From e6f3a5ffcc4d419724c0e5b85f6d2e7c7fc8d5ed Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 21 May 2026 14:23:42 -0600 Subject: [PATCH 001/388] Document and fix from_guppy DEM handoff. --- docs/development/from-guppy-dem-handoff.md | 116 ++++++++++++++++++ .../quantum-pecos/src/pecos/qec/__init__.py | 8 +- python/quantum-pecos/src/pecos/qec/dem.py | 27 ++-- .../guppy/test_surface_ancilla_budget.py | 52 ++++++++ 4 files changed, 181 insertions(+), 22 deletions(-) create mode 100644 docs/development/from-guppy-dem-handoff.md create mode 100644 python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md new file mode 100644 index 000000000..6d3b076c8 --- /dev/null +++ b/docs/development/from-guppy-dem-handoff.md @@ -0,0 +1,116 @@ +# `DetectorErrorModel.from_guppy` Handoff + +This note is for future work on the DEM polish path. It captures the current +local fix and the validation target for constrained-ancilla surface-code DEMs. + +## Context + +The `dem-polish` work adds a Python-level `DetectorErrorModel.from_guppy(...)` +entry point. The intended shape is: + +1. Build any Guppy program, including constrained surface-code memory circuits. +2. Trace it through Selene/QIS into a `TickCircuit`. +3. Attach caller-provided detector and observable metadata. +4. Build the native PECOS DEM from that traced circuit. + +This should support calls like: + +```python +from pecos.guppy import get_num_qubits, make_surface_code +from pecos.qec import DetectorErrorModel + +program = make_surface_code( + distance=9, + num_rounds=18, + basis="Z", + ancilla_budget=17, +) + +dem = DetectorErrorModel.from_guppy( + program, + num_qubits=get_num_qubits(9, ancilla_budget=17), + detectors_json=detectors_json, + observables_json=observables_json, + num_measurements=num_measurements, + p1=p, + p2=p, + p_meas=p, + p_prep=p, +) +``` + +## Import-Time Issue + +`pecos_rslib.qec.DetectorErrorModel` is not currently subclassable from Python. +Defining: + +```python +class DetectorErrorModel(_RustDetectorErrorModel): + ... +``` + +causes `import pecos` to fail with: + +```text +TypeError: type 'pecos_rslib.qec.DetectorErrorModel' is not an acceptable base type +``` + +The current local fix is to re-export the Rust class directly and attach the +Python convenience constructor: + +```python +DetectorErrorModel = _RustDetectorErrorModel +DetectorErrorModel.from_guppy = classmethod(...) +``` + +This keeps the public API as `pecos.qec.DetectorErrorModel.from_guppy(...)` +while preserving the Rust class identity for objects returned by +`from_circuit(...)` and `from_guppy(...)`. + +## Constrained-Ancilla Surface DEM Target + +The key surface-code use case is Helios-sized rotated surface code memory: + +- `distance=9` +- `ancilla_budget=17` +- `num_qubits=98` +- both X and Z memory bases +- DEMs built from the traced Guppy/Selene/QIS path + +Important checks: + +```bash +uv run python -c "from pecos.guppy import make_surface_code, get_num_qubits; from pecos.qec import DetectorErrorModel; print(get_num_qubits(9, ancilla_budget=17)); print(hasattr(DetectorErrorModel, 'from_guppy')); make_surface_code(distance=9, num_rounds=18, basis='Z', ancilla_budget=17); print('ok')" +``` + +Expected output includes: + +```text +98 +True +ok +``` + +In the downstream `surface-memory-helios` repo, this smoke test currently +generates a constrained d=9 traced DEM: + +```bash +uv run python -c "from surface_memory_helios import surface_memory_dem; dem = surface_memory_dem(distance=9, rounds=18, basis='Z', p=0.01, decoder='pymatching', dem_source='traced_qis', ancilla_budget=17); print(len(dem.splitlines())); print(dem.splitlines()[0])" +``` + +The latest local run produced 37,066 DEM lines and a detector metadata first +line. + +## Follow-Up Guidance + +- Prefer the generic `from_guppy(...)` abstraction for future DEM construction + rather than adding more surface-specific tracing plumbing. +- Keep the surface helper path compatible with constrained ancilla budgets: + pass `ancilla_budget` into both `make_surface_code(...)` and + `get_num_qubits(...)` when tracing surface Guppy. +- Avoid reintroducing any `circuit_source="traced_qis"` rejection for + `ancilla_budget`; constrained Guppy programs are valid and traceable. +- Ensure PyMatching users can get decomposed DEM text from the `from_guppy(...)` + result, e.g. via `to_string_decomposed()`. +- Keep or add regression coverage for constrained d=9, `ancilla_budget=17` + through the Guppy/from_guppy route. diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 7aac871c1..9b959d6a5 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -72,10 +72,10 @@ generate_488_layout, ) -# DetectorErrorModel is re-exported from pecos.qec.dem: a thin Python subclass -# of the Rust class that adds the from_guppy convenience constructor (the -# Guppy/Selene trace pipeline is Python-only, so it cannot live in the Rust -# extension without a dependency cycle). +# DetectorErrorModel is re-exported from pecos.qec.dem: the Rust class with a +# Python from_guppy convenience constructor attached. The Guppy/Selene trace +# pipeline is Python-only, so it cannot live in the Rust extension without a +# dependency cycle. from pecos.qec.dem import DetectorErrorModel from pecos.qec.generic import ( CheckSchedule, diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 1adf274c2..7707fc64d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -6,8 +6,9 @@ ``pecos.qec.surface.decode``). To keep the convenient ``DetectorErrorModel.from_guppy(...)`` call site without making the low-level Rust extension import the high-level Python package (a dependency cycle), this -module defines a thin Python subclass that adds :meth:`from_guppy` and is -re-exported as the public ``pecos.qec.DetectorErrorModel``. +module attaches a Python :meth:`from_guppy` classmethod to the Rust-backed +``pecos_rslib.qec.DetectorErrorModel`` and re-exports that class as the public +``pecos.qec.DetectorErrorModel``. This wrapper is intentionally thin: it traces the Guppy program into a ``TickCircuit``, optionally compiles the program to a HUGR (only when @@ -40,22 +41,8 @@ from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel -class DetectorErrorModel(_RustDetectorErrorModel): - """Detector error model with a Guppy/QIS-trace convenience constructor. - - Identical to :class:`pecos_rslib.qec.DetectorErrorModel` except for the - added :meth:`from_guppy` classmethod. - - Identity caveat: the inherited Rust factory classmethods - (``from_circuit``, ``from_pecos_metadata_json``, and ``from_guppy``, which - delegates to ``from_circuit``) construct and return the *Rust base* class - ``pecos_rslib.qec.DetectorErrorModel`` -- they do not return instances of - this Python subclass. Consequently ``isinstance(obj, DetectorErrorModel)`` - is ``False`` for objects produced by those constructors even though every - method works identically. Do not use ``isinstance`` against this public - subclass to recognize DEMs; check the Rust base type instead. (No PECOS - code relies on such an ``isinstance``; this is a public-API caveat only.) - """ +class _DetectorErrorModelMixin: + """Namespace for the Python Guppy/QIS-trace convenience constructor.""" __slots__ = () @@ -251,3 +238,7 @@ def _result_tags_present(detectors_json: str, observables_json: str) -> bool: extraction, loop-guard, resolution, and validation are all done in Rust. """ return '"result_tags"' in (detectors_json or "") or '"result_tags"' in (observables_json or "") + + +DetectorErrorModel = _RustDetectorErrorModel +DetectorErrorModel.from_guppy = classmethod(_DetectorErrorModelMixin.__dict__["from_guppy"].__func__) diff --git a/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py new file mode 100644 index 000000000..424f2efdf --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py @@ -0,0 +1,52 @@ +"""Tests for constrained-ancilla surface-code Guppy generation.""" + +from __future__ import annotations + +import pytest + + +def test_surface_qubit_count_respects_ancilla_budget() -> None: + """The optional budget caps peak live ancillas without changing data qubits.""" + from pecos.guppy import get_num_qubits + + assert get_num_qubits(7) == 97 + assert get_num_qubits(9) == 161 + assert get_num_qubits(9, ancilla_budget=17) == 98 + assert get_num_qubits(9, ancilla_budget=999) == 161 + + +def test_surface_ancilla_budget_must_be_positive() -> None: + """Reject nonsensical budgets early.""" + from pecos.guppy import get_num_qubits + + with pytest.raises(ValueError, match="ancilla_budget must be >= 1"): + get_num_qubits(3, ancilla_budget=0) + + +def test_constrained_ancilla_surface_code_compiles_to_hugr() -> None: + """A budgeted surface memory experiment should still be valid Guppy/HUGR.""" + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy import make_surface_code + + program = make_surface_code(distance=3, num_rounds=1, basis="Z", ancilla_budget=2) + hugr = compile_guppy_to_hugr(program) + + assert len(hugr) > 0 + + +def test_constrained_ancilla_surface_code_traces_to_native_tick_circuit() -> None: + """Budgeted Guppy surface programs should work through traced-QIS DEM plumbing.""" + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + ) + + assert circuit.get_meta("ancilla_budget") == "2" + assert int(circuit.get_meta("num_measurements")) > 0 From 01f8792f41e52c386a83d2dc54c2bae81bbf8a9f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 27 May 2026 19:30:02 -0600 Subject: [PATCH 002/388] Update toml_edit to 0.25. --- Cargo.lock | 52 ++++++++++------------------------------------------ Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 43 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ffffaffbf..c8a201504 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2715,6 +2715,7 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", + "rayon", "serde", ] @@ -2726,7 +2727,6 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", - "rayon", "serde", "serde_core", ] @@ -3818,7 +3818,7 @@ dependencies = [ "tempfile", "thiserror 2.0.18", "toml", - "toml_edit 0.22.27", + "toml_edit", "xz2", "zip", ] @@ -4891,7 +4891,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit", ] [[package]] @@ -5797,7 +5797,7 @@ dependencies = [ "fixedbitset 0.5.7", "foldhash 0.1.5", "hashbrown 0.15.5", - "indexmap 2.14.0", + "indexmap 1.9.3", "ndarray 0.16.1", "num-traits", "petgraph 0.8.3", @@ -6776,18 +6776,12 @@ dependencies = [ "indexmap 2.14.0", "serde_core", "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.2", + "winnow", ] -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -6797,18 +6791,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.22.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", -] - [[package]] name = "toml_edit" version = "0.25.11+spec-1.1.0" @@ -6816,9 +6798,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" dependencies = [ "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.2", + "toml_writer", + "winnow", ] [[package]] @@ -6827,15 +6810,9 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.2", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -8002,15 +7979,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - [[package]] name = "winnow" version = "1.0.2" diff --git a/Cargo.toml b/Cargo.toml index 1e2c2a3b7..912dfc0c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" ron = "0.12" toml = "1" -toml_edit = "0.22" +toml_edit = "0.25" # --- CLI --- clap = { version = "4", features = ["derive"] } From 8bdec31487d2767f5f9640c710eafdf4e0aaf392 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 27 May 2026 19:34:19 -0600 Subject: [PATCH 003/388] Update wasmtime to 45. --- Cargo.lock | 149 +++++++++++++++++++++++------------------------------ Cargo.toml | 2 +- 2 files changed, 65 insertions(+), 86 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c8a201504..be03da802 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -961,27 +961,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8628cc4ba7f88a9205a7ee42327697abc61195a1e3d92cfae172d6a946e722e" +checksum = "8c80cf55a351448317210f26c434be761bcb25e7b36116ec92f89540b73e2833" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d582754487e6c9a065a91c42ccf1bdd8d5977af33468dac5ae9bec0ce88acb3e" +checksum = "07937ca8617b340162fe3a4716be885b5847e9b56d6c7a89abbe4d42340fdc91" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb59c81ace12ee7c33074db7903d4d75d1f40b28cd3e8e6f491de57b29129eb9" +checksum = "88217b08180882436d54c0133274885c590698ae854e352bede1cda041230800" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -989,9 +989,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25c06993a681be9cf3140798a3d4ac5bec955e7444416a2fdc87fda8567285d" +checksum = "d5c3cf7ba29fa56e56040848e34835d4e45988b2760ef212413409af95ffd8c1" dependencies = [ "serde", "serde_derive", @@ -1000,9 +1000,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b61f95c5a211918f5d336254a61a488b36a5818de47a868e8c4658dce9cccc" +checksum = "ebe1aac2efd4cba2047845fce38a68519935a30e20c8a6294ba7e2f448fe722d" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1014,7 +1014,7 @@ dependencies = [ "cranelift-entity", "cranelift-isle", "gimli", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "libm", "log", "pulley-interpreter", @@ -1028,9 +1028,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b85aa822fce72080d041d7c2cf7c3f5c6ecdea7afae68379ba4ef85269c4fa5" +checksum = "0909eaf9d6f18f5bf802d50608cb4368ac340fbd03cc44f2888d1cfcc3faa64e" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1041,24 +1041,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "833eb9fc89326cd072cc19e96892f09b5692c0dfe17cd4da2858ba30c2cd85c0" +checksum = "c95a8da8be283f49cda7d0ef228c94f10d791e517b27b0c7e282dadd2e79ce45" [[package]] name = "cranelift-control" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d005320f487e6e8a3edcc7f2fd4f43fcc9946d1013bf206ea649789ac1617fc" +checksum = "f5b19c81145146da1f7afda2e7f52111842fe6793512e740ad5cf3f5639e6212" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e62ef34c6e720f347a79ece043e8584e242d168911da640bac654a33a6aaaf5" +checksum = "4a55309b47e6633ab05821304206cb1e92952e845b1224985562bb7ac1e92323" dependencies = [ "cranelift-bitset", "serde", @@ -1068,9 +1068,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa2ad00399dd47e7e7e33cb1dc23b0e39ed9dcd01e8f026fc37af91655031b8" +checksum = "064d2d3533d9608f1cf44c8899cf2f7f33feb70300b0fb83e687b0d9e7b91147" dependencies = [ "cranelift-codegen", "log", @@ -1080,15 +1080,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02c51975ed217b4e8e5a7fd11e9ec83a96104bdff311dddcb505d1d8a9fd7fc6" +checksum = "1ac4e0bc095b2dab2212d1e99d7a74b62afc1485db023f1c0cb34a68758f7bd1" [[package]] name = "cranelift-native" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9b1889e00da9729d8f8525f3c12998ded86ea709058ff844ebe00b97548de0e" +checksum = "09a40053f5cb925451dd1d57393d14ad3145c8e0786701c27b5415ebb9a3ba4f" dependencies = [ "cranelift-codegen", "libc", @@ -1097,9 +1097,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.131.1" +version = "0.132.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a8f82fd5124f009f72167e60139245cd3b56cfd4b53050f22110c48c5f4da1" +checksum = "a3ceab9a53f7d362c89841fbaa8e63e44d47c40e91dc96ee6f777fca5d6b323b" [[package]] name = "crc" @@ -2257,8 +2257,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.2.0", - "serde", - "serde_core", ] [[package]] @@ -2268,6 +2266,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash 0.2.0", + "serde", + "serde_core", ] [[package]] @@ -2715,7 +2715,6 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", - "rayon", "serde", ] @@ -2727,6 +2726,7 @@ checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown 0.17.1", + "rayon", "serde", "serde_core", ] @@ -4967,9 +4967,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9326e3a0093d170582cf64ed9e4cf253b8aac155ec4a294ff62330450bbf094" +checksum = "e9204ad9435f2a6fe3bd13bba52389fb8488fa20ba497e35c5d2db638166019d" dependencies = [ "cranelift-bitset", "log", @@ -4979,9 +4979,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00c6433917e3789605b1f4cd2a589f637ff17212344e7fa5ba99544625ba52c7" +checksum = "53009b033747e0d79a76549a744da58e84c9da8076492c7e6d491fdc6cc41b95" dependencies = [ "proc-macro2", "quote", @@ -5797,7 +5797,7 @@ dependencies = [ "fixedbitset 0.5.7", "foldhash 0.1.5", "hashbrown 0.15.5", - "indexmap 1.9.3", + "indexmap 2.14.0", "ndarray 0.16.1", "num-traits", "petgraph 0.8.3", @@ -7180,16 +7180,6 @@ dependencies = [ "wasmparser 0.244.0", ] -[[package]] -name = "wasm-encoder" -version = "0.246.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" -dependencies = [ - "leb128fmt", - "wasmparser 0.246.2", -] - [[package]] name = "wasm-encoder" version = "0.248.0" @@ -7224,19 +7214,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmparser" -version = "0.246.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.16.1", - "indexmap 2.14.0", - "semver", - "serde", -] - [[package]] name = "wasmparser" version = "0.248.0" @@ -7244,26 +7221,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" dependencies = [ "bitflags 2.11.1", + "hashbrown 0.17.1", "indexmap 2.14.0", "semver", + "serde", ] [[package]] name = "wasmprinter" -version = "0.246.2" +version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e41f7493ba994b8a779430a4c25ff550fd5a40d291693af43a6ef48688f00e3" +checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.246.2", + "wasmparser 0.248.0", ] [[package]] name = "wasmtime" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "372db8bbad8ec962038101f75ab2c3ffcd18797d7d3ae877a58ab9873cd0c4bd" +checksum = "d35aec1e932d00a7c941f816ad589e65ad8db948b9e971bf8ec655a1669f1f67" dependencies = [ "addr2line", "async-trait", @@ -7284,7 +7263,7 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasmparser 0.246.2", + "wasmparser 0.248.0", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-cranelift", @@ -7299,9 +7278,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e15aa0d1545e48d9b25ca604e9e27b4cd6d5886d30ac5787b57b3a2daf85b57" +checksum = "d7da3dcce82a7e784121c19c8c9c5f69a743088264ff5212033e4a1f1b9dfaaf" dependencies = [ "anyhow", "cpp_demangle", @@ -7309,7 +7288,7 @@ dependencies = [ "cranelift-bitset", "cranelift-entity", "gimli", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indexmap 2.14.0", "log", "object", @@ -7320,28 +7299,28 @@ dependencies = [ "sha2 0.10.9", "smallvec", "target-lexicon", - "wasm-encoder 0.246.2", - "wasmparser 0.246.2", + "wasm-encoder 0.248.0", + "wasmparser 0.248.0", "wasmprinter", "wasmtime-internal-core", ] [[package]] name = "wasmtime-internal-core" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f2c7fa6523647262bfb4095dbdf4087accefe525813e783f81a0c682f418ce4" +checksum = "1bdae4b55b15a23d774b15f6e7cd90ae0d0aa17c47c12b4db098b3dd11ba9d58" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", "libm", "serde", ] [[package]] name = "wasmtime-internal-cranelift" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98c032f422e39061dfc43f32190c0a3526b04161ec4867f362958f3fe9d1fe29" +checksum = "5773b36b87566239b020f1d01aa753a35626df85030485e40e36fc42a97acf4f" dependencies = [ "cfg-if", "cranelift-codegen", @@ -7357,7 +7336,7 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.246.2", + "wasmparser 0.248.0", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-unwinder", @@ -7366,9 +7345,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8dd76d80adf450cc260ba58f23c28030401930b19149695b1d121f7d621e791" +checksum = "402cce4bba4c8c92a6fbaff39a6b23f8aa626d64b218ecf6dd3eeee8705cf096" dependencies = [ "cc", "cfg-if", @@ -7381,9 +7360,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab453cc600b28ee5d3f9495aa6d4cb2c81eda40903e9287296b548fba8b2391d" +checksum = "8b426a5d0ec9c11a1a4525ed4e973b7caf40223b6d392588bb9f6468e4ae9d29" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -7391,9 +7370,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1859e920871515d324fb9757c3e448d6ed1512ca6ccdff14b6e016505d6ada" +checksum = "8a312ba8bb77955dcd44294a223e7f124c3071ff966583d385d3f6a4639c62e3" dependencies = [ "cfg-if", "libc", @@ -7403,9 +7382,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dfe405bd6adb1386d935a30f16a236bd4ef0d3c383e7cbbab98d063c9d9b73" +checksum = "4a62ad422ee3cbf1e87c2242dc0717a01c7a5878fbc3a68abc4b4d2fff3e85e1" dependencies = [ "cfg-if", "cranelift-codegen", @@ -7416,9 +7395,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "44.0.1" +version = "45.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a9b9165fc45d42c81edfe3e9cb458e58720594ad5db6553c4079ea041a4a581" +checksum = "2c660c5b091648cffdd84a34dc24ffcdb9d027f9048fe7bd5e01896adbd0935f" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 912dfc0c2..cdab4bb98 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -76,7 +76,7 @@ tket-qsystem = { version = "0.23", default-features = false } hugr-core = "=0.25.6" # --- WebAssembly --- -wasmtime = { version = "44", default-features = false, features = [ +wasmtime = { version = "45", default-features = false, features = [ "cranelift", "runtime", "wat", From 6c5cebfb01d84baea61b611a9918c2a08d49b737 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 27 May 2026 19:37:38 -0600 Subject: [PATCH 004/388] Update nalgebra to 0.35. --- Cargo.lock | 154 +++++++---------------------------------------------- Cargo.toml | 2 +- 2 files changed, 19 insertions(+), 137 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be03da802..6f13e8218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -375,7 +375,7 @@ dependencies = [ "rand 0.10.1", "rand_xoshiro 0.8.0", "rapidhash", - "wide 1.4.0", + "wide", ] [[package]] @@ -2038,96 +2038,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glam" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" - -[[package]] -name = "glam" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" - -[[package]] -name = "glam" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" - -[[package]] -name = "glam" -version = "0.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" - -[[package]] -name = "glam" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" - -[[package]] -name = "glam" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" - -[[package]] -name = "glam" -version = "0.20.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" - -[[package]] -name = "glam" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" - -[[package]] -name = "glam" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" - -[[package]] -name = "glam" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" - -[[package]] -name = "glam" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" - -[[package]] -name = "glam" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" - -[[package]] -name = "glam" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" - -[[package]] -name = "glam" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" - -[[package]] -name = "glam" -version = "0.29.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" - [[package]] name = "glam" version = "0.30.10" @@ -2146,6 +2056,12 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" +[[package]] +name = "glam" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb167719045debebe9f532320accc7b5c993c5a3b813f5696a11d5ca7bdc57b" + [[package]] name = "glob" version = "0.3.3" @@ -3325,29 +3241,15 @@ dependencies = [ [[package]] name = "nalgebra" -version = "0.34.2" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346" dependencies = [ "approx 0.5.1", - "glam 0.14.0", - "glam 0.15.2", - "glam 0.16.0", - "glam 0.17.3", - "glam 0.18.0", - "glam 0.19.0", - "glam 0.20.5", - "glam 0.21.3", - "glam 0.22.0", - "glam 0.23.0", - "glam 0.24.2", - "glam 0.25.0", - "glam 0.27.0", - "glam 0.28.0", - "glam 0.29.3", "glam 0.30.10", "glam 0.31.1", "glam 0.32.1", + "glam 0.33.0", "matrixmultiply", "nalgebra-macros", "num-complex 0.4.6", @@ -3981,7 +3883,7 @@ dependencies = [ "pecos-random", "pecos-simulators", "rand 0.10.1", - "wide 1.4.0", + "wide", ] [[package]] @@ -4274,7 +4176,7 @@ dependencies = [ "serde_json", "smallvec", "thiserror 2.0.18", - "wide 1.4.0", + "wide", ] [[package]] @@ -4346,7 +4248,7 @@ dependencies = [ "rand_xoshiro 0.8.0", "random_tester", "rapidhash", - "wide 1.4.0", + "wide", ] [[package]] @@ -4522,7 +4424,7 @@ dependencies = [ "rand 0.10.1", "rayon", "smallvec", - "wide 1.4.0", + "wide", ] [[package]] @@ -5827,15 +5729,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - [[package]] name = "safe_arch" version = "1.0.0" @@ -6151,15 +6044,14 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simba" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +checksum = "8f45c644a9f3a386f9288625d9f0c1e999e1acf07a37df35d0516c7f199d9cb2" dependencies = [ "approx 0.5.1", "num-complex 0.4.6", "num-traits", - "paste", - "wide 0.7.33", + "wide", ] [[package]] @@ -7641,16 +7533,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch 0.7.4", -] - [[package]] name = "wide" version = "1.4.0" @@ -7658,7 +7540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7714cd0430a663154667c74da5d09325c2387695bee18b3f7f72825aa3693a" dependencies = [ "bytemuck", - "safe_arch 1.0.0", + "safe_arch", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index cdab4bb98..eca284162 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,7 +90,7 @@ pest_derive = "2" regex = "1" # --- Numerical computing --- -nalgebra = "0.34" +nalgebra = "0.35" num = "0.4" num-complex = "0.4" num-traits = "0.2" From 20f549b1721da6650b63c43802235de45e211b19 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 28 May 2026 23:53:53 -0600 Subject: [PATCH 005/388] Enable generic Selene runtime plugins in PECOS. --- crates/pecos-engines/src/classical.rs | 12 + crates/pecos-engines/src/sim_builder.rs | 4 +- crates/pecos-qis-ffi-types/src/operations.rs | 3 + crates/pecos-qis/src/ccengine.rs | 53 +- crates/pecos-qis/src/runtime.rs | 34 +- crates/pecos-qis/src/selene_runtime.rs | 906 +++++++++++++++++- python/pecos-rslib/src/engine_builders.rs | 68 +- .../src/pecos/_engine_builders.py | 77 +- .../test_selene_interface_integration.py | 21 + 9 files changed, 1091 insertions(+), 87 deletions(-) diff --git a/crates/pecos-engines/src/classical.rs b/crates/pecos-engines/src/classical.rs index 8204ca878..3c6dc3375 100644 --- a/crates/pecos-engines/src/classical.rs +++ b/crates/pecos-engines/src/classical.rs @@ -10,6 +10,14 @@ use std::any::Any; pub trait ClassicalEngine: Engine + DynClone + Send + Sync { fn num_qubits(&self) -> usize; + /// Provide a qubit-count hint from higher-level simulation configuration. + /// + /// Most classical engines can ignore this. Dynamic runtimes may need it + /// before program execution discovers allocations. + fn set_num_qubits_hint(&mut self, _num_qubits: usize) { + // Default implementation does nothing. + } + /// Generate a `ByteMessage` containing the next batch of quantum commands to execute. /// An empty message indicates no more commands are available. /// @@ -98,6 +106,10 @@ impl ClassicalEngine for Box { (**self).num_qubits() } + fn set_num_qubits_hint(&mut self, num_qubits: usize) { + (**self).set_num_qubits_hint(num_qubits); + } + fn generate_commands(&mut self) -> Result { (**self).generate_commands() } diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index a858e94ef..dc9a5caba 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -265,7 +265,7 @@ impl SimBuilder { use crate::quantum::SparseStabEngine; // Build classical engine (required) - let classical_engine = match self.classical_builder { + let mut classical_engine = match self.classical_builder { Some(builder) => builder.build_boxed()?, None => { return Err(PecosError::Input( @@ -284,6 +284,8 @@ impl SimBuilder { ) })?; + classical_engine.set_num_qubits_hint(num_qubits); + // Build quantum engine (require explicit qubit specification) let quantum_engine = if let Some(mut builder) = self.quantum_builder { // Set qubits on the quantum engine builder if explicitly specified diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 8374db844..1ded1d33b 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -49,6 +49,9 @@ pub enum QuantumOp { // Hardware-native gates (for Selene compatibility) RXY(f64, f64, usize), // theta, phi, qubit + // Idle period in seconds for time-based noise models + Idle(f64, usize), // duration_seconds, qubit + // Two-qubit gates CX(usize, usize), CY(usize, usize), diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 5c85fe079..c1c88bc13 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -197,6 +197,9 @@ pub struct QisEngine { /// qubit handles — use `active_qubit_slots.len()` for that. num_physical_slots: usize, + /// Optional device-size hint supplied by the top-level simulation builder. + num_qubits_hint: Option, + /// Mapping from program-level qubit handles to physical simulator slots. active_qubit_slots: BTreeMap, @@ -320,6 +323,7 @@ impl QisEngine { runtime, current_operations: None, num_physical_slots: 0, + num_qubits_hint: None, active_qubit_slots: BTreeMap::new(), free_qubit_slots: BTreeSet::new(), seen_program_qubits: BTreeSet::new(), @@ -412,6 +416,7 @@ impl QisEngine { runtime, current_operations: None, num_physical_slots: 0, + num_qubits_hint: None, active_qubit_slots: BTreeMap::new(), free_qubit_slots: BTreeSet::new(), seen_program_qubits: BTreeSet::new(), @@ -598,6 +603,9 @@ impl QisEngine { &[self.mapped_qubit(*qubit, qop)?], ); } + QuantumOp::Idle(duration, qubit) => { + builder.idle(*duration, &[self.mapped_qubit(*qubit, qop)?]); + } QuantumOp::CX(control, target) => { builder.cx(&[( self.mapped_qubit(*control, qop)?, @@ -643,6 +651,26 @@ impl QisEngine { message } + /// Convert freshly collected dynamic operations into a `ByteMessage`. + /// + /// Selene runtime plugins can opt in to lowering so their scheduler sees + /// the same operation stream that Selene would receive. Other runtimes keep + /// using PECOS's direct QIS lowering path. + fn lower_operations_to_bytemessage( + &mut self, + ops: &[Operation], + ) -> Result { + if self.runtime.supports_operation_lowering() { + let lowered_ops = self + .runtime + .lower_operations(ops) + .map_err(|e| PecosError::Generic(format!("Runtime lowering error: {e}")))?; + return self.quantum_ops_to_bytemessage(lowered_ops); + } + + self.operations_to_bytemessage(ops) + } + /// Convert already-materialized quantum ops into a `ByteMessage`. /// /// This path is used by runtimes that already present qubit ids in the fixed @@ -698,6 +726,9 @@ impl QisEngine { &[qubit], ); } + QuantumOp::Idle(duration, qubit) => { + builder.idle(duration, &[qubit]); + } QuantumOp::CX(control, target) => { builder.cx(&[(control, target)]); } @@ -760,6 +791,7 @@ impl Clone for QisEngine { runtime: dyn_clone::clone_box(&*self.runtime), current_operations: self.current_operations.clone(), num_physical_slots: self.num_physical_slots, + num_qubits_hint: self.num_qubits_hint, active_qubit_slots: self.active_qubit_slots.clone(), free_qubit_slots: self.free_qubit_slots.clone(), seen_program_qubits: self.seen_program_qubits.clone(), @@ -1120,11 +1152,20 @@ impl ClassicalEngine for QisEngine { // return the physical-slot high-water mark instead. The runtime can // report its own baseline (e.g. from `allocated_qubits` metadata) and // we take the larger of the two. - let num_qubits = self.runtime.num_qubits().max(self.num_physical_slots); + let num_qubits = self + .runtime + .num_qubits() + .max(self.num_physical_slots) + .max(self.num_qubits_hint.unwrap_or(0)); debug!("QisEngine: num_qubits() returning {num_qubits}"); num_qubits } + fn set_num_qubits_hint(&mut self, num_qubits: usize) { + self.num_qubits_hint = Some(num_qubits); + self.runtime.set_num_qubits(num_qubits); + } + fn set_seed(&mut self, seed: u64) { // Seed the RNG for generating per-shot seeds self.rng = PecosRng::seed_from_u64(seed); @@ -1346,7 +1387,7 @@ impl ControlEngine for QisEngine { // Track how many operations we're sending for simulation self.simulated_op_count = ops.len(); if !ops.is_empty() { - let commands = self.operations_to_bytemessage(&ops)?; + let commands = self.lower_operations_to_bytemessage(&ops)?; self.trace_operations_chunk( "pending_start", &ops, @@ -1365,7 +1406,7 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; + let commands = self.lower_operations_to_bytemessage(&final_ops)?; self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); return Ok(EngineStage::NeedsProcessing(commands)); } @@ -1411,7 +1452,7 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; + let commands = self.lower_operations_to_bytemessage(&final_ops)?; self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); return Ok(EngineStage::NeedsProcessing(commands)); } @@ -1455,7 +1496,7 @@ impl ControlEngine for QisEngine { if let Some(ops) = self.get_dynamic_operations() { self.simulated_op_count += ops.len(); if !ops.is_empty() { - let commands = self.operations_to_bytemessage(&ops)?; + let commands = self.lower_operations_to_bytemessage(&ops)?; self.trace_operations_chunk( "pending_continue", &ops, @@ -1475,7 +1516,7 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.operations_to_bytemessage(&final_ops)?; + let commands = self.lower_operations_to_bytemessage(&final_ops)?; self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); return Ok(EngineStage::NeedsProcessing(commands)); } diff --git a/crates/pecos-qis/src/runtime.rs b/crates/pecos-qis/src/runtime.rs index 2f5b30330..961e16ede 100644 --- a/crates/pecos-qis/src/runtime.rs +++ b/crates/pecos-qis/src/runtime.rs @@ -11,7 +11,7 @@ //! doesn't perform quantum simulation but manages program execution flow. use log::trace; -use pecos_qis_ffi_types::{OperationCollector, QuantumOp}; +use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; use std::collections::BTreeMap; /// Result type for runtime operations @@ -201,6 +201,14 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { /// Get the number of qubits used by the program fn num_qubits(&self) -> usize; + /// Provide an external qubit-count hint before runtime initialization. + /// + /// Dynamic programs discover qubits while the program runs, but some + /// runtime plugins need the total device size during initialization. + fn set_num_qubits(&mut self, _num_qubits: usize) { + // Default implementation does nothing. + } + /// Set the maximum number of operations to batch /// /// This allows tuning the trade-off between runtime overhead and @@ -210,6 +218,30 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { let _ = size; } + /// Whether this runtime can lower freshly collected program operations. + /// + /// Dynamic QIS execution yields PECOS `Operation`s from the running program. + /// Most runtimes leave those operations for the engine to lower directly. + /// Selene runtime plugins opt in here so the operation stream flows through + /// the runtime scheduler before it reaches the PECOS quantum/noise stack. + fn supports_operation_lowering(&self) -> bool { + false + } + + /// Lower freshly collected program operations through the runtime. + /// + /// Implementations that return `true` from `supports_operation_lowering` + /// should accept the given operations, drain any runtime-ready scheduled + /// operations, and return them as PECOS quantum operations. + /// + /// # Errors + /// Returns an error if the runtime cannot accept or lower the operations. + fn lower_operations(&mut self, _operations: &[Operation]) -> Result> { + Err(RuntimeError::ExecutionError( + "runtime does not support operation lowering".to_string(), + )) + } + /// Check if the runtime needs to re-execute with known measurements /// /// This is set to true after measurements are provided for programs diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 913c37c9a..a8643f0a7 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -7,11 +7,176 @@ use crate::runtime::{ClassicalState, QisRuntime, Result, RuntimeError, Shot}; use log::{debug, trace}; use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; use std::collections::BTreeMap; -use std::ffi::c_void; +use std::ffi::{CString, c_void}; use std::mem::ManuallyDrop; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; +type RuntimeInstance = *mut c_void; +type RuntimeGetOperationInstance = *mut c_void; + +#[derive(Debug, Clone)] +enum RuntimeScheduledOp { + Rxy { + qubit_id: u64, + theta: f64, + phi: f64, + }, + Rz { + qubit_id: u64, + theta: f64, + }, + Rzz { + qubit_id_1: u64, + qubit_id_2: u64, + theta: f64, + }, + Measure { + qubit_id: u64, + result_id: u64, + }, + MeasureLeaked { + qubit_id: u64, + result_id: u64, + }, + Reset { + qubit_id: u64, + }, + Custom, +} + +#[derive(Debug, Default)] +struct RuntimeOperationBatch { + start_time_nanos: u64, + duration_nanos: u64, + invoked: bool, + operations: Vec, +} + +impl RuntimeOperationBatch { + fn end_time_nanos(&self) -> u64 { + self.start_time_nanos.saturating_add(self.duration_nanos) + } +} + +#[repr(C)] +struct SeleneRuntimeGetOperationInterface { + rzz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64, f64), + rxy_fn: extern "C" fn(RuntimeGetOperationInstance, u64, f64, f64), + rz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, f64), + measure_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), + measure_leaked_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), + reset_fn: extern "C" fn(RuntimeGetOperationInstance, u64), + custom_fn: extern "C" fn(RuntimeGetOperationInstance, usize, *const c_void, usize), + set_batch_time_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64), +} + +extern "C" fn runtime_batch_rxy( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + theta: f64, + phi: f64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Rxy { + qubit_id, + theta, + phi, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_rz(instance: RuntimeGetOperationInstance, qubit_id: u64, theta: f64) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch + .operations + .push(RuntimeScheduledOp::Rz { qubit_id, theta }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_rzz( + instance: RuntimeGetOperationInstance, + qubit_id_1: u64, + qubit_id_2: u64, + theta: f64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Rzz { + qubit_id_1, + qubit_id_2, + theta, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_measure( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + result_id: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Measure { + qubit_id, + result_id, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_measure_leaked( + instance: RuntimeGetOperationInstance, + qubit_id: u64, + result_id: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::MeasureLeaked { + qubit_id, + result_id, + }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_reset(instance: RuntimeGetOperationInstance, qubit_id: u64) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch + .operations + .push(RuntimeScheduledOp::Reset { qubit_id }); + batch.invoked = true; +} + +extern "C" fn runtime_batch_custom( + instance: RuntimeGetOperationInstance, + _tag: usize, + _data: *const c_void, + _data_len: usize, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.operations.push(RuntimeScheduledOp::Custom); + batch.invoked = true; +} + +extern "C" fn runtime_batch_set_time( + instance: RuntimeGetOperationInstance, + start_time_nanos: u64, + duration_nanos: u64, +) { + let batch = unsafe { &mut *(instance.cast::()) }; + batch.start_time_nanos = start_time_nanos; + batch.duration_nanos = duration_nanos; + batch.invoked = true; +} + +static RUNTIME_OPERATION_CALLBACKS: SeleneRuntimeGetOperationInterface = + SeleneRuntimeGetOperationInterface { + rzz_fn: runtime_batch_rzz, + rxy_fn: runtime_batch_rxy, + rz_fn: runtime_batch_rz, + measure_fn: runtime_batch_measure, + measure_leaked_fn: runtime_batch_measure_leaked, + reset_fn: runtime_batch_reset, + custom_fn: runtime_batch_custom, + set_batch_time_fn: runtime_batch_set_time, + }; + /// Selene runtime implementation /// /// The `library` field is wrapped in `ManuallyDrop` to prevent calling `dlclose()` @@ -22,6 +187,12 @@ pub struct SeleneRuntime { /// Path to the Selene .so file plugin_path: String, + /// Runtime-plugin init arguments passed to `selene_runtime_init`. + init_args: Vec, + + /// Additional dynamic library search directories needed by the plugin. + library_search_dirs: Vec, + /// Loaded library (if any) /// Wrapped in `ManuallyDrop` to prevent `dlclose()` during process exit. #[allow(dead_code)] @@ -58,6 +229,18 @@ pub struct SeleneRuntime { /// Track measurement result IDs that have been seen but not yet resolved pending_measurements: Vec, + + /// Program qubit handles mapped onto runtime qubit handles returned by qalloc. + program_to_runtime_qubits: BTreeMap, + + /// Program result IDs mapped onto runtime future IDs returned by measure. + program_to_runtime_results: BTreeMap, + + /// Reverse lookup for measurement operations emitted by the runtime plugin. + runtime_to_program_results: BTreeMap, + + /// End timestamp of the last scheduled physical operation per runtime qubit. + last_gate_time_end_nanos: Vec, } // SAFETY: SeleneRuntime owns its instance pointer exclusively. @@ -72,6 +255,8 @@ impl SeleneRuntime { pub fn new(plugin_path: impl AsRef) -> Self { Self { plugin_path: plugin_path.as_ref().to_string_lossy().to_string(), + init_args: Vec::new(), + library_search_dirs: Vec::new(), library: None, instance: None, state: ClassicalState::default(), @@ -83,9 +268,29 @@ impl SeleneRuntime { current_op_index: 0, needs_reexecution: false, pending_measurements: Vec::new(), + program_to_runtime_qubits: BTreeMap::new(), + program_to_runtime_results: BTreeMap::new(), + runtime_to_program_results: BTreeMap::new(), + last_gate_time_end_nanos: Vec::new(), } } + /// Create a runtime from the generic Selene runtime-plugin shape. + /// + /// `init_args` are passed directly to the plugin's `selene_runtime_init` + /// argc/argv pair. `library_search_dirs` are prepended to the platform + /// dynamic-library search path before loading the plugin. + pub fn with_plugin_config( + plugin_path: impl AsRef, + init_args: Vec, + library_search_dirs: Vec, + ) -> Self { + let mut runtime = Self::new(plugin_path); + runtime.init_args = init_args; + runtime.library_search_dirs = library_search_dirs; + runtime + } + /// Check if this runtime needs re-execution with known measurements /// /// This is set to true after measurements are provided for programs @@ -132,9 +337,14 @@ impl SeleneRuntime { return Ok(()); } + self.apply_library_search_dirs()?; + debug!( - "Loading Selene plugin from {} with {} qubits and {} results", - self.plugin_path, self.num_qubits, self.num_results + "Loading Selene plugin from {} with {} qubits, {} results, and {} init args", + self.plugin_path, + self.num_qubits, + self.num_results, + self.init_args.len() ); unsafe { @@ -150,13 +360,31 @@ impl SeleneRuntime { .get(b"selene_runtime_init") .map_err(|e| RuntimeError::FfiError(format!("Missing init function: {e}")))?; + let c_args = self + .init_args + .iter() + .map(|arg| { + CString::new(arg.as_str()).map_err(|_| { + RuntimeError::FfiError(format!( + "Selene runtime init argument contains NUL byte: {arg:?}" + )) + }) + }) + .collect::>>()?; + let arg_ptrs = c_args.iter().map(|arg| arg.as_ptr()).collect::>(); + let argv = if arg_ptrs.is_empty() { + std::ptr::null() + } else { + arg_ptrs.as_ptr() + }; + let mut instance: *mut c_void = std::ptr::null_mut(); let errno = init_fn( &raw mut instance, self.num_qubits as u64, - 0, // start time - 0, // argc - std::ptr::null(), // argv + 0, // start time + arg_ptrs.len() as u32, + argv, ); if errno != 0 { @@ -172,6 +400,35 @@ impl SeleneRuntime { Ok(()) } + fn apply_library_search_dirs(&self) -> Result<()> { + if self.library_search_dirs.is_empty() { + return Ok(()); + } + + let env_key = if cfg!(target_os = "windows") { + "PATH" + } else if cfg!(target_os = "macos") { + "DYLD_LIBRARY_PATH" + } else { + "LD_LIBRARY_PATH" + }; + + let existing = std::env::var_os(env_key).unwrap_or_default(); + let mut paths = self.library_search_dirs.clone(); + paths.extend(std::env::split_paths(&existing)); + let joined = std::env::join_paths(paths).map_err(|e| { + RuntimeError::FfiError(format!("Invalid Selene runtime library search path: {e}")) + })?; + + // SAFETY: This mirrors Selene's plugin runtime environment setup. The + // mutation happens immediately before loading the selected runtime. + unsafe { + std::env::set_var(env_key, joined); + } + + Ok(()) + } + /// Process operations from the interface sequentially /// /// This method now breaks at measurement operations to allow the quantum @@ -260,6 +517,512 @@ impl SeleneRuntime { Ok(Some(self.operations_buffer.clone())) } } + + fn runtime_qubit_for_program(&mut self, program_qubit: usize) -> Result { + if let Some(&runtime_qubit) = self.program_to_runtime_qubits.get(&program_qubit) { + return Ok(runtime_qubit); + } + + self.load_plugin()?; + let runtime_qubit = self.runtime_qalloc()?; + self.program_to_runtime_qubits + .insert(program_qubit, runtime_qubit); + self.num_qubits = self.num_qubits.max(program_qubit + 1); + Ok(runtime_qubit) + } + + fn runtime_qalloc(&self) -> Result { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let qalloc_fn = lib + .get:: i32>( + b"selene_runtime_qalloc", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing qalloc function: {e}")))?; + let mut runtime_qubit = 0; + let errno = qalloc_fn(instance, &raw mut runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "qalloc failed with errno {errno}" + ))); + } + if runtime_qubit == u64::MAX { + return Err(RuntimeError::ExecutionError( + "Selene runtime failed to allocate a qubit".to_string(), + )); + } + Ok(runtime_qubit) + } + } + + fn runtime_qfree(&self, runtime_qubit: u64) -> Result<()> { + let Some(lib) = &self.library else { + return Ok(()); + }; + let Some(instance) = self.instance else { + return Ok(()); + }; + + unsafe { + let qfree_fn = lib + .get:: i32>(b"selene_runtime_qfree") + .map_err(|e| RuntimeError::FfiError(format!("Missing qfree function: {e}")))?; + let errno = qfree_fn(instance, runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "qfree failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_rxy(&self, runtime_qubit: u64, theta: f64, phi: f64) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let rxy_fn = lib + .get:: i32>( + b"selene_runtime_rxy_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rxy function: {e}")))?; + let errno = rxy_fn(instance, runtime_qubit, theta, phi); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rxy failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_rz(&self, runtime_qubit: u64, theta: f64) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let rz_fn = lib + .get:: i32>( + b"selene_runtime_rz_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rz function: {e}")))?; + let errno = rz_fn(instance, runtime_qubit, theta); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rz failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_rzz( + &self, + runtime_qubit_1: u64, + runtime_qubit_2: u64, + theta: f64, + ) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let rzz_fn = lib + .get:: i32>( + b"selene_runtime_rzz_gate", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing rzz function: {e}")))?; + let errno = rzz_fn(instance, runtime_qubit_1, runtime_qubit_2, theta); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "rzz failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_reset(&self, runtime_qubit: u64) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let reset_fn = lib + .get:: i32>(b"selene_runtime_reset") + .map_err(|e| RuntimeError::FfiError(format!("Missing reset function: {e}")))?; + let errno = reset_fn(instance, runtime_qubit); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "reset failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn call_runtime_measure(&mut self, runtime_qubit: u64, program_result: usize) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + let runtime_result = unsafe { + let measure_fn = lib + .get:: i32>( + b"selene_runtime_measure", + ) + .map_err(|e| RuntimeError::FfiError(format!("Missing measure function: {e}")))?; + let mut runtime_result = 0; + let errno = measure_fn(instance, runtime_qubit, &raw mut runtime_result); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "measure failed with errno {errno}" + ))); + } + runtime_result + }; + + self.program_to_runtime_results + .insert(program_result, runtime_result); + self.runtime_to_program_results + .insert(runtime_result, program_result); + self.force_runtime_result(runtime_result) + } + + fn force_runtime_result(&self, runtime_result: u64) -> Result<()> { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let force_fn = lib + .get:: i32>( + b"selene_runtime_force_result", + ) + .map_err(|e| { + RuntimeError::FfiError(format!("Missing force_result function: {e}")) + })?; + let errno = force_fn(instance, runtime_result); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "force_result failed with errno {errno}" + ))); + } + } + + Ok(()) + } + + fn submit_operation_to_runtime( + &mut self, + op: &Operation, + lowered_ops: &mut Vec, + ) -> Result<()> { + match op { + Operation::AllocateQubit { id } => { + let _ = self.runtime_qubit_for_program(*id)?; + } + Operation::AllocateResult { id } => { + self.num_results = self.num_results.max(id + 1); + } + Operation::ReleaseQubit { id } => { + if let Some(runtime_qubit) = self.program_to_runtime_qubits.remove(id) { + self.runtime_qfree(runtime_qubit)?; + } + } + Operation::RecordOutput { .. } | Operation::Barrier => {} + Operation::Quantum(qop) => self.submit_quantum_op_to_runtime(qop, lowered_ops)?, + } + + Ok(()) + } + + fn submit_quantum_op_to_runtime( + &mut self, + qop: &QuantumOp, + lowered_ops: &mut Vec, + ) -> Result<()> { + match qop { + QuantumOp::RXY(theta, phi, qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_rxy(runtime_qubit, *theta, *phi)?; + } + QuantumOp::RZ(theta, qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_rz(runtime_qubit, *theta)?; + } + QuantumOp::RZZ(theta, qubit_1, qubit_2) => { + let runtime_qubit_1 = self.runtime_qubit_for_program(*qubit_1)?; + let runtime_qubit_2 = self.runtime_qubit_for_program(*qubit_2)?; + self.call_runtime_rzz(runtime_qubit_1, runtime_qubit_2, *theta)?; + } + QuantumOp::Measure(qubit, result_id) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_measure(runtime_qubit, *result_id)?; + } + QuantumOp::Reset(qubit) => { + let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; + self.call_runtime_reset(runtime_qubit)?; + } + _ => { + lowered_ops.extend(self.drain_runtime_operations()?); + lowered_ops.push(self.map_passthrough_op_to_runtime_qubits(qop)?); + } + } + + Ok(()) + } + + fn map_passthrough_op_to_runtime_qubits(&mut self, qop: &QuantumOp) -> Result { + let mut map = |qubit: usize| -> Result { + let runtime_qubit = self.runtime_qubit_for_program(qubit)?; + usize::try_from(runtime_qubit).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime qubit id {runtime_qubit} does not fit in usize" + )) + }) + }; + + Ok(match qop { + QuantumOp::H(qubit) => QuantumOp::H(map(*qubit)?), + QuantumOp::X(qubit) => QuantumOp::X(map(*qubit)?), + QuantumOp::Y(qubit) => QuantumOp::Y(map(*qubit)?), + QuantumOp::Z(qubit) => QuantumOp::Z(map(*qubit)?), + QuantumOp::S(qubit) => QuantumOp::S(map(*qubit)?), + QuantumOp::Sdg(qubit) => QuantumOp::Sdg(map(*qubit)?), + QuantumOp::T(qubit) => QuantumOp::T(map(*qubit)?), + QuantumOp::Tdg(qubit) => QuantumOp::Tdg(map(*qubit)?), + QuantumOp::RX(theta, qubit) => QuantumOp::RX(*theta, map(*qubit)?), + QuantumOp::RY(theta, qubit) => QuantumOp::RY(*theta, map(*qubit)?), + QuantumOp::CX(control, target) => QuantumOp::CX(map(*control)?, map(*target)?), + QuantumOp::CY(control, target) => QuantumOp::CY(map(*control)?, map(*target)?), + QuantumOp::CZ(control, target) => QuantumOp::CZ(map(*control)?, map(*target)?), + QuantumOp::CH(control, target) => QuantumOp::CH(map(*control)?, map(*target)?), + QuantumOp::CRZ(theta, control, target) => { + QuantumOp::CRZ(*theta, map(*control)?, map(*target)?) + } + QuantumOp::CCX(control_1, control_2, target) => { + QuantumOp::CCX(map(*control_1)?, map(*control_2)?, map(*target)?) + } + QuantumOp::ZZ(qubit_1, qubit_2) => QuantumOp::ZZ(map(*qubit_1)?, map(*qubit_2)?), + QuantumOp::Idle(duration, qubit) => QuantumOp::Idle(*duration, map(*qubit)?), + QuantumOp::RXY(..) + | QuantumOp::RZ(..) + | QuantumOp::RZZ(..) + | QuantumOp::Measure(..) + | QuantumOp::Reset(..) => qop.clone(), + }) + } + + fn drain_runtime_operations(&mut self) -> Result> { + self.load_plugin()?; + let mut lowered_ops = Vec::new(); + + loop { + let mut batch = RuntimeOperationBatch::default(); + let errno = { + let lib = self.library.as_ref().ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not loaded".to_string()) + })?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let get_next_fn = lib + .get:: i32>(b"selene_runtime_get_next_operations") + .map_err(|e| { + RuntimeError::FfiError(format!( + "Missing get_next_operations function: {e}" + )) + })?; + get_next_fn( + instance, + (&raw mut batch).cast::(), + &raw const RUNTIME_OPERATION_CALLBACKS, + ) + } + }; + + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "get_next_operations failed with errno {errno}" + ))); + } + + if !batch.invoked { + break; + } + + lowered_ops.extend(self.convert_runtime_batch(batch)?); + } + + Ok(lowered_ops) + } + + fn convert_runtime_batch(&mut self, batch: RuntimeOperationBatch) -> Result> { + let mut lowered_ops = Vec::new(); + let start_time = batch.start_time_nanos; + let end_time = batch.end_time_nanos(); + + for op in batch.operations { + match op { + RuntimeScheduledOp::Rxy { + qubit_id, + theta, + phi, + } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::RXY(theta, phi, qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Rz { qubit_id, theta } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::RZ(theta, qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Rzz { + qubit_id_1, + qubit_id_2, + theta, + } => { + let qubit_1 = self.runtime_qubit_to_usize(qubit_id_1)?; + let qubit_2 = self.runtime_qubit_to_usize(qubit_id_2)?; + self.push_idle_before(&mut lowered_ops, qubit_1, start_time)?; + self.push_idle_before(&mut lowered_ops, qubit_2, start_time)?; + lowered_ops.push(QuantumOp::RZZ(theta, qubit_1, qubit_2)); + self.mark_gate_end(qubit_1, end_time); + self.mark_gate_end(qubit_2, end_time); + } + RuntimeScheduledOp::Measure { + qubit_id, + result_id, + } + | RuntimeScheduledOp::MeasureLeaked { + qubit_id, + result_id, + } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + let program_result = self.runtime_result_to_program_result(result_id)?; + self.push_idle_before(&mut lowered_ops, qubit, start_time)?; + lowered_ops.push(QuantumOp::Measure(qubit, program_result)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Reset { qubit_id } => { + let qubit = self.runtime_qubit_to_usize(qubit_id)?; + lowered_ops.push(QuantumOp::Reset(qubit)); + self.mark_gate_end(qubit, end_time); + } + RuntimeScheduledOp::Custom => {} + } + } + + Ok(lowered_ops) + } + + fn runtime_qubit_to_usize(&mut self, runtime_qubit: u64) -> Result { + let qubit = usize::try_from(runtime_qubit).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime qubit id {runtime_qubit} does not fit in usize" + )) + })?; + self.ensure_timing_slot(qubit); + Ok(qubit) + } + + fn runtime_result_to_program_result(&self, runtime_result: u64) -> Result { + if let Some(&program_result) = self.runtime_to_program_results.get(&runtime_result) { + return Ok(program_result); + } + + usize::try_from(runtime_result).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime result id {runtime_result} does not fit in usize" + )) + }) + } + + fn ensure_timing_slot(&mut self, qubit: usize) { + if self.last_gate_time_end_nanos.len() <= qubit { + self.last_gate_time_end_nanos.resize(qubit + 1, 0); + } + } + + fn push_idle_before( + &mut self, + lowered_ops: &mut Vec, + qubit: usize, + start_time_nanos: u64, + ) -> Result<()> { + self.ensure_timing_slot(qubit); + let last_gate_end = self.last_gate_time_end_nanos[qubit]; + if last_gate_end > start_time_nanos { + return Err(RuntimeError::ExecutionError(format!( + "Runtime operation on qubit {qubit} starts before its previous operation ended: {start_time_nanos} < {last_gate_end}" + ))); + } + + let idle_time = start_time_nanos - last_gate_end; + if idle_time > 0 { + lowered_ops.push(QuantumOp::Idle(nanoseconds_to_seconds(idle_time), qubit)); + } + + Ok(()) + } + + fn mark_gate_end(&mut self, qubit: usize, end_time_nanos: u64) { + self.ensure_timing_slot(qubit); + self.last_gate_time_end_nanos[qubit] = end_time_nanos; + } +} + +fn nanoseconds_to_seconds(nanoseconds: u64) -> f64 { + std::time::Duration::from_nanos(nanoseconds).as_secs_f64() } impl Clone for SeleneRuntime { @@ -268,6 +1031,8 @@ impl Clone for SeleneRuntime { // The library itself can't be cloned, so we'll reload if needed Self { plugin_path: self.plugin_path.clone(), + init_args: self.init_args.clone(), + library_search_dirs: self.library_search_dirs.clone(), library: None, // Will be reloaded on demand instance: None, // Will be recreated on demand state: self.state.clone(), @@ -279,6 +1044,10 @@ impl Clone for SeleneRuntime { current_op_index: self.current_op_index, needs_reexecution: self.needs_reexecution, pending_measurements: self.pending_measurements.clone(), + program_to_runtime_qubits: self.program_to_runtime_qubits.clone(), + program_to_runtime_results: self.program_to_runtime_results.clone(), + runtime_to_program_results: self.runtime_to_program_results.clone(), + last_gate_time_end_nanos: self.last_gate_time_end_nanos.clone(), } } } @@ -325,6 +1094,22 @@ impl QisRuntime for SeleneRuntime { self.process_interface_ops() } + fn supports_operation_lowering(&self) -> bool { + true + } + + fn lower_operations(&mut self, operations: &[Operation]) -> Result> { + self.load_plugin()?; + let mut lowered_ops = Vec::new(); + + for op in operations { + self.submit_operation_to_runtime(op, &mut lowered_ops)?; + } + + lowered_ops.extend(self.drain_runtime_operations()?); + Ok(lowered_ops) + } + fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()> { debug!( "Received {} measurement results, num_results={}, allocated_results={:?}", @@ -341,45 +1126,33 @@ impl QisRuntime for SeleneRuntime { ); self.state.measurements.insert(*result_id, *value); - // For Selene runtime: Only pass measurements that were explicitly allocated - // The Selene runtime doesn't support dynamic result allocation, so we must - // check if this result was known at compile time - if let Some(interface) = &mut self.interface { - if interface.allocated_results.contains(result_id) { - // This result was explicitly allocated, try to pass to Selene runtime - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(set_result_fn) = - lib.get:: i32>( - b"selene_runtime_set_bool_result", - ) - { - let errno = set_result_fn(instance, *result_id as u64, *value); - if errno != 0 { - // Unexpected error - log it at trace level since this is normal - // for programs that don't explicitly allocate all result slots - log::trace!( - "Selene runtime returned error {errno} for result {result_id}" - ); - } + if let Some(runtime_result_id) = self.program_to_runtime_results.get(result_id) { + if let Some(lib) = &self.library + && let Some(instance) = self.instance + { + unsafe { + if let Ok(set_result_fn) = + lib.get:: i32>( + b"selene_runtime_set_bool_result", + ) + { + let errno = set_result_fn(instance, *runtime_result_id, *value); + if errno != 0 { + log::trace!( + "Selene runtime returned error {errno} for result {result_id}" + ); } } } - } else { - // Result wasn't explicitly allocated - this is normal for LLVM programs - // that use implicit result IDs in measurements - log::trace!( - "Measurement result {result_id} was not explicitly allocated, storing locally only" - ); } + } else { + log::trace!( + "Measurement result {result_id} was not allocated by the Selene runtime, storing locally only" + ); + } - // Update the interface with the measurement result + if let Some(interface) = &mut self.interface { interface.store_result(*result_id, *value); - } else { - // No interface loaded - just store locally - log::trace!("No interface loaded, storing measurement {result_id} locally"); } } @@ -422,6 +1195,10 @@ impl QisRuntime for SeleneRuntime { self.num_qubits } + fn set_num_qubits(&mut self, num_qubits: usize) { + self.num_qubits = self.num_qubits.max(num_qubits); + } + fn set_batch_size(&mut self, size: usize) { self.batch_size = size; } @@ -468,6 +1245,10 @@ impl QisRuntime for SeleneRuntime { self.current_op_index = 0; self.needs_reexecution = false; self.pending_measurements.clear(); + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); Ok(()) } @@ -477,12 +1258,10 @@ impl QisRuntime for SeleneRuntime { && let Some(instance) = self.instance { unsafe { - if let Ok(shot_end_fn) = lib - .get:: i32>( - b"selene_runtime_shot_end", - ) + if let Ok(shot_end_fn) = + lib.get:: i32>(b"selene_runtime_shot_end") { - let _ = shot_end_fn(instance, 0, 0); + let _ = shot_end_fn(instance); } } } @@ -515,6 +1294,10 @@ impl QisRuntime for SeleneRuntime { self.library = None; self.state = ClassicalState::default(); self.current_op_index = 0; + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); Ok(()) } @@ -556,4 +1339,37 @@ mod tests { assert_eq!(runtime.num_qubits(), 0); assert!(runtime.is_complete()); } + + #[test] + fn test_selene_runtime_plugin_config_clones() { + let runtime = SeleneRuntime::with_plugin_config( + "/path/to/selene.so", + vec!["--duration-ns-rxy=10".to_string()], + vec![PathBuf::from("/path/to/lib")], + ); + let cloned = runtime.clone(); + assert_eq!(cloned.init_args, ["--duration-ns-rxy=10"]); + assert_eq!(cloned.library_search_dirs, [PathBuf::from("/path/to/lib")]); + } + + #[test] + fn test_runtime_batch_timing_inserts_idle() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + let batch = RuntimeOperationBatch { + start_time_nanos: 20, + duration_nanos: 5, + invoked: true, + operations: vec![RuntimeScheduledOp::Rxy { + qubit_id: 0, + theta: 1.0, + phi: 0.5, + }], + }; + + let ops = runtime.convert_runtime_batch(batch).unwrap(); + assert_eq!( + ops, + vec![QuantumOp::Idle(20e-9, 0), QuantumOp::RXY(1.0, 0.5, 0)] + ); + } } diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index f18ef6271..2585b87bd 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -24,6 +24,7 @@ type RustStateVectorEngineBuilder = StateVectorEngineBuilder; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; // Import existing shot result types @@ -93,6 +94,7 @@ impl PyQasmEngineBuilder { #[derive(Clone)] pub struct PyQisEngineBuilder { pub(crate) inner: RustQisEngineBuilder, + runtime_configured: bool, } #[pymethods] @@ -101,6 +103,7 @@ impl PyQisEngineBuilder { fn new() -> Self { Self { inner: pecos_qis::qis_engine(), + runtime_configured: false, } } @@ -139,14 +142,42 @@ impl PyQisEngineBuilder { Ok(self.clone()) } - /// Use Selene simple runtime - fn selene_runtime(&mut self) -> PyResult { - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { + /// Use a Selene runtime built into the current PECOS/Cargo target. + #[pyo3(signature = (runtime_name = None))] + fn selene_runtime(&mut self, runtime_name: Option<&str>) -> PyResult { + let runtime = match runtime_name { + None | Some("selene_simple_runtime") => pecos_qis::selene_simple_runtime(), + Some(name) => pecos_qis::selene_runtime_auto(name), + } + .map_err(|e| { PyErr::new::(format!( "Failed to load Selene runtime: {e}" )) })?; self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; + Ok(self.clone()) + } + + /// Use a generic Selene runtime plugin by its shared library and plugin arguments. + #[pyo3(signature = (library_file, init_args = None, library_search_dirs = None))] + fn selene_runtime_plugin( + &mut self, + library_file: &str, + init_args: Option>, + library_search_dirs: Option>, + ) -> PyResult { + let runtime = pecos_qis::SeleneRuntime::with_plugin_config( + library_file, + init_args.unwrap_or_default(), + library_search_dirs + .unwrap_or_default() + .into_iter() + .map(PathBuf::from) + .collect(), + ); + self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; Ok(self.clone()) } @@ -163,14 +194,18 @@ impl PyQisEngineBuilder { .clone() .interface(pecos_qis::helios_interface_builder()); - // Always set Selene runtime to work with Helios interface - log::debug!("Setting Selene runtime for Helios interface"); - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { - PyErr::new::(format!( - "Failed to load Selene runtime: {e}" - )) - })?; - self.inner = self.inner.clone().runtime(runtime); + if !self.runtime_configured { + log::debug!( + "No runtime configured; setting default Selene runtime for Helios interface" + ); + let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { + PyErr::new::(format!( + "Failed to load Selene runtime: {e}" + )) + })?; + self.inner = self.inner.clone().runtime(runtime); + self.runtime_configured = true; + } log::debug!("Helios interface and Selene runtime configured"); Ok(self.clone()) @@ -745,19 +780,26 @@ pub fn qasm_engine() -> PyQasmEngineBuilder { pub fn qis_engine() -> PyQisEngineBuilder { PyQisEngineBuilder { inner: pecos_qis::qis_engine(), + runtime_configured: false, } } /// Create a Selene-backed QIS Control Engine builder. #[pyfunction] -pub fn selene_engine() -> PyResult { - let runtime = pecos_qis::selene_simple_runtime().map_err(|e| { +#[pyo3(signature = (runtime_name = None))] +pub fn selene_engine(runtime_name: Option<&str>) -> PyResult { + let runtime = match runtime_name { + None | Some("selene_simple_runtime") => pecos_qis::selene_simple_runtime(), + Some(name) => pecos_qis::selene_runtime_auto(name), + } + .map_err(|e| { PyErr::new::(format!( "Failed to load Selene runtime: {e}" )) })?; Ok(PyQisEngineBuilder { inner: pecos_qis::qis_engine().runtime(runtime), + runtime_configured: true, }) } diff --git a/python/quantum-pecos/src/pecos/_engine_builders.py b/python/quantum-pecos/src/pecos/_engine_builders.py index f8f1e3047..3c0115e24 100644 --- a/python/quantum-pecos/src/pecos/_engine_builders.py +++ b/python/quantum-pecos/src/pecos/_engine_builders.py @@ -17,6 +17,8 @@ from __future__ import annotations +from os import PathLike, fspath +from pathlib import Path from typing import TYPE_CHECKING import pecos_rslib @@ -155,23 +157,21 @@ def program(self, program: Qis | Hugr | CompiledQis) -> Self: self._builder = self._builder.program(program) return self - def selene_runtime(self, runtime_name: str | None = None) -> Self: - """Use Selene simple runtime. + def selene_runtime(self, runtime: object | None = None) -> Self: + """Use a Selene runtime. Args: - runtime_name: Optional runtime name. ``None`` and - ``"selene_simple_runtime"`` both select the default runtime. + runtime: Optional runtime selector. ``None`` selects the default + ``selene_simple_runtime``. A string without path separators is + treated as a built Selene runtime library name. A path-like + value, or any Selene runtime plugin object exposing + ``library_file``, ``get_init_args()``, and optional + ``library_search_dirs``, is passed through generically. Returns: Self for method chaining. """ - if runtime_name not in (None, "selene_simple_runtime"): - msg = ( - "Python QisEngineBuilder.selene_runtime(runtime_name=...) only " - "supports the default 'selene_simple_runtime' wrapper today." - ) - raise NotImplementedError(msg) - self._builder = self._builder.selene_runtime() + self._builder = _configure_selene_runtime(self._builder, runtime) return self def interface(self, builder: object) -> Self: @@ -231,23 +231,58 @@ def qis_engine() -> QisEngineBuilder: return QisEngineBuilder() -def selene_engine(runtime_name: str | None = None) -> QisEngineBuilder: +def _looks_like_library_path(value: str) -> bool: + path = Path(value) + return ( + path.exists() + or "/" in value + or "\\" in value + or path.suffix in {".so", ".dylib", ".dll"} + ) + + +def _configure_selene_runtime(builder: object, runtime: object | None) -> object: + if runtime is None: + return builder.selene_runtime() + + if isinstance(runtime, str): + if _looks_like_library_path(runtime): + return builder.selene_runtime_plugin(runtime) + return builder.selene_runtime(runtime) + + if isinstance(runtime, PathLike): + return builder.selene_runtime_plugin(fspath(runtime)) + + library_file = getattr(runtime, "library_file", None) + if library_file is None: + msg = ( + "Selene runtime must be None, a built runtime name, a shared-library path, " + "or a Selene runtime plugin object with a 'library_file' property." + ) + raise TypeError(msg) + + get_init_args = getattr(runtime, "get_init_args", None) + init_args = list(get_init_args()) if callable(get_init_args) else [] + library_search_dirs = [fspath(path) for path in getattr(runtime, "library_search_dirs", [])] + return builder.selene_runtime_plugin( + fspath(library_file), + [str(arg) for arg in init_args], + library_search_dirs, + ) + + +def selene_engine(runtime: object | None = None) -> QisEngineBuilder: """Create a Selene-backed QIS engine builder. Args: - runtime_name: Optional built Selene runtime library name. - When omitted, the default simple Selene runtime is used. + runtime: Optional runtime selector. ``None`` selects the default + ``selene_simple_runtime``. A built runtime name, shared-library + path, or generic Selene runtime plugin object may also be supplied. Returns: QisEngineBuilder: A builder for Selene-backed QIS/HUGR simulations. """ - if runtime_name not in (None, "selene_simple_runtime"): - msg = ( - "Python selene_engine(runtime_name=...) is not currently supported by the wrapper. " - "Use the default runtime or call into pecos_rslib directly for custom runtime names." - ) - raise NotImplementedError(msg) - return QisEngineBuilder().selene_runtime().interface(pecos_rslib.qis_helios_interface()) + return QisEngineBuilder().selene_runtime(runtime).interface(pecos_rslib.qis_helios_interface()) __all__ = [ diff --git a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py index bb555b916..0973f9ff7 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py +++ b/python/quantum-pecos/tests/pecos/test_selene_interface_integration.py @@ -137,6 +137,27 @@ def test_selene_engine_python_exports() -> None: assert isinstance(named_builder, pecos.QisEngineBuilder) +def test_selene_engine_accepts_generic_runtime_plugin_shape() -> None: + """A downstream Selene runtime plugin object is sufficient; PECOS does not need to know its package.""" + from pathlib import Path + + import pecos + + class RuntimePlugin: + def __init__(self) -> None: + self.library_file = Path("libcustom_selene_runtime.so") + self.library_search_dirs = [Path("custom-selene-libs")] + + def get_init_args(self) -> list[str]: + return ["--hardware-profile=custom"] + + builder = pecos.qis_engine().selene_runtime(RuntimePlugin()) + assert isinstance(builder, pecos.QisEngineBuilder) + + engine_builder = pecos.selene_engine(RuntimePlugin()) + assert isinstance(engine_builder, pecos.QisEngineBuilder) + + def test_sim_guppy_can_use_selene_engine_via_qis_path() -> None: """Test that sim(Guppy(...)).classical(selene_engine()) routes HUGR through the QIS path.""" import pecos From e9b1a0b32b86f07e655ef6114b86efaf71bbdfcf Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 29 May 2026 07:36:02 -0600 Subject: [PATCH 006/388] Rename observable-subgraph decoder to logical-subgraph, add >64-observable and unclassified-detector guards, and fix D0-only DEM parse --- .../pecos-decoder-core/src/ghost_protocol.rs | 10 +- crates/pecos-decoder-core/src/lib.rs | 4 +- .../src/logical_algorithm.rs | 36 ++-- ...rvable_subgraph.rs => logical_subgraph.rs} | 191 ++++++++++++++---- .../committed.rs} | 26 +-- .../windowed.rs} | 42 ++-- crates/pecos-decoders/src/lib.rs | 8 +- docs/user-guide/cmake-setup.md | 4 +- examples/surface/brickwork_sweep.py | 34 ++-- examples/surface/coherent_noise_sweep.py | 6 +- .../src/fault_tolerance_bindings.rs | 88 ++++---- .../src/pecos/qec/surface/logical_circuit.py | 16 +- .../tests/qec/surface/test_circuit_fuzz.py | 54 ++--- 13 files changed, 315 insertions(+), 204 deletions(-) rename crates/pecos-decoder-core/src/{observable_subgraph.rs => logical_subgraph.rs} (83%) rename crates/pecos-decoder-core/src/{committed_osd.rs => logical_subgraph/committed.rs} (86%) rename crates/pecos-decoder-core/src/{windowed_osd.rs => logical_subgraph/windowed.rs} (88%) diff --git a/crates/pecos-decoder-core/src/ghost_protocol.rs b/crates/pecos-decoder-core/src/ghost_protocol.rs index 44cba87cc..99cca51d3 100644 --- a/crates/pecos-decoder-core/src/ghost_protocol.rs +++ b/crates/pecos-decoder-core/src/ghost_protocol.rs @@ -135,9 +135,9 @@ pub struct GhostMessage { #[must_use] pub fn extract_ghost_edges_from_dem( dem_str: &str, - stab_coords: &crate::observable_subgraph::StabCoords, + stab_coords: &crate::logical_subgraph::StabCoords, ) -> Vec { - use crate::observable_subgraph::classify_detector; + use crate::logical_subgraph::classify_detector; use std::collections::BTreeMap; // Parse detector coordinates @@ -263,7 +263,7 @@ mod tests { #[test] fn test_extract_ghost_edges_from_synthetic_dem() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; // Two qubits: qubit 0 has X-stab at (1,1) and Z-stab at (3,1), // qubit 1 has X-stab at (7,1) and Z-stab at (9,1). @@ -309,7 +309,7 @@ mod tests { #[test] fn test_extract_no_ghost_edges_graphlike_dem() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; let stab_coords = vec![QubitStabCoords { x_positions: vec![(1.0, 1.0)], @@ -329,7 +329,7 @@ mod tests { #[test] fn test_extract_three_same_qubit_no_ghost() { - use crate::observable_subgraph::QubitStabCoords; + use crate::logical_subgraph::QubitStabCoords; let stab_coords = vec![QubitStabCoords { x_positions: vec![(1.0, 1.0), (1.0, 3.0)], diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index 6ac5e2bba..acbb2c244 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -25,7 +25,6 @@ pub mod adaptive; pub mod advanced; pub mod bp_matching; -pub mod committed_osd; pub mod config; pub mod correlated_decoder; pub mod correlated_reweighting; @@ -40,7 +39,7 @@ pub mod k_mwpm; pub mod logical_algorithm; pub mod matrix; pub mod multi_decoder; -pub mod observable_subgraph; +pub mod logical_subgraph; pub mod pauli_frame; pub mod perturbed; pub mod preprocessor; @@ -48,7 +47,6 @@ pub mod results; pub mod streaming; pub mod telemetry; pub mod two_pass_decoder; -pub mod windowed_osd; use ndarray::ArrayView1; diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index cb97a7113..9c366e209 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -18,8 +18,8 @@ //! //! # Decoding Modes //! -//! - **Full-circuit**: Uses the full DEM's OSD for maximum accuracy. -//! Equivalent to `ObservableSubgraphDecoder` on the full circuit. +//! - **Full-circuit**: Uses the full DEM's logical-subgraph decoder for maximum accuracy. +//! Equivalent to `LogicalSubgraphDecoder` on the full circuit. //! - **Per-segment** (future streaming): Each segment decoded independently //! with buffer overlap at gate boundaries. @@ -91,17 +91,17 @@ pub struct AlgorithmDescriptor { /// Decoder for logical quantum algorithms. /// -/// Wraps a full-circuit decoder (OSD) with segment metadata. The +/// Wraps a full-circuit decoder (logical-subgraph decoder) with segment metadata. The /// segment structure enables: /// - Tracking which gates occur at which point in the circuit /// - Pauli frame propagation for T-gate/measurement corrections /// - Future streaming mode with per-segment windowed decoding /// /// In the current implementation, `decode_shot` delegates to the -/// full-circuit OSD for maximum accuracy. The segment structure is +/// full-circuit logical-subgraph decoder for maximum accuracy. The segment structure is /// metadata for frame tracking and streaming (step 5). pub struct LogicalAlgorithmDecoder { - /// Full-circuit decoder (OSD on the complete DEM). + /// Full-circuit decoder (logical-subgraph decoder on the complete DEM). full_decoder: Box, /// Segment metadata for streaming/frame tracking. segments: Vec, @@ -114,7 +114,7 @@ pub struct LogicalAlgorithmDecoder { impl LogicalAlgorithmDecoder { /// Build from a full-circuit decoder and algorithm descriptor. /// - /// The `full_decoder` is typically an `ObservableSubgraphDecoder` + /// The `full_decoder` is typically an `LogicalSubgraphDecoder` /// built from the full circuit DEM. #[must_use] pub fn new( @@ -213,7 +213,7 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// Streaming wrapper for `LogicalAlgorithmDecoder`. /// -/// Buffers syndrome data round-by-round. The full-circuit OSD decodes +/// Buffers syndrome data round-by-round. The full-circuit logical-subgraph decoder decodes /// the entire accumulated syndrome at `flush()` for maximum accuracy. /// /// The segment structure tracks which rounds belong to which segment. @@ -257,7 +257,7 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// assert_eq!(obs, 1); /// ``` pub struct StreamingLogicalDecoder { - /// The underlying batch decoder (full-circuit OSD). + /// The underlying batch decoder (full-circuit logical-subgraph decoder). inner: LogicalAlgorithmDecoder, /// Accumulated syndrome buffer (full circuit size). syndrome: Vec, @@ -305,7 +305,7 @@ impl StreamingLogicalDecoder { self.rounds_fed += 1; } - /// Decode the accumulated syndrome using the full-circuit OSD. + /// Decode the accumulated syndrome using the full-circuit logical-subgraph decoder. /// /// Returns the observable correction mask. This is the final /// correction to apply to raw measurement outcomes. @@ -395,7 +395,7 @@ use crate::decode_budget::{DecodeBudget, DecodeStrategy, DetectorRegion}; /// /// - **Offline** (ion trap / simulation): `FullCircuitStrategy` — buffer /// everything, decode at end. Maximum accuracy. -/// - **Streaming** (neutral atom): `CommittedOsdStrategy` — decode and +/// - **Streaming** (neutral atom): `CommittedLogicalSubgraphStrategy` — decode and /// commit at segment boundaries. Bounded memory. /// - **Real-time** (superconducting): windowed UF with ghost protocol /// (future). @@ -457,7 +457,7 @@ impl LogicalCircuitDecoder { /// Decode a full shot (batch mode). /// - /// For offline/ion trap budgets: equivalent to full-circuit OSD. + /// For offline/ion trap budgets: equivalent to full-circuit logical-subgraph decoder. /// For streaming budgets: decodes and commits each segment. pub fn decode_shot(&mut self, full_syndrome: &[u8]) -> Result { self.reset(); @@ -561,7 +561,7 @@ pub struct FullCircuitStrategy { } impl FullCircuitStrategy { - /// Wrap any `ObservableDecoder` (typically OSD). + /// Wrap any `ObservableDecoder` (typically logical-subgraph decoder). #[must_use] pub fn new(decoder: Box) -> Self { Self { inner: decoder } @@ -588,18 +588,18 @@ impl DecodeStrategy for FullCircuitStrategy { } // ============================================================================ -// Strategy: Windowed OSD (neutral atom / medium budget) +// Strategy: Windowed logical-subgraph decoding (neutral atom / medium budget) // ============================================================================ -/// Windowed OSD strategy: per-observable subgraph windowed decoding. +/// Windowed logical-subgraph strategy: per-logical-operator subgraph windowed decoding. /// /// Each observable's subgraph is graphlike (no hyperedges). A windowed /// decoder (sandwich or plain PM) runs inside each subgraph with bounded /// latency. The full matching graph is pre-built; only syndrome routing /// and per-window matching are per-shot work. /// -/// This achieves bounded-latency streaming with OSD-level accuracy. -pub struct WindowedOsdStrategy { +/// This achieves bounded-latency streaming with logical-subgraph decoder-level accuracy. +pub struct WindowedLogicalSubgraphStrategy { /// Per-subgraph decoders (windowed or plain). subgraph_decoders: Vec>, /// Per-subgraph detector maps: `subgraph_detector_maps`[i][local] = global. @@ -610,7 +610,7 @@ pub struct WindowedOsdStrategy { _num_observables: usize, } -impl WindowedOsdStrategy { +impl WindowedLogicalSubgraphStrategy { /// Build from pre-extracted subgraph DEMs and detector maps. /// /// `subgraph_dems`: per-observable DEM strings (graphlike). @@ -644,7 +644,7 @@ impl WindowedOsdStrategy { } } -impl DecodeStrategy for WindowedOsdStrategy { +impl DecodeStrategy for WindowedLogicalSubgraphStrategy { fn decode(&mut self, syndrome: &[u8]) -> Result { let mut obs_mask = 0u64; diff --git a/crates/pecos-decoder-core/src/observable_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs similarity index 83% rename from crates/pecos-decoder-core/src/observable_subgraph.rs rename to crates/pecos-decoder-core/src/logical_subgraph.rs index 31ce1480a..c343d069b 100644 --- a/crates/pecos-decoder-core/src/observable_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -10,7 +10,7 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! Per-observable subgraph decoder for transversal gates. +//! Per-logical-operator subgraph decoder for transversal gates. //! //! Based on the insight (proved independently by Serra-Peralta et al. //! arXiv:2505.13599 and Cain et al. arXiv:2505.13587) that per-observable @@ -25,7 +25,7 @@ //! to identify which (qubit, `stab_type`) groups form its observing region //! 3. Extract a sub-DEM restricted to those detectors //! 4. Run any MWPM-compatible decoder on each subgraph independently -//! 5. Combine per-observable corrections +//! 5. Combine per-logical-operator corrections //! //! # Observing Region //! @@ -36,6 +36,9 @@ //! - ALL detectors in those groups form the observing region //! - This preserves the graphlike property of each subgraph +pub mod committed; +pub mod windowed; + use std::collections::{BTreeMap, BTreeSet}; use crate::ObservableDecoder; @@ -137,9 +140,9 @@ impl SparseDem { if !det_set.remove(&d) { det_set.insert(d); } + has_any_detector = true; if d > max_detector { max_detector = d; - has_any_detector = true; } } } else if let Some(l_str) = token.strip_prefix('L') @@ -184,9 +187,9 @@ impl SparseDem { } if let Some(d) = parse_u32_fast(&line_bytes[start..i]) { dets.push(d); + has_any_detector = true; if d > max_detector { max_detector = d; - has_any_detector = true; } } } else if line_bytes[i] == b'L' { @@ -239,9 +242,9 @@ impl SparseDem { } if let Some(d) = parse_u32_fast(&bytes[start..pos]) { detector_coords.insert(d as usize, coords); + has_any_detector = true; if d > max_detector { max_detector = d; - has_any_detector = true; } } } @@ -346,7 +349,7 @@ pub fn classify_detector(x: f64, y: f64, stab_coords: &StabCoords) -> Option; -pub fn partition_dem_by_observable( +pub fn partition_dem_by_logical( dem_str: &str, stab_coords: &StabCoords, -) -> Result, DecoderError> { - partition_dem_by_observable_windowed(dem_str, stab_coords, None) +) -> Result, DecoderError> { + partition_dem_by_logical_windowed(dem_str, stab_coords, None) } -pub fn partition_dem_by_observable_windowed( +pub fn partition_dem_by_logical_windowed( dem_str: &str, stab_coords: &StabCoords, max_time_radius: MaxTimeRadius, -) -> Result, DecoderError> { +) -> Result, DecoderError> { // Single-pass sparse DEM parsing: mechanisms + detector coordinates. let sdem = SparseDem::from_dem_str(dem_str)?; let coord_map = &sdem.detector_coords; + // Observable flips are packed into a u64 mask (bit i = observable i), so + // more than 64 observables would silently overflow the shift. Fail loud. + if sdem.num_observables > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "LogicalSubgraphDecoder packs observable flips into a u64 mask and \ + supports at most 64 observables, but the DEM declares {}", + sdem.num_observables, + ))); + } + + // Detectors that appear in an error mechanism are the only ones that affect + // decoding. Any such detector that fails to classify would be silently + // dropped from every observing region and corrupt the decode, so treat that + // as a configuration error rather than a silent fallback. Detectors that are + // declared but never used (numbering gaps, unused ancillas) are ignored. + let mut used = vec![false; sdem.num_detectors]; + for (_, dets, _) in &sdem.mechanisms { + for &d in dets { + if (d as usize) < sdem.num_detectors { + used[d as usize] = true; + } + } + } + // Classify each detector into a (qubit, stab_type) group. let mut det_group: Vec> = vec![None; sdem.num_detectors]; let mut group_detectors: BTreeMap> = BTreeMap::new(); + let mut unclassified: Vec = Vec::new(); for (d, group_slot) in det_group.iter_mut().enumerate().take(sdem.num_detectors) { - if let Some(coords) = coord_map.get(&d) - && coords.len() >= 2 - { - let (x, y) = (coords[0], coords[1]); - if let Some(group) = classify_detector(x, y, stab_coords) { - *group_slot = Some(group); - group_detectors.entry(group).or_default().insert(d); + let group = match coord_map.get(&d) { + Some(coords) if coords.len() >= 2 => { + classify_detector(coords[0], coords[1], stab_coords) + } + _ => None, + }; + match group { + Some(g) => { + *group_slot = Some(g); + group_detectors.entry(g).or_default().insert(d); } + None if used[d] => unclassified.push(d), + None => {} } } + if !unclassified.is_empty() { + let shown: Vec = unclassified + .iter() + .take(5) + .map(|&d| match coord_map.get(&d) { + Some(c) => format!("D{d} at {c:?}"), + None => format!("D{d} (no coordinates)"), + }) + .collect(); + return Err(DecoderError::InvalidConfiguration(format!( + "{} detector(s) used in error mechanisms could not be classified against \ + stab_coords (missing coordinates, or a coordinate scale / completeness \ + mismatch). Examples: {}", + unclassified.len(), + shown.join(", "), + ))); + } + // For each observable, find its observing region. let mut subgraphs = Vec::with_capacity(sdem.num_observables); @@ -463,7 +516,7 @@ pub fn partition_dem_by_observable_windowed( } if region_detectors.is_empty() { - subgraphs.push(ObservableSubgraph { + subgraphs.push(LogicalSubgraph { observable_idx: obs_idx, detector_map: Vec::new(), inverse_map: vec![None; sdem.num_detectors], @@ -532,7 +585,7 @@ pub fn partition_dem_by_observable_windowed( let num_sub = detector_map.len(); let edges = DemMatchingGraph::merge_parallel_edges(edges); - subgraphs.push(ObservableSubgraph { + subgraphs.push(LogicalSubgraph { observable_idx: obs_idx, detector_map, inverse_map, @@ -553,19 +606,19 @@ pub fn partition_dem_by_observable_windowed( // Decoder // ============================================================================ -/// Per-observable subgraph decoder. +/// Per-logical-operator subgraph decoder. /// /// Wraps a factory function that creates per-subgraph inner decoders. /// Any `ObservableDecoder` works as the inner decoder (UF, Fusion Blossom, /// perturbed ensemble, etc.). -pub struct ObservableSubgraphDecoder { - subgraphs: Vec, +pub struct LogicalSubgraphDecoder { + subgraphs: Vec, decoders: Vec>, num_observables: usize, sub_syndromes: Vec>, } -impl ObservableSubgraphDecoder { +impl LogicalSubgraphDecoder { /// Build from a DEM string, stabilizer coordinates, and inner decoder factory. /// /// # Errors @@ -595,7 +648,7 @@ impl ObservableSubgraphDecoder { &DemMatchingGraph, ) -> Result, DecoderError>, { - let subgraphs = partition_dem_by_observable_windowed(dem, stab_coords, max_time_radius)?; + let subgraphs = partition_dem_by_logical_windowed(dem, stab_coords, max_time_radius)?; let num_observables = subgraphs.len(); let mut decoders = Vec::with_capacity(subgraphs.len()); @@ -621,7 +674,7 @@ impl ObservableSubgraphDecoder { /// Access a subgraph. #[must_use] - pub fn subgraph(&self, obs_idx: usize) -> Option<&ObservableSubgraph> { + pub fn subgraph(&self, obs_idx: usize) -> Option<&LogicalSubgraph> { self.subgraphs.get(obs_idx) } @@ -688,7 +741,7 @@ impl ObservableSubgraphDecoder { } } -impl ObservableDecoder for ObservableSubgraphDecoder { +impl ObservableDecoder for LogicalSubgraphDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { let mut obs_mask = 0u64; @@ -723,13 +776,13 @@ impl ObservableDecoder for ObservableSubgraphDecoder { } } -/// Parallel per-observable subgraph decoder using rayon. -pub struct ParallelObservableSubgraphDecoder { - subgraphs: Vec, +/// Parallel per-logical-operator subgraph decoder using rayon. +pub struct ParallelLogicalSubgraphDecoder { + subgraphs: Vec, decoders: Vec>>, } -impl ParallelObservableSubgraphDecoder { +impl ParallelLogicalSubgraphDecoder { /// Build from a DEM string, stabilizer coordinates, and inner decoder factory. /// /// # Errors @@ -743,7 +796,7 @@ impl ParallelObservableSubgraphDecoder { where F: FnMut(&DemMatchingGraph) -> Result, DecoderError>, { - let subgraphs = partition_dem_by_observable(dem, stab_coords)?; + let subgraphs = partition_dem_by_logical(dem, stab_coords)?; let mut decoders = Vec::with_capacity(subgraphs.len()); for sg in &subgraphs { @@ -876,7 +929,7 @@ mod tests { "error(0.01) D0 L0\n", // boundary edge → D0 is (qubit 0, X) ); let sc = simple_stab_coords(); - let sgs = partition_dem_by_observable(dem, &sc).unwrap(); + let sgs = partition_dem_by_logical(dem, &sc).unwrap(); assert_eq!(sgs.len(), 1); // Boundary edge D0 L0 → D0 is qubit 0 X-type. // Observing region = all qubit-0 X-type detectors = {D0}. @@ -900,7 +953,7 @@ mod tests { "error(0.01) D2 D3\n", // D2-D3 edge ); let sc = simple_stab_coords(); - let sgs = partition_dem_by_observable(dem, &sc).unwrap(); + let sgs = partition_dem_by_logical(dem, &sc).unwrap(); assert_eq!(sgs.len(), 2); assert_eq!(sgs[0].detector_map, vec![0]); // qubit 0 X-type only assert_eq!(sgs[1].detector_map, vec![2]); // qubit 1 X-type only @@ -915,7 +968,7 @@ mod tests { "error(0.01) D1 L1\n", ); let sc = simple_stab_coords(); - let mut dec = ObservableSubgraphDecoder::from_dem(dem, &sc, |_| { + let mut dec = LogicalSubgraphDecoder::from_dem(dem, &sc, |_| { Ok(Box::new(FixedDecoder(1)) as Box) }) .unwrap(); @@ -929,6 +982,66 @@ mod tests { assert_eq!(obs, 0b10); } + #[test] + fn test_unclassified_used_detector_errors() { + // D1 is used in a mechanism but sits at coords matching no stabilizer. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(99, 99, 0) D1\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", // D1 used here, but won't classify + ); + let sc = simple_stab_coords(); + let err = partition_dem_by_logical(dem, &sc).unwrap_err(); + assert!( + matches!(err, DecoderError::InvalidConfiguration(_)), + "expected InvalidConfiguration, got {err:?}" + ); + } + + #[test] + fn test_unclassified_unused_detector_ignored() { + // D1 has bad coords but is never used in a mechanism -> ignored, no error. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(99, 99, 0) D1\n", + "error(0.01) D0 L0\n", + ); + let sc = simple_stab_coords(); + assert!(partition_dem_by_logical(dem, &sc).is_ok()); + } + + #[test] + fn test_single_detector_d0() { + // Regression: a DEM whose only detector is D0 must parse with + // num_detectors == 1 (not 0). D0 used to never set has_any_detector + // because `0 > max_detector` is false, yielding an empty det_group + // and an out-of-bounds panic downstream. + let dem = concat!( + "detector(1, 0, 0) D0\n", // qubit 0 X + "error(0.01) D0 L0\n", // boundary edge on D0 + ); + let sc = simple_stab_coords(); + let sgs = partition_dem_by_logical(dem, &sc).unwrap(); + assert_eq!(sgs.len(), 1); + assert_eq!(sgs[0].detector_map, vec![0]); + } + + #[test] + fn test_too_many_observables_errors() { + // 65 observables exceeds the u64 mask capacity. + let mut dem = String::from("detector(1, 0, 0) D0\n"); + for l in 0..65 { + dem.push_str(&format!("error(0.01) D0 L{l}\n")); + } + let sc = simple_stab_coords(); + let err = partition_dem_by_logical(&dem, &sc).unwrap_err(); + assert!( + matches!(err, DecoderError::InvalidConfiguration(_)), + "expected InvalidConfiguration, got {err:?}" + ); + } + #[test] fn test_parallel_decoder() { let dem = concat!( @@ -938,7 +1051,7 @@ mod tests { "error(0.01) D1 L1\n", ); let sc = simple_stab_coords(); - let dec = ParallelObservableSubgraphDecoder::from_dem(dem, &sc, |_| { + let dec = ParallelLogicalSubgraphDecoder::from_dem(dem, &sc, |_| { Ok(Box::new(NullDecoder) as Box) }) .unwrap(); diff --git a/crates/pecos-decoder-core/src/committed_osd.rs b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs similarity index 86% rename from crates/pecos-decoder-core/src/committed_osd.rs rename to crates/pecos-decoder-core/src/logical_subgraph/committed.rs index 8157cd6a8..4397ebc40 100644 --- a/crates/pecos-decoder-core/src/committed_osd.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs @@ -10,9 +10,9 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! OSD with software commitment for streaming decoding. +//! logical-subgraph decoder with software commitment for streaming decoding. //! -//! Wraps an `ObservableSubgraphDecoder` with per-detector commitment +//! Wraps an `LogicalSubgraphDecoder` with per-detector commitment //! tracking. Committed detectors are masked during future decodes, //! implementing the "software commitment" concept from Cain et al. //! (arXiv:2505.13587). @@ -23,7 +23,7 @@ use crate::ObservableDecoder; use crate::decode_budget::{DecodeStrategy, DetectorRegion}; use crate::errors::DecoderError; -use crate::observable_subgraph::ObservableSubgraphDecoder; +use crate::logical_subgraph::LogicalSubgraphDecoder; /// Observable subgraph decoder with software commitment. /// @@ -34,9 +34,9 @@ use crate::observable_subgraph::ObservableSubgraphDecoder; /// /// The total correction is `committed_obs ^ active_obs`: the XOR /// of committed corrections and the latest active decode. -pub struct CommittedOsdDecoder { - /// The underlying OSD (unchanged). - inner: ObservableSubgraphDecoder, +pub struct CommittedLogicalSubgraphDecoder { + /// The underlying logical-subgraph decoder (unchanged). + inner: LogicalSubgraphDecoder, /// Per-detector commitment state. True = committed. committed: Vec, /// Accumulated observable correction from committed regions. @@ -47,10 +47,10 @@ pub struct CommittedOsdDecoder { masked_syndrome: Vec, } -impl CommittedOsdDecoder { - /// Wrap an existing OSD with commitment tracking. +impl CommittedLogicalSubgraphDecoder { + /// Wrap an existing logical-subgraph decoder with commitment tracking. #[must_use] - pub fn new(inner: ObservableSubgraphDecoder, num_detectors: usize) -> Self { + pub fn new(inner: LogicalSubgraphDecoder, num_detectors: usize) -> Self { Self { inner, committed: vec![false; num_detectors], @@ -63,7 +63,7 @@ impl CommittedOsdDecoder { /// Decode only uncommitted detectors. /// /// Committed detectors are masked to 0 before passing to the - /// inner OSD. Returns the correction for the active (uncommitted) + /// inner logical-subgraph decoder. Returns the correction for the active (uncommitted) /// region. pub fn decode_active(&mut self, syndrome: &[u8]) -> Result { // Build masked syndrome: zero out committed detectors @@ -123,7 +123,7 @@ impl CommittedOsdDecoder { } } -impl ObservableDecoder for CommittedOsdDecoder { +impl ObservableDecoder for CommittedLogicalSubgraphDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { // Full decode: committed XOR active let active = self.decode_active(syndrome)?; @@ -131,7 +131,7 @@ impl ObservableDecoder for CommittedOsdDecoder { } } -impl DecodeStrategy for CommittedOsdDecoder { +impl DecodeStrategy for CommittedLogicalSubgraphDecoder { fn decode(&mut self, syndrome: &[u8]) -> Result { self.decode_active(syndrome) } @@ -150,7 +150,7 @@ impl DecodeStrategy for CommittedOsdDecoder { } fn reset(&mut self) { - CommittedOsdDecoder::reset(self); + CommittedLogicalSubgraphDecoder::reset(self); } } diff --git a/crates/pecos-decoder-core/src/windowed_osd.rs b/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs similarity index 88% rename from crates/pecos-decoder-core/src/windowed_osd.rs rename to crates/pecos-decoder-core/src/logical_subgraph/windowed.rs index 00e575ffe..2bc561611 100644 --- a/crates/pecos-decoder-core/src/windowed_osd.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs @@ -12,7 +12,7 @@ //! Windowed observable subgraph decoder. //! -//! Splits a DEM into time windows, runs per-observable subgraph decoding +//! Splits a DEM into time windows, runs per-logical-operator subgraph decoding //! within each window. This prevents the observing region from spanning //! the full circuit at deep depths, maintaining decoding accuracy. //! @@ -27,26 +27,26 @@ use std::collections::BTreeMap; use crate::ObservableDecoder; use crate::dem::{DemCheckMatrix, DemMatchingGraph, MatchingEdge, parse_detector_coords}; use crate::errors::DecoderError; -use crate::observable_subgraph::{ObservableSubgraphDecoder, StabCoords}; +use crate::logical_subgraph::{LogicalSubgraphDecoder, StabCoords}; -/// Configuration for windowed OSD. +/// Configuration for windowed logical-subgraph decoder. #[derive(Debug, Clone)] -pub struct WindowedOsdConfig { +pub struct WindowedLogicalSubgraphConfig { /// Core window size in time steps. pub step: usize, /// Buffer size on each side (0 = non-overlapping). pub buffer: usize, } -impl Default for WindowedOsdConfig { +impl Default for WindowedLogicalSubgraphConfig { fn default() -> Self { Self { step: 8, buffer: 4 } } } -/// A single time window with its own OSD. -pub struct OsdWindow { - decoder: ObservableSubgraphDecoder, +/// A single time window with its own logical-subgraph decoder. +pub struct LogicalSubgraphWindow { + decoder: LogicalSubgraphDecoder, /// Maps local detector index → global detector index. local_to_global: Vec, num_local: usize, @@ -56,17 +56,17 @@ pub struct OsdWindow { /// Windowed observable subgraph decoder. /// -/// Splits the DEM into time windows, each decoded with its own OSD. +/// Splits the DEM into time windows, each decoded with its own logical-subgraph decoder. /// The observing region within each window is naturally bounded, /// preventing the scaling degradation seen at deep circuits. -pub struct WindowedOsdDecoder { - pub windows: Vec, +pub struct WindowedLogicalSubgraphDecoder { + pub windows: Vec, _num_detectors: usize, /// Reusable window syndrome buffer window_syn: Vec, } -impl WindowedOsdDecoder { +impl WindowedLogicalSubgraphDecoder { /// Build from a DEM string with time-based windowing. /// /// # Errors @@ -75,7 +75,7 @@ impl WindowedOsdDecoder { pub fn from_dem( dem: &str, stab_coords: &StabCoords, - config: &WindowedOsdConfig, + config: &WindowedLogicalSubgraphConfig, mut inner_factory: F, ) -> Result where @@ -101,11 +101,11 @@ impl WindowedOsdDecoder { let max_t = det_time.values().copied().fold(f64::NEG_INFINITY, f64::max); if max_t <= min_t { - // Single time step or empty — just use full OSD + // Single time step or empty — just use full logical-subgraph decoder let full_osd = - ObservableSubgraphDecoder::from_dem(dem, stab_coords, &mut inner_factory)?; + LogicalSubgraphDecoder::from_dem(dem, stab_coords, &mut inner_factory)?; return Ok(Self { - windows: vec![OsdWindow { + windows: vec![LogicalSubgraphWindow { decoder: full_osd, local_to_global: (0..num_detectors).collect(), num_local: num_detectors, @@ -216,7 +216,7 @@ impl WindowedOsdDecoder { }; // Build sub-DEM string with detector coordinate declarations. - // The OSD needs these to classify detectors by (qubit, stab_type). + // The logical-subgraph decoder needs these to classify detectors by (qubit, stab_type). let mut sub_dem_lines = Vec::new(); for (local_id, &global_id) in local_to_global.iter().enumerate() { // Find this detector's coordinates from the parsed coords @@ -228,11 +228,11 @@ impl WindowedOsdDecoder { sub_dem_lines.push(graph_to_dem_string(&sub_graph)); let sub_dem = sub_dem_lines.join("\n"); - // Build OSD for this window using the sub-DEM + // Build logical-subgraph decoder for this window using the sub-DEM let window_osd = - ObservableSubgraphDecoder::from_dem(&sub_dem, stab_coords, &mut inner_factory)?; + LogicalSubgraphDecoder::from_dem(&sub_dem, stab_coords, &mut inner_factory)?; - windows.push(OsdWindow { + windows.push(LogicalSubgraphWindow { decoder: window_osd, local_to_global, num_local, @@ -250,7 +250,7 @@ impl WindowedOsdDecoder { } } -impl ObservableDecoder for WindowedOsdDecoder { +impl ObservableDecoder for WindowedLogicalSubgraphDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { let mut obs_mask = 0u64; diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 5c9906a45..02a9797c9 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -21,10 +21,10 @@ pub use pecos_decoder_core::{ }; // Re-export observable subgraph decoder (for transversal gates) -pub use pecos_decoder_core::observable_subgraph::{ - DetectorGroup, ObservableSubgraph, ObservableSubgraphDecoder, - ParallelObservableSubgraphDecoder, QubitStabCoords, StabCoords, StabType, - partition_dem_by_observable, +pub use pecos_decoder_core::logical_subgraph::{ + DetectorGroup, LogicalSubgraph, LogicalSubgraphDecoder, + ParallelLogicalSubgraphDecoder, QubitStabCoords, StabCoords, StabType, + partition_dem_by_logical, }; // Re-export LDPC decoders when feature is enabled diff --git a/docs/user-guide/cmake-setup.md b/docs/user-guide/cmake-setup.md index bbd1c5061..443e0dcd6 100644 --- a/docs/user-guide/cmake-setup.md +++ b/docs/user-guide/cmake-setup.md @@ -94,10 +94,10 @@ Optional decoders: `pecos python build` will detect cmake automatically and pass `--features mwpf` to maturin. To check the decoder from Python: ```python -from pecos_rslib.qec import ObservableSubgraphDecoder # MWPF-capable decoder +from pecos_rslib.qec import LogicalSubgraphDecoder # MWPF-capable decoder # Construct with a real DEM + stabilizer coords: -# decoder = ObservableSubgraphDecoder(dem_str, stab_coords, inner_decoder="mwpf") +# decoder = LogicalSubgraphDecoder(dem_str, stab_coords, inner_decoder="mwpf") ``` Set `PECOS_BUILD_MWPF=0` to force MWPF off even when cmake is present (useful for reproducing the lean build locally). `PECOS_BUILD_MWPF=1` forces it on, which is what CI sets. diff --git a/examples/surface/brickwork_sweep.py b/examples/surface/brickwork_sweep.py index 88fe9f01e..55d501e3a 100644 --- a/examples/surface/brickwork_sweep.py +++ b/examples/surface/brickwork_sweep.py @@ -8,7 +8,7 @@ are unambiguous. Decoder names: - observable_subgraph:INNER -- OSD with inner decoder (pymatching, pecos_uf:fast, etc.) + logical_subgraph:INNER -- logical-subgraph decoder with inner decoder (pymatching, pecos_uf:fast, etc.) logical_circuit:BUDGET:INNER -- LogicalCircuitDecoder (unlimited, windowed, 10ms, etc.) logical_algorithm:INNER -- LogicalAlgorithmDecoder (full-circuit, no budget) @@ -16,13 +16,13 @@ uv run python examples/surface/brickwork_sweep.py \ --distances 3 5 --widths 2 3 4 --depths 1 2 3 \ --error-rates 0.001 0.002 \ - --decoders observable_subgraph:pymatching \ + --decoders logical_subgraph:pymatching \ --shots 5000 --output-dir /tmp/brickwork_sweep uv run python examples/surface/brickwork_sweep.py \ --distances 3 5 --widths 2 3 --depths 1 2 \ --error-rates 0.001 \ - --decoders observable_subgraph:pymatching logical_circuit:windowed:pymatching \ + --decoders logical_subgraph:pymatching logical_circuit:windowed:pymatching \ --shots 2000 --save-html --open """ @@ -159,7 +159,7 @@ def run_sweep( ) -> BrickworkShard: """Run the full brickwork sweep.""" from pecos.qec.surface import SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem config = { "distances": distances, @@ -230,19 +230,19 @@ def run_sweep( desc = b.build_algorithm_descriptor(p1=p, p2=p, p_meas=p, p_prep=p) algo = LogicalAlgorithmDecoder(desc, inner) errors = algo.decode_count(batch) - elif decoder_name.startswith("observable_subgraph"): + elif decoder_name.startswith("logical_subgraph"): parts = decoder_name.split(":", 1) inner = parts[1] if len(parts) > 1 else "pymatching" - osd = ObservableSubgraphDecoder(dem_str, sc, inner) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner) # Use parallel decode for large shot counts if shots >= 5000: - errors = osd.decode_count_parallel(batch, dem_str, sc, inner) + errors = decoder.decode_count_parallel(batch, dem_str, sc, inner) else: - errors = osd.decode_count(batch) + errors = decoder.decode_count(batch) else: msg = ( f"Unknown decoder: '{decoder_name}'. " - f"Supported: observable_subgraph:INNER, " + f"Supported: logical_subgraph:INNER, " f"logical_circuit:BUDGET:INNER, " f"logical_algorithm:INNER" ) @@ -430,9 +430,9 @@ def write_html_report(shard: BrickworkShard, path: Path, coherent_results=None) "

Budget strategies

", "

The decoder framework selects a strategy based on the user-specified reaction time budget:

", "
    ", - "
  • unlimited — Full-circuit OSD. Maximum accuracy. " + "
  • unlimited — Full-circuit logical-subgraph decoder. Maximum accuracy. " "Appropriate for Clifford circuits or offline analysis.
  • ", - "
  • windowed / 10ms — Windowed OSD with " + "
  • windowed / 10ms — Windowed logical-subgraph decoder with " "overlap buffers inside each per-observable subgraph. Bounded latency, full " "accuracy with sufficient overlap.
  • ", "
  • 100us / 1us — Tight budget. Windowed " @@ -592,7 +592,7 @@ def main(): help="Override --depths: set depth=2^((d+1)/2) per distance", ) parser.add_argument("--error-rates", type=float, nargs="+", default=[0.001]) - parser.add_argument("--decoders", nargs="+", default=["observable_subgraph:pymatching"]) + parser.add_argument("--decoders", nargs="+", default=["logical_subgraph:pymatching"]) parser.add_argument("--shots", type=int, default=5000) parser.add_argument("--seed", type=int, default=42) parser.add_argument("--rounds-per-layer", type=int, default=2) @@ -680,7 +680,7 @@ def main(): # T-injection circuits if args.include_t_injection or args.t_injection_only: from pecos.qec.surface import SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem print("\n--- T-Injection Circuits ---") for d in args.distances: @@ -721,15 +721,15 @@ def main(): desc = b.build_algorithm_descriptor(p1=p, p2=p, p_meas=p, p_prep=p) algo = LogicalAlgorithmDecoder(desc, inner) errors = algo.decode_count(batch) - elif decoder_name.startswith("observable_subgraph"): + elif decoder_name.startswith("logical_subgraph"): parts = decoder_name.split(":", 1) inner = parts[1] if len(parts) > 1 else "pymatching" - osd = ObservableSubgraphDecoder(dem_str, sc, inner) - errors = osd.decode_count(batch) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner) + errors = decoder.decode_count(batch) else: msg = ( f"Unknown decoder: '{decoder_name}'. " - f"Supported: observable_subgraph:INNER, " + f"Supported: logical_subgraph:INNER, " f"logical_circuit:BUDGET:INNER, " f"logical_algorithm:INNER" ) diff --git a/examples/surface/coherent_noise_sweep.py b/examples/surface/coherent_noise_sweep.py index 1995878c6..63c89a56c 100644 --- a/examples/surface/coherent_noise_sweep.py +++ b/examples/surface/coherent_noise_sweep.py @@ -68,7 +68,7 @@ def run_sweep( ) -> CoherentNoiseSweep: """Run a coherent noise sweep using sim_neo.""" from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch - from pecos_rslib.qec import ObservableSubgraphDecoder + from pecos_rslib.qec import LogicalSubgraphDecoder from pecos_rslib_exp import depolarizing, sim_neo, stab_mps, statevec patch = SurfacePatch.create(distance=distance) @@ -82,7 +82,7 @@ def run_sweep( num_meas = int(tc.get_meta("num_measurements")) dem_str = b.build_dem(p1=p_depol, p2=p_depol, p_meas=p_depol, p_prep=p_depol) sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pymatching") + decoder = LogicalSubgraphDecoder(dem_str, sc, "pymatching") sweep = CoherentNoiseSweep( distance=distance, @@ -133,7 +133,7 @@ def run_sweep( val ^= meas[idx] if val: obs_mask |= 1 << obs["id"] - pred = osd.decode([int(x) for x in det_events]) + pred = decoder.decode([int(x) for x in det_events]) if pred != obs_mask: errors += 1 decode_time = time.perf_counter() - t0 diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c443778b2..400e23215 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2556,19 +2556,19 @@ fn create_observable_decoder( } Ok(Box::new(EnsembleDecoder::new(members))) } - // Per-observable subgraph decoder: requires stab_coords from Python. + // Per-logical-operator subgraph decoder: requires stab_coords from Python. // This is NOT callable from the string-based create_observable_decoder API. - // Use the Python ObservableSubgraphDecoder class directly instead. - s if s == "observable_subgraph" || s.starts_with("observable_subgraph:") => { + // Use the Python LogicalSubgraphDecoder class directly instead. + s if s == "logical_subgraph" || s.starts_with("logical_subgraph:") => { Err(PyErr::new::( - "observable_subgraph decoder requires stab_coords. \ - Use pecos_rslib.qec.ObservableSubgraphDecoder class directly.", + "logical_subgraph decoder requires stab_coords. \ + Use pecos_rslib.qec.LogicalSubgraphDecoder class directly.", )) } _ => Err(PyErr::new::(format!( "Unsupported decoder_type: {decoder_type}. \ Supported: pymatching, tesseract, mwpf, pecos_uf (or pecos_uf:fast/balanced/accurate), \ - observable_subgraph, ensemble:d1,d2,..., bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp." + logical_subgraph, ensemble:d1,d2,..., bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp." ))), } } @@ -4410,9 +4410,9 @@ impl PyCssUfDecoder { // Observable Subgraph Decoder (Python class) // ============================================================================= -/// Per-observable subgraph decoder for transversal gates. +/// Per-logical-operator subgraph decoder for transversal gates. /// -/// Partitions a DEM into per-observable graphlike subgraphs using +/// Partitions a DEM into per-logical-operator graphlike subgraphs using /// stabilizer coordinate information, then decodes each independently. /// /// Args: @@ -4422,19 +4422,19 @@ impl PyCssUfDecoder { /// `inner_decoder`: Inner decoder type string (default "`pecos_uf:fast`"). /// /// Example: -/// >>> decoder = `ObservableSubgraphDecoder`( +/// >>> decoder = `LogicalSubgraphDecoder`( /// ... `dem_str`, /// ... [{"X": [(1,0), (3,1)], "Z": [(0,3), (1,1)]}], /// ... "`pecos_uf:fast`", /// ... ) /// >>> obs = decoder.decode(syndrome) -#[pyclass(name = "ObservableSubgraphDecoder", module = "pecos_rslib.qec")] -pub struct PyObservableSubgraphDecoder { - inner: pecos_decoder_core::observable_subgraph::ObservableSubgraphDecoder, +#[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] +pub struct PyLogicalSubgraphDecoder { + inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, } #[pymethods] -impl PyObservableSubgraphDecoder { +impl PyLogicalSubgraphDecoder { #[new] #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:fast", max_time_radius=None))] fn new( @@ -4443,7 +4443,7 @@ impl PyObservableSubgraphDecoder { inner_decoder: &str, max_time_radius: Option, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; // Parse stab_coords from Python dicts let mut rust_stab_coords = Vec::with_capacity(stab_coords.len()); @@ -4462,7 +4462,7 @@ impl PyObservableSubgraphDecoder { }); } - let inner = ObservableSubgraphDecoder::from_dem_windowed( + let inner = LogicalSubgraphDecoder::from_dem_windowed( dem, &rust_stab_coords, max_time_radius, @@ -4551,7 +4551,7 @@ impl PyObservableSubgraphDecoder { num_workers: Option, max_time_radius: Option, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; use rayon::prelude::*; // Parse stab_coords @@ -4598,7 +4598,7 @@ impl PyObservableSubgraphDecoder { .par_chunks(chunk_size.max(1)) .map(|chunk| { // Build a fresh decoder for this worker - let mut dec = ObservableSubgraphDecoder::from_dem_windowed( + let mut dec = LogicalSubgraphDecoder::from_dem_windowed( &dem_str, &sc, max_time_radius, @@ -4655,7 +4655,7 @@ impl PyObservableSubgraphDecoder { stab_coords: Vec>, ) -> PyResult<(usize, usize)> { use pecos_decoder_core::ghost_protocol::extract_ghost_edges_from_dem; - use pecos_decoder_core::observable_subgraph::QubitStabCoords; + use pecos_decoder_core::logical_subgraph::QubitStabCoords; let mut sc = Vec::with_capacity(stab_coords.len()); for dict in &stab_coords { @@ -4705,12 +4705,12 @@ impl PyObservableSubgraphDecoder { } // ============================================================================= -// Windowed OSD Decoder (Python class) +// Windowed logical-subgraph decoding Decoder (Python class) // ============================================================================= /// Windowed observable subgraph decoder for deep circuits. /// -/// Splits the DEM into time windows, runs OSD within each window. +/// Splits the DEM into time windows, runs logical-subgraph decoder within each window. /// Prevents the observing region from spanning the full circuit. /// /// Args: @@ -4719,13 +4719,13 @@ impl PyObservableSubgraphDecoder { /// `inner_decoder`: Inner MWPM decoder type. /// step: Core window size in time steps. /// buffer: Buffer size on each side (0 = non-overlapping). -#[pyclass(name = "WindowedOsdDecoder", module = "pecos_rslib.qec")] -pub struct PyWindowedOsdDecoder { - inner: pecos_decoder_core::windowed_osd::WindowedOsdDecoder, +#[pyclass(name = "WindowedLogicalSubgraphDecoder", module = "pecos_rslib.qec")] +pub struct PyWindowedLogicalSubgraphDecoder { + inner: pecos_decoder_core::logical_subgraph::windowed::WindowedLogicalSubgraphDecoder, } #[pymethods] -impl PyWindowedOsdDecoder { +impl PyWindowedLogicalSubgraphDecoder { #[new] #[pyo3(signature = (dem, stab_coords, inner_decoder="pymatching", step=8, buffer=4))] fn new( @@ -4735,8 +4735,8 @@ impl PyWindowedOsdDecoder { step: usize, buffer: usize, ) -> PyResult { - use pecos_decoder_core::observable_subgraph::QubitStabCoords; - use pecos_decoder_core::windowed_osd::{WindowedOsdConfig, WindowedOsdDecoder}; + use pecos_decoder_core::logical_subgraph::QubitStabCoords; + use pecos_decoder_core::logical_subgraph::windowed::{WindowedLogicalSubgraphConfig, WindowedLogicalSubgraphDecoder}; let mut sc = Vec::with_capacity(stab_coords.len()); for dict in &stab_coords { @@ -4754,9 +4754,9 @@ impl PyWindowedOsdDecoder { }); } - let config = WindowedOsdConfig { step, buffer }; + let config = WindowedLogicalSubgraphConfig { step, buffer }; - let inner = WindowedOsdDecoder::from_dem(dem, &sc, &config, |subgraph| { + let inner = WindowedLogicalSubgraphDecoder::from_dem(dem, &sc, &config, |subgraph| { let sub_dem = subgraph_to_dem_string(subgraph); let d = create_observable_decoder(&sub_dem, inner_decoder) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; @@ -4801,7 +4801,7 @@ impl PyWindowedOsdDecoder { // Logical Algorithm Decoder (Python class) // ============================================================================= -/// Decoder for logical quantum algorithms with per-segment OSD and +/// Decoder for logical quantum algorithms with per-segment logical-subgraph decoder and /// Pauli frame propagation at transversal gate boundaries. /// /// Built from a descriptor dict produced by @@ -4820,7 +4820,7 @@ impl PyLogicalAlgorithmDecoder { /// /// Args: /// descriptor: Dict from ``LogicalCircuitBuilder.build_algorithm_descriptor()``. - /// `inner_decoder`: Decoder type string for each segment's OSD inner decoder. + /// `inner_decoder`: Decoder type string for each segment's logical-subgraph decoder inner decoder. #[new] #[pyo3(signature = (descriptor, inner_decoder="pymatching"))] fn new( @@ -4830,9 +4830,9 @@ impl PyLogicalAlgorithmDecoder { use pecos_decoder_core::logical_algorithm::{ AlgorithmDescriptor, BoundaryGate, LogicalAlgorithmDecoder, SegmentDescriptor, }; - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; - // Parse full DEM and stab_coords for full-circuit OSD + // Parse full DEM and stab_coords for full-circuit logical-subgraph decoder let full_dem: String = descriptor .get_item("full_dem")? .ok_or_else(|| PyErr::new::("full_dem"))? @@ -4874,8 +4874,8 @@ impl PyLogicalAlgorithmDecoder { let inner_str = inner_decoder.to_string(); - // Build full-circuit OSD from the full DEM - let full_osd = ObservableSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { + // Build full-circuit logical-subgraph decoder from the full DEM + let full_osd = LogicalSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { let sub_dem = subgraph_to_dem_string(subgraph); let d = create_observable_decoder(&sub_dem, &inner_str) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; @@ -5044,8 +5044,8 @@ impl PyLogicalAlgorithmDecoder { /// Budget-aware decoder for logical quantum circuits. /// /// Selects decode strategy based on available reaction time: -/// - ``"unlimited"``: full-circuit OSD (Clifford circuits, offline) -/// - ``"windowed"``: default windowed OSD (~1ms reaction time) +/// - ``"unlimited"``: full-circuit logical-subgraph decoder (Clifford circuits, offline) +/// - ``"windowed"``: default windowed logical-subgraph decoder (~1ms reaction time) /// - ``"10ms"``, ``"1000us"``, etc.: explicit reaction time budget /// /// The reaction time is the time available at feed-forward decision @@ -5076,7 +5076,7 @@ impl PyLogicalCircuitDecoder { AlgorithmDescriptor, BoundaryGate, FullCircuitStrategy, LogicalCircuitDecoder, SegmentDescriptor, }; - use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use pecos_decoder_core::logical_subgraph::{LogicalSubgraphDecoder, QubitStabCoords}; // Parse full DEM let full_dem: String = descriptor @@ -5118,7 +5118,7 @@ impl PyLogicalCircuitDecoder { let num_qubits = rust_sc.len(); let inner_str = inner_decoder.to_string(); - let full_osd = ObservableSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { + let full_osd = LogicalSubgraphDecoder::from_dem(&full_dem, &rust_sc, |subgraph| { let sub_dem = subgraph_to_dem_string(subgraph); let d = create_observable_decoder(&sub_dem, &inner_str) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; @@ -5236,12 +5236,12 @@ impl PyLogicalCircuitDecoder { // Select strategy based on budget. let strategy: Box = if decode_budget.is_unlimited() { - // Unlimited: full-circuit OSD (maximum accuracy) + // Unlimited: full-circuit logical-subgraph decoder (maximum accuracy) Box::new(FullCircuitStrategy::new(Box::new(full_osd))) } else { // Windowed: per-subgraph sandwich decoding. - // Extract per-subgraph DEMs and detector maps from the full OSD. - use pecos_decoder_core::logical_algorithm::WindowedOsdStrategy; + // Extract per-subgraph DEMs and detector maps from the full logical-subgraph decoder. + use pecos_decoder_core::logical_algorithm::WindowedLogicalSubgraphStrategy; let mut sub_dems = Vec::new(); let mut det_maps = Vec::new(); @@ -5262,7 +5262,7 @@ impl PyLogicalCircuitDecoder { format!("windowed:step={d},buf=0") }; - let wosd = WindowedOsdStrategy::new(sub_dems, det_maps, |dem_str| { + let wosd = WindowedLogicalSubgraphStrategy::new(sub_dems, det_maps, |dem_str| { let dec = create_observable_decoder(dem_str, &windowed_str) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; Ok(Box::new(SendWrapper(dec)) @@ -5561,8 +5561,8 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; - qec.add_class::()?; - qec.add_class::()?; + qec.add_class::()?; + qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; diff --git a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py index c5d10a619..c443f389c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py +++ b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py @@ -527,7 +527,7 @@ def stab_coords(self) -> list[dict[str, list[tuple[float, float]]]]: with keys "X" and "Z" mapping to ancilla (x, y) positions. These coordinates match the detector annotations in the Stim circuit. - Used as input to ``ObservableSubgraphDecoder``. + Used as input to ``LogicalSubgraphDecoder``. """ result = [] for ps in self._patches.values(): @@ -609,10 +609,10 @@ def build_sampler_and_decoder( """Build a DemSampler and OSD decoder without any string round-trip. Returns: - Tuple of (DemSampler, ObservableSubgraphDecoder, dem_str). + Tuple of (DemSampler, LogicalSubgraphDecoder, dem_str). dem_str is also returned for compatibility with existing code. """ - from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, ObservableSubgraphDecoder + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, LogicalSubgraphDecoder tc = self.to_tick_circuit() dc = tc.to_dag_circuit() @@ -642,7 +642,7 @@ def build_sampler_and_decoder( dem_str = str(dem) sc = self.stab_coords() - decoder = ObservableSubgraphDecoder(dem_str, sc, inner_decoder) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner_decoder) return sampler, decoder, dem_str @@ -860,7 +860,7 @@ def build_decoder( inner_decoder: str = "fusion_blossom_serial", use_stim_dem: bool = True, ) -> tuple[object, object]: - """Build an ObservableSubgraphDecoder for this circuit. + """Build an LogicalSubgraphDecoder for this circuit. Args: p1: Single-qubit depolarizing error rate. @@ -872,10 +872,10 @@ def build_decoder( mechanisms). If False, use PECOS-native DEM pipeline. Returns: - Tuple of (stim.Circuit, ObservableSubgraphDecoder). + Tuple of (stim.Circuit, LogicalSubgraphDecoder). """ import stim - from pecos_rslib.qec import ObservableSubgraphDecoder + from pecos_rslib.qec import LogicalSubgraphDecoder stim_str = self.to_stim(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) circuit = stim.Circuit(stim_str) @@ -887,7 +887,7 @@ def build_decoder( dem_str = self.build_dem(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) sc = self.stab_coords() - decoder = ObservableSubgraphDecoder(dem_str, sc, inner_decoder) + decoder = LogicalSubgraphDecoder(dem_str, sc, inner_decoder) return circuit, decoder diff --git a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py index 976829c83..b05fbce1d 100644 --- a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py +++ b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py @@ -594,9 +594,9 @@ def test_pecos_dem_decode_memory(self, patch): # At d=3 p=0.001, LER should be very low assert ler < 0.05, f"LER too high: {ler}" - def test_observable_subgraph_decoder(self, patch, nq): - """OSD with PECOS DEM on CX circuit.""" - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + def test_logical_subgraph_decoder(self, patch, nq): + """logical-subgraph decoder with PECOS DEM on CX circuit.""" + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -607,10 +607,10 @@ def test_observable_subgraph_decoder(self, patch, nq): dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pecos_uf:fast") - assert osd.num_observables() == 2 + decoder = LogicalSubgraphDecoder(dem_str, sc, "pecos_uf:fast") + assert decoder.num_observables() == 2 - sizes = osd.subgraph_sizes() + sizes = decoder.subgraph_sizes() for s in sizes: assert s > 0, "Empty subgraph" @@ -812,17 +812,17 @@ def test_noisy_random_composition(self, patch, nq, seed): # --------------------------------------------------------------------------- -# OSD accuracy comparison +# logical-subgraph decoder accuracy comparison # --------------------------------------------------------------------------- -class TestOSDAccuracy: +class TestLogicalSubgraphAccuracy: """Compare observable subgraph decoder accuracy against baseline.""" - def test_osd_better_than_naive_on_cx(self, patch, nq): - """OSD should outperform naive decomposed MWPM on CX circuits.""" + def test_logical_subgraph_better_than_naive_on_cx(self, patch, nq): + """logical-subgraph decoder should outperform naive decomposed MWPM on CX circuits.""" import stim - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -846,31 +846,31 @@ def test_osd_better_than_naive_on_cx(self, patch, nq): naive_errors = batch_naive.decode_count(str(dem_decomp), "pecos_uf:fast") naive_ler = naive_errors / 20000 - # OSD with FB + # logical-subgraph decoder with FB sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") - osd_errors = sum( + decoder = LogicalSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") + lsd_errors = sum( 1 for i in range(20000) - if osd.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) + if decoder.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) ) - osd_ler = osd_errors / 20000 + lsd_ler = lsd_errors / 20000 - # OSD should be at least as good (usually much better) - assert osd_ler <= naive_ler * 1.5 + 0.001, f"OSD ({osd_ler:.5f}) much worse than naive ({naive_ler:.5f})" + # logical-subgraph decoder should be at least as good (usually much better) + assert lsd_ler <= naive_ler * 1.5 + 0.001, f"logical-subgraph decoder ({lsd_ler:.5f}) much worse than naive ({naive_ler:.5f})" # --------------------------------------------------------------------------- -# PECOS-native DEM with OSD decoder on CX +# PECOS-native DEM with logical-subgraph decoder decoder on CX # --------------------------------------------------------------------------- -class TestPecosDemWithOSD: +class TestPecosDemWithLogicalSubgraph: """Test PECOS-native DEM pipeline with observable subgraph decoder.""" - def test_pecos_dem_osd_cx(self, patch, nq): - """PECOS DEM → OSD decoder on CX circuit.""" - from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + def test_pecos_dem_logical_subgraph_cx(self, patch, nq): + """PECOS DEM → logical-subgraph decoder decoder on CX circuit.""" + from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem b = LogicalCircuitBuilder() b.add_patch(patch, "C", qubit_offset=0) @@ -885,17 +885,17 @@ def test_pecos_dem_osd_cx(self, patch, nq): errors = [line for line in dem_str.split("\n") if line.startswith("error(")] assert len(errors) > 0 - # Build OSD decoder from PECOS DEM + # Build logical-subgraph decoder decoder from PECOS DEM sc = b.stab_coords() - osd = ObservableSubgraphDecoder(dem_str, sc, "pecos_uf:fast") - assert osd.num_observables() == 2 + decoder = LogicalSubgraphDecoder(dem_str, sc, "pecos_uf:fast") + assert decoder.num_observables() == 2 # Sample and decode parsed = ParsedDem.from_string(dem_str) batch = parsed.to_dem_sampler().generate_samples(5000, seed=42) errors = batch.decode_count(dem_str, "pecos_uf:fast") ler = errors / 5000 - assert ler < 0.1, f"PECOS DEM + OSD CX LER too high: {ler}" + assert ler < 0.1, f"PECOS DEM + logical-subgraph decoder CX LER too high: {ler}" # --------------------------------------------------------------------------- From a2e18fad01fc8a535686d67d64116be2c4030244 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 29 May 2026 23:20:10 -0600 Subject: [PATCH 007/388] Preserve runtime idle gates through QEC DEM generation. --- .../fault_tolerance/dem_builder/builder.rs | 50 +++++-- .../dem_builder/dem_sampler.rs | 12 +- .../dem_builder/mem_builder.rs | 3 +- .../fault_tolerance/dem_builder/sampler.rs | 4 +- .../src/fault_tolerance/dem_builder/types.rs | 66 ++++++++- .../src/fault_tolerance/influence_builder.rs | 6 +- .../src/fault_tolerance/lookup_decoder.rs | 6 +- .../src/fault_tolerance/propagator/dag.rs | 67 +++++++-- crates/pecos-qec/tests/idle_noise_tests.rs | 59 ++++++++ crates/pecos-qis/src/ccengine.rs | 77 +++++++++- .../src/fault_tolerance_bindings.rs | 131 +++++++++++++----- python/quantum-pecos/src/pecos/qec/dem.py | 16 +++ .../src/pecos/qec/surface/circuit_builder.py | 17 ++- .../src/pecos/qec/surface/decode.py | 96 ++++++++++++- .../tests/qec/test_from_guppy_dem.py | 70 ++++++++++ 15 files changed, 593 insertions(+), 87 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index b6ce524d5..d6dd9a5e4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -163,7 +163,23 @@ impl<'a> DemBuilder<'a> { p_meas: f64, p_prep: f64, ) -> Result { - build_dem_from_circuit(circuit, p1, p2, p_meas, p_prep) + build_dem_from_circuit(circuit, NoiseConfig::new(p1, p2, p_meas, p_prep)) + } + + /// Try to build a `DetectorErrorModel` directly from a `DagCircuit` and + /// full noise configuration. + /// + /// Reads detector/DEM output definitions from circuit metadata and returns + /// parser errors instead of dropping malformed metadata. + /// + /// # Errors + /// + /// Returns an error if detector or observable metadata is malformed. + pub fn try_from_circuit_with_noise_config( + circuit: &pecos_quantum::DagCircuit, + noise: NoiseConfig, + ) -> Result { + build_dem_from_circuit(circuit, noise) } /// Build a `DetectorErrorModel` from a `TickCircuit` and noise. @@ -202,7 +218,24 @@ impl<'a> DemBuilder<'a> { p_prep: f64, ) -> Result { let dag = pecos_quantum::DagCircuit::from(circuit); - build_dem_from_circuit(&dag, p1, p2, p_meas, p_prep) + build_dem_from_circuit(&dag, NoiseConfig::new(p1, p2, p_meas, p_prep)) + } + + /// Try to build a `DetectorErrorModel` from a `TickCircuit` and full noise + /// configuration. + /// + /// Converts to `DagCircuit` internally and returns parser errors instead + /// of dropping malformed metadata. + /// + /// # Errors + /// + /// Returns an error if detector or observable metadata is malformed. + pub fn try_from_tick_circuit_with_noise_config( + circuit: &pecos_quantum::TickCircuit, + noise: NoiseConfig, + ) -> Result { + let dag = pecos_quantum::DagCircuit::from(circuit); + build_dem_from_circuit(&dag, noise) } /// Creates a new DEM builder from a fault influence map. @@ -306,8 +339,7 @@ impl<'a> DemBuilder<'a> { return rates; } if pg.base.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = pg.base.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -315,8 +347,7 @@ impl<'a> DemBuilder<'a> { } if self.noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = self.noise.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -1757,10 +1788,7 @@ fn extract_measurement_refs( /// Reads detector/DEM output definitions from circuit metadata attributes. fn build_dem_from_circuit( circuit: &pecos_quantum::DagCircuit, - p1: f64, - p2: f64, - p_meas: f64, - p_prep: f64, + noise: NoiseConfig, ) -> Result { use crate::fault_tolerance::influence_builder::InfluenceBuilder; use crate::fault_tolerance::propagator::DagFaultAnalyzer; @@ -1796,7 +1824,7 @@ fn build_dem_from_circuit( } }); - let builder = DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep); + let builder = DemBuilder::new(&influence_map).with_noise_config(noise); let builder = if let Some(ref dj) = det_json { builder.with_detectors_json(dj)? diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index 72c102c43..4b11d4559 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -423,10 +423,8 @@ impl SamplingEngine { .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(1, |l| l.idle_duration.max(1)); - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - Some(noise.idle_pauli_probs(duration as f64)) + .map_or(0.0, |l| l.idle_duration.max(0.0)); + Some(noise.idle_pauli_probs(duration)) } else { None }; @@ -2298,8 +2296,7 @@ impl<'a> SamplingEngineBuilder<'a> { return rates; } if pg.base.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = pg.base.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } @@ -2309,8 +2306,7 @@ impl<'a> SamplingEngineBuilder<'a> { if let Some(noise) = &self.idle_noise && noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = noise.idle_pauli_probs(duration); return [probs.px, probs.py, probs.pz]; } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs index 85176ec4c..545f709a0 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/mem_builder.rs @@ -128,8 +128,7 @@ impl<'a> MemBuilder<'a> { } GateType::Idle if !loc.before => { if self.noise.uses_dedicated_idle_noise() { - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); let probs = self.noise.idle_pauli_probs(duration); if probs.px > 0.0 { self.process_single_pauli_fault(loc_idx, Pauli::X, probs.px, &mut mem); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 53d29c1c5..f0e1516b7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1451,9 +1451,7 @@ pub(crate) fn compute_location_probs_from_noise( | GateType::RZZ => noise.p2, GateType::Idle => { if noise.uses_dedicated_idle_noise() { - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - let duration = loc.idle_duration.max(1) as f64; + let duration = loc.idle_duration.max(0.0); noise.idle_pauli_probs(duration).total() } else { 0.0 diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 3deda7745..48a695879 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1541,6 +1541,18 @@ pub struct NoiseConfig { /// /// This is the EEG H-type noise model for idle gates. Default is 0.0. pub idle_rz: f64, + /// Stochastic Z-memory error rate linear in idle duration. + /// + /// This mirrors PECOS engine idle memory noise in a DEM-compatible Pauli + /// channel: each explicit `Idle(duration, q)` contributes an independent + /// Z fault with probability `p_idle_linear_rate * duration`. + pub p_idle_linear_rate: f64, + /// Stochastic Z-memory error rate for the quadratic idle term. + /// + /// This follows the incoherent PECOS engine convention for quadratic idle + /// dephasing: each explicit `Idle(duration, q)` contributes an independent + /// Z fault with probability `sin(p_idle_quadratic_rate * duration)^2`. + pub p_idle_quadratic_rate: f64, } /// Per-Pauli error probabilities for a single qubit. @@ -1606,6 +1618,8 @@ impl Default for NoiseConfig { p1_weights: None, p2_weights: None, idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, } } } @@ -1625,6 +1639,8 @@ impl NoiseConfig { p1_weights: None, p2_weights: None, idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, } } @@ -1642,6 +1658,8 @@ impl NoiseConfig { p1_weights: None, p2_weights: None, idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, } } @@ -1659,6 +1677,8 @@ impl NoiseConfig { p1_weights: None, p2_weights: None, idle_rz: 0.0, + p_idle_linear_rate: 0.0, + p_idle_quadratic_rate: 0.0, } } @@ -1669,6 +1689,20 @@ impl NoiseConfig { self } + /// Sets the linear stochastic Z-memory rate for explicit idle gates. + #[must_use] + pub fn set_idle_linear_rate(mut self, rate: f64) -> Self { + self.p_idle_linear_rate = rate.max(0.0); + self + } + + /// Sets the quadratic stochastic Z-memory rate for explicit idle gates. + #[must_use] + pub fn set_idle_quadratic_rate(mut self, rate: f64) -> Self { + self.p_idle_quadratic_rate = rate; + self + } + /// Sets T1/T2 relaxation times for idle noise. /// /// When set, idle gates use the Pauli-twirled T1/T2 model instead of @@ -1747,17 +1781,40 @@ impl NoiseConfig { self } + fn compose_z_fault(probs: PauliProbs, z_probability: f64) -> PauliProbs { + let z_probability = z_probability.clamp(0.0, 1.0); + if z_probability <= f64::EPSILON { + return probs; + } + + let p_identity = (1.0 - probs.total()).max(0.0); + PauliProbs { + px: probs.px * (1.0 - z_probability) + probs.py * z_probability, + py: probs.py * (1.0 - z_probability) + probs.px * z_probability, + pz: probs.pz * (1.0 - z_probability) + p_identity * z_probability, + } + } + + fn idle_memory_z_probability(&self, duration: f64) -> f64 { + let duration = duration.max(0.0); + let linear = (self.p_idle_linear_rate * duration).clamp(0.0, 1.0); + let quadratic = (self.p_idle_quadratic_rate * duration).sin().powi(2); + + linear + quadratic - 2.0 * linear * quadratic + } + /// Compute per-Pauli idle noise probabilities for a given duration. /// /// If T1/T2 are set, uses the Pauli-twirled model (biased noise). /// Otherwise, uses uniform depolarizing with `p_idle * duration`. #[must_use] pub fn idle_pauli_probs(&self, duration: f64) -> PauliProbs { - if let (Some(t1), Some(t2)) = (self.t1, self.t2) { + let probs = if let (Some(t1), Some(t2)) = (self.t1, self.t2) { PauliProbs::from_t1_t2(duration, t1, t2) } else { PauliProbs::depolarizing((self.p_idle * duration).min(1.0)) - } + }; + Self::compose_z_fault(probs, self.idle_memory_z_probability(duration)) } /// Returns true when idle locations use the dedicated idle-noise model. @@ -1765,7 +1822,10 @@ impl NoiseConfig { /// Otherwise `Idle` is a no-op for noise. #[must_use] pub fn uses_dedicated_idle_noise(&self) -> bool { - self.p_idle > 0.0 || matches!((self.t1, self.t2), (Some(_), Some(_))) + self.p_idle > 0.0 + || matches!((self.t1, self.t2), (Some(_), Some(_))) + || self.p_idle_linear_rate > 0.0 + || self.p_idle_quadratic_rate.abs() > f64::EPSILON } } diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index aa77d8e71..6820ca72c 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -498,16 +498,12 @@ impl<'a> InfluenceBuilder<'a> { // Measurement: before. All others: after. let before = is_measurement; for &q in &qubits { - // idle_duration() returns a non-negative integer stored as f64; - // truncation and sign loss are not a concern. - #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] - let idle_duration = gate.idle_duration() as u64; locations.push(DagSpacetimeLocation { node, qubits: vec![q], before, gate_type: gate.gate_type, - idle_duration, + idle_duration: gate.idle_duration(), }); } } diff --git a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs index 50d828465..e6bc7eba3 100644 --- a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs +++ b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs @@ -102,10 +102,8 @@ impl LookupDecoder { .locations .iter() .find(|l| l.node == loc.node && l.before == loc.before) - .map_or(1, |l| l.idle_duration.max(1)); - // Duration values are small integers; precision loss is not a concern. - #[allow(clippy::cast_precision_loss)] - Some(noise.idle_pauli_probs(duration as f64)) + .map_or(0.0, |l| l.idle_duration.max(0.0)); + Some(noise.idle_pauli_probs(duration)) } else { None }; diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 99d164f04..74ab1c3d9 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -79,7 +79,9 @@ use pecos_core::{PauliString, QuarterPhase, QubitId}; use pecos_quantum::DagCircuit; use pecos_simulators::PauliProp; use smallvec::SmallVec; +use std::cmp::Ordering; use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; +use std::hash::{Hash, Hasher}; /// Reusable work buffers for propagation, avoiding per-call allocation. pub struct PropagationBuffers { @@ -113,6 +115,8 @@ pub struct FaultLocations { pub before: Vec, /// Gate type at each location. pub gate_types: Vec, + /// Idle duration at each location. 0.0 for non-idle gates. + pub idle_durations: Vec, /// Reverse index: node -> list of location IDs at that node. pub node_to_locations: Vec>, } @@ -132,6 +136,7 @@ impl FaultLocations { qubits: Vec::with_capacity(num_locations), before: Vec::with_capacity(num_locations), gate_types: Vec::with_capacity(num_locations), + idle_durations: Vec::with_capacity(num_locations), node_to_locations: vec![SmallVec::new(); max_node + 1], } } @@ -157,12 +162,14 @@ impl FaultLocations { qubits: SmallVec<[usize; 2]>, before: bool, gate_type: GateType, + idle_duration: f64, ) -> usize { let loc_id = self.nodes.len(); self.nodes.push(node); self.qubits.push(qubits); self.before.push(before); self.gate_types.push(gate_type); + self.idle_durations.push(idle_duration); // Update reverse index if node < self.node_to_locations.len() { @@ -206,7 +213,7 @@ impl FaultLocations { qubits: self.qubits[i].iter().map(|&q| QubitId::from(q)).collect(), before: self.before[i], gate_type: self.gate_types[i], - idle_duration: 0, + idle_duration: self.idle_durations[i], }) .collect() } @@ -220,7 +227,7 @@ impl FaultLocations { /// /// Unlike `SpacetimeLocation` which uses tick indices, this uses DAG node indices /// for more efficient sparse propagation. -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone)] pub struct DagSpacetimeLocation { /// The node index in the DAG. pub node: usize, @@ -230,8 +237,47 @@ pub struct DagSpacetimeLocation { pub before: bool, /// The type of gate at this location. pub gate_type: GateType, - /// Duration for idle gates (in abstract time units). 0 for non-idle gates. - pub idle_duration: u64, + /// Duration for idle gates. 0.0 for non-idle gates. + pub idle_duration: f64, +} + +impl PartialEq for DagSpacetimeLocation { + fn eq(&self, other: &Self) -> bool { + self.node == other.node + && self.qubits == other.qubits + && self.before == other.before + && self.gate_type == other.gate_type + && self.idle_duration.to_bits() == other.idle_duration.to_bits() + } +} + +impl Eq for DagSpacetimeLocation {} + +impl PartialOrd for DagSpacetimeLocation { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for DagSpacetimeLocation { + fn cmp(&self, other: &Self) -> Ordering { + self.node + .cmp(&other.node) + .then_with(|| self.qubits.cmp(&other.qubits)) + .then_with(|| self.before.cmp(&other.before)) + .then_with(|| self.gate_type.cmp(&other.gate_type)) + .then_with(|| self.idle_duration.total_cmp(&other.idle_duration)) + } +} + +impl Hash for DagSpacetimeLocation { + fn hash(&self, state: &mut H) { + self.node.hash(state); + self.qubits.hash(state); + self.before.hash(state); + self.gate_type.hash(state); + self.idle_duration.to_bits().hash(state); + } } // ============================================================================ @@ -1785,9 +1831,14 @@ impl<'a> DagFaultAnalyzer<'a> { // Idle gates on non-active qubits provide the missing "before" // coverage that would otherwise require before-gate locations. let before = is_measurement; + let idle_duration = if gate.gate_type == GateType::Idle { + gate.idle_duration() + } else { + 0.0 + }; for &q in &qubits { let single_qubit: SmallVec<[usize; 2]> = smallvec::smallvec![q]; - locations.push(node, single_qubit, before, gate.gate_type); + locations.push(node, single_qubit, before, gate.gate_type, idle_duration); } } } @@ -2645,14 +2696,14 @@ mod tests { qubits: vec![QubitId::from(0)], before: true, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }; let loc2 = DagSpacetimeLocation { node: 1, qubits: vec![QubitId::from(0)], before: true, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }; assert!(loc1 < loc2); } @@ -2764,7 +2815,7 @@ mod tests { qubits: vec![QubitId(0)], before: false, gate_type: GateType::H, - idle_duration: 0, + idle_duration: 0.0, }); map.dem_output_metadata = vec![ DemOutputMetadata::tracked_pauli(pecos_core::PauliString::xs(&[0])), diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 6eebe49d0..dfb7fa2a3 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -37,6 +37,16 @@ fn build_idle_then_measure(num_idles: usize) -> DagCircuit { dag } +fn build_nanosecond_idle_x_basis_measure() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); + dag.idle(TimeUnits::new(20), &[0]); + dag.h(&[0]); + dag.mz(&[0]); + dag +} + #[test] fn idle_locations_contribute_mechanisms_when_rates_set() { let dag = build_idle_then_measure(2); @@ -179,6 +189,55 @@ fn explicit_uniform_idle_noise_is_noisy() { ); } +#[test] +fn nanosecond_timeunit_idle_duration_is_preserved_in_fault_locations() { + let dag = build_nanosecond_idle_x_basis_measure(); + let influence = DagFaultAnalyzer::new(&dag).build_influence_map(); + + let idle = influence + .locations + .iter() + .find(|loc| loc.gate_type == GateType::Idle) + .expect("idle location"); + + assert!((idle.idle_duration - 20.0).abs() < f64::EPSILON); +} + +#[test] +fn linear_memory_z_noise_uses_idle_duration_in_dem() { + let dag = build_nanosecond_idle_x_basis_measure(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let dem = DemBuilder::new(&influence) + .with_noise_config(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle_linear_rate(1.0e-3)) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + assert!( + dem.num_contributions() > 0, + "linear Z-memory noise on an idle should produce DEM contributions", + ); +} + +#[test] +fn idle_memory_z_probabilities_match_linear_and_quadratic_model() { + let linear = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_linear_rate(1.0e-3) + .idle_pauli_probs(20.0); + assert_eq!(linear.px, 0.0); + assert_eq!(linear.py, 0.0); + assert!((linear.pz - 0.02).abs() < 1e-15); + + let quadratic = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_quadratic_rate(0.5) + .idle_pauli_probs(2.0); + assert_eq!(quadratic.px, 0.0); + assert_eq!(quadratic.py, 0.0); + assert!((quadratic.pz - 1.0_f64.sin().powi(2)).abs() < 1e-15); +} + #[test] fn dem_builder_scalar_p1_does_not_attach_to_idle() { let dag = build_idle_then_measure(1); diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index c1c88bc13..f448c9080 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -1597,6 +1597,7 @@ mod tests { let ops = vec![ Operation::AllocateQubit { id: 0 }, QuantumOp::H(0).into(), + QuantumOp::Idle(20e-9, 0).into(), QuantumOp::Measure(0, 7).into(), ]; let commands = engine @@ -1619,17 +1620,89 @@ mod tests { assert_eq!(value["shot_index"], 1); assert_eq!(value["waiting_for_result_id"], 7); assert_eq!(value["current_shot_seed"], 123); - assert_eq!(value["num_operations"], 3); + assert_eq!(value["num_operations"], 4); assert_eq!(value["operations"][0]["AllocateQubit"]["id"], 0); assert_eq!(value["operations"][1]["Quantum"]["H"], 0); + assert_eq!(value["operations"][2]["Quantum"]["Idle"][0], 20e-9); assert_eq!(value["lowered_quantum_ops"][0]["gate_type"], "PZ"); assert_eq!(value["lowered_quantum_ops"][1]["gate_type"], "H"); - assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "MZ"); + assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "Idle"); + assert_eq!(value["lowered_quantum_ops"][2]["params"][0], 20e-9); + assert_eq!(value["lowered_quantum_ops"][3]["gate_type"], "MZ"); let in_memory = collector.lock().expect("collector lock"); assert_eq!(in_memory.len(), 1); assert_eq!(in_memory[0].stage, "unit_test"); assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "PZ"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "Idle"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].params, vec![20e-9]); + } + + #[derive(Clone, Default)] + struct IdleLoweringRuntime { + state: ClassicalState, + } + + impl QisRuntime for IdleLoweringRuntime { + fn load_interface(&mut self, _interface: OperationList) -> RuntimeResult<()> { + Ok(()) + } + + fn execute_until_quantum(&mut self) -> RuntimeResult>> { + Ok(None) + } + + fn provide_measurements( + &mut self, + _measurements: BTreeMap, + ) -> RuntimeResult<()> { + Ok(()) + } + + fn get_classical_state(&self) -> &ClassicalState { + &self.state + } + + fn get_classical_state_mut(&mut self) -> &mut ClassicalState { + &mut self.state + } + + fn is_complete(&self) -> bool { + true + } + + fn num_qubits(&self) -> usize { + 1 + } + + fn supports_operation_lowering(&self) -> bool { + true + } + + fn lower_operations(&mut self, _operations: &[Operation]) -> RuntimeResult> { + Ok(vec![QuantumOp::Idle(20e-9, 0), QuantumOp::H(0)]) + } + } + + #[test] + fn test_operation_trace_chunk_includes_runtime_lowered_idles() { + let mut engine = QisEngine::with_runtime(Box::new(IdleLoweringRuntime::default())); + let collector: OperationTraceStore = Arc::new(Mutex::new(Vec::new())); + engine.set_operation_trace_collector(collector.clone()); + engine.begin_trace_shot(); + + let ops = vec![QuantumOp::H(0).into()]; + let commands = engine + .lower_operations_to_bytemessage(&ops) + .expect("runtime lower ops to bytemessage"); + engine.trace_operations_chunk("unit_test", &ops, None, Some(&commands)); + + let in_memory = collector.lock().expect("collector lock"); + assert_eq!(in_memory.len(), 1); + assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "Idle"); + assert_eq!(in_memory[0].lowered_quantum_ops[0].params, vec![20e-9]); + assert_eq!(in_memory[0].lowered_quantum_ops[0].qubits, vec![0]); + assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "H"); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c443778b2..049e6ce91 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -70,6 +70,31 @@ use pyo3::prelude::*; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); +fn apply_idle_noise_options( + mut noise: NoiseConfig, + p_idle: Option, + t1: Option, + t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, +) -> NoiseConfig { + noise.p_idle = p_idle.unwrap_or(0.0); + if let (Some(t1_val), Some(t2_val)) = (t1, t2) { + noise = noise.set_t1_t2(t1_val, t2_val); + } + if let Some(rz) = idle_rz { + noise = noise.set_idle_rz(rz); + } + if let Some(rate) = p_idle_linear_rate { + noise = noise.set_idle_linear_rate(rate); + } + if let Some(rate) = p_idle_quadratic_rate { + noise = noise.set_idle_quadratic_rate(rate); + } + noise +} + // Adapter for decoder factories that require `Send + Sync` trait objects. // Decoder implementations own their state; Python access remains GIL-mediated. struct SendWrapper(Box); @@ -952,26 +977,42 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, p1: f64, p2: f64, p_meas: f64, p_prep: f64, + p_idle: Option, + t1: Option, + t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; + let noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); if let Ok(dag) = circuit.extract::>() { - let inner = DemBuilder::try_from_circuit(&dag.inner, p1, p2, p_meas, p_prep) + let inner = DemBuilder::try_from_circuit_with_noise_config(&dag.inner, noise) .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; Ok(Self { inner }) } else if let Ok(tc) = circuit.extract::>() { - let inner = DemBuilder::try_from_tick_circuit(&tc.inner, p1, p2, p_meas, p_prep) + let inner = DemBuilder::try_from_tick_circuit_with_noise_config(&tc.inner, noise) .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; Ok(Self { inner }) } else { @@ -1270,7 +1311,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1282,16 +1323,18 @@ impl PyDemBuilder { t1: Option, t2: Option, idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyRefMut<'_, Self> { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let (Some(t1_val), Some(t2_val)) = (t1, t2) { - noise = noise.set_t1_t2(t1_val, t2_val); - } - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } - slf.noise = noise; + slf.noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); slf } @@ -3135,7 +3178,8 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, idle_rz=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, p1: f64, @@ -3143,13 +3187,21 @@ impl PyDemSampler { p_meas: f64, p_prep: f64, p_idle: Option, + t1: Option, + t2: Option, idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyResult { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } + let noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); // Accept both DagCircuit and TickCircuit if let Ok(dag) = @@ -3259,7 +3311,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3272,12 +3324,19 @@ impl PyDemSampler { p_idle: Option, t1: Option, t2: Option, + idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyResult { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let (Some(t1_val), Some(t2_val)) = (t1, t2) { - noise = noise.set_t1_t2(t1_val, t2_val); - } + let noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) .with_detectors(detectors, observables) @@ -3730,7 +3789,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -3742,16 +3801,18 @@ impl PyDemSamplerBuilder { t1: Option, t2: Option, idle_rz: Option, + p_idle_linear_rate: Option, + p_idle_quadratic_rate: Option, ) -> PyRefMut<'_, Self> { - let mut noise = NoiseConfig::new(p1, p2, p_meas, p_prep); - noise.p_idle = p_idle.unwrap_or(0.0); - if let (Some(t1_val), Some(t2_val)) = (t1, t2) { - noise = noise.set_t1_t2(t1_val, t2_val); - } - if let Some(rz) = idle_rz { - noise = noise.set_idle_rz(rz); - } - slf.noise = noise; + slf.noise = apply_idle_noise_options( + NoiseConfig::new(p1, p2, p_meas, p_prep), + p_idle, + t1, + t2, + idle_rz, + p_idle_linear_rate, + p_idle_quadratic_rate, + ); slf } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 1adf274c2..febc93e89 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -72,6 +72,11 @@ def from_guppy( p2: float = 0.01, p_meas: float = 0.001, p_prep: float = 0.001, + p_idle: float | None = None, + t1: float | None = None, + t2: float | None = None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, seed: int = 0, ) -> _RustDetectorErrorModel: """Build a circuit-level DEM from a Guppy program by tracing it. @@ -142,6 +147,12 @@ def from_guppy( p2: Two-qubit gate depolarizing rate. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. + p_idle: Optional uniform depolarizing idle-noise rate per idle duration. + t1: Optional T1 relaxation time for explicit idle gates. + t2: Optional T2 dephasing time for explicit idle gates. + p_idle_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_quadratic_rate: Optional stochastic Z-memory rate using + ``sin(rate * duration) ** 2``. seed: Seed for the ideal trace run. Returns: @@ -241,6 +252,11 @@ def from_guppy( p2=p2, p_meas=p_meas, p_prep=p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b50b51718..5690f528a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2142,6 +2142,8 @@ def generate_dem_from_tick_circuit( p_idle: float | None = None, t1: float | None = None, t2: float | None = None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -2173,6 +2175,9 @@ def generate_dem_from_tick_circuit( The caller is responsible for inserting idle gates where needed. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. + p_idle_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_quadratic_rate: Optional stochastic Z-memory rate using + ``sin(rate * duration) ** 2``. decompose_errors: If True (default), decompose hyperedge errors into graphlike components using the `^` separator. Set to False to output raw hyperedges. Ignored if maximal_decomposition=True. @@ -2207,7 +2212,17 @@ def generate_dem_from_tick_circuit( # Build DEM using Rust DemBuilder builder = DemBuilder(influence_map) - builder.with_noise(p1, p2, p_meas, p_prep, p_idle=p_idle, t1=t1, t2=t2) + builder.with_noise( + p1, + p2, + p_meas, + p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + ) builder.with_num_measurements(num_measurements) builder.with_measurement_order(measurement_order) builder.with_detectors_json(detectors_json) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e65994390..3864b8adc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -92,6 +92,9 @@ class NoiseModel: p_idle: Idle noise rate per time unit (uniform depolarizing). t1: T1 relaxation time for idle noise (same units as idle duration). t2: T2 dephasing time (must satisfy t2 <= 2*t1). + p_idle_linear_rate: Stochastic Z-memory rate linear in idle duration. + p_idle_quadratic_rate: Stochastic Z-memory rate using + ``sin(rate * duration) ** 2``. """ p1: float = 0.0 @@ -101,6 +104,8 @@ class NoiseModel: p_idle: float | None = None t1: float | None = None t2: float | None = None + p_idle_linear_rate: float | None = None + p_idle_quadratic_rate: float | None = None @staticmethod def uniform(physical_error_rate: float) -> NoiseModel: @@ -117,6 +122,8 @@ def is_noiseless(self) -> bool: and self.p_meas == 0.0 and self.p_prep == 0.0 and (self.p_idle is None or self.p_idle == 0.0) + and (self.p_idle_linear_rate is None or self.p_idle_linear_rate == 0.0) + and (self.p_idle_quadratic_rate is None or self.p_idle_quadratic_rate == 0.0) ) @property @@ -125,6 +132,10 @@ def physical_error_rate(self) -> float: rates = [self.p1, self.p2, self.p_meas, self.p_prep] if self.p_idle is not None: rates.append(self.p_idle) + if self.p_idle_linear_rate is not None: + rates.append(self.p_idle_linear_rate) + if self.p_idle_quadratic_rate is not None: + rates.append(self.p_idle_quadratic_rate) return max(rates) @@ -406,6 +417,22 @@ def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: target_tc.set_meta(key, value) +def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: + """Convert runtime idle seconds into PECOS nanosecond time units.""" + import math + + from pecos_rslib import TimeUnits + + if not math.isfinite(duration_seconds) or duration_seconds < 0.0: + msg = f"Idle duration must be finite and non-negative, got {duration_seconds!r}" + raise ValueError(msg) + + units = round(duration_seconds * 1_000_000_000.0) + if duration_seconds > 0.0: + units = max(1, units) + return TimeUnits(units) + + def _replay_qis_trace_into_tick_circuit(operations: list[dict[str, Any]]) -> Any: """Replay traced QIS operations into a PECOS TickCircuit.""" import heapq @@ -502,6 +529,12 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: elif op_name == "RXY": theta, phi, program_id = tuple_args(payload, op_name, 3) tick.r1xy(float(theta), float(phi), [mapped_slot(int(program_id), op_name)]) + elif op_name == "Idle": + duration, program_id = tuple_args(payload, op_name, 2) + tick.idle( + _runtime_idle_seconds_to_time_units(float(duration)), + [mapped_slot(int(program_id), op_name)], + ) elif op_name == "CX": control, target = tuple_args(payload, op_name, 2) tick.cx([(mapped_slot(int(control), op_name), mapped_slot(int(target), op_name))]) @@ -614,6 +647,7 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> gate_type = str(gate["gate_type"]) qubits = [int(q) for q in gate.get("qubits", [])] angles = [float(theta) for theta in gate.get("angles", [])] + params = [float(param) for param in gate.get("params", [])] tick = tick_circuit.tick() if gate_type == "H": @@ -634,6 +668,11 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick.tdg(qubits) elif gate_type == "PZ": tick.pz(qubits) + elif gate_type == "Idle": + if len(params) != 1: + msg = f"Lowered Idle gate expected one duration param, got {params!r}" + raise ValueError(msg) + tick.idle(_runtime_idle_seconds_to_time_units(params[0]), qubits) elif gate_type == "MZ": end = meas_cursor + len(qubits) if end > len(meas_ids_in_order): @@ -977,14 +1016,27 @@ def _uses_dedicated_idle_noise( p_idle: float | None, t1: float | None, t2: float | None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, ) -> bool: """Return True when noise parameters require explicit idle locations.""" - return (p_idle is not None and p_idle > 0.0) or (t1 is not None and t2 is not None) + return ( + (p_idle is not None and p_idle > 0.0) + or (t1 is not None and t2 is not None) + or (p_idle_linear_rate is not None and p_idle_linear_rate > 0.0) + or (p_idle_quadratic_rate is not None and p_idle_quadratic_rate != 0.0) + ) def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: """Return True when this noise model requires explicit idle locations.""" - return _uses_dedicated_idle_noise(p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2) + return _uses_dedicated_idle_noise( + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, + p_idle_linear_rate=noise.p_idle_linear_rate, + p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + ) @cache @@ -1047,7 +1099,17 @@ def _dem_string_from_cached_surface_topology( dem = ( DemBuilder(topology.influence_map) - .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2) + .with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, + p_idle_linear_rate=noise.p_idle_linear_rate, + p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + ) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) .with_detectors_json(topology.detectors_json) @@ -1072,9 +1134,17 @@ def _cached_surface_native_dem_string( p_idle: float | None = None, t1: float | None = None, t2: float | None = None, + p_idle_linear_rate: float | None = None, + p_idle_quadratic_rate: float | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" - include_idle_gates = _uses_dedicated_idle_noise(p_idle=p_idle, t1=t1, t2=t2) + include_idle_gates = _uses_dedicated_idle_noise( + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + ) topology = _cached_surface_native_topology( patch_key, num_rounds, @@ -1085,7 +1155,17 @@ def _cached_surface_native_dem_string( ) return _dem_string_from_cached_surface_topology( topology, - NoiseModel(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, t1=t1, t2=t2), + NoiseModel( + p1=p1, + p2=p2, + p_meas=p_meas, + p_prep=p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + ), decompose_errors=decompose_errors, ) @@ -1134,6 +1214,8 @@ def _build_native_sampler_from_cached_surface_topology( p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, + p_idle_linear_rate=noise.p_idle_linear_rate, + p_idle_quadratic_rate=noise.p_idle_quadratic_rate, ) # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" @@ -1215,6 +1297,8 @@ def generate_circuit_level_dem_from_builder( p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, + p_idle_linear_rate=noise.p_idle_linear_rate, + p_idle_quadratic_rate=noise.p_idle_quadratic_rate, ) @@ -2881,6 +2965,8 @@ def build_native_sampler( p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, + p_idle_linear_rate=noise.p_idle_linear_rate, + p_idle_quadratic_rate=noise.p_idle_quadratic_rate, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 5d493b5fc..7c58aaaa7 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -63,6 +63,16 @@ def _flat_mz_ids(tc) -> list[int]: return ids +def _flat_idle_gates(tc) -> list[tuple[list[int], float]]: + dag = tc.to_dag_circuit() + idles: list[tuple[list[int], float]] = [] + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate is not None and gate.gate_type.name == "Idle": + idles.append((list(gate.qubits), float(gate.params[0]))) + return idles + + def test_from_guppy_meas_ids_are_normalized_to_records() -> None: assert _dem_text(detectors_json='[{"id":0,"meas_ids":[0]}]') == _dem_text( detectors_json='[{"id":0,"records":[-1]}]', @@ -149,6 +159,54 @@ def test_lowered_replay_fails_on_measurement_count_mismatch() -> None: _replay_lowered_qis_trace_into_tick_circuit(chunks) +def test_lowered_replay_preserves_runtime_idles() -> None: + chunks = [ + { + "operations": [{"Quantum": {"H": 0}}], + "lowered_quantum_ops": [ + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [20e-9]}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert _flat_idle_gates(tc) == [([0], 20.0)] + + +def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [20e-9]}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": []}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_linear_rate=1.0e-3, + ) + + assert dem.num_contributions > 0 + + def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: """A trace where every quantum-carrying chunk is also lowered is accepted (this is the real Selene shape; the byte-identical regressions exercise it @@ -217,6 +275,18 @@ def test_non_lowered_replay_preserves_non_sequential_result_ids() -> None: assert _flat_mz_ids(tc) == [77, 3] +def test_non_lowered_replay_preserves_idle_ops() -> None: + operations = [ + {"AllocateQubit": {"id": 10}}, + {"Quantum": {"Idle": [20e-9, 10]}}, + {"Quantum": {"H": 10}}, + ] + + tc = _replay_qis_trace_into_tick_circuit(operations) + + assert _flat_idle_gates(tc) == [([0], 20.0)] + + def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: """Regression: from_guppy(make_surface_code(...)) must work and match the traced_qis reference DEM. A reverted dynamic-control guard had broken this From c2cbfe18fc4434252d97fd5ed154ec347a392037 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 30 May 2026 10:00:59 -0600 Subject: [PATCH 008/388] Allow traced Guppy DEMs to use custom Selene runtimes. --- docs/development/from-guppy-dem-handoff.md | 8 ++ python/quantum-pecos/src/pecos/qec/dem.py | 6 +- .../src/pecos/qec/surface/decode.py | 75 +++++++++++++++++-- 3 files changed, 83 insertions(+), 6 deletions(-) diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index 6d3b076c8..5dff0bfa5 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -105,6 +105,14 @@ line. - Prefer the generic `from_guppy(...)` abstraction for future DEM construction rather than adding more surface-specific tracing plumbing. +- Runtime plugins are intentionally generic: pass a Selene runtime plugin object + through `pecos.selene_engine(runtime)` or the higher-level + `runtime=...` arguments on traced Guppy/DEM helpers. Anduril should live in + downstream experiment projects, not as a PECOS dependency. +- Runtime-produced `Idle` gates are preserved in the QIS operation trace and + replayed into QEC circuits as `TimeUnits` with the convention + `1 TimeUnit = 1 ns`. They only affect DEMs when an idle-noise parameter such + as `p_idle`, `t1/t2`, `p_idle_linear_rate`, or `p_idle_quadratic_rate` is set. - Keep the surface helper path compatible with constrained ancilla budgets: pass `ancilla_budget` into both `make_surface_code(...)` and `get_num_qubits(...)` when tracing surface Guppy. diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index c1e083013..19776ab0d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -64,6 +64,7 @@ def from_guppy( t2: float | None = None, p_idle_linear_rate: float | None = None, p_idle_quadratic_rate: float | None = None, + runtime: object | None = None, seed: int = 0, ) -> _RustDetectorErrorModel: """Build a circuit-level DEM from a Guppy program by tracing it. @@ -140,6 +141,9 @@ def from_guppy( p_idle_linear_rate: Optional stochastic Z-memory rate linear in idle duration. p_idle_quadratic_rate: Optional stochastic Z-memory rate using ``sin(rate * duration) ** 2``. + runtime: Optional Selene runtime selector/plugin. ``None`` selects + the default Selene runtime. Runtime plugin objects are passed + through to ``pecos.selene_engine(runtime)``. seed: Seed for the ideal trace run. Returns: @@ -201,7 +205,7 @@ def from_guppy( ) raise ValueError(msg) from exc - tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed) + tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed, runtime=runtime) # Compilation passes required for traced QIS circuits before fault # analysis: normalize parameterized Clifford rotations to named gates diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 3864b8adc..dc14115ce 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -776,7 +776,13 @@ def _reject_partially_lowered_trace(chunks: list[dict[str, Any]]) -> None: raise ValueError(msg) -def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = 0) -> Any: +def trace_guppy_into_tick_circuit( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, +) -> Any: """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. Runs ``program`` under the Selene QIS engine with operation tracing enabled @@ -797,6 +803,9 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = num_qubits: Number of qubits to allocate. QIS/HUGR programs require an explicit qubit count for trace capture. seed: Seed for the (ideal) trace run. + runtime: Optional Selene runtime selector/plugin. ``None`` selects the + default Selene runtime. Runtime plugin objects are passed through to + ``pecos.selene_engine(runtime)``. Returns: A ``TickCircuit`` with no detector/observable metadata attached; the @@ -805,7 +814,11 @@ def trace_guppy_into_tick_circuit(program: Any, num_qubits: int, *, seed: int = import pecos sim_builder = ( - pecos.sim(program).classical(pecos.selene_engine()).quantum(pecos.stabilizer()).qubits(num_qubits).seed(seed) + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos.stabilizer()) + .qubits(num_qubits) + .seed(seed) ) chunks = list(sim_builder.capture_operation_trace()) @@ -831,6 +844,7 @@ def _generate_traced_surface_tick_circuit( basis: str, *, ancilla_budget: int | None = None, + runtime: object | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -859,6 +873,7 @@ def _generate_traced_surface_tick_circuit( program, get_num_qubits(patch=patch, ancilla_budget=ancilla_budget), seed=0, + runtime=runtime, ) @@ -869,6 +884,7 @@ def _build_surface_tick_circuit_for_native_model( *, ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import ( @@ -895,6 +911,7 @@ def _build_surface_tick_circuit_for_native_model( num_rounds, basis, ancilla_budget=ancilla_budget, + runtime=runtime, ) # Coarse sanity check: the traced and abstract circuits must agree on the # sequence of *measured qubit indices*. This catches gross drift (a dropped @@ -935,6 +952,7 @@ def build_memory_circuit( basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -951,6 +969,8 @@ def build_memory_circuit( ancilla_budget: Optional cap on simultaneously live ancillas. circuit_source: ``"abstract"`` for the native surface builder or ``"traced_qis"`` for the lowered traced QIS gate stream. + runtime: Optional Selene runtime selector/plugin used when + ``circuit_source="traced_qis"``. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -981,6 +1001,7 @@ def build_memory_circuit( basis, ancilla_budget=ancilla_budget, circuit_source=circuit_source, + runtime=runtime, ) @@ -1039,16 +1060,17 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: ) -@cache -def _cached_surface_native_topology( +def _surface_native_topology( patch_key: tuple[int, int, str, bool], num_rounds: int, basis: str, ancilla_budget: int | None, circuit_source: Literal["abstract", "traced_qis"], include_idle_gates: bool, + *, + runtime: object | None = None, ) -> _CachedNativeSurfaceTopology: - """Cache topology-only native analysis shared across noise parameters.""" + """Build topology-only native analysis shared across noise parameters.""" import json from pecos.qec import DagFaultAnalyzer @@ -1061,6 +1083,7 @@ def _cached_surface_native_topology( basis, ancilla_budget=ancilla_budget, circuit_source=circuit_source, + runtime=runtime, ) if include_idle_gates: # Insert idle gates only when the requested noise model includes a @@ -1088,6 +1111,26 @@ def _cached_surface_native_topology( ) +@cache +def _cached_surface_native_topology( + patch_key: tuple[int, int, str, bool], + num_rounds: int, + basis: str, + ancilla_budget: int | None, + circuit_source: Literal["abstract", "traced_qis"], + include_idle_gates: bool, +) -> _CachedNativeSurfaceTopology: + """Cache topology-only native analysis shared across noise parameters.""" + return _surface_native_topology( + patch_key, + num_rounds, + basis, + ancilla_budget, + circuit_source, + include_idle_gates, + ) + + def _dem_string_from_cached_surface_topology( topology: _CachedNativeSurfaceTopology, noise: NoiseModel, @@ -1242,6 +1285,7 @@ def generate_circuit_level_dem_from_builder( decompose_errors: bool = False, ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + runtime: object | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -1270,6 +1314,10 @@ def generate_circuit_level_dem_from_builder( ``"traced_qis"`` traces the lowered ideal Selene/QIS gate stream and replays that exact gate list into a TickCircuit before running native PECOS fault analysis. + runtime: Optional Selene runtime selector/plugin used when + ``circuit_source="traced_qis"``. Custom runtime topologies are not + kept in PECOS's in-process topology cache because plugin objects + can carry private mutable state. Returns: DEM string in standard format @@ -1283,6 +1331,23 @@ def generate_circuit_level_dem_from_builder( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) patch_key = _surface_patch_cache_key(patch) + include_idle_gates = _noise_uses_dedicated_idle_noise(noise) + if runtime is not None: + topology = _surface_native_topology( + patch_key, + num_rounds, + basis.upper(), + ancilla_budget, + circuit_source, + include_idle_gates, + runtime=runtime, + ) + return _dem_string_from_cached_surface_topology( + topology, + noise, + decompose_errors=decompose_errors, + ) + return _cached_surface_native_dem_string( patch_key, num_rounds, From 85cdcfa5621faf799b43d33066637a6ef3968a7e Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 30 May 2026 10:15:51 -0600 Subject: [PATCH 009/388] Keep runtime plugin handoff wording generic. --- docs/development/from-guppy-dem-handoff.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index 5dff0bfa5..54766dbff 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -105,10 +105,11 @@ line. - Prefer the generic `from_guppy(...)` abstraction for future DEM construction rather than adding more surface-specific tracing plumbing. -- Runtime plugins are intentionally generic: pass a Selene runtime plugin object - through `pecos.selene_engine(runtime)` or the higher-level - `runtime=...` arguments on traced Guppy/DEM helpers. Anduril should live in - downstream experiment projects, not as a PECOS dependency. +- Runtime plugins are intentionally generic: pass any Selene-compatible runtime + plugin object through `pecos.selene_engine(runtime)` or the higher-level + `runtime=...` arguments on traced Guppy/DEM helpers. PECOS should depend only + on the public shape of those plugin objects; experiment-specific runtimes and + package sources belong in downstream projects. - Runtime-produced `Idle` gates are preserved in the QIS operation trace and replayed into QEC circuits as `TimeUnits` with the convention `1 TimeUnit = 1 ns`. They only affect DEMs when an idle-noise parameter such From 0962b2074e86604043a912f828de2bced2cc652d Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 30 May 2026 18:39:09 -0600 Subject: [PATCH 010/388] Add weighted Pauli DEM noise support --- .../src/fault_tolerance/dem_builder/types.rs | 128 ++++++++++-- crates/pecos-qec/tests/idle_noise_tests.rs | 14 +- .../src/fault_tolerance_bindings.rs | 196 ++++++++++++++++-- python/quantum-pecos/src/pecos/qec/dem.py | 33 ++- .../src/pecos/qec/surface/circuit_builder.py | 72 ++++++- .../src/pecos/qec/surface/decode.py | 142 ++++++++++++- .../tests/qec/test_from_guppy_dem.py | 81 ++++++++ 7 files changed, 601 insertions(+), 65 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 48a695879..e23e3d68d 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1546,13 +1546,24 @@ pub struct NoiseConfig { /// This mirrors PECOS engine idle memory noise in a DEM-compatible Pauli /// channel: each explicit `Idle(duration, q)` contributes an independent /// Z fault with probability `p_idle_linear_rate * duration`. + /// + /// This is the legacy Z-axis alias for `p_idle_z_linear_rate`. pub p_idle_linear_rate: f64, /// Stochastic Z-memory error rate for the quadratic idle term. /// - /// This follows the incoherent PECOS engine convention for quadratic idle - /// dephasing: each explicit `Idle(duration, q)` contributes an independent - /// Z fault with probability `sin(p_idle_quadratic_rate * duration)^2`. + /// Each explicit `Idle(duration, q)` contributes a Z-fault probability + /// term `p_idle_quadratic_rate * duration^2`. + /// + /// This is the legacy Z-axis alias for `p_idle_z_quadratic_rate`. pub p_idle_quadratic_rate: f64, + /// Stochastic X-memory error rate linear in idle duration. + pub p_idle_x_linear_rate: f64, + /// Stochastic Y-memory error rate linear in idle duration. + pub p_idle_y_linear_rate: f64, + /// Stochastic X-memory error rate quadratic in idle duration. + pub p_idle_x_quadratic_rate: f64, + /// Stochastic Y-memory error rate quadratic in idle duration. + pub p_idle_y_quadratic_rate: f64, } /// Per-Pauli error probabilities for a single qubit. @@ -1620,6 +1631,10 @@ impl Default for NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, + p_idle_x_linear_rate: 0.0, + p_idle_y_linear_rate: 0.0, + p_idle_x_quadratic_rate: 0.0, + p_idle_y_quadratic_rate: 0.0, } } } @@ -1641,6 +1656,10 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, + p_idle_x_linear_rate: 0.0, + p_idle_y_linear_rate: 0.0, + p_idle_x_quadratic_rate: 0.0, + p_idle_y_quadratic_rate: 0.0, } } @@ -1660,6 +1679,10 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, + p_idle_x_linear_rate: 0.0, + p_idle_y_linear_rate: 0.0, + p_idle_x_quadratic_rate: 0.0, + p_idle_y_quadratic_rate: 0.0, } } @@ -1679,6 +1702,10 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, + p_idle_x_linear_rate: 0.0, + p_idle_y_linear_rate: 0.0, + p_idle_x_quadratic_rate: 0.0, + p_idle_y_quadratic_rate: 0.0, } } @@ -1699,7 +1726,30 @@ impl NoiseConfig { /// Sets the quadratic stochastic Z-memory rate for explicit idle gates. #[must_use] pub fn set_idle_quadratic_rate(mut self, rate: f64) -> Self { - self.p_idle_quadratic_rate = rate; + self.p_idle_quadratic_rate = rate.max(0.0); + self + } + + /// Sets the linear stochastic Pauli-memory rates for explicit idle gates. + #[must_use] + pub fn set_idle_pauli_linear_rates(mut self, px_rate: f64, py_rate: f64, pz_rate: f64) -> Self { + self.p_idle_x_linear_rate = px_rate.max(0.0); + self.p_idle_y_linear_rate = py_rate.max(0.0); + self.p_idle_linear_rate = pz_rate.max(0.0); + self + } + + /// Sets the quadratic stochastic Pauli-memory rates for explicit idle gates. + #[must_use] + pub fn set_idle_pauli_quadratic_rates( + mut self, + px_rate: f64, + py_rate: f64, + pz_rate: f64, + ) -> Self { + self.p_idle_x_quadratic_rate = px_rate.max(0.0); + self.p_idle_y_quadratic_rate = py_rate.max(0.0); + self.p_idle_quadratic_rate = pz_rate.max(0.0); self } @@ -1781,28 +1831,64 @@ impl NoiseConfig { self } - fn compose_z_fault(probs: PauliProbs, z_probability: f64) -> PauliProbs { - let z_probability = z_probability.clamp(0.0, 1.0); - if z_probability <= f64::EPSILON { + fn idle_memory_probability(linear_rate: f64, quadratic_rate: f64, duration: f64) -> f64 { + let duration = duration.max(0.0); + (linear_rate.max(0.0) * duration + quadratic_rate.max(0.0) * duration * duration) + .clamp(0.0, 1.0) + } + + /// Dedicated idle-memory Pauli probabilities for `Idle(duration, q)`. + #[must_use] + pub fn idle_memory_pauli_probs(&self, duration: f64) -> PauliProbs { + let mut probs = PauliProbs { + px: Self::idle_memory_probability( + self.p_idle_x_linear_rate, + self.p_idle_x_quadratic_rate, + duration, + ), + py: Self::idle_memory_probability( + self.p_idle_y_linear_rate, + self.p_idle_y_quadratic_rate, + duration, + ), + pz: Self::idle_memory_probability( + self.p_idle_linear_rate, + self.p_idle_quadratic_rate, + duration, + ), + }; + let total = probs.total(); + if total > 1.0 { + probs.px /= total; + probs.py /= total; + probs.pz /= total; + } + probs + } + + fn compose_pauli_channel(probs: PauliProbs, channel: PauliProbs) -> PauliProbs { + if channel.total() <= f64::EPSILON { return probs; } let p_identity = (1.0 - probs.total()).max(0.0); + let c_identity = (1.0 - channel.total()).max(0.0); PauliProbs { - px: probs.px * (1.0 - z_probability) + probs.py * z_probability, - py: probs.py * (1.0 - z_probability) + probs.px * z_probability, - pz: probs.pz * (1.0 - z_probability) + p_identity * z_probability, + px: p_identity * channel.px + + probs.px * c_identity + + probs.py * channel.pz + + probs.pz * channel.py, + py: p_identity * channel.py + + probs.py * c_identity + + probs.px * channel.pz + + probs.pz * channel.px, + pz: p_identity * channel.pz + + probs.pz * c_identity + + probs.px * channel.py + + probs.py * channel.px, } } - fn idle_memory_z_probability(&self, duration: f64) -> f64 { - let duration = duration.max(0.0); - let linear = (self.p_idle_linear_rate * duration).clamp(0.0, 1.0); - let quadratic = (self.p_idle_quadratic_rate * duration).sin().powi(2); - - linear + quadratic - 2.0 * linear * quadratic - } - /// Compute per-Pauli idle noise probabilities for a given duration. /// /// If T1/T2 are set, uses the Pauli-twirled model (biased noise). @@ -1814,7 +1900,7 @@ impl NoiseConfig { } else { PauliProbs::depolarizing((self.p_idle * duration).min(1.0)) }; - Self::compose_z_fault(probs, self.idle_memory_z_probability(duration)) + Self::compose_pauli_channel(probs, self.idle_memory_pauli_probs(duration)) } /// Returns true when idle locations use the dedicated idle-noise model. @@ -1826,6 +1912,10 @@ impl NoiseConfig { || matches!((self.t1, self.t2), (Some(_), Some(_))) || self.p_idle_linear_rate > 0.0 || self.p_idle_quadratic_rate.abs() > f64::EPSILON + || self.p_idle_x_linear_rate > 0.0 + || self.p_idle_y_linear_rate > 0.0 + || self.p_idle_x_quadratic_rate > 0.0 + || self.p_idle_y_quadratic_rate > 0.0 } } diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index dfb7fa2a3..4f195d58d 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -222,7 +222,7 @@ fn linear_memory_z_noise_uses_idle_duration_in_dem() { } #[test] -fn idle_memory_z_probabilities_match_linear_and_quadratic_model() { +fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { let linear = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) .set_idle_linear_rate(1.0e-3) .idle_pauli_probs(20.0); @@ -231,11 +231,19 @@ fn idle_memory_z_probabilities_match_linear_and_quadratic_model() { assert!((linear.pz - 0.02).abs() < 1e-15); let quadratic = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) - .set_idle_quadratic_rate(0.5) + .set_idle_quadratic_rate(0.1) .idle_pauli_probs(2.0); assert_eq!(quadratic.px, 0.0); assert_eq!(quadratic.py, 0.0); - assert!((quadratic.pz - 1.0_f64.sin().powi(2)).abs() < 1e-15); + assert!((quadratic.pz - 0.4).abs() < 1e-15); + + let pauli = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_pauli_linear_rates(1.0e-3, 2.0e-3, 3.0e-3) + .set_idle_pauli_quadratic_rates(1.0e-4, 2.0e-4, 3.0e-4) + .idle_memory_pauli_probs(10.0); + assert!((pauli.px - 0.02).abs() < 1e-15); + assert!((pauli.py - 0.04).abs() < 1e-15); + assert!((pauli.pz - 0.06).abs() < 1e-15); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 049e6ce91..cd5d354fe 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -51,7 +51,8 @@ use pecos_qec::fault_tolerance::dem_builder::{ DemSampler as RustNewDemSampler, DemSamplerBuilder as RustNewDemSamplerBuilder, DetectorErrorModel as RustDetectorErrorModel, DirectSourceFamily as RustDirectSourceFamily, EquivalenceResult as RustEquivalenceResult, FaultContribution as RustFaultContribution, - FaultSourceType as RustFaultSourceType, NoiseConfig, ParsedDem as RustParsedDem, + FaultSourceType as RustFaultSourceType, NoiseConfig, PAULI_2Q_ORDER, + ParsedDem as RustParsedDem, PauliWeights, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, compare_dems_exact as rust_compare_dems_exact, compare_dems_statistical as rust_compare_dems_statistical, @@ -66,11 +67,62 @@ use pecos_quantum::DagCircuit; use pecos_quantum::QubitId; use pyo3::Py; use pyo3::prelude::*; +use std::collections::BTreeMap; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); -fn apply_idle_noise_options( +fn parse_p2_weights(weights: BTreeMap) -> PyResult { + use pecos_core::pauli::{X, Y, Z}; + + let mut entries = Vec::with_capacity(weights.len()); + let mut sum = 0.0; + for (label, weight) in weights { + let label = label.trim().to_ascii_uppercase(); + if !PAULI_2Q_ORDER.contains(&label.as_str()) { + let msg = format!( + "p2_weights keys must be one of {:?}, got {label:?}", + PAULI_2Q_ORDER + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + if !weight.is_finite() || weight < 0.0 { + let msg = + format!("p2_weights[{label:?}] must be finite and non-negative, got {weight}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let mut pauli = None; + for (qubit, ch) in label.chars().enumerate() { + let term = match ch { + 'I' => None, + 'X' => Some(X(qubit)), + 'Y' => Some(Y(qubit)), + 'Z' => Some(Z(qubit)), + _ => unreachable!("validated p2_weights label contains only I/X/Y/Z"), + }; + pauli = match (pauli, term) { + (None, None) => None, + (Some(existing), None) => Some(existing), + (None, Some(term)) => Some(term), + (Some(existing), Some(term)) => Some(existing & term), + }; + } + let Some(pauli) = pauli else { + return Err(pyo3::exceptions::PyValueError::new_err( + "p2_weights cannot contain the identity pair 'II'", + )); + }; + sum += weight; + entries.push((pauli, weight)); + } + if (sum - 1.0).abs() >= 1.0e-6 { + let msg = format!("p2_weights relative probabilities must sum to 1.0, got {sum}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + Ok(PauliWeights::new(entries)) +} + +fn apply_noise_options( mut noise: NoiseConfig, p_idle: Option, t1: Option, @@ -78,7 +130,14 @@ fn apply_idle_noise_options( idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, -) -> NoiseConfig { + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, +) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { noise = noise.set_t1_t2(t1_val, t2_val); @@ -92,7 +151,28 @@ fn apply_idle_noise_options( if let Some(rate) = p_idle_quadratic_rate { noise = noise.set_idle_quadratic_rate(rate); } - noise + if let Some(rate) = p_idle_x_linear_rate { + noise.p_idle_x_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_y_linear_rate { + noise.p_idle_y_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_linear_rate { + noise.p_idle_linear_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_x_quadratic_rate { + noise.p_idle_x_quadratic_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_y_quadratic_rate { + noise.p_idle_y_quadratic_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_quadratic_rate { + noise.p_idle_quadratic_rate = rate.max(0.0); + } + if let Some(weights) = p2_weights { + noise = noise.set_p2_weights(parse_p2_weights(weights)?); + } + Ok(noise) } // Adapter for decoder factories that require `Send + Sync` trait objects. @@ -977,7 +1057,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -991,10 +1071,17 @@ impl PyDetectorErrorModel { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; - let noise = apply_idle_noise_options( + let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -1002,7 +1089,14 @@ impl PyDetectorErrorModel { idle_rz, p_idle_linear_rate, p_idle_quadratic_rate, - ); + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p2_weights, + )?; if let Ok(dag) = circuit.extract::>() { @@ -1311,7 +1405,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1325,8 +1419,15 @@ impl PyDemBuilder { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, - ) -> PyRefMut<'_, Self> { - slf.noise = apply_idle_noise_options( + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, + ) -> PyResult> { + slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -1334,8 +1435,15 @@ impl PyDemBuilder { idle_rz, p_idle_linear_rate, p_idle_quadratic_rate, - ); - slf + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p2_weights, + )?; + Ok(slf) } /// Set the detector definitions from JSON. @@ -3178,7 +3286,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3192,8 +3300,15 @@ impl PyDemSampler { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, ) -> PyResult { - let noise = apply_idle_noise_options( + let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -3201,7 +3316,14 @@ impl PyDemSampler { idle_rz, p_idle_linear_rate, p_idle_quadratic_rate, - ); + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p2_weights, + )?; // Accept both DagCircuit and TickCircuit if let Ok(dag) = @@ -3311,7 +3433,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3327,8 +3449,15 @@ impl PyDemSampler { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, ) -> PyResult { - let noise = apply_idle_noise_options( + let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -3336,7 +3465,14 @@ impl PyDemSampler { idle_rz, p_idle_linear_rate, p_idle_quadratic_rate, - ); + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p2_weights, + )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) .with_detectors(detectors, observables) @@ -3789,7 +3925,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -3803,8 +3939,15 @@ impl PyDemSamplerBuilder { idle_rz: Option, p_idle_linear_rate: Option, p_idle_quadratic_rate: Option, - ) -> PyRefMut<'_, Self> { - slf.noise = apply_idle_noise_options( + p_idle_x_linear_rate: Option, + p_idle_y_linear_rate: Option, + p_idle_z_linear_rate: Option, + p_idle_x_quadratic_rate: Option, + p_idle_y_quadratic_rate: Option, + p_idle_z_quadratic_rate: Option, + p2_weights: Option>, + ) -> PyResult> { + slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), p_idle, t1, @@ -3812,8 +3955,15 @@ impl PyDemSamplerBuilder { idle_rz, p_idle_linear_rate, p_idle_quadratic_rate, - ); - slf + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + p2_weights, + )?; + Ok(slf) } /// Set detector definitions from JSON. diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 19776ab0d..e268bb9db 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -36,10 +36,13 @@ from __future__ import annotations +from collections.abc import Mapping from typing import Any from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel +P2Weights = Mapping[str, float] + class _DetectorErrorModelMixin: """Namespace for the Python Guppy/QIS-trace convenience constructor.""" @@ -57,6 +60,7 @@ def from_guppy( num_measurements: int | None = None, p1: float = 0.001, p2: float = 0.01, + p2_weights: P2Weights | None = None, p_meas: float = 0.001, p_prep: float = 0.001, p_idle: float | None = None, @@ -64,6 +68,12 @@ def from_guppy( t2: float | None = None, p_idle_linear_rate: float | None = None, p_idle_quadratic_rate: float | None = None, + p_idle_x_linear_rate: float | None = None, + p_idle_y_linear_rate: float | None = None, + p_idle_z_linear_rate: float | None = None, + p_idle_x_quadratic_rate: float | None = None, + p_idle_y_quadratic_rate: float | None = None, + p_idle_z_quadratic_rate: float | None = None, runtime: object | None = None, seed: int = 0, ) -> _RustDetectorErrorModel: @@ -133,14 +143,24 @@ def from_guppy( circuit; if given, it must match the traced count. p1: Single-qubit gate depolarizing rate. p2: Two-qubit gate depolarizing rate. + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum + to 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. p_idle: Optional uniform depolarizing idle-noise rate per idle duration. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. - p_idle_linear_rate: Optional stochastic Z-memory rate linear in idle duration. - p_idle_quadratic_rate: Optional stochastic Z-memory rate using - ``sin(rate * duration) ** 2``. + p_idle_linear_rate: Optional legacy alias for stochastic Z-memory rate + linear in idle duration. + p_idle_quadratic_rate: Optional legacy alias for stochastic Z-memory rate + quadratic in idle duration. + p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. + p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. + p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. + p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. + p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. runtime: Optional Selene runtime selector/plugin. ``None`` selects the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. @@ -241,6 +261,7 @@ def from_guppy( tc, p1=p1, p2=p2, + p2_weights=p2_weights, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, @@ -248,6 +269,12 @@ def from_guppy( t2=t2, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 5690f528a..bf5989773 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -21,6 +21,9 @@ from enum import Enum, auto from typing import TYPE_CHECKING, TypedDict +if TYPE_CHECKING: + from collections.abc import Mapping + # `_batched_stabilizers` and `_normalize_ancilla_budget` are imported from # the shared `_ancilla_batching` helper so this builder and the Guppy # emitter (`pecos.guppy.surface`) compute identical batches by @@ -1723,6 +1726,7 @@ def generate_dem_from_tick_circuit_via_pauli_frame( *, p1: float = 0.01, p2: float = 0.01, + p2_weights: Mapping[str, float] | None = None, p_meas: float = 0.01, p_prep: float = 0.01, ) -> str: @@ -1739,6 +1743,9 @@ def generate_dem_from_tick_circuit_via_pauli_frame( tc: TickCircuit with detector/observable metadata p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to + 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement error rate p_prep: Initialization (prep) error rate @@ -1910,9 +1917,34 @@ def simulate_error( # Single-qubit Paulis for depolarizing noise single_paulis = ["X", "Y", "Z"] # Two-qubit Paulis (non-identity on at least one qubit) - two_paulis = [ - (p1, p2) for p1 in ["I", "X", "Y", "Z"] for p2 in ["I", "X", "Y", "Z"] if not (p1 == "I" and p2 == "I") - ] + two_pauli_labels = tuple( + f"{p_ctrl}{p_targ}" + for p_ctrl in ("I", "X", "Y", "Z") + for p_targ in ("I", "X", "Y", "Z") + if not (p_ctrl == "I" and p_targ == "I") + ) + if p2_weights is None: + two_paulis = tuple((label[0], label[1], 1.0 / 15.0) for label in two_pauli_labels) + else: + from math import isfinite + + weights = {str(label).upper(): float(weight) for label, weight in p2_weights.items()} + unknown_labels = sorted(set(weights) - set(two_pauli_labels)) + if unknown_labels: + message = f"p2_weights contains invalid Pauli labels: {unknown_labels}" + raise ValueError(message) + if any(not isfinite(weight) or weight < 0.0 for weight in weights.values()): + message = "p2_weights values must be finite and non-negative" + raise ValueError(message) + weight_sum = sum(weights.values()) + if abs(weight_sum - 1.0) >= 1.0e-6: + message = f"p2_weights relative probabilities must sum to 1.0, got {weight_sum}" + raise ValueError(message) + two_paulis = tuple( + (label[0], label[1], weight) + for label, weight in sorted(weights.items()) + if weight > 0.0 + ) # Process each gate as a potential error location for op_idx, (_tick_idx, gate_name, qubits, meas_idx) in enumerate(circuit_ops): @@ -1936,7 +1968,7 @@ def simulate_error( elif gate_name == "CX" and p2 > 0: # Two-qubit gate error: depolarizing (each Pauli pair with prob p2/15) ctrl, targ = qubits[0], qubits[1] - for p_ctrl, p_targ in two_paulis: + for p_ctrl, p_targ, relative_probability in two_paulis: frame = {} if p_ctrl != "I": frame[ctrl] = p_ctrl @@ -1945,7 +1977,7 @@ def simulate_error( dets, obs = simulate_error(op_idx + 1, frame) if dets or obs: key = (frozenset(dets), frozenset(obs)) - error_mechanisms[key] += p2 / 15 + error_mechanisms[key] += p2 * relative_probability elif gate_name == "MZ" and p_meas > 0: # Measurement error: bit flip (affects this measurement directly) @@ -2137,6 +2169,7 @@ def generate_dem_from_tick_circuit( *, p1: float = 0.01, p2: float = 0.01, + p2_weights: Mapping[str, float] | None = None, p_meas: float = 0.01, p_prep: float = 0.01, p_idle: float | None = None, @@ -2144,6 +2177,12 @@ def generate_dem_from_tick_circuit( t2: float | None = None, p_idle_linear_rate: float | None = None, p_idle_quadratic_rate: float | None = None, + p_idle_x_linear_rate: float | None = None, + p_idle_y_linear_rate: float | None = None, + p_idle_z_linear_rate: float | None = None, + p_idle_x_quadratic_rate: float | None = None, + p_idle_y_quadratic_rate: float | None = None, + p_idle_z_quadratic_rate: float | None = None, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -2169,15 +2208,25 @@ def generate_dem_from_tick_circuit( tc: TickCircuit with detector/observable metadata (required) p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to + 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement error rate p_prep: Initialization (prep) error rate p_idle: Optional idle noise rate per explicit idle-gate time unit. The caller is responsible for inserting idle gates where needed. t1: Optional T1 relaxation time for explicit idle gates. t2: Optional T2 dephasing time for explicit idle gates. - p_idle_linear_rate: Optional stochastic Z-memory rate linear in idle duration. - p_idle_quadratic_rate: Optional stochastic Z-memory rate using - ``sin(rate * duration) ** 2``. + p_idle_linear_rate: Optional legacy alias for stochastic Z-memory rate + linear in idle duration. + p_idle_quadratic_rate: Optional legacy alias for stochastic Z-memory rate + quadratic in idle duration. + p_idle_x_linear_rate: Optional stochastic X-memory rate linear in idle duration. + p_idle_y_linear_rate: Optional stochastic Y-memory rate linear in idle duration. + p_idle_z_linear_rate: Optional stochastic Z-memory rate linear in idle duration. + p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. + p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. + p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. decompose_errors: If True (default), decompose hyperedge errors into graphlike components using the `^` separator. Set to False to output raw hyperedges. Ignored if maximal_decomposition=True. @@ -2217,11 +2266,18 @@ def generate_dem_from_tick_circuit( p2, p_meas, p_prep, + p2_weights=p2_weights, p_idle=p_idle, t1=t1, t2=t2, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, ) builder.with_num_measurements(num_measurements) builder.with_measurement_order(measurement_order) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index dc14115ce..693bc758f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -43,6 +43,7 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from dataclasses import dataclass from enum import Enum from functools import cache @@ -57,6 +58,9 @@ from pecos.qec.surface.patch import Stabilizer, SurfacePatch +P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] + + def _validate_probability(name: str, value: float) -> float: """Return ``value`` as a float after validating it is a probability.""" probability = float(value) @@ -87,18 +91,27 @@ class NoiseModel: Attributes: p1: Single-qubit gate error rate. p2: Two-qubit gate error rate. + p2_weights: Optional relative probabilities over the 15 non-identity + two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to + 1.0; ``p2`` remains the total two-qubit error rate. p_meas: Measurement error rate. p_prep: Initialization error rate. p_idle: Idle noise rate per time unit (uniform depolarizing). t1: T1 relaxation time for idle noise (same units as idle duration). t2: T2 dephasing time (must satisfy t2 <= 2*t1). - p_idle_linear_rate: Stochastic Z-memory rate linear in idle duration. - p_idle_quadratic_rate: Stochastic Z-memory rate using - ``sin(rate * duration) ** 2``. + p_idle_linear_rate: Legacy alias for stochastic Z-memory rate linear in idle duration. + p_idle_quadratic_rate: Legacy alias for stochastic Z-memory rate quadratic in idle duration. + p_idle_x_linear_rate: Stochastic X-memory rate linear in idle duration. + p_idle_y_linear_rate: Stochastic Y-memory rate linear in idle duration. + p_idle_z_linear_rate: Stochastic Z-memory rate linear in idle duration. + p_idle_x_quadratic_rate: Stochastic X-memory rate quadratic in idle duration. + p_idle_y_quadratic_rate: Stochastic Y-memory rate quadratic in idle duration. + p_idle_z_quadratic_rate: Stochastic Z-memory rate quadratic in idle duration. """ p1: float = 0.0 p2: float = 0.0 + p2_weights: P2Weights | None = None p_meas: float = 0.0 p_prep: float = 0.0 p_idle: float | None = None @@ -106,6 +119,38 @@ class NoiseModel: t2: float | None = None p_idle_linear_rate: float | None = None p_idle_quadratic_rate: float | None = None + p_idle_x_linear_rate: float | None = None + p_idle_y_linear_rate: float | None = None + p_idle_z_linear_rate: float | None = None + p_idle_x_quadratic_rate: float | None = None + p_idle_y_quadratic_rate: float | None = None + p_idle_z_quadratic_rate: float | None = None + + def __post_init__(self) -> None: + """Normalize cache-sensitive inputs after dataclass initialization.""" + self.p2_weights = _normalize_p2_weights(self.p2_weights) + + @property + def effective_p_idle_z_linear_rate(self) -> float | None: + """Z-axis linear idle rate, accepting the legacy alias.""" + return self.p_idle_z_linear_rate if self.p_idle_z_linear_rate is not None else self.p_idle_linear_rate + + @property + def effective_p_idle_z_quadratic_rate(self) -> float | None: + """Z-axis quadratic idle rate, accepting the legacy alias.""" + return self.p_idle_z_quadratic_rate if self.p_idle_z_quadratic_rate is not None else self.p_idle_quadratic_rate + + @property + def idle_memory_rates(self) -> tuple[float | None, ...]: + """All dedicated Pauli idle-memory rates that require explicit idles.""" + return ( + self.p_idle_x_linear_rate, + self.p_idle_y_linear_rate, + self.effective_p_idle_z_linear_rate, + self.p_idle_x_quadratic_rate, + self.p_idle_y_quadratic_rate, + self.effective_p_idle_z_quadratic_rate, + ) @staticmethod def uniform(physical_error_rate: float) -> NoiseModel: @@ -122,8 +167,7 @@ def is_noiseless(self) -> bool: and self.p_meas == 0.0 and self.p_prep == 0.0 and (self.p_idle is None or self.p_idle == 0.0) - and (self.p_idle_linear_rate is None or self.p_idle_linear_rate == 0.0) - and (self.p_idle_quadratic_rate is None or self.p_idle_quadratic_rate == 0.0) + and all(rate is None or rate == 0.0 for rate in self.idle_memory_rates) ) @property @@ -132,13 +176,22 @@ def physical_error_rate(self) -> float: rates = [self.p1, self.p2, self.p_meas, self.p_prep] if self.p_idle is not None: rates.append(self.p_idle) - if self.p_idle_linear_rate is not None: - rates.append(self.p_idle_linear_rate) - if self.p_idle_quadratic_rate is not None: - rates.append(self.p_idle_quadratic_rate) + rates.extend(rate for rate in self.idle_memory_rates if rate is not None) return max(rates) +def _normalize_p2_weights(p2_weights: P2Weights | None) -> tuple[tuple[str, float], ...] | None: + if p2_weights is None: + return None + items = p2_weights.items() if isinstance(p2_weights, Mapping) else p2_weights + return tuple(sorted((str(label).upper(), float(weight)) for label, weight in items)) + + +def _p2_weights_dict(p2_weights: P2Weights | None) -> dict[str, float] | None: + normalized = _normalize_p2_weights(p2_weights) + return None if normalized is None else dict(normalized) + + @dataclass class DecodingResult: """Result from decoding a single shot.""" @@ -1039,6 +1092,12 @@ def _uses_dedicated_idle_noise( t2: float | None, p_idle_linear_rate: float | None = None, p_idle_quadratic_rate: float | None = None, + p_idle_x_linear_rate: float | None = None, + p_idle_y_linear_rate: float | None = None, + p_idle_z_linear_rate: float | None = None, + p_idle_x_quadratic_rate: float | None = None, + p_idle_y_quadratic_rate: float | None = None, + p_idle_z_quadratic_rate: float | None = None, ) -> bool: """Return True when noise parameters require explicit idle locations.""" return ( @@ -1046,6 +1105,17 @@ def _uses_dedicated_idle_noise( or (t1 is not None and t2 is not None) or (p_idle_linear_rate is not None and p_idle_linear_rate > 0.0) or (p_idle_quadratic_rate is not None and p_idle_quadratic_rate != 0.0) + or any( + rate is not None and rate > 0.0 + for rate in ( + p_idle_x_linear_rate, + p_idle_y_linear_rate, + p_idle_z_linear_rate, + p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate, + ) + ) ) @@ -1057,6 +1127,12 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: t2=noise.t2, p_idle_linear_rate=noise.p_idle_linear_rate, p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + p_idle_x_linear_rate=noise.p_idle_x_linear_rate, + p_idle_y_linear_rate=noise.p_idle_y_linear_rate, + p_idle_z_linear_rate=noise.p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, ) @@ -1152,6 +1228,13 @@ def _dem_string_from_cached_surface_topology( t2=noise.t2, p_idle_linear_rate=noise.p_idle_linear_rate, p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + p_idle_x_linear_rate=noise.p_idle_x_linear_rate, + p_idle_y_linear_rate=noise.p_idle_y_linear_rate, + p_idle_z_linear_rate=noise.p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p2_weights=_p2_weights_dict(noise.p2_weights), ) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) @@ -1174,11 +1257,18 @@ def _cached_surface_native_dem_string( p_meas: float, p_prep: float, decompose_errors: bool, + p2_weights: tuple[tuple[str, float], ...] | None = None, p_idle: float | None = None, t1: float | None = None, t2: float | None = None, p_idle_linear_rate: float | None = None, p_idle_quadratic_rate: float | None = None, + p_idle_x_linear_rate: float | None = None, + p_idle_y_linear_rate: float | None = None, + p_idle_z_linear_rate: float | None = None, + p_idle_x_quadratic_rate: float | None = None, + p_idle_y_quadratic_rate: float | None = None, + p_idle_z_quadratic_rate: float | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" include_idle_gates = _uses_dedicated_idle_noise( @@ -1187,6 +1277,12 @@ def _cached_surface_native_dem_string( t2=t2, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, ) topology = _cached_surface_native_topology( patch_key, @@ -1201,6 +1297,7 @@ def _cached_surface_native_dem_string( NoiseModel( p1=p1, p2=p2, + p2_weights=p2_weights, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, @@ -1208,6 +1305,12 @@ def _cached_surface_native_dem_string( t2=t2, p_idle_linear_rate=p_idle_linear_rate, p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, ), decompose_errors=decompose_errors, ) @@ -1259,6 +1362,13 @@ def _build_native_sampler_from_cached_surface_topology( t2=noise.t2, p_idle_linear_rate=noise.p_idle_linear_rate, p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + p_idle_x_linear_rate=noise.p_idle_x_linear_rate, + p_idle_y_linear_rate=noise.p_idle_y_linear_rate, + p_idle_z_linear_rate=noise.p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p2_weights=_p2_weights_dict(noise.p2_weights), ) # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" @@ -1359,11 +1469,18 @@ def generate_circuit_level_dem_from_builder( noise.p_meas, noise.p_prep, decompose_errors=decompose_errors, + p2_weights=noise.p2_weights, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, p_idle_linear_rate=noise.p_idle_linear_rate, p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + p_idle_x_linear_rate=noise.p_idle_x_linear_rate, + p_idle_y_linear_rate=noise.p_idle_y_linear_rate, + p_idle_z_linear_rate=noise.p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, ) @@ -3027,11 +3144,18 @@ def build_native_sampler( noise.p_meas, noise.p_prep, decompose_errors=True, + p2_weights=noise.p2_weights, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, p_idle_linear_rate=noise.p_idle_linear_rate, p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + p_idle_x_linear_rate=noise.p_idle_x_linear_rate, + p_idle_y_linear_rate=noise.p_idle_y_linear_rate, + p_idle_z_linear_rate=noise.p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 7c58aaaa7..70b81b569 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -207,6 +207,87 @@ def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: assert dem.num_contributions > 0 +def test_lowered_runtime_idles_accept_axis_memory_noise_dem() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [10e-9]}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": []}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_idle_x_linear_rate=1.0e-3, + p_idle_y_quadratic_rate=1.0e-4, + ) + + assert dem.num_contributions > 0 + + +def test_from_circuit_accepts_biased_p2_weights() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [1, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "PZ", "qubits": [1], "angles": [], "params": []}, + {"gate_type": "CX", "qubits": [0, 1], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [1], "angles": [], "params": []}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + pauli_labels = ( + "IX", + "IY", + "IZ", + "XI", + "XX", + "XY", + "XZ", + "YI", + "YX", + "YY", + "YZ", + "ZI", + "ZX", + "ZY", + "ZZ", + ) + weights = dict.fromkeys(pauli_labels, 0.0) + weights["IX"] = 1.0 + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.0, + p2=0.01, + p2_weights=weights, + p_meas=0.0, + p_prep=0.0, + ) + + assert dem.num_contributions > 0 + + def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: """A trace where every quantum-carrying chunk is also lowered is accepted (this is the real Selene shape; the byte-identical regressions exercise it From 1a9944731f263c6aad6058fb6ff43c7da592eab5 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 30 May 2026 19:56:37 -0600 Subject: [PATCH 011/388] Support runtime-remapped surface DEM metadata --- crates/pecos-core/src/clifford_simplify.rs | 29 ++++ .../src/pecos/qec/surface/decode.py | 136 ++++++++++++++++-- .../tests/qec/test_from_guppy_dem.py | 35 +++++ 3 files changed, 185 insertions(+), 15 deletions(-) diff --git a/crates/pecos-core/src/clifford_simplify.rs b/crates/pecos-core/src/clifford_simplify.rs index e3caf97e2..c8725f137 100644 --- a/crates/pecos-core/src/clifford_simplify.rs +++ b/crates/pecos-core/src/clifford_simplify.rs @@ -11,6 +11,11 @@ use crate::gate_type::GateType; /// Type alias -- all comparisons use 64-bit fixed-point angles. type A64 = Angle; +/// Numerical lowering pipelines can produce angles that are a few fixed-point +/// units away from canonical Clifford quarter-turns. Snap only within a tiny +/// tolerance so genuine non-Clifford rotations still fail loudly. +const R1XY_CLIFFORD_EPSILON_TURNS: f64 = 1e-9; + /// Eighth-turn (pi/4): `QUARTER_TURN` / 2. fn eighth_turn() -> A64 { A64::QUARTER_TURN / 2u64 @@ -94,10 +99,12 @@ pub fn try_simplify_rotation(gate: GateType, angle: A64) -> Option { /// quarter-turn sqrt gates. #[must_use] pub fn try_simplify_r1xy(theta: A64, phi: A64) -> Option { + let theta = snap_r1xy_clifford_angle(theta)?; if theta == A64::ZERO { return Some(GateType::I); } + let phi = snap_r1xy_clifford_angle(phi)?; match phi { A64::ZERO => simplify_rx(theta), A64::HALF_TURN => simplify_rx(-theta), @@ -111,6 +118,17 @@ pub fn try_simplify_r1xy(theta: A64, phi: A64) -> Option { // Internal helpers // ------------------------------------------------------------------------- +fn snap_r1xy_clifford_angle(angle: A64) -> Option { + [ + A64::ZERO, + A64::QUARTER_TURN, + A64::HALF_TURN, + A64::THREE_QUARTERS_TURN, + ] + .into_iter() + .find(|target| angle.abs_diff_eq_turns(target, R1XY_CLIFFORD_EPSILON_TURNS)) +} + /// Negate an angle. fn neg(a: A64) -> A64 { -a @@ -454,6 +472,17 @@ mod tests { ); } + #[test] + fn r1xy_near_quarter_turn_sqrt_gates() { + assert_eq!( + try_simplify_r1xy( + Angle64::from_turns(0.25 + 1e-12), + Angle64::from_turns(0.75 - 1e-12), + ), + Some(GateType::SYdg) + ); + } + #[test] fn r1xy_three_quarter_turn_sqrt_dagger_gates() { // theta=3pi/2, phi=0: SXdg diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 693bc758f..4b5ab9258 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -453,8 +453,16 @@ def det_id(round_: int, stab_idx: int) -> int: return "\n".join(lines) -def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: +def _copy_surface_tick_circuit_metadata( + source_tc: Any, + target_tc: Any, + *, + measurement_index_remap: dict[int, int] | None = None, +) -> None: """Copy the surface-level metadata needed by the native DEM/sampler builders.""" + num_measurements_text = source_tc.get_meta("num_measurements") + num_measurements = int(num_measurements_text) if num_measurements_text is not None else None + for key in ( "basis", "detectors", @@ -467,9 +475,89 @@ def _copy_surface_tick_circuit_metadata(source_tc: Any, target_tc: Any) -> None: ): value = source_tc.get_meta(key) if value is not None: + if measurement_index_remap is not None and key in ( + "detectors", + "observables", + "detector_descriptors", + "observable_descriptors", + ): + if num_measurements is None: + msg = "Cannot remap surface metadata without num_measurements" + raise ValueError(msg) + value = _remap_surface_record_metadata_json( + value, + measurement_index_remap=measurement_index_remap, + num_measurements=num_measurements, + ) target_tc.set_meta(key, value) +def _measurement_index_remap_for_orders( + abstract_measurement_order: list[int], + traced_measurement_order: list[int], +) -> dict[int, int]: + """Map abstract record indices to runtime-traced record indices. + + The detector metadata is generated from the abstract surface schedule, but + a runtime may legally reorder measurement operations while preserving the + same measured qubit occurrences. This helper binds each measurement by + ``(qubit, occurrence_count_for_that_qubit)`` so metadata can follow a pure + scheduling reorder without accepting dropped/extra/wrong measurements. + """ + from collections import Counter, defaultdict + + if len(abstract_measurement_order) != len(traced_measurement_order) or Counter( + abstract_measurement_order, + ) != Counter(traced_measurement_order): + msg = ( + "Traced and abstract surface circuits disagree on the measured-qubit " + "multiset; refusing to remap detector/observable metadata" + ) + raise ValueError(msg) + + traced_occurrences: dict[tuple[int, int], int] = {} + traced_counts: defaultdict[int, int] = defaultdict(int) + for traced_index, qubit in enumerate(traced_measurement_order): + occurrence = traced_counts[qubit] + traced_occurrences[(qubit, occurrence)] = traced_index + traced_counts[qubit] += 1 + + remap: dict[int, int] = {} + abstract_counts: defaultdict[int, int] = defaultdict(int) + for abstract_index, qubit in enumerate(abstract_measurement_order): + occurrence = abstract_counts[qubit] + remap[abstract_index] = traced_occurrences[(qubit, occurrence)] + abstract_counts[qubit] += 1 + + return remap + + +def _remap_surface_record_metadata_json( + metadata_json: str, + *, + measurement_index_remap: dict[int, int], + num_measurements: int, +) -> str: + """Remap negative ``records`` offsets inside detector/observable metadata.""" + import json + + entries = json.loads(metadata_json) + for entry in entries: + records = entry.get("records") + if records is None: + continue + remapped_records = [] + for record in records: + abstract_index = num_measurements + int(record) + if abstract_index not in measurement_index_remap: + msg = f"Surface metadata record {record!r} is out of range for remapping" + raise ValueError(msg) + traced_index = measurement_index_remap[abstract_index] + remapped_records.append(traced_index - num_measurements) + entry["records"] = remapped_records + return json.dumps(entries) + + def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: """Convert runtime idle seconds into PECOS nanosecond time units.""" import math @@ -966,13 +1054,15 @@ def _build_surface_tick_circuit_for_native_model( ancilla_budget=ancilla_budget, runtime=runtime, ) - # Coarse sanity check: the traced and abstract circuits must agree on the - # sequence of *measured qubit indices*. This catches gross drift (a dropped - # or added measurement, a wrong-qubit measurement, a different schedule - # shape). It is NOT an identity-level check: `_extract_measurement_order` - # returns physical qubit indices, and under ancilla reuse the same physical - # qubit appears in many measurements -- so two different stabilizer - # orderings can produce an identical qubit-index sequence and pass here. + # Coarse sanity check: the traced and abstract circuits must either agree + # on measured-qubit order or be remappable by measured-qubit occurrence. + # This catches gross drift (a dropped/added/wrong-qubit measurement) while + # allowing runtimes that preserve stabilizer identity but schedule + # measurements in a different order. It is NOT an identity-level check: + # `_extract_measurement_order` returns physical qubit indices, and under + # ancilla reuse the same physical qubit appears in many measurements -- so + # two different stabilizer orderings can produce an identical qubit-index + # sequence and pass here. # There is no independent stabilizer-identity oracle in the stack today: # the detector/observable record offsets are the production binding (not a # validator), and the byte-identical traced-vs-traced DEM regression shares @@ -983,16 +1073,32 @@ def _build_surface_tick_circuit_for_native_model( # replayed TickCircuit does not currently carry (future work). traced_measurement_order = _extract_measurement_order(traced_tc) abstract_measurement_order = _extract_measurement_order(abstract_tc) + measurement_index_remap = None if traced_measurement_order != abstract_measurement_order: - msg = ( - "Traced and abstract surface circuits disagree on the measured-qubit " - "sequence (a dropped/added/wrong-qubit measurement or a different " - "schedule shape); refusing to build a native DEM/sampler from a " - "circuit that does not match the abstract detector/observable metadata" + try: + measurement_index_remap = _measurement_index_remap_for_orders( + abstract_measurement_order, + traced_measurement_order, + ) + except ValueError as exc: + msg = ( + "Traced and abstract surface circuits disagree on the measured-qubit " + "sequence (a dropped/added/wrong-qubit measurement or a different " + "schedule shape); refusing to build a native DEM/sampler from a " + "circuit that does not match the abstract detector/observable metadata" + ) + raise ValueError(msg) from exc + + if measurement_index_remap is None: + _copy_surface_tick_circuit_metadata(abstract_tc, traced_tc) + else: + _copy_surface_tick_circuit_metadata( + abstract_tc, + traced_tc, + measurement_index_remap=measurement_index_remap, ) - raise ValueError(msg) + traced_tc.set_meta("surface_metadata_record_binding", "runtime_measurement_order") - _copy_surface_tick_circuit_metadata(abstract_tc, traced_tc) traced_tc.set_meta("circuit_source", circuit_source) return traced_tc diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 70b81b569..8dc266600 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -3,6 +3,8 @@ """Regression tests for the Guppy-to-DEM convenience path.""" +import json + import pytest from guppylang import guppy from guppylang.std.builtins import result @@ -12,7 +14,9 @@ from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, + _measurement_index_remap_for_orders, _reject_partially_lowered_trace, + _remap_surface_record_metadata_json, _replay_lowered_qis_trace_into_tick_circuit, _replay_qis_trace_into_tick_circuit, ) @@ -733,6 +737,37 @@ def test_copy_surface_metadata_propagates_descriptors() -> None: assert len(obs_desc) > 0 +def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: + remap = _measurement_index_remap_for_orders( + [0, 1, 0, 2], + [1, 0, 2, 0], + ) + assert remap == {0: 1, 1: 0, 2: 3, 3: 2} + + metadata = json.dumps( + [ + {"id": 0, "records": [-4, -2]}, + {"id": 1, "records": [-3]}, + ], + ) + remapped = json.loads( + _remap_surface_record_metadata_json( + metadata, + measurement_index_remap=remap, + num_measurements=4, + ), + ) + assert remapped == [ + {"id": 0, "records": [-3, -1]}, + {"id": 1, "records": [-4]}, + ] + + +def test_surface_metadata_record_remap_rejects_measurement_drift() -> None: + with pytest.raises(ValueError, match="measured-qubit multiset"): + _measurement_index_remap_for_orders([0, 1, 0], [0, 1, 2]) + + def test_surface_module_cache_collapses_unconstrained_budget_forms() -> None: """``get_surface_code_module`` keys its cache on the *effective* budget (``normalize_ancilla_budget(d*d-1, budget)``), so ``ancilla_budget=None`` From bdb85094100f7e8b0b2bf56ef5d992b7484d3748 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 30 May 2026 23:34:24 -0600 Subject: [PATCH 012/388] Replace logical-subgraph byte-level DEM parser with shared line-based dem::SparseDem (profiled equal) --- crates/pecos-decoder-core/src/dem.rs | 133 ++++++++++ .../src/logical_subgraph.rs | 236 +----------------- 2 files changed, 134 insertions(+), 235 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index 324a21e50..ef69f25af 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -200,6 +200,139 @@ pub mod utils { } } +/// Sparse parse of a DEM: the error mechanisms plus detector coordinates, +/// without the dense matrices of [`DemCheckMatrix`]. +/// +/// Each `error(p) ...` line becomes one `(probability, detector_ids, +/// observable_ids)` entry. Decomposed mechanisms (`D0 ^ D1`) are XOR-combined; +/// graphlike mechanisms keep their DEM token order. `detector(x, y, t) D_i` +/// declarations are collected into `detector_coords`. +/// +/// Parsing runs once at decoder construction (never in a decode hot loop), so +/// this is plain line-based parsing — a byte-level variant was profiled and +/// gave no measurable speedup over this. +/// +/// # Example +/// +/// ``` +/// use pecos_decoder_core::dem::SparseDem; +/// +/// let dem = "detector(1, 0, 0) D0\nerror(0.01) D0 D1 L0\nerror(0.02) D1"; +/// let sdem = SparseDem::from_dem_str(dem).unwrap(); +/// assert_eq!(sdem.num_detectors, 2); +/// assert_eq!(sdem.num_observables, 1); +/// assert_eq!(sdem.mechanisms.len(), 2); +/// ``` +#[derive(Debug, Clone)] +pub struct SparseDem { + /// Per-mechanism: `(probability, detector_ids, observable_ids)`. + pub mechanisms: Vec<(f64, Vec, Vec)>, + /// Detector id → coordinates (spatial + time), from `detector(...)` lines. + pub detector_coords: std::collections::BTreeMap>, + /// Number of detectors: max detector id + 1, across both mechanisms and + /// `detector(...)` declarations (0 if none). + pub num_detectors: usize, + /// Number of observables: max observable id + 1 (0 if none). + pub num_observables: usize, +} + +impl SparseDem { + /// Parse a DEM string into its sparse mechanism + coordinate form. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if an `error(...)` line is malformed. + pub fn from_dem_str(dem: &str) -> Result { + let mut mechanisms: Vec<(f64, Vec, Vec)> = Vec::new(); + let mut detector_coords = std::collections::BTreeMap::new(); + let mut max_detector: Option = None; + let mut max_observable: Option = None; + + for line in dem.lines() { + let line = line.trim(); + + if let Some(rest) = line.strip_prefix("error(") { + let close = rest.find(')').ok_or_else(|| { + DecoderError::InvalidConfiguration("Missing ) in error line".into()) + })?; + let probability: f64 = rest[..close].parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid probability: {}", + &rest[..close] + )) + })?; + let tokens = &rest[close + 1..]; + + let (detectors, observables) = if tokens.contains('^') { + // Decomposed mechanism: XOR-combine components into sorted sets. + let mut det_set = std::collections::BTreeSet::new(); + let mut obs_set = std::collections::BTreeSet::new(); + for token in tokens.split('^').flat_map(str::split_whitespace) { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) + { + if !det_set.remove(&d) { + det_set.insert(d); + } + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } else if let Some(l) = + token.strip_prefix('L').and_then(|s| s.parse::().ok()) + { + if !obs_set.remove(&l) { + obs_set.insert(l); + } + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + ( + det_set.into_iter().collect(), + obs_set.into_iter().collect(), + ) + } else { + // Graphlike mechanism: keep DEM token order. + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + for token in tokens.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) + { + detectors.push(d); + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } else if let Some(l) = + token.strip_prefix('L').and_then(|s| s.parse::().ok()) + { + observables.push(l); + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + (detectors, observables) + }; + + mechanisms.push((probability, detectors, observables)); + } else if let Some(rest) = line.strip_prefix("detector(") { + let Some(close) = rest.find(')') else { + continue; + }; + let coords: Vec = rest[..close] + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + for token in rest[close + 1..].split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + detector_coords.insert(d as usize, coords.clone()); + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + } + } + + Ok(Self { + mechanisms, + detector_coords, + num_detectors: max_detector.map_or(0, |m| m as usize + 1), + num_observables: max_observable.map_or(0, |m| m as usize + 1), + }) + } +} + /// Check matrix representation extracted from a Detector Error Model. /// /// Converts a DEM string into the matrices needed by check-matrix-based diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index c343d069b..1ceb299d7 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -42,243 +42,9 @@ pub mod windowed; use std::collections::{BTreeMap, BTreeSet}; use crate::ObservableDecoder; -use crate::dem::{DemMatchingGraph, MatchingEdge}; +use crate::dem::{DemMatchingGraph, MatchingEdge, SparseDem}; use crate::errors::DecoderError; -/// Sparse representation of a parsed DEM, avoiding the dense matrix -/// allocation of [`DemCheckMatrix`]. Also collects detector coordinates -/// in a single pass to avoid re-scanning the DEM string. -struct SparseDem { - /// Per-mechanism: (probability, `detector_ids`, `observable_ids`). - mechanisms: Vec<(f64, Vec, Vec)>, - /// Detector id → coordinates (spatial + time). - detector_coords: BTreeMap>, - num_detectors: usize, - num_observables: usize, -} - -/// Parse ASCII digits into u32. Faster than `str::parse` for the common case. -#[inline] -fn parse_u32_fast(s: &[u8]) -> Option { - if s.is_empty() { - return None; - } - let mut n: u32 = 0; - for &b in s { - if !b.is_ascii_digit() { - return None; - } - n = n.wrapping_mul(10).wrapping_add(u32::from(b - b'0')); - } - Some(n) -} - -impl SparseDem { - fn from_dem_str(dem: &str) -> Result { - // Estimate capacity: ~1 mechanism per 55 bytes of DEM string. - let est_mechs = dem.len() / 55; - let mut mechanisms = Vec::with_capacity(est_mechs); - let mut detector_coords = BTreeMap::new(); - let mut max_detector: u32 = 0; - let mut max_observable: u32 = 0; - let mut has_any_detector = false; - - let bytes = dem.as_bytes(); - let mut pos = 0; - let len = bytes.len(); - - while pos < len { - // Skip to start of line content (skip whitespace/newlines) - while pos < len - && (bytes[pos] == b' ' - || bytes[pos] == b'\n' - || bytes[pos] == b'\r' - || bytes[pos] == b'\t') - { - pos += 1; - } - if pos >= len { - break; - } - - if bytes[pos] == b'e' && pos + 6 < len && &bytes[pos..pos + 6] == b"error(" { - // Parse error line at byte level. - pos += 6; - // Find closing paren — probability string - let prob_start = pos; - while pos < len && bytes[pos] != b')' { - pos += 1; - } - if pos >= len { - return Err(DecoderError::InvalidConfiguration( - "Missing ) in error line".into(), - )); - } - let prob: f64 = std::str::from_utf8(&bytes[prob_start..pos]) - .unwrap_or("0") - .parse() - .map_err(|_| DecoderError::InvalidConfiguration("Bad probability".into()))?; - pos += 1; // skip ')' - - // Scan for ^ to decide fast vs slow path - let line_start = pos; - while pos < len && bytes[pos] != b'\n' { - pos += 1; - } - let line_end = pos; - let line_bytes = &bytes[line_start..line_end]; - - if line_bytes.contains(&b'^') { - // Slow path: XOR decomposition - let line_str = std::str::from_utf8(line_bytes).unwrap_or(""); - let mut det_set = BTreeSet::new(); - let mut obs_set = BTreeSet::new(); - for component in line_str.split('^') { - for token in component.split_whitespace() { - if let Some(d_str) = token.strip_prefix('D') { - if let Some(d) = parse_u32_fast(d_str.as_bytes()) { - if !det_set.remove(&d) { - det_set.insert(d); - } - has_any_detector = true; - if d > max_detector { - max_detector = d; - } - } - } else if let Some(l_str) = token.strip_prefix('L') - && let Some(l) = parse_u32_fast(l_str.as_bytes()) - { - if !obs_set.remove(&l) { - obs_set.insert(l); - } - if l > max_observable { - max_observable = l; - } - } - } - } - mechanisms.push(( - prob, - det_set.into_iter().collect(), - obs_set.into_iter().collect(), - )); - } else { - // Fast path: no XOR. Parse tokens directly into Vecs. - let mut dets = Vec::with_capacity(3); - let mut obs = Vec::with_capacity(1); - let mut i = 0; - while i < line_bytes.len() { - // Skip whitespace - while i < line_bytes.len() && line_bytes[i] == b' ' { - i += 1; - } - if i >= line_bytes.len() { - break; - } - - if line_bytes[i] == b'D' { - i += 1; - let start = i; - while i < line_bytes.len() - && line_bytes[i] >= b'0' - && line_bytes[i] <= b'9' - { - i += 1; - } - if let Some(d) = parse_u32_fast(&line_bytes[start..i]) { - dets.push(d); - has_any_detector = true; - if d > max_detector { - max_detector = d; - } - } - } else if line_bytes[i] == b'L' { - i += 1; - let start = i; - while i < line_bytes.len() - && line_bytes[i] >= b'0' - && line_bytes[i] <= b'9' - { - i += 1; - } - if let Some(l) = parse_u32_fast(&line_bytes[start..i]) { - obs.push(l); - if l > max_observable { - max_observable = l; - } - } - } else { - // Skip unknown token - while i < line_bytes.len() && line_bytes[i] != b' ' { - i += 1; - } - } - } - mechanisms.push((prob, dets, obs)); - } - } else if bytes[pos] == b'd' && pos + 9 < len && &bytes[pos..pos + 9] == b"detector(" { - // Parse detector coordinate declaration. - pos += 9; - let coord_start = pos; - while pos < len && bytes[pos] != b')' { - pos += 1; - } - if pos < len { - let coord_str = std::str::from_utf8(&bytes[coord_start..pos]).unwrap_or(""); - let coords: Vec = coord_str - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - pos += 1; // skip ')' - // Find detector ID: "D123" - while pos < len && bytes[pos] == b' ' { - pos += 1; - } - if pos < len && bytes[pos] == b'D' { - pos += 1; - let start = pos; - while pos < len && bytes[pos] >= b'0' && bytes[pos] <= b'9' { - pos += 1; - } - if let Some(d) = parse_u32_fast(&bytes[start..pos]) { - detector_coords.insert(d as usize, coords); - has_any_detector = true; - if d > max_detector { - max_detector = d; - } - } - } - } - // Skip rest of line - while pos < len && bytes[pos] != b'\n' { - pos += 1; - } - } else { - // Skip unknown line - while pos < len && bytes[pos] != b'\n' { - pos += 1; - } - } - } - - let has_any_obs = max_observable > 0 || mechanisms.iter().any(|(_, _, o)| !o.is_empty()); - Ok(Self { - mechanisms, - detector_coords, - num_detectors: if has_any_detector { - max_detector as usize + 1 - } else { - 0 - }, - num_observables: if has_any_obs { - max_observable as usize + 1 - } else { - 0 - }, - }) - } -} - // ============================================================================ // Stabilizer coordinate mapping // ============================================================================ From 1288e6eebd2a1038079a726cdfd17db777111bdc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 30 May 2026 23:37:42 -0600 Subject: [PATCH 013/388] Rename test locals lsd_* to subgraph_* to avoid clash with BP-LSD acronym --- python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py index b05fbce1d..88bd2c0a9 100644 --- a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py +++ b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py @@ -849,15 +849,15 @@ def test_logical_subgraph_better_than_naive_on_cx(self, patch, nq): # logical-subgraph decoder with FB sc = b.stab_coords() decoder = LogicalSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") - lsd_errors = sum( + subgraph_errors = sum( 1 for i in range(20000) if decoder.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) ) - lsd_ler = lsd_errors / 20000 + subgraph_ler = subgraph_errors / 20000 # logical-subgraph decoder should be at least as good (usually much better) - assert lsd_ler <= naive_ler * 1.5 + 0.001, f"logical-subgraph decoder ({lsd_ler:.5f}) much worse than naive ({naive_ler:.5f})" + assert subgraph_ler <= naive_ler * 1.5 + 0.001, f"logical-subgraph decoder ({subgraph_ler:.5f}) much worse than naive ({naive_ler:.5f})" # --------------------------------------------------------------------------- From 517dd701f4846da2fe9b9895b035c926cbd3c356 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 10:12:53 -0600 Subject: [PATCH 014/388] Update dependencies and LLVM tooling. --- Cargo.lock | 146 ++-- Cargo.toml | 13 +- Justfile | 15 +- crates/pecos-build/Cargo.toml | 1 + crates/pecos-build/README.md | 7 +- crates/pecos-build/src/cargo_config.rs | 16 +- crates/pecos-build/src/home.rs | 8 +- crates/pecos-build/src/lib.rs | 2 +- crates/pecos-build/src/llvm.rs | 219 +++-- crates/pecos-build/src/llvm/config.rs | 74 +- crates/pecos-build/src/llvm/installer.rs | 800 +++++++++++++----- crates/pecos-build/src/prompt.rs | 2 +- crates/pecos-cli/src/cli.rs | 11 +- crates/pecos-cli/src/cli/env_cmd.rs | 53 +- crates/pecos-cli/src/cli/info.rs | 14 +- crates/pecos-cli/src/cli/install_cmd.rs | 60 +- crates/pecos-cli/src/cli/list.rs | 10 +- crates/pecos-cli/src/cli/llvm_cmd.rs | 85 +- crates/pecos-cli/src/cli/rust_cmd.rs | 84 +- crates/pecos-cli/src/cli/setup_cmd.rs | 27 +- crates/pecos-cli/src/main.rs | 41 +- crates/pecos-hugr-qis/Cargo.toml | 3 + crates/pecos-hugr-qis/src/array.rs | 8 +- crates/pecos-hugr-qis/src/compiler.rs | 7 +- crates/pecos-hugr/Cargo.toml | 3 - crates/pecos-llvm/Cargo.toml | 1 - crates/pecos-llvm/README.md | 6 +- crates/pecos-llvm/build.rs | 54 +- crates/pecos-llvm/src/lib.rs | 4 +- crates/pecos-llvm/src/llvm_compat.rs | 145 +++- crates/pecos-llvm/src/prelude.rs | 4 +- crates/pecos-qis/Cargo.toml | 1 - crates/pecos-qis/build.rs | 56 +- crates/pecos-qis/build_selene.rs | 6 +- crates/pecos-qis/src/executor.rs | 29 +- crates/pecos-qis/src/lib.rs | 2 +- crates/pecos/README.md | 2 +- crates/pecos/src/lib.rs | 2 +- docs/README.md | 2 +- docs/development/DEVELOPMENT.md | 10 +- docs/development/dev-tools.md | 39 +- docs/user-guide/cli.md | 6 +- docs/user-guide/llvm-setup.md | 133 ++- python/pecos-rslib-llvm/Cargo.toml | 2 +- python/pecos-rslib-llvm/src/llvm_bindings.rs | 135 ++- python/pecos-rslib/src/lib.rs | 11 +- .../tests/test_llvm_comprehensive.py | 40 + python/quantum-pecos/README.md | 4 +- .../pecos/simulators/mps_pytket/__init__.py | 3 + .../simulators/mps_pytket/_nvmath_compat.py | 62 ++ .../tests/guppy/test_hugr_compiler_parity.py | 24 +- .../ast_guppy/test_tier2_semantic.py | 6 +- scripts/native_bench/bench_pecos/Cargo.lock | 235 +---- scripts/win-msvc-bootstrap.ps1 | 2 +- 54 files changed, 1845 insertions(+), 890 deletions(-) create mode 100644 python/quantum-pecos/src/pecos/simulators/mps_pytket/_nvmath_compat.py diff --git a/Cargo.lock b/Cargo.lock index 6f13e8218..c607f580a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -387,7 +387,7 @@ dependencies = [ "bitflags 2.11.1", "cexpr", "clang-sys", - "itertools 0.13.0", + "itertools 0.10.5", "log", "prettyplease", "proc-macro2", @@ -853,7 +853,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.2.2", + "unicode-width 0.1.14", ] [[package]] @@ -1783,6 +1783,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -2161,7 +2162,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "rayon", ] [[package]] @@ -2308,21 +2308,20 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hugr" -version = "0.25.6" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc11e18017e6ff91448e08e7180e786ca3247c7539de190f01253298442c92a9" +checksum = "e91648ee98355c1d502a0d466eec0b67ca2612f81ef5d261e62fca8aa567ab52" dependencies = [ "hugr-core", "hugr-llvm", "hugr-model", - "hugr-passes", ] [[package]] name = "hugr-core" -version = "0.25.6" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37961239a500fb1eb128b990de804e32902979682263c326694328e36e95f0ad" +checksum = "dbea3356943ad488326fcea465519c25d03ab792198331610d1292c324d209b8" dependencies = [ "base64", "cgmath", @@ -2349,7 +2348,7 @@ dependencies = [ "smallvec", "smol_str", "static_assertions", - "strum 0.27.2", + "strum 0.28.0", "thiserror 2.0.18", "tracing", "typetag", @@ -2358,9 +2357,9 @@ dependencies = [ [[package]] name = "hugr-llvm" -version = "0.25.6" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d903c4878c5329fb754550460421fbdd35825e5ba6d4bf7ae16336b6e9e7c84" +checksum = "de92b58fa5e8e0c2d03aab5a6a9cd50b4021ad5e3c0ca68d6ffeb604833337ca" dependencies = [ "anyhow", "cc", @@ -2373,14 +2372,14 @@ dependencies = [ "petgraph 0.8.3", "portgraph", "rstest 0.26.1", - "strum 0.27.2", + "strum 0.28.0", ] [[package]] name = "hugr-model" -version = "0.25.7" +version = "0.27.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3b422e3b24b6586f683abe93393f8b9b8bb8b0d028723a13d4c48816039935e" +checksum = "9699c56c272a729b110e989d799e63beb6f077b5bcc118cd76c088453693615e" dependencies = [ "base64", "bumpalo", @@ -2398,25 +2397,6 @@ dependencies = [ "thiserror 2.0.18", ] -[[package]] -name = "hugr-passes" -version = "0.25.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d566bdb985b053d8b02cfbe443f225a3fbc3ef2656193f5072d1c70b6abfe827" -dependencies = [ - "ascent", - "derive_more 2.1.1", - "fxhash", - "hugr-core", - "itertools 0.14.0", - "pastey", - "petgraph 0.8.3", - "portgraph", - "serde_json", - "strum 0.27.2", - "thiserror 2.0.18", -] - [[package]] name = "hybrid-array" version = "0.4.12" @@ -2663,22 +2643,22 @@ dependencies = [ [[package]] name = "inkwell" -version = "0.8.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1def4112dfb2ce2993db7027f7acdb43c1f4ee1c70a082a2eef306ed5d0df365" +checksum = "7decbc9dfa45a4a827a6ff7b822c113b1285678a937e84213417d4ca8a095782" dependencies = [ + "bitflags 2.11.1", "inkwell_internals", "libc", "llvm-sys", - "once_cell", "thiserror 2.0.18", ] [[package]] name = "inkwell_internals" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63736175c9a30ea123f7018de9f26163e0b39cd6978990ae486b510c4f3bad69" +checksum = "6cfe97ee860815a90ed17e09639513269e39420a7440f3f4c996f238c514cf8d" dependencies = [ "proc-macro2", "quote", @@ -3011,14 +2991,15 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "llvm-sys" -version = "140.1.3" +version = "211.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3dc78e9857c0231ec11e3bdccf63870493fdc7d0570b0ea7d50bf5df0cb1a0c" +checksum = "44007a7a44b73bdd877fa9c9ccef256036511220e90f65b4d50e7a15773c0ee3" dependencies = [ + "anyhow", "cc", "lazy_static", "libc", - "regex", + "regex-lite", "semver", ] @@ -3313,7 +3294,7 @@ dependencies = [ "num-complex 0.4.6", "num-traits", "py_literal", - "zip", + "zip 2.4.2", ] [[package]] @@ -3722,7 +3703,8 @@ dependencies = [ "toml", "toml_edit", "xz2", - "zip", + "zip 8.6.0", + "zstd", ] [[package]] @@ -3954,7 +3936,6 @@ name = "pecos-hugr" version = "0.2.0-dev.0" dependencies = [ "anyhow", - "hugr-core", "log", "pecos-core", "pecos-engines", @@ -3974,6 +3955,7 @@ name = "pecos-hugr-qis" version = "0.2.0-dev.0" dependencies = [ "anyhow", + "inkwell", "log", "pecos-core", "serde_json", @@ -4642,9 +4624,9 @@ dependencies = [ [[package]] name = "portgraph" -version = "0.15.3" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d2bc5f0a75d052384bd7f73a30b8e264041cebeeab29cfee0c6b72224bfd57f" +checksum = "6a1395575e261a0c0dd2d360ac4e3b14a4c9f68647ee12e59d96af959e4c0f79" dependencies = [ "bitvec", "delegate", @@ -5378,6 +5360,12 @@ dependencies = [ "regex-syntax 0.8.10", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.6.29" @@ -5427,7 +5415,7 @@ dependencies = [ "derive_more 0.99.20", "fxhash", "itertools 0.13.0", - "petgraph 0.8.3", + "petgraph 0.6.5", "serde", "slotmap_fork_lmondada", "thiserror 1.0.69", @@ -5698,7 +5686,7 @@ checksum = "aaeee6f84153fd6f62507fc22bfe9499c8485075b44186dcbb918166ef75116f" dependencies = [ "fixedbitset 0.5.7", "foldhash 0.1.5", - "hashbrown 0.15.5", + "hashbrown 0.14.5", "indexmap 2.14.0", "ndarray 0.16.1", "num-traits", @@ -6247,15 +6235,6 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - [[package]] name = "strum" version = "0.28.0" @@ -6278,18 +6257,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "strum_macros" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" -dependencies = [ - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "strum_macros" version = "0.28.0" @@ -6564,11 +6531,12 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tket" -version = "0.17.0" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7717a9c758bb7f2082d31bb0c021c20ebe816cbc479205e27d9d83bfd82ea4c" +checksum = "640b38c8fcfc7a6232b0f3afa9a9685a26e35c08d80faa2a264c69c219359181" dependencies = [ "anyhow", + "ascent", "bytemuck", "cgmath", "chrono", @@ -6583,6 +6551,7 @@ dependencies = [ "itertools 0.14.0", "lazy_static", "num-rational", + "pastey", "pest", "pest_derive", "petgraph 0.8.3", @@ -6593,7 +6562,8 @@ dependencies = [ "serde_json", "serde_with", "smol_str", - "strum 0.27.2", + "strum 0.28.0", + "thiserror 2.0.18", "tket-json-rs", "tracing", "typetag", @@ -6615,9 +6585,9 @@ dependencies = [ [[package]] name = "tket-qsystem" -version = "0.23.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d11f18e7cd1fd264a9e5c4492c92770de342524979e72de5854fa0555b2644e8" +checksum = "04aecdc44b24c95bba3ba49cb9a022d199fa0aa22dc015519a1abbb77e5e037e" dependencies = [ "anyhow", "delegate", @@ -6629,7 +6599,7 @@ dependencies = [ "lazy_static", "serde", "smol_str", - "strum 0.27.2", + "strum 0.28.0", "tket", "tket-json-rs", "typetag", @@ -6799,6 +6769,12 @@ version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typeid" version = "1.0.3" @@ -8109,6 +8085,26 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.14.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index eca284162..9be85f048 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,15 +65,11 @@ libc = "0.2" libloading = "0.9" # --- LLVM --- -inkwell = "0.8" +inkwell = { version = "0.9", features = ["llvm21-1-prefer-dynamic"] } # --- HUGR / tket --- -tket = { version = "0.17", default-features = false } -tket-qsystem = { version = "0.23", default-features = false } -# Pin hugr-core to 0.25.6: hugr-core 0.25.7 widened strum to >=0.27.2,<0.29 which lets the -# resolver pick strum 0.28, conflicting with tket 0.17 (requires strum ^0.27). -# Remove this pin once tket-qsystem releases a version compatible with tket 0.18. -hugr-core = "=0.25.6" +tket = { version = "0.18", default-features = false } +tket-qsystem = { version = "0.24", default-features = false } # --- WebAssembly --- wasmtime = { version = "45", default-features = false, features = [ @@ -137,7 +133,8 @@ tar = "0.4" xz2 = "0.1" bzip2 = "0.6" sevenz-rust = "0.6" -zip = { version = "2.4", default-features = false, features = ["deflate"] } +zip = { version = "8", default-features = false, features = ["deflate"] } +zstd = "0.13" # --- Logging --- log = "0.4" diff --git a/Justfile b/Justfile index 470aee204..c7c5022c8 100644 --- a/Justfile +++ b/Justfile @@ -70,17 +70,18 @@ doctor: _msvc-bootstrap ok() { echo " [OK] $1: $2"; } fail() { echo " [!!] $1: $2"; PROBLEMS=$((PROBLEMS + 1)); } - echo "LLVM 14:" + echo "LLVM 21.1:" if LLVM_DIR=$({{pecos}} llvm find 2>/dev/null); then VERSION=$("$LLVM_DIR/bin/llvm-config" --version 2>/dev/null || {{pecos}} llvm version 2>/dev/null | head -1 || echo "unknown") - ok "installed" "$VERSION at $LLVM_DIR" + LINK_MODE=$("$LLVM_DIR/bin/llvm-config" --shared-mode 2>/dev/null || echo "unknown") + ok "installed" "$VERSION ($LINK_MODE LLVM) at $LLVM_DIR" else fail "installed" "not found (run: just setup)" fi - if [ -f .cargo/config.toml ] && grep -q "LLVM_SYS_140_PREFIX" .cargo/config.toml 2>/dev/null; then - ok ".cargo/config.toml" "LLVM_SYS_140_PREFIX configured" + if [ -f .cargo/config.toml ] && grep -q "LLVM_SYS_211_PREFIX" .cargo/config.toml 2>/dev/null; then + ok ".cargo/config.toml" "LLVM_SYS_211_PREFIX configured" else - fail ".cargo/config.toml" "LLVM_SYS_140_PREFIX not set (run: pecos llvm configure)" + fail ".cargo/config.toml" "LLVM_SYS_211_PREFIX not set (run: pecos llvm configure)" fi echo "" @@ -458,7 +459,7 @@ docs-test: # Deps Management (prefer `just setup` or `pecos install `) # ============================================================================= -# Install LLVM 14 +# Install PECOS-managed LLVM 21.1 where supported [group('deps')] install-llvm: _msvc-bootstrap {{pecos}} install llvm @@ -473,7 +474,7 @@ install-cuda: _msvc-bootstrap configure-llvm: _msvc-bootstrap {{pecos}} llvm configure -# Check LLVM 14 installation status +# Check LLVM 21.1 installation status [group('deps')] check-llvm: _msvc-bootstrap -{{pecos}} llvm check diff --git a/crates/pecos-build/Cargo.toml b/crates/pecos-build/Cargo.toml index 66e0c692c..2164401fa 100644 --- a/crates/pecos-build/Cargo.toml +++ b/crates/pecos-build/Cargo.toml @@ -31,6 +31,7 @@ bzip2.workspace = true xz2.workspace = true sevenz-rust.workspace = true zip.workspace = true +zstd.workspace = true # Error handling thiserror.workspace = true diff --git a/crates/pecos-build/README.md b/crates/pecos-build/README.md index 169a2a2af..96a70c7eb 100644 --- a/crates/pecos-build/README.md +++ b/crates/pecos-build/README.md @@ -8,9 +8,9 @@ Used by build scripts (`build.rs`) to manage external dependencies. Handles down ## Key Features -- **LLVM 14 management**: Install, configure, and find LLVM 14 +- **LLVM 21.1 management**: Install where PECOS can provide shared LLVM, configure, and find LLVM 21.1 - **Dependency downloads**: QuEST, Qulacs, Stim, Eigen, etc. -- **Tool finding**: `find_tool("llvm-as")`, `find_llvm_14()` +- **Tool finding**: `find_tool("llvm-as")`, `find_llvm()` - **Manifest parsing**: Load `pecos.toml` for dependency versions ## PECOS Home Directory @@ -20,8 +20,7 @@ All dependencies managed under `~/.pecos/`: ``` ~/.pecos/ ├── cache/ # Downloaded archives -├── deps/ # Extracted source trees -├── llvm/ # LLVM installation +├── deps/ # Extracted toolchains and source trees, including llvm-21.1/ └── tmp/ # Temporary files ``` diff --git a/crates/pecos-build/src/cargo_config.rs b/crates/pecos-build/src/cargo_config.rs index 100d0ccf5..5a1328b65 100644 --- a/crates/pecos-build/src/cargo_config.rs +++ b/crates/pecos-build/src/cargo_config.rs @@ -133,11 +133,11 @@ mod tests { fn creates_forced_env_in_empty_project() { let tmp = tempfile::tempdir().unwrap(); let mut cfg = CargoConfig::open(tmp.path()).unwrap(); - cfg.set_env("LLVM_SYS_140_PREFIX", "C:/llvm", true).unwrap(); + cfg.set_env("LLVM_SYS_211_PREFIX", "C:/llvm", true).unwrap(); assert!(cfg.save().unwrap()); let parsed: toml::Value = toml::from_str(&read(tmp.path())).unwrap(); - let env = &parsed["env"]["LLVM_SYS_140_PREFIX"]; + let env = &parsed["env"]["LLVM_SYS_211_PREFIX"]; assert_eq!(env["value"].as_str().unwrap(), "C:/llvm"); assert!(env["force"].as_bool().unwrap()); } @@ -162,7 +162,7 @@ mod tests { let tmp = tempfile::tempdir().unwrap(); // First writer: LLVM. let mut a = CargoConfig::open(tmp.path()).unwrap(); - a.set_env("LLVM_SYS_140_PREFIX", "/llvm", true).unwrap(); + a.set_env("LLVM_SYS_211_PREFIX", "/llvm", true).unwrap(); a.save().unwrap(); // Second writer: cuQuantum -- must merge into the same [env]. let mut b = CargoConfig::open(tmp.path()).unwrap(); @@ -174,7 +174,7 @@ mod tests { // Both keys survive and parse. let parsed: toml::Value = toml::from_str(&text).unwrap(); assert_eq!( - parsed["env"]["LLVM_SYS_140_PREFIX"]["value"], + parsed["env"]["LLVM_SYS_211_PREFIX"]["value"], "/llvm".into() ); assert_eq!(parsed["env"]["CUQUANTUM_ROOT"]["value"], "/cq".into()); @@ -192,14 +192,14 @@ mod tests { .unwrap(); let mut cfg = CargoConfig::open(tmp.path()).unwrap(); - cfg.set_env("LLVM_SYS_140_PREFIX", "/llvm", true).unwrap(); + cfg.set_env("LLVM_SYS_211_PREFIX", "/llvm", true).unwrap(); cfg.save().unwrap(); let parsed: toml::Value = toml::from_str(&read(tmp.path())).unwrap(); assert_eq!(parsed["build"]["jobs"].as_integer().unwrap(), 4); assert_eq!(parsed["env"]["FOO"].as_str().unwrap(), "bar"); assert_eq!( - parsed["env"]["LLVM_SYS_140_PREFIX"]["value"], + parsed["env"]["LLVM_SYS_211_PREFIX"]["value"], "/llvm".into() ); } @@ -231,11 +231,11 @@ mod tests { fn save_is_idempotent_no_rewrite_when_unchanged() { let tmp = tempfile::tempdir().unwrap(); let mut cfg = CargoConfig::open(tmp.path()).unwrap(); - cfg.set_env("LLVM_SYS_140_PREFIX", "/llvm", true).unwrap(); + cfg.set_env("LLVM_SYS_211_PREFIX", "/llvm", true).unwrap(); assert!(cfg.save().unwrap(), "first write should change the file"); let mut again = CargoConfig::open(tmp.path()).unwrap(); - again.set_env("LLVM_SYS_140_PREFIX", "/llvm", true).unwrap(); + again.set_env("LLVM_SYS_211_PREFIX", "/llvm", true).unwrap(); assert!( !again.save().unwrap(), "re-applying the same value must not rewrite the file" diff --git a/crates/pecos-build/src/home.rs b/crates/pecos-build/src/home.rs index dd7cc4e1e..91555b62d 100644 --- a/crates/pecos-build/src/home.rs +++ b/crates/pecos-build/src/home.rs @@ -6,7 +6,7 @@ //! ~/.pecos/ //! ├── cache/ # Downloaded archives (tar.gz, 7z, etc.) //! ├── deps/ # All dependencies, versioned by name -//! │ ├── llvm-14/ +//! │ ├── llvm-21.1/ //! │ ├── cuda-12.6.3/ //! │ ├── quest-v4.2.0/ //! │ ├── stim-bd60b73525fd/ @@ -159,8 +159,8 @@ pub fn resolve_dep_path(name: &str, version: &str) -> Result { Ok(versioned) } -/// LLVM major version used by PECOS -pub const LLVM_VERSION: &str = "14"; +/// LLVM version used by PECOS +pub const LLVM_VERSION: &str = crate::llvm::REQUIRED_VERSION; /// Get the vendored cmake installation directory path (without creating it) /// @@ -339,7 +339,7 @@ pub fn print_legacy_warning(name: &str, old_path: &Path) { /// Description of a single legacy dep that can be migrated. pub struct LegacyDep { - /// Human-readable name (e.g. "LLVM 14") + /// Human-readable name (e.g. "LLVM 21.1") pub name: &'static str, /// Old path pub old: PathBuf, diff --git a/crates/pecos-build/src/lib.rs b/crates/pecos-build/src/lib.rs index f6c9a9a2c..09f21d22d 100644 --- a/crates/pecos-build/src/lib.rs +++ b/crates/pecos-build/src/lib.rs @@ -3,7 +3,7 @@ //! This crate provides build script utilities for managing external dependencies: //! //! - Downloading and extracting C++ libraries (`QuEST`, Qulacs, Stim, etc.) -//! - Managing LLVM 14 installation +//! - Managing LLVM 21.1 installation //! - Managing the `~/.pecos/` home directory //! //! # PECOS Home Directory diff --git a/crates/pecos-build/src/llvm.rs b/crates/pecos-build/src/llvm.rs index 572476012..a3c98f1bb 100644 --- a/crates/pecos-build/src/llvm.rs +++ b/crates/pecos-build/src/llvm.rs @@ -1,6 +1,6 @@ //! LLVM detection and management //! -//! This module provides functionality to locate, install, and configure LLVM 14 +//! This module provides functionality to locate, install, and configure LLVM 21.1 //! for PECOS across different platforms. pub mod config; @@ -30,26 +30,39 @@ pub fn get_pecos_command() -> &'static str { "cargo run -p pecos --" } -/// LLVM version required by PECOS -pub const REQUIRED_VERSION: &str = "14"; +/// LLVM version required by PECOS. +pub const REQUIRED_VERSION: &str = "21.1"; -/// Find LLVM 14 installation on the system. +/// Cargo/llvm-sys environment variable for the required LLVM version. +pub const LLVM_SYS_PREFIX_ENV: &str = "LLVM_SYS_211_PREFIX"; + +/// Return whether an `llvm-config --version` string is compatible with PECOS. +#[must_use] +pub fn is_required_llvm_version(version: &str) -> bool { + let version = version.trim(); + version == REQUIRED_VERSION + || version + .strip_prefix(REQUIRED_VERSION) + .is_some_and(|rest| rest.starts_with('.')) +} + +/// Find a compatible LLVM installation on the system. /// -/// This function searches for LLVM 14 in the following priority order: -/// 1. PECOS deps directory: `~/.pecos/deps/llvm/` +/// This function searches for LLVM in the following priority order: +/// 1. PECOS deps directory: `~/.pecos/deps/llvm-{version}/` /// 2. Legacy PECOS path: `~/.pecos/llvm/` (prints deprecation warning) -/// - Windows also checks: `~/.pecos/LLVM-14` +/// - Windows also checks: `~/.pecos/LLVM-{version}` /// 3. Project-local installation (`llvm/` directory relative to repository root) /// 4. System installations (platform-specific locations) /// /// # Returns -/// - `Some(PathBuf)` if LLVM 14 is found and valid -/// - `None` if LLVM 14 is not found +/// - `Some(PathBuf)` if a compatible LLVM is found and valid +/// - `None` if a compatible LLVM is not found #[must_use] -pub fn find_llvm_14(repo_root: Option) -> Option { - // 1. Check new deps path: ~/.pecos/deps/llvm/ +pub fn find_llvm(repo_root: Option) -> Option { + // 1. Check versioned deps path: ~/.pecos/deps/llvm-{version}/ if let Ok(deps_llvm) = crate::home::get_llvm_dir_path() - && is_valid_llvm_14(&deps_llvm) + && is_valid_llvm(&deps_llvm) { return Some(deps_llvm); } @@ -60,15 +73,15 @@ pub fn find_llvm_14(repo_root: Option) -> Option { #[cfg(target_os = "windows")] { - let user_llvm_new = pecos_dir.join("LLVM-14"); - if is_valid_llvm_14(&user_llvm_new) { + let user_llvm_new = pecos_dir.join(format!("LLVM-{REQUIRED_VERSION}")); + if is_valid_llvm(&user_llvm_new) { crate::home::print_legacy_warning("LLVM", &user_llvm_new); return Some(user_llvm_new); } } let user_llvm_legacy = pecos_dir.join("llvm"); - if is_valid_llvm_14(&user_llvm_legacy) { + if is_valid_llvm(&user_llvm_legacy) { crate::home::print_legacy_warning("LLVM", &user_llvm_legacy); return Some(user_llvm_legacy); } @@ -77,32 +90,48 @@ pub fn find_llvm_14(repo_root: Option) -> Option { // 3. Check for project-local LLVM if let Some(root) = repo_root { let local_llvm = root.join("llvm"); - if is_valid_llvm_14(&local_llvm) { + if is_valid_llvm(&local_llvm) { return Some(local_llvm); } } // 4. Check system installations - find_system_llvm_14() + find_system_llvm() } -/// Find LLVM 14 in system-wide locations (platform-specific) -fn find_system_llvm_14() -> Option { +/// Find the LLVM installation Cargo should use for this project. +/// +/// An explicit `.cargo/config.toml` setting takes priority because `cargo` +/// applies it to build scripts. If no valid project config exists, this falls +/// back to the normal managed/system detection order. +#[must_use] +pub fn find_configured_or_detected_llvm(repo_root: Option) -> Option { + if let Some(configured_path) = config::read_configured_llvm_path() + && is_valid_llvm(&configured_path) + { + return Some(configured_path); + } + + find_llvm(repo_root) +} + +/// Find LLVM in system-wide locations (platform-specific) +fn find_system_llvm() -> Option { #[cfg(target_os = "macos")] { - if let Ok(output) = Command::new("brew").args(["--prefix", "llvm@14"]).output() + if let Ok(output) = Command::new("brew").args(["--prefix", "llvm@21"]).output() && output.status.success() { let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); let path = PathBuf::from(path_str); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { return Some(path); } } - for path_str in ["/opt/homebrew/opt/llvm@14", "/usr/local/opt/llvm@14"] { + for path_str in ["/opt/homebrew/opt/llvm@21", "/usr/local/opt/llvm@21"] { let llvm_path = PathBuf::from(path_str); - if is_valid_llvm_14(&llvm_path) { + if is_valid_llvm(&llvm_path) { return Some(llvm_path); } } @@ -110,23 +139,23 @@ fn find_system_llvm_14() -> Option { #[cfg(target_os = "linux")] { - if let Ok(output) = Command::new("llvm-config-14").arg("--prefix").output() + if let Ok(output) = Command::new("llvm-config-21").arg("--prefix").output() && output.status.success() { let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); let path = PathBuf::from(path_str); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { return Some(path); } } for path_str in [ - "/usr/lib/llvm-14", - "/usr/local/llvm-14", - "/usr/lib/x86_64-linux-gnu/llvm-14", + "/usr/lib/llvm-21", + "/usr/local/llvm-21", + "/usr/lib/x86_64-linux-gnu/llvm-21", ] { let llvm_path = PathBuf::from(path_str); - if is_valid_llvm_14(&llvm_path) { + if is_valid_llvm(&llvm_path) { return Some(llvm_path); } } @@ -137,11 +166,11 @@ fn find_system_llvm_14() -> Option { for path_str in [ "C:\\Program Files\\LLVM", "C:\\LLVM", - "C:\\Program Files\\LLVM-14", - "C:\\LLVM-14", + "C:\\Program Files\\LLVM-21", + "C:\\LLVM-21", ] { let llvm_path = PathBuf::from(path_str); - if is_valid_llvm_14(&llvm_path) { + if is_valid_llvm(&llvm_path) { return Some(llvm_path); } } @@ -150,9 +179,9 @@ fn find_system_llvm_14() -> Option { None } -/// Check if a given path contains a valid LLVM 14 installation +/// Check if a given path contains a compatible LLVM installation #[must_use] -pub fn is_valid_llvm_14(path: &Path) -> bool { +pub fn is_valid_llvm(path: &Path) -> bool { if !path.exists() { return false; } @@ -171,7 +200,7 @@ pub fn is_valid_llvm_14(path: &Path) -> bool { && output.status.success() { let version = String::from_utf8_lossy(&output.stdout); - return version.starts_with("14."); + return is_required_llvm_version(&version); } false @@ -183,13 +212,7 @@ pub fn is_valid_llvm_14(path: &Path) -> bool { /// /// Returns an error if LLVM is not found or version cannot be determined pub fn get_llvm_version(path: &Path) -> Result { - #[cfg(target_os = "windows")] - let llvm_config = path.join("bin").join("llvm-config.exe"); - - #[cfg(not(target_os = "windows"))] - let llvm_config = path.join("bin").join("llvm-config"); - - let output = Command::new(&llvm_config) + let output = Command::new(llvm_config_path(path)) .arg("--version") .output() .map_err(|e| Error::Llvm(format!("Failed to run llvm-config: {e}")))?; @@ -201,11 +224,83 @@ pub fn get_llvm_version(path: &Path) -> Result { Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } +/// Get LLVM's configured shared/static library mode. +/// +/// # Errors +/// +/// Returns an error if `llvm-config --shared-mode` fails. +pub fn get_llvm_shared_mode(path: &Path) -> Result { + let output = Command::new(llvm_config_path(path)) + .arg("--shared-mode") + .output() + .map_err(|e| Error::Llvm(format!("Failed to run llvm-config: {e}")))?; + + if !output.status.success() { + return Err(Error::Llvm( + "llvm-config --shared-mode returned non-zero status".into(), + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +/// Get the shared LLVM library names reported by `llvm-config`, if available. +#[must_use] +pub fn get_llvm_shared_libraries(path: &Path) -> Option { + let output = Command::new(llvm_config_path(path)) + .args(["--libnames", "--link-shared", "core"]) + .output() + .ok()?; + + if !output.status.success() { + return None; + } + + let libraries = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if libraries.is_empty() { + None + } else { + Some(libraries) + } +} + +/// Get LLVM's library directory as reported by `llvm-config --libdir`. +/// +/// # Errors +/// +/// Returns an error if `llvm-config --libdir` fails. +pub fn get_llvm_libdir(path: &Path) -> Result { + let output = Command::new(llvm_config_path(path)) + .arg("--libdir") + .output() + .map_err(|e| Error::Llvm(format!("Failed to run llvm-config: {e}")))?; + + if !output.status.success() { + return Err(Error::Llvm( + "llvm-config --libdir returned non-zero status".into(), + )); + } + + Ok(PathBuf::from( + String::from_utf8_lossy(&output.stdout).trim(), + )) +} + +fn llvm_config_path(path: &Path) -> PathBuf { + let exe_name = if cfg!(windows) { + "llvm-config.exe" + } else { + "llvm-config" + }; + + path.join("bin").join(exe_name) +} + /// Find a specific LLVM tool by name #[must_use] pub fn find_tool(tool_name: &str) -> Option { let repo_root = get_repo_root_from_manifest(); - let llvm_path = find_llvm_14(repo_root)?; + let llvm_path = find_configured_or_detected_llvm(repo_root)?; let tool_path = if cfg!(windows) { llvm_path.join("bin").join(format!("{tool_name}.exe")) @@ -273,26 +368,28 @@ fn find_cargo_project_root_from(start: &Path) -> Option { first_match } -/// Print a helpful error message when LLVM 14 is not found +/// Print a helpful error message when the required LLVM version is not found pub fn print_llvm_not_found_error() { let cmd = get_pecos_command(); eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("ERROR: LLVM 14 not found!"); + eprintln!("ERROR: LLVM {REQUIRED_VERSION} not found!"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("PECOS requires LLVM version 14 for QIS program execution."); - eprintln!(); - eprintln!("Option 1 - Install LLVM 14 for PECOS (recommended):"); - eprintln!(); - eprintln!(" {cmd} install llvm"); + eprintln!("PECOS requires LLVM version {REQUIRED_VERSION} for QIS program execution."); eprintln!(); + if installer::managed_install_unavailable_reason().is_none() { + eprintln!("Option 1 - Install LLVM {REQUIRED_VERSION} for PECOS (recommended):"); + eprintln!(); + eprintln!(" {cmd} install llvm"); + eprintln!(); + } #[cfg(target_os = "macos")] { - eprintln!("Option 2 - Use system LLVM via Homebrew:"); + eprintln!("Use system LLVM via Homebrew:"); eprintln!(); - eprintln!(" brew install llvm@14"); + eprintln!(" brew install llvm@21"); eprintln!(" {cmd} llvm configure"); eprintln!(); } @@ -301,14 +398,18 @@ pub fn print_llvm_not_found_error() { { eprintln!("Option 2 - Use system LLVM via package manager:"); eprintln!(); - eprintln!(" sudo apt install llvm-14 # Debian/Ubuntu"); + eprintln!(" Install LLVM 21.1 through your distribution packages if available"); eprintln!(" {cmd} llvm configure"); eprintln!(); } #[cfg(target_os = "windows")] { - eprintln!("For Windows, use the PECOS installer (Option 1) above."); + eprintln!("Windows MSVC LLVM builds do not provide shared libLLVM."); + eprintln!("Use WSL2/Linux for the full HUGR test lane, or configure a full"); + eprintln!("LLVM 21 package for targeted static LLVM builds:"); + eprintln!(); + eprintln!(" {cmd} llvm configure C:\\path\\to\\llvm"); eprintln!(); } @@ -420,4 +521,14 @@ mod tests { let result = find_cargo_project_root_from(&deep); assert_eq!(result.as_deref(), Some(root)); } + + #[test] + fn required_llvm_version_matches_only_21_1_series() { + assert!(is_required_llvm_version("21.1")); + assert!(is_required_llvm_version("21.1.8")); + assert!(is_required_llvm_version("21.1.8git")); + assert!(!is_required_llvm_version("21.0.9")); + assert!(!is_required_llvm_version("21.10.0")); + assert!(!is_required_llvm_version("22.0.0")); + } } diff --git a/crates/pecos-build/src/llvm/config.rs b/crates/pecos-build/src/llvm/config.rs index 9ebd6a09f..0702c41f1 100644 --- a/crates/pecos-build/src/llvm/config.rs +++ b/crates/pecos-build/src/llvm/config.rs @@ -2,8 +2,8 @@ use crate::errors::{Error, Result}; use crate::llvm::{ - find_cargo_project_root, find_llvm_14, get_pecos_command, get_repo_root_from_manifest, - is_valid_llvm_14, + LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, find_cargo_project_root, find_llvm, get_pecos_command, + get_repo_root_from_manifest, is_valid_llvm, }; use std::fs; use std::path::{Path, PathBuf}; @@ -15,9 +15,9 @@ pub struct ConfigValidation { pub configured_path: Option, /// Whether the configured path exists pub path_exists: bool, - /// Whether the configured path is valid LLVM 14 - pub path_is_valid_llvm14: bool, - /// Path that `find_llvm_14` would return + /// Whether the configured path is the required LLVM version + pub path_is_valid_llvm: bool, + /// Path that `find_llvm` would return pub detected_path: Option, /// Whether config matches detected LLVM pub config_matches_detected: bool, @@ -27,7 +27,7 @@ impl ConfigValidation { /// Check if the configuration is healthy #[must_use] pub fn is_healthy(&self) -> bool { - self.configured_path.is_some() && self.path_exists && self.path_is_valid_llvm14 + self.configured_path.is_some() && self.path_exists && self.path_is_valid_llvm } /// Print validation warnings if there are issues @@ -44,21 +44,21 @@ impl ConfigValidation { ); eprintln!(); eprintln!("To fix this:"); - eprintln!(" 1. Install LLVM 14 for PECOS (recommended):"); + eprintln!(" 1. Install LLVM {REQUIRED_VERSION} for PECOS (recommended):"); eprintln!(" {cmd} install llvm"); if self.detected_path.is_some() { eprintln!(" 2. Or use the detected system LLVM:"); eprintln!(" {cmd} llvm configure"); } - } else if !self.path_is_valid_llvm14 { + } else if !self.path_is_valid_llvm { eprintln!(); eprintln!( - "Warning: .cargo/config.toml points to {} which is not valid LLVM 14", + "Warning: .cargo/config.toml points to {} which is not valid LLVM {REQUIRED_VERSION}", configured.display() ); eprintln!(); eprintln!("To fix this:"); - eprintln!(" 1. Install LLVM 14 for PECOS (recommended):"); + eprintln!(" 1. Install LLVM {REQUIRED_VERSION} for PECOS (recommended):"); eprintln!(" {cmd} install llvm"); if self.detected_path.is_some() { eprintln!(" 2. Or use the detected system LLVM:"); @@ -83,7 +83,7 @@ impl ConfigValidation { eprintln!("Warning: No LLVM configured in .cargo/config.toml"); eprintln!(); eprintln!("To fix this:"); - eprintln!(" 1. Install LLVM 14 for PECOS (recommended):"); + eprintln!(" 1. Install LLVM {REQUIRED_VERSION} for PECOS (recommended):"); eprintln!(" {cmd} install llvm"); eprintln!(" 2. Or use the detected system LLVM:"); eprintln!(" {cmd} llvm configure"); @@ -94,8 +94,8 @@ impl ConfigValidation { /// Read the configured LLVM path from .cargo/config.toml /// /// Handles both TOML formats: -/// `LLVM_SYS_140_PREFIX = "/path/to/llvm"` -/// `LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true }` +/// `LLVM_SYS_211_PREFIX = "/path/to/llvm"` +/// `LLVM_SYS_211_PREFIX = { value = "/path/to/llvm", force = true }` #[must_use] pub fn read_configured_llvm_path() -> Option { let project_root = find_cargo_project_root()?; @@ -104,14 +104,14 @@ pub fn read_configured_llvm_path() -> Option { let table: toml::Table = content.parse().ok()?; let env = table.get("env")?; - let entry = env.get("LLVM_SYS_140_PREFIX")?; + let entry = env.get(LLVM_SYS_PREFIX_ENV)?; - // Simple string: LLVM_SYS_140_PREFIX = "/path" + // Simple string: LLVM_SYS_211_PREFIX = "/path" if let Some(s) = entry.as_str() { return Some(PathBuf::from(s)); } - // Inline table: LLVM_SYS_140_PREFIX = { value = "/path", force = true } + // Inline table: LLVM_SYS_211_PREFIX = { value = "/path", force = true } if let Some(t) = entry.as_table() && let Some(v) = t.get("value").and_then(|v| v.as_str()) { @@ -126,10 +126,10 @@ pub fn read_configured_llvm_path() -> Option { pub fn validate_llvm_config() -> ConfigValidation { let configured_path = read_configured_llvm_path(); let repo_root = get_repo_root_from_manifest(); - let detected_path = find_llvm_14(repo_root); + let detected_path = find_llvm(repo_root); - let (path_exists, path_is_valid_llvm14) = if let Some(ref path) = configured_path { - (path.exists(), is_valid_llvm_14(path)) + let (path_exists, path_is_valid_llvm) = if let Some(ref path) = configured_path { + (path.exists(), is_valid_llvm(path)) } else { (false, false) }; @@ -143,7 +143,7 @@ pub fn validate_llvm_config() -> ConfigValidation { ConfigValidation { configured_path, path_exists, - path_is_valid_llvm14, + path_is_valid_llvm, detected_path, config_matches_detected, } @@ -151,18 +151,18 @@ pub fn validate_llvm_config() -> ConfigValidation { /// Automatically configure LLVM for PECOS /// -/// This function determines the best LLVM 14 installation to use and writes +/// This function determines the best LLVM installation to use and writes /// it to `.cargo/config.toml` with `force=true`. /// /// Priority order: -/// 1. `~/.pecos/deps/llvm` (PECOS-managed LLVM, new path) +/// 1. `~/.pecos/deps/llvm-{version}` (PECOS-managed LLVM, new path) /// 2. `~/.pecos/llvm` (legacy path) -/// 3. `LLVM_SYS_140_PREFIX` environment variable -/// 4. System LLVM 14 (Homebrew, system paths, etc.) +/// 3. `LLVM_SYS_211_PREFIX` environment variable +/// 4. System LLVM (Homebrew, system paths, etc.) /// /// # Errors /// -/// Returns an error if no suitable LLVM 14 installation could be found +/// Returns an error if no suitable LLVM installation could be found pub fn auto_configure_llvm(project_root: Option) -> Result { // Priority 1 & 2: Check ~/.pecos/deps/llvm and legacy ~/.pecos/llvm let mut pecos_llvm_paths = Vec::new(); @@ -174,11 +174,15 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { } #[cfg(target_os = "windows")] if let Some(home_dir) = dirs::home_dir() { - pecos_llvm_paths.push(home_dir.join(".pecos").join("LLVM-14")); + pecos_llvm_paths.push( + home_dir + .join(".pecos") + .join(format!("LLVM-{REQUIRED_VERSION}")), + ); } for pecos_llvm in pecos_llvm_paths { - if is_valid_llvm_14(&pecos_llvm) { + if is_valid_llvm(&pecos_llvm) { let project_root = project_root .or_else(get_repo_root_from_manifest) .or_else(find_cargo_project_root) @@ -189,10 +193,10 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { } } - // Priority 2: Check LLVM_SYS_140_PREFIX - if let Ok(sys_prefix) = std::env::var("LLVM_SYS_140_PREFIX") { + // Priority 2: Check LLVM_SYS_211_PREFIX + if let Ok(sys_prefix) = std::env::var(LLVM_SYS_PREFIX_ENV) { let path = PathBuf::from(&sys_prefix); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { let project_root = project_root .or_else(get_repo_root_from_manifest) .or_else(find_cargo_project_root) @@ -203,9 +207,9 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { } } - // Priority 3: Scan system for LLVM 14 + // Priority 3: Scan system for LLVM let repo_root = get_repo_root_from_manifest(); - if let Some(detected_path) = find_llvm_14(repo_root) { + if let Some(detected_path) = find_llvm(repo_root) { let project_root = project_root .or_else(get_repo_root_from_manifest) .or_else(find_cargo_project_root) @@ -215,7 +219,9 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { return Ok(detected_path); } - Err(Error::Llvm("No suitable LLVM 14 installation found".into())) + Err(Error::Llvm(format!( + "No suitable LLVM {REQUIRED_VERSION} installation found" + ))) } /// Write or update `.cargo/config.toml` with LLVM configuration @@ -233,7 +239,7 @@ pub fn write_cargo_config(project_root: &Path, llvm_path: &Path, force: bool) -> // Forward slashes keep the value backslash-escape-free in TOML. let llvm_path_str = llvm_path.to_string_lossy().replace('\\', "/"); let mut cfg = crate::cargo_config::CargoConfig::open(project_root)?; - cfg.set_env("LLVM_SYS_140_PREFIX", &llvm_path_str, force)?; + cfg.set_env(LLVM_SYS_PREFIX_ENV, &llvm_path_str, force)?; cfg.save()?; Ok(()) } diff --git a/crates/pecos-build/src/llvm/installer.rs b/crates/pecos-build/src/llvm/installer.rs index ac02ef691..d0a327f62 100644 --- a/crates/pecos-build/src/llvm/installer.rs +++ b/crates/pecos-build/src/llvm/installer.rs @@ -1,38 +1,90 @@ -//! LLVM 14.0.6 installation functionality +//! LLVM 21.1 installation functionality #![allow(clippy::case_sensitive_file_extension_comparisons)] use crate::errors::{Error, Result}; +#[cfg(target_os = "linux")] use sha2::{Digest, Sha256}; use std::fs; use std::io; use std::path::{Path, PathBuf}; -/// Known SHA256 checksums for LLVM 14.0.6 downloads -const LLVM_CHECKSUMS: &[(&str, &str)] = &[ - ( - "clang+llvm-14.0.6-x86_64-apple-darwin.tar.xz", - "e6cc6b8279661fd4452c2847cb8e55ce1e54e1faf4ab497b37c85ffdb6685e7c", - ), - ( - "clang+llvm-14.0.6-arm64-apple-darwin22.3.0.tar.xz", - "82f4f7607a16c9aaf7314b945bde6a4639836ec9d2b474ebb3a31dee33e3c15a", - ), - ( - "clang+llvm-14.0.6-x86_64-linux-gnu-rhel-8.4.tar.xz", - "7412026be8bb8f6b4c25ef58c7a1f78ed5ea039d94f0fa633a386de9c60a6942", - ), - ( - "clang+llvm-14.0.6-aarch64-linux-gnu.tar.xz", - "1a81fda984f5e607584916fdf69cf41e5385b219b983544d2c1a14950d5a65cf", - ), - ( - "LLVM-14.0.6-win64.7z", - "611e7a39363a2b63267d012a05f83ea9ce2b432a448890459c9412233327ac11", - ), +const LLVM_RELEASE_VERSION: &str = "21.1.8"; + +/// LLVM release version installed by the managed installer. +#[must_use] +pub const fn release_version() -> &'static str { + LLVM_RELEASE_VERSION +} + +/// Explain why PECOS cannot provide a managed shared LLVM install here. +#[must_use] +pub fn managed_install_unavailable_reason() -> Option<&'static str> { + #[cfg(target_os = "linux")] + { + None + } + + #[cfg(target_os = "macos")] + { + Some( + "PECOS-managed shared LLVM is currently available only on \ + Debian/Ubuntu-compatible Linux. On macOS, install LLVM 21 with \ + Homebrew (`brew install llvm@21`) and run `pecos llvm configure`.", + ) + } + + #[cfg(target_os = "windows")] + { + Some( + "PECOS-managed shared LLVM is not available on Windows MSVC because \ + LLVM does not provide the libLLVM shared-library target there. Use \ + WSL2/Linux for the full HUGR test lane, or configure a full LLVM 21 \ + package for targeted static LLVM builds with `pecos llvm configure \ + C:\\path\\to\\llvm`.", + ) + } + + #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] + { + Some( + "PECOS-managed shared LLVM is currently available only on \ + Debian/Ubuntu-compatible Linux. Install shared LLVM 21 with your \ + system package manager and run `pecos llvm configure /path/to/llvm`.", + ) + } +} + +#[cfg(target_os = "linux")] +const APT_LLVM_PACKAGES: &[&str] = &[ + "libllvm21", + "llvm-21", + "llvm-21-dev", + "llvm-21-linker-tools", + "clang-21", + "libclang-common-21-dev", + "libclang-cpp21", + "libclang1-21", ]; -/// Install LLVM 14.0.6 to `~/.pecos/deps/llvm/` +#[cfg(target_os = "linux")] +#[derive(Clone, Debug)] +struct AptLlvmSource { + base_url: String, + codename: String, + deb_arch: String, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Debug)] +struct AptPackage { + name: String, + version: String, + filename: String, + sha256: String, +} + +/// Install LLVM 21.1 to `~/.pecos/deps/llvm-21.1/` /// /// # Arguments /// * `force` - Force reinstall even if already present @@ -48,32 +100,59 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { // Check if already installed if llvm_dir.exists() { if is_valid_installation(&llvm_dir) { - if !force { - return Err(Error::Llvm( - "LLVM is already installed. Use --force to reinstall.".into(), - )); + if is_shared_installation(&llvm_dir) { + if !force { + return Err(Error::Llvm( + "LLVM is already installed. Use --force to reinstall.".into(), + )); + } + } else if !force { + let recovery = managed_install_unavailable_reason().map_or_else( + || { + "Run `pecos install llvm --force` to replace it with shared LLVM, \ + or configure your own shared LLVM with `pecos llvm configure /path/to/llvm`." + .to_string() + }, + |reason| { + format!( + "{reason} Remove the static managed install manually if you no longer need it." + ) + }, + ); + return Err(Error::Llvm(format!( + "Existing PECOS-managed LLVM is static. {recovery}" + ))); } } else if !force { + let recovery = managed_install_unavailable_reason().map_or_else( + || "Use --force to reinstall.".to_string(), + |reason| { + format!("{reason} Remove the invalid managed install manually if you no longer need it.") + }, + ); return Err(Error::Llvm(format!( - "Existing LLVM directory is not a valid LLVM 14 installation: {}. \ - Use --force to reinstall.", + "Existing LLVM directory is not a valid LLVM {} installation: {}. {recovery}", + super::REQUIRED_VERSION, llvm_dir.display() ))); } } + if let Some(reason) = managed_install_unavailable_reason() { + return Err(Error::Llvm(reason.into())); + } + // Remove existing if force if force && llvm_dir.exists() { println!("Removing existing LLVM installation..."); fs::remove_dir_all(&llvm_dir)?; } - println!("Installing LLVM 14.0.6..."); - println!("This will download ~400MB and may take 5-10 minutes."); + println!("Installing LLVM {LLVM_RELEASE_VERSION}..."); + println!("PECOS-managed LLVM uses a shared LLVM library when available."); + println!("This downloads a large toolchain and may take several minutes."); println!(); - let (url, archive_name) = get_download_url()?; - // Create parent directory if let Some(parent) = llvm_dir.parent() { fs::create_dir_all(parent)?; @@ -83,13 +162,8 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { let temp_base = llvm_dir.parent().unwrap_or(&llvm_dir).join("tmp"); let temp_dir = temp_base.join("llvm"); fs::create_dir_all(&temp_dir)?; - let archive_path = temp_dir.join(&archive_name); - // Download and verify - download_and_verify_with_retry(&url, &archive_path, &archive_name)?; - - // Extract - extract_llvm(&archive_path, &llvm_dir)?; + install_managed_llvm_payload(&llvm_dir, &temp_dir)?; // Cleanup fs::remove_dir_all(&temp_dir)?; @@ -103,12 +177,22 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { "Installation completed but verification failed".into(), )); } + if !is_shared_installation(&llvm_dir) { + return Err(Error::Llvm( + "Managed LLVM installation is static. PECOS requires managed LLVM to provide \ + shared libLLVM; configure a shared system LLVM with `pecos llvm configure /path/to/llvm`." + .into(), + )); + } verify_llvm_runtime(&llvm_dir)?; println!(); println!("Installation complete!"); - println!("LLVM 14.0.6 installed to: {}", llvm_dir.display()); + println!( + "LLVM {LLVM_RELEASE_VERSION} installed to: {}", + llvm_dir.display() + ); if no_configure { println!(); @@ -136,106 +220,175 @@ pub fn install_llvm(force: bool, no_configure: bool) -> Result { Ok(llvm_dir) } -fn get_download_url() -> Result<(String, String)> { - let os = std::env::consts::OS; - let arch = std::env::consts::ARCH; - - match os { - "macos" => { - if arch == "aarch64" { - Ok(( - "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.6/clang+llvm-14.0.6-arm64-apple-darwin22.3.0.tar.xz".to_string(), - "clang+llvm-14.0.6-arm64-apple-darwin22.3.0.tar.xz".to_string(), - )) - } else { - Ok(( - "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.6/clang+llvm-14.0.6-x86_64-apple-darwin.tar.xz".to_string(), - "clang+llvm-14.0.6-x86_64-apple-darwin.tar.xz".to_string(), - )) - } - } - "linux" => { - if arch == "x86_64" { - Ok(( - "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.6/clang+llvm-14.0.6-x86_64-linux-gnu-rhel-8.4.tar.xz".to_string(), - "clang+llvm-14.0.6-x86_64-linux-gnu-rhel-8.4.tar.xz".to_string(), - )) - } else if arch == "aarch64" { - Ok(( - "https://github.com/llvm/llvm-project/releases/download/llvmorg-14.0.6/clang+llvm-14.0.6-aarch64-linux-gnu.tar.xz".to_string(), - "clang+llvm-14.0.6-aarch64-linux-gnu.tar.xz".to_string(), - )) - } else { - Err(Error::Llvm(format!("Unsupported Linux architecture: {arch}"))) - } +fn install_managed_llvm_payload(llvm_dir: &Path, temp_dir: &Path) -> Result<()> { + #[cfg(target_os = "linux")] + { + if let Some(source) = detect_apt_llvm_source() { + return install_linux_apt_llvm(llvm_dir, temp_dir, &source); } - "windows" => Ok(( - "https://github.com/PLC-lang/llvm-package-windows/releases/download/v14.0.6/LLVM-14.0.6-win64.7z".to_string(), - "LLVM-14.0.6-win64.7z".to_string(), - )), - _ => Err(Error::Llvm(format!("Unsupported operating system: {os}"))), + + Err(Error::Llvm( + "PECOS-managed shared LLVM on Linux currently requires a Debian/Ubuntu-compatible \ + apt.llvm.org repository. Install shared LLVM 21 with your system package manager and \ + run `pecos llvm configure /path/to/llvm`." + .into(), + )) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (llvm_dir, temp_dir); + Err(Error::Llvm( + managed_install_unavailable_reason() + .unwrap_or("PECOS-managed shared LLVM is not available on this platform.") + .into(), + )) } } -fn download_and_verify_with_retry(url: &str, dest: &PathBuf, archive_name: &str) -> Result<()> { - const MAX_RETRIES: u32 = 5; - const BASE_DELAY_SECS: u64 = 10; - - for attempt in 1..=MAX_RETRIES { - if attempt > 1 { - // Exponential backoff: 10s, 20s, 40s, 80s - let delay_secs = BASE_DELAY_SECS * (1 << (attempt - 2)); - println!(); - println!("Retry attempt {attempt}/{MAX_RETRIES} (waiting {delay_secs}s)..."); - std::thread::sleep(std::time::Duration::from_secs(delay_secs)); - } +#[cfg(target_os = "linux")] +fn detect_apt_llvm_source() -> Option { + let os_release = fs::read_to_string("/etc/os-release").ok()?; + let codename = os_release_value(&os_release, "UBUNTU_CODENAME") + .or_else(|| os_release_value(&os_release, "VERSION_CODENAME"))?; + let deb_arch = match std::env::consts::ARCH { + "x86_64" => "amd64", + "aarch64" => "arm64", + _ => return None, + }; - let _ = fs::remove_file(dest); + Some(AptLlvmSource { + base_url: "https://apt.llvm.org".to_string(), + codename, + deb_arch: deb_arch.to_string(), + }) +} - if let Err(e) = download_llvm(url, dest) { - if attempt < MAX_RETRIES { - eprintln!("Download error: {e}"); - continue; - } - return Err(e); +#[cfg(target_os = "linux")] +fn os_release_value(contents: &str, key: &str) -> Option { + contents.lines().find_map(|line| { + let (name, value) = line.split_once('=')?; + if name != key { + return None; } + let value = value.trim().trim_matches('"').to_string(); + (!value.is_empty()).then_some(value) + }) +} + +#[cfg(target_os = "linux")] +fn install_linux_apt_llvm(llvm_dir: &Path, temp_dir: &Path, source: &AptLlvmSource) -> Result<()> { + println!( + "Using apt.llvm.org shared LLVM packages for {} {}.", + source.codename, source.deb_arch + ); + + let packages_url = format!( + "{}/{}/dists/llvm-toolchain-{}-21/main/binary-{}/Packages.gz", + source.base_url, source.codename, source.codename, source.deb_arch + ); + let packages_gz = temp_dir.join("Packages.gz"); + download_file(&packages_url, &packages_gz, "LLVM package index")?; + + let packages = read_apt_packages(&packages_gz)?; + let selected = select_apt_packages(&packages)?; + let deb_dir = temp_dir.join("debs"); + let root_dir = temp_dir.join("root"); + fs::create_dir_all(&deb_dir)?; + fs::create_dir_all(&root_dir)?; + + for package in selected { + let url = format!( + "{}/{}/{}", + source.base_url, source.codename, package.filename + ); + let filename = Path::new(&package.filename).file_name().ok_or_else(|| { + Error::Archive(format!("Invalid package filename: {}", package.filename)) + })?; + let deb_path = deb_dir.join(filename); + println!("Downloading {} {}...", package.name, package.version); + download_file(&url, &deb_path, &package.name)?; + verify_checksum_value(&deb_path, &package.sha256)?; + extract_deb_data(&deb_path, &root_dir)?; + } - // Check for empty downloads (CDN/rate limit issues) - let file_size = fs::metadata(dest).map_or(0, |m| m.len()); - if file_size == 0 { - if attempt < MAX_RETRIES { - eprintln!("Download returned empty file (possible CDN issue)"); - continue; + install_apt_root_as_llvm_prefix(&root_dir, llvm_dir, &source.deb_arch)?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn read_apt_packages(packages_gz: &Path) -> Result> { + let file = fs::File::open(packages_gz)?; + let mut decoder = flate2::read::GzDecoder::new(file); + let mut contents = String::new(); + io::Read::read_to_string(&mut decoder, &mut contents)?; + + let mut packages = Vec::new(); + for stanza in contents.split("\n\n") { + let mut name = None; + let mut version = None; + let mut filename = None; + let mut sha256 = None; + + for line in stanza.lines() { + if let Some(value) = line.strip_prefix("Package: ") { + name = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("Version: ") { + version = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("Filename: ") { + filename = Some(value.trim().to_string()); + } else if let Some(value) = line.strip_prefix("SHA256: ") { + sha256 = Some(value.trim().to_string()); } - return Err(Error::Llvm( - "Download returned empty file after all retries".into(), - )); } - match verify_checksum(dest, archive_name) { - Ok(()) => return Ok(()), - Err(e) => { - if attempt < MAX_RETRIES { - eprintln!(); - eprintln!("Checksum verification failed. Retrying..."); - let _ = fs::remove_file(dest); - continue; - } - return Err(e); - } + if let (Some(name), Some(version), Some(filename), Some(sha256)) = + (name, version, filename, sha256) + { + packages.push(AptPackage { + name, + version, + filename, + sha256, + }); } } - Err(Error::Llvm( - "Download and verification failed after all retries".into(), - )) + Ok(packages) } -fn download_llvm(url: &str, dest: &PathBuf) -> Result<()> { - print!("Downloading LLVM... "); +#[cfg(target_os = "linux")] +fn select_apt_packages(packages: &[AptPackage]) -> Result> { + let mut selected = Vec::with_capacity(APT_LLVM_PACKAGES.len()); + for package_name in APT_LLVM_PACKAGES { + let package = packages + .iter() + .rev() + .find(|package| { + package.name == *package_name && package.version.contains(LLVM_RELEASE_VERSION) + }) + .ok_or_else(|| { + Error::Llvm(format!( + "apt.llvm.org package {package_name} {LLVM_RELEASE_VERSION} was not found" + )) + })?; + selected.push(package.clone()); + } + Ok(selected) +} + +#[cfg(target_os = "linux")] +fn download_file(url: &str, dest: &Path, label: &str) -> Result<()> { + print!("Downloading {label}... "); io::Write::flush(&mut io::stdout())?; let response = reqwest::blocking::get(url).map_err(|e| Error::Http(e.to_string()))?; + if !response.status().is_success() { + return Err(Error::Http(format!( + "GET {url} failed: {}", + response.status() + ))); + } let total_size = response.content_length().unwrap_or(0); let mut file = fs::File::create(dest)?; @@ -257,143 +410,261 @@ fn download_llvm(url: &str, dest: &PathBuf) -> Result<()> { #[allow(clippy::cast_precision_loss)] let progress = (downloaded as f64 / total_size as f64) * 100.0; if progress - last_print >= 1.0 { - print!("\rDownloading LLVM... {progress:.0}%"); + print!("\rDownloading {label}... {progress:.0}%"); io::Write::flush(&mut io::stdout())?; last_print = progress; } } } - println!("\rDownloading LLVM... Done ({} MB)", downloaded / 1_000_000); + println!( + "\rDownloading {label}... Done ({} MB)", + downloaded / 1_000_000 + ); Ok(()) } -fn verify_checksum(file_path: &PathBuf, archive_name: &str) -> Result<()> { +#[cfg(target_os = "linux")] +fn verify_checksum_value(file_path: &Path, expected: &str) -> Result<()> { print!("Verifying checksum... "); io::Write::flush(&mut io::stdout())?; + let computed_hash = sha256_file(file_path)?; + if computed_hash == expected { + println!("OK"); + Ok(()) + } else { + println!("FAILED"); + Err(Error::Sha256Mismatch { + expected: expected.to_string(), + actual: computed_hash, + }) + } +} + +#[cfg(target_os = "linux")] +fn sha256_file(file_path: &Path) -> Result { let data = fs::read(file_path)?; let mut hasher = Sha256::new(); Digest::update(&mut hasher, &data); - let computed_hash = hasher.finalize().iter().fold(String::new(), |mut s, b| { + Ok(hasher.finalize().iter().fold(String::new(), |mut s, b| { use std::fmt::Write; write!(s, "{b:02x}").unwrap(); s - }); - - let expected_hash = LLVM_CHECKSUMS - .iter() - .find(|(name, _)| *name == archive_name) - .map(|(_, hash)| *hash); - - match expected_hash { - Some(expected) if !expected.is_empty() => { - if computed_hash == expected { - println!("OK"); - Ok(()) - } else { - println!("FAILED"); - Err(Error::Sha256Mismatch { - expected: expected.to_string(), - actual: computed_hash, - }) - } + })) +} + +#[cfg(target_os = "linux")] +fn extract_deb_data(deb_path: &Path, dest: &Path) -> Result<()> { + let data = fs::read(deb_path)?; + if !data.starts_with(b"!\n") { + return Err(Error::Archive(format!( + "{} is not a Debian ar archive", + deb_path.display() + ))); + } + + let mut offset = 8usize; + while offset + 60 <= data.len() { + let header = &data[offset..offset + 60]; + offset += 60; + + let name = String::from_utf8_lossy(&header[0..16]) + .trim() + .trim_end_matches('/') + .to_string(); + let size_text = String::from_utf8_lossy(&header[48..58]).trim().to_string(); + let size: usize = size_text.parse().map_err(|e| { + Error::Archive(format!( + "Invalid ar member size in {}: {e}", + deb_path.display() + )) + })?; + if offset + size > data.len() { + return Err(Error::Archive(format!( + "Truncated ar member in {}", + deb_path.display() + ))); } - _ => { - println!("Skipped (checksum not available)"); - Ok(()) + + let member = &data[offset..offset + size]; + offset += size + (size % 2); + + match name.as_str() { + "data.tar.zst" => { + let decoder = zstd::stream::read::Decoder::new(member)?; + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest)?; + return Ok(()); + } + "data.tar.xz" => { + let decoder = xz2::read::XzDecoder::new(member); + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest)?; + return Ok(()); + } + "data.tar.gz" => { + let decoder = flate2::read::GzDecoder::new(member); + let mut archive = tar::Archive::new(decoder); + archive.unpack(dest)?; + return Ok(()); + } + _ => {} } } -} -fn extract_llvm(archive: &PathBuf, dest: &PathBuf) -> Result<()> { - print!("Extracting LLVM... "); - io::Write::flush(&mut io::stdout())?; - - let file_name = archive - .file_name() - .and_then(|n| n.to_str()) - .ok_or_else(|| Error::Archive("Could not determine archive name".into()))?; + Err(Error::Archive(format!( + "{} did not contain a supported data.tar member", + deb_path.display() + ))) +} - if file_name.ends_with(".tar.xz") { - extract_tar_xz(archive, dest)?; - } else if file_name.ends_with(".7z") { - extract_7z(archive, dest)?; - } else { +#[cfg(target_os = "linux")] +fn install_apt_root_as_llvm_prefix(root_dir: &Path, llvm_dir: &Path, deb_arch: &str) -> Result<()> { + let apt_prefix = root_dir.join("usr").join("lib").join("llvm-21"); + if !apt_prefix.exists() { return Err(Error::Archive(format!( - "Unsupported archive format: {file_name}" + "apt.llvm.org packages did not provide {}", + apt_prefix.display() ))); } - println!("Done"); + if llvm_dir.exists() { + fs::remove_dir_all(llvm_dir)?; + } + fs::rename(&apt_prefix, llvm_dir)?; + + let gnu_arch = match deb_arch { + "amd64" => "x86_64-linux-gnu", + "arm64" => "aarch64-linux-gnu", + _ => { + return Err(Error::Archive(format!( + "Unsupported Debian architecture: {deb_arch}" + ))); + } + }; + let system_lib_dir = root_dir.join("usr").join("lib").join(gnu_arch); + let llvm_lib_dir = llvm_dir.join("lib"); + fs::create_dir_all(&llvm_lib_dir)?; + + install_apt_headers(root_dir, llvm_dir)?; + copy_shared_libraries(&system_lib_dir, &llvm_lib_dir)?; + fix_local_shared_library_links(&llvm_lib_dir)?; Ok(()) } -fn extract_tar_xz(archive: &PathBuf, dest: &PathBuf) -> Result<()> { - use tar::Archive; - use xz2::read::XzDecoder; +#[cfg(target_os = "linux")] +fn install_apt_headers(root_dir: &Path, llvm_dir: &Path) -> Result<()> { + let include_dir = llvm_dir.join("include"); + fs::create_dir_all(&include_dir)?; + + copy_dir_contents( + &root_dir + .join("usr") + .join("include") + .join("llvm-21") + .join("llvm"), + &include_dir.join("llvm"), + )?; + copy_dir_contents( + &root_dir + .join("usr") + .join("include") + .join("llvm-c-21") + .join("llvm-c"), + &include_dir.join("llvm-c"), + )?; + Ok(()) +} - let file = fs::File::open(archive)?; - let decompressor = XzDecoder::new(file); - let mut tar_archive = Archive::new(decompressor); +#[cfg(target_os = "linux")] +fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> { + if !source.exists() { + return Err(Error::Archive(format!( + "apt.llvm.org packages did not provide {}", + source.display() + ))); + } - let extract_to = dest - .parent() - .ok_or_else(|| Error::Archive("Invalid destination path".into()))?; - tar_archive.unpack(extract_to)?; + if dest.exists() || fs::symlink_metadata(dest).is_ok() { + let metadata = fs::symlink_metadata(dest)?; + if metadata.is_dir() { + fs::remove_dir_all(dest)?; + } else { + fs::remove_file(dest)?; + } + } + fs::create_dir_all(dest)?; - // Find and rename extracted directory - let archive_name = archive.file_stem().and_then(|s| s.to_str()).unwrap_or(""); - let archive_path_buf = PathBuf::from(archive_name); - let base_name = archive_path_buf - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(archive_name); - let extracted_dir = extract_to.join(base_name); + for entry in fs::read_dir(source)? { + let entry = entry?; + let source_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + let file_type = entry.file_type()?; + if file_type.is_dir() { + copy_dir_contents(&source_path, &dest_path)?; + } else if file_type.is_file() { + fs::copy(&source_path, &dest_path)?; + } else if file_type.is_symlink() { + let target = fs::read_link(&source_path)?; + std::os::unix::fs::symlink(target, dest_path)?; + } + } + Ok(()) +} - if extracted_dir.exists() && !dest.exists() { - fs::rename(&extracted_dir, dest)?; +#[cfg(target_os = "linux")] +fn copy_shared_libraries(source_dir: &Path, dest_dir: &Path) -> Result<()> { + if !source_dir.exists() { + return Ok(()); } + for entry in fs::read_dir(source_dir)? { + let entry = entry?; + let path = entry.path(); + let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if !file_name.starts_with("lib") || !file_name.contains(".so") { + continue; + } + + let dest = dest_dir.join(file_name); + if dest.exists() || fs::symlink_metadata(&dest).is_ok() { + let _ = fs::remove_file(&dest); + } + fs::copy(&path, dest)?; + } Ok(()) } -fn extract_7z(archive: &PathBuf, dest: &PathBuf) -> Result<()> { - use sevenz_rust::{Password, SevenZReader}; +#[cfg(target_os = "linux")] +fn fix_local_shared_library_links(lib_dir: &Path) -> Result<()> { + replace_symlink(lib_dir, "libLLVM-21.so", "libLLVM.so.21.1")?; + replace_symlink(lib_dir, "libLLVM.so", "libLLVM.so.21.1")?; + replace_symlink(lib_dir, "libLLVM.so.1", "libLLVM.so.21.1")?; - let file = fs::File::open(archive)?; - let len = file.metadata()?.len(); - let password = Password::empty(); - let mut reader = - SevenZReader::new(file, len, password).map_err(|e| Error::Archive(e.to_string()))?; + if lib_dir.join("libclang-cpp.so.21.1").exists() { + replace_symlink(lib_dir, "libclang-cpp.so.21", "libclang-cpp.so.21.1")?; + replace_symlink(lib_dir, "libclang-cpp.so", "libclang-cpp.so.21.1")?; + } - // Windows LLVM archives have flat structure (bin/, lib/, etc. at root) - // Extract directly to destination - fs::create_dir_all(dest)?; + Ok(()) +} - reader - .for_each_entries(|entry, reader| { - let entry_name = entry.name(); - - if entry.is_directory() { - let dir_path = dest.join(entry_name); - fs::create_dir_all(&dir_path).ok(); - } else { - let file_path = dest.join(entry_name); - if let Some(parent) = file_path.parent() { - fs::create_dir_all(parent).ok(); - } - let mut output = fs::File::create(&file_path)?; - io::copy(reader, &mut output)?; - } - Ok(true) - }) - .map_err(|e| Error::Archive(e.to_string()))?; +#[cfg(target_os = "linux")] +fn replace_symlink(lib_dir: &Path, link: &str, target: &str) -> Result<()> { + use std::os::unix::fs::symlink; + let link_path = lib_dir.join(link); + if link_path.exists() || fs::symlink_metadata(&link_path).is_ok() { + let _ = fs::remove_file(&link_path); + } + symlink(target, link_path)?; Ok(()) } -/// Remove local LLVM installation (`~/.pecos/llvm/`) +/// Remove local LLVM installation (`~/.pecos/deps/llvm-{version}/`) /// /// # Errors /// @@ -402,7 +673,10 @@ pub fn uninstall_llvm() -> Result<()> { let llvm_dir = crate::home::get_llvm_dir_path()?; if !llvm_dir.exists() { - println!("LLVM is not installed in ~/.pecos/deps/llvm/"); + println!( + "LLVM is not installed in ~/.pecos/deps/llvm-{}/", + super::REQUIRED_VERSION + ); return Ok(()); } @@ -413,7 +687,7 @@ pub fn uninstall_llvm() -> Result<()> { Ok(()) } -/// Validate that a path contains a complete LLVM 14 installation +/// Validate that a path contains a complete LLVM installation #[must_use] pub fn is_valid_installation(path: &Path) -> bool { let exe_ext = if cfg!(windows) { ".exe" } else { "" }; @@ -429,7 +703,14 @@ pub fn is_valid_installation(path: &Path) -> bool { } } - super::get_llvm_version(path).is_ok_and(|version| version.starts_with("14.")) + super::get_llvm_version(path).is_ok_and(|version| super::is_required_llvm_version(&version)) +} + +/// Return whether an LLVM installation reports shared libLLVM support. +#[must_use] +pub fn is_shared_installation(path: &Path) -> bool { + super::get_llvm_shared_mode(path).is_ok_and(|mode| mode.trim().eq_ignore_ascii_case("shared")) + && super::get_llvm_shared_libraries(path).is_some() } fn verify_llvm_runtime(llvm_dir: &Path) -> Result<()> { @@ -449,8 +730,10 @@ fn verify_llvm_runtime(llvm_dir: &Path) -> Result<()> { if output.status.success() { let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if version.starts_with("14.0") { - println!("OK (version {version})"); + if super::is_required_llvm_version(&version) { + let link_mode = + super::get_llvm_shared_mode(llvm_dir).unwrap_or_else(|_| "unknown".into()); + println!("OK (version {version}, link mode {link_mode})"); Ok(()) } else { println!("FAILED"); @@ -496,7 +779,86 @@ fn apply_platform_fixes(llvm_dir: &Path) -> Result<()> { Ok(()) } -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "linux")] +fn apply_platform_fixes(llvm_dir: &Path) -> Result<()> { + use std::os::unix::fs::{PermissionsExt, symlink}; + use std::process::Command; + + print!("Applying Linux platform fixes... "); + io::Write::flush(&mut io::stdout())?; + + let llvm_config = llvm_dir.join("bin").join("llvm-config"); + let llvm_config_real = llvm_dir.join("bin").join("llvm-config.real"); + let gnu_arch = match std::env::consts::ARCH { + "x86_64" => "x86_64-linux-gnu", + "aarch64" => "aarch64-linux-gnu", + _ => { + println!("Skipped (unsupported architecture)"); + return Ok(()); + } + }; + let missing_zstd_static = format!("/usr/lib/{gnu_arch}/libzstd.a"); + let system_zstd_runtime = [ + PathBuf::from(format!("/lib/{gnu_arch}/libzstd.so.1")), + PathBuf::from(format!("/usr/lib/{gnu_arch}/libzstd.so.1")), + ] + .into_iter() + .find(|path| path.exists()); + + let Ok(output) = Command::new(&llvm_config) + .args(["--system-libs", "--link-static"]) + .output() + else { + println!("Skipped (llvm-config unavailable)"); + return Ok(()); + }; + + let system_libs = String::from_utf8_lossy(&output.stdout); + let Some(system_zstd_runtime) = system_zstd_runtime else { + println!("Skipped"); + return Ok(()); + }; + + if !system_libs.contains(&missing_zstd_static) || Path::new(&missing_zstd_static).exists() { + println!("Skipped"); + return Ok(()); + } + + let local_zstd = llvm_dir.join("lib").join("libzstd.so"); + if !local_zstd.exists() { + symlink(&system_zstd_runtime, &local_zstd)?; + } + + if !llvm_config_real.exists() { + fs::rename(&llvm_config, &llvm_config_real)?; + } + + let escaped_zstd_static = missing_zstd_static.replace('/', "\\/"); + let wrapper = r#"#!/usr/bin/env bash +real="$(dirname "$0")/llvm-config.real" +output="$("$real" "$@")" +status=$? +if [ "$status" -ne 0 ]; then + exit "$status" +fi +case " $* " in + *" --system-libs "*) + output="${output//__PECOS_ZSTD_STATIC__/-lzstd}" + ;; +esac +printf '%s\n' "$output" +"# + .replace("__PECOS_ZSTD_STATIC__", &escaped_zstd_static); + fs::write(&llvm_config, wrapper)?; + let mut permissions = fs::metadata(&llvm_config)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&llvm_config, permissions)?; + + println!("OK"); + Ok(()) +} + +#[cfg(not(any(target_os = "macos", target_os = "linux")))] #[allow(clippy::unnecessary_wraps)] fn apply_platform_fixes(_llvm_dir: &Path) -> Result<()> { Ok(()) diff --git a/crates/pecos-build/src/prompt.rs b/crates/pecos-build/src/prompt.rs index ce650a333..a4850e9b9 100644 --- a/crates/pecos-build/src/prompt.rs +++ b/crates/pecos-build/src/prompt.rs @@ -18,7 +18,7 @@ pub enum PromptMode { /// Prompt the user with a yes/no question. /// -/// - `message`: The question to display (e.g. "Install LLVM 14 to ~/.pecos/deps/llvm/ (~400MB)?") +/// - `message`: The question to display (e.g. "Install LLVM 21.1 to ~/.pecos/deps/llvm-21.1/?") /// - `default_yes`: Whether the default answer is yes (`[Y/n]`) or no (`[y/N]`) /// - `mode`: How to resolve the prompt /// diff --git a/crates/pecos-cli/src/cli.rs b/crates/pecos-cli/src/cli.rs index 1dab58b1e..d0f2ea8de 100644 --- a/crates/pecos-cli/src/cli.rs +++ b/crates/pecos-cli/src/cli.rs @@ -188,12 +188,12 @@ pub enum SeleneCommands { #[derive(Subcommand, Clone)] pub enum LlvmCommands { - /// Check if LLVM 14 is available + /// Check if LLVM 21.1 is available Check { #[arg(short, long)] quiet: bool, }, - /// Ensure LLVM 14 is installed and runtime-valid + /// Ensure LLVM 21.1 is installed and runtime-valid Ensure { /// Require the PECOS-managed installation under ~/.pecos/deps #[arg(long)] @@ -203,8 +203,11 @@ pub enum LlvmCommands { #[arg(long)] no_configure: bool, }, - /// Configure .cargo/config.toml with LLVM path - Configure, + /// Configure .cargo/config.toml with detected LLVM or an explicit LLVM path + Configure { + /// LLVM installation prefix to configure, e.g. /usr/lib/llvm-21 + path: Option, + }, /// Find LLVM installation path Find { #[arg(long)] diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index 4ad0fc4a0..70a19412d 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -24,6 +24,7 @@ //! pecos env --github-actions # write GitHub Actions env/path files use std::collections::BTreeMap; +use std::ffi::OsString; use std::fmt::Write; use std::fs::OpenOptions; use std::io::Write as IoWrite; @@ -31,6 +32,7 @@ use std::path::Path; use pecos_build::Result; use pecos_build::errors::Error; +use pecos_build::llvm::LLVM_SYS_PREFIX_ENV; /// Collect the build environment for the current platform. /// @@ -41,10 +43,10 @@ pub fn collect_env() -> BTreeMap { let mut env = BTreeMap::new(); // LLVM - if let Some(llvm_path) = pecos_build::llvm::find_llvm_14(None) { + if let Some(llvm_path) = pecos_build::llvm::find_configured_or_detected_llvm(None) { let llvm_str = llvm_path.display().to_string(); env.insert("PECOS_LLVM".into(), llvm_str.clone()); - env.insert("LLVM_SYS_140_PREFIX".into(), llvm_str); + env.insert(LLVM_SYS_PREFIX_ENV.into(), llvm_str); // Add LLVM bin to PATH let bin_path = llvm_path.join("bin"); @@ -56,6 +58,13 @@ pub fn collect_env() -> BTreeMap { env.insert("PATH".into(), path.to_string_lossy().into_owned()); } } + + if pecos_build::llvm::get_llvm_shared_mode(&llvm_path) + .is_ok_and(|mode| mode.trim().eq_ignore_ascii_case("shared")) + && let Ok(libdir) = pecos_build::llvm::get_llvm_libdir(&llvm_path) + { + add_llvm_runtime_library_path(&mut env, &libdir); + } } // macOS-specific @@ -122,6 +131,36 @@ pub fn collect_env() -> BTreeMap { env } +#[cfg(any(target_os = "linux", target_os = "freebsd"))] +fn add_llvm_runtime_library_path(env: &mut BTreeMap, libdir: &Path) { + prepend_path_env(env, "LD_LIBRARY_PATH", libdir); +} + +#[cfg(target_os = "macos")] +fn add_llvm_runtime_library_path(env: &mut BTreeMap, libdir: &Path) { + prepend_path_env(env, "DYLD_LIBRARY_PATH", libdir); +} + +#[cfg(target_os = "windows")] +fn add_llvm_runtime_library_path(_env: &mut BTreeMap, _libdir: &Path) { + // Windows LLVM DLLs are expected in bin/, which is already prepended to PATH. +} + +fn prepend_path_env(env: &mut BTreeMap, key: &str, first: &Path) { + let current = env + .get(key) + .map(OsString::from) + .or_else(|| std::env::var_os(key)); + let mut entries = vec![first.to_path_buf()]; + if let Some(current) = current { + entries.extend(std::env::split_paths(¤t)); + } + + if let Ok(joined) = std::env::join_paths(entries) { + env.insert(key.to_string(), joined.to_string_lossy().into_owned()); + } +} + /// Print environment in shell-eval format: `export KEY="VALUE"` pub fn print_shell(env: &BTreeMap) { for (key, value) in env { @@ -191,7 +230,7 @@ fn write_github_actions_files( .append(true) .create(true) .open(github_path)?; - if let Some(llvm_path) = env.get("LLVM_SYS_140_PREFIX") { + if let Some(llvm_path) = env.get(LLVM_SYS_PREFIX_ENV) { writeln!(path_file, "{}", Path::new(llvm_path).join("bin").display())?; } @@ -226,15 +265,15 @@ mod tests { let env_path = std::env::temp_dir().join(format!("pecos-gh-env-{unique}")); let path_path = std::env::temp_dir().join(format!("pecos-gh-path-{unique}")); - let llvm_prefix = Path::new("/opt/pecos/llvm-14"); + let llvm_prefix = Path::new("/opt/pecos/llvm-21.1"); let llvm_prefix_str = llvm_prefix.display().to_string(); let llvm_bin_str = llvm_prefix.join("bin").display().to_string(); let mut env = BTreeMap::new(); - env.insert("LLVM_SYS_140_PREFIX".to_string(), llvm_prefix_str.clone()); + env.insert(LLVM_SYS_PREFIX_ENV.to_string(), llvm_prefix_str.clone()); env.insert( "PATH".to_string(), - "/opt/pecos/llvm-14/bin:/usr/bin".to_string(), + "/opt/pecos/llvm-21.1/bin:/usr/bin".to_string(), ); env.insert("PECOS_LLVM".to_string(), llvm_prefix_str.clone()); @@ -243,7 +282,7 @@ mod tests { let env_file = std::fs::read_to_string(&env_path).unwrap(); let path_file = std::fs::read_to_string(&path_path).unwrap(); - assert!(env_file.contains(&format!("LLVM_SYS_140_PREFIX={llvm_prefix_str}"))); + assert!(env_file.contains(&format!("{LLVM_SYS_PREFIX_ENV}={llvm_prefix_str}"))); assert!(env_file.contains(&format!("PECOS_LLVM={llvm_prefix_str}"))); assert!(!env_file.contains("PATH=")); assert_eq!(path_file.trim(), llvm_bin_str); diff --git a/crates/pecos-cli/src/cli/info.rs b/crates/pecos-cli/src/cli/info.rs index 182cb1a39..7ae10d8e1 100644 --- a/crates/pecos-cli/src/cli/info.rs +++ b/crates/pecos-cli/src/cli/info.rs @@ -3,7 +3,9 @@ use pecos_build::cuda::{find_cuda, get_cuda_version}; use pecos_build::cuquantum::{find_cuquantum, get_cuquantum_version}; use pecos_build::home::{get_cache_dir, get_deps_dir, get_llvm_dir, get_pecos_home}; -use pecos_build::llvm::{find_llvm_14, get_llvm_version, get_repo_root_from_manifest}; +use pecos_build::llvm::{ + LLVM_SYS_PREFIX_ENV, find_llvm, get_llvm_version, get_repo_root_from_manifest, +}; use std::process::Command; /// Run the info command @@ -99,8 +101,8 @@ pub fn run() { println!(" PECOS_CACHE_DIR = {val}"); has_overrides = true; } - if let Ok(val) = std::env::var("LLVM_SYS_140_PREFIX") { - println!(" LLVM_SYS_140_PREFIX = {val}"); + if let Ok(val) = std::env::var(LLVM_SYS_PREFIX_ENV) { + println!(" {LLVM_SYS_PREFIX_ENV} = {val}"); has_overrides = true; } @@ -115,11 +117,11 @@ fn print_toolchain_status() { // LLVM let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_llvm(repo_root) { let version = get_llvm_version(&llvm_path).unwrap_or_else(|_| "unknown".to_string()); - println!(" LLVM 14: {} ({})", version, llvm_path.display()); + println!(" LLVM 21.1: {} ({})", version, llvm_path.display()); } else { - println!(" LLVM 14: not found"); + println!(" LLVM 21.1: not found"); } // CUDA diff --git a/crates/pecos-cli/src/cli/install_cmd.rs b/crates/pecos-cli/src/cli/install_cmd.rs index 6c988b03f..9dc8e348c 100644 --- a/crates/pecos-cli/src/cli/install_cmd.rs +++ b/crates/pecos-cli/src/cli/install_cmd.rs @@ -8,7 +8,13 @@ use pecos_build::prompt::{PromptMode, confirm}; const KNOWN_TARGETS: &[&str] = &["cuda", "llvm", "cuquantum", "cmake"]; /// Run the install command -pub fn run(targets: &[String], force: bool, all: bool, no_configure: bool) -> Result<()> { +pub fn run( + targets: &[String], + force: bool, + all: bool, + no_configure: bool, + yes: bool, +) -> Result<()> { let targets: Vec<&str> = if all { KNOWN_TARGETS.to_vec() } else { @@ -58,10 +64,14 @@ pub fn run(targets: &[String], force: bool, all: bool, no_configure: bool) -> Re if confirm( " Install a PECOS-managed copy to ~/.pecos/deps/ instead?", false, - PromptMode::Interactive, + if yes { + PromptMode::AcceptAll + } else { + PromptMode::Interactive + }, ) { println!(); - install_target(target, true, no_configure)?; + install_target(target, true, no_configure, yes)?; } } if *target == "llvm" { @@ -70,7 +80,7 @@ pub fn run(targets: &[String], force: bool, all: bool, no_configure: bool) -> Re } else { println!("[{}/{}] Installing {target}...", i + 1, total); println!(); - install_target(target, force, no_configure)?; + install_target(target, force, no_configure, yes)?; } println!(); } @@ -83,7 +93,7 @@ pub fn run(targets: &[String], force: bool, all: bool, no_configure: bool) -> Re fn find_existing(target: &str) -> Option { match target { "cuda" => pecos_build::cuda::find_cuda(), - "llvm" => pecos_build::llvm::find_llvm_14(None), + "llvm" => pecos_build::llvm::find_llvm(None), "cuquantum" => pecos_build::cuquantum::find_cuquantum(), "cmake" => pecos_build::cmake::find_cmake(), _ => None, @@ -91,12 +101,13 @@ fn find_existing(target: &str) -> Option { } /// Install a single target -fn install_target(target: &str, force: bool, no_configure: bool) -> Result<()> { +fn install_target(target: &str, force: bool, no_configure: bool, yes: bool) -> Result<()> { match target { "cuda" => { pecos_build::cuda::installer::install_cuda(force)?; } "llvm" => { + confirm_managed_llvm_install(yes)?; pecos_build::llvm::installer::install_llvm(force, no_configure)?; } "cuquantum" => { @@ -110,6 +121,43 @@ fn install_target(target: &str, force: bool, no_configure: bool) -> Result<()> { Ok(()) } +fn confirm_managed_llvm_install(yes: bool) -> Result<()> { + let version = pecos_build::llvm::installer::release_version(); + let install_dir = pecos_build::home::get_llvm_dir_path()?; + + if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() { + return Err(Error::Config(reason.into())); + } + + println!("PECOS-managed LLVM is the recommended development setup."); + println!( + "This will install LLVM {version} to {}.", + install_dir.display() + ); + println!("Expect a large download and several GB of extracted files."); + println!("The managed install is shared-first; static LLVM is not accepted for"); + println!("the full workspace HUGR test lane because LLVM 21.1 static links can"); + println!("use substantial memory."); + println!(); + println!("To use your own LLVM instead, run:"); + println!(" pecos llvm configure /path/to/llvm"); + println!(); + + let mode = if yes { + PromptMode::AcceptAll + } else { + PromptMode::Interactive + }; + + if confirm("Continue with PECOS-managed LLVM install?", true, mode) { + Ok(()) + } else { + Err(Error::Config( + "LLVM installation cancelled. Configure an existing LLVM with `pecos llvm configure /path/to/llvm`.".into(), + )) + } +} + /// Ensure LLVM is configured in .cargo/config.toml when already installed. /// Auto-configures if not healthy, unless --no-configure was passed. fn ensure_llvm_configured(no_configure: bool) { diff --git a/crates/pecos-cli/src/cli/list.rs b/crates/pecos-cli/src/cli/list.rs index 3290620b7..8b94108a1 100644 --- a/crates/pecos-cli/src/cli/list.rs +++ b/crates/pecos-cli/src/cli/list.rs @@ -4,7 +4,7 @@ use pecos_build::cuda::find_cuda; use pecos_build::cuquantum::find_cuquantum; use pecos_build::deps::list_dependencies; use pecos_build::home::{get_cache_dir, get_deps_dir}; -use pecos_build::llvm::{find_llvm_14, get_llvm_version, get_repo_root_from_manifest}; +use pecos_build::llvm::{find_llvm, get_llvm_version, get_repo_root_from_manifest}; use std::fs; use std::path::Path; @@ -16,13 +16,15 @@ pub fn run(verbose: bool) { // LLVM status let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_llvm(repo_root) { let version = get_llvm_version(&llvm_path) .map(|v| format!(" ({v})")) .unwrap_or_default(); - println!("LLVM 14: {}{version}", llvm_path.display()); + println!("LLVM 21.1: {}{version}", llvm_path.display()); + } else if pecos_build::llvm::installer::managed_install_unavailable_reason().is_some() { + println!("LLVM 21.1: not found (configure shared LLVM 21 manually)"); } else { - println!("LLVM 14: not found (install with: pecos install llvm)"); + println!("LLVM 21.1: not found (install with: pecos install llvm)"); } // CUDA status diff --git a/crates/pecos-cli/src/cli/llvm_cmd.rs b/crates/pecos-cli/src/cli/llvm_cmd.rs index c415a5c3a..1d83f987e 100644 --- a/crates/pecos-cli/src/cli/llvm_cmd.rs +++ b/crates/pecos-cli/src/cli/llvm_cmd.rs @@ -2,10 +2,14 @@ use super::LlvmCommands; use pecos_build::Result; -use pecos_build::llvm::config::{auto_configure_llvm, validate_llvm_config}; +use pecos_build::llvm::config::{auto_configure_llvm, validate_llvm_config, write_cargo_config}; use pecos_build::llvm::{ - find_llvm_14, find_tool, get_llvm_version, get_pecos_command, get_repo_root_from_manifest, + LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, find_cargo_project_root, + find_configured_or_detected_llvm, find_llvm, find_tool, get_llvm_shared_libraries, + get_llvm_shared_mode, get_llvm_version, get_pecos_command, get_repo_root_from_manifest, + is_valid_llvm, }; +use std::path::Path; /// Run an LLVM subcommand pub fn run(command: LlvmCommands) -> Result<()> { @@ -18,7 +22,7 @@ pub fn run(command: LlvmCommands) -> Result<()> { managed, no_configure, } => run_ensure(managed, no_configure), - LlvmCommands::Configure => run_configure(), + LlvmCommands::Configure { path } => run_configure(path), LlvmCommands::Find { export } => { run_find(export); Ok(()) @@ -34,12 +38,13 @@ pub fn run(command: LlvmCommands) -> Result<()> { fn run_check(quiet: bool) { let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_configured_or_detected_llvm(repo_root) { if !quiet { - println!("LLVM 14 found at: {}", llvm_path.display()); + println!("LLVM 21.1 found at: {}", llvm_path.display()); if let Ok(version) = get_llvm_version(&llvm_path) { println!("Version: {version}"); } + print_link_info(&llvm_path); // Validate configuration let validation = validate_llvm_config(); @@ -53,9 +58,17 @@ fn run_check(quiet: bool) { } else { if !quiet { let cmd = get_pecos_command(); - eprintln!("LLVM 14 not found"); + eprintln!("LLVM 21.1 not found"); eprintln!(); - eprintln!("Install with: `{cmd} install llvm`"); + if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() + { + eprintln!("{reason}"); + eprintln!(); + eprintln!("After installing LLVM 21, configure it with:"); + eprintln!(" {cmd} llvm configure /path/to/llvm"); + } else { + eprintln!("Install with: `{cmd} install llvm`"); + } } std::process::exit(1); } @@ -64,7 +77,7 @@ fn run_check(quiet: bool) { fn run_ensure(managed: bool, no_configure: bool) -> Result<()> { let llvm_path = if managed { ensure_managed_llvm(no_configure)? - } else if let Some(path) = find_llvm_14(get_repo_root_from_manifest()) { + } else if let Some(path) = find_llvm(get_repo_root_from_manifest()) { if !no_configure { auto_configure_llvm(None)?; } @@ -99,8 +112,27 @@ fn ensure_managed_llvm(no_configure: bool) -> Result { Ok(llvm_path) } -fn run_configure() -> Result<()> { - let llvm_path = auto_configure_llvm(None)?; +fn run_configure(path: Option) -> Result<()> { + let llvm_path = if let Some(path) = path { + let llvm_path = std::path::PathBuf::from(path); + if !is_valid_llvm(&llvm_path) { + return Err(pecos_build::errors::Error::Llvm(format!( + "{} is not a valid LLVM {REQUIRED_VERSION} installation", + llvm_path.display() + ))); + } + + let project_root = get_repo_root_from_manifest() + .or_else(find_cargo_project_root) + .ok_or_else(|| { + pecos_build::errors::Error::Config("Could not find Cargo project root".into()) + })?; + write_cargo_config(&project_root, &llvm_path, true)?; + llvm_path + } else { + auto_configure_llvm(None)? + }; + println!("Configured LLVM path: {}", llvm_path.display()); println!("Updated .cargo/config.toml"); Ok(()) @@ -108,27 +140,27 @@ fn run_configure() -> Result<()> { fn run_find(export: bool) { let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_configured_or_detected_llvm(repo_root) { if export { - println!("export LLVM_SYS_140_PREFIX=\"{}\"", llvm_path.display()); + println!("export {LLVM_SYS_PREFIX_ENV}=\"{}\"", llvm_path.display()); } else { println!("{}", llvm_path.display()); } } else { - eprintln!("LLVM 14 not found"); + eprintln!("LLVM 21.1 not found"); std::process::exit(1); } } fn run_version() -> Result<()> { let repo_root = get_repo_root_from_manifest(); - if let Some(llvm_path) = find_llvm_14(repo_root) { + if let Some(llvm_path) = find_configured_or_detected_llvm(repo_root) { let version = get_llvm_version(&llvm_path)?; println!("LLVM version: {version}"); println!("Location: {}", llvm_path.display()); Ok(()) } else { - eprintln!("LLVM 14 not found"); + eprintln!("LLVM 21.1 not found"); std::process::exit(1); } } @@ -138,9 +170,9 @@ fn run_validate(path: Option) -> Result<()> { std::path::PathBuf::from(p) } else { let repo_root = get_repo_root_from_manifest(); - find_llvm_14(repo_root).ok_or_else(|| { + find_configured_or_detected_llvm(repo_root).ok_or_else(|| { pecos_build::errors::Error::Llvm( - "LLVM 14 not found. Specify a path or install first.".into(), + "LLVM 21.1 not found. Specify a path or install first.".into(), ) })? }; @@ -172,10 +204,10 @@ fn run_validate(path: Option) -> Result<()> { // Check version println!(); if let Ok(version) = get_llvm_version(&llvm_path) { - if version.starts_with("14.") { + if pecos_build::llvm::is_required_llvm_version(&version) { println!("Version: {version} [OK]"); } else { - println!("Version: {version} [WARNING: expected 14.x]"); + println!("Version: {version} [WARNING: expected {REQUIRED_VERSION}]"); all_present = false; } } else { @@ -183,6 +215,9 @@ fn run_validate(path: Option) -> Result<()> { all_present = false; } + print_link_info(&llvm_path); + println!(); + println!(); if all_present { println!("Validation: PASSED"); @@ -202,3 +237,15 @@ fn run_tool(name: &str) { std::process::exit(1); } } + +fn print_link_info(llvm_path: &Path) { + if let Ok(mode) = get_llvm_shared_mode(llvm_path) { + println!("Link mode: {mode}"); + } + + if let Some(libraries) = get_llvm_shared_libraries(llvm_path) { + println!("Shared library: {libraries}"); + } else { + println!("Shared library: unavailable"); + } +} diff --git a/crates/pecos-cli/src/cli/rust_cmd.rs b/crates/pecos-cli/src/cli/rust_cmd.rs index 8ddda43e0..4ac013805 100644 --- a/crates/pecos-cli/src/cli/rust_cmd.rs +++ b/crates/pecos-cli/src/cli/rust_cmd.rs @@ -3,6 +3,7 @@ use pecos_build::Result; use pecos_build::errors::Error; use serde_json::Value; +use std::path::PathBuf; use std::process::Command; /// FFI crates that need a non-Rust toolchain or external SDK to check / @@ -63,6 +64,65 @@ enum GpuProbeResult { ProbeFailed(String), } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum LlvmLinkMode { + Shared, + Static, + Unknown, +} + +impl LlvmLinkMode { + fn from_llvm_config(output: &str) -> Self { + match output.trim().to_ascii_lowercase().as_str() { + "shared" => Self::Shared, + "static" => Self::Static, + _ => Self::Unknown, + } + } +} + +fn detect_cargo_llvm_link_mode() -> Option<(PathBuf, LlvmLinkMode)> { + let llvm_path = pecos_build::llvm::find_configured_or_detected_llvm(None)?; + let link_mode = pecos_build::llvm::get_llvm_shared_mode(&llvm_path) + .map_or(LlvmLinkMode::Unknown, |mode| { + LlvmLinkMode::from_llvm_config(&mode) + }); + Some((llvm_path, link_mode)) +} + +fn reject_static_llvm_workspace_test() -> Result<()> { + let Some((llvm_path, link_mode)) = detect_cargo_llvm_link_mode() else { + return Ok(()); + }; + if matches!(link_mode, LlvmLinkMode::Shared) { + return Ok(()); + } + + let mode = if matches!(link_mode, LlvmLinkMode::Static) { + "static" + } else { + "unknown" + }; + + let setup_hint = pecos_build::llvm::installer::managed_install_unavailable_reason().map_or( + "Install/configure shared LLVM 21.1 instead, for example `pecos install llvm --force` \ + or `pecos llvm configure /path/to/llvm`." + .to_string(), + |reason| { + format!( + "{reason} `pecos rust test` requires shared LLVM; use targeted Cargo tests if you must build against static LLVM." + ) + }, + ); + + Err(Error::Config(format!( + "Refusing full workspace HUGR tests with {mode} LLVM at {}. \ + LLVM 21.1 static workspace tests can spawn many multi-GB linker jobs. \ + {setup_hint}", + llvm_path.display() + ))) +} + /// Run the rust subcommand pub fn run(command: &super::RustCommands) -> Result<()> { match command { @@ -202,7 +262,7 @@ fn is_tool_available(tool: &str) -> bool { /// Run a cargo command and return success status. /// -/// Applies the PECOS build environment (`CMAKE`, `LLVM_SYS_140_PREFIX`, +/// Applies the PECOS build environment (`CMAKE`, `LLVM_SYS_211_PREFIX`, /// `SDKROOT`, etc.) so build scripts like highs-sys's cmake-rs invocation /// find the PECOS-managed cmake without further plumbing. fn run_cargo_command(args: &[&str]) -> bool { @@ -478,6 +538,7 @@ fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { ]); args.extend(profile_args); + reject_static_llvm_workspace_test()?; if !run(&args) { return Err(Error::Config("cargo test (workspace) failed".to_string())); @@ -550,3 +611,24 @@ fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { println!("cargo test completed successfully"); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn llvm_link_mode_parses_llvm_config_output() { + assert_eq!( + LlvmLinkMode::from_llvm_config("shared\n"), + LlvmLinkMode::Shared + ); + assert_eq!( + LlvmLinkMode::from_llvm_config("STATIC"), + LlvmLinkMode::Static + ); + assert_eq!( + LlvmLinkMode::from_llvm_config("unknown"), + LlvmLinkMode::Unknown + ); + } +} diff --git a/crates/pecos-cli/src/cli/setup_cmd.rs b/crates/pecos-cli/src/cli/setup_cmd.rs index c24311310..0b28e1883 100644 --- a/crates/pecos-cli/src/cli/setup_cmd.rs +++ b/crates/pecos-cli/src/cli/setup_cmd.rs @@ -67,7 +67,7 @@ pub fn run( } fn has_missing_deps(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool) -> bool { - if !skip_llvm && pecos_build::llvm::find_llvm_14(None).is_none() { + if !skip_llvm && pecos_build::llvm::find_llvm(None).is_none() { return true; } if !skip_cuda && cuda_platform_supported() && pecos_build::cuda::find_cuda().is_none() { @@ -97,11 +97,15 @@ fn print_status_summary(skip_llvm: bool, skip_cuda: bool, skip_cmake: bool) { // LLVM if skip_llvm { - println!(" LLVM 14: skipped (--skip-llvm)"); - } else if let Some(path) = pecos_build::llvm::find_llvm_14(None) { - println!(" LLVM 14: {}", path.display()); + println!(" LLVM 21.1: skipped (--skip-llvm)"); + } else if let Some(path) = pecos_build::llvm::find_llvm(None) { + println!(" LLVM 21.1: {}", path.display()); + } else if pecos_build::llvm::installer::managed_install_unavailable_reason().is_some() { + println!(" LLVM 21.1: not found (configure a shared LLVM 21 install manually)"); } else { - println!(" LLVM 14: not found (~400 MB, required for QIR/HUGR compilation)"); + println!( + " LLVM 21.1: not found (several hundred MB, required for QIR/HUGR compilation)" + ); } // CUDA @@ -282,14 +286,23 @@ fn check_legacy_deps(mode: PromptMode) -> Result<()> { // ── LLVM ──────────────────────────────────────────────────────────────────── fn setup_llvm(mode: PromptMode) -> Result<()> { - if pecos_build::llvm::find_llvm_14(None).is_some() { + if pecos_build::llvm::find_llvm(None).is_some() { ensure_llvm_configured(); return Ok(()); } + if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() { + println!(" LLVM 21.1 not found."); + println!(" {reason}"); + println!(" QIR/HUGR features will not be available until LLVM is configured."); + return Ok(()); + } + let version = pecos_build::home::LLVM_VERSION; if confirm( - &format!("Install LLVM {version}? (~400 MB download, required for QIR/HUGR)"), + &format!( + "Install PECOS-managed LLVM {version}? (large download, several GB extracted, shared LLVM required; use `pecos llvm configure /path/to/llvm` for your own install)" + ), true, mode, ) { diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index 7cbf4ad67..eab5089b5 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -24,7 +24,7 @@ use pecos_build::cuda::find_cuda; #[cfg(feature = "runtime")] use pecos_build::cuquantum::{find_cuquantum, get_cuquantum_version}; #[cfg(feature = "runtime")] -use pecos_build::llvm::get_llvm_version; +use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, get_llvm_version}; #[cfg(feature = "runtime")] use std::io::Write; @@ -97,7 +97,7 @@ enum Commands { #[command(subcommand)] command: GpuCommands, }, - /// LLVM 14 inspection, validation, and configuration + /// LLVM 21.1 inspection, validation, and configuration Llvm { #[command(subcommand)] command: LlvmCommands, @@ -189,6 +189,10 @@ enum Commands { /// Skip automatic configuration after installation (applies to llvm) #[arg(long)] no_configure: bool, + + /// Accept installation prompts + #[arg(short, long)] + yes: bool, }, /// Uninstall optional dependencies (cuda, llvm, cuquantum) /// @@ -728,7 +732,8 @@ fn main() -> Result<(), Box> { force, all, no_configure, - } => cli::install_cmd::run(targets, *force, *all, *no_configure)?, + yes, + } => cli::install_cmd::run(targets, *force, *all, *no_configure, *yes)?, Commands::Uninstall { targets, all, yes } => { cli::uninstall_cmd::run(targets, *all, *yes)?; } @@ -903,8 +908,8 @@ fn run_doctor() { let mut problems: Vec = Vec::new(); let mut hints: Vec = Vec::new(); - // --- LLVM 14 --- - println!("LLVM 14:"); + // --- LLVM 21.1 --- + println!("LLVM 21.1:"); let llvm_config = pecos_build::llvm::config::validate_llvm_config(); if let Some(ref path) = llvm_config.detected_path { let version = get_llvm_version(path).unwrap_or_else(|_| "unknown".into()); @@ -915,11 +920,15 @@ fn run_doctor() { ); } else { print_check("installed", false, "not found"); - problems.push("LLVM 14 not installed. Run: pecos install llvm".into()); + if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() { + problems.push(format!("LLVM 21.1 not installed. {reason}")); + } else { + problems.push("LLVM 21.1 not installed. Run: pecos install llvm".into()); + } } if let Some(ref path) = llvm_config.configured_path { - if llvm_config.path_is_valid_llvm14 { + if llvm_config.path_is_valid_llvm { print_check(".cargo/config.toml", true, &format!("{}", path.display())); } else if !llvm_config.path_exists { print_check( @@ -932,32 +941,36 @@ fn run_doctor() { print_check( ".cargo/config.toml", false, - &format!("path exists but is not valid LLVM 14: {}", path.display()), + &format!("path exists but is not valid LLVM 21.1: {}", path.display()), ); problems.push( - "LLVM path in .cargo/config.toml is not valid LLVM 14. Run: pecos llvm configure" + "LLVM path in .cargo/config.toml is not valid LLVM 21.1. Run: pecos llvm configure" .into(), ); } } else { - print_check(".cargo/config.toml", false, "LLVM_SYS_140_PREFIX not set"); + print_check( + ".cargo/config.toml", + false, + &format!("{LLVM_SYS_PREFIX_ENV} not set"), + ); if llvm_config.detected_path.is_some() { problems.push("LLVM installed but not configured. Run: pecos llvm configure".into()); } } - if let Ok(env_val) = std::env::var("LLVM_SYS_140_PREFIX") { + if let Ok(env_val) = std::env::var(LLVM_SYS_PREFIX_ENV) { let env_path = std::path::Path::new(&env_val); if env_path.exists() { - print_check("LLVM_SYS_140_PREFIX env", true, &env_val); + print_check(&format!("{LLVM_SYS_PREFIX_ENV} env"), true, &env_val); } else { print_check( - "LLVM_SYS_140_PREFIX env", + &format!("{LLVM_SYS_PREFIX_ENV} env"), false, &format!("set but path missing: {env_val}"), ); problems.push(format!( - "LLVM_SYS_140_PREFIX={env_val} but path does not exist" + "{LLVM_SYS_PREFIX_ENV}={env_val} but path does not exist" )); } } diff --git a/crates/pecos-hugr-qis/Cargo.toml b/crates/pecos-hugr-qis/Cargo.toml index f6c4a6937..1a6f641a6 100644 --- a/crates/pecos-hugr-qis/Cargo.toml +++ b/crates/pecos-hugr-qis/Cargo.toml @@ -24,6 +24,9 @@ pecos-core = { workspace = true, features = ["anyhow"] } # LLVM features are required for this crate's core functionality tket = { workspace = true, features = ["llvm"] } tket-qsystem = { workspace = true, features = ["llvm"] } +# `hugr-llvm` only exposes plain `llvm21-1`; this keeps its transitive +# inkwell/llvm-sys build preferring libLLVM when a shared LLVM is available. +inkwell.workspace = true [features] default = [] diff --git a/crates/pecos-hugr-qis/src/array.rs b/crates/pecos-hugr-qis/src/array.rs index 772b8a979..6ddd3b73d 100644 --- a/crates/pecos-hugr-qis/src/array.rs +++ b/crates/pecos-hugr-qis/src/array.rs @@ -21,10 +21,8 @@ impl ArrayCodegen for SeleneHeapArrayCodegen { size: IntValue<'c>, ) -> Result> { let iw_ctx = ctx.typing_session().iw_context(); - let malloc_sig = iw_ctx - .i8_type() - .ptr_type(AddressSpace::default()) - .fn_type(&[iw_ctx.i64_type().into()], false); + let ptr_ty = iw_ctx.ptr_type(AddressSpace::default()); + let malloc_sig = ptr_ty.fn_type(&[iw_ctx.i64_type().into()], false); let malloc = ctx.get_extern_func("heap_alloc", malloc_sig)?; let res = ctx .builder() @@ -40,7 +38,7 @@ impl ArrayCodegen for SeleneHeapArrayCodegen { ptr: PointerValue<'c>, ) -> Result<()> { let iw_ctx = ctx.typing_session().iw_context(); - let ptr_ty = iw_ctx.i8_type().ptr_type(AddressSpace::default()); + let ptr_ty = iw_ctx.ptr_type(AddressSpace::default()); let ptr = ctx.builder().build_bit_cast(ptr, ptr_ty, "")?; let free_sig = iw_ctx.void_type().fn_type(&[ptr_ty.into()], false); diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 91bf4eb0d..e1348c085 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -53,6 +53,7 @@ use tket::hugr::llvm::{ use tket::hugr::ops::DataflowParent; use tket::hugr::{Hugr, HugrView, Node}; use tket::llvm::rotation::RotationCodegenExtension; +use tket::passes::ComposablePass; use tket_qsystem::QSystemPass; use tket_qsystem::llvm::array_utils::ArrayLowering; use tket_qsystem::llvm::futures::FuturesCodegenExtension; @@ -494,7 +495,9 @@ pub fn compile_hugr_bytes_to_bitcode_with_options( let module = compile(args, &context, &mut hugr) .map_err(|e| PecosError::Generic(format!("Compilation failed: {e}")))?; - // Write to memory buffer and get bitcode + // Write to memory buffer and get bitcode. `as_slice()` includes LLVM's + // trailing C-string NUL, which is not part of the bitcode stream. let buffer = module.write_bitcode_to_memory(); - Ok(buffer.as_slice().to_vec()) + let bitcode = buffer.as_slice(); + Ok(bitcode[..bitcode.len().saturating_sub(1)].to_vec()) } diff --git a/crates/pecos-hugr/Cargo.toml b/crates/pecos-hugr/Cargo.toml index 117ea07e8..2b509302c 100644 --- a/crates/pecos-hugr/Cargo.toml +++ b/crates/pecos-hugr/Cargo.toml @@ -29,9 +29,6 @@ serde_json.workspace = true # HUGR support tket.workspace = true tket-qsystem.workspace = true -# Pin hugr-core to 0.25.6 to avoid strum 0.27/0.28 conflict with tket 0.17. -# See workspace Cargo.toml for details. Remove once tket supports strum 0.28+. -hugr-core.workspace = true # Workspace dependencies pecos-core.workspace = true diff --git a/crates/pecos-llvm/Cargo.toml b/crates/pecos-llvm/Cargo.toml index 2e4cf0752..70aca136c 100644 --- a/crates/pecos-llvm/Cargo.toml +++ b/crates/pecos-llvm/Cargo.toml @@ -17,7 +17,6 @@ pecos-core.workspace = true # Inkwell for LLVM IR generation [dependencies.inkwell] workspace = true -features = ["llvm14-0"] [features] default = [] diff --git a/crates/pecos-llvm/README.md b/crates/pecos-llvm/README.md index b67c55385..e6a5fb748 100644 --- a/crates/pecos-llvm/README.md +++ b/crates/pecos-llvm/README.md @@ -16,12 +16,12 @@ Provides Rust types for generating LLVM IR, designed to be compatible with Pytho ## Relationship to pecos-build -- **pecos-build**: Manages LLVM 14 *installation* (downloading, finding) -- **pecos-llvm**: *Uses* LLVM 14 (via inkwell) for IR generation +- **pecos-build**: Manages LLVM 21.1 *installation* (downloading, finding) +- **pecos-llvm**: *Uses* LLVM 21.1 (via inkwell) for IR generation ## Requirements -Requires LLVM 14. Install with: +Requires LLVM 21.1. Install with: ```bash cargo run -p pecos -- install llvm ``` diff --git a/crates/pecos-llvm/build.rs b/crates/pecos-llvm/build.rs index 10908bd87..40337fad9 100644 --- a/crates/pecos-llvm/build.rs +++ b/crates/pecos-llvm/build.rs @@ -5,44 +5,48 @@ fn main() { } fn validate_llvm() { - use pecos_build::llvm::is_valid_llvm_14; + use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, is_valid_llvm}; use std::env; use std::path::PathBuf; - // Check if LLVM_SYS_140_PREFIX is already set and valid - if let Ok(sys_prefix) = env::var("LLVM_SYS_140_PREFIX") { + // Check if LLVM_SYS_PREFIX_ENV is already set and valid + if let Ok(sys_prefix) = env::var(LLVM_SYS_PREFIX_ENV) { let path = PathBuf::from(&sys_prefix); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { // LLVM is configured and valid, we're good! return; } eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("ERROR: Invalid LLVM_SYS_140_PREFIX"); + eprintln!("ERROR: Invalid {LLVM_SYS_PREFIX_ENV}"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("LLVM_SYS_140_PREFIX is set to: {sys_prefix}"); - eprintln!("But this is not a valid LLVM 14 installation."); + eprintln!("{LLVM_SYS_PREFIX_ENV} is set to: {sys_prefix}"); + eprintln!("But this is not a valid LLVM {REQUIRED_VERSION} installation."); eprintln!(); eprintln!("Please either:"); - eprintln!(" 1. Fix the path to point to a valid LLVM 14 installation"); + eprintln!(" 1. Fix the path to point to a valid LLVM {REQUIRED_VERSION} installation"); eprintln!(" 2. Unset it and configure LLVM:"); - eprintln!(" unset LLVM_SYS_140_PREFIX"); + eprintln!(" unset {LLVM_SYS_PREFIX_ENV}"); eprintln!(" pecos llvm configure"); eprintln!("═══════════════════════════════════════════════════════════════\n"); - panic!("Invalid LLVM_SYS_140_PREFIX. See error message above."); + panic!("Invalid {LLVM_SYS_PREFIX_ENV}. See error message above."); } - // LLVM_SYS_140_PREFIX not set - print setup instructions + // LLVM_SYS_PREFIX_ENV not set - print setup instructions print_llvm_not_found_error_extended(); - panic!("LLVM 14 not configured. See error message above for setup instructions."); + panic!( + "LLVM {REQUIRED_VERSION} not configured. See error message above for setup instructions." + ); } fn print_llvm_not_found_error_extended() { + use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION}; + eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("LLVM 14 Setup Required for pecos-qir"); + eprintln!("LLVM {REQUIRED_VERSION} Setup Required for pecos-qir"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("The pecos-qir crate requires LLVM 14 for QIR generation."); + eprintln!("The pecos-qir crate requires LLVM {REQUIRED_VERSION} for QIR generation."); eprintln!("Choose one of these installation methods:"); eprintln!(); eprintln!("Option 1: Use pecos setup (recommended)"); @@ -50,14 +54,16 @@ fn print_llvm_not_found_error_extended() { eprintln!(" cargo build"); eprintln!(); eprintln!(" This detects and installs all missing dependencies."); - eprintln!(" (LLVM 14: ~400 MB download, installs to ~/.pecos/deps/llvm-14/)"); + eprintln!( + " (LLVM {REQUIRED_VERSION}: several hundred MB download, installs to ~/.pecos/deps/llvm-{REQUIRED_VERSION}/)" + ); eprintln!(); #[cfg(target_os = "macos")] { eprintln!("Option 2: Install via Homebrew"); - eprintln!(" # Install LLVM 14"); - eprintln!(" brew install llvm@14"); + eprintln!(" # Install LLVM 21"); + eprintln!(" brew install llvm@21"); eprintln!(); eprintln!(" # Configure PECOS to use it"); eprintln!(" pecos llvm configure"); @@ -75,14 +81,14 @@ fn print_llvm_not_found_error_extended() { eprintln!(); eprintln!(" Debian/Ubuntu:"); eprintln!(" sudo apt update"); - eprintln!(" sudo apt install llvm-14 llvm-14-dev"); + eprintln!(" sudo apt install llvm-21 llvm-21-dev"); eprintln!(); eprintln!(" Fedora/RHEL:"); - eprintln!(" sudo dnf install llvm14 llvm14-devel"); + eprintln!(" sudo dnf install llvm21 llvm21-devel"); eprintln!(); eprintln!(" Arch Linux:"); - eprintln!(" # LLVM 14 may need to be built from AUR"); - eprintln!(" yay -S llvm14"); + eprintln!(" # LLVM 21 may need to come from an alternate repository"); + eprintln!(" yay -S llvm21"); eprintln!(); eprintln!(" Then configure and build:"); eprintln!(" pecos llvm configure"); @@ -102,7 +108,7 @@ fn print_llvm_not_found_error_extended() { eprintln!(" https://github.com/vovkos/llvm-package-windows"); eprintln!(); eprintln!(" After extracting to C:\\LLVM (or similar):"); - eprintln!(" set LLVM_SYS_140_PREFIX=C:\\LLVM"); + eprintln!(" set {LLVM_SYS_PREFIX_ENV}=C:\\LLVM"); eprintln!(" pecos llvm configure"); eprintln!(" cargo build"); eprintln!(); @@ -112,9 +118,9 @@ fn print_llvm_not_found_error_extended() { eprintln!(" Instead of 'configure', you can set environment variables:"); eprintln!(); #[cfg(target_os = "windows")] - eprintln!(" set LLVM_SYS_140_PREFIX=C:\\path\\to\\llvm"); + eprintln!(" set {LLVM_SYS_PREFIX_ENV}=C:\\path\\to\\llvm"); #[cfg(not(target_os = "windows"))] - eprintln!(" export LLVM_SYS_140_PREFIX=/path/to/llvm"); + eprintln!(" export {LLVM_SYS_PREFIX_ENV}=/path/to/llvm"); #[cfg(not(target_os = "windows"))] eprintln!(" Or add llvm-config to PATH:"); #[cfg(not(target_os = "windows"))] diff --git a/crates/pecos-llvm/src/lib.rs b/crates/pecos-llvm/src/lib.rs index 8919bcb8b..4563acf94 100644 --- a/crates/pecos-llvm/src/lib.rs +++ b/crates/pecos-llvm/src/lib.rs @@ -25,6 +25,6 @@ pub mod prelude; // Re-export main types at crate root for convenience pub use llvm_compat::{ - LLConstant, LLContext, LLFunction, LLFunctionType, LLIRBuilder, LLModule, LLResult, LLType, - LLValue, + LLConstant, LLContext, LLFunction, LLFunctionType, LLIRBuilder, LLModule, LLPointerValue, + LLResult, LLType, LLValue, gep_result_pointee_type, }; diff --git a/crates/pecos-llvm/src/llvm_compat.rs b/crates/pecos-llvm/src/llvm_compat.rs index 90ad38f80..4a2f5b92c 100644 --- a/crates/pecos-llvm/src/llvm_compat.rs +++ b/crates/pecos-llvm/src/llvm_compat.rs @@ -32,14 +32,13 @@ use inkwell::basic_block::BasicBlock; use inkwell::builder::Builder; use inkwell::context::Context; use inkwell::module::Module; -use inkwell::types::{ - ArrayType, BasicType, BasicTypeEnum, FloatType, IntType, PointerType, StructType, -}; +use inkwell::types::{ArrayType, BasicTypeEnum, FloatType, IntType, PointerType, StructType}; use inkwell::values::{ ArrayValue, BasicValueEnum, FloatValue, FunctionValue, GlobalValue, IntValue, PointerValue, }; use inkwell::{AddressSpace, IntPredicate}; use pecos_core::prelude::PecosError; +use std::num::NonZeroU32; pub type LLResult = Result; @@ -117,7 +116,9 @@ impl<'ctx> LLModule<'ctx> { /// Get the LLVM bitcode as bytes pub fn to_bitcode(&self) -> Vec { - self.module.write_bitcode_to_memory().as_slice().to_vec() + let buffer = self.module.write_bitcode_to_memory(); + let bitcode = buffer.as_slice(); + bitcode[..bitcode.len().saturating_sub(1)].to_vec() } /// Get an identified (opaque) type by name, creating it if it doesn't exist @@ -146,7 +147,7 @@ impl<'ctx> LLModule<'ctx> { match init_val { LLValue::Int(v) => global.set_initializer(&v), LLValue::Float(v) => global.set_initializer(&v), - LLValue::Pointer(v) => global.set_initializer(&v), + LLValue::Pointer(v) => global.set_initializer(&v.value()), LLValue::Array(v) => global.set_initializer(&v), } } @@ -236,6 +237,37 @@ pub enum LLType<'ctx> { Array(ArrayType<'ctx>), } +fn basic_type_to_ll_type(ty: BasicTypeEnum<'_>) -> Option> { + match ty { + BasicTypeEnum::ArrayType(t) => Some(LLType::Array(t)), + BasicTypeEnum::FloatType(t) => Some(LLType::Float(t)), + BasicTypeEnum::IntType(t) => Some(LLType::Int(t)), + BasicTypeEnum::PointerType(t) => Some(LLType::Pointer(t)), + BasicTypeEnum::StructType(t) => Some(LLType::Struct(t)), + BasicTypeEnum::ScalableVectorType(_) | BasicTypeEnum::VectorType(_) => None, + } +} + +#[must_use] +pub fn gep_result_pointee_type<'ctx>( + pointee_type: LLType<'ctx>, + indices: &[IntValue<'ctx>], +) -> Option> { + let mut current = pointee_type; + for index in indices.iter().skip(1) { + current = match current { + LLType::Array(t) => basic_type_to_ll_type(t.get_element_type())?, + LLType::Struct(t) => { + let field_index = u32::try_from(index.get_zero_extended_constant()?).ok()?; + basic_type_to_ll_type(t.get_field_type_at_index(field_index)?)? + } + LLType::Int(_) | LLType::Float(_) | LLType::Pointer(_) => current, + LLType::Void => return None, + }; + } + Some(current) +} + // inkwell 0.8.0 only derives `Hash` for `IntType`; the other type wrappers // are `Eq` (LLVM type-ref pointer equality) but not `Hash`. Hash the same // `LLVMTypeRef` pointer so `Hash` stays consistent with that `Eq`. @@ -282,13 +314,21 @@ impl<'ctx> LLType<'ctx> { match bits { // Use custom_width_int_type(1) instead of bool_type() to match llvmlite // llvmlite renders i1 constants as "i1 1" and "i1 0", not "i1 true" and "i1 false" - 1 => LLType::Int(context.custom_width_int_type(1)), + 1 => LLType::Int( + context + .custom_width_int_type(NonZeroU32::new(1).expect("nonzero width")) + .expect("i1 is a valid LLVM integer width"), + ), 8 => LLType::Int(context.i8_type()), 16 => LLType::Int(context.i16_type()), 32 => LLType::Int(context.i32_type()), 64 => LLType::Int(context.i64_type()), 128 => LLType::Int(context.i128_type()), - _ => LLType::Int(context.custom_width_int_type(bits)), + _ => LLType::Int( + context + .custom_width_int_type(NonZeroU32::new(bits).expect("nonzero width")) + .expect("valid LLVM integer width"), + ), } } @@ -315,15 +355,12 @@ impl<'ctx> LLType<'ctx> { #[must_use] pub fn as_pointer(&self, context: &'ctx Context) -> LLType<'ctx> { match self { - LLType::Void => { - // Void pointers are represented as i8* - LLType::Pointer(context.i8_type().ptr_type(AddressSpace::default())) - } - LLType::Int(t) => LLType::Pointer(t.ptr_type(AddressSpace::default())), - LLType::Float(t) => LLType::Pointer(t.ptr_type(AddressSpace::default())), + LLType::Void + | LLType::Int(_) + | LLType::Float(_) + | LLType::Struct(_) + | LLType::Array(_) => LLType::Pointer(context.ptr_type(AddressSpace::default())), LLType::Pointer(t) => LLType::Pointer(*t), // Already a pointer - LLType::Struct(t) => LLType::Pointer(t.ptr_type(AddressSpace::default())), - LLType::Array(t) => LLType::Pointer(t.ptr_type(AddressSpace::default())), } } @@ -372,12 +409,39 @@ impl<'ctx> LLType<'ctx> { // Value wrappers // ============================================================================ +/// Pointer value plus the pointee type needed by LLVM opaque-pointer APIs. +#[derive(Clone, Copy)] +pub struct LLPointerValue<'ctx> { + value: PointerValue<'ctx>, + pointee_type: Option>, +} + +impl<'ctx> LLPointerValue<'ctx> { + #[must_use] + pub fn new(value: PointerValue<'ctx>, pointee_type: Option>) -> Self { + Self { + value, + pointee_type, + } + } + + #[must_use] + pub fn value(self) -> PointerValue<'ctx> { + self.value + } + + #[must_use] + pub fn pointee_type(self) -> Option> { + self.pointee_type + } +} + /// Wrapper for LLVM values that mirrors llvmlite's value types #[derive(Clone, Copy)] pub enum LLValue<'ctx> { Int(IntValue<'ctx>), Float(FloatValue<'ctx>), - Pointer(PointerValue<'ctx>), + Pointer(LLPointerValue<'ctx>), Array(ArrayValue<'ctx>), } @@ -387,7 +451,7 @@ impl<'ctx> LLValue<'ctx> { match self { LLValue::Int(v) => (*v).into(), LLValue::Float(v) => (*v).into(), - LLValue::Pointer(v) => (*v).into(), + LLValue::Pointer(v) => v.value().into(), LLValue::Array(v) => (*v).into(), } } @@ -411,7 +475,15 @@ impl<'ctx> LLValue<'ctx> { #[must_use] pub fn as_pointer_value(&self) -> PointerValue<'ctx> { match self { - LLValue::Pointer(v) => *v, + LLValue::Pointer(v) => v.value(), + _ => panic!("Expected pointer value"), + } + } + + #[must_use] + pub fn pointer_pointee_type(&self) -> Option> { + match self { + LLValue::Pointer(v) => v.pointee_type(), _ => panic!("Expected pointer value"), } } @@ -686,7 +758,7 @@ impl<'ctx> LLIRBuilder<'ctx> { Ok(call_site.try_as_basic_value().basic().map(|v| match v { BasicValueEnum::IntValue(i) => LLValue::Int(i), - BasicValueEnum::PointerValue(p) => LLValue::Pointer(p), + BasicValueEnum::PointerValue(p) => LLValue::Pointer(LLPointerValue::new(p, None)), _ => panic!("Unsupported return value type"), })) } @@ -740,13 +812,28 @@ impl<'ctx> LLIRBuilder<'ctx> { name: &str, ) -> LLResult> { let idx_values: Vec<_> = indices.iter().map(LLValue::as_int_value).collect(); + let pointee_type = ptr + .pointer_pointee_type() + .ok_or_else(|| PecosError::Generic("gep: pointer pointee type is unknown".into()))?; + let basic_pointee_type = pointee_type + .to_basic_metadata_type() + .ok_or_else(|| PecosError::Generic("gep: pointer to void is not supported".into()))?; + let result_pointee_type = gep_result_pointee_type(pointee_type, &idx_values); unsafe { let result = self .builder - .build_gep(ptr.as_pointer_value(), &idx_values, name) + .build_gep( + basic_pointee_type, + ptr.as_pointer_value(), + &idx_values, + name, + ) .map_err(|e| PecosError::Generic(format!("Failed to build gep: {e}")))?; - Ok(LLValue::Pointer(result)) + Ok(LLValue::Pointer(LLPointerValue::new( + result, + result_pointee_type, + ))) } } @@ -764,19 +851,25 @@ impl<'ctx> LLIRBuilder<'ctx> { .builder .build_alloca(basic_ty, name) .map_err(|e| PecosError::Generic(format!("Failed to build alloca: {e}")))?; - Ok(LLValue::Pointer(result)) + Ok(LLValue::Pointer(LLPointerValue::new(result, Some(ll_type)))) } - /// `load` (LLVM-14 typed pointer: pointee inferred from `ptr`). + /// `load` (LLVM opaque-pointer API requires an explicit pointee type). pub fn load(&self, ptr: LLValue<'ctx>, name: &str) -> LLResult> { + let pointee_type = ptr + .pointer_pointee_type() + .ok_or_else(|| PecosError::Generic("load: pointer pointee type is unknown".into()))?; + let basic_pointee_type = pointee_type + .to_basic_metadata_type() + .ok_or_else(|| PecosError::Generic("load: pointer to void is not supported".into()))?; let result = self .builder - .build_load(ptr.as_pointer_value(), name) + .build_load(basic_pointee_type, ptr.as_pointer_value(), name) .map_err(|e| PecosError::Generic(format!("Failed to build load: {e}")))?; Ok(match result { BasicValueEnum::IntValue(v) => LLValue::Int(v), BasicValueEnum::FloatValue(v) => LLValue::Float(v), - BasicValueEnum::PointerValue(v) => LLValue::Pointer(v), + BasicValueEnum::PointerValue(v) => LLValue::Pointer(LLPointerValue::new(v, None)), BasicValueEnum::ArrayValue(v) => LLValue::Array(v), other => { return Err(PecosError::Generic(format!( @@ -905,7 +998,7 @@ impl LLConstant { match ll_type { LLType::Int(t) => Ok(LLValue::Int(t.const_zero())), LLType::Float(t) => Ok(LLValue::Float(t.const_zero())), - LLType::Pointer(t) => Ok(LLValue::Pointer(t.const_zero())), + LLType::Pointer(t) => Ok(LLValue::Pointer(LLPointerValue::new(t.const_zero(), None))), LLType::Array(t) => Ok(LLValue::Array(t.const_zero())), LLType::Void | LLType::Struct(_) => Err(PecosError::Generic( "Cannot create a zero constant for void/struct type".to_string(), diff --git a/crates/pecos-llvm/src/prelude.rs b/crates/pecos-llvm/src/prelude.rs index d87f93232..06c7224ab 100644 --- a/crates/pecos-llvm/src/prelude.rs +++ b/crates/pecos-llvm/src/prelude.rs @@ -17,6 +17,6 @@ //! This module re-exports the main public API for LLVM IR generation. pub use crate::llvm_compat::{ - LLConstant, LLContext, LLFunction, LLFunctionType, LLIRBuilder, LLModule, LLResult, LLType, - LLValue, + LLConstant, LLContext, LLFunction, LLFunctionType, LLIRBuilder, LLModule, LLPointerValue, + LLResult, LLType, LLValue, gep_result_pointee_type, }; diff --git a/crates/pecos-qis/Cargo.toml b/crates/pecos-qis/Cargo.toml index d58b89ef1..ee8636375 100644 --- a/crates/pecos-qis/Cargo.toml +++ b/crates/pecos-qis/Cargo.toml @@ -55,7 +55,6 @@ libloading.workspace = true # Inkwell for LLVM support (optional) [dependencies.inkwell] workspace = true -features = ["llvm14-0"] optional = true [dependencies.selene-simple-runtime] diff --git a/crates/pecos-qis/build.rs b/crates/pecos-qis/build.rs index 8b4d9bcc6..ccaede835 100644 --- a/crates/pecos-qis/build.rs +++ b/crates/pecos-qis/build.rs @@ -20,7 +20,7 @@ fn main() { validate_llvm(); // Embed LLVM bin path at compile time for runtime use - if let Ok(llvm_prefix) = env::var("LLVM_SYS_140_PREFIX") { + if let Ok(llvm_prefix) = env::var(pecos_build::llvm::LLVM_SYS_PREFIX_ENV) { let llvm_bin = PathBuf::from(&llvm_prefix).join("bin"); println!("cargo:rustc-env=PECOS_LLVM_BIN_PATH={}", llvm_bin.display()); } @@ -32,57 +32,63 @@ fn main() { #[cfg(feature = "llvm")] fn validate_llvm() { - use pecos_build::llvm::is_valid_llvm_14; + use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, is_valid_llvm}; - // Check if LLVM_SYS_140_PREFIX is already set and valid - if let Ok(sys_prefix) = env::var("LLVM_SYS_140_PREFIX") { + // Check if LLVM_SYS_PREFIX_ENV is already set and valid + if let Ok(sys_prefix) = env::var(LLVM_SYS_PREFIX_ENV) { let path = PathBuf::from(&sys_prefix); - if is_valid_llvm_14(&path) { + if is_valid_llvm(&path) { // LLVM is configured and valid, we're good! return; } eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("ERROR: Invalid LLVM_SYS_140_PREFIX"); + eprintln!("ERROR: Invalid {LLVM_SYS_PREFIX_ENV}"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("LLVM_SYS_140_PREFIX is set to: {sys_prefix}"); - eprintln!("But this is not a valid LLVM 14 installation."); + eprintln!("{LLVM_SYS_PREFIX_ENV} is set to: {sys_prefix}"); + eprintln!("But this is not a valid LLVM {REQUIRED_VERSION} installation."); eprintln!(); eprintln!("Please either:"); - eprintln!(" 1. Fix the path to point to a valid LLVM 14 installation"); + eprintln!(" 1. Fix the path to point to a valid LLVM {REQUIRED_VERSION} installation"); eprintln!(" 2. Unset it and configure LLVM:"); - eprintln!(" unset LLVM_SYS_140_PREFIX"); + eprintln!(" unset {LLVM_SYS_PREFIX_ENV}"); eprintln!(" pecos llvm configure"); eprintln!("═══════════════════════════════════════════════════════════════\n"); - panic!("Invalid LLVM_SYS_140_PREFIX. See error message above."); + panic!("Invalid {LLVM_SYS_PREFIX_ENV}. See error message above."); } - // LLVM_SYS_140_PREFIX not set - print setup instructions + // LLVM_SYS_PREFIX_ENV not set - print setup instructions print_llvm_not_found_error_extended(); - panic!("LLVM 14 not configured. See error message above for setup instructions."); + panic!( + "LLVM {REQUIRED_VERSION} not configured. See error message above for setup instructions." + ); } #[cfg(feature = "llvm")] fn print_llvm_not_found_error_extended() { + use pecos_build::llvm::{LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION}; + eprintln!("\n═══════════════════════════════════════════════════════════════"); - eprintln!("LLVM 14 Setup Required"); + eprintln!("LLVM {REQUIRED_VERSION} Setup Required"); eprintln!("═══════════════════════════════════════════════════════════════"); eprintln!(); - eprintln!("PECOS needs LLVM 14. Choose one of these installation methods:"); + eprintln!("PECOS needs LLVM {REQUIRED_VERSION}. Choose one of these installation methods:"); eprintln!(); eprintln!("Option 1: Use pecos setup (recommended)"); eprintln!(" pecos setup"); eprintln!(" cargo build"); eprintln!(); eprintln!(" This detects and installs all missing dependencies."); - eprintln!(" (LLVM 14: ~400 MB download, installs to ~/.pecos/deps/llvm-14/)"); + eprintln!( + " (LLVM {REQUIRED_VERSION}: several hundred MB download, installs to ~/.pecos/deps/llvm-{REQUIRED_VERSION}/)" + ); eprintln!(); #[cfg(target_os = "macos")] { eprintln!("Option 2: Install via Homebrew"); - eprintln!(" # Install LLVM 14"); - eprintln!(" brew install llvm@14"); + eprintln!(" # Install LLVM 21"); + eprintln!(" brew install llvm@21"); eprintln!(); eprintln!(" # Configure PECOS to use it"); eprintln!(" pecos llvm configure"); @@ -100,14 +106,14 @@ fn print_llvm_not_found_error_extended() { eprintln!(); eprintln!(" Debian/Ubuntu:"); eprintln!(" sudo apt update"); - eprintln!(" sudo apt install llvm-14 llvm-14-dev"); + eprintln!(" sudo apt install llvm-21 llvm-21-dev"); eprintln!(); eprintln!(" Fedora/RHEL:"); - eprintln!(" sudo dnf install llvm14 llvm14-devel"); + eprintln!(" sudo dnf install llvm21 llvm21-devel"); eprintln!(); eprintln!(" Arch Linux:"); - eprintln!(" # LLVM 14 may need to be built from AUR"); - eprintln!(" yay -S llvm14"); + eprintln!(" # LLVM 21 may need to come from an alternate repository"); + eprintln!(" yay -S llvm21"); eprintln!(); eprintln!(" Then configure and build:"); eprintln!(" pecos llvm configure"); @@ -127,7 +133,7 @@ fn print_llvm_not_found_error_extended() { eprintln!(" https://github.com/vovkos/llvm-package-windows"); eprintln!(); eprintln!(" After extracting to C:\\LLVM (or similar):"); - eprintln!(" set LLVM_SYS_140_PREFIX=C:\\LLVM"); + eprintln!(" set {LLVM_SYS_PREFIX_ENV}=C:\\LLVM"); eprintln!(" pecos llvm configure"); eprintln!(" cargo build"); eprintln!(); @@ -137,9 +143,9 @@ fn print_llvm_not_found_error_extended() { eprintln!(" Instead of 'configure', you can set environment variables:"); eprintln!(); #[cfg(target_os = "windows")] - eprintln!(" set LLVM_SYS_140_PREFIX=C:\\path\\to\\llvm"); + eprintln!(" set {LLVM_SYS_PREFIX_ENV}=C:\\path\\to\\llvm"); #[cfg(not(target_os = "windows"))] - eprintln!(" export LLVM_SYS_140_PREFIX=/path/to/llvm"); + eprintln!(" export {LLVM_SYS_PREFIX_ENV}=/path/to/llvm"); #[cfg(not(target_os = "windows"))] eprintln!(" Or add llvm-config to PATH:"); #[cfg(not(target_os = "windows"))] diff --git a/crates/pecos-qis/build_selene.rs b/crates/pecos-qis/build_selene.rs index 11a44ec00..599bc3098 100644 --- a/crates/pecos-qis/build_selene.rs +++ b/crates/pecos-qis/build_selene.rs @@ -4,6 +4,8 @@ //! It is only compiled when the `selene` feature is enabled. use log::info; +#[cfg(target_os = "windows")] +use pecos_build::llvm::LLVM_SYS_PREFIX_ENV; use std::env; use std::path::{Path, PathBuf}; use std::process::Command; @@ -186,7 +188,7 @@ EXPORTS // Try to use llvm-dlltool (from LLVM) or dlltool (from MinGW) to generate import library // First try llvm-dlltool which should be available with our LLVM installation - let dlltool_result = if let Ok(llvm_prefix) = env::var("LLVM_SYS_140_PREFIX") { + let dlltool_result = if let Ok(llvm_prefix) = env::var(LLVM_SYS_PREFIX_ENV) { let llvm_dlltool = PathBuf::from(llvm_prefix) .join("bin") .join("llvm-dlltool.exe"); @@ -208,7 +210,7 @@ EXPORTS } else { Err(std::io::Error::new( std::io::ErrorKind::NotFound, - "LLVM_SYS_140_PREFIX not set", + format!("{LLVM_SYS_PREFIX_ENV} not set"), )) }; diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index d1ba56774..6bf0764d8 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -593,7 +593,7 @@ fn find_helios_lib() -> Result { /// Find an LLVM tool with the following priority: /// 1. Embedded path from build time (`PECOS_LLVM_BIN_PATH`) -/// 2. Runtime `LLVM_SYS_140_PREFIX` environment variable +/// 2. Runtime `LLVM_SYS_211_PREFIX` environment variable /// 3. Fall back to PATH fn find_llvm_tool(tool_name: &str) -> PathBuf { let tool_exe = if cfg!(windows) { @@ -617,13 +617,13 @@ fn find_llvm_tool(tool_name: &str) -> PathBuf { } }) .or_else(|| { - std::env::var("LLVM_SYS_140_PREFIX") + std::env::var("LLVM_SYS_211_PREFIX") .ok() .and_then(|prefix| { let path = PathBuf::from(prefix).join("bin").join(&tool_exe); if path.exists() { debug!( - "Using {} from LLVM_SYS_140_PREFIX: {}", + "Using {} from LLVM_SYS_211_PREFIX: {}", tool_name, path.display() ); @@ -1556,26 +1556,9 @@ entry: so_path_for_clang.display() ); - // Build clang command with platform-specific flags - // Try to find clang: first check LLVM_SYS_140_PREFIX, then fall back to PATH - let clang_cmd_path = std::env::var("LLVM_SYS_140_PREFIX") - .ok() - .and_then(|prefix| { - let mut path = PathBuf::from(prefix); - path.push("bin"); - path.push(if cfg!(windows) { "clang.exe" } else { "clang" }); - if path.exists() { - debug!("Using clang from LLVM_SYS_140_PREFIX: {}", path.display()); - Some(path) - } else { - None - } - }) - .unwrap_or_else(|| { - debug!("Using clang from PATH"); - PathBuf::from("clang") - }); - + // Use the same LLVM tool resolver as llvm-as/llvm-link so runtime linking + // honors the build-time LLVM prefix embedded in Python wheels. + let clang_cmd_path = find_llvm_tool("clang"); let mut clang_cmd = Command::new(&clang_cmd_path); // On Windows, we need to be more careful with paths and flags diff --git a/crates/pecos-qis/src/lib.rs b/crates/pecos-qis/src/lib.rs index 44478f886..76338a0fd 100644 --- a/crates/pecos-qis/src/lib.rs +++ b/crates/pecos-qis/src/lib.rs @@ -34,7 +34,7 @@ //! //! # LLVM Setup //! -//! This crate requires LLVM 14 for QIR (Quantum Intermediate Representation) support. +//! This crate requires LLVM 21.1 for QIR (Quantum Intermediate Representation) support. //! //! If the build fails, run: //! diff --git a/crates/pecos/README.md b/crates/pecos/README.md index 47fc78eea..2c01e426d 100644 --- a/crates/pecos/README.md +++ b/crates/pecos/README.md @@ -15,6 +15,6 @@ Provides a unified API for PECOS users. Most users should depend on this crate r ## Feature Flags - `runtime` (default): Full simulation with QASM/PHIR support -- `qis`: QIS/LLVM IR execution (requires LLVM 14) +- `qis`: QIS/LLVM IR execution (requires LLVM 21.1) - `hugr`: HUGR program support - `quest`, `qulacs`: Additional quantum backends diff --git a/crates/pecos/src/lib.rs b/crates/pecos/src/lib.rs index 95244ce20..7e4ad9700 100644 --- a/crates/pecos/src/lib.rs +++ b/crates/pecos/src/lib.rs @@ -5,7 +5,7 @@ //! - **`core`**: Core types and error handling //! - **`sim`**: Quantum simulation (includes core + num) //! - **`runtime`**: Full simulation with QASM + PHIR support -//! - **`qis`**: QIS/LLVM IR execution (requires LLVM 14) +//! - **`qis`**: QIS/LLVM IR execution (requires LLVM 21.1) //! - **`hugr`**: HUGR program support //! - **`quest`/`qulacs`/`cppsparsestab`**: Simulator backends //! - **`num`**: Numerical computing (scipy-like) diff --git a/docs/README.md b/docs/README.md index 9eab704d6..d0db09e6f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -101,7 +101,7 @@ For OpenQASM, PHIR, or other formats, see the [User Guide](user-guide/getting-st - **Fast Simulation**: Leverages a fast stabilizer simulation algorithm. - **Multi-language extensions**: Core functionalities implemented via Rust for performance and safety. Additional add-ons and extension support in C/C++ via Cython. -- **QIR Support**: Execute Quantum Intermediate Representation programs (requires LLVM version 14). +- **QIR Support**: Execute Quantum Intermediate Representation programs (requires LLVM version 21.1). ## Available Implementations diff --git a/docs/development/DEVELOPMENT.md b/docs/development/DEVELOPMENT.md index 750b79a05..87207b00c 100644 --- a/docs/development/DEVELOPMENT.md +++ b/docs/development/DEVELOPMENT.md @@ -67,9 +67,9 @@ For developers who want to contribute or modify PECOS: Combine groups with multiple `--group` flags (e.g. `uv sync --group examples --group cuda`). -6. **LLVM 14 Setup (Required for LLVM IR/QIS Support)** +6. **LLVM 21.1 Setup (Required for LLVM IR/QIS Support)** - PECOS requires LLVM version 14 for LLVM IR execution features. + PECOS requires LLVM version 21.1 for LLVM IR execution features. **Quick setup:** ```sh @@ -77,7 +77,9 @@ For developers who want to contribute or modify PECOS: cargo build ``` - For detailed installation instructions for all platforms (macOS, Linux, Windows), see the [**LLVM Setup Guide**](../user-guide/llvm-setup.md). + `pecos install llvm` is the managed shared-LLVM path on supported + Debian/Ubuntu-compatible Linux systems. For macOS, Windows, and other Linux + distributions, see the [**LLVM Setup Guide**](../user-guide/llvm-setup.md). 7. You may wish to explicitly activate the environment for development. To do so: @@ -176,7 +178,7 @@ PECOS uses `~/.pecos/` to store external dependencies and build artifacts that c ``` ~/.pecos/ -├── llvm/ # LLVM-14 installation (for QIR/LLVM IR execution) +├── deps/llvm-21.1/ # LLVM 21.1 installation (for QIR/LLVM IR execution) ├── deps/ # Downloaded C++ dependencies (Stim, etc.) └── cache/ # Build artifacts and intermediate files ``` diff --git a/docs/development/dev-tools.md b/docs/development/dev-tools.md index 774dacfae..9c11440af 100644 --- a/docs/development/dev-tools.md +++ b/docs/development/dev-tools.md @@ -24,7 +24,7 @@ pecos python build --profile release # Release build pecos python build --profile native # Release + native-CPU codegen (Rust and C++) # Dependency installation -pecos install llvm # Install LLVM 14 to ~/.pecos/deps/llvm-14/ +pecos install llvm # Install managed LLVM 21.1 where supported pecos install cuda # Install CUDA Toolkit to ~/.pecos/deps/cuda/ pecos install cuquantum # Install cuQuantum SDK to ~/.pecos/deps/cuquantum/ pecos install --all # Install all optional dependencies @@ -33,7 +33,8 @@ pecos upgrade llvm # Upgrade (force reinstall) LLVM # Inspection pecos llvm check # Check LLVM installation status -pecos llvm configure # Configure .cargo/config.toml +pecos llvm configure # Configure .cargo/config.toml using detected LLVM +pecos llvm configure /path/to/llvm # Configure an explicit user-managed LLVM pecos cuda check # Check CUDA availability pecos sys-info # Show toolchain and environment info @@ -100,9 +101,12 @@ Run `just --list` to see all available commands. ### Install LLVM ```bash -# Automated installation (downloads pre-built binaries) +# Automated installation where PECOS can provide shared LLVM pecos install llvm +# Accept the managed-install prompt +pecos install llvm --yes + # Force reinstall pecos install llvm --force @@ -110,7 +114,21 @@ pecos install llvm --force pecos install llvm --no-configure ``` -This downloads and installs LLVM 14 to `~/.pecos/deps/llvm-14/`. +On Debian/Ubuntu-compatible Linux systems this downloads apt.llvm.org shared +LLVM packages into `~/.pecos/deps/llvm-21.1/` without `sudo`. The managed +install is the preferred developer path where it is available, but it is a +large toolchain install. `pecos install llvm` prints what it is about to install +and asks for confirmation before downloading. + +On macOS, install Homebrew LLVM 21 (`brew install llvm@21`) and run +`pecos llvm configure`. On native Windows MSVC, LLVM does not provide shared +`libLLVM`; use WSL2/Linux for the full HUGR test lane, or configure a full LLVM +development package for targeted static builds. + +`pecos rust test` requires shared LLVM for the workspace HUGR test lane. LLVM +21.1 static test links can use multiple GB of RAM each, so PECOS fails early +instead of letting `just dev` spawn enough concurrent linkers to overwhelm a +normal development machine. ### Check LLVM Status @@ -125,9 +143,12 @@ pecos llvm check --quiet ```bash pecos llvm configure + +# Or explicitly use a system/Homebrew/apt LLVM instead of the managed install +pecos llvm configure /path/to/llvm ``` -Updates `.cargo/config.toml` with the correct `LLVM_SYS_140_PREFIX` environment variable. +Updates `.cargo/config.toml` with the correct `LLVM_SYS_211_PREFIX` environment variable. ### Find LLVM Path @@ -188,7 +209,7 @@ Use `just check-all` before a broad PR; it runs the build, tests, lint gate, and | Variable | Description | Default | |----------|-------------|---------| | `PECOS_HOME` | PECOS cache and data directory | `~/.pecos` | -| `LLVM_SYS_140_PREFIX` | LLVM 14 installation path | auto-detected | +| `LLVM_SYS_211_PREFIX` | LLVM 21.1 installation path | auto-detected | | `RUST_LOG` | Log level for build output (`info` shows download progress) | `warn` | ## Typical Workflows @@ -199,13 +220,17 @@ Use `just check-all` before a broad PR; it runs the build, tests, lint gate, and # 1. Check if LLVM is already available pecos llvm check -# 2. If not, install it +# 2. If not, install it where managed shared LLVM is supported pecos install llvm # 3. Now you can build with LLVM support cargo build -p pecos --features llvm ``` +On macOS use `brew install llvm@21 && pecos llvm configure`. On native Windows +MSVC, use WSL2/Linux for the full HUGR test lane or configure a full LLVM 21 +package for targeted static builds. + Or using Justfile: ```bash just install-llvm diff --git a/docs/user-guide/cli.md b/docs/user-guide/cli.md index 660b064ce..2d3fdfee7 100644 --- a/docs/user-guide/cli.md +++ b/docs/user-guide/cli.md @@ -126,9 +126,9 @@ For quick checks without compilation, use `just doctor`: ```bash $ just doctor -LLVM 14: - [OK] installed: 14.0.6 at /home/user/.pecos/deps/llvm - [OK] .cargo/config.toml: LLVM_SYS_140_PREFIX configured +LLVM 21.1: + [OK] installed: 21.1.8 at /home/user/.pecos/deps/llvm-21.1 + [OK] .cargo/config.toml: LLVM_SYS_211_PREFIX configured Python: [OK] uv: uv 0.11.3 diff --git a/docs/user-guide/llvm-setup.md b/docs/user-guide/llvm-setup.md index 3a523ea88..f2e0f0747 100644 --- a/docs/user-guide/llvm-setup.md +++ b/docs/user-guide/llvm-setup.md @@ -21,12 +21,12 @@ If you don't need QIS LLVM IR/QIR execution features, you can skip LLVM installa ## Installation Options -### Option 1: Automatic Installation (Recommended) +### Option 1: PECOS-Managed Installation (Recommended Where Available) -Use the `pecos` CLI (`pecos install llvm`, or `cargo run -p pecos-cli -- install llvm` in a source checkout) to automatically download and install LLVM 14.0.6: +Use the `pecos` CLI (`pecos install llvm`, or `cargo run -p pecos-cli -- install llvm` in a source checkout) to automatically download and install LLVM 21.1.8 where PECOS can provide a verified shared LLVM package: ```bash -# Install LLVM 14.0.6 to ~/.pecos/deps/llvm-14/ (~400MB, ~5 minutes) +# Install LLVM 21.1.8 to ~/.pecos/deps/llvm-21.1/ cargo run -p pecos-cli -- install llvm # Build PECOS with LLVM support @@ -35,19 +35,30 @@ cargo build --features llvm The `install` command automatically: -- Downloads the correct LLVM binary for your platform -- Extracts it to `~/.pecos/deps/llvm-14/` +- Downloads a shared LLVM toolchain on supported platforms +- Extracts it to `~/.pecos/deps/llvm-21.1/` - Configures PECOS by updating `.cargo/config.toml` -This is the **recommended approach** for all platforms, especially Windows where system package managers may not provide LLVM 14 development files. +This is the **recommended approach** where PECOS can provide a verified shared +LLVM package. On Debian/Ubuntu-compatible Linux distributions, PECOS downloads +the apt.llvm.org LLVM 21 packages into `~/.pecos/deps/llvm-21.1/` without using +`sudo`. On macOS, use Homebrew for LLVM 21. On Windows MSVC, LLVM does not +provide the shared `libLLVM` target PECOS requires for the full workspace HUGR +test lane; use WSL2/Linux for that lane, or configure a full LLVM package for +targeted static LLVM builds. + +This is a developer toolchain install: the CLI prints the install size/behavior +and asks for confirmation before downloading. Use `--yes` to accept the prompt +in scripts. Depending on platform and archive layout, the extracted toolchain +can occupy several GB. ### Option 2: System Package Manager -Install LLVM 14 using your system's package manager, then configure PECOS: +Install LLVM 21.1 using your system's package manager, then configure PECOS: === "macOS" ```bash - brew install llvm@14 + brew install llvm@21 cargo run -p pecos-cli -- llvm configure cargo build --features llvm ``` @@ -57,32 +68,35 @@ Install LLVM 14 using your system's package manager, then configure PECOS: === "Linux (Debian/Ubuntu)" ```bash sudo apt update - sudo apt install llvm-14 llvm-14-dev + sudo apt install llvm-21 llvm-21-dev cargo run -p pecos-cli -- llvm configure cargo build --features llvm ``` + If your distribution repositories do not provide LLVM 21, use the LLVM + project's Debian/Ubuntu repository at . + === "Linux (Fedora/RHEL)" ```bash - sudo dnf install llvm14 llvm14-devel + sudo dnf install llvm21 llvm21-devel cargo run -p pecos-cli -- llvm configure cargo build --features llvm ``` === "Linux (Arch)" ```bash - yay -S llvm14 # May need to build from AUR + yay -S llvm21 # May need to build from AUR cargo run -p pecos-cli -- llvm configure cargo build --features llvm ``` === "Windows" !!! warning "Windows LLVM Requirement" - The official LLVM Windows installer (`LLVM-*.exe`) is **toolchain-only** and lacks required development files (`llvm-config.exe` and headers). + The official LLVM Windows installer (`LLVM-*.exe`) is **toolchain-only** and lacks required development files (`llvm-config.exe` and headers). LLVM's MSVC builds also do not provide shared `libLLVM`, so `pecos rust test` / `just dev` with the full HUGR test lane is not supported on native Windows. - **Recommended:** Use Option 1 (automatic installation) above. + **Recommended for full development tests:** Use WSL2/Linux and Option 1 above. - **Alternative:** Download a full development package from: + **Alternative for targeted static LLVM builds:** Download a full development package from: - [bitgate/llvm-windows-full-builds](https://github.com/bitgate/llvm-windows-full-builds) (recommended) - [vovkos/llvm-package-windows](https://github.com/vovkos/llvm-package-windows) @@ -90,8 +104,8 @@ Install LLVM 14 using your system's package manager, then configure PECOS: Extract to `C:\LLVM`, then: ```cmd - set LLVM_SYS_140_PREFIX=C:\LLVM - cargo run -p pecos-cli -- llvm configure + set LLVM_SYS_211_PREFIX=C:\LLVM + cargo run -p pecos-cli -- llvm configure C:\LLVM cargo build --features llvm ``` @@ -100,7 +114,7 @@ Install LLVM 14 using your system's package manager, then configure PECOS: After installing LLVM, you can verify the installation using these commands: ```bash -# Check if LLVM 14 is detected +# Check if LLVM 21.1 is detected cargo run -p pecos-cli -- llvm check # Show LLVM version and path @@ -110,13 +124,24 @@ cargo run -p pecos-cli -- llvm version cargo run -p pecos-cli -- llvm find ``` +`llvm check` also reports LLVM's link mode. PECOS Rust builds prefer +`libLLVM-21.so` when a shared LLVM 21.1 installation is available; static LLVM +is only suitable for targeted builds. + +For `pecos rust test` and `just dev`, PECOS requires shared LLVM. On a Linux +x86_64 developer machine, one static LLVM test link measured about 4 GB peak +RSS, while the same target linked against shared LLVM measured about 0.8 GB. +Failing early on static LLVM is intentional: full workspace tests can spawn +many LLVM-linking test binaries at once. + ## `pecos llvm` CLI Reference The `pecos llvm` subcommand provides several useful commands: ### `install` -Download and install LLVM 14.0.6 to `~/.pecos/deps/llvm-14/`: +Download and install LLVM 21.1.8 to `~/.pecos/deps/llvm-21.1/` on supported +platforms: ```bash cargo run -p pecos-cli -- install llvm @@ -134,13 +159,16 @@ Auto-configure PECOS to use detected LLVM installation: ```bash cargo run -p pecos-cli -- llvm configure + +# Or explicitly configure a user-managed LLVM installation +cargo run -p pecos-cli -- llvm configure /path/to/llvm ``` This updates `.cargo/config.toml` with the LLVM path. ### `check` -Verify LLVM 14 is available: +Verify LLVM 21.1 is available: ```bash cargo run -p pecos-cli -- llvm check @@ -195,7 +223,7 @@ cargo run -p pecos-cli -- llvm tool llvm-link ### Version Requirement -PECOS specifically requires **LLVM version 14.x** (14.0.x). Other versions are not compatible with the current implementation. +PECOS specifically requires **LLVM version 21.1.x** (21.1.x). Other versions are not compatible with the current implementation. ### Configuration File @@ -203,7 +231,7 @@ The `configure` command updates `.cargo/config.toml` in the project root with: ```toml [env] -LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true } +LLVM_SYS_211_PREFIX = { value = "/path/to/llvm", force = true } ``` **Important notes:** @@ -212,39 +240,62 @@ LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true } - It's in `.gitignore` and should not be committed - The `force = true` setting ensures the configured LLVM path takes priority over environment variables +### Shared vs Static LLVM + +PECOS enables inkwell's `llvm21-1-prefer-dynamic` feature. That means Rust +builds use `libLLVM-21.so` / `libLLVM.dylib` when `llvm-config --link-shared` +can provide it. The managed installer rejects static LLVM because the normal +development test path links many LLVM-using test binaries. + +System package manager installs usually provide shared LLVM. On Debian/Ubuntu +compatible Linux distributions, the managed installer uses the apt.llvm.org +LLVM 21 packages locally under `~/.pecos/deps/llvm-21.1/`, without installing +system packages. + +When LLVM is shared, PECOS CLI commands add LLVM's `libdir` to the runtime +library path for child Cargo commands. That lets locally configured shared LLVM +installs work without editing your shell startup files. + ### Detection Priority -The `pecos llvm` tooling searches for LLVM 14 in this order: +Build commands that need to match Cargo's behavior first honor +`.cargo/config.toml` if it sets `LLVM_SYS_211_PREFIX`, then fall back to the +normal detector. The normal `pecos llvm` detector searches for LLVM 21.1 in +this order: 1. **Home directory:** - - Windows: `~/.pecos/deps/llvm-14` - - Unix: `~/.pecos/deps/llvm-14` + - Windows: `~/.pecos/deps/llvm-21.1` + - Unix: `~/.pecos/deps/llvm-21.1` + +2. **Legacy home directory:** `~/.pecos/llvm` -2. **Project-local:** `/llvm/` +3. **Project-local:** `/llvm/` -3. **System installations:** - - **macOS:** Homebrew locations (`/opt/homebrew/opt/llvm@14`, `/usr/local/opt/llvm@14`) - - **Linux:** Via `llvm-config-14` command and common paths +4. **System installations:** + - **macOS:** Homebrew locations (`/opt/homebrew/opt/llvm@21`, `/usr/local/opt/llvm@21`) + - **Linux:** Via `llvm-config-21` command and common paths - **Windows:** Common paths (`C:\Program Files\LLVM`, `C:\LLVM`, etc.) ### Platform-Specific Notes **macOS:** -- Supports both Intel and Apple Silicon architectures +- Use Homebrew LLVM 21: `brew install llvm@21` - Automatically detects Homebrew installations -- Downloads appropriate binary for each platform **Linux:** -- Detects system LLVM via `llvm-config-14` command -- Supports x86_64 and aarch64 architectures +- Detects system LLVM via `llvm-config-21` command +- Managed install uses apt.llvm.org on Debian/Ubuntu-compatible x86_64 and + aarch64 systems +- Other Linux distributions should install shared LLVM 21 through their package + manager and run `pecos llvm configure /path/to/llvm` **Windows:** -- Uses `.7z` archives for distribution -- Pure Rust extraction (no external tools required) -- Official LLVM Windows installer lacks development files - use `pecos install llvm` or community packages +- Native Windows LLVM is static-only for PECOS's purposes +- Use WSL2/Linux for the full HUGR test lane +- For targeted static LLVM builds, configure a full development package with `pecos llvm configure C:\path\to\llvm` ### Security @@ -271,7 +322,7 @@ cargo run -p pecos-cli -- llvm version ### Wrong LLVM version detected -PECOS requires LLVM 14.x. If you have multiple LLVM versions installed, the tool will prioritize LLVM 14. Use the `find` command to see which installation is detected: +PECOS requires LLVM 21.1.x. If you have multiple LLVM versions installed, the tool will prioritize LLVM 21.1. Use the `find` command to see which installation is detected: ```bash cargo run -p pecos-cli -- llvm find @@ -283,27 +334,27 @@ If automatic configuration doesn't work, you can manually set the environment va ```bash # Unix/macOS -export LLVM_SYS_140_PREFIX=/path/to/llvm +export LLVM_SYS_211_PREFIX=/path/to/llvm # Windows -set LLVM_SYS_140_PREFIX=C:\path\to\llvm +set LLVM_SYS_211_PREFIX=C:\path\to\llvm ``` Or add to `.cargo/config.toml`: ```toml [env] -LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true } +LLVM_SYS_211_PREFIX = { value = "/path/to/llvm", force = true } ``` ## PECOS Home Directory -LLVM is installed to `~/.pecos/deps/llvm-14/`, which is part of the PECOS home directory structure: +LLVM is installed to `~/.pecos/deps/llvm-21.1/`, which is part of the PECOS home directory structure: ``` ~/.pecos/ ├── deps/ -│ ├── llvm/ # LLVM-14 installation +│ ├── llvm-21.1/ # LLVM 21.1 installation │ ├── cuda/ # CUDA Toolkit │ └── cuquantum/ # cuQuantum SDK └── cache/ # Build artifacts diff --git a/python/pecos-rslib-llvm/Cargo.toml b/python/pecos-rslib-llvm/Cargo.toml index 8c230b0a4..3a81db1af 100644 --- a/python/pecos-rslib-llvm/Cargo.toml +++ b/python/pecos-rslib-llvm/Cargo.toml @@ -26,7 +26,7 @@ extension-module = [ [dependencies] pecos-llvm.workspace = true -inkwell = { workspace = true, features = ["llvm14-0"] } +inkwell.workspace = true pyo3.workspace = true regex.workspace = true tempfile.workspace = true diff --git a/python/pecos-rslib-llvm/src/llvm_bindings.rs b/python/pecos-rslib-llvm/src/llvm_bindings.rs index 4cf3cefbf..45b736379 100644 --- a/python/pecos-rslib-llvm/src/llvm_bindings.rs +++ b/python/pecos-rslib-llvm/src/llvm_bindings.rs @@ -290,6 +290,8 @@ impl PyLLVMModule { function: ll_function.get(), // Get the underlying FunctionValue context_ptr: self.context_ptr, module_id: self.module_ptr as usize, + ret_pointee_type: func_type.ret_pointee_type, + param_pointee_types: func_type.param_pointee_types.clone(), } } @@ -309,6 +311,7 @@ impl PyLLVMModule { let global = module.add_global(name, ll_type, init_val); PyGlobalVariable { global, + value_type: ll_type, context_ptr: self.context_ptr, } } @@ -377,7 +380,12 @@ impl PyModuleContext { is_var_arg: Option, ) -> PyFunctionType { let context = unsafe { &*self.context_ptr }; + let ret_pointee_type = return_type.pointer_pointee_type(); let ret_ty = return_type.to_ll_type(context); + let param_pointee_types: Vec<_> = param_types + .iter() + .map(PyAnyType::pointer_pointee_type) + .collect(); let param_tys: Vec<_> = param_types .into_iter() .map(|pt| pt.to_ll_type(context)) @@ -385,7 +393,9 @@ impl PyModuleContext { PyFunctionType { ret_type: ret_ty, + ret_pointee_type, param_types: param_tys, + param_pointee_types, var_args: is_var_arg.unwrap_or(false), context_ptr: self.context_ptr, } @@ -428,6 +438,17 @@ impl PyAnyType { PyAnyType::Array(t) => t.ll_type, } } + + fn pointer_pointee_type(&self) -> Option> { + match self { + PyAnyType::Pointer(t) => t.pointee_type, + PyAnyType::Int(_) + | PyAnyType::Double(_) + | PyAnyType::Void(_) + | PyAnyType::Struct(_) + | PyAnyType::Array(_) => None, + } + } } /// Type equality by LLVM `LLVMTypeRef` identity within one `Context` @@ -462,6 +483,38 @@ fn lltype_hash(a: LLType<'static>) -> u64 { std::hash::Hasher::finish(&h) } +fn pointer_type_richcmp( + py: Python<'_>, + this: &PyPointerType, + other: &Bound<'_, PyAny>, + op: CompareOp, +) -> Py { + match op { + CompareOp::Eq | CompareOp::Ne => { + let eq = other.extract::().is_ok_and(|o| match o { + PyAnyType::Pointer(p) => { + this.ll_type == p.ll_type && this.pointee_type == p.pointee_type + } + _ => this.ll_type == o.ll_type(), + }); + let val = if matches!(op, CompareOp::Eq) { eq } else { !eq }; + val.into_pyobject(py) + .expect("bool -> PyBool is infallible") + .to_owned() + .into_any() + .unbind() + } + _ => py.NotImplemented(), + } +} + +fn pointer_type_hash(pointer: PyPointerType) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&pointer.ll_type, &mut h); + std::hash::Hash::hash(&pointer.pointee_type, &mut h); + std::hash::Hasher::finish(&h) +} + /// Python wrapper for struct types #[pyclass(name = "StructType", from_py_object)] #[derive(Copy, Clone)] @@ -482,6 +535,7 @@ impl PyStructType { let ptr_type = ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(ll_type), context_ptr: self.context_ptr, } } @@ -492,6 +546,7 @@ impl PyStructType { #[derive(Copy, Clone)] pub struct PyPointerType { ll_type: LLType<'static>, + pointee_type: Option>, context_ptr: *mut Context, } @@ -501,10 +556,10 @@ unsafe impl Sync for PyPointerType {} #[pymethods] impl PyPointerType { fn __richcmp__(&self, py: Python<'_>, other: &Bound<'_, PyAny>, op: CompareOp) -> Py { - lltype_richcmp(py, self.ll_type, other, op) + pointer_type_richcmp(py, self, other, op) } fn __hash__(&self) -> u64 { - lltype_hash(self.ll_type) + pointer_type_hash(*self) } fn as_pointer(&self) -> PyPointerType { @@ -512,6 +567,7 @@ impl PyPointerType { let ptr_type = self.ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(self.ll_type), context_ptr: self.context_ptr, } } @@ -542,6 +598,7 @@ impl PyIntType { let ptr_type = self.ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(self.ll_type), context_ptr: self.context_ptr, } } @@ -581,6 +638,7 @@ impl PyDoubleType { let ptr_type = self.ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(self.ll_type), context_ptr: self.context_ptr, } } @@ -642,6 +700,7 @@ impl PyArrayType { let ptr_type = self.ll_type.as_pointer(context); PyPointerType { ll_type: ptr_type, + pointee_type: Some(self.ll_type), context_ptr: self.context_ptr, } } @@ -902,9 +961,18 @@ impl PyIRBuilder { let result = builder .call(function.function, &arg_values, name) .map_err(|e| PyRuntimeError::new_err(format!("call failed: {e}")))?; - Ok(result.map(|value| PyLLValue { - value, - context_ptr: self.context_ptr, + Ok(result.map(|value| { + let value = match value { + LLValue::Pointer(pointer) => LLValue::Pointer(LLPointerValue::new( + pointer.value(), + function.ret_pointee_type, + )), + LLValue::Int(_) | LLValue::Float(_) | LLValue::Array(_) => value, + }; + PyLLValue { + value, + context_ptr: self.context_ptr, + } })) } @@ -945,7 +1013,7 @@ impl PyIRBuilder { }) } - /// Load from a pointer (`load`; LLVM-14 typed-pointer pointee). + /// Load from a pointer (`load`; uses the tracked pointee type for opaque pointers). #[pyo3(signature = (ptr, name=""))] fn load(&mut self, ptr: PyLLValue, name: &str) -> PyResult { let builder = unsafe { &mut *self.builder_ptr }; @@ -1275,6 +1343,8 @@ pub struct PyFunction { context_ptr: *mut Context, /// Module ID for comment tracking module_id: usize, + ret_pointee_type: Option>, + param_pointee_types: Vec>>, } unsafe impl Send for PyFunction {} @@ -1300,11 +1370,17 @@ impl PyFunction { // Get function parameters and wrap in PyLLValue self.function .get_param_iter() - .map(|param| { + .enumerate() + .map(|(index, param)| { // Convert BasicValueEnum to LLValue - only supporting types in LLValue enum let value = match param { inkwell::values::BasicValueEnum::IntValue(v) => LLValue::Int(v), - inkwell::values::BasicValueEnum::PointerValue(v) => LLValue::Pointer(v), + inkwell::values::BasicValueEnum::PointerValue(v) => { + LLValue::Pointer(LLPointerValue::new( + v, + self.param_pointee_types.get(index).copied().flatten(), + )) + } inkwell::values::BasicValueEnum::ArrayValue(v) => LLValue::Array(v), _ => panic!("Unsupported parameter type (float values not in LLValue enum)"), }; @@ -1335,7 +1411,9 @@ unsafe impl Sync for PyBasicBlock {} #[derive(Clone)] pub struct PyFunctionType { ret_type: LLType<'static>, + ret_pointee_type: Option>, param_types: Vec>, + param_pointee_types: Vec>>, var_args: bool, #[allow(dead_code)] context_ptr: *mut Context, @@ -1363,7 +1441,12 @@ impl PyFunctionType { }; let context = unsafe { &*context_ptr }; + let ret_pointee_type = return_type.pointer_pointee_type(); let ret_ty = return_type.to_ll_type(context); + let param_pointee_types: Vec<_> = param_types + .iter() + .map(PyAnyType::pointer_pointee_type) + .collect(); let param_tys: Vec<_> = param_types .into_iter() .map(|pt| pt.to_ll_type(context)) @@ -1371,7 +1454,9 @@ impl PyFunctionType { Self { ret_type: ret_ty, + ret_pointee_type, param_types: param_tys, + param_pointee_types, var_args, context_ptr, } @@ -1410,7 +1495,7 @@ impl PyLLValue { let ptr_val = int_val.const_to_pointer(target_ptr_type); Ok(Self { - value: LLValue::Pointer(ptr_val), + value: LLValue::Pointer(LLPointerValue::new(ptr_val, ptr_type.pointee_type)), context_ptr: self.context_ptr, }) } @@ -1429,7 +1514,8 @@ impl PyLLValue { context_ptr: self.context_ptr, }), LLValue::Pointer(v) => PyAnyType::Pointer(PyPointerType { - ll_type: LLType::Pointer(v.get_type()), + ll_type: LLType::Pointer(v.value().get_type()), + pointee_type: v.pointee_type(), context_ptr: self.context_ptr, }), LLValue::Array(v) => PyAnyType::Array(PyArrayType { @@ -1448,6 +1534,7 @@ impl PyLLValue { #[pyclass(name = "GlobalVariable")] pub struct PyGlobalVariable { global: inkwell::values::GlobalValue<'static>, + value_type: LLType<'static>, context_ptr: *mut Context, } @@ -1467,6 +1554,7 @@ impl PyGlobalVariable { let global = module_ref.add_global(name, ll_type, None); Self { global, + value_type: ll_type, context_ptr: module.context_ptr, } } @@ -1477,7 +1565,7 @@ impl PyGlobalVariable { match &value.value { LLValue::Int(v) => self.global.set_initializer(v), LLValue::Float(v) => self.global.set_initializer(v), - LLValue::Pointer(v) => self.global.set_initializer(v), + LLValue::Pointer(v) => self.global.set_initializer(&v.value()), LLValue::Array(v) => self.global.set_initializer(v), } } @@ -1516,11 +1604,20 @@ impl PyGlobalVariable { .collect(); let int_indices = int_indices?; + let basic_ty = self + .value_type + .to_basic_metadata_type() + .ok_or_else(|| PyRuntimeError::new_err("Cannot GEP into a void global"))?; // Use const_gep for global variables - let gep_val = unsafe { self.global.as_pointer_value().const_gep(&int_indices) }; + let gep_val = unsafe { + self.global + .as_pointer_value() + .const_gep(basic_ty, &int_indices) + }; + let pointee_type = gep_result_pointee_type(self.value_type, &int_indices); Ok(PyLLValue { - value: LLValue::Pointer(gep_val), + value: LLValue::Pointer(LLPointerValue::new(gep_val, pointee_type)), context_ptr: self.context_ptr, }) } @@ -1528,7 +1625,10 @@ impl PyGlobalVariable { /// Get the pointer value of this global fn as_pointer_value(&self) -> PyLLValue { PyLLValue { - value: LLValue::Pointer(self.global.as_pointer_value()), + value: LLValue::Pointer(LLPointerValue::new( + self.global.as_pointer_value(), + Some(self.value_type), + )), context_ptr: self.context_ptr, } } @@ -1827,9 +1927,12 @@ impl PyModuleRef { }) })?; - // Write module to bitcode + // `MemoryBuffer::as_slice()` includes LLVM's trailing C-string NUL; + // that byte is not part of the bitcode stream and strict readers + // reject it. let bitcode_buffer = module.write_bitcode_to_memory(); - Ok(bitcode_buffer.as_slice().to_vec()) + let bitcode = bitcode_buffer.as_slice(); + Ok(bitcode[..bitcode.len().saturating_sub(1)].to_vec()) } } diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index f22d4f96d..c01231733 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -102,11 +102,12 @@ use wasm_foreign_object_bindings::PyWasmForeignObject; /// Find an LLVM tool by name (e.g., "llvm-as", "llc", "opt"). /// -/// This searches for the tool in the LLVM 14 installation using the same +/// This searches for the tool in the LLVM 21.1 installation using the same /// logic as the pecos-build crate: -/// 1. ~/.pecos/llvm/ (PECOS managed installation) -/// 2. Project-local llvm/ directory -/// 3. System installations (Homebrew on macOS, package manager on Linux) +/// 1. ~/.pecos/deps/llvm-21.1/ (PECOS-managed installation where supported) +/// 2. ~/.pecos/llvm/ (legacy path) +/// 3. Project-local llvm/ directory +/// 4. System installations (Homebrew on macOS, package manager on Linux) /// /// Returns None if the tool is not found. #[pyfunction] @@ -187,7 +188,7 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { setup_cuda_library_path(); // CRITICAL: Preload libselene_simple_runtime.so with RTLD_GLOBAL BEFORE anything else - // This prevents conflicts with LLVM-14 when the Selene runtime is loaded later + // This prevents conflicts with LLVM-21.1 when the Selene runtime is loaded later #[cfg(unix)] { use std::ffi::CString; diff --git a/python/pecos-rslib/tests/test_llvm_comprehensive.py b/python/pecos-rslib/tests/test_llvm_comprehensive.py index ea4b5de94..c255ad6d8 100644 --- a/python/pecos-rslib/tests/test_llvm_comprehensive.py +++ b/python/pecos-rslib/tests/test_llvm_comprehensive.py @@ -68,6 +68,46 @@ def test_function_creation(qir_module) -> None: _ = main_func, h_gate, mz_func +def test_load_pointer_function_argument(qir_module) -> None: + from pecos_rslib_llvm import ir + + module, ctx = qir_module + + i32 = ctx.int_type(32) + void = ctx.void_type() + func_type = ctx.function_type(void, [i32.as_pointer()], False) + test_func = module.add_function("load_arg", func_type) + entry = test_func.append_basic_block("entry") + builder = ir.IRBuilder(entry) + + loaded = builder.load(test_func.args[0], "loaded") + builder.ret_void() + + _ = loaded + + +def test_load_pointer_return_value(qir_module) -> None: + from pecos_rslib_llvm import ir + + module, ctx = qir_module + + i32 = ctx.int_type(32) + callee_type = ctx.function_type(i32.as_pointer(), [], False) + callee = module.add_function("returns_i32_ptr", callee_type) + + void = ctx.void_type() + caller_type = ctx.function_type(void, [], False) + caller = module.add_function("load_call_result", caller_type) + entry = caller.append_basic_block("entry") + builder = ir.IRBuilder(entry) + + ptr = builder.call(callee, [], "ptr") + loaded = builder.load(ptr, "loaded") + builder.ret_void() + + _ = loaded + + def test_global_variables(qir_module) -> None: from pecos_rslib_llvm import ir diff --git a/python/quantum-pecos/README.md b/python/quantum-pecos/README.md index 2014f26f0..bbb2d2dca 100644 --- a/python/quantum-pecos/README.md +++ b/python/quantum-pecos/README.md @@ -22,7 +22,7 @@ calls to Wasm VMs, conditional branching, and more. - Fast Simulation: Leverages a fast stabilizer simulation algorithm. - Multi-language extensions: Core functionalities implemented via Rust for performance and safety. Additional add-ons and extension support in C/C++ via Cython. -- LLVM IR Support: Execute LLVM Intermediate Representation programs for hybrid quantum/classical computing. LLVM support is optional - PECOS can be built without LLVM by using `--no-default-features` when building the Rust crates. When LLVM is enabled (default), requires LLVM version 14. +- LLVM IR Support: Execute LLVM Intermediate Representation programs for hybrid quantum/classical computing. LLVM support is optional - PECOS can be built without LLVM by using `--no-default-features` when building the Rust crates. When LLVM is enabled (default), requires LLVM version 21.1. ## Getting Started @@ -116,7 +116,7 @@ pecos = "0.x.x" # Replace with the latest version #### Optional Dependencies -- **LLVM version 14**: Required for LLVM IR execution support (optional) +- **LLVM version 21.1**: Required for LLVM IR execution support (optional) PECOS provides an automated installer or you can install manually: diff --git a/python/quantum-pecos/src/pecos/simulators/mps_pytket/__init__.py b/python/quantum-pecos/src/pecos/simulators/mps_pytket/__init__.py index b96f5c02d..370d53bc5 100644 --- a/python/quantum-pecos/src/pecos/simulators/mps_pytket/__init__.py +++ b/python/quantum-pecos/src/pecos/simulators/mps_pytket/__init__.py @@ -15,4 +15,7 @@ # specific language governing permissions and limitations under the License. from pecos.simulators.mps_pytket import bindings +from pecos.simulators.mps_pytket._nvmath_compat import patch_nvmath_cupy_external_stream from pecos.simulators.mps_pytket.state import MPS + +patch_nvmath_cupy_external_stream() diff --git a/python/quantum-pecos/src/pecos/simulators/mps_pytket/_nvmath_compat.py b/python/quantum-pecos/src/pecos/simulators/mps_pytket/_nvmath_compat.py new file mode 100644 index 000000000..9efe60971 --- /dev/null +++ b/python/quantum-pecos/src/pecos/simulators/mps_pytket/_nvmath_compat.py @@ -0,0 +1,62 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Compatibility patches for pytket-cutensornet's nvmath dependency.""" + +from __future__ import annotations + +from importlib import import_module + +_PATCHED_ATTR = "_pecos_cupy_stream_from_external_patch" + + +class _CudaStreamHolder: + """Adapter object implementing the CUDA stream protocol for a raw stream handle.""" + + def __init__(self, handle: int) -> None: + self.handle = int(handle) + + def __cuda_stream__(self) -> tuple[int, int]: + return (0, self.handle) + + +def patch_nvmath_cupy_external_stream() -> bool: + """Use CuPy's supported external-stream API in nvmath when it is available. + + nvmath-python 0.9.0 still wraps raw CUDA stream pointers with + ``cupy.cuda.ExternalStream``, which is deprecated in CuPy 14. CuPy's + replacement API accepts an object implementing the CUDA stream protocol. + """ + try: + cp = import_module("cupy") + package_ifc_cupy = import_module("nvmath.internal.package_ifc_cupy") + except ImportError: + return False + + try: + cupy_package = package_ifc_cupy.CupyPackage + from_external = getattr(cp.cuda.Stream, "from_external", None) + except AttributeError: + return False + + if getattr(cupy_package, _PATCHED_ATTR, False): + return True + + if from_external is None: + return False + + def create_external_stream(device_id: int, stream_ptr: int) -> object: + del device_id + return from_external(_CudaStreamHolder(stream_ptr)) + + cupy_package.create_external_stream = staticmethod(create_external_stream) + setattr(cupy_package, _PATCHED_ATTR, True) + return True diff --git a/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py b/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py index b1e810606..3c79feda8 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py @@ -4,6 +4,7 @@ for the same HUGR input. """ +from collections import Counter from pathlib import Path import pytest @@ -99,18 +100,27 @@ def compare_compilers( if selene_qis == rust_qis: return True, "QIS calls match exactly" - # If not exact match, provide diagnostic info - selene_set = set(selene_qis) - rust_set = set(rust_qis) + selene_counts = Counter(selene_qis) + rust_counts = Counter(rust_qis) + selene_set = set(selene_counts) + rust_set = set(rust_counts) - only_selene = selene_set - rust_set - only_rust = rust_set - selene_set + # LLVM 21 can peel runtime loops, duplicating static call sites while + # preserving the dynamic behavior. In that case exact call-site + # multiplicity is not a robust parity signal. + if selene_set == rust_set and ( + "llvm.loop.peeled.count" in selene_ir or "llvm.loop.peeled.count" in rust_ir + ): + return True, "QIS call set matches; static call counts differ only after LLVM loop peeling" + + only_selene = selene_counts - rust_counts + only_rust = rust_counts - selene_counts msg = "QIS calls differ:\n" if only_selene: - msg += f" Only in Selene: {only_selene}\n" + msg += f" Only in Selene: {dict(only_selene)}\n" if only_rust: - msg += f" Only in Rust: {only_rust}\n" + msg += f" Only in Rust: {dict(only_rust)}\n" return False, msg diff --git a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py index 49ae0685a..f24209932 100644 --- a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py +++ b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py @@ -44,10 +44,8 @@ bitcode via the bundled `selene_helios_qis_plugin` (+ Helios QIR runtime) -- `selene_sim.build(BitcodeString(...)) -> run_shots(Stim)`. So the direct `qir_to_qis -> Selene` - differential long claimed "blocked" (an - alleged LLVM 14<->21 / opaque-vs-typed bridge) is in fact - available with zero PECOS LLVM work: PECOS-Rust stays LLVM-14; - the LLVM-21 capability lives entirely in the qir-qis + + differential long claimed "blocked" by LLVM version or pointer + representation differences is available through the qir-qis + selene_sim *Python* deps. Layer D (`test_tier2_executable_differential`) lands the **representative** executable differential (deterministic diff --git a/scripts/native_bench/bench_pecos/Cargo.lock b/scripts/native_bench/bench_pecos/Cargo.lock index 5030ea8a4..7a6893b8c 100644 --- a/scripts/native_bench/bench_pecos/Cargo.lock +++ b/scripts/native_bench/bench_pecos/Cargo.lock @@ -97,15 +97,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" -dependencies = [ - "derive_arbitrary", -] - [[package]] name = "arrayvec" version = "0.7.6" @@ -576,17 +567,6 @@ dependencies = [ "powerfmt", ] -[[package]] -name = "derive_arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "digest" version = "0.10.7" @@ -767,6 +747,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -913,96 +894,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glam" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333928d5eb103c5d4050533cec0384302db6be8ef7d3cebd30ec6a35350353da" - -[[package]] -name = "glam" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3abb554f8ee44336b72d522e0a7fe86a29e09f839a36022fa869a7dfe941a54b" - -[[package]] -name = "glam" -version = "0.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4126c0479ccf7e8664c36a2d719f5f2c140fbb4f9090008098d2c291fa5b3f16" - -[[package]] -name = "glam" -version = "0.17.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01732b97afd8508eee3333a541b9f7610f454bb818669e66e90f5f57c93a776" - -[[package]] -name = "glam" -version = "0.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "525a3e490ba77b8e326fb67d4b44b4bd2f920f44d4cc73ccec50adc68e3bee34" - -[[package]] -name = "glam" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b8509e6791516e81c1a630d0bd7fbac36d2fa8712a9da8662e716b52d5051ca" - -[[package]] -name = "glam" -version = "0.20.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f43e957e744be03f5801a55472f593d43fabdebf25a4585db250f04d86b1675f" - -[[package]] -name = "glam" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "518faa5064866338b013ff9b2350dc318e14cc4fcd6cb8206d7e7c9886c98815" - -[[package]] -name = "glam" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f597d56c1bd55a811a1be189459e8fad2bbc272616375602443bdfb37fa774" - -[[package]] -name = "glam" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4afd9ad95555081e109fe1d21f2a30c691b5f0919c67dfa690a2e1eb6bd51c" - -[[package]] -name = "glam" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5418c17512bdf42730f9032c74e1ae39afc408745ebb2acf72fbc4691c17945" - -[[package]] -name = "glam" -version = "0.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "151665d9be52f9bb40fc7966565d39666f2d1e69233571b71b87791c7e0528b3" - -[[package]] -name = "glam" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e05e7e6723e3455f4818c7b26e855439f7546cf617ef669d1adedb8669e5cb9" - -[[package]] -name = "glam" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "779ae4bf7e8421cf91c0b3b64e7e8b40b862fba4d393f59150042de7c4965a94" - -[[package]] -name = "glam" -version = "0.29.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" - [[package]] name = "glam" version = "0.30.10" @@ -1021,6 +912,12 @@ version = "0.32.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" +[[package]] +name = "glam" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fb167719045debebe9f532320accc7b5c993c5a3b813f5696a11d5ca7bdc57b" + [[package]] name = "glob" version = "0.3.3" @@ -1703,29 +1600,15 @@ dependencies = [ [[package]] name = "nalgebra" -version = "0.34.2" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df76ea0ff5c7e6b88689085804d6132ded0ddb9de5ca5b8aeb9eeadc0508a70a" +checksum = "adc43a60c217b0c6ff46e47f26911015ad8d2e5a8be1af668c67e370d99a4346" dependencies = [ "approx", - "glam 0.14.0", - "glam 0.15.2", - "glam 0.16.0", - "glam 0.17.3", - "glam 0.18.0", - "glam 0.19.0", - "glam 0.20.5", - "glam 0.21.3", - "glam 0.22.0", - "glam 0.23.0", - "glam 0.24.2", - "glam 0.25.0", - "glam 0.27.0", - "glam 0.28.0", - "glam 0.29.3", "glam 0.30.10", "glam 0.31.1", "glam 0.32.1", + "glam 0.33.0", "matrixmultiply", "nalgebra-macros", "num-complex", @@ -1979,12 +1862,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - [[package]] name = "pecos-build" version = "0.2.0-dev.0" @@ -2102,7 +1979,7 @@ dependencies = [ "rand 0.10.1", "rand_core 0.10.0", "rapidhash", - "wide 1.2.0", + "wide", ] [[package]] @@ -2117,7 +1994,7 @@ dependencies = [ "rand 0.10.1", "rayon", "smallvec", - "wide 1.2.0", + "wide", ] [[package]] @@ -2704,15 +2581,6 @@ dependencies = [ "rayon-cond", ] -[[package]] -name = "safe_arch" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b02de82ddbe1b636e6170c21be622223aea188ef2e139be0a5b219ec215323" -dependencies = [ - "bytemuck", -] - [[package]] name = "safe_arch" version = "1.0.0" @@ -2878,15 +2746,14 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "simba" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c99284beb21666094ba2b75bbceda012e610f5479dfcc2d6e2426f53197ffd95" +checksum = "8f45c644a9f3a386f9288625d9f0c1e999e1acf07a37df35d0516c7f199d9cb2" dependencies = [ "approx", "num-complex", "num-traits", - "paste", - "wide 0.7.33", + "wide", ] [[package]] @@ -3138,18 +3005,12 @@ dependencies = [ "indexmap", "serde_core", "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", "toml_writer", - "winnow 1.0.1", + "winnow", ] -[[package]] -name = "toml_datetime" -version = "0.6.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -3161,14 +3022,15 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.22.27" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", - "toml_datetime 0.6.11", - "toml_write", - "winnow 0.7.15", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] @@ -3177,15 +3039,9 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ - "winnow 1.0.1", + "winnow", ] -[[package]] -name = "toml_write" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" - [[package]] name = "toml_writer" version = "1.1.1+spec-1.1.0" @@ -3262,6 +3118,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + [[package]] name = "typenum" version = "1.19.0" @@ -3662,16 +3524,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "wide" -version = "0.7.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce5da8ecb62bcd8ec8b7ea19f69a51275e91299be594ea5cc6ef7819e16cd03" -dependencies = [ - "bytemuck", - "safe_arch 0.7.4", -] - [[package]] name = "wide" version = "1.2.0" @@ -3679,7 +3531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "198f6abc41fab83526d10880fa5c17e2b4ee44e763949b4bb34e2fd1e8ca48e4" dependencies = [ "bytemuck", - "safe_arch 1.0.0", + "safe_arch", ] [[package]] @@ -4025,19 +3877,13 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" - [[package]] name = "wit-bindgen" version = "0.51.0" @@ -4271,21 +4117,24 @@ dependencies = [ [[package]] name = "zip" -version = "2.4.2" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ - "arbitrary", "crc32fast", - "crossbeam-utils", - "displaydoc", "flate2", "indexmap", "memchr", - "thiserror 2.0.18", + "typed-path", "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" + [[package]] name = "zmij" version = "1.0.21" diff --git a/scripts/win-msvc-bootstrap.ps1 b/scripts/win-msvc-bootstrap.ps1 index 01b4a3ad5..c53cd5d54 100644 --- a/scripts/win-msvc-bootstrap.ps1 +++ b/scripts/win-msvc-bootstrap.ps1 @@ -22,7 +22,7 @@ # # This script is the ONLY writer of the `[target.x86_64-pc-windows-msvc]` table # and the MSVC subset of `[env]` (LIB/INCLUDE/LIBPATH). It does a *scoped* merge -# so it never disturbs the keys the Rust writers own (LLVM_SYS_140_PREFIX, +# so it never disturbs the keys the Rust writers own (LLVM_SYS_211_PREFIX, # CUQUANTUM_ROOT) or any other table, and never emits a duplicate `[env]`. # # Inert (exit 0) on non-Windows so it is safe as an unconditional just prereq. From 3095ed3b6eef18712efb6d4ce0b3b58ef83b9a54 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 10:13:12 -0600 Subject: [PATCH 015/388] Count logical_observable declarations in DEM parsers so unflipped observables aren't dropped --- crates/pecos-decoder-core/src/dem.rs | 61 +++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index ef69f25af..ce0adf31c 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -148,6 +148,17 @@ pub mod utils { } } } + "logical_observable" => { + // Declared observable with no flipping mechanism (Stim emits + // these for deterministic logicals) still counts. + for part in &parts[1..] { + if let Some(l_str) = part.strip_prefix('L') + && let Ok(l) = l_str.parse::() + { + observables.insert(l); + } + } + } _ => {} } } @@ -321,6 +332,16 @@ impl SparseDem { max_detector = Some(max_detector.map_or(d, |m| m.max(d))); } } + } else if let Some(rest) = line.strip_prefix("logical_observable") { + // Stim emits `logical_observable Lk` for observables that no + // error mechanism flips (deterministic / unflipped logicals). + // Honour the declared count so a trailing unflipped observable + // is not silently dropped from `num_observables`. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } } } @@ -392,8 +413,17 @@ impl DemCheckMatrix { if line.is_empty() || line.starts_with('#') { continue; } + if let Some(rest) = line.strip_prefix("logical_observable") { + // Count declared observables that no mechanism flips. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + continue; + } if !line.starts_with("error(") { - // Skip non-error lines (detector, logical_observable, etc.) + // Skip non-error lines (detector, etc.) continue; } @@ -574,6 +604,15 @@ impl DemMatchingGraph { for line in dem.lines() { let line = line.trim(); + if let Some(rest) = line.strip_prefix("logical_observable") { + // Count declared observables that no mechanism flips. + for token in rest.split_whitespace() { + if let Some(l) = token.strip_prefix('L').and_then(|s| s.parse::().ok()) { + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + continue; + } if line.is_empty() || line.starts_with('#') || !line.starts_with("error(") { continue; } @@ -999,6 +1038,26 @@ mod tests { assert_eq!(observables, 2); // L0 and L1 } + #[test] + fn test_logical_observable_declaration_counts() { + // L1 has no flipping mechanism; Stim emits `logical_observable L1`. + // All parsers must still count it so the trailing observable is not + // silently dropped. + let dem = "error(0.01) D0 L0\ndetector(0, 0, 0) D0\nlogical_observable L1\n"; + + let sdem = SparseDem::from_dem_str(dem).unwrap(); + assert_eq!(sdem.num_observables, 2, "SparseDem must count L1"); + + let dcm = DemCheckMatrix::from_dem_str(dem).unwrap(); + assert_eq!(dcm.num_observables, 2, "DemCheckMatrix must count L1"); + + let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); + assert_eq!(graph.num_observables, 2, "DemMatchingGraph must count L1"); + + let (_dets, obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!(obs, 2, "parse_dem_metadata must count L1"); + } + #[test] fn test_dem_check_matrix_basic() { let dem = "error(0.01) D0 D1 L0\nerror(0.02) D1 D2\nerror(0.03) D0 D2 L0"; From dae55dc66d00a0543a1fcdb468c3e9c0a90ff6bb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 10:13:13 -0600 Subject: [PATCH 016/388] Split logical-subgraph membership source from subgraph extractor and add lomatching differential test --- .../src/logical_subgraph.rs | 233 +++++++++++++++--- .../src/fault_tolerance_bindings.rs | 9 + ...test_logical_subgraph_lomatching_parity.py | 124 ++++++++++ 3 files changed, 336 insertions(+), 30 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 1ceb299d7..6f39e550d 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -10,12 +10,25 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! Per-logical-operator subgraph decoder for transversal gates. +//! Logical-operator subgraph decoder for transversal gates. //! -//! Based on the insight (proved independently by Serra-Peralta et al. -//! arXiv:2505.13599 and Cain et al. arXiv:2505.13587) that per-observable -//! subgraphs of a transversal-gate DEM are always graphlike — even when -//! the full DEM contains weight-3+ hyperedges. +//! This decodes a *logical operator's subgraph of the DEM* — not the full DEM. +//! In the Heisenberg picture a detector error model records how errors meet +//! evolving operators: stabilizers evolve into **detectors**, logical operators +//! evolve into **observables**. This decoder groups detectors by their +//! stabilizer (X/Z, per qubit) and restricts the DEM to one logical operator at +//! a time — the errors that can flip it. Each logical operator is effectively +//! its own channel through the circuit (e.g. a transversal CX propagates +//! `XI → XX`, `IZ → ZZ`), so the per-operator problems decouple. +//! +//! The payoff (proved independently by Serra-Peralta et al. arXiv:2505.13599 +//! and Cain et al. arXiv:2505.13587): restricted to one logical operator, the +//! subgraph is always graphlike — only 1-2 detector edges, matchable — even +//! when the full DEM contains weight-3+ hyperedges. So any MWPM-style decoder +//! handles each piece, and the results combine. +//! +//! Naming: abbreviate this "LS decoder" if needed — never "LSD", which collides +//! with BP-LSD (Localised Statistics Decoding) elsewhere in the workspace. //! //! # Algorithm //! @@ -164,19 +177,43 @@ pub fn partition_dem_by_logical_windowed( stab_coords: &StabCoords, max_time_radius: MaxTimeRadius, ) -> Result, DecoderError> { - // Single-pass sparse DEM parsing: mechanisms + detector coordinates. + // Region source (coordinate classification) -> shared subgraph extractor. let sdem = SparseDem::from_dem_str(dem_str)?; - let coord_map = &sdem.detector_coords; + let membership = coordinate_membership_from_dem(&sdem, stab_coords, max_time_radius)?; + subgraphs_from_membership(&sdem, &membership) +} - // Observable flips are packed into a u64 mask (bit i = observable i), so - // more than 64 observables would silently overflow the shift. Fail loud. - if sdem.num_observables > 64 { - return Err(DecoderError::InvalidConfiguration(format!( - "LogicalSubgraphDecoder packs observable flips into a u64 mask and \ - supports at most 64 observables, but the DEM declares {}", - sdem.num_observables, - ))); - } +/// Per-observable detector membership: entry `k` is the sorted full-DEM detector +/// ids in logical observable `k`'s observing region. +/// +/// This is the seam between a *region source* and the *subgraph extractor* +/// ([`subgraphs_from_membership`]). The coordinate region source lives here +/// ([`coordinate_membership_from_dem`]); a back-propagation source can live in a +/// higher crate (e.g. `pecos-qec`) — it only needs to produce this same +/// membership and hand it down, which preserves the `pecos-qec -> +/// pecos-decoder-core` dependency direction. +pub type ObservingRegions = Vec>; + +/// Region source: derive each observable's detector membership from the DEM's +/// spatial stabilizer coordinates and boundary edges. +/// +/// This is the algorithm `lomatching` ships for vertex selection +/// (`get_detector_indices_for_subgraphs`): find the 1-detector mechanisms that +/// flip each observable, bucket their detectors into `(qubit, stab_type)` groups +/// by coordinate, and include every detector of those groups at the boundary +/// times. `max_time_radius` widens the per-time inclusion (default `None` = +/// exact boundary times, matching lomatching). +/// +/// # Errors +/// +/// Returns an error if a detector used in an error mechanism has coordinates +/// that match no stabilizer position in `stab_coords`. +pub fn coordinate_membership_from_dem( + sdem: &SparseDem, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, +) -> Result { + let coord_map = &sdem.detector_coords; // Detectors that appear in an error mechanism are the only ones that affect // decoding. Any such detector that fails to classify would be silently @@ -232,12 +269,12 @@ pub fn partition_dem_by_logical_windowed( ))); } - // For each observable, find its observing region. - let mut subgraphs = Vec::with_capacity(sdem.num_observables); + // For each observable, collect its observing region. + let mut membership: ObservingRegions = Vec::with_capacity(sdem.num_observables); for obs_idx in 0..sdem.num_observables { - // Step 1: Find boundary edges — 1-detector mechanisms that flip - // this observable. Collect (group, time) from each boundary detector. + // Step 1: Find boundary edges — 1-detector mechanisms that flip this + // observable. Collect (group, time) from each boundary detector. let mut group_times: BTreeMap> = BTreeMap::new(); for (_, dets, obs) in &sdem.mechanisms { @@ -256,11 +293,11 @@ pub fn partition_dem_by_logical_windowed( } } - // Step 2: For each (group, time) boundary edge, include ALL - // detectors of that group at that time. This matches lomatching's - // per-time-step approach: detectors are included only at times - // where boundary edges exist, not across the full time range. - // With max_time_radius, extend each boundary time by ±radius. + // Step 2: For each (group, time) boundary edge, include ALL detectors of + // that group at that time. This matches lomatching's per-time-step + // approach: detectors are included only at times where boundary edges + // exist, not across the full time range. With max_time_radius, extend + // each boundary time by ±radius. let mut region_detectors = BTreeSet::new(); for (group, times) in &group_times { if let Some(dets) = group_detectors.get(group) { @@ -281,7 +318,38 @@ pub fn partition_dem_by_logical_windowed( } } - if region_detectors.is_empty() { + membership.push(region_detectors.into_iter().collect()); + } + + Ok(membership) +} + +/// Subgraph extractor: turn per-observable detector membership into decodable +/// graphlike subgraphs. The membership may come from any region source — the +/// coordinate one ([`coordinate_membership_from_dem`]) or a back-propagation +/// builder in a higher crate. +/// +/// # Errors +/// +/// Returns an error if `membership` has more than 64 entries: observable flips +/// are packed into a u64 mask (bit `i` = observable `i`), so any region source +/// feeding this extractor inherits the 64-observable limit. +pub fn subgraphs_from_membership( + sdem: &SparseDem, + membership: &[Vec], +) -> Result, DecoderError> { + if membership.len() > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "LogicalSubgraphDecoder packs observable flips into a u64 mask and \ + supports at most 64 observables, but got membership for {}", + membership.len(), + ))); + } + + let mut subgraphs = Vec::with_capacity(membership.len()); + + for (obs_idx, detectors) in membership.iter().enumerate() { + if detectors.is_empty() { subgraphs.push(LogicalSubgraph { observable_idx: obs_idx, detector_map: Vec::new(), @@ -297,14 +365,15 @@ pub fn partition_dem_by_logical_windowed( continue; } - // Step 3: Build detector mapping. - let detector_map: Vec = region_detectors.into_iter().collect(); + // Build detector mapping (full DEM id -> subgraph-local index). + let detector_map: Vec = detectors.clone(); let mut inverse_map = vec![None; sdem.num_detectors]; for (sub_idx, &full_idx) in detector_map.iter().enumerate() { inverse_map[full_idx] = Some(sub_idx); } - // Step 4: Extract edges for this subgraph. + // Extract edges for this subgraph by projecting mechanisms onto the + // membership detectors. let mut edges = Vec::new(); let mut skipped = 0; @@ -313,7 +382,6 @@ pub fn partition_dem_by_logical_windowed( continue; } - // Map mechanism detectors to subgraph indices. let sub_dets: Vec = dets .iter() .filter_map(|&d| inverse_map[d as usize].map(|s| s as u32)) @@ -444,6 +512,18 @@ impl LogicalSubgraphDecoder { self.subgraphs.get(obs_idx) } + /// Per-observable observing regions: entry `k` is the sorted full-DEM + /// detector ids in observable `k`'s subgraph. This is the membership the + /// region source produced — exposed for differential testing against + /// reference implementations (e.g. lomatching). + #[must_use] + pub fn observing_regions(&self) -> ObservingRegions { + self.subgraphs + .iter() + .map(|sg| sg.detector_map.clone()) + .collect() + } + /// Batch decode multiple syndromes, returning error count. /// /// For each subgraph, extracts all sub-syndromes into a flat buffer @@ -706,6 +786,99 @@ mod tests { assert_eq!(sgs[0].detector_map, vec![0]); } + #[test] + fn test_membership_seam_matches_partition() { + // coordinate_membership_from_dem + subgraphs_from_membership must equal + // the all-in-one partition (the seam is behaviour-preserving). + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(0, 1, 0) D1\n", + "detector(3, 0, 0) D2\n", + "detector(2, 1, 0) D3\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", + "error(0.01) D2 L1\n", + "error(0.01) D2 D3\n", + ); + let sc = simple_stab_coords(); + + let sdem = SparseDem::from_dem_str(dem).unwrap(); + let membership = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(membership, vec![vec![0usize], vec![2usize]]); + + let via_seam = subgraphs_from_membership(&sdem, &membership).unwrap(); + let direct = partition_dem_by_logical(dem, &sc).unwrap(); + assert_eq!(via_seam.len(), direct.len()); + for (a, b) in via_seam.iter().zip(direct.iter()) { + assert_eq!(a.detector_map, b.detector_map); + } + } + + #[test] + fn test_subgraphs_from_external_membership() { + // A region source (e.g. a future back-propagation builder) can hand in + // its own membership and the extractor builds the subgraphs from it. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(1, 0, 1) D1\n", + "error(0.01) D0 L0\n", + "error(0.01) D0 D1\n", + ); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + // Membership the coordinate path would NOT produce on its own (both times). + let membership = vec![vec![0usize, 1usize]]; + let sgs = subgraphs_from_membership(&sdem, &membership).unwrap(); + assert_eq!(sgs.len(), 1); + assert_eq!(sgs[0].detector_map, vec![0, 1]); + + // >64 observable membership is rejected by the extractor regardless of source. + let big: Vec> = (0..65).map(|_| Vec::new()).collect(); + assert!(matches!( + subgraphs_from_membership(&sdem, &big), + Err(DecoderError::InvalidConfiguration(_)) + )); + } + + #[test] + fn test_membership_exact_time_vs_radius() { + // D0 and D1 are both qubit-0 X at times 0 and 1. The boundary edge is at + // time 0. Default (None) includes only the boundary time; a radius pulls + // in the neighbouring time step. + let dem = concat!( + "detector(1, 0, 0) D0\n", // qubit 0 X, time 0 + "detector(1, 0, 1) D1\n", // qubit 0 X, time 1 + "error(0.01) D0 L0\n", // boundary at time 0 + "error(0.01) D0 D1\n", + ); + let sc = simple_stab_coords(); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + + let exact = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(exact, vec![vec![0usize]], "None = exact boundary time only"); + + let widened = coordinate_membership_from_dem(&sdem, &sc, Some(1)).unwrap(); + assert_eq!(widened, vec![vec![0usize, 1usize]], "radius pulls in time 1"); + } + + #[test] + fn test_decomposed_mechanism_partitions() { + // A `^`-decomposed mechanism is XOR-combined into one 2-detector + // mechanism, so it is NOT a boundary edge and does not seed the region; + // the single-detector `D0 L0` does. Partition must still succeed. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(0, 1, 0) D1\n", + "error(0.01) D0 L0\n", // boundary → qubit 0 X + "error(0.02) D0 ^ D1 L0\n", // decomposed; XOR -> {D0, D1} + ); + let sc = simple_stab_coords(); + let sdem = SparseDem::from_dem_str(dem).unwrap(); + // The `^` line parsed as one mechanism with both detectors. + assert_eq!(sdem.mechanisms.len(), 2); + let membership = coordinate_membership_from_dem(&sdem, &sc, None).unwrap(); + assert_eq!(membership, vec![vec![0usize]]); // D1 (qubit-0 Z) excluded + } + #[test] fn test_partition_two_qubits() { let dem = concat!( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 400e23215..4c3e38b79 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4634,6 +4634,15 @@ impl PyLogicalSubgraphDecoder { .collect() } + /// Per-observable observing regions: a list (one entry per observable) of + /// sorted full-DEM detector ids in that observable's subgraph. + /// + /// Exposed for differential testing against reference implementations such + /// as `lomatching.get_detector_indices_for_subgraphs`. + fn observing_regions(&self) -> Vec> { + self.inner.observing_regions() + } + /// Diagnostics: (`num_edges`, `skipped_hyperedges`) for each subgraph. fn subgraph_diagnostics(&self) -> Vec<(usize, usize)> { (0..self.inner.num_observables()) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py new file mode 100644 index 000000000..4975b2ca9 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py @@ -0,0 +1,124 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Differential test: PECOS LogicalSubgraphDecoder observing-region selection +vs the reference algorithm shipped by lomatching. + +PECOS's `coordinate_membership_from_dem` (exposed via +`LogicalSubgraphDecoder.observing_regions`) is the same boundary-edge + +stabilizer-coordinate vertex selection that lomatching ships for its MWPM +subgraphs (Serra-Peralta et al., arXiv:2505.13599). The papers justify the +construction via Clifford back-propagation, but the shipping decoder uses this +coordinate path -- so PECOS should agree with it detector-for-detector. + +The oracle below is a faithful port of +``lomatching.util.get_detector_indices_for_subgraphs`` +(``~/Repos/lomatching/lomatching/util.py:255-353``), depending only on stim so +the full lomatching decoder stack (numba/galois/ldpc/pymatching) is not required. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalSubgraphDecoder + +stim = pytest.importorskip("stim", reason="stim is the differential-test oracle dependency") + + +def _lomatching_reference_membership(dem_str: str, stab_coords) -> list[list[int]]: + """Faithful port of lomatching's ``get_detector_indices_for_subgraphs``. + + Returns, per observable (sorted by observable index), the sorted detector + ids in that observable's observing region. Reference: + Serra-Peralta et al. arXiv:2505.13599; lomatching/util.py:255-353. + """ + dem = stim.DetectorErrorModel(dem_str).flattened() + + det_to_coords = { + d: tuple(map(float, c)) for d, c in dem.get_detector_coordinates().items() + } + coords_to_det = {c: d for d, c in det_to_coords.items()} + + # spatial coord (all but the trailing time element) -> (logical qubit, stab type) + coords_to_stab: dict[tuple, tuple[int, str]] = {} + for l_ind, qubit in enumerate(stab_coords): + for stab_type in ("X", "Z"): + for coord in qubit[stab_type]: + coords_to_stab[tuple(map(float, coord))] = (l_ind, stab_type) + + # boundary edges = single-detector error mechanisms that flip an observable + bd_edges_obs: dict[int, list[int]] = {o: [] for o in range(dem.num_observables)} + for instr in dem: + if instr.type != "error": + continue + dets = [t.val for t in instr.targets_copy() if t.is_relative_detector_id()] + if len(dets) != 1: + continue + for o in (t.val for t in instr.targets_copy() if t.is_logical_observable_id()): + bd_edges_obs[o] += dets + + # (logical qubit, stab type, time) seeds for each observable + lst_obs: dict[int, set[tuple[int, str, float]]] = { + o: set() for o in range(dem.num_observables) + } + for obs, dets in bd_edges_obs.items(): + for det in dets: + coords = det_to_coords[det] + l_ind, stab = coords_to_stab[coords[:-1]] + lst_obs[obs].add((l_ind, stab, coords[-1])) + + # include every detector of those (qubit, stab, time) groups + membership: list[list[int]] = [] + for obs in sorted(lst_obs): + inds: list[int] = [] + for l_ind, stab, time in lst_obs[obs]: + for c in stab_coords[l_ind][stab]: + coord = (*map(float, c), time) + inds.append(coords_to_det[coord]) + membership.append(sorted(inds)) + return membership + + +def _assert_parity(dem_str: str, stab_coords) -> None: + decoder = LogicalSubgraphDecoder(dem_str, stab_coords, "pecos_uf:fast") + pecos = [sorted(r) for r in decoder.observing_regions()] + reference = _lomatching_reference_membership(dem_str, stab_coords) + + assert len(pecos) == len(reference), ( + f"observable count mismatch: PECOS {len(pecos)} vs lomatching {len(reference)}" + ) + for obs, (p, r) in enumerate(zip(pecos, reference, strict=True)): + assert p == r, f"observable {obs} membership differs:\n PECOS={p}\n lomatching={r}" + + +def test_parity_memory_z(): + """Single-patch Z memory: PECOS regions == lomatching regions.""" + b = LogicalCircuitBuilder() + b.add_patch(SurfacePatch.create(distance=3), "A") + b.add_memory("A", 3, "Z") + dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + _assert_parity(dem_str, b.stab_coords()) + + +def test_parity_transversal_cx(): + """Two-patch transversal CX (the hyperedge case): regions must agree.""" + patch = SurfacePatch.create(distance=3) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 3, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 3, "Z") + dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + _assert_parity(dem_str, b.stab_coords()) From 96f95dfa5f0f9503548c4e097624e706023945aa Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 10:22:02 -0600 Subject: [PATCH 017/388] Reject non-flattened DEMs (repeat / shift_detectors) in line-based parsers instead of silently mis-parsing --- crates/pecos-decoder-core/src/dem.rs | 40 ++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index ce0adf31c..acf4003e3 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -262,6 +262,19 @@ impl SparseDem { for line in dem.lines() { let line = line.trim(); + // This is a flat, single-pass parser: it does not expand `repeat` + // blocks or apply `shift_detectors`. Silently mis-parsing those would + // corrupt detector ids, so refuse them and tell the caller to flatten + // (e.g. stim's `DetectorErrorModel.flattened()`). + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "SparseDem requires a flattened DEM: `repeat` / `shift_detectors` \ + are not supported. Flatten the DEM first (e.g. stim's \ + DetectorErrorModel.flattened())." + .into(), + )); + } + if let Some(rest) = line.strip_prefix("error(") { let close = rest.find(')').ok_or_else(|| { DecoderError::InvalidConfiguration("Missing ) in error line".into()) @@ -422,6 +435,13 @@ impl DemCheckMatrix { } continue; } + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "DemCheckMatrix requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } if !line.starts_with("error(") { // Skip non-error lines (detector, etc.) continue; @@ -613,6 +633,13 @@ impl DemMatchingGraph { } continue; } + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "DemMatchingGraph requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } if line.is_empty() || line.starts_with('#') || !line.starts_with("error(") { continue; } @@ -1058,6 +1085,19 @@ mod tests { assert_eq!(obs, 2, "parse_dem_metadata must count L1"); } + #[test] + fn test_non_flattened_dem_rejected() { + // repeat blocks and shift_detectors would corrupt detector ids if parsed + // line-by-line; all parsers must refuse rather than silently mis-parse. + let repeat_dem = "repeat 3 {\n error(0.01) D0 L0\n shift_detectors 1\n}\n"; + assert!(SparseDem::from_dem_str(repeat_dem).is_err()); + assert!(DemCheckMatrix::from_dem_str(repeat_dem).is_err()); + assert!(DemMatchingGraph::from_dem_str(repeat_dem).is_err()); + + let shift_dem = "error(0.01) D0 L0\nshift_detectors 1\nerror(0.01) D0 L0\n"; + assert!(SparseDem::from_dem_str(shift_dem).is_err()); + } + #[test] fn test_dem_check_matrix_basic() { let dem = "error(0.01) D0 D1 L0\nerror(0.02) D1 D2\nerror(0.03) D0 D2 L0"; From 6fd2ce4c12867faf751e5abbe13d12b5f2c2ecce Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 11:19:44 -0600 Subject: [PATCH 018/388] Add LogicalSubgraphDecoder.from_membership and compare coordinate vs back-prop observing regions (back-prop decodes ~25x worse) --- .../src/logical_subgraph.rs | 42 ++++++ .../src/fault_tolerance_bindings.rs | 28 ++++ ...test_logical_subgraph_region_comparison.py | 121 ++++++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 6f39e550d..47b28d026 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -500,6 +500,48 @@ impl LogicalSubgraphDecoder { }) } + /// Build from a precomputed per-observable detector membership instead of + /// from `stab_coords`. + /// + /// The membership may come from ANY region source — the coordinate path + /// ([`coordinate_membership_from_dem`]) or a back-propagation / detecting- + /// region source. This is the entry point for comparing alternative + /// observing-region constructions (e.g. the paper's back-propagation region + /// vs the coordinate group-fill) on the same DEM and decoders. + /// + /// # Errors + /// + /// Returns an error if the DEM is malformed, the membership has more than 64 + /// entries, or the factory fails. + pub fn from_membership( + dem: &str, + membership: &[Vec], + mut factory: F, + ) -> Result + where + F: FnMut( + &DemMatchingGraph, + ) -> Result, DecoderError>, + { + let sdem = SparseDem::from_dem_str(dem)?; + let subgraphs = subgraphs_from_membership(&sdem, membership)?; + let num_observables = subgraphs.len(); + + let mut decoders = Vec::with_capacity(subgraphs.len()); + let mut sub_syndromes = Vec::with_capacity(subgraphs.len()); + for sg in &subgraphs { + decoders.push(factory(&sg.graph)?); + sub_syndromes.push(vec![0u8; sg.detector_map.len()]); + } + + Ok(Self { + subgraphs, + decoders, + num_observables, + sub_syndromes, + }) + } + /// Number of observables. #[must_use] pub fn num_observables(&self) -> usize { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 4c3e38b79..c52592a2e 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4479,6 +4479,34 @@ impl PyLogicalSubgraphDecoder { Ok(Self { inner }) } + /// Build from a precomputed per-observable detector membership instead of + /// from `stab_coords`. + /// + /// `membership` is a list (one entry per observable) of full-DEM detector + /// ids. This lets callers supply an alternative observing-region + /// construction (e.g. the paper's back-propagation / detecting-region set) + /// and decode with the same machinery for direct comparison. + #[staticmethod] + #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:fast"))] + fn from_membership( + dem: &str, + membership: Vec>, + inner_decoder: &str, + ) -> PyResult { + use pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder; + + let inner = LogicalSubgraphDecoder::from_membership(dem, &membership, |subgraph| { + let sub_dem = subgraph_to_dem_string(subgraph); + let decoder = create_observable_decoder(&sub_dem, inner_decoder) + .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; + Ok(Box::new(SendWrapper(decoder)) + as Box) + }) + .map_err(|e| PyErr::new::(e.to_string()))?; + + Ok(Self { inner }) + } + /// Decode a syndrome and return observable flip predictions. fn decode(&mut self, syndrome: Vec) -> PyResult { use pecos_decoder_core::ObservableDecoder; diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py new file mode 100644 index 000000000..0a7ae476a --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -0,0 +1,121 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Compare observing-region constructions for the logical-subgraph decoder. + +The decoder's accuracy depends on which detectors land in each observable's +subgraph. Two constructions: + +- **coordinate group-fill** (the shipping path, == lomatching's + ``get_detector_indices_for_subgraphs``): boundary edges seed + ``(qubit, stab_type, time)`` groups, then ALL detectors of those groups are + included. +- **back-propagation / co-flipped** (the papers' derivation, arXiv:2505.13587): + detectors that share a fault with the observable -- i.e. lie in its + detecting region. Computed here directly from the DEM ``L`` targets. + +`LogicalSubgraphDecoder.from_membership` lets both feed the same decoder, so we +can compare. Finding (recorded in +``pecos-docs/design/logical-subgraph-backprop-region-builder.md``): the raw +back-prop set decodes much WORSE despite being larger, because it lacks the +group-fill structure that makes each subgraph cleanly matchable. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem + + +def _coflip_membership_from_dem(dem_str: str, num_observables: int) -> list[list[int]]: + """Back-propagation / co-flipped observing region, read straight off the DEM. + + For each observable O, the detectors of every error mechanism that flips O + (an error in their shared support flips both -- O's detecting region). + """ + regions: list[set[int]] = [set() for _ in range(num_observables)] + for raw_line in dem_str.splitlines(): + line = raw_line.strip() + if not line.startswith("error("): + continue + tokens = line[line.index(")") + 1 :].split() + dets = [int(t[1:]) for t in tokens if t.startswith("D")] + obs = [int(t[1:]) for t in tokens if t.startswith("L")] + for o in obs: + regions[o].update(dets) + return [sorted(r) for r in regions] + + +def _cx_circuit(): + patch = SurfacePatch.create(distance=3) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 3, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 3, "Z") + return b + + +def test_from_membership_reproduces_coordinate_path(): + """Feeding the coordinate membership through from_membership reproduces the + normal coordinate decoder exactly (the seam is behaviour-preserving).""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + rebuilt = LogicalSubgraphDecoder.from_membership( + dem, + coord.observing_regions(), + "pecos_uf:fast", + ) + + assert rebuilt.subgraph_sizes() == coord.subgraph_sizes() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(2000, seed=3) + assert rebuilt.decode_count(batch) == coord.decode_count(batch) + + +def test_coordinate_region_beats_raw_backprop_region(): + """The coordinate group-fill region decodes far better than the raw + back-prop / co-flipped detector set -- group-fill is essential, not + cosmetic.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + n = 20000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=11) + + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + coflip_membership = _coflip_membership_from_dem(dem, coord.num_observables()) + backprop = LogicalSubgraphDecoder.from_membership(dem, coflip_membership, "pecos_uf:fast") + + coord_ler = coord.decode_count(batch) / n + backprop_ler = backprop.decode_count(batch) / n + + # The coordinate group-fill region is dramatically better. The gap is large + # and stable (~20x at d=3, p=0.001); assert a conservative margin. + assert coord_ler < backprop_ler, ( + f"expected coordinate region to beat raw back-prop: " + f"coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + ) + assert coord_ler * 5 < backprop_ler, ( + f"expected a large gap (group-fill essential): " + f"coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v", "-s"]) From 62357753fbdef33d37e50b69154054551ad63784 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 11:52:27 -0600 Subject: [PATCH 019/388] Support replacement two-qubit DEM weights --- .../src/fault_tolerance/dem_builder.rs | 3 +- .../fault_tolerance/dem_builder/builder.rs | 7 +- .../src/fault_tolerance/dem_builder/types.rs | 300 +++++++++++++++++- .../src/fault_tolerance_bindings.rs | 79 ++++- python/quantum-pecos/src/pecos/qec/dem.py | 15 +- .../src/pecos/qec/surface/decode.py | 20 +- 6 files changed, 400 insertions(+), 24 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index a6af42108..82abce66a 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -100,6 +100,7 @@ pub use types::{ ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, - PecosDemMetadataError, PerGateTypeNoise, TwoDetectorDirectRenderPolicy, combine_probabilities, + PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, + TwoDetectorDirectRenderPolicy, combine_probabilities, omitted_two_qubit_gate_pauli_twirl, record_offset_to_absolute_index, }; diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index d6dd9a5e4..1b9ae02e4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -378,7 +378,12 @@ impl<'a> DemBuilder<'a> { let flat = idx + 1; let p1 = flat / 4; let p2 = flat % 4; - self.noise.p2 * weights.weight_for(&pauli_pair_for_weight(p1, p2)) + self.noise.p2 + * weights.two_qubit_weight_for( + loc1.gate_type, + &pauli_pair_for_weight(p1, p2), + self.noise.p2_replacement_approximation, + ) }); } [per_channel_probability(self.noise.p2, 15); 15] diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index e23e3d68d..664e381d9 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1410,8 +1410,23 @@ impl std::error::Error for PecosDemMetadataError {} /// ``` #[derive(Debug, Clone)] pub struct PauliWeights { - /// (`PauliString`, weight) pairs. Weights must sum to ~1.0. + /// Post-gate (`PauliString`, weight) pairs. entries: Vec<(pecos_core::PauliString, f64)>, + /// Replacement (`PauliString`, weight) pairs. These omit the ideal gate before + /// applying the stored Pauli. + replacement_entries: Vec<(pecos_core::PauliString, f64)>, +} + +/// Approximation used for replacement two-qubit fault branches. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ReplacementBranchApproximation { + /// Ignore the omitted ideal gate and treat replacement entries like post-gate + /// Pauli entries. Useful as a baseline comparison. + IgnoreGateRemoval, + /// Convolve replacement entries with the Pauli twirl of the omitted ideal + /// gate's dagger. This is the default approximation for starred entries. + #[default] + PauliTwirlOmittedGate, } impl PauliWeights { @@ -1423,16 +1438,40 @@ impl PauliWeights { /// /// Panics if weights don't sum to ~1.0 or if any weight is negative. pub fn new(entries: impl IntoIterator) -> Self { + Self::with_replacement(entries, std::iter::empty()) + } + + /// Create from post-gate and replacement branch entries. + /// + /// Replacement entries model branches where the ideal two-qubit gate is omitted + /// and the entry Pauli is applied instead. The combined post-gate and replacement + /// branch weights must sum to ~1.0. + /// + /// # Panics + /// + /// Panics if weights don't sum to ~1.0 or if any weight is negative. + pub fn with_replacement( + entries: impl IntoIterator, + replacement_entries: impl IntoIterator, + ) -> Self { let entries: Vec<_> = entries.into_iter().collect(); - let sum: f64 = entries.iter().map(|(_, w)| w).sum(); + let replacement_entries: Vec<_> = replacement_entries.into_iter().collect(); + let sum: f64 = entries + .iter() + .chain(replacement_entries.iter()) + .map(|(_, w)| w) + .sum(); assert!( (sum - 1.0).abs() < 1e-6, "PauliWeights must sum to 1.0, got {sum}" ); - for (ps, w) in &entries { + for (ps, w) in entries.iter().chain(replacement_entries.iter()) { assert!(*w >= 0.0, "Weight for {ps} must be non-negative, got {w}"); } - Self { entries } + Self { + entries, + replacement_entries, + } } /// Uniform weights for single-qubit gates: X, Y, Z each with 1/3. @@ -1441,6 +1480,7 @@ impl PauliWeights { use pecos_core::pauli::{X, Y, Z}; Self { entries: vec![(X(0), 1.0 / 3.0), (Y(0), 1.0 / 3.0), (Z(0), 1.0 / 3.0)], + replacement_entries: Vec::new(), } } @@ -1467,6 +1507,7 @@ impl PauliWeights { (Z(0) & Y(1), w), (Z(0) & Z(1), w), ], + replacement_entries: Vec::new(), } } @@ -1484,11 +1525,76 @@ impl PauliWeights { .map_or(0.0, |(_, w)| *w) } + /// Look up the effective two-qubit Pauli weight for a specific gate. + /// + /// Plain entries contribute directly. Replacement entries first convolve with + /// the Pauli twirl of the omitted gate, so `*II` on `SZZ` contributes half + /// `II` and half `ZZ`, while `*XX` on `SZZ` contributes half `XX` and half + /// `YY`. The identity component is intentionally not returned by callers that + /// query only non-identity Pauli labels. + #[must_use] + pub fn two_qubit_weight_for( + &self, + gate_type: GateType, + pauli: &pecos_core::PauliString, + approximation: ReplacementBranchApproximation, + ) -> f64 { + let Ok(query_label) = two_qubit_pauli_label(pauli) else { + return 0.0; + }; + let direct = self + .entries + .iter() + .filter_map(|(ps, weight)| { + (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) + }) + .sum::(); + + if approximation == ReplacementBranchApproximation::IgnoreGateRemoval { + return direct + + self + .replacement_entries + .iter() + .filter_map(|(ps, weight)| { + (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) + }) + .sum::(); + } + + let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { + return direct; + }; + direct + + self + .replacement_entries + .iter() + .filter_map(|(ps, weight)| { + let replacement_label = two_qubit_pauli_label(ps).ok()?; + Some( + twirl + .iter() + .filter_map(|(twirl_label, twirl_weight)| { + (multiply_two_qubit_pauli_labels(&replacement_label, twirl_label) + == query_label) + .then_some(weight * twirl_weight) + }) + .sum::(), + ) + }) + .sum::() + } + /// Get all entries as `(PauliString, weight)` pairs. #[must_use] pub fn entries(&self) -> &[(pecos_core::PauliString, f64)] { &self.entries } + + /// Get replacement entries as `(PauliString, weight)` pairs. + #[must_use] + pub fn replacement_entries(&self) -> &[(pecos_core::PauliString, f64)] { + &self.replacement_entries + } } impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights { @@ -1497,6 +1603,37 @@ impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights } } +/// Return the Pauli-twirled channel for omitting a supported two-qubit Clifford gate. +/// +/// Some physical error models have *replacement* fault branches: when the branch +/// fires, the intended gate is not applied and the branch operation is applied +/// instead. A DEM built in the ideal-circuit frame can approximate that missing +/// operation by convolving the replacement branch with the Pauli twirl of the +/// omitted gate's inverse. Clifford gates and their adjoints have the same +/// Pauli-twirl probabilities, so this helper returns the distribution in terms +/// of two-qubit Pauli labels, including `"II"` when present. +/// +/// This helper is intentionally parameter-free and device-agnostic. Callers +/// remain responsible for deciding which fault branches are replacement +/// branches, how leakage symbols are projected, and how branch probabilities are +/// scaled. +#[must_use] +pub fn omitted_two_qubit_gate_pauli_twirl( + gate_type: GateType, +) -> Option> { + let entries: &[(&str, f64)] = match gate_type { + GateType::CX => &[("II", 0.25), ("IX", 0.25), ("ZI", 0.25), ("ZX", 0.25)], + GateType::CY => &[("II", 0.25), ("IY", 0.25), ("ZI", 0.25), ("ZY", 0.25)], + GateType::CZ => &[("II", 0.25), ("IZ", 0.25), ("ZI", 0.25), ("ZZ", 0.25)], + GateType::SWAP => &[("II", 0.25), ("XX", 0.25), ("YY", 0.25), ("ZZ", 0.25)], + GateType::SXX | GateType::SXXdg => &[("II", 0.5), ("XX", 0.5)], + GateType::SYY | GateType::SYYdg => &[("II", 0.5), ("YY", 0.5)], + GateType::SZZ | GateType::SZZdg => &[("II", 0.5), ("ZZ", 0.5)], + _ => return None, + }; + Some(entries.iter().copied().collect()) +} + /// Noise model configuration for circuit-level fault analysis. #[derive(Debug, Clone)] pub struct NoiseConfig { @@ -1532,6 +1669,8 @@ pub struct NoiseConfig { /// Maps each two-qubit Pauli fault to its relative probability. Must sum to ~1.0. /// Default (None) = uniform depolarizing. pub p2_weights: Option, + /// Approximation used for replacement two-qubit entries in `p2_weights`. + pub p2_replacement_approximation: ReplacementBranchApproximation, /// Coherent idle RZ rotation angle per time unit. /// /// When set (> 0), idle gates contribute a coherent Z rotation in addition @@ -1628,6 +1767,7 @@ impl Default for NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, @@ -1653,6 +1793,7 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, @@ -1676,6 +1817,7 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, @@ -1699,6 +1841,7 @@ impl NoiseConfig { t2: None, p1_weights: None, p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, @@ -1802,6 +1945,16 @@ impl NoiseConfig { self } + /// Sets how replacement entries in `p2_weights` are approximated. + #[must_use] + pub fn set_p2_replacement_approximation( + mut self, + approximation: ReplacementBranchApproximation, + ) -> Self { + self.p2_replacement_approximation = approximation; + self + } + /// Sets idle noise from a coherent RZ rotation angle per time unit. /// /// Converts `idle_rz` (the angle theta of an RZ(theta) rotation applied @@ -1927,6 +2080,43 @@ fn pauli_pattern(ps: &pecos_core::PauliString) -> Vec { ps.paulis().iter().map(|&(p, _)| p).collect() } +fn two_qubit_pauli_label(ps: &pecos_core::PauliString) -> Result { + let mut chars = ['I', 'I']; + for &(pauli, qubit) in ps.paulis() { + let idx = qubit.index(); + if idx >= 2 { + return Err(format!( + "two-qubit Pauli weights only support qubit indices 0 and 1, got {idx}" + )); + } + chars[idx] = match pauli { + pecos_core::Pauli::I => 'I', + pecos_core::Pauli::X => 'X', + pecos_core::Pauli::Y => 'Y', + pecos_core::Pauli::Z => 'Z', + }; + } + Ok(chars.iter().collect()) +} + +fn multiply_two_qubit_pauli_labels(left: &str, right: &str) -> String { + left.chars() + .zip(right.chars()) + .map(|(a, b)| multiply_pauli_labels(a, b)) + .collect() +} + +fn multiply_pauli_labels(left: char, right: char) -> char { + match (left, right) { + ('I', p) | (p, 'I') => p, + ('X', 'X') | ('Y', 'Y') | ('Z', 'Z') => 'I', + ('X', 'Y') | ('Y', 'X') => 'Z', + ('X', 'Z') | ('Z', 'X') => 'Y', + ('Y', 'Z') | ('Z', 'Y') => 'X', + _ => unreachable!("validated Pauli labels contain only I/X/Y/Z"), + } +} + fn pecos_metadata_dem_output_value(target: &DemOutput) -> serde_json::Value { serde_json::json!({ "id": target.id, @@ -5494,4 +5684,106 @@ mod tests { Some(DirectSourceFamily::SingleLocationY) ); } + + #[test] + fn test_omitted_two_qubit_gate_pauli_twirl_for_spp_gates() { + let szz = omitted_two_qubit_gate_pauli_twirl(GateType::SZZ).expect("SZZ is supported"); + let szzdg = + omitted_two_qubit_gate_pauli_twirl(GateType::SZZdg).expect("SZZdg is supported"); + + assert_eq!(szz, BTreeMap::from([("II", 0.5), ("ZZ", 0.5)])); + assert_eq!(szzdg, szz); + } + + #[test] + fn test_omitted_two_qubit_gate_pauli_twirl_for_entanglers() { + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::CX).expect("CX is supported"), + BTreeMap::from([("II", 0.25), ("IX", 0.25), ("ZI", 0.25), ("ZX", 0.25)]), + ); + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::CZ).expect("CZ is supported"), + BTreeMap::from([("II", 0.25), ("IZ", 0.25), ("ZI", 0.25), ("ZZ", 0.25)]), + ); + assert_eq!( + omitted_two_qubit_gate_pauli_twirl(GateType::SWAP).expect("SWAP is supported"), + BTreeMap::from([("II", 0.25), ("XX", 0.25), ("YY", 0.25), ("ZZ", 0.25)]), + ); + assert!(omitted_two_qubit_gate_pauli_twirl(GateType::RZZ).is_none()); + } + + #[test] + fn test_two_qubit_replacement_weight_convolves_with_omitted_gate_twirl() { + use pecos_core::pauli::{X, Y, Z}; + + let weights = PauliWeights::with_replacement([(X(0) & X(1), 0.25)], [(X(0) & X(1), 0.75)]); + + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(X(0) & X(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - (0.25 + 0.75 * 0.5)) + .abs() + < 1e-12 + ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(Y(0) & Y(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.75 * 0.5) + .abs() + < 1e-12 + ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(X(0) & X(1)), + ReplacementBranchApproximation::IgnoreGateRemoval, + ) - 1.0) + .abs() + < 1e-12 + ); + + let replacement_omits_only = PauliWeights::with_replacement( + [], + [( + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + Vec::new(), + ), + 1.0, + )], + ); + assert!( + (replacement_omits_only.two_qubit_weight_for( + GateType::SZZ, + &(Z(0) & Z(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.5) + .abs() + < 1e-12 + ); + + let cx_replacement_identity = PauliWeights::with_replacement([], [(Z(0) & X(1), 1.0)]); + assert!( + (cx_replacement_identity.two_qubit_weight_for( + GateType::CX, + &(Z(0) & X(1)), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.25) + .abs() + < 1e-12 + ); + assert!( + (cx_replacement_identity.two_qubit_weight_for( + GateType::CX, + &Z(0), + ReplacementBranchApproximation::PauliTwirlOmittedGate, + ) - 0.25) + .abs() + < 1e-12 + ); + } } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index cd5d354fe..1c8f80aad 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -52,7 +52,7 @@ use pecos_qec::fault_tolerance::dem_builder::{ DetectorErrorModel as RustDetectorErrorModel, DirectSourceFamily as RustDirectSourceFamily, EquivalenceResult as RustEquivalenceResult, FaultContribution as RustFaultContribution, FaultSourceType as RustFaultSourceType, NoiseConfig, PAULI_2Q_ORDER, - ParsedDem as RustParsedDem, PauliWeights, + ParsedDem as RustParsedDem, PauliWeights, ReplacementBranchApproximation, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, compare_dems_exact as rust_compare_dems_exact, compare_dems_statistical as rust_compare_dems_statistical, @@ -76,12 +76,18 @@ fn parse_p2_weights(weights: BTreeMap) -> PyResult { use pecos_core::pauli::{X, Y, Z}; let mut entries = Vec::with_capacity(weights.len()); + let mut replacement_entries = Vec::new(); let mut sum = 0.0; for (label, weight) in weights { let label = label.trim().to_ascii_uppercase(); - if !PAULI_2Q_ORDER.contains(&label.as_str()) { + let (replacement, label) = match label.strip_prefix('*') { + Some(stripped) => (true, stripped.to_string()), + None => (false, label), + }; + let replacement_identity = replacement && label == "II"; + if !replacement_identity && !PAULI_2Q_ORDER.contains(&label.as_str()) { let msg = format!( - "p2_weights keys must be one of {:?}, got {label:?}", + "p2_weights keys must be one of {:?} or prefixed with '*' for replacement branches, got {label:?}", PAULI_2Q_ORDER ); return Err(pyo3::exceptions::PyValueError::new_err(msg)); @@ -107,19 +113,54 @@ fn parse_p2_weights(weights: BTreeMap) -> PyResult { (Some(existing), Some(term)) => Some(existing & term), }; } - let Some(pauli) = pauli else { + let pauli = if let Some(pauli) = pauli { + pauli + } else if replacement { + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + Vec::new(), + ) + } else { return Err(pyo3::exceptions::PyValueError::new_err( - "p2_weights cannot contain the identity pair 'II'", + "plain p2_weights cannot contain identity pair 'II'; use '*II' for a replacement branch that only omits the gate", )); }; sum += weight; - entries.push((pauli, weight)); + if replacement { + replacement_entries.push((pauli, weight)); + } else { + entries.push((pauli, weight)); + } } if (sum - 1.0).abs() >= 1.0e-6 { let msg = format!("p2_weights relative probabilities must sum to 1.0, got {sum}"); return Err(pyo3::exceptions::PyValueError::new_err(msg)); } - Ok(PauliWeights::new(entries)) + Ok(PauliWeights::with_replacement(entries, replacement_entries)) +} + +fn parse_replacement_approximation( + value: Option, +) -> PyResult { + let Some(value) = value else { + return Ok(ReplacementBranchApproximation::default()); + }; + match value + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_") + .as_str() + { + "pauli_twirl_omitted_gate" | "pauli_twirl" | "twirl" => { + Ok(ReplacementBranchApproximation::PauliTwirlOmittedGate) + } + "ignore_gate_removal" | "ignore_removal" | "post_gate" | "postgate" => { + Ok(ReplacementBranchApproximation::IgnoreGateRemoval) + } + _ => Err(pyo3::exceptions::PyValueError::new_err( + "p2_replacement_approximation must be 'pauli_twirl_omitted_gate' or 'ignore_gate_removal'", + )), + } } fn apply_noise_options( @@ -137,6 +178,7 @@ fn apply_noise_options( p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { @@ -172,6 +214,9 @@ fn apply_noise_options( if let Some(weights) = p2_weights { noise = noise.set_p2_weights(parse_p2_weights(weights)?); } + noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( + p2_replacement_approximation, + )?); Ok(noise) } @@ -1057,7 +1102,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1078,6 +1123,7 @@ impl PyDetectorErrorModel { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; @@ -1096,6 +1142,7 @@ impl PyDetectorErrorModel { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; if let Ok(dag) = circuit.extract::>() @@ -1405,7 +1452,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1426,6 +1473,7 @@ impl PyDemBuilder { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -1442,6 +1490,7 @@ impl PyDemBuilder { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; Ok(slf) } @@ -3286,7 +3335,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3307,6 +3356,7 @@ impl PyDemSampler { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3323,6 +3373,7 @@ impl PyDemSampler { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; // Accept both DagCircuit and TickCircuit @@ -3433,7 +3484,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3456,6 +3507,7 @@ impl PyDemSampler { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3472,6 +3524,7 @@ impl PyDemSampler { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) @@ -3925,7 +3978,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -3946,6 +3999,7 @@ impl PyDemSamplerBuilder { p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, p2_weights: Option>, + p2_replacement_approximation: Option, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3962,6 +4016,7 @@ impl PyDemSamplerBuilder { p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, p2_weights, + p2_replacement_approximation, )?; Ok(slf) } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index e268bb9db..12087cf76 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -61,6 +61,7 @@ def from_guppy( p1: float = 0.001, p2: float = 0.01, p2_weights: P2Weights | None = None, + p2_replacement_approximation: str | None = None, p_meas: float = 0.001, p_prep: float = 0.001, p_idle: float | None = None, @@ -143,9 +144,16 @@ def from_guppy( circuit; if given, it must match the traced count. p1: Single-qubit gate depolarizing rate. p2: Two-qubit gate depolarizing rate. - p2_weights: Optional relative probabilities over the 15 non-identity - two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum - to 1.0; ``p2`` remains the total two-qubit error rate. + p2_weights: Optional relative probabilities over two-qubit Pauli + error labels. Plain labels such as ``"XX"`` are post-gate + Pauli branches; labels prefixed by ``"*"`` such as ``"*XX"`` + are replacement branches that omit the ideal two-qubit gate + before applying the Pauli. Values must sum to 1.0; ``p2`` + remains the total two-qubit error rate. + p2_replacement_approximation: Approximation used for starred + replacement labels. ``"pauli_twirl_omitted_gate"`` convolves + with the omitted two-qubit gate's Pauli twirl; ``"ignore_gate_removal"`` + treats starred entries like plain post-gate Pauli entries. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. p_idle: Optional uniform depolarizing idle-noise rate per idle duration. @@ -262,6 +270,7 @@ def from_guppy( p1=p1, p2=p2, p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 4b5ab9258..5d14d8933 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -91,9 +91,16 @@ class NoiseModel: Attributes: p1: Single-qubit gate error rate. p2: Two-qubit gate error rate. - p2_weights: Optional relative probabilities over the 15 non-identity - two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to - 1.0; ``p2`` remains the total two-qubit error rate. + p2_weights: Optional relative probabilities over two-qubit Pauli error + labels. Plain labels such as ``"XX"`` are post-gate Pauli branches; + labels prefixed by ``"*"`` such as ``"*XX"`` are replacement + branches that omit the ideal two-qubit gate before applying the + Pauli. Values must sum to 1.0; ``p2`` remains the total two-qubit + error rate. + p2_replacement_approximation: Approximation used for starred + replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with + the omitted two-qubit gate's Pauli twirl; ``"ignore_gate_removal"`` + treats starred entries like plain post-gate Pauli entries. p_meas: Measurement error rate. p_prep: Initialization error rate. p_idle: Idle noise rate per time unit (uniform depolarizing). @@ -112,6 +119,7 @@ class NoiseModel: p1: float = 0.0 p2: float = 0.0 p2_weights: P2Weights | None = None + p2_replacement_approximation: str | None = None p_meas: float = 0.0 p_prep: float = 0.0 p_idle: float | None = None @@ -1341,6 +1349,7 @@ def _dem_string_from_cached_surface_topology( p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, p2_weights=_p2_weights_dict(noise.p2_weights), + p2_replacement_approximation=noise.p2_replacement_approximation, ) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) @@ -1364,6 +1373,7 @@ def _cached_surface_native_dem_string( p_prep: float, decompose_errors: bool, p2_weights: tuple[tuple[str, float], ...] | None = None, + p2_replacement_approximation: str | None = None, p_idle: float | None = None, t1: float | None = None, t2: float | None = None, @@ -1404,6 +1414,7 @@ def _cached_surface_native_dem_string( p1=p1, p2=p2, p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, @@ -1475,6 +1486,7 @@ def _build_native_sampler_from_cached_surface_topology( p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, p2_weights=_p2_weights_dict(noise.p2_weights), + p2_replacement_approximation=noise.p2_replacement_approximation, ) # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" @@ -1576,6 +1588,7 @@ def generate_circuit_level_dem_from_builder( noise.p_prep, decompose_errors=decompose_errors, p2_weights=noise.p2_weights, + p2_replacement_approximation=noise.p2_replacement_approximation, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, @@ -3251,6 +3264,7 @@ def build_native_sampler( noise.p_prep, decompose_errors=True, p2_weights=noise.p2_weights, + p2_replacement_approximation=noise.p2_replacement_approximation, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2, From 454a60c9c704a3872be46f3b8421cac8639a929e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 11:57:48 -0600 Subject: [PATCH 020/388] Add back-prop-seeded group-fill region comparison: strictly broader than coordinate and decodes worse --- ...test_logical_subgraph_region_comparison.py | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index 0a7ae476a..c10468e87 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -56,6 +56,60 @@ def _coflip_membership_from_dem(dem_str: str, num_observables: int) -> list[list return [sorted(r) for r in regions] +def _groupfill_membership(dem_str: str, stab_coords, *, seed_all: bool) -> list[list[int]]: + """Coordinate group-fill membership, parameterized by the seed rule. + + ``seed_all=False`` seeds only 1-detector boundary edges (the shipping + coordinate path / lomatching). ``seed_all=True`` seeds from every detector of + any O-flipping mechanism (the back-propagation / detecting-region crossings), + then group-fills the same way -- a strictly broader region. + """ + from collections import defaultdict + + det_coords: dict[int, tuple[float, ...]] = {} + mechs: list[tuple[list[int], list[int]]] = [] + for raw in dem_str.splitlines(): + ln = raw.strip() + if ln.startswith("detector("): + coords = tuple(float(x) for x in ln[ln.index("(") + 1 : ln.index(")")].split(",")) + for t in ln[ln.index(")") + 1 :].split(): + if t.startswith("D"): + det_coords[int(t[1:])] = coords + elif ln.startswith("error("): + toks = ln[ln.index(")") + 1 :].split() + mechs.append( + ([int(t[1:]) for t in toks if t.startswith("D")], + [int(t[1:]) for t in toks if t.startswith("L")]), + ) + + coords_to_stab: dict[tuple, tuple[int, str]] = {} + for li, q in enumerate(stab_coords): + for st in ("X", "Z"): + for c in q[st]: + coords_to_stab[tuple(map(float, c))] = (li, st) + + det_group: dict[int, tuple] = {} + group_dets: dict[tuple, list[int]] = defaultdict(list) + for d, c in det_coords.items(): + spatial, time = c[:-1], c[-1] + if spatial in coords_to_stab: + li, st = coords_to_stab[spatial] + det_group[d] = (li, st, time) + group_dets[(li, st, time)].append(d) + + nobs = 1 + max((o for _, obs in mechs for o in obs), default=-1) + seeds: list[set] = [set() for _ in range(nobs)] + for dets, obs in mechs: + if not obs: + continue + seed_dets = dets if (seed_all or len(dets) == 1) else [] + for o in obs: + for d in seed_dets: + if d in det_group: + seeds[o].add(det_group[d]) + return [sorted({d for g in seeds[o] for d in group_dets[g]}) for o in range(nobs)] + + def _cx_circuit(): patch = SurfacePatch.create(distance=3) nq = patch.geometry.num_data + patch.geometry.num_ancilla @@ -117,5 +171,46 @@ def test_coordinate_region_beats_raw_backprop_region(): ) +def test_coordinate_seeding_reproduces_shipping_path(): + """The group-fill helper with boundary-edge seeding reproduces the shipping + coordinate membership exactly (validates the helper used for the broader + back-prop comparison).""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + coord = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + helper = _groupfill_membership(dem, sc, seed_all=False) + assert helper == [sorted(r) for r in coord.observing_regions()] + + +def test_coordinate_beats_backprop_seeded_groupfill(): + """The faithful 'next step': seed the same group-fill from the operator's + back-propagation crossings (all O-flipping mechanism detectors) instead of + boundary edges. It is strictly broader than the coordinate region and + decodes worse -- confirming the boundary-edge seeding IS the right (faithful) + back-propagation region, and broadening hurts.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + sc = b.stab_coords() + + coord_membership = _groupfill_membership(dem, sc, seed_all=False) + backprop_membership = _groupfill_membership(dem, sc, seed_all=True) + + # Coordinate region is a strict subset of the back-prop-seeded region. + for c, bp in zip(coord_membership, backprop_membership, strict=True): + assert set(c) <= set(bp) + assert sum(len(bp) for bp in backprop_membership) > sum(len(c) for c in coord_membership) + + n = 20000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=13) + coord = LogicalSubgraphDecoder.from_membership(dem, coord_membership, "pecos_uf:fast") + backprop = LogicalSubgraphDecoder.from_membership(dem, backprop_membership, "pecos_uf:fast") + coord_ler = coord.decode_count(batch) / n + backprop_ler = backprop.decode_count(batch) / n + assert coord_ler < backprop_ler, ( + f"coord={coord_ler:.5f} backprop-seeds={backprop_ler:.5f}" + ) + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) From 8835879ef1a6ba17d311f5c4c7512971eec5fb72 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 12:15:38 -0600 Subject: [PATCH 021/388] Add branch-impact DEM approximation --- .../fault_tolerance/dem_builder/builder.rs | 246 ++++++++++++------ .../dem_builder/dem_sampler.rs | 83 +++++- .../fault_tolerance/dem_builder/sampler.rs | 9 +- .../src/fault_tolerance/dem_builder/types.rs | 89 +++++-- .../src/fault_tolerance_bindings.rs | 5 +- python/quantum-pecos/src/pecos/qec/dem.py | 6 +- .../src/pecos/qec/surface/decode.py | 6 +- 7 files changed, 321 insertions(+), 123 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 1b9ae02e4..6f94ccfe7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -17,7 +17,8 @@ use super::types::{ DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, FaultMechanism, - NoiseConfig, PerGateTypeNoise, SourceMetadata, record_offset_to_absolute_index, + NoiseConfig, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, + record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; @@ -378,12 +379,19 @@ impl<'a> DemBuilder<'a> { let flat = idx + 1; let p1 = flat / 4; let p2 = flat % 4; - self.noise.p2 - * weights.two_qubit_weight_for( + let pauli = pauli_pair_for_weight(p1, p2); + let weight = if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::BranchImpact + { + weights.post_gate_two_qubit_weight_for(&pauli) + } else { + weights.two_qubit_weight_for( loc1.gate_type, - &pauli_pair_for_weight(p1, p2), + &pauli, self.noise.p2_replacement_approximation, ) + }; + self.noise.p2 * weight }); } [per_channel_probability(self.noise.p2, 15); 15] @@ -841,6 +849,17 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::BranchImpact + { + self.process_two_qubit_replacement_branch_impacts_source_tracked( + pair[0], + pair[1], + dem, + meas_to_detectors, + meas_to_observables, + ); + } } } } @@ -867,6 +886,39 @@ impl<'a> DemBuilder<'a> { } } + /// Processes starred two-qubit replacement branches as explicit branch impacts. + fn process_two_qubit_replacement_branch_impacts_source_tracked( + &self, + loc1: usize, + loc2: usize, + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let Some(weights) = &self.noise.p2_weights else { + return; + }; + let loc1_meta = &self.influence_map.locations[loc1]; + let branch_weights = weights.replacement_branch_impact_weights(loc1_meta.gate_type); + if branch_weights.is_empty() { + return; + } + + let effects = + self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); + let loc2_meta = &self.influence_map.locations[loc2]; + + for (label, relative_weight) in branch_weights { + let Some((p1, p2)) = two_qubit_label_to_pauli_indices(&label) else { + continue; + }; + let prob = self.noise.p2 * relative_weight; + self.add_two_qubit_pauli_contribution( + loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, + ); + } + } + /// Processes a measurement fault with source tracking. fn process_meas_fault_source_tracked( &self, @@ -980,19 +1032,47 @@ impl<'a> DemBuilder<'a> { let loc1_meta = &self.influence_map.locations[loc1]; let loc2_meta = &self.influence_map.locations[loc2]; - // Compute base effects for X and Z on each qubit + let effects = + self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); + + // Process all 15 non-trivial Pauli combinations + for p1 in 0u8..4 { + for p2 in 0u8..4 { + if p1 == 0 && p2 == 0 { + continue; // Skip II + } + + // Per-pair rate: index = 4*p1 + p2 - 1 (skipping II at idx 0). + let flat = 4 * (p1 as usize) + (p2 as usize); + let prob = rates[flat - 1]; + if prob == 0.0 { + continue; + } + self.add_two_qubit_pauli_contribution( + loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, + ); + } + } + } + + fn two_qubit_effect_table( + &self, + loc1: usize, + loc2: usize, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) -> [[FaultMechanism; 4]; 4] { let x1 = self.compute_mechanism(loc1, Pauli::X, meas_to_detectors, meas_to_observables); let z1 = self.compute_mechanism(loc1, Pauli::Z, meas_to_detectors, meas_to_observables); let x2 = self.compute_mechanism(loc2, Pauli::X, meas_to_detectors, meas_to_observables); let z2 = self.compute_mechanism(loc2, Pauli::Z, meas_to_detectors, meas_to_observables); - // Build effect table for all 16 Pauli combinations let get_single_effect = |p: u8, x: &FaultMechanism, z: &FaultMechanism| -> FaultMechanism { match p { - 0 => FaultMechanism::new(), // I - 1 => x.clone(), // X - 2 => x.xor(z), // Y = X XOR Z - 3 => z.clone(), // Z + 0 => FaultMechanism::new(), + 1 => x.clone(), + 2 => x.xor(z), + 3 => z.clone(), _ => unreachable!("Pauli index must be 0-3"), } }; @@ -1005,81 +1085,66 @@ impl<'a> DemBuilder<'a> { effects[p1 as usize][p2 as usize] = e1.xor(&e2); } } + effects + } - // Process all 15 non-trivial Pauli combinations - for p1 in 0u8..4 { - for p2 in 0u8..4 { - if p1 == 0 && p2 == 0 { - continue; // Skip II - } - - let effect = &effects[p1 as usize][p2 as usize]; - if effect.is_empty() { - continue; - } - - // Per-pair rate: index = 4*p1 + p2 - 1 (skipping II at idx 0). - let flat = 4 * (p1 as usize) + (p2 as usize); - let prob = rates[flat - 1]; - if prob == 0.0 { - continue; - } + #[allow(clippy::too_many_arguments)] + fn add_two_qubit_pauli_contribution( + &self, + loc1: usize, + loc2: usize, + p1: u8, + p2: u8, + prob: f64, + effects: &[[FaultMechanism; 4]; 4], + loc1_meta: &DagSpacetimeLocation, + loc2_meta: &DagSpacetimeLocation, + dem: &mut DetectorErrorModel, + ) { + let effect = &effects[p1 as usize][p2 as usize]; + if effect.is_empty() { + return; + } - // Get component effects (P1I and IP2) - let e1 = &effects[p1 as usize][0]; // P1 on qubit 1, I on qubit 2 - let e2 = &effects[0][p2 as usize]; // I on qubit 1, P2 on qubit 2 - - // Check if this is a "graphlike decomposable" source: - // - Combined effect has exactly 2 detectors and no dem_outputs - // - Both component effects are non-empty - // - Both component effects are graphlike (≤2 detectors) - let graphlike_decomposable = effect.num_detectors() == 2 - && effect.dem_outputs.is_empty() - && !e1.is_empty() - && !e2.is_empty() - && e1.num_detectors() <= 2 - && e2.num_detectors() <= 2; - if graphlike_decomposable { - dem.mark_graphlike_decomposable(effect.detectors[0], effect.detectors[1]); - } + let e1 = &effects[p1 as usize][0]; + let e2 = &effects[0][p2 as usize]; + + let graphlike_decomposable = effect.num_detectors() == 2 + && effect.dem_outputs.is_empty() + && !e1.is_empty() + && !e2.is_empty() + && e1.num_detectors() <= 2 + && e2.num_detectors() <= 2; + if graphlike_decomposable { + dem.mark_graphlike_decomposable(effect.detectors[0], effect.detectors[1]); + } - // Check for intra-channel decomposition (Y-containing cases) - if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { - // Y-containing channels can be decomposable if both their X and Z - // components have non-empty, distinct effects. Otherwise they - // produce the effect directly without decomposition. - let e_a = &effects[a1 as usize][a2 as usize]; - let e_b = &effects[b1 as usize][b2 as usize]; - - // Only truly decomposable if both components are non-empty and different. - // add_y_decomposed_contribution handles routing to Direct when appropriate. - dem.add_y_decomposed_contribution_with_source( - e_a, - e_b, - prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), - ); - } else { - // Non-Y channel (XI, IX, ZI, IZ, XX, XZ, ZX, ZZ) - // These are always direct sources. - dem.add_direct_contribution_with_source_components( - effect.clone(), - prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), - DirectSourceComponents::new(e1, e2), - ); - } - } + if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { + let e_a = &effects[a1 as usize][a2 as usize]; + let e_b = &effects[b1 as usize][b2 as usize]; + dem.add_y_decomposed_contribution_with_source( + e_a, + e_b, + prob, + SourceMetadata::new( + &[loc1, loc2], + &[Pauli::from_u8(p1), Pauli::from_u8(p2)], + &[loc1_meta.gate_type, loc2_meta.gate_type], + &[loc1_meta.before, loc2_meta.before], + ), + ); + } else { + dem.add_direct_contribution_with_source_components( + effect.clone(), + prob, + SourceMetadata::new( + &[loc1, loc2], + &[Pauli::from_u8(p1), Pauli::from_u8(p2)], + &[loc1_meta.gate_type, loc2_meta.gate_type], + &[loc1_meta.before, loc2_meta.before], + ), + DirectSourceComponents::new(e1, e2), + ); } } @@ -1304,6 +1369,23 @@ fn pauli_pair_for_weight(p1: usize, p2: usize) -> pecos_core::PauliString { pecos_core::PauliString::with_phase_and_paulis(pecos_core::QuarterPhase::PlusOne, paulis) } +fn two_qubit_label_to_pauli_indices(label: &str) -> Option<(u8, u8)> { + let mut chars = label.chars(); + let p1 = pauli_label_to_index(chars.next()?)?; + let p2 = pauli_label_to_index(chars.next()?)?; + chars.next().is_none().then_some((p1, p2)) +} + +fn pauli_label_to_index(label: char) -> Option { + match label { + 'I' => Some(0), + 'X' => Some(1), + 'Y' => Some(2), + 'Z' => Some(3), + _ => None, + } +} + /// Computes the per-error probability for independent error channels. /// /// For a depolarizing channel with total error probability `p` split among `n` diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index 4b11d4559..ef72c41c9 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -71,7 +71,10 @@ use smallvec::SmallVec; use std::collections::{BTreeMap, BTreeSet}; use wide::u64x4; -use super::types::{NoiseConfig, PerGateTypeNoise, combine_probabilities}; +use super::types::{ + NoiseConfig, PauliWeights, PerGateTypeNoise, ReplacementBranchApproximation, + combine_probabilities, +}; // ============================================================================ // DEM Mechanism (used during building) @@ -461,7 +464,18 @@ impl SamplingEngine { // Custom per-Pauli weights: p * weight_for(pauli) events .iter() - .map(|event| p * weights.weight_for(&event.pauli)) + .map(|event| { + let weight = if n_qubits == 2 { + weights.two_qubit_weight_for( + loc.gate_type, + &event.pauli, + noise.p2_replacement_approximation, + ) + } else { + weights.weight_for(&event.pauli) + }; + p * weight + }) .collect() } else { // Default uniform: p / num_events @@ -1836,6 +1850,9 @@ pub(crate) struct SamplingEngineBuilder<'a> { p2: f64, p_meas: f64, p_prep: f64, + p1_weights: Option, + p2_weights: Option, + p2_replacement_approximation: ReplacementBranchApproximation, idle_noise: Option, detector_records: Vec>, observable_records: Vec>, @@ -1859,6 +1876,9 @@ impl<'a> SamplingEngineBuilder<'a> { p2: 0.01, p_meas: 0.01, p_prep: 0.01, + p1_weights: None, + p2_weights: None, + p2_replacement_approximation: ReplacementBranchApproximation::default(), idle_noise: None, per_gate: None, detector_records: Vec::new(), @@ -1875,6 +1895,24 @@ impl<'a> SamplingEngineBuilder<'a> { self.p2 = p2; self.p_meas = p_meas; self.p_prep = p_prep; + self.p1_weights = None; + self.p2_weights = None; + self.p2_replacement_approximation = ReplacementBranchApproximation::default(); + self.idle_noise = None; + self + } + + /// Set the full noise model, including biased Pauli weights. + #[must_use] + pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { + self.p1 = noise.p1; + self.p2 = noise.p2; + self.p_meas = noise.p_meas; + self.p_prep = noise.p_prep; + self.p1_weights = noise.p1_weights.clone(); + self.p2_weights = noise.p2_weights.clone(); + self.p2_replacement_approximation = noise.p2_replacement_approximation; + self.idle_noise = noise.uses_dedicated_idle_noise().then_some(noise); self } @@ -2277,6 +2315,14 @@ impl<'a> SamplingEngineBuilder<'a> { ] } } else { + if let Some(weights) = &self.p1_weights { + use pecos_core::pauli::{X, Y, Z}; + return [ + self.p1 * weights.weight_for(&X(0)), + self.p1 * weights.weight_for(&Y(0)), + self.p1 * weights.weight_for(&Z(0)), + ]; + } [self.p1 / 3.0; 3] } } @@ -2324,6 +2370,19 @@ impl<'a> SamplingEngineBuilder<'a> { std::array::from_fn(|i| pg.rate_2q(gate, i)) } } else { + if let Some(weights) = &self.p2_weights { + return std::array::from_fn(|idx| { + let flat = idx + 1; + let p1 = flat / 4; + let p2 = flat % 4; + self.p2 + * weights.two_qubit_weight_for( + gate, + &pauli_pair_for_weight(p1, p2), + self.p2_replacement_approximation, + ) + }); + } [self.p2 / 15.0; 15] } } @@ -2532,6 +2591,26 @@ where } } +fn pauli_pair_for_weight(p1: usize, p2: usize) -> pecos_core::PauliString { + let mut paulis = Vec::new(); + let pauli_from_index = |idx| match idx { + 0 => pecos_core::Pauli::I, + 1 => pecos_core::Pauli::X, + 2 => pecos_core::Pauli::Y, + 3 => pecos_core::Pauli::Z, + _ => unreachable!("Pauli index must be 0-3"), + }; + let pa1 = pauli_from_index(p1); + let pa2 = pauli_from_index(p2); + if pa1 != pecos_core::Pauli::I { + paulis.push((pa1, pecos_core::QubitId::from(0usize))); + } + if pa2 != pecos_core::Pauli::I { + paulis.push((pa2, pecos_core::QubitId::from(1usize))); + } + pecos_core::PauliString::with_phase_and_paulis(pecos_core::QuarterPhase::PlusOne, paulis) +} + /// XORs two [`DemMechanism`]s (symmetric difference of detectors and standard observables). fn xor_mechanisms(a: Option<&DemMechanism>, b: Option<&DemMechanism>) -> DemMechanism { match (a, b) { diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index f0e1516b7..6bd954490 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1351,19 +1351,12 @@ impl<'a> DemSamplerBuilder<'a> { } let mut builder = SamplingEngineBuilder::new(self.influence_map) - .with_noise( - self.noise.p1, - self.noise.p2, - self.noise.p_meas, - self.noise.p_prep, - ) + .with_noise_config(self.noise.clone()) .with_detector_records(detector_records) .with_observable_records(observable_records.clone()); if let Some(per_gate) = self.per_gate { builder = builder.with_per_gate_noise(per_gate); - } else if self.noise.uses_dedicated_idle_noise() { - builder = builder.with_idle_noise_config(self.noise.clone()); } if let Some(order) = self.measurement_order { diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 664e381d9..c9ed0a8a0 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1427,6 +1427,10 @@ pub enum ReplacementBranchApproximation { /// gate's dagger. This is the default approximation for starred entries. #[default] PauliTwirlOmittedGate, + /// Evaluate replacement branches as their own fault branches after + /// convolving with the omitted gate's Pauli twirl, instead of folding them + /// into the ordinary post-gate two-qubit rate vector. + BranchImpact, } impl PauliWeights { @@ -1542,13 +1546,7 @@ impl PauliWeights { let Ok(query_label) = two_qubit_pauli_label(pauli) else { return 0.0; }; - let direct = self - .entries - .iter() - .filter_map(|(ps, weight)| { - (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) - }) - .sum::(); + let direct = self.post_gate_two_qubit_weight_for(pauli); if approximation == ReplacementBranchApproximation::IgnoreGateRemoval { return direct @@ -1561,27 +1559,53 @@ impl PauliWeights { .sum::(); } - let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { - return direct; - }; direct + self - .replacement_entries - .iter() - .filter_map(|(ps, weight)| { - let replacement_label = two_qubit_pauli_label(ps).ok()?; - Some( - twirl - .iter() - .filter_map(|(twirl_label, twirl_weight)| { - (multiply_two_qubit_pauli_labels(&replacement_label, twirl_label) - == query_label) - .then_some(weight * twirl_weight) - }) - .sum::(), - ) - }) - .sum::() + .replacement_branch_impact_weights(gate_type) + .get(query_label.as_str()) + .copied() + .unwrap_or(0.0) + } + + /// Look up only the plain post-gate two-qubit Pauli weight. + #[must_use] + pub fn post_gate_two_qubit_weight_for(&self, pauli: &pecos_core::PauliString) -> f64 { + let Ok(query_label) = two_qubit_pauli_label(pauli) else { + return 0.0; + }; + self.entries + .iter() + .filter_map(|(ps, weight)| { + (two_qubit_pauli_label(ps).ok()?.as_str() == query_label).then_some(*weight) + }) + .sum::() + } + + /// Effective non-identity branch-impact weights from replacement entries. + /// + /// Each starred replacement entry is convolved with the Pauli twirl of the + /// omitted ideal gate. Identity effects are omitted because DEM builders + /// only emit branches that flip detectors or logical observables. + #[must_use] + pub fn replacement_branch_impact_weights(&self, gate_type: GateType) -> BTreeMap { + let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { + return BTreeMap::new(); + }; + let mut weights = BTreeMap::new(); + for (ps, replacement_weight) in &self.replacement_entries { + let Ok(replacement_label) = two_qubit_pauli_label(ps) else { + continue; + }; + for (twirl_label, twirl_weight) in &twirl { + let effective_label = + multiply_two_qubit_pauli_labels(&replacement_label, twirl_label); + if effective_label == "II" { + continue; + } + *weights.entry(effective_label).or_insert(0.0) += replacement_weight * twirl_weight; + } + } + weights } /// Get all entries as `(PauliString, weight)` pairs. @@ -5736,6 +5760,15 @@ mod tests { .abs() < 1e-12 ); + assert!( + (weights.two_qubit_weight_for( + GateType::SZZ, + &(Y(0) & Y(1)), + ReplacementBranchApproximation::BranchImpact, + ) - 0.75 * 0.5) + .abs() + < 1e-12 + ); assert!( (weights.two_qubit_weight_for( GateType::SZZ, @@ -5765,6 +5798,10 @@ mod tests { .abs() < 1e-12 ); + assert_eq!( + replacement_omits_only.replacement_branch_impact_weights(GateType::SZZ), + BTreeMap::from([("ZZ".to_string(), 0.5)]) + ); let cx_replacement_identity = PauliWeights::with_replacement([], [(Z(0) & X(1), 1.0)]); assert!( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 1c8f80aad..9c8555ec1 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -154,11 +154,14 @@ fn parse_replacement_approximation( "pauli_twirl_omitted_gate" | "pauli_twirl" | "twirl" => { Ok(ReplacementBranchApproximation::PauliTwirlOmittedGate) } + "branch_impact" | "replacement_branch_impact" | "impact" => { + Ok(ReplacementBranchApproximation::BranchImpact) + } "ignore_gate_removal" | "ignore_removal" | "post_gate" | "postgate" => { Ok(ReplacementBranchApproximation::IgnoreGateRemoval) } _ => Err(pyo3::exceptions::PyValueError::new_err( - "p2_replacement_approximation must be 'pauli_twirl_omitted_gate' or 'ignore_gate_removal'", + "p2_replacement_approximation must be 'pauli_twirl_omitted_gate', 'branch_impact', or 'ignore_gate_removal'", )), } } diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 12087cf76..251ea8109 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -152,8 +152,10 @@ def from_guppy( remains the total two-qubit error rate. p2_replacement_approximation: Approximation used for starred replacement labels. ``"pauli_twirl_omitted_gate"`` convolves - with the omitted two-qubit gate's Pauli twirl; ``"ignore_gate_removal"`` - treats starred entries like plain post-gate Pauli entries. + with the omitted two-qubit gate's Pauli twirl; + ``"branch_impact"`` evaluates starred entries as replacement + branch impacts; ``"ignore_gate_removal"`` treats starred + entries like plain post-gate Pauli entries. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. p_idle: Optional uniform depolarizing idle-noise rate per idle duration. diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 5d14d8933..4a411242d 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -99,8 +99,10 @@ class NoiseModel: error rate. p2_replacement_approximation: Approximation used for starred replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with - the omitted two-qubit gate's Pauli twirl; ``"ignore_gate_removal"`` - treats starred entries like plain post-gate Pauli entries. + the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` + evaluates starred entries as replacement branch impacts; + ``"ignore_gate_removal"`` treats starred entries like plain + post-gate Pauli entries. p_meas: Measurement error rate. p_prep: Initialization error rate. p_idle: Idle noise rate per time unit (uniform depolarizing). From 1c286156a426e75136cff77e418c6b28720085cc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 12:24:24 -0600 Subject: [PATCH 022/388] Default LogicalSubgraphDecoder inner to pecos_uf:bp (exact-MWPM LER, native, ~13-26% lower than pecos_uf:fast) --- .../src/fault_tolerance_bindings.rs | 9 ++++-- ...test_logical_subgraph_region_comparison.py | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c52592a2e..39d877bcc 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4435,8 +4435,13 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { + // Default inner is `pecos_uf:bp` (native union-find + belief propagation): it + // reaches exact-MWPM LER -- ~13-26% lower than `pecos_uf:fast`, the gap + // growing with code distance -- at ~3x the speed of pymatching, with no + // external decoder dependency. See + // pecos-docs/design/logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:fast", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:bp", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, @@ -4487,7 +4492,7 @@ impl PyLogicalSubgraphDecoder { /// construction (e.g. the paper's back-propagation / detecting-region set) /// and decode with the same machinery for direct comparison. #[staticmethod] - #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:fast"))] + #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:bp"))] fn from_membership( dem: &str, membership: Vec>, diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index c10468e87..e2a7edab2 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -212,5 +212,34 @@ def test_coordinate_beats_backprop_seeded_groupfill(): ) +def test_bp_inner_is_lower_ler_default(): + """The default inner decoder is the native UF+BP (`pecos_uf:bp`), which + reaches exact-MWPM accuracy and decodes with lower LER than the older + `pecos_uf:fast` union-find. The gap grows with distance; assert it at d=3.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.002, p2=0.002, p_meas=0.002) + sc = b.stab_coords() + + n = 40000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=4) + + default = LogicalSubgraphDecoder(dem, sc) # no inner -> the new default + bp = LogicalSubgraphDecoder(dem, sc, "pecos_uf:bp") + fast = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") + exact = LogicalSubgraphDecoder(dem, sc, "pymatching") + + default_ler = default.decode_count(batch) / n + bp_ler = bp.decode_count(batch) / n + fast_ler = fast.decode_count(batch) / n + exact_ler = exact.decode_count(batch) / n + + # The default IS pecos_uf:bp. + assert default_ler == bp_ler + # BP inner beats the old fast default... + assert bp_ler < fast_ler, f"bp={bp_ler:.5f} fast={fast_ler:.5f}" + # ...and matches exact MWPM (native UF+BP reaches the optimum on graphlike subgraphs). + assert abs(bp_ler - exact_ler) <= 0.0005, f"bp={bp_ler:.5f} exact={exact_ler:.5f}" + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) From 51acac95c4a54ee690b3309b5e66b392d555c7ed Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 12:49:00 -0600 Subject: [PATCH 023/388] Reserve exact replacement branch replay --- .../src/fault_tolerance/dem_builder.rs | 4 +- .../fault_tolerance/dem_builder/builder.rs | 117 +++++++-- .../dem_builder/dem_sampler.rs | 10 + .../src/fault_tolerance/dem_builder/types.rs | 231 ++++++++++++++---- .../src/fault_tolerance_bindings.rs | 9 +- python/quantum-pecos/src/pecos/qec/dem.py | 4 +- .../src/pecos/qec/surface/decode.py | 3 + 7 files changed, 307 insertions(+), 71 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 82abce66a..f96ef6633 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -101,6 +101,6 @@ pub use types::{ DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, - TwoDetectorDirectRenderPolicy, combine_probabilities, omitted_two_qubit_gate_pauli_twirl, - record_offset_to_absolute_index, + ReplacementBranchImpact, TwoDetectorDirectRenderPolicy, combine_probabilities, + omitted_two_qubit_gate_pauli_twirl, record_offset_to_absolute_index, }; diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 6f94ccfe7..575fe7fde 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -16,8 +16,8 @@ //! influence maps and detector/DEM-output metadata. use super::types::{ - DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, FaultMechanism, - NoiseConfig, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, + DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, DirectSourceFamily, + FaultMechanism, NoiseConfig, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; @@ -641,6 +641,7 @@ impl<'a> DemBuilder<'a> { pub fn try_build(&self) -> Result { self.validate_measurement_count()?; self.validate_metadata_refs()?; + self.validate_replacement_branch_approximation()?; Ok(self.build()) } @@ -655,6 +656,8 @@ impl<'a> DemBuilder<'a> { /// circuit-derived metadata must use [`Self::try_build`] instead. #[must_use] pub fn build(&self) -> DetectorErrorModel { + self.validate_replacement_branch_approximation() + .expect("invalid DEM replacement branch approximation"); let num_influence_dem_outputs = self .num_influence_dem_outputs() .max(self.influence_map.dem_output_metadata.len()); @@ -721,6 +724,24 @@ impl<'a> DemBuilder<'a> { dem } + fn validate_replacement_branch_approximation(&self) -> Result<(), DemBuilderError> { + let has_replacement_branches = self + .noise + .p2_weights + .as_ref() + .is_some_and(|weights| weights.has_replacement_entries()); + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay + && has_replacement_branches + { + return Err(DemBuilderError::ConfigurationError( + "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" + .to_string(), + )); + } + Ok(()) + } + fn num_influence_dem_outputs(&self) -> usize { self.influence_map .influences @@ -899,8 +920,8 @@ impl<'a> DemBuilder<'a> { return; }; let loc1_meta = &self.influence_map.locations[loc1]; - let branch_weights = weights.replacement_branch_impact_weights(loc1_meta.gate_type); - if branch_weights.is_empty() { + let branch_impacts = weights.replacement_branch_impacts(loc1_meta.gate_type); + if branch_impacts.is_empty() { return; } @@ -908,13 +929,22 @@ impl<'a> DemBuilder<'a> { self.two_qubit_effect_table(loc1, loc2, meas_to_detectors, meas_to_observables); let loc2_meta = &self.influence_map.locations[loc2]; - for (label, relative_weight) in branch_weights { - let Some((p1, p2)) = two_qubit_label_to_pauli_indices(&label) else { + for impact in branch_impacts { + let Some((p1, p2)) = two_qubit_label_to_pauli_indices(&impact.pauli_label) else { continue; }; - let prob = self.noise.p2 * relative_weight; + let prob = self.noise.p2 * impact.relative_probability; self.add_two_qubit_pauli_contribution( - loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, + loc1, + loc2, + p1, + p2, + prob, + &effects, + loc1_meta, + loc2_meta, + dem, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact), ); } } @@ -1049,7 +1079,7 @@ impl<'a> DemBuilder<'a> { continue; } self.add_two_qubit_pauli_contribution( - loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, + loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, None, ); } } @@ -1100,6 +1130,7 @@ impl<'a> DemBuilder<'a> { loc1_meta: &DagSpacetimeLocation, loc2_meta: &DagSpacetimeLocation, dem: &mut DetectorErrorModel, + direct_source_family: Option, ) { let effect = &effects[p1 as usize][p2 as usize]; if effect.is_empty() { @@ -1119,30 +1150,40 @@ impl<'a> DemBuilder<'a> { dem.mark_graphlike_decomposable(effect.detectors[0], effect.detectors[1]); } + let source_locations = [loc1, loc2]; + let source_paulis = [Pauli::from_u8(p1), Pauli::from_u8(p2)]; + let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; + let source_before_flags = [loc1_meta.before, loc2_meta.before]; + if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { let e_a = &effects[a1 as usize][a2 as usize]; let e_b = &effects[b1 as usize][b2 as usize]; - dem.add_y_decomposed_contribution_with_source( - e_a, - e_b, - prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), + let mut source = SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, ); + if direct_source_family.is_some() { + source = source.with_replacement_branch(); + } + dem.add_y_decomposed_contribution_with_source(e_a, e_b, prob, source); } else { + let mut source = SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ); + if let Some(family) = direct_source_family { + source = source + .with_direct_source_family(family) + .with_replacement_branch(); + } dem.add_direct_contribution_with_source_components( effect.clone(), prob, - SourceMetadata::new( - &[loc1, loc2], - &[Pauli::from_u8(p1), Pauli::from_u8(p2)], - &[loc1_meta.gate_type, loc2_meta.gate_type], - &[loc1_meta.before, loc2_meta.before], - ), + source, DirectSourceComponents::new(e1, e2), ); } @@ -2129,12 +2170,15 @@ pub fn resolve_result_tags( pub enum DemBuilderError { /// JSON parsing error. ParseError(String), + /// Invalid DEM builder configuration. + ConfigurationError(String), } impl std::fmt::Display for DemBuilderError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::ParseError(msg) => write!(f, "DEM builder parse error: {msg}"), + Self::ConfigurationError(msg) => write!(f, "DEM builder configuration error: {msg}"), } } } @@ -2751,6 +2795,29 @@ mod tests { ); } + #[test] + fn test_try_build_rejects_exact_branch_replay_without_provider() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::pauli::Z; + + let influence_map = DagFaultInfluenceMap::with_capacity(0); + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement([], [(Z(0) & Z(1), 1.0)])) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let err = DemBuilder::new(&influence_map) + .with_noise_config(noise) + .try_build() + .expect_err("exact branch replay must fail loud without an exact provider"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("circuit-aware exact branch provider"), + "unexpected error: {err}", + ); + } + #[test] fn test_parse_accepts_dem_label_id_form() { let det = parse_detectors_json(r#"[{"id": "D0", "records": [-1]}]"#).unwrap(); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index ef72c41c9..4fe4b1de7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -2000,6 +2000,16 @@ impl<'a> SamplingEngineBuilder<'a> { /// Build the [`SamplingEngine`]. #[must_use] pub fn build(self) -> SamplingEngine { + if self.p2_replacement_approximation == ReplacementBranchApproximation::ExactBranchReplay + && self + .p2_weights + .as_ref() + .is_some_and(|weights| weights.has_replacement_entries()) + { + panic!( + "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" + ); + } let num_detectors = self.detector_records.len(); let influence_observable_ids = self.influence_map.observable_ids(); let num_influence_observables = self.influence_map.num_observables(); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index c9ed0a8a0..2aea930f8 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -126,6 +126,9 @@ pub enum DirectSourceFamily { /// Two-location direct source where exactly one component is non-empty. TwoLocationOneSidedComponent, + /// Two-location direct source produced by a replacement-branch projection. + TwoLocationReplacementBranchImpact, + /// Fallback for other direct-source shapes. Other, } @@ -168,6 +171,10 @@ pub struct FaultContribution { /// currently recorded for direct two-qubit channel sources to aid decomposition /// analysis without changing emitted DEM behavior. pub direct_component_effects: Option<(FaultMechanism, FaultMechanism)>, + + /// True when this contribution came from a replacement branch rather than + /// ordinary post-gate Pauli noise. + pub replacement_branch: bool, } #[derive(Debug, Clone, Copy)] @@ -176,6 +183,8 @@ pub(crate) struct SourceMetadata<'a, Index> { paulis: &'a [Pauli], gate_types: &'a [GateType], before_flags: &'a [bool], + direct_source_family_override: Option, + replacement_branch: bool, } impl<'a, Index> SourceMetadata<'a, Index> { @@ -190,8 +199,20 @@ impl<'a, Index> SourceMetadata<'a, Index> { paulis, gate_types, before_flags, + direct_source_family_override: None, + replacement_branch: false, } } + + pub(crate) const fn with_direct_source_family(mut self, family: DirectSourceFamily) -> Self { + self.direct_source_family_override = Some(family); + self + } + + pub(crate) const fn with_replacement_branch(mut self) -> Self { + self.replacement_branch = true; + self + } } #[derive(Debug, Clone, Copy)] @@ -254,6 +275,7 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + replacement_branch: false, } } @@ -275,12 +297,11 @@ impl FaultContribution { paulis: source.paulis.iter().copied().collect(), source_gate_types: source.gate_types.iter().copied().collect(), source_before_flags: source.before_flags.iter().copied().collect(), - direct_source_family: Self::classify_direct_source_family( - source.location_indices, - source.paulis, - None, - ), + direct_source_family: source.direct_source_family_override.or_else(|| { + Self::classify_direct_source_family(source.location_indices, source.paulis, None) + }), direct_component_effects: None, + replacement_branch: source.replacement_branch, } } @@ -311,12 +332,15 @@ impl FaultContribution { paulis: source.paulis.iter().copied().collect(), source_gate_types: source.gate_types.iter().copied().collect(), source_before_flags: source.before_flags.iter().copied().collect(), - direct_source_family: Self::classify_direct_source_family( - source.location_indices, - source.paulis, - Some((components.first, components.second)), - ), + direct_source_family: source.direct_source_family_override.or_else(|| { + Self::classify_direct_source_family( + source.location_indices, + source.paulis, + Some((components.first, components.second)), + ) + }), direct_component_effects: Some((components.first.clone(), components.second.clone())), + replacement_branch: source.replacement_branch, } } @@ -346,6 +370,7 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + replacement_branch: false, } } @@ -376,6 +401,7 @@ impl FaultContribution { source_before_flags: source.before_flags.iter().copied().collect(), direct_source_family: None, direct_component_effects: None, + replacement_branch: source.replacement_branch, } } @@ -1164,6 +1190,21 @@ fn convert_location_indices(location_indices: &[usize]) -> SmallVec<[u32; 2]> { .collect() } +fn converted_source_metadata<'a>( + source: SourceMetadata<'a, usize>, + location_indices: &'a [u32], +) -> SourceMetadata<'a, u32> { + let mut converted = SourceMetadata::new( + location_indices, + source.paulis, + source.gate_types, + source.before_flags, + ); + converted.direct_source_family_override = source.direct_source_family_override; + converted.replacement_branch = source.replacement_branch; + converted +} + /// Converts a DEM measurement-record offset to an absolute measurement index. /// /// Negative offsets count backward from the end of the measurement record @@ -1427,10 +1468,42 @@ pub enum ReplacementBranchApproximation { /// gate's dagger. This is the default approximation for starred entries. #[default] PauliTwirlOmittedGate, - /// Evaluate replacement branches as their own fault branches after - /// convolving with the omitted gate's Pauli twirl, instead of folding them - /// into the ordinary post-gate two-qubit rate vector. + /// Evaluate Pauli-projected replacement branches as their own contribution + /// streams after convolving with the omitted gate's Pauli twirl, instead of + /// folding them into the ordinary post-gate two-qubit rate vector. + /// + /// This preserves replacement-branch provenance in contribution metadata, + /// but it is still a Pauli projection rather than exact non-Pauli branch + /// replay. BranchImpact, + /// Request exact replay of replacement branches against the circuit. + /// + /// This mode is intentionally fail-loud until a circuit-aware replay + /// provider is attached. A replacement branch can be a non-Pauli Clifford + /// effect in the ideal-circuit frame, which standard DEM rows cannot always + /// represent as one deterministic Pauli-like event. + ExactBranchReplay, +} + +/// A single Pauli-projected impact term produced by a replacement branch. +/// +/// This is intentionally an intermediate representation rather than a final +/// DEM contribution. It preserves the expanded branch term until the builder +/// can evaluate the term against detector/observable metadata. Today the +/// projection comes from the Pauli twirl of the omitted two-qubit Clifford +/// gate. A future exact replay implementation should attach the same +/// replacement-branch contribution metadata, but may need to bypass this +/// Pauli-projected shape when the branch is not representable as a Pauli fault. +#[derive(Debug, Clone, PartialEq)] +pub struct ReplacementBranchImpact { + /// Replacement branch Pauli label before omitted-gate projection. + pub replacement_pauli_label: String, + /// Pauli-twirl term for the omitted ideal gate. + pub omitted_gate_twirl_label: String, + /// Non-identity two-qubit Pauli label (`IX` through `ZZ`) to evaluate. + pub pauli_label: String, + /// Relative probability mass for this projected branch term. + pub relative_probability: f64, } impl PauliWeights { @@ -1561,10 +1634,11 @@ impl PauliWeights { direct + self - .replacement_branch_impact_weights(gate_type) - .get(query_label.as_str()) - .copied() - .unwrap_or(0.0) + .replacement_branch_impacts(gate_type) + .into_iter() + .filter(|impact| impact.pauli_label == query_label) + .map(|impact| impact.relative_probability) + .sum::() } /// Look up only the plain post-gate two-qubit Pauli weight. @@ -1581,17 +1655,20 @@ impl PauliWeights { .sum::() } - /// Effective non-identity branch-impact weights from replacement entries. + /// Non-identity branch-impact terms from replacement entries. /// /// Each starred replacement entry is convolved with the Pauli twirl of the - /// omitted ideal gate. Identity effects are omitted because DEM builders - /// only emit branches that flip detectors or logical observables. + /// omitted ideal gate. The returned terms are deliberately not aggregated: + /// the builder should evaluate each branch term as a separate contribution + /// before the DEM's normal contribution grouping combines equivalent + /// detector/logical effects. Identity effects are omitted because DEM + /// builders only emit branches that flip detectors or logical observables. #[must_use] - pub fn replacement_branch_impact_weights(&self, gate_type: GateType) -> BTreeMap { + pub fn replacement_branch_impacts(&self, gate_type: GateType) -> Vec { let Some(twirl) = omitted_two_qubit_gate_pauli_twirl(gate_type) else { - return BTreeMap::new(); + return Vec::new(); }; - let mut weights = BTreeMap::new(); + let mut impacts = Vec::new(); for (ps, replacement_weight) in &self.replacement_entries { let Ok(replacement_label) = two_qubit_pauli_label(ps) else { continue; @@ -1602,9 +1679,27 @@ impl PauliWeights { if effective_label == "II" { continue; } - *weights.entry(effective_label).or_insert(0.0) += replacement_weight * twirl_weight; + impacts.push(ReplacementBranchImpact { + replacement_pauli_label: replacement_label.clone(), + omitted_gate_twirl_label: (*twirl_label).to_string(), + pauli_label: effective_label, + relative_probability: replacement_weight * twirl_weight, + }); } } + impacts + } + + /// Effective non-identity branch-impact weights from replacement entries. + /// + /// This is a convenience aggregation for callers that do not need source + /// branch identity. + #[must_use] + pub fn replacement_branch_impact_weights(&self, gate_type: GateType) -> BTreeMap { + let mut weights = BTreeMap::new(); + for impact in self.replacement_branch_impacts(gate_type) { + *weights.entry(impact.pauli_label).or_insert(0.0) += impact.relative_probability; + } weights } @@ -1619,6 +1714,12 @@ impl PauliWeights { pub fn replacement_entries(&self) -> &[(pecos_core::PauliString, f64)] { &self.replacement_entries } + + /// Whether this weight table contains starred replacement branches. + #[must_use] + pub fn has_replacement_entries(&self) -> bool { + !self.replacement_entries.is_empty() + } } impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights { @@ -3475,6 +3576,9 @@ impl DetectorErrorModel { DirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", DirectSourceFamily::TwoLocationComponent => "TwoLocationComponent", DirectSourceFamily::TwoLocationOneSidedComponent => "TwoLocationOneSidedComponent", + DirectSourceFamily::TwoLocationReplacementBranchImpact => { + "TwoLocationReplacementBranchImpact" + } DirectSourceFamily::Other => "Other", } } @@ -3620,12 +3724,7 @@ impl DetectorErrorModel { .push(FaultContribution::direct_with_source( effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), )); } @@ -3646,12 +3745,7 @@ impl DetectorErrorModel { .push(FaultContribution::direct_with_source_components( effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), components, )); } @@ -3731,12 +3825,7 @@ impl DetectorErrorModel { x_effect, z_effect, probability, - SourceMetadata::new( - &location_indices, - source.paulis, - source.gate_types, - source.before_flags, - ), + converted_source_metadata(source, &location_indices), )); } @@ -5682,6 +5771,55 @@ mod tests { assert_eq!(contribution.source_before_flags.as_slice(), &[false, false]); } + #[test] + fn test_replacement_branch_source_metadata_is_preserved() { + let effect = FaultMechanism::from_unsorted([7, 11], std::iter::empty()); + let first = effect.clone(); + let second = FaultMechanism::new(); + + let contribution = FaultContribution::direct_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3, 4], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationReplacementBranchImpact) + .with_replacement_branch(), + DirectSourceComponents::new(&first, &second), + ); + + assert!(contribution.replacement_branch); + assert_eq!( + contribution.direct_source_family, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact) + ); + + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([7, 11], std::iter::empty()), + 0.02, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationReplacementBranchImpact) + .with_replacement_branch(), + DirectSourceComponents::new(&first, &second), + ); + let contributions = dem.contributions_for_effect(&[7, 11], &[]); + assert_eq!(contributions.len(), 1); + assert!(contributions[0].replacement_branch); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::TwoLocationReplacementBranchImpact) + ); + } + #[test] fn test_add_y_decomposed_contribution_with_source_routes_metadata_to_direct() { let mut dem = DetectorErrorModel::new(); @@ -5802,6 +5940,15 @@ mod tests { replacement_omits_only.replacement_branch_impact_weights(GateType::SZZ), BTreeMap::from([("ZZ".to_string(), 0.5)]) ); + assert_eq!( + replacement_omits_only.replacement_branch_impacts(GateType::SZZ), + vec![ReplacementBranchImpact { + replacement_pauli_label: "II".to_string(), + omitted_gate_twirl_label: "ZZ".to_string(), + pauli_label: "ZZ".to_string(), + relative_probability: 0.5, + }], + ); let cx_replacement_identity = PauliWeights::with_replacement([], [(Z(0) & X(1), 1.0)]); assert!( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 9c8555ec1..6e0efd483 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -157,11 +157,14 @@ fn parse_replacement_approximation( "branch_impact" | "replacement_branch_impact" | "impact" => { Ok(ReplacementBranchApproximation::BranchImpact) } + "exact_branch_replay" | "exact_replay" | "exact_branch" | "exact" => { + Ok(ReplacementBranchApproximation::ExactBranchReplay) + } "ignore_gate_removal" | "ignore_removal" | "post_gate" | "postgate" => { Ok(ReplacementBranchApproximation::IgnoreGateRemoval) } _ => Err(pyo3::exceptions::PyValueError::new_err( - "p2_replacement_approximation must be 'pauli_twirl_omitted_gate', 'branch_impact', or 'ignore_gate_removal'", + "p2_replacement_approximation must be 'pauli_twirl_omitted_gate', 'branch_impact', 'exact_branch_replay', or 'ignore_gate_removal'", )), } } @@ -1052,10 +1055,14 @@ fn contribution_record_to_pydict( RustDirectSourceFamily::TwoLocationPlainY => "TwoLocationPlainY", RustDirectSourceFamily::TwoLocationComponent => "TwoLocationComponent", RustDirectSourceFamily::TwoLocationOneSidedComponent => "TwoLocationOneSidedComponent", + RustDirectSourceFamily::TwoLocationReplacementBranchImpact => { + "TwoLocationReplacementBranchImpact" + } RustDirectSourceFamily::Other => "Other", }; dict.set_item("direct_source_family", family_label)?; } + dict.set_item("replacement_branch", contribution.replacement_branch)?; match contribution.source_type { RustFaultSourceType::Direct => { diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 251ea8109..e7ae7fd45 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -154,7 +154,9 @@ def from_guppy( replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` evaluates starred entries as replacement - branch impacts; ``"ignore_gate_removal"`` treats starred + branch impacts; ``"exact_branch_replay"`` is reserved for a + future circuit-aware exact replay provider and currently fails + loudly for starred entries; ``"ignore_gate_removal"`` treats starred entries like plain post-gate Pauli entries. p_meas: Measurement flip rate. p_prep: Preparation (reset) error rate. diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 4a411242d..cfb9a3e3f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -101,6 +101,9 @@ class NoiseModel: replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` evaluates starred entries as replacement branch impacts; + ``"exact_branch_replay"`` is reserved for a future circuit-aware + exact replay provider and currently fails loudly for starred + entries; ``"ignore_gate_removal"`` treats starred entries like plain post-gate Pauli entries. p_meas: Measurement error rate. From 5fff6807de84526989f7e9a88a54f8315200d598 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 12:55:51 -0600 Subject: [PATCH 024/388] Start exact branch replay plumbing --- .../fault_tolerance/dem_builder/builder.rs | 199 +++++++++++++----- 1 file changed, 150 insertions(+), 49 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 575fe7fde..cb736f1dd 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -761,9 +761,6 @@ impl<'a> DemBuilder<'a> { ) { let locations = &self.influence_map.locations; - // Group CX locations by node for two-qubit gate processing - let mut cx_groups: BTreeMap> = BTreeMap::new(); - for (loc_idx, loc) in locations.iter().enumerate() { match loc.gate_type { GateType::PZ | GateType::QAlloc @@ -786,23 +783,7 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } - GateType::CX - | GateType::CZ - | GateType::CY - | GateType::SZZ - | GateType::SZZdg - | GateType::SXX - | GateType::SXXdg - | GateType::SYY - | GateType::SYYdg - | GateType::SWAP - | GateType::RXX - | GateType::RYY - | GateType::RZZ - if !loc.before => - { - cx_groups.entry(loc.node).or_default().push(loc_idx); - } + gate_type if is_two_qubit_noise_gate(gate_type) && !loc.before => {} GateType::H | GateType::F | GateType::Fdg @@ -852,35 +833,30 @@ impl<'a> DemBuilder<'a> { } // Process two-qubit gates. - for (_, loc_indices) in cx_groups { - for pair in loc_indices.chunks(2) { - if pair.len() != 2 { - continue; - } - let loc1 = &locations[pair[0]]; - let loc2 = &locations[pair[1]]; - let rates = self.rates_2q_for_locs(loc1, loc2); - if rates.iter().any(|r| *r > 0.0) { - self.process_two_qubit_fault_source_tracked( - pair[0], - pair[1], - rates, - dem, - meas_to_detectors, - meas_to_observables, - ); - } - if self.noise.p2_replacement_approximation - == ReplacementBranchApproximation::BranchImpact - { - self.process_two_qubit_replacement_branch_impacts_source_tracked( - pair[0], - pair[1], - dem, - meas_to_detectors, - meas_to_observables, - ); - } + for [loc1_idx, loc2_idx] in two_qubit_after_location_pairs(locations) { + let loc1 = &locations[loc1_idx]; + let loc2 = &locations[loc2_idx]; + let rates = self.rates_2q_for_locs(loc1, loc2); + if rates.iter().any(|r| *r > 0.0) { + self.process_two_qubit_fault_source_tracked( + loc1_idx, + loc2_idx, + rates, + dem, + meas_to_detectors, + meas_to_observables, + ); + } + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::BranchImpact + { + self.process_two_qubit_replacement_branch_impacts_source_tracked( + loc1_idx, + loc2_idx, + dem, + meas_to_detectors, + meas_to_observables, + ); } } } @@ -1907,6 +1883,44 @@ fn extract_measurement_refs( Ok((records, meas_ids)) } +fn is_two_qubit_noise_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::CX + | GateType::CZ + | GateType::CY + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SWAP + | GateType::RXX + | GateType::RYY + | GateType::RZZ + ) +} + +fn two_qubit_after_location_pairs(locations: &[DagSpacetimeLocation]) -> Vec<[usize; 2]> { + let mut groups: BTreeMap> = BTreeMap::new(); + for (loc_idx, loc) in locations.iter().enumerate() { + if is_two_qubit_noise_gate(loc.gate_type) && !loc.before { + groups.entry(loc.node).or_default().push(loc_idx); + } + } + + groups + .into_values() + .flat_map(|loc_indices| { + loc_indices + .chunks_exact(2) + .map(|pair| [pair[0], pair[1]]) + .collect::>() + }) + .collect() +} + // ============================================================================ // Convenience: build DEM from circuit (free function to handle lifetimes) // ============================================================================ @@ -1979,6 +1993,36 @@ fn build_dem_from_circuit( builder.try_build() } +/// Return a branch circuit where one ideal two-qubit gate has been omitted. +/// +/// Replacement-branch exact replay needs to evaluate "the hardware branch did +/// not apply this entangler" without disturbing the surrounding DAG wiring. +/// Replacing the selected node by batched identities preserves the node id, +/// qubit wires, and topological context while making the operation itself a +/// no-op on every qubit carried by the original gate. +#[cfg(test)] +fn circuit_with_omitted_two_qubit_gate( + circuit: &pecos_quantum::DagCircuit, + node: usize, +) -> Result { + let original = circuit.gate(node).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "cannot omit gate at node {node}: no such gate node exists" + )) + })?; + if !original.gate_type.is_two_qubit() { + return Err(DemBuilderError::ConfigurationError(format!( + "cannot omit gate at node {node}: {:?} is not a two-qubit gate", + original.gate_type + ))); + } + + let mut branch = circuit.clone(); + let replacement = pecos_core::Gate::simple(GateType::I, original.qubits.clone()); + *branch.gate_mut(node).expect("gate existed before clone") = replacement; + Ok(branch) +} + fn observable_records_from_annotations( circuit: &pecos_quantum::DagCircuit, influence_map: &DagFaultInfluenceMap, @@ -2818,6 +2862,63 @@ mod tests { ); } + #[test] + fn test_circuit_with_omitted_two_qubit_gate_preserves_wiring() { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + let prep0 = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + let prep1 = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + let entangler = circuit.add_gate_auto_wire(Gate::szz(&[(QubitId(0), QubitId(1))])); + let meas0 = circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + let meas1 = circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + + let branch = circuit_with_omitted_two_qubit_gate(&circuit, entangler) + .expect("two-qubit entangler can be omitted"); + + assert_eq!(circuit.gate(entangler).unwrap().gate_type, GateType::SZZ); + let replacement = branch.gate(entangler).unwrap(); + assert_eq!(replacement.gate_type, GateType::I); + assert_eq!(replacement.qubits.as_slice(), &[QubitId(0), QubitId(1)]); + + assert_eq!( + branch.predecessor_on_qubit(entangler, QubitId(0)), + Some(prep0) + ); + assert_eq!( + branch.predecessor_on_qubit(entangler, QubitId(1)), + Some(prep1) + ); + assert_eq!( + branch.successor_on_qubit(entangler, QubitId(0)), + Some(meas0) + ); + assert_eq!( + branch.successor_on_qubit(entangler, QubitId(1)), + Some(meas1) + ); + assert_eq!(branch.topological_order(), circuit.topological_order()); + } + + #[test] + fn test_circuit_with_omitted_two_qubit_gate_rejects_bad_nodes() { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + let prep = circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + + assert!(matches!( + circuit_with_omitted_two_qubit_gate(&circuit, prep), + Err(DemBuilderError::ConfigurationError(_)) + )); + assert!(matches!( + circuit_with_omitted_two_qubit_gate(&circuit, prep + 1), + Err(DemBuilderError::ConfigurationError(_)) + )); + } + #[test] fn test_parse_accepts_dem_label_id_form() { let det = parse_detectors_json(r#"[{"id": "D0", "records": [-1]}]"#).unwrap(); From de1ed63061e4491b340f8afeda5aabbe0ccda60a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 13:02:35 -0600 Subject: [PATCH 025/388] Attach exact branch replay context --- .../fault_tolerance/dem_builder/builder.rs | 131 +++++++++++++++++- 1 file changed, 129 insertions(+), 2 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index cb736f1dd..b0bb813e9 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -112,6 +112,20 @@ pub struct DemBuilder<'a> { /// Optional measurement order: maps `TickCircuit` measurement index -> qubit. /// This allows proper mapping between record offsets and influence map indices. measurement_order: Option>, + /// Optional circuit context for future exact replacement-branch replay. + exact_branch_context: Option>, +} + +#[derive(Debug, Clone, Copy)] +struct ExactBranchReplayContext<'a> { + circuit: &'a pecos_quantum::DagCircuit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct ExactBranchReplayRequest { + gate_node: usize, + gate_type: GateType, + loc_indices: [usize; 2], } impl<'a> DemBuilder<'a> { @@ -250,6 +264,7 @@ impl<'a> DemBuilder<'a> { observables: Vec::new(), num_measurements: influence_map.measurements.len(), measurement_order: None, + exact_branch_context: None, } } @@ -267,6 +282,11 @@ impl<'a> DemBuilder<'a> { self } + fn with_exact_branch_replay_context(mut self, circuit: &'a pecos_quantum::DagCircuit) -> Self { + self.exact_branch_context = Some(ExactBranchReplayContext { circuit }); + self + } + /// Attach per-gate-type per-Pauli noise. When present, overrides /// [`Self::with_noise`] scalars for gate types in the spec's maps. /// Mirrors @@ -734,6 +754,14 @@ impl<'a> DemBuilder<'a> { == ReplacementBranchApproximation::ExactBranchReplay && has_replacement_branches { + if let Some(context) = self.exact_branch_context { + let requests = + context.replacement_branch_requests(&self.influence_map.locations)?; + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay has circuit context and identified {} two-qubit replay request(s), but exact replacement-branch DEM emission is not implemented yet; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations", + requests.len() + ))); + } return Err(DemBuilderError::ConfigurationError( "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" .to_string(), @@ -1921,6 +1949,52 @@ fn two_qubit_after_location_pairs(locations: &[DagSpacetimeLocation]) -> Vec<[us .collect() } +impl ExactBranchReplayContext<'_> { + fn replacement_branch_requests( + &self, + locations: &[DagSpacetimeLocation], + ) -> Result, DemBuilderError> { + let mut requests = Vec::new(); + for [loc1_idx, loc2_idx] in two_qubit_after_location_pairs(locations) { + let loc1 = &locations[loc1_idx]; + let loc2 = &locations[loc2_idx]; + if loc1.node != loc2.node { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected paired two-qubit locations to share a node, got {} and {}", + loc1.node, loc2.node + ))); + } + if loc1.gate_type != loc2.gate_type { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected paired two-qubit locations at node {} to share a gate type, got {:?} and {:?}", + loc1.node, loc1.gate_type, loc2.gate_type + ))); + } + let branch = circuit_with_omitted_two_qubit_gate(self.circuit, loc1.node)?; + let replacement = branch + .gate(loc1.node) + .expect("omitted branch preserves the original gate node"); + if !loc1 + .qubits + .iter() + .chain(loc2.qubits.iter()) + .all(|qubit| replacement.qubits.contains(qubit)) + { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay location qubits at node {} are not all present in the omitted branch gate", + loc1.node + ))); + } + requests.push(ExactBranchReplayRequest { + gate_node: loc1.node, + gate_type: loc1.gate_type, + loc_indices: [loc1_idx, loc2_idx], + }); + } + Ok(requests) + } +} + // ============================================================================ // Convenience: build DEM from circuit (free function to handle lifetimes) // ============================================================================ @@ -1966,7 +2040,9 @@ fn build_dem_from_circuit( } }); - let builder = DemBuilder::new(&influence_map).with_noise_config(noise); + let builder = DemBuilder::new(&influence_map) + .with_noise_config(noise) + .with_exact_branch_replay_context(circuit); let builder = if let Some(ref dj) = det_json { builder.with_detectors_json(dj)? @@ -2000,7 +2076,6 @@ fn build_dem_from_circuit( /// Replacing the selected node by batched identities preserves the node id, /// qubit wires, and topological context while making the operation itself a /// no-op on every qubit carried by the original gate. -#[cfg(test)] fn circuit_with_omitted_two_qubit_gate( circuit: &pecos_quantum::DagCircuit, node: usize, @@ -2862,6 +2937,29 @@ mod tests { ); } + #[test] + fn test_from_circuit_exact_branch_replay_attaches_context_but_still_rejects() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::pauli::Z; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.szz(&[(0, 1)]); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement([], [(Z(0) & Z(1), 1.0)])) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("exact branch replay must remain fail-loud until emission is implemented"); + + assert!( + err.to_string().contains("has circuit context"), + "unexpected error: {err}", + ); + } + #[test] fn test_circuit_with_omitted_two_qubit_gate_preserves_wiring() { use pecos_core::{Gate, QubitId}; @@ -2901,6 +2999,35 @@ mod tests { assert_eq!(branch.topological_order(), circuit.topological_order()); } + #[test] + fn test_exact_branch_replay_context_collects_two_qubit_requests() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_core::{Gate, QubitId}; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + let entangler = circuit.add_gate_auto_wire(Gate::szz(&[(QubitId(0), QubitId(1))])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let requests = ExactBranchReplayContext { circuit: &circuit } + .replacement_branch_requests(&influence_map.locations) + .expect("two-qubit branch requests should be recoverable"); + + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].gate_node, entangler); + assert_eq!(requests[0].gate_type, GateType::SZZ); + let loc_qubits: Vec<_> = requests[0] + .loc_indices + .iter() + .flat_map(|&idx| influence_map.locations[idx].qubits.iter().copied()) + .collect(); + assert_eq!(loc_qubits, vec![QubitId(0), QubitId(1)]); + } + #[test] fn test_circuit_with_omitted_two_qubit_gate_rejects_bad_nodes() { use pecos_core::{Gate, QubitId}; From 78a21a5bdaff7924f848d77fd207883657e45c21 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 13:07:11 -0600 Subject: [PATCH 026/388] Validate omitted branch replay locations --- .../fault_tolerance/dem_builder/builder.rs | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index b0bb813e9..89d715fbf 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -402,6 +402,8 @@ impl<'a> DemBuilder<'a> { let pauli = pauli_pair_for_weight(p1, p2); let weight = if self.noise.p2_replacement_approximation == ReplacementBranchApproximation::BranchImpact + || self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay { weights.post_gate_two_qubit_weight_for(&pauli) } else { @@ -757,6 +759,10 @@ impl<'a> DemBuilder<'a> { if let Some(context) = self.exact_branch_context { let requests = context.replacement_branch_requests(&self.influence_map.locations)?; + for request in &requests { + let _branch_locs = context + .omitted_branch_location_pair(*request, &self.influence_map.locations)?; + } return Err(DemBuilderError::ConfigurationError(format!( "exact_branch_replay has circuit context and identified {} two-qubit replay request(s), but exact replacement-branch DEM emission is not implemented yet; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations", requests.len() @@ -1993,6 +1999,67 @@ impl ExactBranchReplayContext<'_> { } Ok(requests) } + + fn omitted_branch_location_pair( + &self, + request: ExactBranchReplayRequest, + original_locations: &[DagSpacetimeLocation], + ) -> Result<[usize; 2], DemBuilderError> { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + + let branch = circuit_with_omitted_two_qubit_gate(self.circuit, request.gate_node)?; + let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); + identity_location_pair_for_request(request, original_locations, &branch_map.locations) + } +} + +fn identity_location_pair_for_request( + request: ExactBranchReplayRequest, + original_locations: &[DagSpacetimeLocation], + branch_locations: &[DagSpacetimeLocation], +) -> Result<[usize; 2], DemBuilderError> { + let [orig_loc1, orig_loc2] = request.loc_indices; + let expected_qubits = [ + *original_locations + .get(orig_loc1) + .and_then(|loc| loc.qubits.first()) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay original location {orig_loc1} has no qubit" + )) + })?, + *original_locations + .get(orig_loc2) + .and_then(|loc| loc.qubits.first()) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay original location {orig_loc2} has no qubit" + )) + })?, + ]; + + let mut pair = [usize::MAX; 2]; + for (branch_idx, branch_loc) in branch_locations.iter().enumerate() { + if branch_loc.node != request.gate_node + || branch_loc.before + || branch_loc.gate_type != GateType::I + { + continue; + } + if branch_loc.qubits.first() == Some(&expected_qubits[0]) { + pair[0] = branch_idx; + } else if branch_loc.qubits.first() == Some(&expected_qubits[1]) { + pair[1] = branch_idx; + } + } + + if pair.contains(&usize::MAX) { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay could not find identity branch locations for omitted gate node {} on qubits {:?}", + request.gate_node, expected_qubits + ))); + } + Ok(pair) } // ============================================================================ @@ -3020,6 +3087,11 @@ mod tests { assert_eq!(requests.len(), 1); assert_eq!(requests[0].gate_node, entangler); assert_eq!(requests[0].gate_type, GateType::SZZ); + let branch_pair = (ExactBranchReplayContext { circuit: &circuit }) + .omitted_branch_location_pair(requests[0], &influence_map.locations) + .expect("omitted branch identity locations should be recoverable"); + assert_ne!(branch_pair[0], branch_pair[1]); + let loc_qubits: Vec<_> = requests[0] .loc_indices .iter() From 5e96dc929cc633c31540894c529fbacb3870be6a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 15:01:41 -0600 Subject: [PATCH 027/388] Validate exact branch effects --- .../fault_tolerance/dem_builder/builder.rs | 353 +++++++++++++++++- .../src/fault_tolerance/dem_builder/types.rs | 6 + .../src/fault_tolerance/influence_builder.rs | 10 +- .../src/fault_tolerance_bindings.rs | 3 + 4 files changed, 356 insertions(+), 16 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 89d715fbf..07a1268d7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -22,7 +22,9 @@ use super::types::{ }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; +use pecos_core::BitSet; use pecos_core::gate_type::GateType; +use pecos_simulators::symbolic_sparse_stab::MeasurementHistory; use smallvec::SmallVec; use std::collections::BTreeMap; @@ -128,6 +130,12 @@ struct ExactBranchReplayRequest { loc_indices: [usize; 2], } +#[derive(Debug, Clone, PartialEq, Eq)] +struct MeasurementParityExpression { + dependencies: BitSet, + flip: bool, +} + impl<'a> DemBuilder<'a> { /// Build a `DetectorErrorModel` directly from a circuit and noise. /// @@ -603,6 +611,38 @@ impl<'a> DemBuilder<'a> { .collect() } + fn measurement_indices_from_refs( + &self, + records: &[i32], + meas_ids: &[usize], + ) -> Result, DemBuilderError> { + if !records.is_empty() { + return records + .iter() + .map(|&rec| { + record_offset_to_absolute_index(self.num_measurements, rec).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "record offset {rec} is out of range for a circuit with {} measurement(s)", + self.num_measurements + )) + }) + }) + .collect(); + } + + meas_ids + .iter() + .map(|&meas_id| { + self.resolve_meas_id_to_tc_index(meas_id).ok_or_else(|| { + DemBuilderError::ParseError(format!( + "meas_id {meas_id} is not present in the circuit's {} measurement(s)", + self.num_measurements + )) + }) + }) + .collect() + } + /// Validates metadata refs, then builds the Detector Error Model. /// /// This is the fail-loud entry point. Every path that ingests @@ -759,9 +799,21 @@ impl<'a> DemBuilder<'a> { if let Some(context) = self.exact_branch_context { let requests = context.replacement_branch_requests(&self.influence_map.locations)?; + let weights = self + .noise + .p2_weights + .as_ref() + .expect("replacement entries exist"); for request in &requests { let _branch_locs = context .omitted_branch_location_pair(*request, &self.influence_map.locations)?; + let _omitted_effect = + self.exact_omitted_branch_base_effect(context, *request)?; + for (replacement_pauli, _weight) in weights.replacement_entries() { + let label = two_qubit_label_for_replay(replacement_pauli)?; + let _replacement_effect = + self.exact_replacement_branch_effect(context, *request, &label)?; + } } return Err(DemBuilderError::ConfigurationError(format!( "exact_branch_replay has circuit context and identified {} two-qubit replay request(s), but exact replacement-branch DEM emission is not implemented yet; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations", @@ -776,6 +828,75 @@ impl<'a> DemBuilder<'a> { Ok(()) } + fn exact_omitted_branch_base_effect( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + ) -> Result { + let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); + let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); + + for detector in &self.detectors { + let indices = + self.measurement_indices_from_refs(&detector.records, &detector.meas_ids)?; + if context.omitted_branch_flips_measurement_parity(request, &indices)? { + xor_toggle_4(&mut triggered_dets, detector.id); + } + } + + for observable in &self.observables { + let indices = + self.measurement_indices_from_refs(&observable.records, &observable.meas_ids)?; + if context.omitted_branch_flips_measurement_parity(request, &indices)? { + xor_toggle_2(&mut triggered_obs, observable.id); + } + } + + triggered_dets.sort_unstable(); + triggered_obs.sort_unstable(); + Ok(FaultMechanism::from_sorted_with_tracked_paulis( + triggered_dets, + triggered_obs, + SmallVec::new(), + )) + } + + fn exact_replacement_branch_effect( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + replacement_pauli_label: &str, + ) -> Result { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + + let (p1, p2) = two_qubit_label_to_pauli_indices(replacement_pauli_label).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay replacement Pauli label {replacement_pauli_label:?} is not a two-qubit Pauli label" + )) + })?; + let base_effect = self.exact_omitted_branch_base_effect(context, request)?; + if p1 == 0 && p2 == 0 { + return Ok(base_effect); + } + + let branch = circuit_with_omitted_two_qubit_gate(context.circuit, request.gate_node)?; + let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); + let branch_locs = identity_location_pair_for_request( + request, + &self.influence_map.locations, + &branch_map.locations, + )?; + let (meas_to_detectors, meas_to_observables) = self.build_measurement_mappings(); + let branch_effects = Self::two_qubit_effect_table_for_map( + &branch_map, + branch_locs[0], + branch_locs[1], + &meas_to_detectors, + &meas_to_observables, + ); + Ok(base_effect.xor(&branch_effects[p1 as usize][p2 as usize])) + } + fn num_influence_dem_outputs(&self) -> usize { self.influence_map .influences @@ -1128,6 +1249,63 @@ impl<'a> DemBuilder<'a> { effects } + fn two_qubit_effect_table_for_map( + influence_map: &DagFaultInfluenceMap, + loc1: usize, + loc2: usize, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) -> [[FaultMechanism; 4]; 4] { + let x1 = Self::compute_mechanism_for_map( + influence_map, + loc1, + Pauli::X, + meas_to_detectors, + meas_to_observables, + ); + let z1 = Self::compute_mechanism_for_map( + influence_map, + loc1, + Pauli::Z, + meas_to_detectors, + meas_to_observables, + ); + let x2 = Self::compute_mechanism_for_map( + influence_map, + loc2, + Pauli::X, + meas_to_detectors, + meas_to_observables, + ); + let z2 = Self::compute_mechanism_for_map( + influence_map, + loc2, + Pauli::Z, + meas_to_detectors, + meas_to_observables, + ); + + let get_single_effect = |p: u8, x: &FaultMechanism, z: &FaultMechanism| -> FaultMechanism { + match p { + 0 => FaultMechanism::new(), + 1 => x.clone(), + 2 => x.xor(z), + 3 => z.clone(), + _ => unreachable!("Pauli index must be 0-3"), + } + }; + + let mut effects: [[FaultMechanism; 4]; 4] = Default::default(); + for p1 in 0..4u8 { + for p2 in 0..4u8 { + let e1 = get_single_effect(p1, &x1, &z1); + let e2 = get_single_effect(p2, &x2, &z2); + effects[p1 as usize][p2 as usize] = e1.xor(&e2); + } + } + effects + } + #[allow(clippy::too_many_arguments)] fn add_two_qubit_pauli_contribution( &self, @@ -1327,27 +1505,35 @@ impl<'a> DemBuilder<'a> { pauli: Pauli, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, + ) -> FaultMechanism { + Self::compute_mechanism_for_map( + self.influence_map, + loc_idx, + pauli, + meas_to_detectors, + meas_to_observables, + ) + } + + fn compute_mechanism_for_map( + influence_map: &DagFaultInfluenceMap, + loc_idx: usize, + pauli: Pauli, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, ) -> FaultMechanism { // Get the measurement indices that this fault flips - let rust_dets = self - .influence_map - .get_detector_indices(loc_idx, pauli.as_u8()); + let rust_dets = influence_map.get_detector_indices(loc_idx, pauli.as_u8()); // Convert to pre-defined detector IDs using XOR let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); let mut triggered_tracked_paulis: SmallVec<[u32; 2]> = SmallVec::new(); - for dem_output_idx in self - .influence_map - .get_observable_indices(loc_idx, pauli.as_u8()) - { + for dem_output_idx in influence_map.get_observable_indices(loc_idx, pauli.as_u8()) { xor_toggle_2(&mut triggered_obs, dem_output_idx); } - for tracked_pauli_idx in self - .influence_map - .get_tracked_pauli_indices(loc_idx, pauli.as_u8()) - { + for tracked_pauli_idx in influence_map.get_tracked_pauli_indices(loc_idx, pauli.as_u8()) { xor_toggle_2(&mut triggered_tracked_paulis, tracked_pauli_idx); } @@ -1427,6 +1613,35 @@ fn two_qubit_label_to_pauli_indices(label: &str) -> Option<(u8, u8)> { chars.next().is_none().then_some((p1, p2)) } +fn two_qubit_label_for_replay(pauli: &pecos_core::PauliString) -> Result { + let mut label = String::with_capacity(2); + for qubit in [0, 1] { + label.push(pauli_index_to_label(match pauli.get(qubit) { + pecos_core::Pauli::I => 0, + pecos_core::Pauli::X => 1, + pecos_core::Pauli::Y => 2, + pecos_core::Pauli::Z => 3, + })); + } + if pauli.qubits().into_iter().any(|qubit| qubit > 1) { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay replacement Pauli {} is not supported by the two-qubit replay path", + pauli.to_sparse_str() + ))); + } + Ok(label) +} + +fn pauli_index_to_label(index: u8) -> char { + match index { + 0 => 'I', + 1 => 'X', + 2 => 'Y', + 3 => 'Z', + _ => unreachable!("Pauli index must be 0-3"), + } +} + fn pauli_label_to_index(label: char) -> Option { match label { 'I' => Some(0), @@ -2011,6 +2226,51 @@ impl ExactBranchReplayContext<'_> { let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); identity_location_pair_for_request(request, original_locations, &branch_map.locations) } + + fn omitted_branch_flips_measurement_parity( + &self, + request: ExactBranchReplayRequest, + measurement_indices: &[usize], + ) -> Result { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + + let ideal_info = InfluenceBuilder::new(self.circuit).run_symbolic_simulation(); + let branch = circuit_with_omitted_two_qubit_gate(self.circuit, request.gate_node)?; + let branch_info = InfluenceBuilder::new(&branch).run_symbolic_simulation(); + + let ideal = + measurement_parity_expression(&ideal_info.history, measurement_indices, "ideal")?; + let branch = + measurement_parity_expression(&branch_info.history, measurement_indices, "branch")?; + if ideal.dependencies != branch.dependencies { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay omitted gate at node {} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", + request.gate_node, measurement_indices + ))); + } + Ok(ideal.flip ^ branch.flip) + } +} + +fn measurement_parity_expression( + history: &MeasurementHistory, + measurement_indices: &[usize], + history_label: &str, +) -> Result { + let mut dependencies = BitSet::new(); + let mut flip = false; + + for &measurement_idx in measurement_indices { + let result = history.get(measurement_idx).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay {history_label} history has no measurement {measurement_idx}" + )) + })?; + dependencies.symmetric_difference_update(&result.outcome); + flip ^= result.flip; + } + + Ok(MeasurementParityExpression { dependencies, flip }) } fn identity_location_pair_for_request( @@ -3100,6 +3360,77 @@ mod tests { assert_eq!(loc_qubits, vec![QubitId(0), QubitId(1)]); } + #[test] + fn test_exact_branch_replay_base_effect_detects_deterministic_omitted_gate_flip() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + let entangler = circuit.add_gate_auto_wire(pecos_core::Gate::cx(&[(0, 1)])); + circuit.mz(&[1]); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let context = ExactBranchReplayContext { circuit: &circuit }; + let request = context + .replacement_branch_requests(&influence_map.locations) + .unwrap() + .into_iter() + .find(|request| request.gate_node == entangler) + .expect("CX request should be present"); + + let builder = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id":0,"records":[-1]}]"#) + .unwrap() + .with_num_measurements(1); + let effect = builder + .exact_omitted_branch_base_effect(context, request) + .expect("omitting this CX only flips a deterministic measurement parity"); + + assert_eq!(effect.detectors.as_slice(), &[0]); + assert!(effect.dem_outputs.is_empty()); + } + + #[test] + fn test_exact_branch_replay_replacement_pauli_combines_with_omitted_gate_effect() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + let entangler = circuit.add_gate_auto_wire(pecos_core::Gate::cx(&[(0, 1)])); + circuit.mz(&[1]); + + let influence_map = DagFaultAnalyzer::new(&circuit).build_influence_map(); + let context = ExactBranchReplayContext { circuit: &circuit }; + let request = context + .replacement_branch_requests(&influence_map.locations) + .unwrap() + .into_iter() + .find(|request| request.gate_node == entangler) + .expect("CX request should be present"); + + let builder = DemBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id":0,"records":[-1]}]"#) + .unwrap() + .with_num_measurements(1); + + let omitted_only = builder + .exact_replacement_branch_effect(context, request, "II") + .expect("omission-only branch should be deterministic here"); + assert_eq!(omitted_only.detectors.as_slice(), &[0]); + + let omitted_then_target_x = builder + .exact_replacement_branch_effect(context, request, "IX") + .expect("replacement X on the target should be deterministic here"); + assert!( + omitted_then_target_x.is_empty(), + "target X after omitted CX restores the ideal target measurement" + ); + } + #[test] fn test_circuit_with_omitted_two_qubit_gate_rejects_bad_nodes() { use pecos_core::{Gate, QubitId}; diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 2aea930f8..91cf82cce 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -129,6 +129,9 @@ pub enum DirectSourceFamily { /// Two-location direct source produced by a replacement-branch projection. TwoLocationReplacementBranchImpact, + /// Two-location direct source produced by exact replacement-branch replay. + TwoLocationExactReplacementBranch, + /// Fallback for other direct-source shapes. Other, } @@ -3579,6 +3582,9 @@ impl DetectorErrorModel { DirectSourceFamily::TwoLocationReplacementBranchImpact => { "TwoLocationReplacementBranchImpact" } + DirectSourceFamily::TwoLocationExactReplacementBranch => { + "TwoLocationExactReplacementBranch" + } DirectSourceFamily::Other => "Other", } } diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index 6820ca72c..bef7ed93c 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -250,7 +250,7 @@ impl<'a> InfluenceBuilder<'a> { } /// Run symbolic simulation to get measurement correlations. - fn run_symbolic_simulation(&self) -> MeasurementInfo { + pub(crate) fn run_symbolic_simulation(&self) -> MeasurementInfo { let topo_order = self.dag.topological_order(); // Determine number of qubits from the circuit @@ -862,11 +862,11 @@ impl<'a> InfluenceBuilder<'a> { } /// Information about measurements from symbolic simulation. -struct MeasurementInfo { - history: pecos_simulators::symbolic_sparse_stab::MeasurementHistory, - node_to_meas_idx: Vec>, +pub(crate) struct MeasurementInfo { + pub(crate) history: pecos_simulators::symbolic_sparse_stab::MeasurementHistory, + pub(crate) node_to_meas_idx: Vec>, #[allow(dead_code)] - num_measurements: usize, + pub(crate) num_measurements: usize, } /// Definition of a detector as XOR of measurements. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 6e0efd483..81a8563b7 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1058,6 +1058,9 @@ fn contribution_record_to_pydict( RustDirectSourceFamily::TwoLocationReplacementBranchImpact => { "TwoLocationReplacementBranchImpact" } + RustDirectSourceFamily::TwoLocationExactReplacementBranch => { + "TwoLocationExactReplacementBranch" + } RustDirectSourceFamily::Other => "Other", }; dict.set_item("direct_source_family", family_label)?; From b923f17372a7978bda780c1286cac17667871890 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 15:12:10 -0600 Subject: [PATCH 028/388] Emit exact replacement branch effects --- .../fault_tolerance/dem_builder/builder.rs | 167 ++++++++++++++++-- 1 file changed, 156 insertions(+), 11 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 07a1268d7..5882bfc42 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -815,10 +815,7 @@ impl<'a> DemBuilder<'a> { self.exact_replacement_branch_effect(context, *request, &label)?; } } - return Err(DemBuilderError::ConfigurationError(format!( - "exact_branch_replay has circuit context and identified {} two-qubit replay request(s), but exact replacement-branch DEM emission is not implemented yet; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations", - requests.len() - ))); + return Ok(()); } return Err(DemBuilderError::ConfigurationError( "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" @@ -1013,6 +1010,13 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } + if self.noise.p2_replacement_approximation + == ReplacementBranchApproximation::ExactBranchReplay + { + self.process_two_qubit_exact_replacement_branches_source_tracked( + loc1_idx, loc2_idx, dem, + ); + } } } @@ -1080,6 +1084,66 @@ impl<'a> DemBuilder<'a> { } } + /// Processes starred two-qubit replacement branches by replaying the exact + /// omitted-gate branch against detector/observable metadata. + fn process_two_qubit_exact_replacement_branches_source_tracked( + &self, + loc1: usize, + loc2: usize, + dem: &mut DetectorErrorModel, + ) { + let Some(weights) = &self.noise.p2_weights else { + return; + }; + if !weights.has_replacement_entries() { + return; + } + + let context = self + .exact_branch_context + .expect("exact_branch_replay was validated with circuit context"); + let loc1_meta = &self.influence_map.locations[loc1]; + let loc2_meta = &self.influence_map.locations[loc2]; + let request = ExactBranchReplayRequest { + gate_node: loc1_meta.node, + gate_type: loc1_meta.gate_type, + loc_indices: [loc1, loc2], + }; + + for (replacement_pauli, relative_probability) in weights.replacement_entries() { + if *relative_probability <= 0.0 { + continue; + } + let label = two_qubit_label_for_replay(replacement_pauli) + .expect("exact_branch_replay replacement Pauli was validated"); + let (p1, p2) = two_qubit_label_to_pauli_indices(&label) + .expect("exact_branch_replay label must be a two-qubit Pauli"); + let effect = self + .exact_replacement_branch_effect(context, request, &label) + .expect("exact_branch_replay effect was validated"); + if effect.is_empty() { + continue; + } + + let source_locations = [loc1, loc2]; + let source_paulis = [Pauli::from_u8(p1), Pauli::from_u8(p2)]; + let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; + let source_before_flags = [loc1_meta.before, loc2_meta.before]; + dem.add_direct_contribution_with_source( + effect, + self.noise.p2 * *relative_probability, + SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ) + .with_direct_source_family(DirectSourceFamily::TwoLocationExactReplacementBranch) + .with_replacement_branch(), + ); + } + } + /// Processes a measurement fault with source tracking. fn process_meas_fault_source_tracked( &self, @@ -3265,24 +3329,105 @@ mod tests { } #[test] - fn test_from_circuit_exact_branch_replay_attaches_context_but_still_rejects() { + fn test_from_circuit_exact_branch_replay_emits_omitted_gate_effect() { use crate::fault_tolerance::dem_builder::PauliWeights; - use pecos_core::pauli::Z; - use pecos_quantum::DagCircuit; + use pecos_core::PauliString; + use pecos_quantum::{Attribute, DagCircuit}; let mut circuit = DagCircuit::new(); circuit.pz(&[0, 1]); - circuit.szz(&[(0, 1)]); + circuit.x(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) - .set_p2_weights(PauliWeights::with_replacement([], [(Z(0) & Z(1), 1.0)])) + .set_p2_weights(PauliWeights::with_replacement( + [], + [(PauliString::identity(), 1.0)], + )) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("exact branch replay should emit representable branch effects"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!(contributions.len(), 1); + assert!((contributions[0].probability - 0.01).abs() < 1.0e-12); + assert!(contributions[0].replacement_branch); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::TwoLocationExactReplacementBranch) + ); + assert!( + contributions[0] + .paulis + .iter() + .all(|pauli| *pauli == Pauli::I), + "omission-only replacement branch should be recorded as *II" + ); + } + + #[test] + fn test_from_circuit_exact_branch_replay_skips_empty_replacement_effect() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::pauli::X; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.x(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement([], [(X(1), 1.0)])) + .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("exact branch replay should allow empty representable effects"); + + assert_eq!(dem.num_contributions(), 0); + } + + #[test] + fn test_from_circuit_exact_branch_replay_rejects_dependency_changing_branch() { + use crate::fault_tolerance::dem_builder::PauliWeights; + use pecos_core::PauliString; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0, 1]); + circuit.h(&[0]); + circuit.cx(&[(0, 1)]); + circuit.mz(&[1]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + let noise = NoiseConfig::new(0.0, 0.01, 0.0, 0.0) + .set_p2_weights(PauliWeights::with_replacement( + [], + [(PauliString::identity(), 1.0)], + )) .set_p2_replacement_approximation(ReplacementBranchApproximation::ExactBranchReplay); let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) - .expect_err("exact branch replay must remain fail-loud until emission is implemented"); + .expect_err("dependency-changing replacement branches must stay fail-loud"); assert!( - err.to_string().contains("has circuit context"), + err.to_string().contains("not representable"), "unexpected error: {err}", ); } From b30b093bc4133a42903133c9f5753bbbf0102d53 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 15:18:53 -0600 Subject: [PATCH 029/388] Guard LogicalSubgraphDecoder distance suppression (xfail-strict): documents the hyperedge-decomposition bug capping suppression past d~3 --- ...test_logical_subgraph_region_comparison.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index e2a7edab2..460774fa4 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -241,5 +241,45 @@ def test_bp_inner_is_lower_ler_default(): assert abs(bp_ler - exact_ler) <= 0.0005, f"bp={bp_ler:.5f} exact={exact_ler:.5f}" +@pytest.mark.xfail( + strict=True, + reason=( + "KNOWN BUG: LogicalSubgraphDecoder does not achieve distance suppression past " + "d~3. It builds each subgraph by PROJECTING undecomposed hyperedges (Y / " + "correlated errors, ~50-70% of mechanisms) onto the region, instead of " + "decomposing them into matching-optimal graphlike edges (a 2-det edge plus " + "boundary edges) the way stim's decompose_errors / lomatching's " + "get_circuit_subgraph does. The observing region is correct (matches lomatching " + "exactly at d=3/5/7) but the edges are wrong, so MWPM cannot suppress. lomatching " + "MoMatching drives memory LER to 0 at d=7 on the same circuits; PECOS does not. " + "Fix = port matching-oriented hyperedge decomposition into subgraph construction. " + "See pecos-docs/design/logical-subgraph-backprop-region-builder.md. When the fix " + "lands this xfail flips to a pass (strict) -- remove the marker then." + ), +) +def test_distance_suppression_memory(): + """A fault-tolerant decoder must drive LER DOWN as code distance grows below + threshold. This guards against the hyperedge-decomposition bug regressing + further and turns green the moment the fix is in.""" + + def mem_ler(d, p, n, seed): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", d, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + return LogicalSubgraphDecoder(dem, sc).decode_count(batch) / n + + p, n = 0.001, 60000 + ler_d3 = mem_ler(3, p, n, seed=1) + ler_d5 = mem_ler(5, p, n, seed=1) + # Below threshold, d=5 must beat d=3 by a clear margin (lomatching: ~13x). + assert ler_d5 < ler_d3 * 0.7, ( + f"no distance suppression: d3={ler_d3:.5f} d5={ler_d5:.5f}" + ) + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) From 7030f5669a3afd70a2074f178faccc2f374daef4 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 16:37:59 -0600 Subject: [PATCH 030/388] Thread exact branch context through surface DEMs --- .../fault_tolerance/dem_builder/builder.rs | 6 +++++- .../src/fault_tolerance_bindings.rs | 20 +++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 3 +++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 5882bfc42..c5f0175d7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -290,7 +290,11 @@ impl<'a> DemBuilder<'a> { self } - fn with_exact_branch_replay_context(mut self, circuit: &'a pecos_quantum::DagCircuit) -> Self { + #[must_use] + pub fn with_exact_branch_replay_context( + mut self, + circuit: &'a pecos_quantum::DagCircuit, + ) -> Self { self.exact_branch_context = Some(ExactBranchReplayContext { circuit }); self } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 81a8563b7..6942b5a40 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1432,6 +1432,7 @@ pub struct PyDemBuilder { observables_json: Option, num_measurements: Option, measurement_order: Option>, + exact_branch_circuit: Option, } #[pymethods] @@ -1449,6 +1450,7 @@ impl PyDemBuilder { observables_json: None, num_measurements: None, measurement_order: None, + exact_branch_circuit: None, } } @@ -1564,6 +1566,20 @@ impl PyDemBuilder { slf } + /// Attach the original circuit for exact replacement-branch replay. + /// + /// This is only needed when using `p2_replacement_approximation="exact_branch_replay"` + /// with starred p2 replacement branches. The influence map still determines + /// ordinary Pauli propagation; the circuit context lets PECOS replay the + /// omitted-gate branch and fail loudly if it is not DEM-representable. + fn with_exact_branch_replay_circuit<'py>( + mut slf: PyRefMut<'py, Self>, + circuit: &crate::dag_circuit_bindings::PyDagCircuit, + ) -> PyRefMut<'py, Self> { + slf.exact_branch_circuit = Some(circuit.inner.clone()); + slf + } + /// Build the Detector Error Model. /// /// Returns: @@ -1577,6 +1593,10 @@ impl PyDemBuilder { let mut builder = RustDemBuilder::new(&self.influence_map).with_noise_config(self.noise.clone()); + if let Some(ref circuit) = self.exact_branch_circuit { + builder = builder.with_exact_branch_replay_context(circuit); + } + if let Some(num) = self.num_measurements { builder = builder.with_num_measurements(num); } diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index cfb9a3e3f..cd442e62c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -220,6 +220,7 @@ class DecodingResult: class _CachedNativeSurfaceTopology: """Topology-only native model data reused across noise configurations.""" + dag_circuit: Any influence_map: Any detectors_json: str observables_json: str @@ -1296,6 +1297,7 @@ def _surface_native_topology( num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) return _CachedNativeSurfaceTopology( + dag_circuit=dag, influence_map=influence_map, detectors_json=detectors_json, observables_json=observables_json, @@ -1356,6 +1358,7 @@ def _dem_string_from_cached_surface_topology( p2_weights=_p2_weights_dict(noise.p2_weights), p2_replacement_approximation=noise.p2_replacement_approximation, ) + .with_exact_branch_replay_circuit(topology.dag_circuit) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) .with_detectors_json(topology.detectors_json) From e90ba04842ce03f3e14d6dd532c9744e7680d8ec Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 17:06:24 -0600 Subject: [PATCH 031/388] Document exact branch replay surface DEMs --- python/quantum-pecos/src/pecos/qec/surface/decode.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index cd442e62c..0754a16f7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -101,9 +101,9 @@ class NoiseModel: replacement labels. ``"pauli_twirl_omitted_gate"`` convolves with the omitted two-qubit gate's Pauli twirl; ``"branch_impact"`` evaluates starred entries as replacement branch impacts; - ``"exact_branch_replay"`` is reserved for a future circuit-aware - exact replay provider and currently fails loudly for starred - entries; + ``"exact_branch_replay"`` uses the traced circuit context to replay + omitted-gate branches at concrete two-qubit gate locations and + fails loudly when a branch is not DEM-representable; ``"ignore_gate_removal"`` treats starred entries like plain post-gate Pauli entries. p_meas: Measurement error rate. From fc48370a9680cf3245c6c0876baf547489cc73ec Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 17:56:42 -0600 Subject: [PATCH 032/388] Fix LogicalSubgraphDecoder distance suppression: default to exact-MWPM inner (fusion_blossom); native union-find inners don't suppress at d>=5 --- .../src/fault_tolerance_bindings.rs | 15 +-- ...test_logical_subgraph_region_comparison.py | 93 +++++++------------ 2 files changed, 43 insertions(+), 65 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 39d877bcc..ef0ac6c0e 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4435,13 +4435,16 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `pecos_uf:bp` (native union-find + belief propagation): it - // reaches exact-MWPM LER -- ~13-26% lower than `pecos_uf:fast`, the gap - // growing with code distance -- at ~3x the speed of pymatching, with no - // external decoder dependency. See + // Default inner is `fusion_blossom_serial` (exact MWPM): it achieves + // distance suppression (LER drops with code distance), matching lomatching + // exactly (memory d=7 -> 0 logical errors). The native union-find inners + // (`pecos_uf:fast`, `pecos_uf:bp`) decode WELL at d=3 but DO NOT suppress at + // d>=5 -- a bug in the union-find decoder, not the subgraph construction + // (region + edge topology are correct, == lomatching). Correctness is + // non-negotiable here, so the default must be an exact MWPM inner. See // pecos-docs/design/logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:bp", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="fusion_blossom_serial", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, @@ -4492,7 +4495,7 @@ impl PyLogicalSubgraphDecoder { /// construction (e.g. the paper's back-propagation / detecting-region set) /// and decode with the same machinery for direct comparison. #[staticmethod] - #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:bp"))] + #[pyo3(signature = (dem, membership, inner_decoder="fusion_blossom_serial"))] fn from_membership( dem: &str, membership: Vec>, diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index 460774fa4..11c26744f 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -212,72 +212,47 @@ def test_coordinate_beats_backprop_seeded_groupfill(): ) -def test_bp_inner_is_lower_ler_default(): - """The default inner decoder is the native UF+BP (`pecos_uf:bp`), which - reaches exact-MWPM accuracy and decodes with lower LER than the older - `pecos_uf:fast` union-find. The gap grows with distance; assert it at d=3.""" - b = _cx_circuit() - dem = b.build_dem(p1=0.002, p2=0.002, p_meas=0.002) +def _mem_ler(d, p, n, seed, inner=None): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", d, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + dec = LogicalSubgraphDecoder(dem, sc) if inner is None else LogicalSubgraphDecoder(dem, sc, inner) + return dec.decode_count(batch) / n + - n = 40000 - batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=4) - - default = LogicalSubgraphDecoder(dem, sc) # no inner -> the new default - bp = LogicalSubgraphDecoder(dem, sc, "pecos_uf:bp") - fast = LogicalSubgraphDecoder(dem, sc, "pecos_uf:fast") - exact = LogicalSubgraphDecoder(dem, sc, "pymatching") - - default_ler = default.decode_count(batch) / n - bp_ler = bp.decode_count(batch) / n - fast_ler = fast.decode_count(batch) / n - exact_ler = exact.decode_count(batch) / n - - # The default IS pecos_uf:bp. - assert default_ler == bp_ler - # BP inner beats the old fast default... - assert bp_ler < fast_ler, f"bp={bp_ler:.5f} fast={fast_ler:.5f}" - # ...and matches exact MWPM (native UF+BP reaches the optimum on graphlike subgraphs). - assert abs(bp_ler - exact_ler) <= 0.0005, f"bp={bp_ler:.5f} exact={exact_ler:.5f}" - - -@pytest.mark.xfail( - strict=True, - reason=( - "KNOWN BUG: LogicalSubgraphDecoder does not achieve distance suppression past " - "d~3. It builds each subgraph by PROJECTING undecomposed hyperedges (Y / " - "correlated errors, ~50-70% of mechanisms) onto the region, instead of " - "decomposing them into matching-optimal graphlike edges (a 2-det edge plus " - "boundary edges) the way stim's decompose_errors / lomatching's " - "get_circuit_subgraph does. The observing region is correct (matches lomatching " - "exactly at d=3/5/7) but the edges are wrong, so MWPM cannot suppress. lomatching " - "MoMatching drives memory LER to 0 at d=7 on the same circuits; PECOS does not. " - "Fix = port matching-oriented hyperedge decomposition into subgraph construction. " - "See pecos-docs/design/logical-subgraph-backprop-region-builder.md. When the fix " - "lands this xfail flips to a pass (strict) -- remove the marker then." - ), -) def test_distance_suppression_memory(): """A fault-tolerant decoder must drive LER DOWN as code distance grows below - threshold. This guards against the hyperedge-decomposition bug regressing - further and turns green the moment the fix is in.""" - - def mem_ler(d, p, n, seed): - patch = SurfacePatch.create(d) - b = LogicalCircuitBuilder() - b.add_patch(patch, "A") - b.add_memory("A", d, "Z") - dem = b.build_dem(p1=p, p2=p, p_meas=p) - sc = b.stab_coords() - batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) - return LogicalSubgraphDecoder(dem, sc).decode_count(batch) / n - + threshold. The default inner (exact MWPM, fusion_blossom) suppresses, + matching lomatching (d=7 -> 0). Guards against the default reverting to a + non-suppressing inner.""" p, n = 0.001, 60000 - ler_d3 = mem_ler(3, p, n, seed=1) - ler_d5 = mem_ler(5, p, n, seed=1) + ler_d3 = _mem_ler(3, p, n, seed=1) + ler_d5 = _mem_ler(5, p, n, seed=1) # Below threshold, d=5 must beat d=3 by a clear margin (lomatching: ~13x). assert ler_d5 < ler_d3 * 0.7, ( - f"no distance suppression: d3={ler_d3:.5f} d5={ler_d5:.5f}" + f"no distance suppression with default inner: d3={ler_d3:.5f} d5={ler_d5:.5f}" + ) + + +def test_native_union_find_inner_does_not_suppress(): + """KNOWN BUG (separately tracked): PECOS's native union-find inner decoders + (`pecos_uf:*`) decode well at d=3 but do NOT achieve distance suppression at + d>=5 -- which is why the LogicalSubgraphDecoder default is exact MWPM, not + `pecos_uf`. The subgraph region + edge topology are correct (== lomatching); + the failure is in the UF decoder itself. This pins the bug; when UF is fixed + (d=5 LER < d=3), this assertion flips and the test should be updated. + See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" + p, n = 0.001, 60000 + uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") + uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") + # Currently the UF inner does NOT suppress: d=5 is not meaningfully below d=3. + assert uf_d5 >= uf_d3 * 0.7, ( + f"union-find inner now suppresses (d3={uf_d3:.5f} d5={uf_d5:.5f}) -- " + "UF bug appears fixed; update this test and reconsider the default inner." ) From 384ca5e32cd0b81a89cf47a0bc863acd13ad1f3b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 31 May 2026 18:03:31 -0600 Subject: [PATCH 033/388] Fix LLVM 21 migration and pointer metadata. --- crates/pecos-build/src/home.rs | 224 ++++++++++- crates/pecos-cli/src/cli/install_cmd.rs | 3 +- crates/pecos-cli/src/cli/llvm_cmd.rs | 8 +- crates/pecos-cli/src/cli/migrate_cmd.rs | 57 ++- crates/pecos-cli/src/cli/setup_cmd.rs | 50 ++- crates/pecos-cli/src/main.rs | 25 +- crates/pecos-llvm/src/llvm_compat.rs | 371 ++++++++++++++++-- docs/development/dev-tools.md | 3 + docs/user-guide/llvm-setup.md | 4 + python/pecos-rslib-llvm/src/llvm_bindings.rs | 23 +- .../tests/test_llvm_comprehensive.py | 14 + 11 files changed, 714 insertions(+), 68 deletions(-) diff --git a/crates/pecos-build/src/home.rs b/crates/pecos-build/src/home.rs index 91555b62d..4b68a649e 100644 --- a/crates/pecos-build/src/home.rs +++ b/crates/pecos-build/src/home.rs @@ -347,6 +347,25 @@ pub struct LegacyDep { pub new: PathBuf, } +/// Description of a legacy dep that cannot be migrated safely. +pub struct IncompatibleLegacyDep { + /// Human-readable name (e.g. "LLVM") + pub name: &'static str, + /// Legacy path + pub old: PathBuf, + /// Why the dependency cannot be migrated + pub reason: String, +} + +/// Result of scanning legacy dependency paths. +#[derive(Default)] +pub struct LegacyDepStatus { + /// Legacy dependencies that can be moved to versioned paths. + pub migratable: Vec, + /// Legacy dependencies that need user action instead of migration. + pub incompatible: Vec, +} + /// Check for legacy top-level installs that should be migrated. /// /// Returns a list of deps whose old path exists but new path does not. @@ -355,9 +374,22 @@ pub struct LegacyDep { /// /// Returns an error if unable to determine paths. pub fn find_legacy_deps() -> Result> { - let mut found = Vec::new(); + Ok(find_legacy_dep_status()?.migratable) +} + +/// Check legacy installs and report both migratable and incompatible entries. +/// +/// # Errors +/// +/// Returns an error if unable to determine paths. +pub fn find_legacy_dep_status() -> Result { let deps_dir = get_deps_dir_path()?; + let home = get_pecos_home_path()?; + Ok(find_legacy_dep_status_at(&home, &deps_dir)) +} +fn find_legacy_dep_status_at(home: &Path, deps_dir: &Path) -> LegacyDepStatus { + let mut status = LegacyDepStatus::default(); let checks: &[(&str, &str)] = &[ ("LLVM", LLVM_VERSION), ("CUDA", crate::cuda::CUDA_VERSION), @@ -367,33 +399,64 @@ pub fn find_legacy_deps() -> Result> { for &(name, version) in checks { let lower = name.to_lowercase(); let versioned = deps_dir.join(format!("{lower}-{version}")); - if versioned.exists() { - continue; // Already at versioned path - } + let versioned_exists = versioned.exists(); + let mut migration_queued = false; // Check unversioned deps/ path (e.g. deps/llvm/) let unversioned = deps_dir.join(&lower); if unversioned.exists() { - found.push(LegacyDep { - name, - old: unversioned, - new: versioned.clone(), - }); - continue; + if let Some(reason) = legacy_incompatibility_reason(name, &unversioned) { + status.incompatible.push(IncompatibleLegacyDep { + name, + old: unversioned, + reason, + }); + } else if !versioned_exists { + status.migratable.push(LegacyDep { + name, + old: unversioned, + new: versioned.clone(), + }); + migration_queued = true; + } } // Check top-level legacy path (e.g. ~/.pecos/llvm/) - if let Ok(top_level) = get_pecos_home_path().map(|h| h.join(&lower)) - && top_level.exists() - { - found.push(LegacyDep { - name, - old: top_level, - new: versioned, - }); + let top_level = home.join(&lower); + if top_level.exists() { + if let Some(reason) = legacy_incompatibility_reason(name, &top_level) { + status.incompatible.push(IncompatibleLegacyDep { + name, + old: top_level, + reason, + }); + continue; + } + if !versioned_exists && !migration_queued { + status.migratable.push(LegacyDep { + name, + old: top_level, + new: versioned, + }); + } } } - Ok(found) + status +} + +fn legacy_incompatibility_reason(name: &str, old_path: &Path) -> Option { + if name != "LLVM" { + return None; + } + + if crate::llvm::is_valid_llvm(old_path) { + None + } else { + Some(format!( + "not a valid LLVM {} installation; it may be an older LLVM 14 install", + crate::llvm::REQUIRED_VERSION + )) + } } /// Migrate a single legacy dep by renaming old -> new. @@ -409,6 +472,16 @@ pub fn migrate_legacy_dep(dep: &LegacyDep) -> Result<()> { Ok(()) } +/// Remove an incompatible legacy dependency path. +/// +/// # Errors +/// +/// Returns an error if the directory cannot be removed. +pub fn remove_incompatible_legacy_dep(dep: &IncompatibleLegacyDep) -> Result<()> { + fs::remove_dir_all(&dep.old)?; + Ok(()) +} + /// Get information about the PECOS home directory #[derive(Debug)] pub struct HomeInfo { @@ -469,6 +542,26 @@ mod tests { std::env::temp_dir().join(format!("pecos_test_{prefix}_{pid}_{id}")) } + #[cfg(unix)] + fn create_fake_llvm_config(llvm_dir: &Path, version: &str) { + use std::os::unix::fs::PermissionsExt; + + let bin_dir = llvm_dir.join("bin"); + fs::create_dir_all(&bin_dir).expect("Should create fake llvm bin dir"); + let llvm_config = bin_dir.join("llvm-config"); + fs::write( + &llvm_config, + format!("#!/bin/sh\nif [ \"$1\" = \"--version\" ]; then echo \"{version}\"; fi\n"), + ) + .expect("Should write fake llvm-config"); + let mut permissions = fs::metadata(&llvm_config) + .expect("Should stat fake llvm-config") + .permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&llvm_config, permissions) + .expect("Should make fake llvm-config executable"); + } + #[test] fn test_get_pecos_home_default() { // Test that default home ends with .pecos (uses real home dir) @@ -544,4 +637,97 @@ mod tests { // Cleanup let _ = std::fs::remove_dir_all(&test_home); } + + #[test] + fn legacy_migration_skips_invalid_llvm() { + let test_home = unique_test_dir("legacy_invalid_llvm"); + let deps = test_home.join("deps"); + let legacy_llvm = test_home.join("llvm"); + fs::create_dir_all(&legacy_llvm).expect("Should create legacy llvm dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert!( + status.migratable.iter().all(|dep| dep.name != "LLVM"), + "invalid legacy LLVM must not migrate into llvm-21.1" + ); + assert_eq!(status.incompatible.len(), 1); + assert_eq!(status.incompatible[0].name, "LLVM"); + assert_eq!(status.incompatible[0].old, legacy_llvm); + + let _ = std::fs::remove_dir_all(&test_home); + } + + #[test] + fn legacy_migration_reports_invalid_unversioned_llvm() { + let test_home = unique_test_dir("legacy_invalid_unversioned_llvm"); + let deps = test_home.join("deps"); + let legacy_llvm = deps.join("llvm"); + fs::create_dir_all(&legacy_llvm).expect("Should create legacy llvm dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert!( + status.migratable.iter().all(|dep| dep.name != "LLVM"), + "invalid unversioned LLVM must not migrate into llvm-21.1" + ); + assert_eq!(status.incompatible.len(), 1); + assert_eq!(status.incompatible[0].name, "LLVM"); + assert_eq!(status.incompatible[0].old, legacy_llvm); + + let _ = std::fs::remove_dir_all(&test_home); + } + + #[test] + fn legacy_migration_reports_invalid_llvm_when_versioned_path_exists() { + let test_home = unique_test_dir("legacy_invalid_llvm_with_current"); + let deps = test_home.join("deps"); + let legacy_llvm = test_home.join("llvm"); + let versioned_llvm = deps.join(format!("llvm-{LLVM_VERSION}")); + fs::create_dir_all(&legacy_llvm).expect("Should create legacy llvm dir"); + fs::create_dir_all(&versioned_llvm).expect("Should create versioned llvm dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert!(status.migratable.is_empty()); + assert_eq!(status.incompatible.len(), 1); + assert_eq!(status.incompatible[0].name, "LLVM"); + assert_eq!(status.incompatible[0].old, legacy_llvm); + + let _ = std::fs::remove_dir_all(&test_home); + } + + #[cfg(unix)] + #[test] + fn legacy_migration_reports_top_level_invalid_llvm_when_unversioned_can_migrate() { + let test_home = unique_test_dir("legacy_invalid_top_level_with_unversioned"); + let deps = test_home.join("deps"); + let unversioned_llvm = deps.join("llvm"); + let top_level_llvm = test_home.join("llvm"); + create_fake_llvm_config(&unversioned_llvm, crate::llvm::REQUIRED_VERSION); + fs::create_dir_all(&top_level_llvm).expect("Should create top-level llvm dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert_eq!(status.migratable.len(), 1); + assert_eq!(status.migratable[0].name, "LLVM"); + assert_eq!(status.migratable[0].old, unversioned_llvm); + assert_eq!(status.incompatible.len(), 1); + assert_eq!(status.incompatible[0].name, "LLVM"); + assert_eq!(status.incompatible[0].old, top_level_llvm); + + let _ = std::fs::remove_dir_all(&test_home); + } + + #[test] + fn legacy_migration_still_finds_non_llvm_deps() { + let test_home = unique_test_dir("legacy_cuda"); + let deps = test_home.join("deps"); + let legacy_cuda = test_home.join("cuda"); + fs::create_dir_all(&legacy_cuda).expect("Should create legacy cuda dir"); + + let status = find_legacy_dep_status_at(&test_home, &deps); + assert_eq!(status.migratable.len(), 1); + assert_eq!(status.incompatible.len(), 0); + assert_eq!(status.migratable[0].name, "CUDA"); + assert_eq!(status.migratable[0].old, legacy_cuda); + + let _ = std::fs::remove_dir_all(&test_home); + } } diff --git a/crates/pecos-cli/src/cli/install_cmd.rs b/crates/pecos-cli/src/cli/install_cmd.rs index 9dc8e348c..b3119ae44 100644 --- a/crates/pecos-cli/src/cli/install_cmd.rs +++ b/crates/pecos-cli/src/cli/install_cmd.rs @@ -123,7 +123,8 @@ fn install_target(target: &str, force: bool, no_configure: bool, yes: bool) -> R fn confirm_managed_llvm_install(yes: bool) -> Result<()> { let version = pecos_build::llvm::installer::release_version(); - let install_dir = pecos_build::home::get_llvm_dir_path()?; + let install_dir = + pecos_build::home::get_versioned_dep_path("llvm", pecos_build::home::LLVM_VERSION)?; if let Some(reason) = pecos_build::llvm::installer::managed_install_unavailable_reason() { return Err(Error::Config(reason.into())); diff --git a/crates/pecos-cli/src/cli/llvm_cmd.rs b/crates/pecos-cli/src/cli/llvm_cmd.rs index 1d83f987e..b46bf4b49 100644 --- a/crates/pecos-cli/src/cli/llvm_cmd.rs +++ b/crates/pecos-cli/src/cli/llvm_cmd.rs @@ -114,7 +114,13 @@ fn ensure_managed_llvm(no_configure: bool) -> Result { fn run_configure(path: Option) -> Result<()> { let llvm_path = if let Some(path) = path { - let llvm_path = std::path::PathBuf::from(path); + let input_path = std::path::PathBuf::from(&path); + let llvm_path = input_path.canonicalize().map_err(|e| { + pecos_build::errors::Error::Llvm(format!( + "Could not resolve LLVM path {}: {e}", + input_path.display() + )) + })?; if !is_valid_llvm(&llvm_path) { return Err(pecos_build::errors::Error::Llvm(format!( "{} is not a valid LLVM {REQUIRED_VERSION} installation", diff --git a/crates/pecos-cli/src/cli/migrate_cmd.rs b/crates/pecos-cli/src/cli/migrate_cmd.rs index b9c86561e..197537871 100644 --- a/crates/pecos-cli/src/cli/migrate_cmd.rs +++ b/crates/pecos-cli/src/cli/migrate_cmd.rs @@ -4,7 +4,8 @@ //! into `~/.pecos/deps/` to match the new directory layout. use pecos_build::Result; -use pecos_build::home::{find_legacy_deps, migrate_legacy_dep}; +use pecos_build::home::{find_legacy_dep_status, migrate_legacy_dep}; +use pecos_build::prompt::{PromptMode, confirm}; use std::path::PathBuf; fn find_project_root() -> Result { @@ -20,16 +21,58 @@ fn find_project_root() -> Result { } /// Run the migrate command. -pub fn run() -> Result<()> { - let legacy = find_legacy_deps()?; +pub fn run(mode: PromptMode) -> Result<()> { + let legacy = find_legacy_dep_status()?; - if legacy.is_empty() { - println!("Nothing to migrate. All dependencies are already under ~/.pecos/deps/."); + if !legacy.incompatible.is_empty() { + println!("Found legacy dependencies that cannot be migrated safely:"); + for dep in &legacy.incompatible { + println!(" {} at {}", dep.name, dep.old.display()); + println!(" {}", dep.reason); + if dep.name == "LLVM" { + println!(" This path will not be moved into ~/.pecos/deps/llvm-21.1/."); + println!(" Remove it before installing/configuring LLVM 21.1."); + println!(" Then install LLVM 21.1 with `pecos install llvm`, or configure"); + println!( + " an existing LLVM 21.1 install with `pecos llvm configure /path/to/llvm`." + ); + } + } + println!(); + + for dep in &legacy.incompatible { + if dep.name != "LLVM" { + continue; + } + if confirm( + &format!("Remove incompatible legacy LLVM at {}?", dep.old.display()), + true, + mode, + ) { + print!(" Removing old LLVM..."); + pecos_build::home::remove_incompatible_legacy_dep(dep)?; + println!(" done"); + } else { + println!( + " Keeping old LLVM at {}. It will not be used as LLVM 21.1.", + dep.old.display() + ); + } + } + println!(); + } + + if legacy.migratable.is_empty() { + if legacy.incompatible.is_empty() { + println!("Nothing to migrate. All dependencies are already under ~/.pecos/deps/."); + } else { + println!("No compatible legacy dependencies can be migrated automatically."); + } return Ok(()); } println!("Migrating legacy dependencies to ~/.pecos/deps/:"); - for dep in &legacy { + for dep in &legacy.migratable { print!( " {} : {} -> {}", dep.name, @@ -42,7 +85,7 @@ pub fn run() -> Result<()> { // Update .cargo/config.toml to point to the new paths let llvm_dir = pecos_build::home::get_llvm_dir_path()?; - if llvm_dir.exists() + if pecos_build::llvm::is_valid_llvm(&llvm_dir) && let Ok(project_root) = find_project_root() && pecos_build::llvm::config::write_cargo_config(&project_root, &llvm_dir, true).is_ok() { diff --git a/crates/pecos-cli/src/cli/setup_cmd.rs b/crates/pecos-cli/src/cli/setup_cmd.rs index 0b28e1883..c0bba7f1f 100644 --- a/crates/pecos-cli/src/cli/setup_cmd.rs +++ b/crates/pecos-cli/src/cli/setup_cmd.rs @@ -254,13 +254,55 @@ fn find_repo_root() -> Option { // ── Migration ────────────────────────────────────────────────────────────── fn check_legacy_deps(mode: PromptMode) -> Result<()> { - let legacy = pecos_build::home::find_legacy_deps()?; - if legacy.is_empty() { + let legacy = pecos_build::home::find_legacy_dep_status()?; + if legacy.migratable.is_empty() && legacy.incompatible.is_empty() { + return Ok(()); + } + + if !legacy.incompatible.is_empty() { + println!("Found legacy dependencies that need manual action:"); + for dep in &legacy.incompatible { + println!(" {} at {}", dep.name, dep.old.display()); + println!(" {}", dep.reason); + if dep.name == "LLVM" { + println!(" This will not be migrated into ~/.pecos/deps/llvm-21.1/."); + println!(" Remove it before installing/configuring LLVM 21.1."); + println!(" Then install LLVM 21.1 with `pecos install llvm`, or configure"); + println!( + " an existing LLVM 21.1 install with `pecos llvm configure /path/to/llvm`." + ); + } + } + println!(); + + for dep in &legacy.incompatible { + if dep.name != "LLVM" { + continue; + } + if confirm( + &format!("Remove incompatible legacy LLVM at {}?", dep.old.display()), + true, + mode, + ) { + print!(" Removing old LLVM..."); + pecos_build::home::remove_incompatible_legacy_dep(dep)?; + println!(" done"); + } else { + println!( + " Keeping old LLVM at {}. It will not be used as LLVM 21.1.", + dep.old.display() + ); + } + } + println!(); + } + + if legacy.migratable.is_empty() { return Ok(()); } println!("Found dependencies at legacy paths:"); - for dep in &legacy { + for dep in &legacy.migratable { println!(" {} -> {}", dep.old.display(), dep.new.display()); } @@ -269,7 +311,7 @@ fn check_legacy_deps(mode: PromptMode) -> Result<()> { true, mode, ) { - for dep in &legacy { + for dep in &legacy.migratable { print!(" Moving {}...", dep.name); pecos_build::home::migrate_legacy_dep(dep)?; println!(" done"); diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index eab5089b5..ac5f7dd94 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -168,8 +168,18 @@ enum Commands { /// Migrate legacy deps from ~/.pecos/ to ~/.pecos/deps/ /// /// Moves LLVM, CUDA, and cuQuantum installations from the old top-level - /// paths into the unified deps/ directory. - Migrate, + /// paths into the unified deps/ directory. Legacy LLVM installations that + /// are not valid LLVM 21.1 installs can be removed before installing the + /// current managed LLVM. + Migrate { + /// Accept all prompts without asking + #[arg(long, conflicts_with = "no")] + yes: bool, + + /// Decline all prompts without asking + #[arg(long, conflicts_with = "yes")] + no: bool, + }, /// Install optional dependencies (cuda, llvm, cuquantum) /// /// Example: pecos install cuda cuquantum @@ -726,7 +736,16 @@ fn main() -> Result<(), Box> { }; cli::setup_cmd::run(mode, *skip_llvm, *skip_cuda, *skip_cmake, *quiet)?; } - Commands::Migrate => cli::migrate_cmd::run()?, + Commands::Migrate { yes, no } => { + let mode = if *yes { + pecos_build::prompt::PromptMode::AcceptAll + } else if *no { + pecos_build::prompt::PromptMode::DeclineAll + } else { + pecos_build::prompt::PromptMode::Interactive + }; + cli::migrate_cmd::run(mode)?; + } Commands::Install { targets, force, diff --git a/crates/pecos-llvm/src/llvm_compat.rs b/crates/pecos-llvm/src/llvm_compat.rs index 4a2f5b92c..4780dd5eb 100644 --- a/crates/pecos-llvm/src/llvm_compat.rs +++ b/crates/pecos-llvm/src/llvm_compat.rs @@ -138,7 +138,9 @@ impl<'ctx> LLModule<'ctx> { LLType::Array(t) => self.module.add_global(t, None, name), LLType::Int(t) => self.module.add_global(t, None, name), LLType::Float(t) => self.module.add_global(t, None, name), - LLType::Pointer(t) => self.module.add_global(t, None, name), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => { + self.module.add_global(t, None, name) + } LLType::Struct(t) => self.module.add_global(t, None, name), LLType::Void => panic!("Cannot create global variable of void type"), }; @@ -157,8 +159,17 @@ impl<'ctx> LLModule<'ctx> { /// Add a function declaration (mirrors llvmlite's ir.Function) pub fn add_function(&mut self, name: &str, fn_type: LLFunctionType<'ctx>) -> LLFunction<'ctx> { - let function = self.module.add_function(name, fn_type.get(), None); - LLFunction { function } + let LLFunctionType { + fn_type, + ret_pointee_type, + param_pointee_types, + } = fn_type; + let function = self.module.add_function(name, fn_type, None); + LLFunction { + function, + ret_pointee_type, + param_pointee_types, + } } } @@ -167,9 +178,11 @@ impl<'ctx> LLModule<'ctx> { // ============================================================================ /// Wrapper for LLVM function types (mirrors llvmlite's ir.FunctionType) -#[derive(Copy, Clone)] +#[derive(Clone)] pub struct LLFunctionType<'ctx> { fn_type: inkwell::types::FunctionType<'ctx>, + ret_pointee_type: Option>, + param_pointee_types: Vec>>, } impl<'ctx> LLFunctionType<'ctx> { @@ -188,12 +201,19 @@ impl<'ctx> LLFunctionType<'ctx> { } LLType::Int(t) => t.fn_type(¶ms, var_args), LLType::Float(t) => t.fn_type(¶ms, var_args), - LLType::Pointer(t) => t.fn_type(¶ms, var_args), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => t.fn_type(¶ms, var_args), LLType::Struct(t) => t.fn_type(¶ms, var_args), LLType::Array(t) => t.fn_type(¶ms, var_args), }; - Self { fn_type } + Self { + fn_type, + ret_pointee_type: return_type.pointer_pointee_type(), + param_pointee_types: param_types + .iter() + .map(LLType::pointer_pointee_type) + .collect(), + } } #[must_use] @@ -212,31 +232,114 @@ impl<'ctx> LLFunctionType<'ctx> { LLType::Void => context.void_type().fn_type(¶ms, var_args), LLType::Int(t) => t.fn_type(¶ms, var_args), LLType::Float(t) => t.fn_type(¶ms, var_args), - LLType::Pointer(t) => t.fn_type(¶ms, var_args), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => t.fn_type(¶ms, var_args), LLType::Struct(t) => t.fn_type(¶ms, var_args), LLType::Array(t) => t.fn_type(¶ms, var_args), }; - Self { fn_type } + Self { + fn_type, + ret_pointee_type: return_type.pointer_pointee_type(), + param_pointee_types: param_types + .iter() + .map(LLType::pointer_pointee_type) + .collect(), + } } #[must_use] pub fn get(&self) -> inkwell::types::FunctionType<'ctx> { self.fn_type } + + #[must_use] + pub fn ret_pointee_type(&self) -> Option> { + self.ret_pointee_type + } + + #[must_use] + pub fn param_pointee_types(&self) -> &[Option>] { + &self.param_pointee_types + } } -/// Wrapper for LLVM types that mirrors llvmlite's type hierarchy +/// Wrapper for LLVM types that mirrors llvmlite's type hierarchy. +/// +/// LLVM 21 uses opaque pointer types. `TypedPointer` carries one level of +/// pointee metadata for APIs like load and GEP that still need the element +/// type; nested pointers keep only the immediate pointer pointee. #[derive(Clone, Copy, PartialEq, Eq)] pub enum LLType<'ctx> { Void, Int(IntType<'ctx>), Float(FloatType<'ctx>), Pointer(PointerType<'ctx>), + TypedPointer(PointerType<'ctx>, LLPointeeType<'ctx>), Struct(StructType<'ctx>), Array(ArrayType<'ctx>), } +/// One-level pointee metadata for opaque LLVM pointer types. +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum LLPointeeType<'ctx> { + Int(IntType<'ctx>), + Float(FloatType<'ctx>), + Pointer(PointerType<'ctx>), + Struct(StructType<'ctx>), + Array(ArrayType<'ctx>), +} + +impl<'ctx> LLPointeeType<'ctx> { + fn from_ll_type(ty: LLType<'ctx>) -> Option { + match ty { + LLType::Void => None, + LLType::Int(t) => Some(Self::Int(t)), + LLType::Float(t) => Some(Self::Float(t)), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => Some(Self::Pointer(t)), + LLType::Struct(t) => Some(Self::Struct(t)), + LLType::Array(t) => Some(Self::Array(t)), + } + } + + fn to_ll_type(self) -> LLType<'ctx> { + match self { + Self::Int(t) => LLType::Int(t), + Self::Float(t) => LLType::Float(t), + Self::Pointer(t) => LLType::Pointer(t), + Self::Struct(t) => LLType::Struct(t), + Self::Array(t) => LLType::Array(t), + } + } +} + +impl std::hash::Hash for LLPointeeType<'_> { + fn hash(&self, state: &mut H) { + use inkwell::types::AsTypeRef; + match self { + LLPointeeType::Int(t) => { + 1u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + LLPointeeType::Float(t) => { + 2u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + LLPointeeType::Pointer(t) => { + 3u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + LLPointeeType::Struct(t) => { + 4u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + LLPointeeType::Array(t) => { + 5u8.hash(state); + (t.as_type_ref() as usize).hash(state); + } + } + } +} + fn basic_type_to_ll_type(ty: BasicTypeEnum<'_>) -> Option> { match ty { BasicTypeEnum::ArrayType(t) => Some(LLType::Array(t)), @@ -261,7 +364,9 @@ pub fn gep_result_pointee_type<'ctx>( let field_index = u32::try_from(index.get_zero_extended_constant()?).ok()?; basic_type_to_ll_type(t.get_field_type_at_index(field_index)?)? } - LLType::Int(_) | LLType::Float(_) | LLType::Pointer(_) => current, + LLType::Int(_) | LLType::Float(_) | LLType::Pointer(_) | LLType::TypedPointer(_, _) => { + current + } LLType::Void => return None, }; } @@ -288,6 +393,11 @@ impl std::hash::Hash for LLType<'_> { 3u8.hash(state); (t.as_type_ref() as usize).hash(state); } + LLType::TypedPointer(t, pointee_type) => { + 3u8.hash(state); + (t.as_type_ref() as usize).hash(state); + pointee_type.hash(state); + } LLType::Struct(t) => { 4u8.hash(state); (t.as_type_ref() as usize).hash(state); @@ -344,7 +454,7 @@ impl<'ctx> LLType<'ctx> { match element_type { LLType::Int(t) => LLType::Array(t.array_type(count)), LLType::Float(t) => LLType::Array(t.array_type(count)), - LLType::Pointer(t) => LLType::Array(t.array_type(count)), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => LLType::Array(t.array_type(count)), LLType::Struct(t) => LLType::Array(t.array_type(count)), LLType::Array(t) => LLType::Array(t.array_type(count)), LLType::Void => panic!("Cannot create array of void type"), @@ -355,15 +465,41 @@ impl<'ctx> LLType<'ctx> { #[must_use] pub fn as_pointer(&self, context: &'ctx Context) -> LLType<'ctx> { match self { - LLType::Void - | LLType::Int(_) - | LLType::Float(_) - | LLType::Struct(_) - | LLType::Array(_) => LLType::Pointer(context.ptr_type(AddressSpace::default())), - LLType::Pointer(t) => LLType::Pointer(*t), // Already a pointer + LLType::Void => LLType::Pointer(context.ptr_type(AddressSpace::default())), + LLType::Int(_) | LLType::Float(_) | LLType::Struct(_) | LLType::Array(_) => { + let ptr_type = context.ptr_type(AddressSpace::default()); + LLType::TypedPointer( + ptr_type, + LLPointeeType::from_ll_type(*self).expect("non-void pointee"), + ) + } + LLType::Pointer(t) | LLType::TypedPointer(t, _) => { + LLType::TypedPointer(*t, LLPointeeType::Pointer(*t)) + } + } + } + + /// Return tracked pointee metadata for typed opaque pointer types. + #[must_use] + pub fn pointer_pointee_type(&self) -> Option> { + match self { + LLType::TypedPointer(_, pointee_type) => Some(pointee_type.to_ll_type()), + _ => None, } } + /// Build an opaque pointer type with explicit one-level pointee metadata. + #[must_use] + pub fn typed_pointer( + pointer_type: PointerType<'ctx>, + pointee_type: LLType<'ctx>, + ) -> Option { + Some(LLType::TypedPointer( + pointer_type, + LLPointeeType::from_ll_type(pointee_type)?, + )) + } + /// Get the underlying inkwell type for function signatures #[must_use] pub fn to_basic_metadata_type(&self) -> Option> { @@ -371,7 +507,7 @@ impl<'ctx> LLType<'ctx> { LLType::Void => None, LLType::Int(t) => Some((*t).into()), LLType::Float(t) => Some((*t).into()), - LLType::Pointer(t) => Some((*t).into()), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => Some((*t).into()), LLType::Struct(t) => Some((*t).into()), LLType::Array(t) => Some((*t).into()), } @@ -390,7 +526,7 @@ impl<'ctx> LLType<'ctx> { #[must_use] pub fn as_pointer_type(&self) -> PointerType<'ctx> { match self { - LLType::Pointer(t) => *t, + LLType::Pointer(t) | LLType::TypedPointer(t, _) => *t, _ => panic!("Expected pointer type"), } } @@ -502,8 +638,11 @@ impl<'ctx> LLValue<'ctx> { // ============================================================================ /// Wrapper around inkwell's `FunctionValue` that mirrors llvmlite's ir.Function +#[derive(Clone)] pub struct LLFunction<'ctx> { function: FunctionValue<'ctx>, + ret_pointee_type: Option>, + param_pointee_types: Vec>>, } impl<'ctx> LLFunction<'ctx> { @@ -522,14 +661,19 @@ impl<'ctx> LLFunction<'ctx> { LLType::Void => module.context().void_type().fn_type(¶m_types, false), LLType::Int(t) => t.fn_type(¶m_types, false), LLType::Float(t) => t.fn_type(¶m_types, false), - LLType::Pointer(t) => t.fn_type(¶m_types, false), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => t.fn_type(¶m_types, false), LLType::Struct(t) => t.fn_type(¶m_types, false), LLType::Array(t) => t.fn_type(¶m_types, false), }; let function = module.get_mut().add_function(name, fn_type, None); + let ret_pointee_type = return_type.pointer_pointee_type(); - Self { function } + Self { + function, + ret_pointee_type, + param_pointee_types: arg_types.iter().map(LLType::pointer_pointee_type).collect(), + } } #[must_use] @@ -537,6 +681,34 @@ impl<'ctx> LLFunction<'ctx> { self.function } + #[must_use] + pub fn ret_pointee_type(&self) -> Option> { + self.ret_pointee_type + } + + #[must_use] + pub fn param_pointee_type(&self, index: usize) -> Option> { + self.param_pointee_types.get(index).copied().flatten() + } + + /// Return function parameters with tracked pointer metadata where known. + #[must_use] + pub fn args(&self) -> Vec> { + self.function + .get_param_iter() + .enumerate() + .map(|(index, param)| match param { + BasicValueEnum::IntValue(v) => LLValue::Int(v), + BasicValueEnum::FloatValue(v) => LLValue::Float(v), + BasicValueEnum::PointerValue(v) => { + LLValue::Pointer(LLPointerValue::new(v, self.param_pointee_type(index))) + } + BasicValueEnum::ArrayValue(v) => LLValue::Array(v), + _ => panic!("Unsupported parameter type"), + }) + .collect() + } + /// Append a basic block to this function (mirrors llvmlite's `func.append_basic_block`) #[must_use] pub fn append_basic_block(&self, context: &'ctx Context, name: &str) -> BasicBlock<'ctx> { @@ -548,6 +720,38 @@ impl<'ctx> LLFunction<'ctx> { // IRBuilder wrapper // ============================================================================ +pub struct LLCallable<'ctx> { + function: FunctionValue<'ctx>, + ret_pointee_type: Option>, +} + +impl<'ctx> From> for LLCallable<'ctx> { + fn from(function: FunctionValue<'ctx>) -> Self { + Self { + function, + ret_pointee_type: None, + } + } +} + +impl<'ctx> From> for LLCallable<'ctx> { + fn from(function: LLFunction<'ctx>) -> Self { + Self { + function: function.get(), + ret_pointee_type: function.ret_pointee_type(), + } + } +} + +impl<'ctx> From<&LLFunction<'ctx>> for LLCallable<'ctx> { + fn from(function: &LLFunction<'ctx>) -> Self { + Self { + function: function.get(), + ret_pointee_type: function.ret_pointee_type(), + } + } +} + /// Wrapper around inkwell's Builder that mirrors llvmlite's ir.IRBuilder pub struct LLIRBuilder<'ctx> { builder: Builder<'ctx>, @@ -743,22 +947,28 @@ impl<'ctx> LLIRBuilder<'ctx> { // Function calls // ======================================================================== - pub fn call( + pub fn call( &self, - function: FunctionValue<'ctx>, + function: F, args: &[LLValue<'ctx>], name: &str, - ) -> LLResult>> { + ) -> LLResult>> + where + F: Into>, + { + let function = function.into(); let arg_values: Vec<_> = args.iter().map(|v| v.to_basic_value().into()).collect(); let call_site = self .builder - .build_call(function, &arg_values, name) + .build_call(function.function, &arg_values, name) .map_err(|e| PecosError::Generic(format!("Failed to build call: {e}")))?; Ok(call_site.try_as_basic_value().basic().map(|v| match v { BasicValueEnum::IntValue(i) => LLValue::Int(i), - BasicValueEnum::PointerValue(p) => LLValue::Pointer(LLPointerValue::new(p, None)), + BasicValueEnum::PointerValue(p) => { + LLValue::Pointer(LLPointerValue::new(p, function.ret_pointee_type)) + } _ => panic!("Unsupported return value type"), })) } @@ -998,7 +1208,9 @@ impl LLConstant { match ll_type { LLType::Int(t) => Ok(LLValue::Int(t.const_zero())), LLType::Float(t) => Ok(LLValue::Float(t.const_zero())), - LLType::Pointer(t) => Ok(LLValue::Pointer(LLPointerValue::new(t.const_zero(), None))), + LLType::Pointer(t) | LLType::TypedPointer(t, _) => Ok(LLValue::Pointer( + LLPointerValue::new(t.const_zero(), ll_type.pointer_pointee_type()), + )), LLType::Array(t) => Ok(LLValue::Array(t.const_zero())), LLType::Void | LLType::Struct(_) => Err(PecosError::Generic( "Cannot create a zero constant for void/struct type".to_string(), @@ -1006,3 +1218,108 @@ impl LLConstant { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn call_preserves_pointer_return_pointee_type_for_load() { + let context = Context::create(); + let mut module = LLModule::new(&context, "call_pointer_return"); + let i32_type = LLType::int(&context, 32); + let i32_ptr_type = i32_type.as_pointer(&context); + + let callee_type = LLFunctionType::new(i32_ptr_type, &[], false); + let callee = module.add_function("get_i32_ptr", callee_type); + + let caller_type = LLFunctionType::new_with_context(&context, LLType::Void, &[], false); + let caller = module.add_function("caller", caller_type); + let block = caller.append_basic_block(&context, "entry"); + let builder = LLIRBuilder::new(&context, block); + + let ptr = builder + .call(&callee, &[], "ptr") + .expect("call should build") + .expect("callee returns a pointer"); + let loaded = builder + .load(ptr, "loaded") + .expect("call result should keep pointee metadata"); + assert!(matches!(loaded, LLValue::Int(_))); + builder.ret_void().expect("return should build"); + } + + #[test] + fn raw_function_value_call_has_unknown_pointer_pointee_type() { + let context = Context::create(); + let mut module = LLModule::new(&context, "raw_call_pointer_return"); + let i32_type = LLType::int(&context, 32); + let i32_ptr_type = i32_type.as_pointer(&context); + + let callee_type = LLFunctionType::new(i32_ptr_type, &[], false); + let callee = module.add_function("get_i32_ptr", callee_type); + + let caller_type = LLFunctionType::new_with_context(&context, LLType::Void, &[], false); + let caller = module.add_function("caller", caller_type); + let block = caller.append_basic_block(&context, "entry"); + let builder = LLIRBuilder::new(&context, block); + + let ptr = builder + .call(callee.get(), &[], "ptr") + .expect("call should build") + .expect("callee returns a pointer"); + let Err(err) = builder.load(ptr, "loaded") else { + panic!("raw FunctionValue should not carry pointee metadata"); + }; + assert!(err.to_string().contains("pointer pointee type is unknown")); + builder.ret_void().expect("return should build"); + } + + #[test] + fn as_pointer_on_typed_pointer_models_pointer_to_pointer() { + let context = Context::create(); + let mut module = LLModule::new(&context, "double_pointer_return"); + let i32_type = LLType::int(&context, 32); + let i32_ptr_type = i32_type.as_pointer(&context); + let i32_ptr_ptr_type = i32_ptr_type.as_pointer(&context); + + let callee_type = LLFunctionType::new(i32_ptr_ptr_type, &[], false); + let callee = module.add_function("get_i32_ptr_ptr", callee_type); + + let caller_type = LLFunctionType::new_with_context(&context, LLType::Void, &[], false); + let caller = module.add_function("caller", caller_type); + let block = caller.append_basic_block(&context, "entry"); + let builder = LLIRBuilder::new(&context, block); + + let ptr_to_ptr = builder + .call(&callee, &[], "ptr_to_ptr") + .expect("call should build") + .expect("callee returns a pointer"); + let loaded_ptr = builder + .load(ptr_to_ptr, "loaded_ptr") + .expect("double pointer load should load a pointer"); + assert!(matches!(loaded_ptr, LLValue::Pointer(_))); + builder.ret_void().expect("return should build"); + } + + #[test] + fn function_args_preserve_pointer_pointee_type_for_load() { + let context = Context::create(); + let mut module = LLModule::new(&context, "pointer_arg"); + let i32_type = LLType::int(&context, 32); + let i32_ptr_type = i32_type.as_pointer(&context); + + let function_type = + LLFunctionType::new_with_context(&context, LLType::Void, &[i32_ptr_type], false); + let function = module.add_function("takes_i32_ptr", function_type); + let block = function.append_basic_block(&context, "entry"); + let builder = LLIRBuilder::new(&context, block); + + let args = function.args(); + let loaded = builder + .load(args[0], "loaded") + .expect("pointer arg should keep pointee metadata"); + assert!(matches!(loaded, LLValue::Int(_))); + builder.ret_void().expect("return should build"); + } +} diff --git a/docs/development/dev-tools.md b/docs/development/dev-tools.md index 9c11440af..1be1a64f5 100644 --- a/docs/development/dev-tools.md +++ b/docs/development/dev-tools.md @@ -149,6 +149,9 @@ pecos llvm configure /path/to/llvm ``` Updates `.cargo/config.toml` with the correct `LLVM_SYS_211_PREFIX` environment variable. +Explicit paths are canonicalized, so configuring a symlink records the resolved +LLVM directory. Re-run `pecos llvm configure /path/to/llvm` after repointing the +symlink. ### Find LLVM Path diff --git a/docs/user-guide/llvm-setup.md b/docs/user-guide/llvm-setup.md index f2e0f0747..32de5edcc 100644 --- a/docs/user-guide/llvm-setup.md +++ b/docs/user-guide/llvm-setup.md @@ -166,6 +166,10 @@ cargo run -p pecos-cli -- llvm configure /path/to/llvm This updates `.cargo/config.toml` with the LLVM path. +Explicit paths are canonicalized before being written. If `/path/to/llvm` is a +symlink, PECOS writes the resolved LLVM directory into `.cargo/config.toml`; run +`pecos llvm configure /path/to/llvm` again after repointing that symlink. + ### `check` Verify LLVM 21.1 is available: diff --git a/python/pecos-rslib-llvm/src/llvm_bindings.rs b/python/pecos-rslib-llvm/src/llvm_bindings.rs index 45b736379..fcc3877c7 100644 --- a/python/pecos-rslib-llvm/src/llvm_bindings.rs +++ b/python/pecos-rslib-llvm/src/llvm_bindings.rs @@ -1487,7 +1487,9 @@ impl PyLLValue { }; // Get the pointer type from PyPointerType - let LLType::Pointer(target_ptr_type) = ptr_type.ll_type else { + let (LLType::Pointer(target_ptr_type) | LLType::TypedPointer(target_ptr_type, _)) = + ptr_type.ll_type + else { return Err(PyRuntimeError::new_err("Target must be a pointer type")); }; @@ -1513,11 +1515,20 @@ impl PyLLValue { ll_type: LLType::Float(v.get_type()), context_ptr: self.context_ptr, }), - LLValue::Pointer(v) => PyAnyType::Pointer(PyPointerType { - ll_type: LLType::Pointer(v.value().get_type()), - pointee_type: v.pointee_type(), - context_ptr: self.context_ptr, - }), + LLValue::Pointer(v) => { + let pointee_type = v.pointee_type(); + let ll_type = pointee_type + .and_then(|pointee_type| { + LLType::typed_pointer(v.value().get_type(), pointee_type) + }) + .unwrap_or_else(|| LLType::Pointer(v.value().get_type())); + + PyAnyType::Pointer(PyPointerType { + ll_type, + pointee_type, + context_ptr: self.context_ptr, + }) + } LLValue::Array(v) => PyAnyType::Array(PyArrayType { ll_type: LLType::Array(v.get_type()), context_ptr: self.context_ptr, diff --git a/python/pecos-rslib/tests/test_llvm_comprehensive.py b/python/pecos-rslib/tests/test_llvm_comprehensive.py index c255ad6d8..7d96c280b 100644 --- a/python/pecos-rslib/tests/test_llvm_comprehensive.py +++ b/python/pecos-rslib/tests/test_llvm_comprehensive.py @@ -37,6 +37,20 @@ def test_pointer_types(qir_module) -> None: _ = qubit_ptr, result_ptr +def test_inttoptr_accepts_typed_pointer_targets(qir_module) -> None: + from pecos_rslib_llvm import ir + + module, ctx = qir_module + + i64 = ctx.int_type(64) + qubit_ty = module.context.get_identified_type("Qubit") + qubit_ptr = qubit_ty.as_pointer() + + ptr = ir.Constant(i64, 0).inttoptr(qubit_ptr) + + assert ptr.type == qubit_ptr + + def test_array_types(qir_module) -> None: _, ctx = qir_module From 1cd1e2f6aae4bbc5ae9fdc40f1e111a27b7f50e7 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 31 May 2026 23:20:11 -0600 Subject: [PATCH 034/388] Speed up exact branch DEM replay --- .../fault_tolerance/dem_builder/builder.rs | 183 +++++++++++++----- 1 file changed, 139 insertions(+), 44 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index c5f0175d7..20428e3e7 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -26,7 +26,9 @@ use pecos_core::BitSet; use pecos_core::gate_type::GateType; use pecos_simulators::symbolic_sparse_stab::MeasurementHistory; use smallvec::SmallVec; +use std::cell::RefCell; use std::collections::BTreeMap; +use std::rc::Rc; // ============================================================================ // JSON Parsing Types @@ -116,6 +118,10 @@ pub struct DemBuilder<'a> { measurement_order: Option>, /// Optional circuit context for future exact replacement-branch replay. exact_branch_context: Option>, + /// Ideal symbolic measurement history shared by exact branch replays. + exact_ideal_history_cache: RefCell>>, + /// Per-gate cache for exact replacement-branch replay effects. + exact_branch_cache: RefCell>, } #[derive(Debug, Clone, Copy)] @@ -123,6 +129,12 @@ struct ExactBranchReplayContext<'a> { circuit: &'a pecos_quantum::DagCircuit, } +#[derive(Debug, Clone)] +struct ExactBranchReplayAnalysis { + base_effect: FaultMechanism, + branch_effects: [[FaultMechanism; 4]; 4], +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct ExactBranchReplayRequest { gate_node: usize, @@ -273,9 +285,15 @@ impl<'a> DemBuilder<'a> { num_measurements: influence_map.measurements.len(), measurement_order: None, exact_branch_context: None, + exact_ideal_history_cache: RefCell::new(None), + exact_branch_cache: RefCell::new(BTreeMap::new()), } } + fn clear_exact_branch_cache(&mut self) { + self.exact_branch_cache.get_mut().clear(); + } + /// Sets the noise configuration from individual parameters. #[must_use] pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { @@ -296,6 +314,8 @@ impl<'a> DemBuilder<'a> { circuit: &'a pecos_quantum::DagCircuit, ) -> Self { self.exact_branch_context = Some(ExactBranchReplayContext { circuit }); + self.exact_ideal_history_cache.get_mut().take(); + self.clear_exact_branch_cache(); self } @@ -435,6 +455,7 @@ impl<'a> DemBuilder<'a> { #[must_use] pub fn with_num_measurements(mut self, num: usize) -> Self { self.num_measurements = num; + self.clear_exact_branch_cache(); self } @@ -459,6 +480,7 @@ impl<'a> DemBuilder<'a> { #[must_use] pub fn with_measurement_order(mut self, order: Vec) -> Self { self.measurement_order = Some(order); + self.clear_exact_branch_cache(); self } @@ -479,6 +501,7 @@ impl<'a> DemBuilder<'a> { /// Returns an error if the JSON is malformed. pub fn with_detectors_json(mut self, json: &str) -> Result { self.detectors = parse_detectors_json(json)?; + self.clear_exact_branch_cache(); Ok(self) } @@ -494,6 +517,7 @@ impl<'a> DemBuilder<'a> { /// Returns an error if the JSON is malformed. pub fn with_observables_json(mut self, json: &str) -> Result { self.observables = parse_observables_json(json)?; + self.clear_exact_branch_cache(); Ok(self) } @@ -510,6 +534,7 @@ impl<'a> DemBuilder<'a> { meas_ids: Vec::new(), }) .collect(); + self.clear_exact_branch_cache(); self } @@ -809,10 +834,6 @@ impl<'a> DemBuilder<'a> { .as_ref() .expect("replacement entries exist"); for request in &requests { - let _branch_locs = context - .omitted_branch_location_pair(*request, &self.influence_map.locations)?; - let _omitted_effect = - self.exact_omitted_branch_base_effect(context, *request)?; for (replacement_pauli, _weight) in weights.replacement_entries() { let label = two_qubit_label_for_replay(replacement_pauli)?; let _replacement_effect = @@ -829,18 +850,38 @@ impl<'a> DemBuilder<'a> { Ok(()) } + #[cfg(test)] fn exact_omitted_branch_base_effect( &self, context: ExactBranchReplayContext<'_>, request: ExactBranchReplayRequest, ) -> Result { + let branch = circuit_with_omitted_two_qubit_gate(context.circuit, request.gate_node)?; + self.exact_omitted_branch_base_effect_for_branch(context, request, &branch) + } + + fn exact_omitted_branch_base_effect_for_branch( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + branch: &pecos_quantum::DagCircuit, + ) -> Result { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + + let ideal_history = self.exact_ideal_measurement_history(context); + let branch_info = InfluenceBuilder::new(branch).run_symbolic_simulation(); let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); for detector in &self.detectors { let indices = self.measurement_indices_from_refs(&detector.records, &detector.meas_ids)?; - if context.omitted_branch_flips_measurement_parity(request, &indices)? { + if omitted_branch_flips_measurement_parity_from_histories( + request, + ideal_history.as_ref(), + &branch_info.history, + &indices, + )? { xor_toggle_4(&mut triggered_dets, detector.id); } } @@ -848,7 +889,12 @@ impl<'a> DemBuilder<'a> { for observable in &self.observables { let indices = self.measurement_indices_from_refs(&observable.records, &observable.meas_ids)?; - if context.omitted_branch_flips_measurement_parity(request, &indices)? { + if omitted_branch_flips_measurement_parity_from_histories( + request, + ideal_history.as_ref(), + &branch_info.history, + &indices, + )? { xor_toggle_2(&mut triggered_obs, observable.id); } } @@ -862,25 +908,44 @@ impl<'a> DemBuilder<'a> { )) } - fn exact_replacement_branch_effect( + fn exact_ideal_measurement_history( + &self, + context: ExactBranchReplayContext<'_>, + ) -> Rc { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + + if let Some(cached) = self.exact_ideal_history_cache.borrow().as_ref().cloned() { + return cached; + } + + let history = Rc::new( + InfluenceBuilder::new(context.circuit) + .run_symbolic_simulation() + .history, + ); + *self.exact_ideal_history_cache.borrow_mut() = Some(history.clone()); + history + } + + fn exact_branch_analysis( &self, context: ExactBranchReplayContext<'_>, request: ExactBranchReplayRequest, - replacement_pauli_label: &str, - ) -> Result { + ) -> Result { use crate::fault_tolerance::propagator::DagFaultAnalyzer; - let (p1, p2) = two_qubit_label_to_pauli_indices(replacement_pauli_label).ok_or_else(|| { - DemBuilderError::ConfigurationError(format!( - "exact_branch_replay replacement Pauli label {replacement_pauli_label:?} is not a two-qubit Pauli label" - )) - })?; - let base_effect = self.exact_omitted_branch_base_effect(context, request)?; - if p1 == 0 && p2 == 0 { - return Ok(base_effect); + if let Some(cached) = self + .exact_branch_cache + .borrow() + .get(&request.gate_node) + .cloned() + { + return Ok(cached); } let branch = circuit_with_omitted_two_qubit_gate(context.circuit, request.gate_node)?; + let base_effect = + self.exact_omitted_branch_base_effect_for_branch(context, request, &branch)?; let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); let branch_locs = identity_location_pair_for_request( request, @@ -895,7 +960,35 @@ impl<'a> DemBuilder<'a> { &meas_to_detectors, &meas_to_observables, ); - Ok(base_effect.xor(&branch_effects[p1 as usize][p2 as usize])) + let analysis = ExactBranchReplayAnalysis { + base_effect, + branch_effects, + }; + self.exact_branch_cache + .borrow_mut() + .insert(request.gate_node, analysis.clone()); + Ok(analysis) + } + + fn exact_replacement_branch_effect( + &self, + context: ExactBranchReplayContext<'_>, + request: ExactBranchReplayRequest, + replacement_pauli_label: &str, + ) -> Result { + let (p1, p2) = two_qubit_label_to_pauli_indices(replacement_pauli_label).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay replacement Pauli label {replacement_pauli_label:?} is not a two-qubit Pauli label" + )) + })?; + let analysis = self.exact_branch_analysis(context, request)?; + if p1 == 0 && p2 == 0 { + return Ok(analysis.base_effect); + } + + Ok(analysis + .base_effect + .xor(&analysis.branch_effects[p1 as usize][p2 as usize])) } fn num_influence_dem_outputs(&self) -> usize { @@ -2259,10 +2352,12 @@ impl ExactBranchReplayContext<'_> { loc1.node, loc1.gate_type, loc2.gate_type ))); } - let branch = circuit_with_omitted_two_qubit_gate(self.circuit, loc1.node)?; - let replacement = branch - .gate(loc1.node) - .expect("omitted branch preserves the original gate node"); + let replacement = self.circuit.gate(loc1.node).ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "exact_branch_replay expected an original gate at node {}", + loc1.node + )) + })?; if !loc1 .qubits .iter() @@ -2283,6 +2378,7 @@ impl ExactBranchReplayContext<'_> { Ok(requests) } + #[cfg(test)] fn omitted_branch_location_pair( &self, request: ExactBranchReplayRequest, @@ -2294,30 +2390,23 @@ impl ExactBranchReplayContext<'_> { let branch_map = DagFaultAnalyzer::new(&branch).build_influence_map(); identity_location_pair_for_request(request, original_locations, &branch_map.locations) } +} - fn omitted_branch_flips_measurement_parity( - &self, - request: ExactBranchReplayRequest, - measurement_indices: &[usize], - ) -> Result { - use crate::fault_tolerance::influence_builder::InfluenceBuilder; - - let ideal_info = InfluenceBuilder::new(self.circuit).run_symbolic_simulation(); - let branch = circuit_with_omitted_two_qubit_gate(self.circuit, request.gate_node)?; - let branch_info = InfluenceBuilder::new(&branch).run_symbolic_simulation(); - - let ideal = - measurement_parity_expression(&ideal_info.history, measurement_indices, "ideal")?; - let branch = - measurement_parity_expression(&branch_info.history, measurement_indices, "branch")?; - if ideal.dependencies != branch.dependencies { - return Err(DemBuilderError::ConfigurationError(format!( - "exact_branch_replay omitted gate at node {} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", - request.gate_node, measurement_indices - ))); - } - Ok(ideal.flip ^ branch.flip) +fn omitted_branch_flips_measurement_parity_from_histories( + request: ExactBranchReplayRequest, + ideal_history: &MeasurementHistory, + branch_history: &MeasurementHistory, + measurement_indices: &[usize], +) -> Result { + let ideal = measurement_parity_expression(ideal_history, measurement_indices, "ideal")?; + let branch = measurement_parity_expression(branch_history, measurement_indices, "branch")?; + if ideal.dependencies != branch.dependencies { + return Err(DemBuilderError::ConfigurationError(format!( + "exact_branch_replay omitted gate at node {} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", + request.gate_node, measurement_indices + ))); } + Ok(ideal.flip ^ branch.flip) } fn measurement_parity_expression( @@ -3570,6 +3659,8 @@ mod tests { .exact_replacement_branch_effect(context, request, "II") .expect("omission-only branch should be deterministic here"); assert_eq!(omitted_only.detectors.as_slice(), &[0]); + assert!(builder.exact_ideal_history_cache.borrow().is_some()); + assert_eq!(builder.exact_branch_cache.borrow().len(), 1); let omitted_then_target_x = builder .exact_replacement_branch_effect(context, request, "IX") @@ -3578,6 +3669,10 @@ mod tests { omitted_then_target_x.is_empty(), "target X after omitted CX restores the ideal target measurement" ); + assert_eq!(builder.exact_branch_cache.borrow().len(), 1); + + let builder = builder.with_detectors_json("[]").unwrap(); + assert!(builder.exact_branch_cache.borrow().is_empty()); } #[test] From fa89af225b683a02190d58372b9774d1951ab427 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 1 Jun 2026 10:28:26 -0600 Subject: [PATCH 035/388] Update tket and LLVM 21 QIR tests --- Cargo.lock | 242 ++-- Cargo.toml | 4 +- crates/pecos-hugr-qis/src/compiler.rs | 9 +- .../ast_guppy/test_tier2_semantic.py | 26 +- .../pecos/unit/slr/test_basic_permutation.py | 9 +- .../unit/slr/test_complex_permutation.py | 2 +- .../pecos/unit/slr/test_creg_permutation.py | 4 +- .../unit/slr/test_measurement_permutation.py | 2 +- .../unit/slr/test_register_permutation.py | 4 +- uv.lock | 1209 +++++++++-------- 10 files changed, 819 insertions(+), 692 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c607f580a..2286043f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -306,15 +306,15 @@ dependencies = [ [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -322,9 +322,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -373,7 +373,7 @@ dependencies = [ "pecos-stab-tn", "quizx", "rand 0.10.1", - "rand_xoshiro 0.8.0", + "rand_xoshiro 0.8.1", "rapidhash", "wide", ] @@ -387,14 +387,14 @@ dependencies = [ "bitflags 2.11.1", "cexpr", "clang-sys", - "itertools 0.10.5", + "itertools 0.13.0", "log", "prettyplease", "proc-macro2", "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn 2.0.117", ] @@ -560,9 +560,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" dependencies = [ "allocator-api2", ] @@ -658,14 +658,14 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -853,7 +853,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width 0.2.2", ] [[package]] @@ -1212,9 +1212,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -1508,7 +1508,7 @@ checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -1565,9 +1565,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1618,9 +1618,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "embedded-io" @@ -1725,9 +1725,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.28" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", @@ -1935,9 +1935,9 @@ checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" @@ -2162,6 +2162,7 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", + "rayon", ] [[package]] @@ -2269,9 +2270,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -2408,9 +2409,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2753,9 +2754,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -2766,9 +2767,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.24" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -2845,9 +2846,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -2917,9 +2918,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libbz2-rs-sys" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" @@ -2955,9 +2956,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -3020,9 +3021,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "logos" @@ -3100,9 +3101,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memfd" @@ -3137,9 +3138,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -3390,9 +3391,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -3641,9 +3642,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" [[package]] name = "pbr" @@ -3750,7 +3751,7 @@ dependencies = [ "pecos-random", "rand 0.10.1", "rand_core 0.10.1", - "rand_xoshiro 0.8.0", + "rand_xoshiro 0.8.1", "serde", "serde_json", "smallvec", @@ -4227,7 +4228,7 @@ version = "0.2.0-dev.0" dependencies = [ "rand 0.10.1", "rand_core 0.10.1", - "rand_xoshiro 0.8.0", + "rand_xoshiro 0.8.1", "random_tester", "rapidhash", "wide", @@ -5188,9 +5189,9 @@ dependencies = [ [[package]] name = "rand_xoshiro" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f0b2cc7bfeef8f0320ca45f88b00157a03c67137022d59393614352d6bf4312" +checksum = "662effc7698e08ea324d3acccf8d9d7f7bf79b9785e270a174ea36e56900c91d" dependencies = [ "rand_core 0.10.1", ] @@ -5386,9 +5387,9 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "relay-bp" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d6c034c28950af24584860eb93e72398e49de4ce06ac0ba7a43c65c619568ea" +checksum = "1aad515fe7adde3d2c5508efb582dac0e34f1eb7a30631560d38846d2657b8d4" dependencies = [ "dyn-clone", "env_logger", @@ -5415,7 +5416,7 @@ dependencies = [ "derive_more 0.99.20", "fxhash", "itertools 0.13.0", - "petgraph 0.6.5", + "petgraph 0.8.3", "serde", "slotmap_fork_lmondada", "thiserror 1.0.69", @@ -5429,9 +5430,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -5686,7 +5687,7 @@ checksum = "aaeee6f84153fd6f62507fc22bfe9499c8485075b44186dcbb918166ef75116f" dependencies = [ "fixedbitset 0.5.7", "foldhash 0.1.5", - "hashbrown 0.14.5", + "hashbrown 0.15.5", "indexmap 2.14.0", "ndarray 0.16.1", "num-traits", @@ -5924,9 +5925,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -6030,6 +6031,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simba" version = "0.10.0" @@ -6136,9 +6143,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -6337,9 +6344,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -6531,9 +6538,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tket" -version = "0.18.0" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "640b38c8fcfc7a6232b0f3afa9a9685a26e35c08d80faa2a264c69c219359181" +checksum = "a56460bb4f82313dd11c188979a4c3f2eb74b98fb6ba2d60fef0d44e0eff8b4b" dependencies = [ "anyhow", "ascent", @@ -6585,9 +6592,9 @@ dependencies = [ [[package]] name = "tket-qsystem" -version = "0.24.0" +version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04aecdc44b24c95bba3ba49cb9a022d199fa0aa22dc015519a1abbb77e5e037e" +checksum = "d9b3899fb263ffd2b0990a1afc7c97fe06fc3045fcc1cb61d39bf19bd69378ad" dependencies = [ "anyhow", "delegate", @@ -6655,9 +6662,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime", @@ -6698,9 +6705,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.10" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags 2.11.1", "bytes", @@ -6783,15 +6790,15 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" -version = "0.2.21" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2212c8a9b9bcfca32024de14998494cf9a5dfa59ea1b829de98bac374b86bf" +checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" dependencies = [ "erased-serde", "inventory", @@ -6802,9 +6809,9 @@ dependencies = [ [[package]] name = "typetag-impl" -version = "0.2.21" +version = "0.2.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27a7a9b72ba121f6f1f6c3632b85604cac41aedb5ddc70accbebb6cac83de846" +checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" dependencies = [ "proc-macro2", "quote", @@ -6903,9 +6910,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6985,9 +6992,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -6998,9 +7005,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.71" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -7008,9 +7015,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7018,9 +7025,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -7031,9 +7038,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.121" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -7058,6 +7065,16 @@ dependencies = [ "wasmparser 0.248.0", ] +[[package]] +name = "wasm-encoder" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" +dependencies = [ + "leb128fmt", + "wasmparser 0.251.0", +] + [[package]] name = "wasm-metadata" version = "0.244.0" @@ -7095,6 +7112,17 @@ dependencies = [ "serde", ] +[[package]] +name = "wasmparser" +version = "0.251.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" +dependencies = [ + "bitflags 2.11.1", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "wasmprinter" version = "0.248.0" @@ -7274,22 +7302,22 @@ dependencies = [ [[package]] name = "wast" -version = "248.0.0" +version = "251.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" +checksum = "5cc7467dda0a96142eb2c980329dfb62480b1e1d3622fdeb1a44e2bca6ceed74" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width 0.2.2", - "wasm-encoder 0.248.0", + "wasm-encoder 0.251.0", ] [[package]] name = "wat" -version = "1.248.0" +version = "1.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" +checksum = "81b1086c9e85b95bd6a229a928bc6c6d0662e42af0250c88d067b418831ea4d4" dependencies = [ "wast", ] @@ -7314,9 +7342,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.98" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -7818,9 +7846,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -7990,18 +8018,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -8010,9 +8038,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] diff --git a/Cargo.toml b/Cargo.toml index 9be85f048..40c106469 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,8 +68,8 @@ libloading = "0.9" inkwell = { version = "0.9", features = ["llvm21-1-prefer-dynamic"] } # --- HUGR / tket --- -tket = { version = "0.18", default-features = false } -tket-qsystem = { version = "0.24", default-features = false } +tket = { version = "0.19", default-features = false } +tket-qsystem = { version = "0.25", default-features = false } # --- WebAssembly --- wasmtime = { version = "45", default-features = false, features = [ diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index e1348c085..7d1ddda66 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -54,19 +54,22 @@ use tket::hugr::ops::DataflowParent; use tket::hugr::{Hugr, HugrView, Node}; use tket::llvm::rotation::RotationCodegenExtension; use tket::passes::ComposablePass; -use tket_qsystem::QSystemPass; use tket_qsystem::llvm::array_utils::ArrayLowering; use tket_qsystem::llvm::futures::FuturesCodegenExtension; use tket_qsystem::llvm::{ debug::DebugCodegenExtension, prelude::QISPreludeCodegen, qsystem::QSystemCodegenExtension, random::RandomCodegenExtension, result::ResultsCodegenExtension, utils::UtilsCodegenExtension, }; +use tket_qsystem::{QSystemPass, QSystemPlatform}; // Import read_hugr_envelope from utils module use crate::utils::read_hugr_envelope; const LLVM_MAIN: &str = "qmain"; const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; +// PECOS targets the Selene Helios QIS runtime; keep the qsystem lowering +// platform explicit so new tket-qsystem platforms do not silently change codegen. +const QSYSTEM_PLATFORM: QSystemPlatform = QSystemPlatform::Helios; // Extension registry is defined in the parent module @@ -102,7 +105,7 @@ impl Default for CompileArgs { /// Note: `QSystemPass` internally calls `inline_constant_functions` when the /// `llvm` feature is enabled, so we don't need to call it separately. fn process_hugr(hugr: &mut Hugr) -> Result<()> { - QSystemPass::default().run(hugr)?; + QSystemPass::defaults(QSYSTEM_PLATFORM).run(hugr)?; Ok(()) } @@ -121,7 +124,7 @@ fn codegen_extensions() -> CodegenExtsMap<'static, Hugr> { .add_default_static_array_extensions() .add_default_borrow_array_extensions(pcg.clone()) .add_extension(FuturesCodegenExtension) - .add_extension(QSystemCodegenExtension::from(pcg.clone())) + .add_extension(QSystemCodegenExtension::new(QSYSTEM_PLATFORM, pcg.clone())) .add_extension(RandomCodegenExtension) .add_extension(ResultsCodegenExtension::new( SeleneHeapArrayCodegen::LOWERING, diff --git a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py index f24209932..bad58eb12 100644 --- a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py +++ b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py @@ -124,6 +124,15 @@ def _case(label: str) -> Main: raise KeyError(msg) +def _creg_storage_operand(size: int, name: str) -> str: + """Match LLVM 21 opaque `ptr %c` and legacy typed `[N x i1]* %c` IR.""" + return rf"(?:ptr|\[{size} x i1\]\*) %{re.escape(name)}" + + +def _creg_gep_pattern(size: int, name: str, index: int) -> str: + return rf"getelementptr \[{size} x i1\], {_creg_storage_operand(size, name)}, i64 0, i64 {index}" + + # --- extra programs not in the corpus (plan amendment 8 coverage) --- @@ -188,8 +197,9 @@ def _layer_b_structural(prog: Main, label: str, *, creg_sizes: dict[str, int]) - for name, size in creg_sizes.items(): assert f"%{name} = alloca [{size} x i1]" in ir, f"{label}: missing entry-block alloca for CReg {name!r}" - assert ( - f"store [{size} x i1] zeroinitializer, [{size} x i1]* %{name}" in ir + assert re.search( + rf"store \[{size} x i1\] zeroinitializer, {_creg_storage_operand(size, name)}", + ir, ), f"{label}: missing zeroinitializer for CReg {name!r} (unset bits must read 0, not undef)" _assert_mz_rr_pairing(label, ir) @@ -210,10 +220,7 @@ def _assert_set_int_unpack(label: str, ir: str, *, name: str, size: int, value: """ for i in range(size): bit = (value >> i) & 1 - pat = ( - rf"getelementptr \[{size} x i1\], \[{size} x i1\]\* %{name}, " - rf"i64 0, i64 {i}\n\s*store i1 {bit}, i1\* %[.\w]+" - ) + pat = rf"{_creg_gep_pattern(size, name, i)}\n\s*store i1 {bit}, (?:ptr|i1\*) %[.\w]+" assert re.search( pat, ir, @@ -230,11 +237,12 @@ def _assert_zero_init_predicate(label: str, ir: str, *, name: str) -> None: """`If(c[i])` before any write must branch on a `load` of the zero-initialised buffer (so the predicate is deterministically 0, never `undef`).""" - assert ( - f"store [1 x i1] zeroinitializer, [1 x i1]* %{name}" in ir + assert re.search( + rf"store \[1 x i1\] zeroinitializer, {_creg_storage_operand(1, name)}", + ir, ), f"{label}: missing zeroinitializer for the pre-read CReg {name!r}" assert re.search( - rf"getelementptr \[1 x i1\], \[1 x i1\]\* %{name}, i64 0, i64 0\n\s*%[.\w]+ = load i1", + rf"{_creg_gep_pattern(1, name, 0)}\n\s*%[.\w]+ = load i1", ir, ), f"{label}: If(c[0]) predicate is not a load of the zero-inited buffer" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_basic_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_basic_permutation.py index 0c046dfb9..519875acd 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_basic_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_basic_permutation.py @@ -166,7 +166,10 @@ def test_basic_permutation_qir(basic_permutation_program: tuple) -> None: assert "; Permutation: a[0] -> b[1], b[1] -> a[0]" in qir, qir # a[0].set(1) after the swap writes b[1]'s slot, not a[0]'s. - m = re.search(r"%(\.\d+) = getelementptr \[2 x i1\], \[2 x i1\]\* %b, i64 0, i64 1\n\s*store i1 1, i1\* %\1", qir) + m = re.search( + r"%(\.\d+) = getelementptr \[2 x i1\], (?:ptr|\[2 x i1\]\*) %b, i64 0, i64 1\n\s*store i1 1, (?:ptr|i1\*) %\1", + qir, + ) assert m, f"Expected a[0].set(1) to store into b[1] (relabelled):\n{qir}" assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -185,8 +188,8 @@ def test_same_register_permutation_qir( # a[0].set(1)->a[2], a[1].set(0)->a[0], a[2].set(1)->a[1]. def _stored(slot: int, val: int) -> bool: pat = ( - rf"%(\.\d+) = getelementptr \[3 x i1\], \[3 x i1\]\* %a, i64 0, i64 {slot}\n" - rf"\s*store i1 {val}, i1\* %\1" + rf"%(\.\d+) = getelementptr \[3 x i1\], (?:ptr|\[3 x i1\]\*) %a, i64 0, i64 {slot}\n" + rf"\s*store i1 {val}, (?:ptr|i1\*) %\1" ) return bool( re.search( diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py index 1744823d7..7dab7693a 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py @@ -179,7 +179,7 @@ def test_permutation_with_conditional_qir() -> None: assert "; Permutation: a[0] -> a[1], a[1] -> a[0], b[0] -> b[1], b[1] -> b[0]" in qir, qir # b[0].set(1) is BEFORE the permute -> b[0] slot (index 0). assert re.search( - r"%(\.\d+) = getelementptr \[2 x i1\], \[2 x i1\]\* %b, i64 0, i64 0\n\s*store i1 1, i1\* %\1", + r"%(\.\d+) = getelementptr \[2 x i1\], (?:ptr|\[2 x i1\]\*) %b, i64 0, i64 0\n\s*store i1 1, (?:ptr|i1\*) %\1", qir, ), qir # X(a[0]) after the permute -> a[1] = q1. diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_creg_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_creg_permutation.py index 53cd8fef8..0b0e46714 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_creg_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_creg_permutation.py @@ -71,8 +71,8 @@ def test_creg_permutation_qir() -> None: # a[0].set(1) after Permute(a, b) -> stores into register b's # `[1 x i1]` buffer (a[0] now resolves to b[0]); register a's # buffer is never the store target for that set. - assert re.search(r"store i1 1, i1\* %\.\d+", qir), qir - b_slot = re.search(r"%(\.\d+) = getelementptr \[1 x i1\], \[1 x i1\]\* %b, i64 0, i64 0", qir) + assert re.search(r"store i1 1, (?:ptr|i1\*) %\.\d+", qir), qir + b_slot = re.search(r"%(\.\d+) = getelementptr \[1 x i1\], (?:ptr|\[1 x i1\]\*) %b, i64 0, i64 0", qir) assert b_slot, f"Expected a[0].set(1) to target register b's buffer (relabelled):\n{qir}" assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py index a88609b93..1b3fe97c9 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py @@ -80,7 +80,7 @@ def _mz_then_store(qir: str) -> list[tuple[str, str, str]]: r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " r"%Result\* inttoptr \(i64 (\d+) to %Result\*\)\)\n" r"\s*%(?:\.\d+) = call i1 @__quantum__rt__read_result\(%Result\* inttoptr \(i64 \2 to %Result\*\)\)\n" - r"\s*%(\.\d+) = getelementptr \[\d+ x i1\], \[\d+ x i1\]\* %(\w+), i64 0, i64 (\d+)" + r"\s*%(\.\d+) = getelementptr \[\d+ x i1\], (?:ptr|\[\d+ x i1\]\*) %(\w+), i64 0, i64 (\d+)" ) # groups: 1=qubit, 2=result idx, 3=gep var, 4=creg name, 5=creg idx return [(m[0], m[3], m[4]) for m in re.findall(pat, qir)] diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py index 8e49e8eb0..721a0806a 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py @@ -135,11 +135,11 @@ def test_whole_register_permutation_qir() -> None: # Permute(a, b); b[2].set(1); a[3].set(0). After the swap, b[2] # resolves to a[2] and a[3] resolves to b[3]. assert re.search( - r"%(\.\d+) = getelementptr \[5 x i1\], \[5 x i1\]\* %a, i64 0, i64 2\n\s*store i1 1, i1\* %\1", + r"%(\.\d+) = getelementptr \[5 x i1\], (?:ptr|\[5 x i1\]\*) %a, i64 0, i64 2\n\s*store i1 1, (?:ptr|i1\*) %\1", qir, ), f"b[2].set(1) should target a[2] after swap:\n{qir}" assert re.search( - r"%(\.\d+) = getelementptr \[5 x i1\], \[5 x i1\]\* %b, i64 0, i64 3\n\s*store i1 0, i1\* %\1", + r"%(\.\d+) = getelementptr \[5 x i1\], (?:ptr|\[5 x i1\]\*) %b, i64 0, i64 3\n\s*store i1 0, (?:ptr|i1\*) %\1", qir, ), f"a[3].set(0) should target b[3] after swap:\n{qir}" diff --git a/uv.lock b/uv.lock index e5f7faa75..99d65a64a 100644 --- a/uv.lock +++ b/uv.lock @@ -185,7 +185,7 @@ wheels = [ [[package]] name = "black" -version = "26.3.1" +version = "26.5.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -197,34 +197,34 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/a8/11170031095655d36ebc6664fe0897866f6023892396900eec0e8fdc4299/black-26.3.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:86a8b5035fce64f5dcd1b794cf8ec4d31fe458cf6ce3986a30deb434df82a1d2", size = 1866562, upload-time = "2026-03-12T03:39:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/9e7548d719c3248c6c2abfd555d11169457cbd584d98d179111338423790/black-26.3.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5602bdb96d52d2d0672f24f6ffe5218795736dd34807fd0fd55ccd6bf206168b", size = 1703623, upload-time = "2026-03-12T03:40:00.347Z" }, - { url = "https://files.pythonhosted.org/packages/7f/0a/8d17d1a9c06f88d3d030d0b1d4373c1551146e252afe4547ed601c0e697f/black-26.3.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c54a4a82e291a1fee5137371ab488866b7c86a3305af4026bdd4dc78642e1ac", size = 1768388, upload-time = "2026-03-12T03:40:01.765Z" }, - { url = "https://files.pythonhosted.org/packages/52/79/c1ee726e221c863cde5164f925bacf183dfdf0397d4e3f94889439b947b4/black-26.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:6e131579c243c98f35bce64a7e08e87fb2d610544754675d4a0e73a070a5aa3a", size = 1412969, upload-time = "2026-03-12T03:40:03.252Z" }, - { url = "https://files.pythonhosted.org/packages/73/a5/15c01d613f5756f68ed8f6d4ec0a1e24b82b18889fa71affd3d1f7fad058/black-26.3.1-cp310-cp310-win_arm64.whl", hash = "sha256:5ed0ca58586c8d9a487352a96b15272b7fa55d139fc8496b519e78023a8dab0a", size = 1220345, upload-time = "2026-03-12T03:40:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/17/57/5f11c92861f9c92eb9dddf515530bc2d06db843e44bdcf1c83c1427824bc/black-26.3.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:28ef38aee69e4b12fda8dba75e21f9b4f979b490c8ac0baa7cb505369ac9e1ff", size = 1851987, upload-time = "2026-03-12T03:40:06.248Z" }, - { url = "https://files.pythonhosted.org/packages/54/aa/340a1463660bf6831f9e39646bf774086dbd8ca7fc3cded9d59bbdf4ad0a/black-26.3.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bf9bf162ed91a26f1adba8efda0b573bc6924ec1408a52cc6f82cb73ec2b142c", size = 1689499, upload-time = "2026-03-12T03:40:07.642Z" }, - { url = "https://files.pythonhosted.org/packages/f3/01/b726c93d717d72733da031d2de10b92c9fa4c8d0c67e8a8a372076579279/black-26.3.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:474c27574d6d7037c1bc875a81d9be0a9a4f9ee95e62800dab3cfaadbf75acd5", size = 1754369, upload-time = "2026-03-12T03:40:09.279Z" }, - { url = "https://files.pythonhosted.org/packages/e3/09/61e91881ca291f150cfc9eb7ba19473c2e59df28859a11a88248b5cbbc4d/black-26.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:5e9d0d86df21f2e1677cc4bd090cd0e446278bcbbe49bf3659c308c3e402843e", size = 1413613, upload-time = "2026-03-12T03:40:10.943Z" }, - { url = "https://files.pythonhosted.org/packages/16/73/544f23891b22e7efe4d8f812371ab85b57f6a01b2fc45e3ba2e52ba985b8/black-26.3.1-cp311-cp311-win_arm64.whl", hash = "sha256:9a5e9f45e5d5e1c5b5c29b3bd4265dcc90e8b92cf4534520896ed77f791f4da5", size = 1219719, upload-time = "2026-03-12T03:40:12.597Z" }, - { url = "https://files.pythonhosted.org/packages/dc/f8/da5eae4fc75e78e6dceb60624e1b9662ab00d6b452996046dfa9b8a6025b/black-26.3.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e6f89631eb88a7302d416594a32faeee9fb8fb848290da9d0a5f2903519fc1", size = 1895920, upload-time = "2026-03-12T03:40:13.921Z" }, - { url = "https://files.pythonhosted.org/packages/2c/9f/04e6f26534da2e1629b2b48255c264cabf5eedc5141d04516d9d68a24111/black-26.3.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cd2012d35b47d589cb8a16faf8a32ef7a336f56356babd9fcf70939ad1897f", size = 1718499, upload-time = "2026-03-12T03:40:15.239Z" }, - { url = "https://files.pythonhosted.org/packages/04/91/a5935b2a63e31b331060c4a9fdb5a6c725840858c599032a6f3aac94055f/black-26.3.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f76ff19ec5297dd8e66eb64deda23631e642c9393ab592826fd4bdc97a4bce7", size = 1794994, upload-time = "2026-03-12T03:40:17.124Z" }, - { url = "https://files.pythonhosted.org/packages/e7/0a/86e462cdd311a3c2a8ece708d22aba17d0b2a0d5348ca34b40cdcbea512e/black-26.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:ddb113db38838eb9f043623ba274cfaf7d51d5b0c22ecb30afe58b1bb8322983", size = 1420867, upload-time = "2026-03-12T03:40:18.83Z" }, - { url = "https://files.pythonhosted.org/packages/5b/e5/22515a19cb7eaee3440325a6b0d95d2c0e88dd180cb011b12ae488e031d1/black-26.3.1-cp312-cp312-win_arm64.whl", hash = "sha256:dfdd51fc3e64ea4f35873d1b3fb25326773d55d2329ff8449139ebaad7357efb", size = 1230124, upload-time = "2026-03-12T03:40:20.425Z" }, - { url = "https://files.pythonhosted.org/packages/f5/77/5728052a3c0450c53d9bb3945c4c46b91baa62b2cafab6801411b6271e45/black-26.3.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:855822d90f884905362f602880ed8b5df1b7e3ee7d0db2502d4388a954cc8c54", size = 1895034, upload-time = "2026-03-12T03:40:21.813Z" }, - { url = "https://files.pythonhosted.org/packages/52/73/7cae55fdfdfbe9d19e9a8d25d145018965fe2079fa908101c3733b0c55a0/black-26.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8a33d657f3276328ce00e4d37fe70361e1ec7614da5d7b6e78de5426cb56332f", size = 1718503, upload-time = "2026-03-12T03:40:23.666Z" }, - { url = "https://files.pythonhosted.org/packages/e1/87/af89ad449e8254fdbc74654e6467e3c9381b61472cc532ee350d28cfdafb/black-26.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f1cd08e99d2f9317292a311dfe578fd2a24b15dbce97792f9c4d752275c1fa56", size = 1793557, upload-time = "2026-03-12T03:40:25.497Z" }, - { url = "https://files.pythonhosted.org/packages/43/10/d6c06a791d8124b843bf325ab4ac7d2f5b98731dff84d6064eafd687ded1/black-26.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:c7e72339f841b5a237ff14f7d3880ddd0fc7f98a1199e8c4327f9a4f478c1839", size = 1422766, upload-time = "2026-03-12T03:40:27.14Z" }, - { url = "https://files.pythonhosted.org/packages/59/4f/40a582c015f2d841ac24fed6390bd68f0fc896069ff3a886317959c9daf8/black-26.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc622538b430aa4c8c853f7f63bc582b3b8030fd8c80b70fb5fa5b834e575c2", size = 1232140, upload-time = "2026-03-12T03:40:28.882Z" }, - { url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" }, - { url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" }, - { url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" }, - { url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" }, - { url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c0/37/5628dd55bf2b34257fc7603f0fe97c40e3aaf24265f416a9c85c95ca1436/black-26.5.1.tar.gz", hash = "sha256:dd321f668053961824bcc1be1cc1df748b2d7e4fa28086b08331e577b0100a73", size = 679439, upload-time = "2026-05-18T16:53:36.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/84/b3f55026206a9e8820a91503308075ca48eadc515e436731ca01dbe043b3/black-26.5.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9942db8888e06943c5dde66ca0037dcff82a2a4ec1ad0ada9e0d2ee9d9823893", size = 1987719, upload-time = "2026-05-18T17:05:02.757Z" }, + { url = "https://files.pythonhosted.org/packages/c6/34/7db312c5e5783d6e76cffd9d5ac8972a32badae4c6e3288dac0eed8d3bed/black-26.5.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:89c93167a74d3a75dfaa38a5c7cca015537d5820dd7f17d63267d674a61cae90", size = 1810083, upload-time = "2026-05-18T17:05:04.302Z" }, + { url = "https://files.pythonhosted.org/packages/33/e2/e0101e73c2c8727634e2efcb35e2b34bd23ad70dfa673789f5773a591b21/black-26.5.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f2cd76d069cc54c71f10360744ba8983fbb616903b4304a85b734915c8e1b4", size = 1860633, upload-time = "2026-05-18T17:05:06.391Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4c/e15c0c5b23cf3651035fe5addcce90e283af3548a3f91bb03d81b83106ab/black-26.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:87ed5c6f450580a2f6790bc7cbfb016dfc73bc750249762268a3695361315eef", size = 1477886, upload-time = "2026-05-18T17:05:07.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3f/59d43ade98d2ce5c8dc34a4e46cbecd177e6d55d7d4092969c6003ccc655/black-26.5.1-cp310-cp310-win_arm64.whl", hash = "sha256:58b4bd92cf88aacf83d88479c8f9caee044b1ec55f2451a337354a7ea2590a22", size = 1277111, upload-time = "2026-05-18T17:05:09.473Z" }, + { url = "https://files.pythonhosted.org/packages/4b/96/3c3e09f09f44a37aac36b178a279cd19aa7001bd796187a7b162a294c81f/black-26.5.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:96ae2c733b2aabdd9986e2c5df628ff3473676cd1c5faded1ff496cf6d74083c", size = 1970639, upload-time = "2026-05-18T17:05:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/83/ea/5ad117b9ee3ecd933c712bcbae610006e5b7cc9f41c526cd7ed3b6c4124c/black-26.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0e48b87e03bf109288e55cfceadcfa15ff5470aca2851a851950ed2926f450d7", size = 1792130, upload-time = "2026-05-18T17:05:12.983Z" }, + { url = "https://files.pythonhosted.org/packages/06/3a/7c448bc623fcdfa96672531beb5a616ea5e64f6975955254d7731ffb0ad9/black-26.5.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5119fa92ae61f786e8c3662fd60aece1d0a2dd5cca5d0c79417a95e7a4272a59", size = 1846134, upload-time = "2026-05-18T17:05:14.506Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5b/0b39b3a5917f0657ac014ad2edb58c139553a478adfe7f817abf1622ff6e/black-26.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:30d3c14661f2792e9142cce3eeeb1cbc175b3eb5f733be0c8eeb99651e52b0c3", size = 1478883, upload-time = "2026-05-18T17:05:16.542Z" }, + { url = "https://files.pythonhosted.org/packages/4c/48/dc222692e0f95030db1bbfb6c857e76858bad09058221ea7aae815255327/black-26.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:1ef92b76f7733f282fd096ea406200b5a286c42947412b0eaff3a74e3616cefe", size = 1277776, upload-time = "2026-05-18T17:05:18.029Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/7744b906703228264ef73bdd534df88ec1ef3de45c4e78f6d31b9e32d0c9/black-26.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4ad6fa01f941920f54f2bbb35f3df7673428a0ef98a0b0840c2eaef3b110efa8", size = 2012518, upload-time = "2026-05-18T17:05:20.108Z" }, + { url = "https://files.pythonhosted.org/packages/b7/c0/c5a3b1636dfd09c42534f2b3cf33506814f6d3e066fb0879ffa16c1ae860/black-26.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3915f256e75a2d7cf88d8953d37f780455dc586cc72dee059c528fe77f581217", size = 1816016, upload-time = "2026-05-18T17:05:21.84Z" }, + { url = "https://files.pythonhosted.org/packages/1f/0e/36044316b65ca471d3bb6d3703fd06fb50c6b727c3562f6a5a3153634f88/black-26.5.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d98d4137277c75dfb898ec8d846c4fd68ba1e9cf77f95e2865c203dc18f4c3d", size = 1884150, upload-time = "2026-05-18T17:05:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/b3/33/dafc5808c2af43672912111d7c3354af1615f7e2be3bed7a878461abbe4d/black-26.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:a1dca32d9f1784af512a13410ec204c6f7f0aa9797a111c42e1c03449821c264", size = 1486825, upload-time = "2026-05-18T17:05:25.004Z" }, + { url = "https://files.pythonhosted.org/packages/82/14/b965ee6ad2a311f28bdbf692def3ee9848d2ae289dab28b27657fcee3e78/black-26.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1037d5ac7b7b310b2632ad867ec8d0e4c4819dcdb0b820f63135da746a24e418", size = 1288646, upload-time = "2026-05-18T17:05:26.477Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5c/c384363980e11e25ca6b93205949bb331fbf35f4e0dbec376dfa6326cec8/black-26.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2b36cf2ddf5566e205f6535f782a62194a184d33e175b64ae8c40b1737522be3", size = 2009020, upload-time = "2026-05-18T17:05:28.132Z" }, + { url = "https://files.pythonhosted.org/packages/0b/df/9f31c5e0babbfed77d505fc5d120beb98b21b33feaeded3924ea941fe360/black-26.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f7ea64ebfa01b50f693508fc39f875e264446d3b097088f84f203b9d09618a0", size = 1813335, upload-time = "2026-05-18T17:05:31.266Z" }, + { url = "https://files.pythonhosted.org/packages/fb/24/8e7b9a2fa61b0afd82209efe937557d180a1fa055bd7f6161eb9defc3719/black-26.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecb3e624844c798144e9bd986954e0adc81d8911a1f30f375e1252fe26e8c294", size = 1881614, upload-time = "2026-05-18T17:05:32.718Z" }, + { url = "https://files.pythonhosted.org/packages/49/ad/b4e0d9365ba8ac34f6bbab62a4b1b2dd5d618fac3fa1b8db968c844201b5/black-26.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:e1a26503279b6b310669fb0b219c39e4820b77e8189fe80f522bb511f247db0a", size = 1488925, upload-time = "2026-05-18T17:05:34.259Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4b/652b859bf5df88a751c30451b09338f7fd26a77d1271c666992f836b7711/black-26.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c34b25da232ead53a6f335b76dbea124f4d152ad568b9080d6f944bc2b34b52", size = 1289883, upload-time = "2026-05-18T17:05:36.019Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a8da8eb208c51c7f4ce74609a45d0dcc6d8a2141e45e81ee5289d1bb0d59/black-26.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e88976690a64b0af98312ca958415849cb42423423c5f2ee74af4b49a97a2168", size = 2004800, upload-time = "2026-05-18T17:05:38.182Z" }, + { url = "https://files.pythonhosted.org/packages/11/8a/a479296a19e383b70a725882a6cf3d786540601ff03cabbaaf1cce864c5a/black-26.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32d5ea7f6c8bdfa6e648326ebca1f02b0764e2a029edc6f8dce2627e19d468c3", size = 1815576, upload-time = "2026-05-18T17:05:40.309Z" }, + { url = "https://files.pythonhosted.org/packages/81/6b/cfaf3d39f25132c156a068f6b805576c9103a84086019507c70e1911ee7d/black-26.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea8d16dc41655aa113cd64665e7219446cd7e4ff2248d7178eaa905190c86b18", size = 1877927, upload-time = "2026-05-18T17:05:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/66/76/302e313964bcff7e28df329d39f84f5270095730d85ff0acc260610a0d82/black-26.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:577f21094ea469ef92ec1adaf2c9441a226d2144d01a5be2fa823cecf6543e50", size = 1511860, upload-time = "2026-05-18T17:05:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/27/4e/a3827e35e0e567f9f9ee59e2a0ab979267dca98718f25547ca8c6733afd4/black-26.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:ed1a20af114c301a0269bf01163d51dbef72737fd65f850001e7cbe7f3c7abae", size = 1316632, upload-time = "2026-05-18T17:05:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/94/51/f975cae76d44274cc2868dc9040ac5d58d464784610234455b4e7b19c6ef/black-26.5.1-py3-none-any.whl", hash = "sha256:4ed7f7da04046d2e488437170797d3b4a4ad83906683bcb7dfc68b673bbce5e2", size = 213693, upload-time = "2026-05-18T16:53:33.964Z" }, ] [[package]] @@ -246,11 +246,11 @@ css = [ [[package]] name = "certifi" -version = "2026.4.22" +version = "2026.5.20" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/25/ee/6caf7a40c36a1220410afe15a1cc64993a1f864871f698c0f93acb72842a/certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580", size = 137077, upload-time = "2026-04-22T11:26:11.191Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707, upload-time = "2026-04-22T11:26:09.372Z" }, + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, ] [[package]] @@ -451,14 +451,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.4.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, ] [[package]] @@ -561,7 +561,7 @@ resolution-markers = [ "(python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'darwin')", ] dependencies = [ - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -640,115 +640,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/59/9d/7c83ef51c3eb495f10010094e661833588b7709946da634c8b66520b97c7/coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075", size = 219668, upload-time = "2026-05-10T17:59:23.106Z" }, - { url = "https://files.pythonhosted.org/packages/24/34/898546aefbd28f0af131201d0dc852c9e976f817bd7d5bfb8dc4e02863bb/coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82", size = 220192, upload-time = "2026-05-10T17:59:26.095Z" }, - { url = "https://files.pythonhosted.org/packages/df/4a/b457c88aca72b0df13a98167ebd5d947135ccd9881ea88ce6a570e13aa9b/coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c", size = 246932, upload-time = "2026-05-10T17:59:27.806Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d9/92600e89486fd074c50f0117422b2c9592c3e144e2f25bd5ac0bc62bc7a0/coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893", size = 248762, upload-time = "2026-05-10T17:59:29.479Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e1/9ea1eb9c311da7f15853559dc1d9d82bef88ecd3e59fbeb51f16bc2ffa91/coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20", size = 250625, upload-time = "2026-05-10T17:59:31.33Z" }, - { url = "https://files.pythonhosted.org/packages/a5/03/57afca1b8106f8549a5329139315041fe166d6099bd9381346b9430dfbd1/coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec", size = 252539, upload-time = "2026-05-10T17:59:32.692Z" }, - { url = "https://files.pythonhosted.org/packages/57/5e/2e9fc63c9928119c1dbae02222be51407d3e7ebac5811ebbda4af3557795/coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757", size = 247636, upload-time = "2026-05-10T17:59:34.599Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e2/0b7898cda21041cc67546e19b80ba66cbbb47cbece52a76a5904de6a3aaf/coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a", size = 248666, upload-time = "2026-05-10T17:59:36.232Z" }, - { url = "https://files.pythonhosted.org/packages/d6/e3/d33662a2fdaef23229c15921f39c84ec38441f3069ba26e134ed402c833b/coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea", size = 246670, upload-time = "2026-05-10T17:59:38.029Z" }, - { url = "https://files.pythonhosted.org/packages/99/b2/533942c3bfbf6770b5c32d7f2ff029fe013dba31f3fe8b45cabbb250365e/coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb", size = 250484, upload-time = "2026-05-10T17:59:39.974Z" }, - { url = "https://files.pythonhosted.org/packages/d8/00/15acbad83a96de13c73831486c7627bfed73dfaec53b04e4a6315edf3fd8/coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218", size = 246942, upload-time = "2026-05-10T17:59:41.659Z" }, - { url = "https://files.pythonhosted.org/packages/70/db/cef0228de493f2c740c760a9057a61d00c6849480073b70a75b87c7d4bab/coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85", size = 247544, upload-time = "2026-05-10T17:59:43.471Z" }, - { url = "https://files.pythonhosted.org/packages/77/a0/d9ef8e148f3025c2ae8401d77cda1502b6d2a4d8102603a8af31460aedb6/coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323", size = 222285, upload-time = "2026-05-10T17:59:44.908Z" }, - { url = "https://files.pythonhosted.org/packages/85/c0/30c454c7d3cf47b2805d4e06f12443f5eece8a5d030d3b0350e7b74ecb49/coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a", size = 223215, upload-time = "2026-05-10T17:59:46.779Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e4/649c8d4f7f1709b6dbfc474358aa1bba02f67bcd52e2fec291a5014006cd/coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480", size = 219795, upload-time = "2026-05-10T17:59:48.198Z" }, - { url = "https://files.pythonhosted.org/packages/7f/8d/46692d24b3f395d4cbf17bfcc57136b4f2f9c0c0df864b0bddfc1d71a014/coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4", size = 220299, upload-time = "2026-05-10T17:59:49.683Z" }, - { url = "https://files.pythonhosted.org/packages/12/c2/a40f5cb295bbcbb697a76947a56081c494c61950366294ee426ffe261099/coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7", size = 250721, upload-time = "2026-05-10T17:59:51.494Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/202235eb5c3c14c212462cd91d61b7386bf8fc44bc7a77f4742d2a69174b/coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed", size = 252633, upload-time = "2026-05-10T17:59:53.244Z" }, - { url = "https://files.pythonhosted.org/packages/bb/80/5f596e8995785124ee191c42535664c5e62c65995b66f4ca21e28ae04c81/coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980", size = 254743, upload-time = "2026-05-10T17:59:55.021Z" }, - { url = "https://files.pythonhosted.org/packages/1e/6d/0d178825be2350f0adb27984d0aa7cf84bbdab201f6fb926b535d23a8f5f/coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0", size = 256700, upload-time = "2026-05-10T17:59:56.511Z" }, - { url = "https://files.pythonhosted.org/packages/19/5b/9e549c2f6e9dfea472adadba06c294e64735dabc2dd19015fac082095013/coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742", size = 250854, upload-time = "2026-05-10T17:59:57.94Z" }, - { url = "https://files.pythonhosted.org/packages/3d/1c/b94f9f5f36396021ee2f62c5834b12e6a3d31f0bed5d6fc6d1c3caec087c/coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5", size = 252433, upload-time = "2026-05-10T17:59:59.688Z" }, - { url = "https://files.pythonhosted.org/packages/b5/cb/d192cd8e1345eccabc32016f2d39072ecd10cb4f4b983ed8d0ebdeaf00dc/coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327", size = 250494, upload-time = "2026-05-10T18:00:01.953Z" }, - { url = "https://files.pythonhosted.org/packages/53/c5/aac9f460a41d835dbddef1d377f105f6ac2311d0f3c1588e9f51046d8813/coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d", size = 254261, upload-time = "2026-05-10T18:00:03.779Z" }, - { url = "https://files.pythonhosted.org/packages/23/aa/7af7c0081980a9cb3d289c5a435a4b7657dcecbd128e25c580e6a50389b5/coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20", size = 250216, upload-time = "2026-05-10T18:00:05.262Z" }, - { url = "https://files.pythonhosted.org/packages/35/60/a4257538ce2f6b978aeb51870d6c4208c510928a03db7e0339bb625dccb7/coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c", size = 251125, upload-time = "2026-05-10T18:00:06.858Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ab/f91af47642ec1aa53490e835a95847168d9c77fc39aa58527604c051e145/coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3", size = 222300, upload-time = "2026-05-10T18:00:08.608Z" }, - { url = "https://files.pythonhosted.org/packages/f0/f0/a71ddbd874431e7a7cd96071f0c331cfbbad07704833c765d24ffbab8a67/coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1", size = 223241, upload-time = "2026-05-10T18:00:10.746Z" }, - { url = "https://files.pythonhosted.org/packages/d8/6e/d9d312a5151a96cd110efee32efc3fc97b01ebd86203fe618ccb29cf4c92/coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627", size = 221908, upload-time = "2026-05-10T18:00:12.242Z" }, - { url = "https://files.pythonhosted.org/packages/09/1e/2f996b2c8415cbb6f54b0f5ec1ee850c96d7911961afb4fc05f4a89d8c58/coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5", size = 219967, upload-time = "2026-05-10T18:00:13.756Z" }, - { url = "https://files.pythonhosted.org/packages/34/23/35c7aea1274aef7525bdd2dc92f710bdde6d11652239d71d1ec450067939/coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662", size = 220329, upload-time = "2026-05-10T18:00:15.264Z" }, - { url = "https://files.pythonhosted.org/packages/75/cf/a8f4b43a16e194b0261257ad28ded5853ec052570afef4a84e1d81189f3b/coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f", size = 251839, upload-time = "2026-05-10T18:00:17.16Z" }, - { url = "https://files.pythonhosted.org/packages/69/ff/6699e7b71e60d3049eb2bdcbc95ee3f35707b2b0e48f32e9e63d3ce30c08/coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67", size = 254576, upload-time = "2026-05-10T18:00:18.829Z" }, - { url = "https://files.pythonhosted.org/packages/22/ec/c936d495fcd67f48f03a9c4ad3297ff80d1f222a5df3980f15b34c186c21/coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9", size = 255690, upload-time = "2026-05-10T18:00:20.648Z" }, - { url = "https://files.pythonhosted.org/packages/5c/42/5af63f636cc62a4a2b1b3ba9146f6ee6f53a35a50d5cefc54d5670f60999/coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb", size = 257949, upload-time = "2026-05-10T18:00:22.28Z" }, - { url = "https://files.pythonhosted.org/packages/26/d3/a225317bd2012132a27e1176d51660b826f99bb975876463c44ea0d7ee5a/coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e", size = 252242, upload-time = "2026-05-10T18:00:24.076Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7f/9e65495298c3ea414742998539c37d048b5e81cc818fb1828cc6b51d10bf/coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3", size = 253608, upload-time = "2026-05-10T18:00:25.588Z" }, - { url = "https://files.pythonhosted.org/packages/94/46/1522b524a35bdad22b2b8c4f9d32d0a104b524726ec380b2db68db1746f5/coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4", size = 251753, upload-time = "2026-05-10T18:00:27.104Z" }, - { url = "https://files.pythonhosted.org/packages/f3/e9/cdf00d38817742c541ade405e115a3f7bf36e6f2a8b99d4f209861b85a2d/coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1", size = 255823, upload-time = "2026-05-10T18:00:29.038Z" }, - { url = "https://files.pythonhosted.org/packages/38/fc/5e7877cf5f902d08a17ff1c532511476d87e1bea355bd5028cb97f902e79/coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5", size = 251323, upload-time = "2026-05-10T18:00:30.647Z" }, - { url = "https://files.pythonhosted.org/packages/18/9d/50f05a72dff8487464fdd4178dda5daed642a060e60afb644e3d45123559/coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595", size = 253197, upload-time = "2026-05-10T18:00:32.211Z" }, - { url = "https://files.pythonhosted.org/packages/00/3f/6f61ffe6439df266c3cf60f5c99cfaa21103d0210d706a42fc6c30683ff8/coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27", size = 222515, upload-time = "2026-05-10T18:00:33.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/19/93853133df2cb371083285ef6a93982a0173e7a233b0f61373ba9fd30eb2/coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2", size = 223324, upload-time = "2026-05-10T18:00:35.172Z" }, - { url = "https://files.pythonhosted.org/packages/74/18/9f7fe62f659f24b7a82a0be56bf94c1bd0a89e0ae7ab4c668f6e82404294/coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d", size = 221944, upload-time = "2026-05-10T18:00:37.014Z" }, - { url = "https://files.pythonhosted.org/packages/6b/76/b7c66ee3c66e1b0f9d894c8125983aa0c03fb2336f2fd16559f9c966157f/coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef", size = 219990, upload-time = "2026-05-10T18:00:38.887Z" }, - { url = "https://files.pythonhosted.org/packages/b3/af/e567cbad5ba69c013a50146dfa886dc7193361fda77521f51274ff620e1b/coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66", size = 220365, upload-time = "2026-05-10T18:00:40.864Z" }, - { url = "https://files.pythonhosted.org/packages/44/6f/9ad575d505b4d805b254febc8a5b338a2efe278f8786e56ff1cb8413f9c3/coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b", size = 251363, upload-time = "2026-05-10T18:00:42.489Z" }, - { url = "https://files.pythonhosted.org/packages/6f/5f/b5370068b2f57787454592ed7dcd1002f0f1703b7db1fa30f6a325a4ca6e/coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca", size = 253961, upload-time = "2026-05-10T18:00:44.079Z" }, - { url = "https://files.pythonhosted.org/packages/29/1e/51adf17738976e8f2b85ddef7b7aa12a0838b056c92f175941d8862767c1/coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7", size = 255193, upload-time = "2026-05-10T18:00:45.623Z" }, - { url = "https://files.pythonhosted.org/packages/9e/7b/5bfd7ac1df3b881c2ac7a5cbc99c7609e6296c402f5ef587cd81c6f355b3/coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2", size = 257326, upload-time = "2026-05-10T18:00:47.173Z" }, - { url = "https://files.pythonhosted.org/packages/7d/38/1d37d316b174fad3843a1d76dbdfe4398771c9ecd0515935dd9ece9cd627/coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367", size = 251582, upload-time = "2026-05-10T18:00:49.152Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/746704f95980ba220214e1a41e18cec5aea80a898eaa53c51bf2d645ff36/coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9", size = 253325, upload-time = "2026-05-10T18:00:51.252Z" }, - { url = "https://files.pythonhosted.org/packages/e1/b9/bbe87206d9687b192352f893797825b5f5b15ecd3aa9c68fbff0c074d77b/coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087", size = 251291, upload-time = "2026-05-10T18:00:52.816Z" }, - { url = "https://files.pythonhosted.org/packages/46/57/b8cdb12ac0d73ef0243218bd5e22c9df8f92edab8018213a86aec67c5324/coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef", size = 255448, upload-time = "2026-05-10T18:00:54.548Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d4/5002019538b2036ce3c84340f54d2fd5100d55b0a6b0894eee56128d03c7/coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52", size = 251110, upload-time = "2026-05-10T18:00:56.122Z" }, - { url = "https://files.pythonhosted.org/packages/37/53/20c5009477660f084e6ed60bc02a91894b8e234e617e86ecfd9aaf78e27b/coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe", size = 252885, upload-time = "2026-05-10T18:00:57.967Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ab/3cf6427ac9c1f1db747dbb1ce71dde47984876d4c2cfd018a3fef0a78d4d/coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae", size = 222539, upload-time = "2026-05-10T18:00:59.581Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b8/9228523e80321c2cb4880d1f589bc0171f2f71432c35118ad04dc01decce/coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e", size = 223344, upload-time = "2026-05-10T18:01:01.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/99/118daa192f95e3a6cb2740100fbf8797cda1734b4134ef0b5d501a7fa8f3/coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96", size = 221966, upload-time = "2026-05-10T18:01:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/e6/f1/a46cc0c013be170216253184a32366d7cbdb9252feaec866b05c2d12a894/coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90", size = 220679, upload-time = "2026-05-10T18:01:05.058Z" }, - { url = "https://files.pythonhosted.org/packages/64/8c/9c30a3d311a34177fa432995be7fbfc64477d8bac5630bd38055b1c9b424/coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1", size = 221033, upload-time = "2026-05-10T18:01:07.002Z" }, - { url = "https://files.pythonhosted.org/packages/9a/cd/3fb5e06c3badefd0c1b47e2044fdca67f8220a4ec2e7fcfb476aa0a67c6c/coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd", size = 262333, upload-time = "2026-05-10T18:01:08.903Z" }, - { url = "https://files.pythonhosted.org/packages/a8/e6/fbc322325c7294d3e22c1ad6b79e45d0806b25228c8e5842aed6d8169aa7/coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc", size = 264410, upload-time = "2026-05-10T18:01:10.531Z" }, - { url = "https://files.pythonhosted.org/packages/08/92/c497b264bec1673c47cc77e26f760fcda4654cabf1f39546d1a23a3b8c35/coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426", size = 266836, upload-time = "2026-05-10T18:01:12.19Z" }, - { url = "https://files.pythonhosted.org/packages/78/fc/045da320987f401af5d2815d351e8aa799aec859f60e29f445e3089eeedb/coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899", size = 267974, upload-time = "2026-05-10T18:01:13.926Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ae/227b1e379497fb7a4fc3286e620f80c8a1e7cec66d45695a01639eb1af65/coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b", size = 261578, upload-time = "2026-05-10T18:01:15.564Z" }, - { url = "https://files.pythonhosted.org/packages/a0/f5/3570342900f2acea31d33ff1590c5d8bac1a8e1a2e1c6d34a5d5e61de681/coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90", size = 264394, upload-time = "2026-05-10T18:01:17.607Z" }, - { url = "https://files.pythonhosted.org/packages/16/29/de1bbc01c935b28f89b1dc3db85b011c055e843a8e5e3b83141c3f80af7f/coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f", size = 262022, upload-time = "2026-05-10T18:01:19.304Z" }, - { url = "https://files.pythonhosted.org/packages/35/95/f53890b0bf2fc10ab168e05d38869215e73ca24c4cb521c3bb0eb62fe16b/coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d", size = 265732, upload-time = "2026-05-10T18:01:21.494Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ea/c919e259081dd2bdf0e43b87209709ba7ec2e4117c2a7f5185379c43463c/coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47", size = 260921, upload-time = "2026-05-10T18:01:23.533Z" }, - { url = "https://files.pythonhosted.org/packages/1a/2c/c2831889705a81dc5d1c6ca12e4d8e9b95dfc146d153488a6c0ea685d28e/coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477", size = 263109, upload-time = "2026-05-10T18:01:25.165Z" }, - { url = "https://files.pythonhosted.org/packages/5a/a9/2fcae5003cac3d63fe344d2166243c2756935f48420863c5272b240d550b/coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab", size = 223212, upload-time = "2026-05-10T18:01:27.157Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/18e94d7b14b9b398164197114a587a04ab7c9fdbe1d237eef57311c5e883/coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917", size = 224272, upload-time = "2026-05-10T18:01:29.107Z" }, - { url = "https://files.pythonhosted.org/packages/db/56/4f14fad782b035c81c4ffd09159e7103d42bb1d93ac8496d04b90a11b7da/coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8", size = 222530, upload-time = "2026-05-10T18:01:31.151Z" }, - { url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" }, - { url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" }, - { url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" }, - { url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" }, - { url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" }, - { url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" }, - { url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" }, - { url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" }, - { url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" }, - { url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" }, - { url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" }, - { url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" }, - { url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" }, - { url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" }, - { url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" }, - { url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" }, - { url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" }, - { url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" }, - { url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" }, - { url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" }, - { url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" }, - { url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" }, - { url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" }, - { url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" }, - { url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" }, - { url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" }, +version = "7.14.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/fd/0ab2772530e946e1be1abd0bc09e647ec9b02e88f0867857601fefca8953/coverage-7.14.1.tar.gz", hash = "sha256:30c08f7d90415aa98b3c990385dea2939b0da55f38515e5b369b83655f8523be", size = 920132, upload-time = "2026-05-26T20:41:36.783Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/69/0d2ef01ff4b8fcecd4cba920d11e92fa4f96ae412441d3b56a90a258e69b/coverage-7.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:3e3680291c4a1d0dadfa84a2c459576a4af5133abb617905714339a0c73138cf", size = 219722, upload-time = "2026-05-26T20:38:14.002Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ae/9afdeaa31b9d9ce98124b6abf8bb49119bf71aecae04f8567c189d91299f/coverage-7.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a5274669f37f2343635a347b91a60777621341ab3378e9c6ac9335eee704bddf", size = 220240, upload-time = "2026-05-26T20:38:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/51/69/c998589871df7ea7dba865cc5ee32b5a3e1d47ba6c68ef91104c7c46fa5e/coverage-7.14.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cfe5a5fec635799ef33428f1e5e61bafa45a92a96190ba731561ba558ccc214d", size = 246981, upload-time = "2026-05-26T20:38:19.266Z" }, + { url = "https://files.pythonhosted.org/packages/fc/10/1c7d04c13040dac531d21b712bbe08f902e6dd9b58f5d77875c4d030f8f2/coverage-7.14.1-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:62a9f70b52e0b5a95cfef4a5c5641b06983cadc5e538a3feeb5c00211f523ac2", size = 248812, upload-time = "2026-05-26T20:38:20.75Z" }, + { url = "https://files.pythonhosted.org/packages/c1/65/2a38a4607ef27cadcfbcee034dba5830ae2569f90144a0f4c7dbf47d30b0/coverage-7.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c18ebc343e15be53049b3a2dce38fe82d58f37e20ab9094b3a39c0aa4f6bb47", size = 250675, upload-time = "2026-05-26T20:38:22.159Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a2/a446ed9752a4a59b79e0fb6cbb319f6facb2183045c0725462625e66f87e/coverage-7.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b84ffdf877644e7096aa936991efeed873f7f3df57b9cd001312b7668ab08550", size = 252590, upload-time = "2026-05-26T20:38:23.63Z" }, + { url = "https://files.pythonhosted.org/packages/9e/fd/e81fbd7ba752365546e9842b1cbdaad3d6919d2a522c590aef16a281ec5e/coverage-7.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e854312c4103f2ad4c0dc023b69b77ebfd2c89db5f86c4c94dc2353f9a92167e", size = 247691, upload-time = "2026-05-26T20:38:25.057Z" }, + { url = "https://files.pythonhosted.org/packages/53/35/f3c26fdaae9ea937d154ca4d372e5ea0a4167ff70d36c6074ac2eacb2f83/coverage-7.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c643734307300234fafa36bf2a040a7235f8f177ea1fd6ec1423aea6fb7b929f", size = 248716, upload-time = "2026-05-26T20:38:26.406Z" }, + { url = "https://files.pythonhosted.org/packages/2e/14/940b6c49551fd343e8507ee2b0ba7af5d0aa04ed5bf768285cb7c72a9884/coverage-7.14.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:84ac9499e48700399a5dd0ea7085b5091961fec52c68d66b4ec0d3cf7f4441b1", size = 246721, upload-time = "2026-05-26T20:38:28.282Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2c/40fc0634186c28292a662dff578866b3913983d6c375a3c2a74020938719/coverage-7.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:7f02d09f70776579b926d889a4c9c235070a1f47c40458aeaca563fae5acfdb5", size = 250533, upload-time = "2026-05-26T20:38:29.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/e3/2c26bf1e811f9df991ff2a9bdddebdd13ee0665d564df7d05979f9146297/coverage-7.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:ce66d8e46da2bb5ee313a745cbd2e391d319176c1f7a9451bfcd3a2fb920859b", size = 246990, upload-time = "2026-05-26T20:38:31.516Z" }, + { url = "https://files.pythonhosted.org/packages/a8/b0/060260ef56bd92363ebdce0c7095ce422b06e69aae71828efeca473ab1ca/coverage-7.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:c912c259304cfb5ee584481cfb7ce1ff932b4d61e6c9140b8f19cb7b5ed82332", size = 247593, upload-time = "2026-05-26T20:38:33.065Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/501502046efeb0d6d94b5ca54941d95f1184183dd6bdb7f283985783bb4a/coverage-7.14.1-cp310-cp310-win32.whl", hash = "sha256:1238cb94638e610e972c60dac68e813f868dc7d6e982535270558443058d9d59", size = 222330, upload-time = "2026-05-26T20:38:35.36Z" }, + { url = "https://files.pythonhosted.org/packages/a0/5d/1bf99f2c558f128faf7906817ccbdb576ba815d3b41ce2ac1719b70a3663/coverage-7.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:fc459e5d73be2d6332fcfe8dbf3d8994671fe33c700f4565988ecfa511547253", size = 223261, upload-time = "2026-05-26T20:38:37.196Z" }, + { url = "https://files.pythonhosted.org/packages/7d/d7/477ad149490e6cb849f28abea1dabb9c823cea72e7500c81b4240ce619c0/coverage-7.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:478b5bcd63c2e1357c5c7e16c070690df7b07f676b1c114d7b93e533c664309f", size = 219848, upload-time = "2026-05-26T20:38:38.715Z" }, + { url = "https://files.pythonhosted.org/packages/91/82/a5eb47257c50601bb7b9a9d2857c67b7a3a85ad74180eb2c98bb1fbe0ce5/coverage-7.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a24a81f9715ee42ef59a316cc11611c98fe23920f7c81861315c9f3ff4a230f4", size = 220354, upload-time = "2026-05-26T20:38:40.232Z" }, + { url = "https://files.pythonhosted.org/packages/43/8b/78419b5391a5cb706b6544390507e469d83ffc9a8248b02c4011aceb9365/coverage-7.14.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:196a13319ad88d6d8ef5ab489ec4f44ddde2143c0c7d5b27786f6c3ffd56a7e1", size = 250771, upload-time = "2026-05-26T20:38:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/77/63/e77aaacd491182210d639636b7a8bba23ffffa9b82aa3762da9431855fa9/coverage-7.14.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3d452fd08b5c72c5167c93e6867b5c08500bd40f2a21e1e854a500550b6cc36f", size = 252683, upload-time = "2026-05-26T20:38:43.305Z" }, + { url = "https://files.pythonhosted.org/packages/65/1c/a022e3cfbec2ac241640003cb3a817e161d9c7f5aa9b49173756cdc03204/coverage-7.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23bf7fa51ac02e07fc7c96849b82946da47ae862dc8f86d183b2a4864fc38129", size = 254791, upload-time = "2026-05-26T20:38:45.361Z" }, + { url = "https://files.pythonhosted.org/packages/61/d6/967e408aca4c1ceb88cb0cc677169110ae7f5995fb5eaf5fb1f5a1bb8f5d/coverage-7.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bcaa50684dcaadfa599ac48f81103c756d791cfd85c97203d2217c593d48b860", size = 256748, upload-time = "2026-05-26T20:38:46.91Z" }, + { url = "https://files.pythonhosted.org/packages/b8/be/869188f7fe28638078ec479331ace6dc5f7b40b7153eb616f47ab79404d8/coverage-7.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4ea1c034f95c9b056e856b794630b17f9fa3d57e4800ff1e503d3be0f9c9078c", size = 250907, upload-time = "2026-05-26T20:38:48.493Z" }, + { url = "https://files.pythonhosted.org/packages/07/aa/adb7d3b4278d690e68703abcd76ab1b948242e3668d921711551b78f9ddb/coverage-7.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e057326434e441306226fbeb5d1aaf14a2637efe97ba668306635835f32ad7", size = 252483, upload-time = "2026-05-26T20:38:50.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/61/331c74103c62dcb0c4b9b3a0de9a61aca016208b0a90f109592a9f9ecc28/coverage-7.14.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:59baf88468dbc8d63b1887afd92bda52e40bb1561696e5819670601403810cec", size = 250545, upload-time = "2026-05-26T20:38:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b6/c5dae3c104d89be04828f61810e6b3473825482e4c288cc4ed04553e08ae/coverage-7.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d34d75f892b3ab73ba11cab5442cce7b3e168fd64162b16f0e1e0d09c508edef", size = 254310, upload-time = "2026-05-26T20:38:53.503Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a1/2b9d5863e3b83c01ad8199e3c597802fbb3a9dc90b058885804c20296d31/coverage-7.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3a56abc20a472baf0304c455721bc601477440d28ecfde8a03dde79ede07e0df", size = 250266, upload-time = "2026-05-26T20:38:55.414Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/0e511fbdb269359be26fe678a1c3fa1f2aa2a01573cc3f54268c8d6d4797/coverage-7.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6a3cb83d1552c0cd1b4906655b6a33fd4a8473229633a901c6b73bf86914dee9", size = 251174, upload-time = "2026-05-26T20:38:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/10/e55307b622b3dd9671cb321824502dc10f93e72f2802b9946159a8edadeb/coverage-7.14.1-cp311-cp311-win32.whl", hash = "sha256:10274a1fbeb8ec5d72966e17bb198a3104257aca4ac09d98667c5f8aca8c8548", size = 222354, upload-time = "2026-05-26T20:38:58.727Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/107421693cfb71e4f1ca5bf70443f64d4161878068d07a3e51c7ad21d17b/coverage-7.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:87ebdf787d4888e3f3f2d523eadc6e18c6d18c6d0eb173801a189641627fb37e", size = 223290, upload-time = "2026-05-26T20:39:00.413Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1d/3e3644585eb29e9dafefb19555078529a4d7cce12bd21929664eea989277/coverage-7.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:dd34767fa19848d35659ffc0a75314f58c7af3f1cd87ec521e8292a1238398a3", size = 221953, upload-time = "2026-05-26T20:39:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b7/bdbb725ba02c5b42825b200c940f38b7a54fcad24627b7192f78f8110d76/coverage-7.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a06c76364a9360e33d6d23769aefdf7f66f38e2ffb60ceb1baaa4989d83b695c", size = 220022, upload-time = "2026-05-26T20:39:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/72/81/fdc0898a55c6219223291ec1a1fe89966ef212ce82276aa0899df84b5de0/coverage-7.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fad54e871165f6ec2f536063ac74c3104508a12963e64072ba44bd822de52b0c", size = 220379, upload-time = "2026-05-26T20:39:05.381Z" }, + { url = "https://files.pythonhosted.org/packages/de/72/de048c4a25e13bce59ac6a339351c10bdf2515e07459afcdaf04dc3143a2/coverage-7.14.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:84b535f00655ecafe1d929d1fb00ed5d6fa3051ea643ab2c161a3887b86f294b", size = 251888, upload-time = "2026-05-26T20:39:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/28/30/300c343f68beb9d4cbb64ec81e58c5b6b80b56927f72d2b38654ac26e013/coverage-7.14.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6b6b0853b895fe0e98cbfc580d1ec3393d9302b4b1e96a77b3f5c91fdab899e6", size = 254624, upload-time = "2026-05-26T20:39:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ed/7b25642496e8170b6bac14adce00537c6e5fa2d586159401a4de3e8b49e6/coverage-7.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:442cc9c952b2df400cda54bb04ab87330cf2cd08a8692cbbea36773531eb6f37", size = 255739, upload-time = "2026-05-26T20:39:10.889Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a2/abd210b8c4e29c24e4624916db97bb519097a91034aaeb767f937e7da794/coverage-7.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8270544c361ed405a27a060dbc9ed2c124b084d96dfdc2d9a2510482aef981ad", size = 257998, upload-time = "2026-05-26T20:39:12.722Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/7c50beed3792fe62f6ce0545c6686ce83379719e2c0276179333d97eae92/coverage-7.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:48b283b1dd6372e8de2a7a9a4c4d5dc06f4d4fd209b876f3c88a7a205a0c8f84", size = 252296, upload-time = "2026-05-26T20:39:14.259Z" }, + { url = "https://files.pythonhosted.org/packages/15/05/0f874628ebcbfc77ead559ff210281ef06a97db08481832e7dd39274a135/coverage-7.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5b0c99ba93a07d56f6df340bb79be53202a082b2fdb81bfe6190b741a3470d54", size = 253658, upload-time = "2026-05-26T20:39:15.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/6f/ca6ad067364b337ef997802115e7ecad2abd2248b05471464b0dea02b4d4/coverage-7.14.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e471bc5769ff073b058cfadb0d736b56ce067c8560eabeb0da88462df98c23e7", size = 251803, upload-time = "2026-05-26T20:39:17.537Z" }, + { url = "https://files.pythonhosted.org/packages/c0/30/b9b4d377cd9f40baf228068f5a81faf8450c6228503011bd499708483a50/coverage-7.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f497a1ea81d4cd7c10ddcaa685135b9aabd291af3d55775a9ddf3cb7a364cdd9", size = 255873, upload-time = "2026-05-26T20:39:19.414Z" }, + { url = "https://files.pythonhosted.org/packages/3c/21/7c721a9e5e6bb88547d30a787aefb97512d3f54c1324c7488d9b3743f7f9/coverage-7.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:2222be86d0b54f5dd5a38f45f17f315f737245e857bf0bdedc70734f84a13c02", size = 251372, upload-time = "2026-05-26T20:39:21.169Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f8ae5a2200130e1503cd7661a6cd3b2b7bacef98277fbf3571fb13f8b766/coverage-7.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:85e85586565842f6932abebd4c18bcb1074223dc0b3576e7d173ca710622813a", size = 253245, upload-time = "2026-05-26T20:39:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/34/62/70a9024672a5f6910517d9628c52c9afbdd3cf8f46426af52bb148a56fff/coverage-7.14.1-cp312-cp312-win32.whl", hash = "sha256:4a28fd227808366b196a75476dced2eb35b351d6766ba9c858dc93319e87f4f1", size = 222567, upload-time = "2026-05-26T20:39:24.868Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/8b7cd386839b039ebe1855733b9f9449a8dec5d79564018234f185a7fa70/coverage-7.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:54acdb6674a4661768d7bf7db32dfb9f46ab1d764f8aba6df75ce1a6a088724e", size = 223372, upload-time = "2026-05-26T20:39:26.603Z" }, + { url = "https://files.pythonhosted.org/packages/ae/ba/b44d472022f620d289d95fa830143235c0c36461c6f2437ea8d51e5481ed/coverage-7.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:99cd41ff91afd94896fea3bc002706b6ae4ce95727d06e4a0f39c0a8d8bd8b1a", size = 221989, upload-time = "2026-05-26T20:39:28.242Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/5f6d56327c62b185225d145191c607e07515294a0aa6338e58805cd4a5ac/coverage-7.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:be9f2c802dcfce3f71298303aa5dad0dce440a76c52f2f60dacd8656dab78793", size = 220044, upload-time = "2026-05-26T20:39:29.902Z" }, + { url = "https://files.pythonhosted.org/packages/75/92/e82aca356744cbbc0f77a0b623e38918c1872361963413a3bab5d0340393/coverage-7.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6223a72fd0e4c7156353ec0f08a5f93623e1d3034d0e2683b9bb8ea674131b1d", size = 220412, upload-time = "2026-05-26T20:39:31.561Z" }, + { url = "https://files.pythonhosted.org/packages/27/c9/385bde0bf7ed0f4bf3a7ee5367060a86b5d218718cfd6fb943c0f836b34f/coverage-7.14.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7279d2110a28cebc738b6459ecda2771735a4c18465fbbd36b3288fe5ed92247", size = 251412, upload-time = "2026-05-26T20:39:33.337Z" }, + { url = "https://files.pythonhosted.org/packages/51/8c/23faf6a2343a0d17f960a4bd56c43bc7eb4cf312f774dd6ceebd82c7d8fc/coverage-7.14.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9eeb3fcbc13ba40dfbdb22d01d196a28e9cef9ed4c29b60061a1e0e823a9929d", size = 254008, upload-time = "2026-05-26T20:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/42/06/36f4aa9ca8a815e6036156e80706a67828bb97bd826948244f6996dda957/coverage-7.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f0cfc27c539f07cf5c0a4cfe211d0b6cae039f8f40526dbaa71944e64b50a7b", size = 255241, upload-time = "2026-05-26T20:39:36.71Z" }, + { url = "https://files.pythonhosted.org/packages/ca/79/95266316352f90f6b1c6736bb413302edfde2453fb32422d3911642691b3/coverage-7.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:221c70f316241a78e77e607c227cefc8808d4e08f28d99c04f35694690e940be", size = 257373, upload-time = "2026-05-26T20:39:38.412Z" }, + { url = "https://files.pythonhosted.org/packages/e3/9c/58316d1f66c488b5fca8a0eb3e98348807813efa8a0d0833b9021be27488/coverage-7.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da028256b04ec30e5e0114b6f76172938c313991f0a2d3d894271315cf5d5e43", size = 251635, upload-time = "2026-05-26T20:39:40.268Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5a/ca2398a568e16fed7bb713e84ba3603a7164fb65779abe645c565ec890d5/coverage-7.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76a085d7005236a767e3426148b2c407e53ad61695c562f8a81da2d373324901", size = 253373, upload-time = "2026-05-26T20:39:42.145Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2c/0396562c32deaebe7be51d865b3a41e9a87d7561acafe1a28f53b07e019a/coverage-7.14.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b553d04b5e778a8e56d57eb134aff42a92718ecba45e79c4764ecfa40efd92ff", size = 251341, upload-time = "2026-05-26T20:39:43.907Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8f/a94f9221184c9cae1ee115820e3798e48b6b17777a9f19e46fb9a0c8dc74/coverage-7.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:46f714d2fb8ae2f4f29f23ada7f1e79b759fff5a70f94a1dac23af204c3ec9e4", size = 255497, upload-time = "2026-05-26T20:39:46.166Z" }, + { url = "https://files.pythonhosted.org/packages/71/69/505d70e47db1eaebcd002c39759707621ef184cd6b1ae084d9f41293f323/coverage-7.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1896f5e19ff3f0431c7ce2172adc54890fd97f86b59ced8ca1649145d9ffe35d", size = 251159, upload-time = "2026-05-26T20:39:48.03Z" }, + { url = "https://files.pythonhosted.org/packages/e0/aa/58681c383aa33a9d2ed40a02d7a22fbf780d1fa4d575396365777828198c/coverage-7.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:62fd185ef9df3c33d1c8178c5af105f762afbad96038de9a4ae100aa6297ca33", size = 252934, upload-time = "2026-05-26T20:39:49.872Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fd/11c928cd6bdffc7074bb5965c173d9ebf517fb00205e1da524b98d29ef92/coverage-7.14.1-cp313-cp313-win32.whl", hash = "sha256:ab4af6352741a604c431c6072fce5bee33bf0f20dc7a56618d6bf6bb89e9810c", size = 222584, upload-time = "2026-05-26T20:39:51.68Z" }, + { url = "https://files.pythonhosted.org/packages/6f/92/fb416fc26d340dcba19518c418d6048e913186e17243982c5e435e41fa7a/coverage-7.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:7af486dabe8954d03b087f0021540897afe084f04e16ff5579e08cc46f871416", size = 223394, upload-time = "2026-05-26T20:39:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/73/c6/02d56e3867972f77d5036de924643f26c056e848f00452cafb4dbc3c29b4/coverage-7.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:2224f89ffd0c5605ccce1ed7a584da162bc7c55f601ab1c946bc9de31a486b42", size = 222015, upload-time = "2026-05-26T20:39:55.374Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/fcc77914050df73f7662fa1f00902774c79c075a8388ab334074574bf77e/coverage-7.14.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:de286598cc65d2b489411174b1faec2f5a7775fb3201fd925db2a76b4030f37d", size = 220733, upload-time = "2026-05-26T20:39:57.189Z" }, + { url = "https://files.pythonhosted.org/packages/f7/67/2963cbdaf5cbadec44efa3a1e39eaa1f02df4079585f05387607a221e126/coverage-7.14.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:042c46ded7c288aeb07cf14a28b6c1e10b78fcba40171c3fa1e939377eeef0b5", size = 221086, upload-time = "2026-05-26T20:39:59.019Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/8701645574e11881f2f47d8930f98bc48b5d43b25eb5b4430dfc4a2f9f48/coverage-7.14.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f4ddbe407477f04c45115d1a4e5bc480f753553b534d338d4c3358b1cdd0ea52", size = 262381, upload-time = "2026-05-26T20:40:00.822Z" }, + { url = "https://files.pythonhosted.org/packages/7c/28/7a64d73598263e0c5abd5084211a8474488d31b3c552ff531c719dfcff62/coverage-7.14.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d13e6725992e2d2fd7d81d4f5241952d13740121dfd501da09201be39b2c003a", size = 264458, upload-time = "2026-05-26T20:40:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d8/4969179db9f7eb4df218e69540adf829d1c835f59452513d065d15446802/coverage-7.14.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f747dc8edcfe740130f28f32f3995e955494285717e86ee25af51db2219df08a", size = 266884, upload-time = "2026-05-26T20:40:04.421Z" }, + { url = "https://files.pythonhosted.org/packages/a6/78/a45d5794dbc9bafd97afc96a4377c86c7820d78b6cf51b89bc1d4e919275/coverage-7.14.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced2f09ef276fd58611a1ef502164ad266d2b75174e5a40cabbdb4033f9f6cf2", size = 268022, upload-time = "2026-05-26T20:40:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/21/cb/4f5e354e9e3e67af96bd4e57113e6db6b22298c7168b13eec408a549903d/coverage-7.14.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b84800013769a78ccb9ef4659402e26d06867e337b61ec365f77ad008adea80e", size = 261631, upload-time = "2026-05-26T20:40:08.226Z" }, + { url = "https://files.pythonhosted.org/packages/ec/49/eced49af4cb996d5d8b7e94e736175c513e4facd3398507b89892b4326d8/coverage-7.14.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ea8cd6ca0ee9f616aaef3afc6882e32c2cbf18b00d96313ffd76af650574034d", size = 264443, upload-time = "2026-05-26T20:40:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/f1/d8/5603a88a7c5913a6b54f6cb1a8c46f7b39cbb30f27cd3f492908da09b2d7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:aa5e304a873fabddc11e484e9b6b738bd38bd7bed17b09aa84eecf5332e8b8bb", size = 262069, upload-time = "2026-05-26T20:40:11.999Z" }, + { url = "https://files.pythonhosted.org/packages/f0/59/2ae3cb79da554a06c8619d6c88ea19dd1e4aed4b834b6a83bb1fa243bdc5/coverage-7.14.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5a1c5215be81035e629d5bc756650634d0bf31991038db7a0eccb90f025ce16d", size = 265780, upload-time = "2026-05-26T20:40:13.858Z" }, + { url = "https://files.pythonhosted.org/packages/af/5f/b130c1dc999031f2648bd25317fbce505ad8d5562079b4ed81e736a84967/coverage-7.14.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:79058c47dae6788504b5effb319961bcd72d7240551464b91d474bc0ed186d69", size = 260970, upload-time = "2026-05-26T20:40:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/87/d1/ec13ccddeb48ec963bdfa72a11224bac2584bd045ba13beca82f8113e9c7/coverage-7.14.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:370c5afae3fa0658e11694a32b24c2778f6bc2d17718121f94ee185e69f26b54", size = 263157, upload-time = "2026-05-26T20:40:18.382Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c2/cd91ead503045161092d3845f7bb95ea2f25131ce96d3e314dd835d91b9c/coverage-7.14.1-cp313-cp313t-win32.whl", hash = "sha256:3758dd0a7f1fa57365ef2e781df0f0731d38b6e3772259d13dae4bd8a958d4b1", size = 223259, upload-time = "2026-05-26T20:40:20.381Z" }, + { url = "https://files.pythonhosted.org/packages/71/9f/1e28d97e6bd2c76b07f38b7c02870f1371255ff6717f54eca578fcbbdd0e/coverage-7.14.1-cp313-cp313t-win_amd64.whl", hash = "sha256:6ff665fb023a77386fe11685190cee1f60a7d635994a30d9b0a061533d470fce", size = 224320, upload-time = "2026-05-26T20:40:22.316Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e0/d936e908f0e1efa55e52b91e01b52f1055cef5e1ab2718493390ed8e2fb8/coverage-7.14.1-cp313-cp313t-win_arm64.whl", hash = "sha256:17a5a241e5997621a956a7f402a7433ef4221e5152809b785bec79e2323799f1", size = 222577, upload-time = "2026-05-26T20:40:24.894Z" }, + { url = "https://files.pythonhosted.org/packages/d6/34/fc2f101b151af3799a101f0550b0454aa008afdc0add677394ec4aa8ea10/coverage-7.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d5ed429d0b8edaac649e889b4ffcedb6c80b06629a3f93050e3dddfb99235bee", size = 220091, upload-time = "2026-05-26T20:40:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a7/1ebae2ab5b961b5c79bb09fe7b3ac99edb190d8be4a8c510b2cf66f46468/coverage-7.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8011224a62280e50dab346960c03cf47aca1a1e09e608c0fb33fd6e0cc8e9500", size = 220421, upload-time = "2026-05-26T20:40:30.084Z" }, + { url = "https://files.pythonhosted.org/packages/5e/90/92aca9cf0acc95123c96cd1eb1f08917897a7f5dee01e15738922971ec31/coverage-7.14.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c42ec1e14f553c4f817e989365982e646e27211f10a0f717855b94a79c8906", size = 251466, upload-time = "2026-05-26T20:40:32.542Z" }, + { url = "https://files.pythonhosted.org/packages/26/2b/78048cbe3b999f6cbf9cc0d90abba6a88a3e0863a8c1c6cbc762f3f8802f/coverage-7.14.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06144cd511cf2624873a035c5069cf297144f6e77a73ee3d7a55b605ec5efb42", size = 253973, upload-time = "2026-05-26T20:40:34.473Z" }, + { url = "https://files.pythonhosted.org/packages/8e/21/c2e33b29d1cfde484a19d437afc343c6cd30b08d78cbbf9f5aff14e57b2b/coverage-7.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a311d8e1da24be5c1ccf85cbfb06315dbaa1703d5a1eab3f6432c72b837917c8", size = 255318, upload-time = "2026-05-26T20:40:38.154Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ee/aad2f108d63b769121005302f16bf66db8625c88ceaba466942e09a2607e/coverage-7.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c79cead5b5bc584d9c71451cb984d0e3a84e0c0937379c8efcbf27c8d661b851", size = 257633, upload-time = "2026-05-26T20:40:40.164Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f8/11a2c29b4fd76d9849f81d0bb812ec0017a9396df3217214e38934a8c837/coverage-7.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dcbf65f1f66a26cdd88c35cf68fb4729c5d1cd2e88added72420541dfb212034", size = 251488, upload-time = "2026-05-26T20:40:42.631Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b8/9a5820de4b8ac2b71d85e3b5fb49108d7469c665f0e2ad0dd7569023e305/coverage-7.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fd86572566fb40189a8260446158235159bc7a82dfbc87a3b39cf4fb57fcec1c", size = 253329, upload-time = "2026-05-26T20:40:45.208Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ff/f33e4823667e27548e8fd8df44217515303f9808d0ff29817db56f87d990/coverage-7.14.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7771b601718fdde84832c3a434ca9bbf4ae9adbc49d84198b4110700c3c77c36", size = 251291, upload-time = "2026-05-26T20:40:47.502Z" }, + { url = "https://files.pythonhosted.org/packages/68/9b/489db0ebb209054766b90a9014a45f6d26eb724c02ec21311c3733b5a644/coverage-7.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:39b21e212c55af06fa375e3dbf90a8a8e38792f3a910c580066d23563830ddd5", size = 255564, upload-time = "2026-05-26T20:40:49.372Z" }, + { url = "https://files.pythonhosted.org/packages/27/b5/16bc2d4c2409b23c7737edb68c83bc89e345f378050549fe1d75ac7d34d5/coverage-7.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f2302660e32562a532b442480121aef8aa61a5bdb20b30bf0adab29f10a5a4b4", size = 251107, upload-time = "2026-05-26T20:40:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/7d/0c/2629997469a00cd069d588a41c9dc887610f2775ae89d250c4791e65272a/coverage-7.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:03a6f93c1ec3b7f2e77b5dbcc5573a2c21f12529a5c6bbe0f16f72303cc2fa4d", size = 252764, upload-time = "2026-05-26T20:40:54.267Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ee/f78d63c8f079e0d7211c7e2401fa17e311514534ba61bae03e4b287ce4ab/coverage-7.14.1-cp314-cp314-win32.whl", hash = "sha256:8a3ce026d73290f42f08dafecbd82c193a74df280461fbf97300fec51fd133ee", size = 222837, upload-time = "2026-05-26T20:40:56.496Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b9/be539854f93a70dfbeec69117f33ec70dc42ff0b65b5b07ab8d40d04228e/coverage-7.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:114c95ef29302423b87d159075805f4ab973254a2638a5d7d046c94887cc87d7", size = 223650, upload-time = "2026-05-26T20:40:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9e/24e2842fef40f35ac82ba3a7719c8023d011bf3bf652d0675316a9d088a1/coverage-7.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:a07891c3f4805442b31b71e84ba3cf29ed1aa9a428284e06deeb4b23e5b46343", size = 222218, upload-time = "2026-05-26T20:41:00.321Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1d/ac0a9df5fe31c1e8bdd658074905fc12844a05c1a7e3fdb8417e97c31e23/coverage-7.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1101a5ebb083aecb625ebb6209d4105b58f647b093cb2dc8122d7b33f743cfe1", size = 220822, upload-time = "2026-05-26T20:41:02.281Z" }, + { url = "https://files.pythonhosted.org/packages/32/cf/f964fd9aff20323f9f1a726c97135f8a76bcd87b92dad141a456a43f3c64/coverage-7.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:851b9e1e4e8a4608e77c79714b2e77c0970d2ed7202a05e92ae407817481887b", size = 221084, upload-time = "2026-05-26T20:41:04.593Z" }, + { url = "https://files.pythonhosted.org/packages/d8/5e/7e5ef2aba844de2b80d678619fcf0841b42e3f37f16411226f3fe4c1016f/coverage-7.14.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d5b89cdfb2ee051b71e8c3c70bd81a9eff81100f736a269136fe1a68efe00474", size = 262454, upload-time = "2026-05-26T20:41:06.641Z" }, + { url = "https://files.pythonhosted.org/packages/64/62/75809bded87015cc4935524218a2a8ed8dd1a8498bfed30a2f4f7a4b4d34/coverage-7.14.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0177614a0370f227888b4e436a7c55686d6a9f90eb1ade2b624ba685a1686e86", size = 264578, upload-time = "2026-05-26T20:41:08.556Z" }, + { url = "https://files.pythonhosted.org/packages/f3/42/d33392dc14633525012d2d504fa1a33b05538bf535f5c1d64675e5754b78/coverage-7.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2d69af5dea2de76fc485a83032a630523f985198b7e25be901ec60181587b01e", size = 266981, upload-time = "2026-05-26T20:41:10.824Z" }, + { url = "https://files.pythonhosted.org/packages/2a/49/0157c4428c2aca7f1e09d5565930586fd5ae36f1655f08b0daa7cf1fcae1/coverage-7.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:35ab22d91de736e8966b980dc355cbcdd2c6dbbcfe275f9a2991bc8a91b3df65", size = 268112, upload-time = "2026-05-26T20:41:12.966Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/86b9ce71f4092b1ed325ce1421698081df1286b833400b6836912834d6e0/coverage-7.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:357d4e32935c36588aaba057d734fa32428c360c9fc2e4442afbf1b646beee6e", size = 261558, upload-time = "2026-05-26T20:41:15Z" }, + { url = "https://files.pythonhosted.org/packages/20/4c/c311210c5472cf5401d8422b0d7812cdd520f24417673afabda6c323faca/coverage-7.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:51bd64741cc6fa065abd300ede1afe5a5291ece9c31da8b24884deda48bcc3f8", size = 264447, upload-time = "2026-05-26T20:41:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/fb/71/59513f8710ed3e6b0ac0a050a5b7e977bb9c9e880354863b5d00d8809256/coverage-7.14.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9132cd363a68a4c3daa7c8704a654b1e39d3360f6f5b8ddd470608a945236c07", size = 262048, upload-time = "2026-05-26T20:41:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/bceed32dc494f5bbf50f775cd2e78ca814953942b5ea28d3c1c3ac316f14/coverage-7.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:07c6290b1697b862c0478eab545eec949a0d0e4d6d03497f446d706da3b4f2de", size = 265781, upload-time = "2026-05-26T20:41:21.559Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c5/9348fe40dbfd4991aaf78df2c6c3098bfb2cc834d1fd362a64b4efef855a/coverage-7.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5ea0c297e27133853b4d8a3eb799bff5a2dbd9f2f41537a240d337ac9b4df890", size = 260896, upload-time = "2026-05-26T20:41:23.428Z" }, + { url = "https://files.pythonhosted.org/packages/ca/92/1ea0f03929da7cf87206b1fa24f4c8e9c158be0455481af29ec0a1f3503f/coverage-7.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:01b7733daad0237daa01ef80fe2dfceffc911e6a17fa7b55d14aa8214eaaaecd", size = 263214, upload-time = "2026-05-26T20:41:25.419Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a9/b2493c054c0e01a643266742ab45e15744e60743f9260cd930c7142b1124/coverage-7.14.1-cp314-cp314t-win32.whl", hash = "sha256:6adc5a36984624a70bf11d7184e20fa0a49aa7c47ffab43804106a1a695ea22e", size = 223624, upload-time = "2026-05-26T20:41:27.795Z" }, + { url = "https://files.pythonhosted.org/packages/fc/bd/3e1e6a57fccd2d7c83fcdf338e93ba98eb85c6e877dd34731ac585375490/coverage-7.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:ddf799247318f34dbcd2efa8c95a8d0642674e926bb1774cf9b63dfd2a389d1c", size = 224728, upload-time = "2026-05-26T20:41:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/31066cf1d2f0c6c797fce911bcfa01dd35642dc6da992a950256097c5860/coverage-7.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:145986fe66647eb489f18d9a997567a3fd358584c4b5a808769113abc07466af", size = 222752, upload-time = "2026-05-26T20:41:32.123Z" }, + { url = "https://files.pythonhosted.org/packages/8a/3c/1a983b9a745d7f83d53f057bcc5bf79ba6a2bbc08266b3f0c7d6fe630c9b/coverage-7.14.1-py3-none-any.whl", hash = "sha256:a252f21c27e38347e60111a3266b03827422a7d5525951aceee313aa68bab1d2", size = 211815, upload-time = "2026-05-26T20:41:34.078Z" }, ] [package.optional-dependencies] @@ -758,30 +758,30 @@ toml = [ [[package]] name = "cuda-bindings" -version = "13.2.0" +version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/fe/7351d7e586a8b4c9f89731bfe4cf0148223e8f9903ff09571f78b3fb0682/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b395f79cb89ce0cd8effff07c4a1e20101b873c256a1aeb286e8fd7bd0f556", size = 5744254, upload-time = "2026-03-11T00:12:29.798Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ef/184aa775e970fc089942cd9ec6302e6e44679d4c14549c6a7ea45bf7f798/cuda_bindings-13.2.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6f3682ec3c4769326aafc67c2ba669d97d688d0b7e63e659d36d2f8b72f32d6", size = 6329075, upload-time = "2026-03-11T00:12:32.319Z" }, - { url = "https://files.pythonhosted.org/packages/ec/ea/81999d01375645f34596c76eb046b4b36d58cc6fe2bddb2410f8a7b7a827/cuda_bindings-13.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:845025438a1b9e20718b9fb42add3e0eb72e85458bcab3eeb80bfd8f0a9dab33", size = 5600047, upload-time = "2026-03-11T00:12:34.848Z" }, - { url = "https://files.pythonhosted.org/packages/e0/a9/3a8241c6e19483ac1f1dcf5c10238205dcb8a6e9d0d4d4709240dff28ff4/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:721104c603f059780d287969be3d194a18d0cc3b713ed9049065a1107706759d", size = 5730273, upload-time = "2026-03-11T00:12:37.18Z" }, - { url = "https://files.pythonhosted.org/packages/e9/94/2748597f47bb1600cd466b20cab4159f1530a3a33fe7f70fee199b3abb9e/cuda_bindings-13.2.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1eba9504ac70667dd48313395fe05157518fd6371b532790e96fbb31bbb5a5e1", size = 6313924, upload-time = "2026-03-11T00:12:39.462Z" }, - { url = "https://files.pythonhosted.org/packages/29/5a/0ce1731c48bcd9f40996a4ef1abbf634f1a7fe4a15c5050b1e75ce3a7acf/cuda_bindings-13.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:debb51b211d246f8326f6b6e982506a5d0d9906672c91bc478b66addc7ecc60a", size = 5631363, upload-time = "2026-03-11T00:12:41.58Z" }, - { url = "https://files.pythonhosted.org/packages/52/c8/b2589d68acf7e3d63e2be330b84bc25712e97ed799affbca7edd7eae25d6/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e865447abfb83d6a98ad5130ed3c70b1fc295ae3eeee39fd07b4ddb0671b6788", size = 5722404, upload-time = "2026-03-11T00:12:44.041Z" }, - { url = "https://files.pythonhosted.org/packages/1f/92/f899f7bbb5617bb65ec52a6eac1e9a1447a86b916c4194f8a5001b8cde0c/cuda_bindings-13.2.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46d8776a55d6d5da9dd6e9858fba2efcda2abe6743871dee47dd06eb8cb6d955", size = 6320619, upload-time = "2026-03-11T00:12:45.939Z" }, - { url = "https://files.pythonhosted.org/packages/bb/a5/d7f01a415e134546248cef612adad8153c9f1eb10ec79505a7cd8294370b/cuda_bindings-13.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:45815daeb595bf3b405c52671a2542b1f8e9329f3b029494acbfcc74aeaa1f2d", size = 5840830, upload-time = "2026-03-11T00:12:48.43Z" }, - { url = "https://files.pythonhosted.org/packages/df/93/eef988860a3ca985f82c4f3174fc0cdd94e07331ba9a92e8e064c260337f/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6629ca2df6f795b784752409bcaedbd22a7a651b74b56a165ebc0c9dcbd504d0", size = 5614610, upload-time = "2026-03-11T00:12:50.337Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/6db3aba46864aee357ab2415135b3fe3da7e9f1fa0221fa2a86a5968099c/cuda_bindings-13.2.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7dca0da053d3b4cc4869eff49c61c03f3c5dbaa0bcd712317a358d5b8f3f385d", size = 6149914, upload-time = "2026-03-11T00:12:52.374Z" }, - { url = "https://files.pythonhosted.org/packages/c4/84/d3b6220b51cbc02ca14db7387e97445126b4ff5125aaa6c5dd7dcb75e679/cuda_bindings-13.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:8cebe3ce4aeeca5af9c490e175f76c4b569bbf4a35a62294b777bc77bf7ac4d8", size = 5796512, upload-time = "2026-03-11T00:12:54.483Z" }, - { url = "https://files.pythonhosted.org/packages/c0/87/87a014f045b77c6de5c8527b0757fe644417b184e5367db977236a141602/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6464b30f46692d6c7f65d4a0e0450d81dd29de3afc1bb515653973d01c2cd6e", size = 5685673, upload-time = "2026-03-11T00:12:56.371Z" }, - { url = "https://files.pythonhosted.org/packages/ee/5e/c0fe77a73aaefd3fff25ffaccaac69c5a63eafdf8b9a4c476626ef0ac703/cuda_bindings-13.2.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4af9f3e1be603fa12d5ad6cfca7844c9d230befa9792b5abdf7dd79979c3626", size = 6191386, upload-time = "2026-03-11T00:12:58.965Z" }, - { url = "https://files.pythonhosted.org/packages/e3/73/98bcb069778fe420226db75aff54b5dd6c3ecfd0912edabab723326e80b7/cuda_bindings-13.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:bd658bb5c0e55b7b3e5dd0ed509c6addb298c665db26a9bfba35e1e626000ba2", size = 5938605, upload-time = "2026-03-11T00:13:01.639Z" }, - { url = "https://files.pythonhosted.org/packages/5f/58/ed2c3b39c8dd5f96aa7a4abef0d47a73932c7a988e30f5fa428f00ed0da1/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df850a1ff8ce1b3385257b08e47b70e959932f5f432d0a4e46a355962b4e4771", size = 5507469, upload-time = "2026-03-11T00:13:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/1f/01/0c941b112ceeb21439b05895eace78ca1aa2eaaf695c8521a068fd9b4c00/cuda_bindings-13.2.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8a16384c6494e5485f39314b0b4afb04bee48d49edb16d5d8593fd35bbd231b", size = 6059693, upload-time = "2026-03-11T00:13:06.003Z" }, - { url = "https://files.pythonhosted.org/packages/52/49/4e01cc06447d39476e138d1b1adec8d35c0d04eccd2c8d69befc08cd66e8/cuda_bindings-13.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6ccf14e0c1def3b7200100aafff3a9f7e210ecb6e409329e92dcf6cd2c00d5c7", size = 6662637, upload-time = "2026-03-11T00:13:07.881Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1f/5ef51f5fbaa5d4d3201bb3d7555af028ec1aa4416275ccbf73c9e34e3d2d/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0", size = 6675244, upload-time = "2026-05-29T23:11:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/fc/64/bb17e4d168569ef7be05c44474fe3dc19278d60a69ba228e45a431c86444/cuda_bindings-13.3.1-cp310-cp310-win_amd64.whl", hash = "sha256:c0c4b1a995098c46695c24257a342dc97d6e6d3f3050b944c9f43bd26d734051", size = 5625597, upload-time = "2026-05-29T23:11:40.808Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/93/f7/0e35987a21914f84068061dcf4b61466ccbce1c62ddc9727596d5ed0c26f/cuda_bindings-13.3.1-cp311-cp311-win_amd64.whl", hash = "sha256:507b0e19e7f934c5e30f30f0244ad70a75812619a7d3a0d742543caae1bd50f1", size = 5664286, upload-time = "2026-05-29T23:11:47.719Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/7c/95/872a0392122f1fb43fcb06869790ef3171f37beee9f7db8f441739113570/cuda_bindings-13.3.1-cp312-cp312-win_amd64.whl", hash = "sha256:b134dd8c5c66ae4c4ad814f7aee88fd215353c077010cbc47e3b55ed35ec9eff", size = 5875099, upload-time = "2026-05-29T23:11:54.635Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2f/6a0dd496550c6fafbf6aeb1bf40242eeabb2fd138a43892aabb4be8224c2/cuda_bindings-13.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:18c8c167c8907b8f02531ca810534315c458dabef31f7965095619bf647b9202", size = 5830027, upload-time = "2026-05-29T23:12:01.205Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/45/2734be44dbc80ac082ec23a86b41c8294992dcb90033645ed1bc50aafe4c/cuda_bindings-13.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:8de12ef60bf40756852cb62bbb40460609269f6ece522903d1cc93d73a3ececb", size = 5961055, upload-time = "2026-05-29T23:12:07.971Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, + { url = "https://files.pythonhosted.org/packages/27/2a/b59bcac016ab9985d6b48a5d05b0d698461a159ca03ee11c4abd54da2ac4/cuda_bindings-13.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61120b5e4f4a63f67efd7e7396914cb9ef871bb1f0021e990fb70277be240a4d", size = 6740329, upload-time = "2026-05-29T23:12:15.153Z" }, ] [[package]] @@ -790,7 +790,7 @@ version = "0.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/b3/b2/c9bb66a400baae14d5cd25cca9e7cf69f9328543edb37efa6e7ba1088cc7/cuda_core-0.7.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8feaf99613cd1d025b26374b1c92ff72d9e8078a3236fc78aa550ff3b0028e3", size = 28962792, upload-time = "2026-04-08T17:03:00.541Z" }, @@ -815,10 +815,10 @@ wheels = [ [[package]] name = "cuda-pathfinder" -version = "1.5.4" +version = "1.5.5" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/11/d0/c177e29701cf1d3008d7d2b16b5fc626592ce13bd535f8795c5f57187e0e/cuda_pathfinder-1.5.4-py3-none-any.whl", hash = "sha256:9563d3175ce1828531acf4b94e1c1c7d67208c347ca002493e2654878b26f4b7", size = 51657, upload-time = "2026-04-27T22:42:07.712Z" }, + { url = "https://files.pythonhosted.org/packages/11/c8/26f2e4aae92f11522a96043892ba39a90eac610d5242523aa863212bc1c7/cuda_pathfinder-1.5.5-py3-none-any.whl", hash = "sha256:0228c023f95d1480f143ef5c8922d27a2ab052087a942e81dc289c9eb8f91689", size = 51671, upload-time = "2026-05-27T01:21:25.413Z" }, ] [[package]] @@ -845,28 +845,30 @@ wheels = [ [[package]] name = "cupy-cuda13x" -version = "14.0.1" +version = "14.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/0c/9d/5f271070c17aac8e30b140b5ec9c129983f57b49899a8fc34c3f114d09f7/cupy_cuda13x-14.0.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:4d6d5ae87eb1a6eb55f5a3d54c606f5263c54319c3d85afe4627cb7fff403050", size = 72993295, upload-time = "2026-02-20T10:23:48.346Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/bd6867acfa356c822971d90792e814fce1fcfc54baaa20b1cadb8eec5c52/cupy_cuda13x-14.0.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:3d292c204eb7a2cfe04698b26de44b4173a1c576f603d6da9f0b0b6444225a63", size = 68923997, upload-time = "2026-02-20T10:23:53.046Z" }, - { url = "https://files.pythonhosted.org/packages/c5/62/25a256acdc0127c70153799a3b094adbeee11630e3be118f8a840415cafc/cupy_cuda13x-14.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:a3e34e0b29e09a4f80c9c1fef963cd75540fb003cbbb6183ac3f784a8a02aef3", size = 35450515, upload-time = "2026-02-20T10:23:56.602Z" }, - { url = "https://files.pythonhosted.org/packages/37/a2/84f2a9739e914cb119807b8b91d48b2c7628794e4eee66daa3402a2d7442/cupy_cuda13x-14.0.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:5c368d7b9fa9eef8a5d209d6335f468f84893cf875defdd1d195b6b27da941c9", size = 74276076, upload-time = "2026-02-20T10:24:00.841Z" }, - { url = "https://files.pythonhosted.org/packages/5b/fe/064f73bab30460eb9024e1a52319b8b69ef0c1cd853301189d1b4579e686/cupy_cuda13x-14.0.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:97fe8f0ff683ed5f39b82d0e56ad90368fc001c1b191a3bc662d31a0ac8cb482", size = 70267352, upload-time = "2026-02-20T10:24:05.105Z" }, - { url = "https://files.pythonhosted.org/packages/df/3c/a0de0513ad575f3a9f9543efd9ae408303101205c206d9cfe5a37b59bddc/cupy_cuda13x-14.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9dda18b7100a19a54536b4325e63a29c1fb58891a0973623c6c8c80642b3f933", size = 35450158, upload-time = "2026-02-20T10:24:09.205Z" }, - { url = "https://files.pythonhosted.org/packages/7d/9b/7983b4e24749937dd4ab34565561a8c015e88df4ff9fd4678337b710b3ee/cupy_cuda13x-14.0.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:763f33f5641a28db5ac91788301271930e910813f1b1279119b504babeb1b863", size = 73616635, upload-time = "2026-02-20T10:24:14.115Z" }, - { url = "https://files.pythonhosted.org/packages/5c/d4/42f79f6baea881c604982df18d158456e40071925bfdd53c1b6eb82f6e75/cupy_cuda13x-14.0.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:a06cdc0d6a0054663fca4770e32df6a39cbeb7396a08b23f97965e5e1c0edb7d", size = 69793920, upload-time = "2026-02-20T10:24:20.287Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0c/83c6be011fa00a270c0d08985844fd992d59c34a6bb91755dad4f31942e8/cupy_cuda13x-14.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:e91052340bab0860bdf1fd4e5a4fc85846800df6cc9a1de02a5afa1dd8655550", size = 35353554, upload-time = "2026-02-20T10:24:24.337Z" }, - { url = "https://files.pythonhosted.org/packages/6e/de/679a7a571dcd1b654378cc4f9c5cd6f6d4af09ec8973eb4b2dce276adce4/cupy_cuda13x-14.0.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:a8488a6a9c934c417f98078ac7dd2a47059c39195199b7e557c84d01b3071c75", size = 73167833, upload-time = "2026-02-20T10:24:30.645Z" }, - { url = "https://files.pythonhosted.org/packages/e4/05/f60525718e83c0ea10efc4ae3183ea9c5309935b76c200b6d9c2569185de/cupy_cuda13x-14.0.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:1c206483aaea40cd38bfbd1a29f4df0c19d555d306ffe43ef16f39db0e7e7a7f", size = 69416450, upload-time = "2026-02-20T10:24:35.982Z" }, - { url = "https://files.pythonhosted.org/packages/73/4e/8b4b996690d91745e34ce6103fbf8a9fb51f2e46b4e47c45ea8b3fff9d2b/cupy_cuda13x-14.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:cd01307b4f67e3bf242537f02ac8e5db729a557510728f70ee41a32490ce1e1a", size = 35336403, upload-time = "2026-02-20T10:24:40.355Z" }, - { url = "https://files.pythonhosted.org/packages/e0/f9/e5480aefcc86cfa45abe12c323e8e65b8a04727c227f67dff1cae99ead1b/cupy_cuda13x-14.0.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:70a5b3d4b20367abf978e2c75bd0235f0768b5901a6102398877aef044628151", size = 73012147, upload-time = "2026-02-20T10:24:45.12Z" }, - { url = "https://files.pythonhosted.org/packages/61/9e/cd8ddc220283272a7891a8277fb911a584a9224bd1c8f56d75ca6f62d976/cupy_cuda13x-14.0.1-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:2e6fbfb24bc336ba91507e9e6488589665a4c7366453bb80c717d874fce3c373", size = 68714185, upload-time = "2026-02-20T10:24:49.984Z" }, - { url = "https://files.pythonhosted.org/packages/c1/f5/273193563cdc37cdb22de3b73e7db12819b39fafb73de6bcf7d48f20945e/cupy_cuda13x-14.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:22b50139e05c4612fac905dd6c3390f8687e0e390f0e200d5be14be1726e3d04", size = 35474838, upload-time = "2026-02-20T10:24:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/18/e9/19185f325905eada94ed96f649a2b92336dcb6caa62a5adac514bb2f0de5/cupy_cuda13x-14.1.0-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:80f169592f5bbf7cb411ce4eeda315d06addb1a3cb14ed668e9d2ba287e3e4d4", size = 72670820, upload-time = "2026-05-23T01:11:17.547Z" }, + { url = "https://files.pythonhosted.org/packages/25/0a/0796a2465c86b7656e7b4e0aaa2b5e17f1ae2d088db1a43a1ffd83e9a468/cupy_cuda13x-14.1.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:de745ef499998d44e96dfa9be1f442047299bbc5d9c7a0738e378375a18ed7c5", size = 68695023, upload-time = "2026-05-23T01:11:21.78Z" }, + { url = "https://files.pythonhosted.org/packages/1c/55/ac9107d7b0d58fcf671b0b32a7905709b7c767abd6d91256648498fb2831/cupy_cuda13x-14.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:49f026a1526f00adbf7b56ee28e986725e5ce1e1cd455f2a1a56ff74e5049e28", size = 35289284, upload-time = "2026-05-23T01:11:24.926Z" }, + { url = "https://files.pythonhosted.org/packages/20/a0/b7dc0aff6097b2fb19535715fe1b98a113bdddf3eea56ac1acd878c77f43/cupy_cuda13x-14.1.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:dfc24cceaa8234272159419f2b0763c78968909e1c31120ba83c22fdca91872f", size = 73971015, upload-time = "2026-05-23T01:11:28.359Z" }, + { url = "https://files.pythonhosted.org/packages/4a/31/e6a8e5786d5038417963d50041c6dbb1b0376091f6c170e036a8997120c2/cupy_cuda13x-14.1.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:079d99f6c492f24692f7904c013fc164c2b3ce605b2036293d0f2f6ae31dc3a8", size = 70032754, upload-time = "2026-05-23T01:11:32.001Z" }, + { url = "https://files.pythonhosted.org/packages/64/6b/3d4a6d766a055e1dfb36d66655c818ae5b999ba12d6723966ee6c4eb4ba4/cupy_cuda13x-14.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:1ee0839c5bfa801cd6f5181fced40d38ca90538f4723e17614fccd47292c4450", size = 35290568, upload-time = "2026-05-23T01:11:36.073Z" }, + { url = "https://files.pythonhosted.org/packages/23/7e/21afe0ab60bd88acbfa0daa6e1d7beff31fc9ec72702173520f58236daec/cupy_cuda13x-14.1.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:9b83ce4965466bf2bf99aaa6ce594c0547ca696f451676cd1d46124443476fb0", size = 73277053, upload-time = "2026-05-23T01:11:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2e/d3780ce0a8d951e632349bb989d8536e77e03e8ff1c419922ed933f3ba55/cupy_cuda13x-14.1.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:3d3a64a850f5560239b942a833a6498e2d9a5fff4efbb6e93cddfa8c4f53b9cf", size = 69532518, upload-time = "2026-05-23T01:11:44.38Z" }, + { url = "https://files.pythonhosted.org/packages/99/2d/5faee3a1626deac5feef290fd52460ad57460009e9738ec4f0e62d40af5b/cupy_cuda13x-14.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:9e42ff785aec56e76ffc05fc0d92f7eab2a970dc7c377ad61288a98aa10e7db8", size = 35192581, upload-time = "2026-05-23T01:11:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/04/8a/31c58ffa8e1780c8f15492018997d5fb3548427f5fa0e6327becf26f00c6/cupy_cuda13x-14.1.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:42f85f692a589b92a86627113e1072c534cc9d9047433b4291b8bd7b49fb238a", size = 72812202, upload-time = "2026-05-23T01:11:51.015Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/be34e911811a0369e3e66b1b618dfccdea1f84ad6a2cc17f146ce9095e99/cupy_cuda13x-14.1.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:eef76f0647af5a9bfe3d0bac641f201deb5f15a644f4b13a2e68c0c62e6808fe", size = 69094718, upload-time = "2026-05-23T01:11:57.103Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c1/c92b4c9c2c561c1298dfad2e3dbb75e527ac1a15d567d8fb868fc277c80e/cupy_cuda13x-14.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:18338008d428bf04dcd73ab2489c6f90c39b7ff439ab2b609115b9bb0e16e0ec", size = 35171775, upload-time = "2026-05-23T01:12:00.57Z" }, + { url = "https://files.pythonhosted.org/packages/24/b4/8963aced61d836417ca681d7cd1b6b27916ed877a01f3adf7c902bd8f392/cupy_cuda13x-14.1.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:fa77dc442a166e784e28e5c7dd4e1bb4b2ac54a2ec55c59c1af3c0ed27e14f69", size = 72680065, upload-time = "2026-05-23T01:12:03.979Z" }, + { url = "https://files.pythonhosted.org/packages/8b/a8/2ae8d8235a236564e65a73812ee0bf4c1afe96b7104f754f100832f015e9/cupy_cuda13x-14.1.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:7824fc7757bac2d91d6c810c4f5376359c1eeaf5d37c2625c4a670705f3d9f36", size = 68432300, upload-time = "2026-05-23T01:12:07.777Z" }, + { url = "https://files.pythonhosted.org/packages/ee/12/8e1d2b9efda12e8ad5c648f1fc289454b7c6210c2f4d9e99d7663178d8e1/cupy_cuda13x-14.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f3c9f5e61fefc7920bcd3da9818679ea8f9319cca080dd88118f463a2ca08e41", size = 35323531, upload-time = "2026-05-23T01:12:11.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/bc/6cceec523fd467fc6259c4c394d87a9e7e3b8f011685da028d9862bf8497/cupy_cuda13x-14.1.0-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:e5b2a0dcaa5fb27faf034f5e3d271eaa13c4a5351e2c6dec925a866c210f3605", size = 72984031, upload-time = "2026-05-23T01:12:14.388Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/180777ccc0f85ce4469f3e6e615c471968f482fed85827410090adf40523/cupy_cuda13x-14.1.0-cp314-cp314t-manylinux2014_x86_64.whl", hash = "sha256:69b314f0085d7ad46a671338e62b3f35270c144c31df8c443c0341b97a905980", size = 68662879, upload-time = "2026-05-23T01:12:18.04Z" }, ] [[package]] @@ -881,7 +883,7 @@ dependencies = [ { name = "custabilizer-cu13", marker = "python_full_version >= '3.11'" }, { name = "custatevec-cu13", marker = "python_full_version >= '3.11'" }, { name = "cutensornet-cu13", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "nvmath-python", marker = "python_full_version >= '3.11'" }, ] wheels = [ @@ -973,11 +975,11 @@ wheels = [ [[package]] name = "decorator" -version = "5.2.1" +version = "5.3.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711, upload-time = "2025-02-24T04:41:34.073Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/8b/32f9823da46cde7df2087faa08cd98d01b908f8dcab982cdba9c84e85355/decorator-5.3.1.tar.gz", hash = "sha256:4cbcdd55a6efadb9dbea26b858f4fb3264567b52d69ca0d25b721b553f60ea82", size = 58084, upload-time = "2026-05-18T06:03:28.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, + { url = "https://files.pythonhosted.org/packages/05/7f/798705f5296a58ca505d600456748d1be48078eac8a7050d8a98bc9edb89/decorator-5.3.1-py3-none-any.whl", hash = "sha256:f47fe6fdbd2edd623ecfe36875d37aba411624e2670dd395dddae1358689bb3c", size = 10365, upload-time = "2026-05-18T06:03:26.517Z" }, ] [[package]] @@ -1039,59 +1041,59 @@ wheels = [ [[package]] name = "fonttools" -version = "4.62.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9a/08/7012b00a9a5874311b639c3920270c36ee0c445b69d9989a85e5c92ebcb0/fonttools-4.62.1.tar.gz", hash = "sha256:e54c75fd6041f1122476776880f7c3c3295ffa31962dc6ebe2543c00dca58b5d", size = 3580737, upload-time = "2026-03-13T13:54:25.52Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/532ed43808b469c807e8cb6b21358da3fe6fd51486b3a8c93db0bb5d957f/fonttools-4.62.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ad5cca75776cd453b1b035b530e943334957ae152a36a88a320e779d61fc980c", size = 2873740, upload-time = "2026-03-13T13:52:11.822Z" }, - { url = "https://files.pythonhosted.org/packages/85/e4/2318d2b430562da7227010fb2bb029d2fa54d7b46443ae8942bab224e2a0/fonttools-4.62.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b3ae47e8636156a9accff64c02c0924cbebad62854c4a6dbdc110cd5b4b341a", size = 2417649, upload-time = "2026-03-13T13:52:14.605Z" }, - { url = "https://files.pythonhosted.org/packages/4c/28/40f15523b5188598018e7956899fed94eb7debec89e2dd70cb4a8df90492/fonttools-4.62.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b9e288b4da2f64fd6180644221749de651703e8d0c16bd4b719533a3a7d6e3", size = 4935213, upload-time = "2026-03-13T13:52:17.399Z" }, - { url = "https://files.pythonhosted.org/packages/42/09/7dbe3d7023f57d9b580cfa832109d521988112fd59dddfda3fddda8218f9/fonttools-4.62.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bca7a1c1faf235ffe25d4f2e555246b4750220b38de8261d94ebc5ce8a23c23", size = 4892374, upload-time = "2026-03-13T13:52:20.175Z" }, - { url = "https://files.pythonhosted.org/packages/d1/2d/84509a2e32cb925371560ef5431365d8da2183c11d98e5b4b8b4e42426a5/fonttools-4.62.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b4e0fcf265ad26e487c56cb12a42dffe7162de708762db951e1b3f755319507d", size = 4911856, upload-time = "2026-03-13T13:52:22.777Z" }, - { url = "https://files.pythonhosted.org/packages/a5/80/df28131379eed93d9e6e6fccd3bf6e3d077bebbfe98cc83f21bbcd83ed02/fonttools-4.62.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2d850f66830a27b0d498ee05adb13a3781637b1826982cd7e2b3789ef0cc71ae", size = 5031712, upload-time = "2026-03-13T13:52:25.14Z" }, - { url = "https://files.pythonhosted.org/packages/3d/03/3c8f09aad64230cd6d921ae7a19f9603c36f70930b00459f112706f6769a/fonttools-4.62.1-cp310-cp310-win32.whl", hash = "sha256:486f32c8047ccd05652aba17e4a8819a3a9d78570eb8a0e3b4503142947880ed", size = 1507878, upload-time = "2026-03-13T13:52:28.149Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ec/f53f626f8f3e89f4cadd8fc08f3452c8fd182c951ad5caa35efac22b29ab/fonttools-4.62.1-cp310-cp310-win_amd64.whl", hash = "sha256:5a648bde915fba9da05ae98856987ca91ba832949a9e2888b48c47ef8b96c5a9", size = 1556766, upload-time = "2026-03-13T13:52:30.814Z" }, - { url = "https://files.pythonhosted.org/packages/88/39/23ff32561ec8d45a4d48578b4d241369d9270dc50926c017570e60893701/fonttools-4.62.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:40975849bac44fb0b9253d77420c6d8b523ac4dcdcefeff6e4d706838a5b80f7", size = 2871039, upload-time = "2026-03-13T13:52:33.127Z" }, - { url = "https://files.pythonhosted.org/packages/24/7f/66d3f8a9338a9b67fe6e1739f47e1cd5cee78bd3bc1206ef9b0b982289a5/fonttools-4.62.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9dde91633f77fa576879a0c76b1d89de373cae751a98ddf0109d54e173b40f14", size = 2416346, upload-time = "2026-03-13T13:52:35.676Z" }, - { url = "https://files.pythonhosted.org/packages/aa/53/5276ceba7bff95da7793a07c5284e1da901cf00341ce5e2f3273056c0cca/fonttools-4.62.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6acb4109f8bee00fec985c8c7afb02299e35e9c94b57287f3ea542f28bd0b0a7", size = 5100897, upload-time = "2026-03-13T13:52:38.102Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a1/40a5c4d8e28b0851d53a8eeeb46fbd73c325a2a9a165f290a5ed90e6c597/fonttools-4.62.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1c5c25671ce8805e0d080e2ffdeca7f1e86778c5cbfbeae86d7f866d8830517b", size = 5071078, upload-time = "2026-03-13T13:52:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/e3/be/d378fca4c65ea1956fee6d90ace6e861776809cbbc5af22388a090c3c092/fonttools-4.62.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a5d8825e1140f04e6c99bb7d37a9e31c172f3bc208afbe02175339e699c710e1", size = 5076908, upload-time = "2026-03-13T13:52:44.122Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d9/ae6a1d0693a4185a84605679c8a1f719a55df87b9c6e8e817bfdd9ef5936/fonttools-4.62.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:268abb1cb221e66c014acc234e872b7870d8b5d4657a83a8f4205094c32d2416", size = 5202275, upload-time = "2026-03-13T13:52:46.591Z" }, - { url = "https://files.pythonhosted.org/packages/54/6c/af95d9c4efb15cabff22642b608342f2bd67137eea6107202d91b5b03184/fonttools-4.62.1-cp311-cp311-win32.whl", hash = "sha256:942b03094d7edbb99bdf1ae7e9090898cad7bf9030b3d21f33d7072dbcb51a53", size = 2293075, upload-time = "2026-03-13T13:52:48.711Z" }, - { url = "https://files.pythonhosted.org/packages/d3/97/bf54c5b3f2be34e1f143e6db838dfdc54f2ffa3e68c738934c82f3b2a08d/fonttools-4.62.1-cp311-cp311-win_amd64.whl", hash = "sha256:e8514f4924375f77084e81467e63238b095abda5107620f49421c368a6017ed2", size = 2344593, upload-time = "2026-03-13T13:52:50.725Z" }, - { url = "https://files.pythonhosted.org/packages/47/d4/dbacced3953544b9a93088cc10ef2b596d348c983d5c67a404fa41ec51ba/fonttools-4.62.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:90365821debbd7db678809c7491ca4acd1e0779b9624cdc6ddaf1f31992bf974", size = 2870219, upload-time = "2026-03-13T13:52:53.664Z" }, - { url = "https://files.pythonhosted.org/packages/66/9e/a769c8e99b81e5a87ab7e5e7236684de4e96246aae17274e5347d11ebd78/fonttools-4.62.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:12859ff0b47dd20f110804c3e0d0970f7b832f561630cd879969011541a464a9", size = 2414891, upload-time = "2026-03-13T13:52:56.493Z" }, - { url = "https://files.pythonhosted.org/packages/69/64/f19a9e3911968c37e1e620e14dfc5778299e1474f72f4e57c5ec771d9489/fonttools-4.62.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c125ffa00c3d9003cdaaf7f2c79e6e535628093e14b5de1dccb08859b680936", size = 5033197, upload-time = "2026-03-13T13:52:59.179Z" }, - { url = "https://files.pythonhosted.org/packages/9b/8a/99c8b3c3888c5c474c08dbfd7c8899786de9604b727fcefb055b42c84bba/fonttools-4.62.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:149f7d84afca659d1a97e39a4778794a2f83bf344c5ee5134e09995086cc2392", size = 4988768, upload-time = "2026-03-13T13:53:02.761Z" }, - { url = "https://files.pythonhosted.org/packages/d1/c6/0f904540d3e6ab463c1243a0d803504826a11604c72dd58c2949796a1762/fonttools-4.62.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0aa72c43a601cfa9273bb1ae0518f1acadc01ee181a6fc60cd758d7fdadffc04", size = 4971512, upload-time = "2026-03-13T13:53:05.678Z" }, - { url = "https://files.pythonhosted.org/packages/29/0b/5cbef6588dc9bd6b5c9ad6a4d5a8ca384d0cea089da31711bbeb4f9654a6/fonttools-4.62.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:19177c8d96c7c36359266e571c5173bcee9157b59cfc8cb0153c5673dc5a3a7d", size = 5122723, upload-time = "2026-03-13T13:53:08.662Z" }, - { url = "https://files.pythonhosted.org/packages/4a/47/b3a5342d381595ef439adec67848bed561ab7fdb1019fa522e82101b7d9c/fonttools-4.62.1-cp312-cp312-win32.whl", hash = "sha256:a24decd24d60744ee8b4679d38e88b8303d86772053afc29b19d23bb8207803c", size = 2281278, upload-time = "2026-03-13T13:53:10.998Z" }, - { url = "https://files.pythonhosted.org/packages/28/b1/0c2ab56a16f409c6c8a68816e6af707827ad5d629634691ff60a52879792/fonttools-4.62.1-cp312-cp312-win_amd64.whl", hash = "sha256:9e7863e10b3de72376280b515d35b14f5eeed639d1aa7824f4cf06779ec65e42", size = 2331414, upload-time = "2026-03-13T13:53:13.992Z" }, - { url = "https://files.pythonhosted.org/packages/3b/56/6f389de21c49555553d6a5aeed5ac9767631497ac836c4f076273d15bd72/fonttools-4.62.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c22b1014017111c401469e3acc5433e6acf6ebcc6aa9efb538a533c800971c79", size = 2865155, upload-time = "2026-03-13T13:53:16.132Z" }, - { url = "https://files.pythonhosted.org/packages/03/c5/0e3966edd5ec668d41dfe418787726752bc07e2f5fd8c8f208615e61fa89/fonttools-4.62.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68959f5fc58ed4599b44aad161c2837477d7f35f5f79402d97439974faebfebe", size = 2412802, upload-time = "2026-03-13T13:53:18.878Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/e6ac4b44026de7786fe46e3bfa0c87e51d5d70a841054065d49cd62bb909/fonttools-4.62.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef46db46c9447103b8f3ff91e8ba009d5fe181b1920a83757a5762551e32bb68", size = 5013926, upload-time = "2026-03-13T13:53:21.379Z" }, - { url = "https://files.pythonhosted.org/packages/e2/98/8b1e801939839d405f1f122e7d175cebe9aeb4e114f95bfc45e3152af9a7/fonttools-4.62.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6706d1cb1d5e6251a97ad3c1b9347505c5615c112e66047abbef0f8545fa30d1", size = 4964575, upload-time = "2026-03-13T13:53:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/46/76/7d051671e938b1881670528fec69cc4044315edd71a229c7fd712eaa5119/fonttools-4.62.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2e7abd2b1e11736f58c1de27819e1955a53267c21732e78243fa2fa2e5c1e069", size = 4953693, upload-time = "2026-03-13T13:53:26.569Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ae/b41f8628ec0be3c1b934fc12b84f4576a5c646119db4d3bdd76a217c90b5/fonttools-4.62.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:403d28ce06ebfc547fbcb0cb8b7f7cc2f7a2d3e1a67ba9a34b14632df9e080f9", size = 5094920, upload-time = "2026-03-13T13:53:29.329Z" }, - { url = "https://files.pythonhosted.org/packages/f2/f6/53a1e9469331a23dcc400970a27a4caa3d9f6edbf5baab0260285238b884/fonttools-4.62.1-cp313-cp313-win32.whl", hash = "sha256:93c316e0f5301b2adbe6a5f658634307c096fd5aae60a5b3412e4f3e1728ab24", size = 2279928, upload-time = "2026-03-13T13:53:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/38/60/35186529de1db3c01f5ad625bde07c1f576305eab6d86bbda4c58445f721/fonttools-4.62.1-cp313-cp313-win_amd64.whl", hash = "sha256:7aa21ff53e28a9c2157acbc44e5b401149d3c9178107130e82d74ceb500e5056", size = 2330514, upload-time = "2026-03-13T13:53:34.991Z" }, - { url = "https://files.pythonhosted.org/packages/36/f0/2888cdac391807d68d90dcb16ef858ddc1b5309bfc6966195a459dd326e2/fonttools-4.62.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fa1d16210b6b10a826d71bed68dd9ec24a9e218d5a5e2797f37c573e7ec215ca", size = 2864442, upload-time = "2026-03-13T13:53:37.509Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b2/e521803081f8dc35990816b82da6360fa668a21b44da4b53fc9e77efcd62/fonttools-4.62.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:aa69d10ed420d8121118e628ad47d86e4caa79ba37f968597b958f6cceab7eca", size = 2410901, upload-time = "2026-03-13T13:53:40.55Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/8c3511ff06e53110039358dbbdc1a65d72157a054638387aa2ada300a8b8/fonttools-4.62.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd13b7999d59c5eb1c2b442eb2d0c427cb517a0b7a1f5798fc5c9e003f5ff782", size = 4999608, upload-time = "2026-03-13T13:53:42.798Z" }, - { url = "https://files.pythonhosted.org/packages/28/63/cd0c3b26afe60995a5295f37c246a93d454023726c3261cfbb3559969bb9/fonttools-4.62.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8d337fdd49a79b0d51c4da87bc38169d21c3abbf0c1aa9367eff5c6656fb6dae", size = 4912726, upload-time = "2026-03-13T13:53:45.405Z" }, - { url = "https://files.pythonhosted.org/packages/70/b9/ac677cb07c24c685cf34f64e140617d58789d67a3dd524164b63648c6114/fonttools-4.62.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d241cdc4a67b5431c6d7f115fdf63335222414995e3a1df1a41e1182acd4bcc7", size = 4951422, upload-time = "2026-03-13T13:53:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/e6/10/11c08419a14b85b7ca9a9faca321accccc8842dd9e0b1c8a72908de05945/fonttools-4.62.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c05557a78f8fa514da0f869556eeda40887a8abc77c76ee3f74cf241778afd5a", size = 5060979, upload-time = "2026-03-13T13:53:51.366Z" }, - { url = "https://files.pythonhosted.org/packages/4e/3c/12eea4a4cf054e7ab058ed5ceada43b46809fce2bf319017c4d63ae55bb4/fonttools-4.62.1-cp314-cp314-win32.whl", hash = "sha256:49a445d2f544ce4a69338694cad575ba97b9a75fff02720da0882d1a73f12800", size = 2283733, upload-time = "2026-03-13T13:53:53.606Z" }, - { url = "https://files.pythonhosted.org/packages/6b/67/74b070029043186b5dd13462c958cb7c7f811be0d2e634309d9a1ffb1505/fonttools-4.62.1-cp314-cp314-win_amd64.whl", hash = "sha256:1eecc128c86c552fb963fe846ca4e011b1be053728f798185a1687502f6d398e", size = 2335663, upload-time = "2026-03-13T13:53:56.23Z" }, - { url = "https://files.pythonhosted.org/packages/42/c5/4d2ed3ca6e33617fc5624467da353337f06e7f637707478903c785bd8e20/fonttools-4.62.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1596aeaddf7f78e21e68293c011316a25267b3effdaccaf4d59bc9159d681b82", size = 2947288, upload-time = "2026-03-13T13:53:59.397Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e9/7ab11ddfda48ed0f89b13380e5595ba572619c27077be0b2c447a63ff351/fonttools-4.62.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f8fca95d3bb3208f59626a4b0ea6e526ee51f5a8ad5d91821c165903e8d9260", size = 2449023, upload-time = "2026-03-13T13:54:01.642Z" }, - { url = "https://files.pythonhosted.org/packages/b2/10/a800fa090b5e8819942e54e19b55fc7c21fe14a08757c3aa3ca8db358939/fonttools-4.62.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee91628c08e76f77b533d65feb3fbe6d9dad699f95be51cf0d022db94089cdc4", size = 5137599, upload-time = "2026-03-13T13:54:04.495Z" }, - { url = "https://files.pythonhosted.org/packages/37/dc/8ccd45033fffd74deb6912fa1ca524643f584b94c87a16036855b498a1ed/fonttools-4.62.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5f37df1cac61d906e7b836abe356bc2f34c99d4477467755c216b72aa3dc748b", size = 4920933, upload-time = "2026-03-13T13:54:07.557Z" }, - { url = "https://files.pythonhosted.org/packages/99/eb/e618adefb839598d25ac8136cd577925d6c513dc0d931d93b8af956210f0/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:92bb00a947e666169c99b43753c4305fc95a890a60ef3aeb2a6963e07902cc87", size = 5016232, upload-time = "2026-03-13T13:54:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/d9/5f/9b5c9bfaa8ec82def8d8168c4f13615990d6ce5996fe52bd49bfb5e05134/fonttools-4.62.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bdfe592802ef939a0e33106ea4a318eeb17822c7ee168c290273cbd5fabd746c", size = 5042987, upload-time = "2026-03-13T13:54:13.569Z" }, - { url = "https://files.pythonhosted.org/packages/90/aa/dfbbe24c6a6afc5c203d90cc0343e24bcbb09e76d67c4d6eef8c2558d7ba/fonttools-4.62.1-cp314-cp314t-win32.whl", hash = "sha256:b820fcb92d4655513d8402d5b219f94481c4443d825b4372c75a2072aa4b357a", size = 2348021, upload-time = "2026-03-13T13:54:16.98Z" }, - { url = "https://files.pythonhosted.org/packages/13/6f/ae9c4e4dd417948407b680855c2c7790efb52add6009aaecff1e3bc50e8e/fonttools-4.62.1-cp314-cp314t-win_amd64.whl", hash = "sha256:59b372b4f0e113d3746b88985f1c796e7bf830dd54b28374cd85c2b8acd7583e", size = 2414147, upload-time = "2026-03-13T13:54:19.416Z" }, - { url = "https://files.pythonhosted.org/packages/fd/ba/56147c165442cc5ba7e82ecf301c9a68353cede498185869e6e02b4c264f/fonttools-4.62.1-py3-none-any.whl", hash = "sha256:7487782e2113861f4ddcc07c3436450659e3caa5e470b27dc2177cade2d8e7fd", size = 1152647, upload-time = "2026-03-13T13:54:22.735Z" }, +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/c9/4141c90a90db20f807c7e10bfd689fe53eb8f7f4caff58ee4d4dfe46919f/fonttools-4.63.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e3297a6a4059b4acc3a1e9a8b04741f240a80044eef08ebd32e8b5bcdddce75b", size = 2884632, upload-time = "2026-05-14T12:02:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/b8/46/ad12b5c10eae602d7ef814b02afa08aacbf89da917fed5b071282b7eadc2/fonttools-4.63.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b1cd75a03ad8cb5bc40c90bfde68c0c47de423aa19e5c0f362b43520645eea94", size = 2429441, upload-time = "2026-05-14T12:02:41.162Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/bdca24a84c81d56fffed052229cdcff368f6e05882e526f4558891481f65/fonttools-4.63.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0425b277a59cff3d80ca42162a8de360f318438a2ac83570842a678d826d579", size = 4946346, upload-time = "2026-05-14T12:02:43.41Z" }, + { url = "https://files.pythonhosted.org/packages/04/59/a639c0e136441ee91a65b56fdf89e5d075927e7a09c559d1b0f5276577db/fonttools-4.63.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d7e5c9973aa04c95650c96e5f5ad865fbf42d62079163ecfab1e01cbc2504c22", size = 4903184, upload-time = "2026-05-14T12:02:45.742Z" }, + { url = "https://files.pythonhosted.org/packages/e6/53/91b7e0cb45b536f3da1b29ba8cbab89f27e8b986809e0b1982303a3f4eca/fonttools-4.63.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cb014d58140a38135f16064c74c652ed57aa0b75cbf8bb59cac821f7edb5334e", size = 4922967, upload-time = "2026-05-14T12:02:48.386Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/87439bf44e6b97c5538cd29d0b7e366a5b8ce2cc132a4134fb67fa3f2fa2/fonttools-4.63.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:032038247a96c1690f9f31e377c389383c902531b085aa4e4dabd6f57f870e69", size = 5042799, upload-time = "2026-05-14T12:02:50.424Z" }, + { url = "https://files.pythonhosted.org/packages/ad/7c/8b96c3263b89ef99cded544c0f0636686f85dbd3c211c4dceef0231fca23/fonttools-4.63.0-cp310-cp310-win32.whl", hash = "sha256:a8b33a82979e0a6a34ff435cc81317be1f95ec1ebb7a3a2d1c8a6a54f02ae44e", size = 1519704, upload-time = "2026-05-14T12:02:52.523Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4d/2c2f0069970b6907de8fb5b05c5c0193cc22f717df151d1c7aef1c738f58/fonttools-4.63.0-cp310-cp310-win_amd64.whl", hash = "sha256:0c18358a155d75034911c5ee397a5b44cd19dd325dbb8b35fb60bf421d6a72ac", size = 1568666, upload-time = "2026-05-14T12:02:54.917Z" }, + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, ] [[package]] @@ -1135,34 +1137,38 @@ wheels = [ [[package]] name = "guppylang" -version = "0.21.6" +version = "0.21.11" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "guppylang-internals" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pytket" }, { name = "selene-hugr-qis-compiler" }, { name = "selene-sim" }, { name = "tqdm" }, { name = "types-tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0d/45/0c4cb644d10682fdf9de492911ef55ddc571469270487058655fe9e233b4/guppylang-0.21.6.tar.gz", hash = "sha256:57cdfa0c2fe7ffd80c193c830505285e0a8c779a6f817016cb8e295eaa1cae19", size = 60477, upload-time = "2025-10-30T17:37:21.435Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/6d/dcfebfca39fc8fce2f5b27fc2f411ebfcd18e4509959215ac6d6a38afb5f/guppylang-0.21.11.tar.gz", hash = "sha256:5ff823484c9e8cc2a9c13279be0aec2dc68f3aa725bd9f125d912a393983747e", size = 68353, upload-time = "2026-04-01T13:23:19.721Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/81/3c0fffa0c28bd2bcaf37dd053d50017f8afc3794de13d9e60b7f5b9a5dab/guppylang-0.21.6-py3-none-any.whl", hash = "sha256:ecc03bf0c1f2d1146a3fdcc2e75a9a775eb11fd37b128a1febf319ab6b709619", size = 58427, upload-time = "2025-10-30T17:37:20.212Z" }, + { url = "https://files.pythonhosted.org/packages/48/73/7e6e9567d600cdb181273216ba82ab9b8b279da92c969685506b83181af9/guppylang-0.21.11-py3-none-any.whl", hash = "sha256:b00e8f1be52c846c349c576c4d264771fca01fe5f2f0a6a01434f92d4b7350c4", size = 65701, upload-time = "2026-04-01T13:23:18.265Z" }, ] [[package]] name = "guppylang-internals" -version = "0.25.0" +version = "0.32.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, + { name = "pytket" }, + { name = "tket" }, { name = "tket-exts" }, { name = "typing-extensions" }, + { name = "wasmtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/56/d1/1d71187602922cc70a4880137d65ea742a3f3a37be9519e23c4c5f28bc14/guppylang_internals-0.25.0.tar.gz", hash = "sha256:abbcbccb44f3404adff52046907c8c1ee993b8d779fcbef259b7bd8840c450d1", size = 180215, upload-time = "2025-10-29T15:13:20.78Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/99/ee9e0475e0597c06bd8a6a05ceb5b0a3dfb52c1830a84fd322d541db5ada/guppylang_internals-0.32.0.tar.gz", hash = "sha256:ecd074ba42903558c381d0e62891e7e749932a62003d56bda5678c06959f818e", size = 207836, upload-time = "2026-04-01T12:45:18.605Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2e/d1/48129590b4eb5e99deff8e3ddf3a039a66d96523dfb68592a6267b779b30/guppylang_internals-0.25.0-py3-none-any.whl", hash = "sha256:95473e5ef93329fc6a764fce82e0ef3931b691407a10a21520c522080405565b", size = 233971, upload-time = "2025-10-29T15:13:19.214Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/3196a4b254d58d66c56235ff4194e519e603927753ac6a6d13179cadf59e/guppylang_internals-0.32.0-py3-none-any.whl", hash = "sha256:8d711e4a60c28b726b92ffbb383ef7f836ac2a8135838b5ad9f49f6e4e840962", size = 260494, upload-time = "2026-04-01T12:45:16.369Z" }, ] [[package]] @@ -1204,48 +1210,45 @@ wheels = [ [[package]] name = "hugr" -version = "0.14.4" +version = "0.15.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "graphviz" }, { name = "pydantic" }, { name = "pydantic-extra-types" }, - { name = "pyzstd" }, { name = "semver" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/20/12/6e653fb7ea01567a220f4fe0b3ae9e435f1e4d8d052d7fc6089195b1322e/hugr-0.14.4.tar.gz", hash = "sha256:0f39810cde20aa22741ceec236ce8936a50cf16b72b259e561fd205a8c02ea08", size = 1076949, upload-time = "2025-11-26T16:15:07.355Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/f9/3af5afddee55a5c42fc2b3ccd85a7c23b284f6570822a0171a1157622866/hugr-0.14.4-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:93c9c56ddc0b5e66e4a6a19f95e94f95c2ce764c2763a8abf3329fb1c12c7cdd", size = 3831727, upload-time = "2025-11-26T16:14:50.634Z" }, - { url = "https://files.pythonhosted.org/packages/04/18/b854c36821819a0f96fdcbf6849f0b29a057e52bda101c2081f705dfeb64/hugr-0.14.4-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:196d1c4eafa4b86502267809b316fcc9d9f5bd30a9052991ee4cd9140a99a969", size = 3406301, upload-time = "2025-11-26T16:14:46.918Z" }, - { url = "https://files.pythonhosted.org/packages/00/2b/335abcb77657074c7bd1d32fe78acf4bfb86243b84ca4537006aa2ed88ad/hugr-0.14.4-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8affb8819f864dcbc7b9a32bb9efbc66d45b6656d5446afb2078cf1c1c15b63", size = 3719565, upload-time = "2025-11-26T16:14:13.276Z" }, - { url = "https://files.pythonhosted.org/packages/b7/92/e048e852e58d948c9a3d84daf8b5f124642050a7a200e22e0923025d6dd9/hugr-0.14.4-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1bc1960987198c024300b8baf5415bda825a786329fe7d9c4b5bf4c798809d8c", size = 3774829, upload-time = "2025-11-26T16:14:20.189Z" }, - { url = "https://files.pythonhosted.org/packages/df/3d/a537e46cf2777538b33664150a36de5688ad9b79285765d3f47c1e208319/hugr-0.14.4-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d39ea3725047505458d63a5f3f9e1b7ea248fa62f8fab3a94c3ee5fefde36197", size = 4032332, upload-time = "2025-11-26T16:14:40.093Z" }, - { url = "https://files.pythonhosted.org/packages/92/01/108efd9b71dcf3c80863c1d12bcb39eaff1052472b62b0ff06cf73607f76/hugr-0.14.4-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dfcd7656edbf746c49a28391ffadd995eabdeeb47c79b4b6d286996d175a67f6", size = 4187215, upload-time = "2025-11-26T16:14:26.906Z" }, - { url = "https://files.pythonhosted.org/packages/16/23/b4699cbf9e50d7657ce7e179d873980e25416ca2a56c7d97234cc9528bc2/hugr-0.14.4-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbe48b812fe2f2c0eeb8ffc38bca08d34d88449f58eb139e77b5fc0c5a3e73c0", size = 4383840, upload-time = "2025-11-26T16:14:34.108Z" }, - { url = "https://files.pythonhosted.org/packages/37/c9/081bb993bdf097a67b7ec9292cd5027f940666972d8631580ab346ed547a/hugr-0.14.4-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdb23f2b40e56875752ed739bf1c8bc3ae14bbdacde298e1c7ed0641b27ff853", size = 4068208, upload-time = "2025-11-26T16:14:43.755Z" }, - { url = "https://files.pythonhosted.org/packages/5b/8b/4f6ae5ca60115afc3a925b197c3504143a454cbfb84615abe7e6a4c666f4/hugr-0.14.4-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:772d631a6ad5099021f2d95d45ade7c543889d49b36d1b03fc072d3da3caa9a2", size = 3942511, upload-time = "2025-11-26T16:14:53.466Z" }, - { url = "https://files.pythonhosted.org/packages/11/5e/851c84aa0b15779318f57c25d19aaca46e103efb982238c7ad6771a0d49e/hugr-0.14.4-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c7fc26b710141d521951be5b5d85ec4fa0d583ca09b4c6a7e4d1e0d18864072d", size = 4053248, upload-time = "2025-11-26T16:14:56.593Z" }, - { url = "https://files.pythonhosted.org/packages/c6/fc/38ca1caec06bb019fc3ed8a9f86f96ce0a26db7d0ca1496cf13d96ab51c0/hugr-0.14.4-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:b0f0455ec23fbc60dc9a5770c7c996baa544d96547622de0ba3e9ae4f27ab667", size = 4078485, upload-time = "2025-11-26T16:15:00.721Z" }, - { url = "https://files.pythonhosted.org/packages/bb/eb/6c851799c7cf5899253178928e6579322c50793b23abf152c093a4d9952f/hugr-0.14.4-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a40bda1a592a02560d8735cc1f0514e04dfc4d6fdd95ece8a63819708b3e6b12", size = 4265564, upload-time = "2025-11-26T16:15:04.257Z" }, - { url = "https://files.pythonhosted.org/packages/9f/17/cecf6b74c6896ae85ed98962a69c651f988bd7208c437c68a75ff5846114/hugr-0.14.4-cp310-abi3-win32.whl", hash = "sha256:8b930de9755cf8fa649775484a3ab360271fdd29a1aa0ec5570204e80824a71a", size = 3378991, upload-time = "2025-11-26T16:15:10.638Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2a/9a56a0da944ce7daf754cac339270acb47a15d68c7bc84f3df366be64eb5/hugr-0.14.4-cp310-abi3-win_amd64.whl", hash = "sha256:1987f8b5078dc40b674203acb5952d02f1b72728997123652a01f5fface882a0", size = 3606113, upload-time = "2025-11-26T16:15:08.762Z" }, - { url = "https://files.pythonhosted.org/packages/28/56/caac43ee7fcc69edbc3d32ec5796ab072d973b5b21c0ce389c18e202dca6/hugr-0.14.4-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a1cdef74e8b5bd3831f5f635dbde78debb5bd3f9c997b78747542f1256d499fb", size = 3849266, upload-time = "2025-11-26T16:14:52.047Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b0/d5e3187560aa0d84044863e24a3df8cb1706ae86315510d79905c53b29da/hugr-0.14.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e9880f01550807a3fcb9acbb062c1ae9601328bfe4684580ca04d543d3131eb0", size = 3408154, upload-time = "2025-11-26T16:14:49.019Z" }, - { url = "https://files.pythonhosted.org/packages/05/ff/817a4595b68c2ef56ddfea82ec20c0247ebbcad1e721b2767ff513ece73b/hugr-0.14.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f6b0a67e1740fb78d66ab31561be4d6374f480cab115f6314d6bbc8d26704a73", size = 3715760, upload-time = "2025-11-26T16:14:15.307Z" }, - { url = "https://files.pythonhosted.org/packages/54/19/53574f3dbcf6b5c3602c71d017fda201196f5ea13c365012c12e3de8d87f/hugr-0.14.4-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3aedae6e89aaebadfbbd201e35b07be2a683bb23307b2bbdcac410a170c0d11", size = 3771058, upload-time = "2025-11-26T16:14:21.889Z" }, - { url = "https://files.pythonhosted.org/packages/3b/45/3094b9254030a6443b83ff70114edf90dfbaf1356d387677335107509186/hugr-0.14.4-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7d32813dafd772ac067d68d5bd2ed3f1a98d8912a2908db68a137ad75d4a88e7", size = 4038863, upload-time = "2025-11-26T16:14:42.014Z" }, - { url = "https://files.pythonhosted.org/packages/b6/7b/4065d535076a61a9ae004b664aa8379a3c493dd768336deb927bc26f4701/hugr-0.14.4-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:983f26f1cf0bf196c3aca3d0f97d7ddf27ec7cc8753bea8d358b4afbe904029c", size = 4185665, upload-time = "2025-11-26T16:14:28.733Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a5/d5c5c7ad832c0b817743df9a4db58c72025c155d7e78ae12262975d2e527/hugr-0.14.4-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5b453bc489b5c4e4746c980c935a79ada189988c10a2f77a6c2a10e04bae0b89", size = 4379657, upload-time = "2025-11-26T16:14:35.633Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7b/5083c0cc249e53082443323fe493ac6d18388f181b82d6bfa479b523b7d9/hugr-0.14.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:328eb29099b3cc1ddbc5479a6bb7e1a437f170f4e85ee27b3d35357ef2caf369", size = 4066330, upload-time = "2025-11-26T16:14:45.255Z" }, - { url = "https://files.pythonhosted.org/packages/87/e2/46cc78aff8da4544993c49638ff801a6a9a44149bf20f7e5917b18dd9fef/hugr-0.14.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:1fd38be47ac27ccb9edb124a18c3b14e545708b8bbed89c8c20d1b2e14a4e9b2", size = 3939452, upload-time = "2025-11-26T16:14:54.9Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ba/f027273d8d3a6ebcfa9e6954502615050293e695dc8e38824164ef9abea1/hugr-0.14.4-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:b59dc1972a07a2cee4d8e53d24020634d70d7d923fe0592c977e0c3de8d538ed", size = 4048708, upload-time = "2025-11-26T16:14:58.135Z" }, - { url = "https://files.pythonhosted.org/packages/aa/27/bdb89dc5ac0f1dce5e8f79a47eaa30a90aed671329107f6f305330e4e043/hugr-0.14.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:19392043a13c53bb49cfb9ffabc6a084f6a1b563a51be3bef6d4e7f78b96aee6", size = 4088139, upload-time = "2025-11-26T16:15:02.8Z" }, - { url = "https://files.pythonhosted.org/packages/8d/e1/d1571d13f49d54cb4b84dbcc5d81e3548761b15abe019b97abdf7606c8a2/hugr-0.14.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c88b23a70ccd780f30148784eaad7cfd7384c459f5e221870d901922a6300492", size = 4259853, upload-time = "2025-11-26T16:15:05.828Z" }, - { url = "https://files.pythonhosted.org/packages/eb/75/e49bd706b9be6005da9a838e94d2a15cd96373839ddcc88fb38b7798fd29/hugr-0.14.4-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c29d997d8d210ebde9d07305833cd80b2425c1419b6554042d150c314ccd1c4", size = 3719146, upload-time = "2025-11-26T16:14:16.785Z" }, - { url = "https://files.pythonhosted.org/packages/68/73/f8ec89ff697b20e244844f2bdf4aaa07b3ee063d845861659479582b0aa2/hugr-0.14.4-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:57b0487a13382005d0ee0e908ae349272bdc03c3537202a0a46e9a07c4fc112c", size = 3773374, upload-time = "2025-11-26T16:14:23.602Z" }, - { url = "https://files.pythonhosted.org/packages/16/81/a82554d06dcee43b8580156c9e5ca1bafbb2eef6cb54d674b2c49bc0a21f/hugr-0.14.4-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bd82eddfcd20d26c96aa8bc8be8ab867f1c231902ae4200b570f405f0eb7ca9", size = 4185152, upload-time = "2025-11-26T16:14:30.574Z" }, - { url = "https://files.pythonhosted.org/packages/3b/a8/ae3b32d52c682545b66a3e73481565050e15d47ecf374da7658e5d8f60a5/hugr-0.14.4-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea4264d9d93b9684b1b5d47a8f5d7a23e73631e4d9cbd7d34008da37f2402913", size = 4376569, upload-time = "2025-11-26T16:14:37.123Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ff/32/01b0e17e2aade67a18180af4bc2b74942c3f526851ab13e3072ee0655550/hugr-0.15.5.tar.gz", hash = "sha256:538b50c0070fc2e45f3e1394fa862ec131b22390e9acfd89f48e5c1ad122d635", size = 1115293, upload-time = "2026-03-30T12:59:53.771Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/1e/e412e18a0bfd606be5e51b4bb3aba98c24d11c871e89a9a7aa59d84595e3/hugr-0.15.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b0959494e29cd9f48ce1e6fd11deb24b009569abb3a2c7216fd90602adf1a850", size = 3287674, upload-time = "2026-03-30T12:59:50.234Z" }, + { url = "https://files.pythonhosted.org/packages/03/d1/7a02ea4508d4fe36a028c52efaedf8141976a98ea194b3ae1fefdfae92a9/hugr-0.15.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3d968983df1b1fa89f4523b45438d3bcba4cddfa0e7365ecffc0116613a8aba7", size = 2937104, upload-time = "2026-03-30T12:59:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/4e/28/1c15cd8c33d0dbd2bf0f63a451951c9f0eec06d2fd4b61678253197b8026/hugr-0.15.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5a84c326d5a16bf6cb83db3872e73fbe2a3a0134a998aa4d00364a8cb5f1e2e", size = 3259115, upload-time = "2026-03-30T12:59:04.481Z" }, + { url = "https://files.pythonhosted.org/packages/ec/66/e2c335fb57e5f1d2ea09782a7dbd82de8e49e3290653d3f3e79e32754af5/hugr-0.15.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7a106cd91076aa7a8392ca7266aec9785b92cb7bedbe3a2d06569ca5783dcc19", size = 3243950, upload-time = "2026-03-30T12:59:08.566Z" }, + { url = "https://files.pythonhosted.org/packages/48/28/54219692e40e38cb3fac6afa336f898db033e1bf434fd452a867a40a2be1/hugr-0.15.5-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86ae8263cc5b576fb2d1403e3b1326db9f8f01e316f4588842b53f6b2fa3868b", size = 3666466, upload-time = "2026-03-30T12:59:12.096Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/0e14fc7e7eb402256d906bcd6aa40cd06d3394d41680a960805c22757520/hugr-0.15.5-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:92e10d5be181a49c381b11082a2a2c5973cb5f139b5eb077bb0b3aabe217be84", size = 3739167, upload-time = "2026-03-30T12:59:16.236Z" }, + { url = "https://files.pythonhosted.org/packages/f6/1d/7d0f934e24e8a8ad0477d941529774cf09a2f13cf1e6cace5f9bd040be56/hugr-0.15.5-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:00d700296911b0d6dd7a9854899bd3cca7d77ebc24a999a4117d0120fead88d0", size = 3513669, upload-time = "2026-04-01T17:50:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/b0/62/df3c0cf828757965da82abea5f3fe03e3eeb77452fdc88e31b2c35581675/hugr-0.15.5-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0a536c5eb547b64f4d820229397ef20f0fffcb929559ec00bf4ba8a66927143", size = 3541999, upload-time = "2026-04-01T17:50:24.524Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ca/531363852d9fe16e14453e52b1e2420deda99a84fdcfb56f622c52357d0c/hugr-0.15.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d1b1cc6671da1f75f04205fceaf6d2096b3c2e3a584e9bc78ad4fa6694d6cb75", size = 3467535, upload-time = "2026-03-30T12:59:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e5/a82d0007c3136319ae8280a9b752fe224a0b6700aeafdb6b641924e2dd60/hugr-0.15.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b3cbeb904291afc99ec9cd617fbf0b87e4e1813db2d5b4c7a880c5c68f380ee9", size = 3523569, upload-time = "2026-03-30T12:59:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/19/04/257cdcead28cf9279bc8864338a3d4c33dec454a6217c3ebd207f648c943/hugr-0.15.5-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7b7fed0cf9ab91d238d42003a1eb33bf673a2005e9f838f6424fa857784f10ba", size = 3607051, upload-time = "2026-03-30T12:59:34.322Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/468df655f5e44bdb56c1cfa2e2489d0cf20862a03f878403bad9cf4c9e2b/hugr-0.15.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2ac6c9cfc2e2c3540108723e8f9f0bfd08b767d94195a7b5e02143345336988f", size = 3763498, upload-time = "2026-03-30T12:59:38.997Z" }, + { url = "https://files.pythonhosted.org/packages/29/63/8b01ae8d59f8220fd03504eb0ff771f40dd803fcf75160e3c8b65bacac05/hugr-0.15.5-cp310-abi3-win32.whl", hash = "sha256:5996a0d93d23ab3b192ce99c03215e456fc0754566d04694e1bb8404d7931c36", size = 2897922, upload-time = "2026-03-30T12:59:44.406Z" }, + { url = "https://files.pythonhosted.org/packages/c1/4d/ba3a51e3dbacaf23a0d7c4bead0b8e52e7744754d942875102b6f7d91e3f/hugr-0.15.5-cp310-abi3-win_amd64.whl", hash = "sha256:4ca15068cd32199c52cd93225686f7a1416b478f534905026593ecf8ae82d38f", size = 3120930, upload-time = "2026-03-30T12:59:42.705Z" }, + { url = "https://files.pythonhosted.org/packages/10/aa/8791aee20794f282653b0d3d775265cdf72438c23d27e624172db92fb813/hugr-0.15.5-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:2a101e02e6288556029b269faf9338acafd30635b9832971fc8b2f9f465e9fc5", size = 3286019, upload-time = "2026-03-30T12:59:52.202Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c3/592b85d664fc52a4ac5312c44b42e6421a02f8de1aec5f865603ec1b48b9/hugr-0.15.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e7efbd8a56ef70690ae161b292d96eb8ac766e8fa1dc466ceb8686803fb81d72", size = 2936026, upload-time = "2026-03-30T12:59:48.254Z" }, + { url = "https://files.pythonhosted.org/packages/ad/16/169de9863b41c6bec0493fd3cdc7c35d20d7cbf93079f234ff24bb7a9ba3/hugr-0.15.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c11e28b43569cdb049cbb21b99f8bee15be516157d0c336421b9fc5c270642a", size = 3256239, upload-time = "2026-03-30T12:59:06.557Z" }, + { url = "https://files.pythonhosted.org/packages/69/54/83d94589c289c2a1df7d871af548e23f23a658be1f455f68f4abb744a2a9/hugr-0.15.5-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:55f9fb9863733f347374afba74ce91f3cd8a1b39e7db2c2474def16004688e62", size = 3243114, upload-time = "2026-03-30T12:59:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d7/07/a13d1f373a6dcc6ee8fc079b936cc450c510333dbcf4a000112343e91c15/hugr-0.15.5-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dacc70fc00fbc69cb24145027609c136d0380cb7de27e6f60755c990c8d64e1", size = 3511629, upload-time = "2026-03-30T12:59:19.719Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ac/173ea1a8b6c7768f3a496302a5e61581bfc1d636c1356d01b227d86ed8a1/hugr-0.15.5-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe5b8ce5e17982adc6427ff590e9e4a535b2e449b009706ec760c2e9213f3f6", size = 3662265, upload-time = "2026-03-30T12:59:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/29/44/60e22b8d61dcc35009aa06ad63f300061ba8e24aeb9ea16193350eee1f38/hugr-0.15.5-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b9c4ad0d637edeb8a45610e1d9a71c4137e064d4a2dc1334e2099e9ffdeeaac", size = 3737613, upload-time = "2026-03-30T12:59:18.06Z" }, + { url = "https://files.pythonhosted.org/packages/67/49/4c648af0c5f9521fd089a8803f9f6c2dd1c31e636c21f0e701bfa2da9030/hugr-0.15.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ea20c8ab98aa0d865577a0af1b11598b782ae5e6e076b55e8421f318288f16c", size = 3534945, upload-time = "2026-03-30T12:59:23.222Z" }, + { url = "https://files.pythonhosted.org/packages/10/b0/4f2b061e78bcc871865e698a5120bd2504a45bb0aaa0007ae6f9fa2c2b06/hugr-0.15.5-cp313-cp313t-manylinux_2_28_i686.whl", hash = "sha256:68511a127b73c10c8cab08ca6e92b2f96fd111d7e2a3bc86eda64ecc7cb8e6ad", size = 3514687, upload-time = "2026-04-01T17:50:35.44Z" }, + { url = "https://files.pythonhosted.org/packages/cc/36/f11007c43ea13c5b5c7f50ce3b40dad1bb4551e53dd12e3ca908c071b483/hugr-0.15.5-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:f50260b4e330d3bb06c1e7acf87194dfbf090b3f608536e77fe9562f2ed8d57c", size = 3543491, upload-time = "2026-04-01T17:50:39.132Z" }, + { url = "https://files.pythonhosted.org/packages/d5/be/1d61f890ed50a5ad3b826dadb127fa297a1022d43573a122a619d09a3b2c/hugr-0.15.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0376cda72bb3075a94cdfab2d0dc4e1aec79284f21855178057bdf143b4c0ddd", size = 3465437, upload-time = "2026-03-30T12:59:28.4Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e2/3a13a9b44842baaef1efac6d470bb974d956eaa7f7ab602a34f557b2df8a/hugr-0.15.5-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2fd3dcc9731bc357a4efd71c667e7d33ddaac78c1a6bb3de987583a4429651b6", size = 3522732, upload-time = "2026-03-30T12:59:32.328Z" }, + { url = "https://files.pythonhosted.org/packages/52/62/a396c7c8a40acd2c8c394f25f32165cbbfbe8db989cc218b3d509844010f/hugr-0.15.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:37efbdd3cfd96a692a026bd4bd8bae2795ae3374786c57f053819c335a98f24a", size = 3607041, upload-time = "2026-03-30T12:59:36.787Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2a/735cba4a2fd45aac30e678d196c91b9e6d8707a6a8f1a8a2157c73b11f22/hugr-0.15.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:291f48270b27e183cfca356602d63925ff1a9f51847d35de16366d04fbbfc5ca", size = 3763612, upload-time = "2026-03-30T12:59:40.945Z" }, ] [[package]] @@ -1272,11 +1275,11 @@ wheels = [ [[package]] name = "idna" -version = "3.15" +version = "3.17" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/28/99c51f664567218d824af024c0251650fb27e4ca066df188dab0769c5b91/idna-3.17.tar.gz", hash = "sha256:5eb0cb53bc467c12eadcf6de83163ad8527cec9416f44b9b61b19caedad2b87f", size = 196048, upload-time = "2026-05-28T14:32:38.55Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/de/a7/f76514cc40ad6234098ecdebda08732d75964776c51a42845b7da10649e2/idna-3.17-py3-none-any.whl", hash = "sha256:466e48829084efe2548012b855df21540b96f2e20e51bd124c851536556a592c", size = 65316, upload-time = "2026-05-28T14:32:37.035Z" }, ] [[package]] @@ -1297,7 +1300,7 @@ dependencies = [ { name = "comm" }, { name = "debugpy" }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython", version = "9.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "matplotlib-inline" }, @@ -1341,7 +1344,7 @@ wheels = [ [[package]] name = "ipython" -version = "9.13.0" +version = "9.14.0" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", @@ -1357,15 +1360,15 @@ dependencies = [ { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, - { name = "psutil", marker = "python_full_version >= '3.11'" }, + { name = "psutil", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten'" }, { name = "pygments", marker = "python_full_version >= '3.11'" }, { name = "stack-data", marker = "python_full_version >= '3.11'" }, { name = "traitlets", marker = "python_full_version >= '3.11'" }, { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/87cda5842cf5c31837c06ddb588e11c3c35d8ece89b7a0108c06b8c9b00a/ipython-9.13.0.tar.gz", hash = "sha256:7e834b6afc99f020e3f05966ced34792f40267d64cb1ea9043886dab0dde5967", size = 4430549, upload-time = "2026-04-24T12:24:55.221Z" } +sdist = { url = "https://files.pythonhosted.org/packages/21/c2/c0064cf15d026501a1ef70e42efd9c3f818663089399aacc5e37a82901c1/ipython-9.14.0.tar.gz", hash = "sha256:6f27ff0f1d9ea050e0551f71568bc4b34d8aba579e8f111c5b4175f44ac6b4aa", size = 4432601, upload-time = "2026-05-29T15:13:24.611Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/86/3060e8029b7cc505cce9a0137431dda81d0a3fde93a8f0f50ee0bf37a795/ipython-9.13.0-py3-none-any.whl", hash = "sha256:57f9d4639e20818d328d287c7b549af3d05f12486ea8f2e7f73e52a36ec4d201", size = 627274, upload-time = "2026-04-24T12:24:53.038Z" }, + { url = "https://files.pythonhosted.org/packages/14/a3/9e59340f02c1dc8f8c0a05b09244712b8609eb5439f9996e887e2b82f452/ipython-9.14.0-py3-none-any.whl", hash = "sha256:8fd984a3372c14b12790b084ba6b5cff5678c0cb063244a0034f06a51f20d6c2", size = 627457, upload-time = "2026-05-29T15:13:22.942Z" }, ] [[package]] @@ -1387,7 +1390,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "comm" }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython", version = "9.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jupyterlab-widgets" }, { name = "traitlets" }, { name = "widgetsnbextension" }, @@ -1459,7 +1462,8 @@ dependencies = [ { name = "attrs" }, { name = "jsonschema-specifications" }, { name = "referencing" }, - { name = "rpds-py" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1531,7 +1535,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ipykernel" }, { name = "ipython", version = "8.39.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "ipython", version = "9.14.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "prompt-toolkit" }, @@ -1590,7 +1594,7 @@ wheels = [ [[package]] name = "jupyter-server" -version = "2.18.2" +version = "2.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1613,9 +1617,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/15/1eacb0fcb79ef86e8a0a79a708e6ad7435f6f223097dd29a4ce861fabc44/jupyter_server-2.18.2.tar.gz", hash = "sha256:06b4f40d8a7a00bb39d5216859c81374a0e7cfefe6d8a5a7facc5a5c37c679a7", size = 753177, upload-time = "2026-05-06T07:04:36.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/a0/eb3c511f54df7b54ca5fc7bff3f4d2277d69052d6a7f521643dfed5279d6/jupyter_server-2.19.0.tar.gz", hash = "sha256:1731236bc32b680223e1ceb9d68209a845203475012ef68773a81434b46a31a7", size = 754561, upload-time = "2026-05-29T11:21:26.057Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/50/ecf4f70d65bdb7519b28a33d1b2fee8a4b4ba1ae1a92f15d97e877c5de21/jupyter_server-2.18.2-py3-none-any.whl", hash = "sha256:fa5e46539ded65791838035a2b6001f13e54d5f64b8b3752eb1e91fdd641a5b8", size = 391907, upload-time = "2026-05-06T07:04:34.014Z" }, + { url = "https://files.pythonhosted.org/packages/c1/78/d2881e68894cecdcd05912a9c585cfb776ef1fb38b62c8dba98f12ab3adc/jupyter_server-2.19.0-py3-none-any.whl", hash = "sha256:cb76591b76d7093584c2ad2ae72ac3d58614a4b597507a1bb04e1f9f683cf9ea", size = 392244, upload-time = "2026-05-29T11:21:23.871Z" }, ] [[package]] @@ -2075,7 +2079,7 @@ dependencies = [ { name = "fonttools" }, { name = "kiwisolver" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "packaging" }, { name = "pillow" }, { name = "pyparsing" }, @@ -2540,7 +2544,7 @@ wheels = [ [[package]] name = "numpy" -version = "2.4.4" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", @@ -2548,79 +2552,79 @@ resolution-markers = [ "(python_full_version >= '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.14' and sys_platform != 'darwin')", "(python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'darwin')", ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, - { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, - { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, - { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, - { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, - { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, - { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, - { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, - { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, - { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, - { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, - { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, - { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, - { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, - { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, - { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, - { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, - { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, - { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, - { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, - { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, - { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, - { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, - { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, - { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, - { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, - { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, - { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, - { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, - { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, - { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, - { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, - { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, - { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, - { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, - { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, - { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, - { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, - { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, - { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, - { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, - { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, - { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, - { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, - { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, - { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, - { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, - { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, - { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, - { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, - { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, - { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, - { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, - { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, - { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, - { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, - { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] [[package]] @@ -2631,7 +2635,7 @@ dependencies = [ { name = "cuda-bindings", marker = "python_full_version >= '3.11'" }, { name = "cuda-core", marker = "python_full_version >= '3.11'" }, { name = "cuda-pathfinder", marker = "python_full_version >= '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pywin32", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, ] wheels = [ @@ -2733,7 +2737,7 @@ dev = [ ] numpy-compat = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] @@ -2941,7 +2945,7 @@ examples = [ ] numpy-compat = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] @@ -3117,11 +3121,11 @@ wheels = [ [[package]] name = "platformdirs" -version = "4.9.6" +version = "4.10.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/47/e4501f49c178ae1d9f4a75073fda4204f52647993f075a9db4d14930e0c5/platformdirs-4.10.0.tar.gz", hash = "sha256:31e761a6a0ca04faf7353ea759bdba55652be214725111e5aac52dfa29d4bef7", size = 31224, upload-time = "2026-05-28T03:32:53.587Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" }, + { url = "https://files.pythonhosted.org/packages/81/e6/cd9575ac904136b3cbf7aa7ee819ef86eedb7274e46f230e94ea4342e729/platformdirs-4.10.0-py3-none-any.whl", hash = "sha256:fb516cdb12eb0d857d0cd85a7c57cea4d060bee4578d6cf5a14dfdf8cbf8784a", size = 22743, upload-time = "2026-05-28T03:32:52.175Z" }, ] [[package]] @@ -3147,30 +3151,30 @@ wheels = [ [[package]] name = "polars" -version = "1.40.1" +version = "1.41.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/8c/bc9bc948058348ed43117cecc3007cd608f395915dae8a00974579a5dab1/polars-1.40.1.tar.gz", hash = "sha256:ab2694134b137596b5a59bfd7b4c54ebbc9b59f9403127f18e32d363777552e8", size = 733574, upload-time = "2026-04-22T19:15:55.507Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/f9/aeda46259b0669247a160315d2d51269de9504b9dd2f70acadbcb22f46b7/polars-1.41.2.tar.gz", hash = "sha256:256d6731162371b77f3f29a55eacb8c0fc740ddb1a293a01d2ef5b5393c5c708", size = 737996, upload-time = "2026-05-29T17:39:15.604Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/91/74fc60d94488685a92ac9d49d7ec55f3e91fe9b77942a6235a5fa7f249c3/polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd", size = 828723, upload-time = "2026-04-22T19:14:25.452Z" }, + { url = "https://files.pythonhosted.org/packages/1f/22/28f62d24f7db56ac4343588f9362d49b7b4177e55ac47a466fe696b0099b/polars-1.41.2-py3-none-any.whl", hash = "sha256:23ce9a2910b6e3e8d4258770bf44aa17170958df7af6e85feedf4458a04d8d29", size = 833445, upload-time = "2026-05-29T17:37:05.576Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.40.1" +version = "1.41.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ba/26d40f039be9f552b5fd7365a621bdfc0f8e912ef77094ae4693491b0bae/polars_runtime_32-1.40.1.tar.gz", hash = "sha256:37f3065615d1bf90d03b5326222df4c5c1f8a5d33e50470aa588e3465e6eb814", size = 2935843, upload-time = "2026-04-22T19:15:57.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/56/54e3ea0e9b64f327179049e4742241cc6b1d3e8fa414b05a057dd26df367/polars_runtime_32-1.41.2.tar.gz", hash = "sha256:7af09ec1ab053da2c9669e8d15f809a4083a29be05db57111688b8051062af56", size = 2989474, upload-time = "2026-05-29T17:39:17.257Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/46/22c8af5eed68ac2eeb556e0fa3ca8a7b798e984ceff4450888f3b5ac61fd/polars_runtime_32-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b748ef652270cc49e9e69f99a035e0eb4d5f856d42bcd6ac4d9d80a40142aa1e", size = 52098755, upload-time = "2026-04-22T19:14:28.555Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/48599a38009ca60ff82a6f38c8a621ce3c0286aa7397c7d79e741bd9060e/polars_runtime_32-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d249b3743e05986060cec0a7aaa542d020df6c6b876e556023a310efd581f9be", size = 46367542, upload-time = "2026-04-22T19:14:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/43/e9/384bc069367a1a36ee31c13782c178dbd039b2b873b772d4a0fc23a2373d/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5987b30e7aa1059d069498496e8dda35afd592b0ac3d46ed87e3ff8df1ad652c", size = 50252104, upload-time = "2026-04-22T19:14:35.945Z" }, - { url = "https://files.pythonhosted.org/packages/15/ef/7d57ceb0651af74194e97ed6583e148d352f03d696090221b8059cdfc90b/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d7f42a8b3f16fc66002cc0f6516f7dd7653396886ae0ed362ab95c0b3408b59", size = 56250788, upload-time = "2026-04-22T19:14:39.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/0f/e4b3ffc748827a14a474ec9c42e45c066050e440fec57e914091d9adda75/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f7becc237a7ec9d9a10878dc8e54b73bbf4e2d94a2991c37d7a0b38590d8f9", size = 50432590, upload-time = "2026-04-22T19:14:43.388Z" }, - { url = "https://files.pythonhosted.org/packages/d9/0b/b8d95fbed869fa4caabe9c400e4210374913b376e925e96fdcfa9be6416b/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:992d14cf191dde043d36fbdbc98a65e43fbc7e9a5024cecd45f838ac4988c1ee", size = 54155564, upload-time = "2026-04-22T19:14:47.239Z" }, - { url = "https://files.pythonhosted.org/packages/06/d9/d091d8fb5cbed5e9536adfed955c4c89987a4cc3b8e73ae4532402b91c74/polars_runtime_32-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:f78bb2abd00101cbb23cc0cb068f7e36e081057a15d2ec2dde3dda280709f030", size = 51829755, upload-time = "2026-04-22T19:14:50.85Z" }, - { url = "https://files.pythonhosted.org/packages/65/ad/b33c3022a394f3eb55c3310597cec615412a8a33880055eee191d154a628/polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537", size = 45822104, upload-time = "2026-04-22T19:14:54.192Z" }, + { url = "https://files.pythonhosted.org/packages/d6/9b/fe72a3811c0357cdb06c67bdc7695fa1623ad47948fc523195f5ac31037f/polars_runtime_32-1.41.2-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:95a08346dac337357cdb825c8076df7d36da54c4caa59a5cb41d0a30691c5edd", size = 52265283, upload-time = "2026-05-29T17:37:09.407Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/fab9da803fd80d9e83ef88c20932f637a10bc611b20415fc322eec84bc44/polars_runtime_32-1.41.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:dedfaeec2c7f995298da7319dd9431d662e5dd1d0ec51b1459df4a0234ceff52", size = 46571222, upload-time = "2026-05-29T17:37:13.698Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2a/8843f34a8ac57acd058a39b87b03b580dd352a490e9dae0415e02033bdd4/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18eea22c5cc34e27f8a60950458ad81e6a9ea75e89363ca1367e14e7e7f781fc", size = 50409372, upload-time = "2026-05-29T17:37:17.875Z" }, + { url = "https://files.pythonhosted.org/packages/6c/c6/92b352fe88cf51bd0a19fb99e1c0cbe46aa26c14dcf7995b89869cd932ae/polars_runtime_32-1.41.2-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2630540dfdfb0f36f9b04a07c7c2e3f50bf2ad384113263c1c812007ee9141e0", size = 56405484, upload-time = "2026-05-29T17:37:22.684Z" }, + { url = "https://files.pythonhosted.org/packages/74/c4/bae3174c3b02f6b441d2e58594387abcd509f67a098f682a83b195f08966/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:20e969e08f9b137e233c04cc04de73d9795f89eb77d34854e40a025965a43763", size = 50603512, upload-time = "2026-05-29T17:37:27.422Z" }, + { url = "https://files.pythonhosted.org/packages/f4/ed/f2d26ae02d92c2689056838ed59e2a626326ad23c2831d58637d25f6c82a/polars_runtime_32-1.41.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e7016a3deb641b64a31447abbbee0f34bd020a6a9ae34ee6b743837def15e2a4", size = 54328561, upload-time = "2026-05-29T17:37:32.587Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c4/9c3831cc885dc7769e59abf8f583821a5fb4403fd0e4eba0ccc6d47a3d4b/polars_runtime_32-1.41.2-cp310-abi3-win_amd64.whl", hash = "sha256:1e5e5377c315e0dcafdfb2a31adc546abbaeb3f9cb1864e6536523d2af473265", size = 51978643, upload-time = "2026-05-29T17:37:37.443Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c6/79e9f3f270270d7ed5575d92b7bfef49f01abd9275447161275b23b553a8/polars_runtime_32-1.41.2-cp310-abi3-win_arm64.whl", hash = "sha256:843d96f69d18eca53429c1198e58891db7f18111f83b9c419bb45ad9d73eaed5", size = 46006901, upload-time = "2026-05-29T17:37:42.522Z" }, ] [[package]] @@ -3522,15 +3526,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.3.0" +version = "1.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/cc5a8653e9a24f6cf84768f05064aa8ed5a83dcefd5e2a043db14a1c5f44/python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0", size = 63925, upload-time = "2026-05-05T14:38:39.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/d4/24d543ab8b8158b7f5a97113c831205f5c900c92c8762b1e7f44b7ea0405/python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f", size = 33124, upload-time = "2026-05-05T14:38:38.539Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" }, ] [[package]] @@ -3544,7 +3548,7 @@ wheels = [ [[package]] name = "pytket" -version = "2.16.0" +version = "2.18.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "graphviz" }, @@ -3553,7 +3557,7 @@ dependencies = [ { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "qwasm" }, { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -3561,21 +3565,21 @@ dependencies = [ { name = "typing-extensions" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/d9/3c/ea309a72e0c1ca8e51f66ee6adb28887bc50af0864192fa1dfbb260a6aba/pytket-2.16.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ea53862f3f8ee2b48b564048d0d6684285abe96aedffbc9f387ead78540648a8", size = 5548141, upload-time = "2026-03-25T14:52:37.446Z" }, - { url = "https://files.pythonhosted.org/packages/af/c6/66ebf277713d915e902b6de0a3e57105874af72e6dc869692229247cb8c9/pytket-2.16.0-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:859597eaf8c50b0dc4ed3c6f5bba01bfaa020995bd84bf9503650f3cb30e9be3", size = 6236954, upload-time = "2026-03-25T14:52:39.746Z" }, - { url = "https://files.pythonhosted.org/packages/02/51/daa28cff7c720cbed715f84c5ea843c571285af33e285afea2a776fedb52/pytket-2.16.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f25a60846202a1e9c5f165fb4112503b96638361f5a7d619f854676045dd7494", size = 7580705, upload-time = "2026-03-25T14:52:41.862Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e4/f47935bbf44a5c68b4f3dee51b4d97ef6417b07b7c34f6401297beaed21e/pytket-2.16.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e6b87e9810e80ab57539619ddf6f9d4639cd2aea1a01fb58433e48677aadb00c", size = 8317981, upload-time = "2026-03-25T14:52:43.806Z" }, - { url = "https://files.pythonhosted.org/packages/17/df/291ac431bbe8f0249171e4014e7f1dc9c4b63d961a1b09404ea8d986bb85/pytket-2.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:1e548c94443a934ecd318609988535a3b2741443b12716955328263ee69811f8", size = 9790329, upload-time = "2026-03-25T14:52:45.85Z" }, - { url = "https://files.pythonhosted.org/packages/9b/74/5c28d78be92d9b464908793d950f00125d08ac1f36683803fbdabdd9a80e/pytket-2.16.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:e069de503b6fc86d3a46ff4c17ab60374215c3713b0659d0a07eb88b9aee6716", size = 5550081, upload-time = "2026-03-25T14:52:48.711Z" }, - { url = "https://files.pythonhosted.org/packages/22/d9/85e3754d84c90c3fc80a9aa728f2a1335de0c60c6f210449651b6dd80687/pytket-2.16.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:c005ca4b76b1c896110e6ab810a9c466140f66c11b74dcf36f8695b4a4a3c28e", size = 6239367, upload-time = "2026-03-25T14:52:50.648Z" }, - { url = "https://files.pythonhosted.org/packages/7c/84/a7dd8e96c8ebfa2b5a8fcfb31cb7619685cd4bb40363edaf301f20dda2fc/pytket-2.16.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:825c7b8f98f69e0a9e73008131ad53a0e7eb786e373d4f7d4b5b0865b7a4fd5a", size = 7581960, upload-time = "2026-03-25T14:52:53.139Z" }, - { url = "https://files.pythonhosted.org/packages/58/28/63e7429910065a107bc651fe7cf83581ea3c292560c7a2d5ea20591aa582/pytket-2.16.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e55a82f066a31015f4223bc556aefb06c38eedab01d91030af0b4e7c8fffebf2", size = 8318509, upload-time = "2026-03-25T14:52:55.603Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ea/4b2561423c24212d96d63b1655fdcf86c5d9803455b42b5cfe1f96e4c705/pytket-2.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5db78f3600c9626ea050c3a35267097950bfd4e0b002469c066d2a5ffbbca8ab", size = 9791412, upload-time = "2026-03-25T14:52:57.876Z" }, - { url = "https://files.pythonhosted.org/packages/7b/c0/e5fa70ba5d6fcc58ef009938c2037022830ab16d71eba2564abdeff5c40c/pytket-2.16.0-cp312-abi3-macosx_14_0_arm64.whl", hash = "sha256:30e918546c5c9a64157eb59fdbc64a436517ef23b52d0c4ccf04e1707709ae80", size = 5530999, upload-time = "2026-03-25T14:53:00.271Z" }, - { url = "https://files.pythonhosted.org/packages/12/00/387750d1fcf08fc2935b566c71bf7556b810072c28c3415cf495e9b3f625/pytket-2.16.0-cp312-abi3-macosx_15_0_x86_64.whl", hash = "sha256:581d3de487a6098c95b695947104e9ab1a934b5081fa5cf17d78a97ff8b8b701", size = 6220914, upload-time = "2026-03-25T14:53:02.614Z" }, - { url = "https://files.pythonhosted.org/packages/d1/7f/68d933790e9a9353a07147744336468e61df57dd28976665c071ec6ec9e1/pytket-2.16.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:58fce2fb058dace4c0335ce83a49dc8061f78c9c363fb5439706435cb9be1797", size = 7532010, upload-time = "2026-03-25T14:53:04.909Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/6891f73f189065a8616c44473748eb2c3e4feb8ce4ec85bfa50fb031bb7e/pytket-2.16.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc0a6be06dc03c38423775cdd5bd9e6df81518f9e78128d9deaca20663eff358", size = 8260551, upload-time = "2026-03-25T14:53:07.337Z" }, - { url = "https://files.pythonhosted.org/packages/c8/23/d856913e5c5501e82890791ec9934add317e6f344a7d495e5dac141cc9a0/pytket-2.16.0-cp312-abi3-win_amd64.whl", hash = "sha256:b00f7eb42a84bb77d1b33a460e78392f51026724a1ec7e090a1f018db51c566e", size = 9764004, upload-time = "2026-03-25T14:53:09.658Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8d/7b860651db750067666a74400812b7610847ecd604636574b980a4400b9a/pytket-2.18.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ab80518ee545bc44dff7f40fb68d46cfa946a0d86cbe47227e0c704c57819391", size = 5549168, upload-time = "2026-05-15T08:57:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cb/5a2ca0e39fa3ffed7460ce446fd843824c82dc501966d067b8ab2e0e9988/pytket-2.18.0-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:392384e74f2df432428c12020eb358474cee201b0387eeddac51cace3b2bfd2f", size = 6238136, upload-time = "2026-05-15T08:57:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/ec940c12f8376686d24d0d7d51674a71e45d0441b50bdfada7260e65b6c3/pytket-2.18.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ada57f98fde63a43db5d49407cfda06b70c792c93c018bfee9fb1dd336e07eb6", size = 7583259, upload-time = "2026-05-15T08:57:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/5c/80/0c66b92d7b14c990e90f9d226d69cc1a9d59816b515795ffe63f43045ca3/pytket-2.18.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e50c5368516649e42723256a0b610112f688824fef9535e9adb31d83c1ac359", size = 8319658, upload-time = "2026-05-15T08:57:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/c4/41/87498ac87aa2b5e330b188f6b4614466b451ca64012c7586c5411adcc354/pytket-2.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:6d29b2d1b5277e47ad149b28d5bf13706dd2d3d0502ec577ca21cac4353cc499", size = 9790910, upload-time = "2026-05-15T08:57:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/47/cc/cfcc64549767dee84172ebb5904e1b69d6246099d6ae170b2c91e0e0002c/pytket-2.18.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f700a97c4caf2ac98c47a2c8e37b71b28b9b35ca9218bbb0357bba499b23eeaa", size = 5550955, upload-time = "2026-05-15T08:57:47.038Z" }, + { url = "https://files.pythonhosted.org/packages/27/78/69295578749e0724647d3ba927f4d5466c48dc89218019e3298acb36842d/pytket-2.18.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:9349e1f6faddbe315fe32dd42da8609899b923b607034ff97ae40cb7f5a3d40c", size = 6240572, upload-time = "2026-05-15T08:57:49.046Z" }, + { url = "https://files.pythonhosted.org/packages/37/e1/6d75b33869b710699e939ab0b5193477d5e0f2d825613b07febf3c16c020/pytket-2.18.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3af39d4f3ae3297de8d75773b940800820192e12392071dab7eb3b4c761857", size = 7584459, upload-time = "2026-05-15T08:57:50.761Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/9047f68f21a12f14ca0c1a4fde2e618935afc79f061e2288ded62c468dbb/pytket-2.18.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4817d27ff0ebb5481b64b86eb3ec759b619354c0dff5e4f1a4cfc6e4b888cf78", size = 8320126, upload-time = "2026-05-15T08:57:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/7dbbd09d136084cb07f4aff4fc8cae03791e16c026713e6a72b9b170c601/pytket-2.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:60d95245695257642853ff9b0dec31fd520c9c07cdba4d4fb429a861914b97fe", size = 9791813, upload-time = "2026-05-15T08:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/15/39/28cd32ee7606b1b252b29464ba9afaa2c59cfa4a21fea715c9ed150dfe4e/pytket-2.18.0-cp312-abi3-macosx_14_0_arm64.whl", hash = "sha256:9673824105eb781ae05c4c80d3cd6786f4ab58c5a5def5b9ca7ae890dce9fd2d", size = 5531855, upload-time = "2026-05-15T08:57:57.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/03df743d1349f649a96e26eab7726288b03e9a84d2ecaf6eb9f6e25760e5/pytket-2.18.0-cp312-abi3-macosx_15_0_x86_64.whl", hash = "sha256:c819bae62bd96020053ad872a7e85150509781c2a83d7a66a69b9c6b5f6329cb", size = 6223192, upload-time = "2026-05-15T08:57:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e5/9886826e5ccb5639e493567073e04dc2911972dd8ea095367305f546df13/pytket-2.18.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c805946d49d93d13bb3b4fe21f69e89be38f9b1d5739aea8f64cd1e70c85da4d", size = 7534626, upload-time = "2026-05-15T08:58:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/01/00/b50f3b11b92d5c74713d37ddb57e310a0fcc63eb13549007167985801286/pytket-2.18.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ca100f33ca3b56f8fe9f1d72c904067789cfc58bc084439f9aa409ec6621fc3", size = 8262137, upload-time = "2026-05-15T08:58:03.839Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ce/87fe3c1d0cb981f076d0cccde68043734ec57587a3f8049a675aae0e29e8/pytket-2.18.0-cp312-abi3-win_amd64.whl", hash = "sha256:9557d36cfe4b4376c36c071b0ac792038314ebec396df8252143ac4a1972a7b3", size = 9764538, upload-time = "2026-05-15T08:58:05.654Z" }, ] [[package]] @@ -3822,92 +3826,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/01/1b/5dbe84eefc86f48473947e2f41711aded97eecef1231f4558f1f02713c12/pyzmq-27.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c9f7f6e13dff2e44a6afeaf2cf54cee5929ad64afaf4d40b50f93c58fc687355", size = 544862, upload-time = "2025-09-08T23:09:56.509Z" }, ] -[[package]] -name = "pyzstd" -version = "0.18.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/47/82/7bcafbf06ee83a66990ce5badbb8f4dc32184346bab20de7e468b1a2f6ec/pyzstd-0.18.0.tar.gz", hash = "sha256:81b6851ab1ca2e5f2c709e896a1362e3065a64f271f43db77fb7d5e4a78e9861", size = 806048, upload-time = "2025-10-05T08:19:47.994Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/18/92/a9f14aba213e39c5994e01343f4ec679fbecc303f60cd26a65b2687826ce/pyzstd-0.18.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:79bb84d866bf57ad2c4bc6b8247628b38e965c4f66288f887bf90f546a42ae04", size = 369087, upload-time = "2025-10-05T08:17:38.272Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/99688133d11bb2fb9fdfb13e8391742c34750927b2091f3a93bc98ccaf99/pyzstd-0.18.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c0576c48e2f7a2c457538414a6197397c343b1bf5bfe9332b049afd0366c0c92", size = 295161, upload-time = "2025-10-05T08:17:40.377Z" }, - { url = "https://files.pythonhosted.org/packages/04/d7/84e4ea7f2d429454ee1abc8bbccd6d7a288edbd014fd960f914af807fe06/pyzstd-0.18.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ea7702484795ee3c16c48a03d990123e833f1e1d6baabbe9a53256238eb04cbc", size = 408550, upload-time = "2025-10-05T08:17:41.565Z" }, - { url = "https://files.pythonhosted.org/packages/ba/2f/84dbb3fa0f87077450e795863cc408ee94fb24f85e76428f38a98902e25d/pyzstd-0.18.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c412ac29a9ebb76c8c40f2df146327b460ce184bbbdaa5bc9257317dce4caa8", size = 514924, upload-time = "2025-10-05T08:17:43.195Z" }, - { url = "https://files.pythonhosted.org/packages/48/07/8a326a0a050bdba20ac75254f472fa0c305cd31518c8d05451ef4ad8c256/pyzstd-0.18.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:36baae4201196c2ec6567faf4a3f19c68211efc2fca30836c885b848ed057f66", size = 573393, upload-time = "2025-10-05T08:17:44.444Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6c/bccc029d5c304d9315d8a11d606ee50c31b822e8bce1a7e2be300969e282/pyzstd-0.18.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f6d9c8a535af243c5a19f2d66c3733595ab633e00b97237d877e70e8389edc5", size = 428790, upload-time = "2025-10-05T08:17:45.666Z" }, - { url = "https://files.pythonhosted.org/packages/5c/a1/a21bb405bb405f516581529eccb1b057db391246af31f8755072ed08716a/pyzstd-0.18.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a533550740ce8c721aae27b377fb1160df68a9f457f16015ec8e47547a033dfc", size = 416564, upload-time = "2025-10-05T08:17:47.185Z" }, - { url = "https://files.pythonhosted.org/packages/38/58/9ca3eb20d047f23db0e1d99cd81e6889d9d7e7b3dde3ddae8894f7e0f75a/pyzstd-0.18.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdd76049c8ccbb98276cfa78d807b4a497ec6bad2603361eceae993c6130e5bf", size = 519751, upload-time = "2025-10-05T08:17:48.745Z" }, - { url = "https://files.pythonhosted.org/packages/13/7c/156bcc5f499eb6ca0eafba3f716cda01d8d5119270fab7ac5ca0519b0c3a/pyzstd-0.18.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:09b73fe07a8d81898ef1575cb3063816168abb3305c1a9f30110383b61a4ee92", size = 563494, upload-time = "2025-10-05T08:17:50.298Z" }, - { url = "https://files.pythonhosted.org/packages/11/c3/511560f2043c88dee9a87a8db750d2b805e9be8054f35414e145acade401/pyzstd-0.18.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6baf9fd75d0af4f5d677b6e2d8dd3deb359c4ec2250c8536fe5ea48fd9305199", size = 433321, upload-time = "2025-10-05T08:17:51.46Z" }, - { url = "https://files.pythonhosted.org/packages/11/1e/6d676a5403acbc422e79637c997235d5fc1a2d1fa234a9b22dd4c240fe29/pyzstd-0.18.0-cp310-cp310-win32.whl", hash = "sha256:c0634ab42226d2ad96c94d57fd242df2ca9417350c2969eb97c8c61d9574ba69", size = 220852, upload-time = "2025-10-05T08:17:53.001Z" }, - { url = "https://files.pythonhosted.org/packages/f2/b5/b1389a474d04c59dc50b8546032e5eaac67718025db9efe8dabe04d5b6f9/pyzstd-0.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:ec99569321a99b9868666c85a5846151f9a16b6a222b59b2570e2ddeefd4d80c", size = 249552, upload-time = "2025-10-05T08:17:54.463Z" }, - { url = "https://files.pythonhosted.org/packages/13/2d/6a6bcee3f94a44bda87a3fc8619496ab02736979e373f2dc74d0d9b083fb/pyzstd-0.18.0-cp310-cp310-win_arm64.whl", hash = "sha256:85371149cc1d8168461981084438b9f2f139c1699e989fef44562f7504ba0632", size = 222621, upload-time = "2025-10-05T08:17:55.573Z" }, - { url = "https://files.pythonhosted.org/packages/63/d5/c81a3b2b2ddfd534552649344f7f9dc48c05c48b0b3e4065eb12209d37b0/pyzstd-0.18.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:848914835a8a984d4c5fad2355dc66f0aca979b35ec22753c9e694be8e98403c", size = 369088, upload-time = "2025-10-05T08:17:57.063Z" }, - { url = "https://files.pythonhosted.org/packages/b3/00/9c9b0afba9a9bad95cd1bc16203058296a3c2b28040bb0decccd8d662c10/pyzstd-0.18.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3938fea87fe83113b5d8ec2925bb265b4c540e374bb0ec73e5528de58d68c393", size = 295160, upload-time = "2025-10-05T08:17:58.714Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f4/d71aafa852232dec6ec0d34a45b12136958042079703e60314e4a48bab5d/pyzstd-0.18.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9af4bcde7dde46ca7e82a4c6f5fda1760bcbfd15525dbea36fe625263ef06b5e", size = 408553, upload-time = "2025-10-05T08:17:59.889Z" }, - { url = "https://files.pythonhosted.org/packages/27/5b/6af21645f1eb9e22416297dd1bc92615b89f91e5a657d37c528872a566ad/pyzstd-0.18.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d9419d173d26de25342235256aba363190e48e3fd8a8988420a26221b45320", size = 514931, upload-time = "2025-10-05T08:18:01.208Z" }, - { url = "https://files.pythonhosted.org/packages/7d/19/38c3440b82b227d3c99f1e4283584903633bba7e5a8bf757ffce3e913efb/pyzstd-0.18.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b84f75f0494087afad31363e80a3463d1f32a0a6265f1a24660e6422b2b6fa6", size = 573396, upload-time = "2025-10-05T08:18:02.48Z" }, - { url = "https://files.pythonhosted.org/packages/36/b9/7ee22f141b0438c2082cde550ced698ee2490ce5c0acd461d7f8fc2db18a/pyzstd-0.18.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cfcdf0e46020bda2e98814464ca3ae830da83937c4c61776bf8835c7094214e", size = 428794, upload-time = "2025-10-05T08:18:04.063Z" }, - { url = "https://files.pythonhosted.org/packages/ea/8f/b6a16829534fa3eac8ef40cc95dbf44a0c2b36a366829b49486a133e5d6b/pyzstd-0.18.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8551b6bc3690fb76e730967a628b6aab0d9331c38a41f5cddb546be994771191", size = 416581, upload-time = "2025-10-05T08:18:05.357Z" }, - { url = "https://files.pythonhosted.org/packages/6d/dc/601f56488064483358e90005169eecdec33fdb41d10fef825ca8cea6f1ea/pyzstd-0.18.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6883b47a4d5d5489890e24e74ef14c1f16dcd68bb326b86911ae0e254e33e4b7", size = 519760, upload-time = "2025-10-05T08:18:06.614Z" }, - { url = "https://files.pythonhosted.org/packages/70/d9/98328d76d9ce3bf5479af62dfe2f141250c60e210cd9f1a9ed9e14ebc978/pyzstd-0.18.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:929dec930296362ce03fee81877fa93a68ca4de3af75fdfa96ecbe0e366b2ee3", size = 563496, upload-time = "2025-10-05T08:18:08.344Z" }, - { url = "https://files.pythonhosted.org/packages/bb/80/ba2cb4769035014a4d272c06414404b9652bbfc14562ab59a9b8ffd9b1eb/pyzstd-0.18.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:278c80fdeaf857b620295cc815a31f6478fcb217d476ac889985a43b2b67e9bd", size = 433325, upload-time = "2025-10-05T08:18:10.11Z" }, - { url = "https://files.pythonhosted.org/packages/31/72/5303be53d439500067195c754ec31a8d9e8b2e95a6089ecd3f7354d9134e/pyzstd-0.18.0-cp311-cp311-win32.whl", hash = "sha256:0d1b678644894e49b5a448f02eebe0ac31bde6f51813168f5ff223d7212e1974", size = 220845, upload-time = "2025-10-05T08:18:11.706Z" }, - { url = "https://files.pythonhosted.org/packages/3f/4c/12f74c81d8e43e42338295347223bb37edf9d13a149690fd92e91a083664/pyzstd-0.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:8285a464aed201b166bb0d2f4667485b61b607cf89f12943b1f21f7e84cb4550", size = 249578, upload-time = "2025-10-05T08:18:13.112Z" }, - { url = "https://files.pythonhosted.org/packages/e7/5f/64480ea0ead505f3d30dfa46de9e9f72d7eb0325612ace435f84fafca780/pyzstd-0.18.0-cp311-cp311-win_arm64.whl", hash = "sha256:942badf996589e5ab6cbdd0f7dd33f5dc2cd7ed0b65441c96b9a12ffa7700d51", size = 222617, upload-time = "2025-10-05T08:18:14.591Z" }, - { url = "https://files.pythonhosted.org/packages/8f/19/7c78cf4cedb812362bb77d0ad5c7e0fa843a344d5d5737a55dd1c1c2b987/pyzstd-0.18.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5eef13ee3e230e50c01b288d581664e8758f7b831271f6f32cfc29823a6ab365", size = 369768, upload-time = "2025-10-05T08:18:16.049Z" }, - { url = "https://files.pythonhosted.org/packages/24/97/b01f76d7a9d7237d9b1dee94469d638575268635ba22c1d07acc23ee3ead/pyzstd-0.18.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f78d6ef80d2f355b5bc1a897e9aa58659e85170b3fa268f3211c4979c768264c", size = 295766, upload-time = "2025-10-05T08:18:17.234Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ac/2f629bb68c545d2f65e070b69a4aad39418b874ad61715d5f12014a06a74/pyzstd-0.18.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:394175aeeb4e2255ff5340b32f6db79375b3ffb25514fe4c1439015a7f335ec2", size = 409439, upload-time = "2025-10-05T08:18:18.397Z" }, - { url = "https://files.pythonhosted.org/packages/fa/cc/b9bab32be36cf4552940c5bda281e1480b01cbf12b5852b529f69a6f33e8/pyzstd-0.18.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3250c551f526d3b966cf4a2199a8d9538dc5c7083b7a26a45f305f8f2ab20a06", size = 516327, upload-time = "2025-10-05T08:18:19.597Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/c208c74ba04fa5fcef421fd728e906cd1570f8bffb77285c8f9418892bd4/pyzstd-0.18.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a99ca80053ca37be21f05f6c4152c70777e0eface72b08277cb4b10b6d286e79", size = 574893, upload-time = "2025-10-05T08:18:20.824Z" }, - { url = "https://files.pythonhosted.org/packages/57/fa/b049d9688f46f8dd3509f5cb0a8ba629716bb9238161c119d8d06454412d/pyzstd-0.18.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5dc4488536e87ff0aac698b9cd65f2913ac87417b3952d80be32463c8e95cc35", size = 429851, upload-time = "2025-10-05T08:18:22.12Z" }, - { url = "https://files.pythonhosted.org/packages/9d/35/dff064fa704a0f335aab326318651af81aac611a8a24e7f1be3b9fb50d32/pyzstd-0.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12da158f6ec1180be0a3d6f531050dfc1357a25e5d0fd8dd99d4506d2a3f448", size = 417437, upload-time = "2025-10-05T08:18:23.264Z" }, - { url = "https://files.pythonhosted.org/packages/c1/2d/d1731fd1213da3a41dcba84e297118c09ed6f05ff0f600c5b0a42edb0f62/pyzstd-0.18.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f9a7d6bff36dfbe87dce1730e4b70d6ab49058a6f8ea22e85b33642491a2d053", size = 521302, upload-time = "2025-10-05T08:18:24.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/89/0a50c7b3f71921a7df41a222283b56fbe236dc79dc142969705676a9eca1/pyzstd-0.18.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:0f56086bf8019f7c809a406dcc182ce0fb0d3623a9edf351ed80dbb484514613", size = 564793, upload-time = "2025-10-05T08:18:26.122Z" }, - { url = "https://files.pythonhosted.org/packages/1f/8c/b1927786cd208a1271728969ba45881a823663b6621e2905c1029ad31a15/pyzstd-0.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eb69217ad9b760537e93f2d578c7927b788a9cac0e2104e536855a2797b5b09", size = 434400, upload-time = "2025-10-05T08:18:27.668Z" }, - { url = "https://files.pythonhosted.org/packages/66/f2/9780dc79d4bc9b6f5c64fe91541bf4a15dda9a9bf5eaf4a641436a50b2d0/pyzstd-0.18.0-cp312-cp312-win32.whl", hash = "sha256:05ce49412c7aef970e0a6be8e9add4748bc474a7f13533a14555642022f871e9", size = 221172, upload-time = "2025-10-05T08:18:29.464Z" }, - { url = "https://files.pythonhosted.org/packages/a2/d0/1b5c6e7bbe8e300159edd47556233a7756f59ee6d78b83e95e258e1ceaf9/pyzstd-0.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:e951c3013b9df479cff758d578b83837b2531d02fb6c3e59166a756795697e19", size = 249751, upload-time = "2025-10-05T08:18:30.642Z" }, - { url = "https://files.pythonhosted.org/packages/0e/c2/7b64e5c2afd752fa18bc5a5c6e260dad14402e6f94d810f8ce2ad64ad512/pyzstd-0.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:33b54781c66a86e33c93c89ae426811d0aa35a216a23116fc5d5162449284305", size = 222551, upload-time = "2025-10-05T08:18:31.774Z" }, - { url = "https://files.pythonhosted.org/packages/f5/55/9e200f8ad193bb9d060cd724a8f876e40be918eece578c46c33cab336cf4/pyzstd-0.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:65117997d1e10e9b41336c90c2c4877c8d27533f753272805ff39df15fd5298a", size = 369781, upload-time = "2025-10-05T08:18:33.37Z" }, - { url = "https://files.pythonhosted.org/packages/64/73/28bc86e284ca5e5ea082641748f2a583c9db8744dbf2c28d54e85e101114/pyzstd-0.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8550efbfb5944343666d0e79d6a3687adcbeb4dbf17aa743146a25e72d12d47f", size = 295771, upload-time = "2025-10-05T08:18:34.665Z" }, - { url = "https://files.pythonhosted.org/packages/98/3d/bb6f1316a14bd4079aa3cad13cfd7cd3a1feef4a5756bdb7a9e8758a5ab1/pyzstd-0.18.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac61854c4a77df66695540549a89f4c67039e4181a9158b8646425f1d56d947a", size = 411244, upload-time = "2025-10-05T08:18:35.879Z" }, - { url = "https://files.pythonhosted.org/packages/be/ee/32fdd020003a3bc672b93ec1f9c2520a196bc57e6847a7ff38991dc7b4ea/pyzstd-0.18.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4c453369483f67480f86d67a7b63ef22827db65e7f0d4bec7992bb81751a94b9", size = 518289, upload-time = "2025-10-05T08:18:37.657Z" }, - { url = "https://files.pythonhosted.org/packages/a3/c2/0564465315ba5d3524f8bc625af2c125d4549a186bda6fa3b6eafa88d8f4/pyzstd-0.18.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4ef4b757b2df808ac15058fc2aa41e07d93843ee5a95629ff51eb6e8f1950951", size = 577193, upload-time = "2025-10-05T08:18:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/de/16/8540b868a5b0fa9eaf743856f86088e3cac8477fbf2339e76c5c973622cc/pyzstd-0.18.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b42529770febd331e23c5e8a68e9899acb0cc0806ee4c970354806c0ceeec6c7", size = 429177, upload-time = "2025-10-05T08:18:40.11Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/08ba7b145978b0b5c4d83e26dca8140d71516fed42f60b31a7f387c6c89f/pyzstd-0.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7f54d13c269cdc37d2f73c9b3e70c6d2bb168dec768a472d54c2ed830bb19fb9", size = 419281, upload-time = "2025-10-05T08:18:41.46Z" }, - { url = "https://files.pythonhosted.org/packages/cb/da/7f64baf48b060bdc071388c90fb6b32992a5827ab5810d6deced86219ec5/pyzstd-0.18.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:e6686460ca4be536dca1b6f2f80055f383a78e92e68e03a14806428572c4fdba", size = 523202, upload-time = "2025-10-05T08:18:42.868Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5b/f5227ebe3ba672ec1051d27546c1d1cb1fde4a84fe8c5fa8cd6bc1b35614/pyzstd-0.18.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:8da3978d7de9095cacc5089bd0c435ab84ebd127e0979cd31fa1b216111644af", size = 567497, upload-time = "2025-10-05T08:18:44.507Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ca/d067850a8035efc68ccc0399f343e86c18111dcb9ddec2a4b8b2ebe421eb/pyzstd-0.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ebc87e6e50547cff97e07c3fed9999d79b6327c9c4143c3049a7cfeacb2cdba", size = 433881, upload-time = "2025-10-05T08:18:45.761Z" }, - { url = "https://files.pythonhosted.org/packages/64/26/6c91c0815556ab8f7ddd67e891009a4ba6481b905b84a361697f208b053a/pyzstd-0.18.0-cp313-cp313-win32.whl", hash = "sha256:2dd203f2534b16dea2761394fda4e0f3c465a5109ae6450bdaada67e6ac14a45", size = 221170, upload-time = "2025-10-05T08:18:46.986Z" }, - { url = "https://files.pythonhosted.org/packages/a5/8e/3f6f445ee23864988ae3cfd6cebd3fe79dd42dbaa50538275928082e3df8/pyzstd-0.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:98f43488f88b859291d6bdc51cc7793d1eab17aa9382b17d762944bbb8567c98", size = 249759, upload-time = "2025-10-05T08:18:48.526Z" }, - { url = "https://files.pythonhosted.org/packages/b3/43/1a96bd7bae073ae57c3927ece4cd72355ad46b7153961857d6a77a5bec9d/pyzstd-0.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:cff8922e25e19d8fbd95b53f451e637bc80e826ab53c8777a885d4e99d1c0c2d", size = 222548, upload-time = "2025-10-05T08:18:50.163Z" }, - { url = "https://files.pythonhosted.org/packages/11/37/d82e2bf7aad6df47a0914b47460a242802d6107cb63e3b0f0b02fab911b0/pyzstd-0.18.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:67f795ec745cfd6930cdaf5118fcdd8d87ce02b07b254d37efe75afd33ce9917", size = 369755, upload-time = "2025-10-05T08:18:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/90/1f/b01eb168e7dd689a0df6dcdd5e8171953927330e3b40e290c7a227714e50/pyzstd-0.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a8a589673b9b417a084e393f18d09a16b67b87a80f80da6d3b4f84dd983c9b3d", size = 295789, upload-time = "2025-10-05T08:18:53.065Z" }, - { url = "https://files.pythonhosted.org/packages/c0/70/581b01d765833eb12f8b04e048d4d5f803007e491c9ecb57bc1ca810317f/pyzstd-0.18.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdaee8c33f96a6568225e821e6cc33045917628ae0bc7d8d3855332085c1aa7c", size = 411367, upload-time = "2025-10-05T08:18:54.295Z" }, - { url = "https://files.pythonhosted.org/packages/32/ff/9896a01d1eef51e352f6d9d8175f9ff13b394a8239f337c6e0ec930fe3ee/pyzstd-0.18.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42bf45d8e835d7c9c0bef98ff703143a5129edf09ef6c3b757037cbf79eabcaa", size = 518386, upload-time = "2025-10-05T08:18:55.472Z" }, - { url = "https://files.pythonhosted.org/packages/26/24/60cebe2101a3915535a480f77c0acc1e2b9d690377ef72e0237594def37e/pyzstd-0.18.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f4dff2a15e2047baea9359d3a547dee80f61887f17e0f23190b4b932fd617e4", size = 577251, upload-time = "2025-10-05T08:18:56.789Z" }, - { url = "https://files.pythonhosted.org/packages/7e/d0/86beb2bf4b112c46eb39c24389561f979672f262dcb76f3b500c7ab47fb1/pyzstd-0.18.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2ed87932d6c534fc8921f7d44a4dadb32881e10ebc68935175a2cba254f5cc83", size = 429102, upload-time = "2025-10-05T08:18:57.973Z" }, - { url = "https://files.pythonhosted.org/packages/be/a4/1037092d5ff6fc11db687ae71413c51eef59b3fc6899a4777328eafcc7b3/pyzstd-0.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7d08a372b2b7fa1fd24217424e13d3d794e01299c43c8bd55f50934ef0785779", size = 419324, upload-time = "2025-10-05T08:18:59.297Z" }, - { url = "https://files.pythonhosted.org/packages/b3/97/13855620caa4f00262b819b0962dbf66e798f17523fd770edea5eb471e52/pyzstd-0.18.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:e8403108172e24622f51732a336a89fe32bf3842965e0dc677c65df3a562f3ad", size = 523380, upload-time = "2025-10-05T08:19:01.022Z" }, - { url = "https://files.pythonhosted.org/packages/a4/20/7f0b24d7da75637094191847df620e6a3de954742a2899c89c0f80fd9f71/pyzstd-0.18.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5604eeb7f00ec308b7e878dae92abfc4eee2e5d238765a62d4fadc0d57bbbff3", size = 567596, upload-time = "2025-10-05T08:19:02.408Z" }, - { url = "https://files.pythonhosted.org/packages/58/87/389186d0ba732ba4f53dbf3dc82ac08a783e7ea2ab06de21c98ae4ae5a65/pyzstd-0.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6b300c5240409f1e7ab9972ab2a880a1949447d8414dbc11d89c10bfcb31aa5", size = 433862, upload-time = "2025-10-05T08:19:05.258Z" }, - { url = "https://files.pythonhosted.org/packages/fc/6f/bedcdbbfecae9f253eaeaa3776a2876cbd4af66ce710d1e2492546debc61/pyzstd-0.18.0-cp314-cp314-win32.whl", hash = "sha256:83f4fe1409a59c45a5e6fccb4d451e1e3dd03a5fabebd2dd6ba651468f54025e", size = 225481, upload-time = "2025-10-05T08:19:06.525Z" }, - { url = "https://files.pythonhosted.org/packages/df/04/85bc2d0d906762c84f6e2f2be7228c1f610b8d42dfbb0602b281ee14bd9d/pyzstd-0.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:73c3dcd9a16f1669ed6eef0dad1d840b7dd6070ab7d48719171ca691101e7975", size = 255101, upload-time = "2025-10-05T08:19:07.807Z" }, - { url = "https://files.pythonhosted.org/packages/64/5c/19e973213762479698dd551b39e7841fcc16104f492fdf941daa42637633/pyzstd-0.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:61333bbb337b9746284624ed14f6238838dfae1e395691ba49f227015374f760", size = 229465, upload-time = "2025-10-05T08:19:09.084Z" }, - { url = "https://files.pythonhosted.org/packages/59/79/10e69fe241968e1993b6ea4c09c4f4cb5783a7f639d680958a9835310ed4/pyzstd-0.18.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:35934369fcdfde6fb932f88fa441337c8ddaf4b08e7b0b12952010f0ba2082f7", size = 354239, upload-time = "2025-10-05T08:19:28.84Z" }, - { url = "https://files.pythonhosted.org/packages/73/a2/f0e6b6a816693d37840342d1e6a81fa1504c9c0c825f504f80c2ebc9dd91/pyzstd-0.18.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:55b8e12c9657359a697440e88a8535d1a771025e5d8f1c3087ad69ba11bee6d2", size = 283761, upload-time = "2025-10-05T08:19:30.494Z" }, - { url = "https://files.pythonhosted.org/packages/99/c3/3f22f0ded2cd19e506eee9ea270854919d4d2df5006fbb5faef3d561b0f6/pyzstd-0.18.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:134d33d3e56b5083c8f827b63254c2abf85d6ace2b323e69d28e3954b5b71883", size = 338432, upload-time = "2025-10-05T08:19:31.83Z" }, - { url = "https://files.pythonhosted.org/packages/22/62/ac46ca28d6dd56e22207a86e1315b5b2a2c1c1d23b08e1079c07ebbff1e7/pyzstd-0.18.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6c4bffa0157ef9e5cfa32413a5a79448e5affadece4982df274f1b5aae3a680", size = 358420, upload-time = "2025-10-05T08:19:33.059Z" }, - { url = "https://files.pythonhosted.org/packages/38/ce/ec54eeca984a67d4785f0d1fad5179de1781a38adee80a7ecb51a1c5edf9/pyzstd-0.18.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8c36824d94cf77997a899b60886cc2be3ac969083f1d74eb4dd4127234ba50a4", size = 244901, upload-time = "2025-10-05T08:19:34.321Z" }, - { url = "https://files.pythonhosted.org/packages/25/cd/2dafdf61d6d092cc331aa5310ba34a42fa528ae3f3ab0e505b6b5728a56a/pyzstd-0.18.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:788e0889db436cd6d16a3b490006ab80a913d8ce6f46db127f1888066ff4560b", size = 354192, upload-time = "2025-10-05T08:19:35.549Z" }, - { url = "https://files.pythonhosted.org/packages/af/ed/89a2c9144da69dcf8113fa2f3b6d234a17e19ab37f599cc2270bb1ee394e/pyzstd-0.18.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e70b7c36a40d7f946bf6391a206374b057299735d366fad6524d3b9f392441f", size = 283718, upload-time = "2025-10-05T08:19:36.819Z" }, - { url = "https://files.pythonhosted.org/packages/a1/b1/179601c6972cea615af936de9ba8225be9d53c2cfd0a2a7ceacf32a22857/pyzstd-0.18.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:571c5f71622943387370f76de8cc0de3d5c6217ab0f38386cb127665e4e09275", size = 338432, upload-time = "2025-10-05T08:19:38.073Z" }, - { url = "https://files.pythonhosted.org/packages/b8/57/067cf55f86a4baf8cd9e91f7e9596038cabc2b296ac3e0f9be14310c435f/pyzstd-0.18.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de0b730f374b583894d58b79cff76569540baf1e84bc493be191d3128b58e559", size = 358419, upload-time = "2025-10-05T08:19:39.34Z" }, - { url = "https://files.pythonhosted.org/packages/49/88/53d1ec8c639305fb96944b3a1e7f60b6e6af80781d970036c3cf2d6d2316/pyzstd-0.18.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b32184013f33dba2fabcdda89f2a83289f5b717a0c2477cda764e53fdafec7ee", size = 244902, upload-time = "2025-10-05T08:19:40.607Z" }, -] - [[package]] name = "qir-qis" version = "0.1.8" @@ -3996,7 +3914,8 @@ version = "0.37.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, - { name = "rpds-py" }, + { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -4006,7 +3925,7 @@ wheels = [ [[package]] name = "requests" -version = "2.34.0" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -4014,9 +3933,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -4069,6 +3988,10 @@ wheels = [ name = "rpds-py" version = "0.30.0" source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version < '3.11' and platform_machine != 'x86_64') or (python_full_version < '3.11' and sys_platform != 'darwin')", +] sdist = { url = "https://files.pythonhosted.org/packages/20/af/3f2f423103f1113b36230496629986e0ef7e199d2aa8392452b484b38ced/rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84", size = 69469, upload-time = "2025-11-30T20:24:38.837Z" } wheels = [ { url = "https://files.pythonhosted.org/packages/06/0c/0c411a0ec64ccb6d104dcabe0e713e05e153a9a2c3c2bd2b32ce412166fe/rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288", size = 370490, upload-time = "2025-11-30T20:21:33.256Z" }, @@ -4187,29 +4110,172 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/b7/b95708304cd49b7b6f82fdd039f1748b66ec2b21d6a45180910802f1abf1/rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e", size = 562191, upload-time = "2025-11-30T20:24:36.853Z" }, ] +[[package]] +name = "rpds-py" +version = "2026.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", + "(python_full_version >= '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.14' and sys_platform != 'darwin')", + "(python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'darwin')", +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, + { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, + { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, + { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, + { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, + { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, + { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, + { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, + { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, + { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, + { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, + { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, + { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, + { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, + { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, + { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, + { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, + { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, + { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, + { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, + { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, + { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, + { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, + { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, + { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, + { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, + { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, + { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, + { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, + { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, + { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, + { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, + { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, + { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, + { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, + { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, + { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, + { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, + { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, + { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, + { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, + { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, + { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, + { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, + { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, + { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, + { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, + { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, + { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, + { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, + { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, + { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, + { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, + { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, + { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, + { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, + { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, + { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, + { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, + { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, + { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, + { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, + { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, + { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, + { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, + { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, + { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, + { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, + { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, + { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, + { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, + { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +] + [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" }, + { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" }, + { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" }, + { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" }, + { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" }, + { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" }, + { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" }, + { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" }, + { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" }, + { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" }, ] [[package]] @@ -4283,7 +4349,7 @@ resolution-markers = [ "(python_full_version >= '3.11' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform != 'darwin')", ] dependencies = [ - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } wheels = [ @@ -4351,7 +4417,7 @@ wheels = [ [[package]] name = "selene-core" -version = "0.2.9" +version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, @@ -4367,7 +4433,7 @@ dependencies = [ { name = "ziglang" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/4e/a9/a94ad38852346869a9c8da037a92cff867b2fe72e9f512bae561a6bc6d23/selene_core-0.2.9-py3-none-any.whl", hash = "sha256:847c78ea393de43e736adf20c3aa7006ae8f96765299a401abbbda6af9af3128", size = 33096, upload-time = "2026-04-28T12:47:34.292Z" }, + { url = "https://files.pythonhosted.org/packages/13/33/32906365f8359588d7d05301a2e645563321bf39a11f076ee16f7aa5178a/selene_core-0.2.10-py3-none-any.whl", hash = "sha256:c53857781c25ce95e170dcf83044ba152e68d799a3585f4874281b306a24899c", size = 33014, upload-time = "2026-05-22T10:07:21.106Z" }, ] [[package]] @@ -4384,22 +4450,22 @@ wheels = [ [[package]] name = "selene-sim" -version = "0.2.15" +version = "0.2.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pyyaml" }, { name = "selene-core" }, { name = "selene-hugr-qis-compiler" }, { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/7b/05/76931c2c757b9a4ac44a75c3b8b1e50209dd94716dad8bc91f8a0023c344/selene_sim-0.2.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:44f301aeb84de22ab4c0d4a2fc0bfa42a035ae66bc2ba3acbcd6f8c67b0a1284", size = 4055479, upload-time = "2026-04-28T13:13:59.353Z" }, - { url = "https://files.pythonhosted.org/packages/40/1c/90d029fe76a22fbd4462039b6dc35b6574d86775d4cf5fd27da760185d73/selene_sim-0.2.15-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:7d5f67ac8c19b3422e653d697eace0c9599b28b82ea1bdc35cc1c3e09f14145b", size = 4212319, upload-time = "2026-04-28T13:14:00.688Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/87d3e225f32b05da5813bfe1ff8bf6c70f60c60919044ec48cae9c6ce625/selene_sim-0.2.15-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:2667b07b0d826b770e6381f7d78eda7559487f38e04abe37c5f343b7dbb2703b", size = 4498690, upload-time = "2026-04-28T13:14:02.208Z" }, - { url = "https://files.pythonhosted.org/packages/ec/08/25d10f9c2c410b2d17cb2eb1c63bfe5dc84282e71081cd8e9e2b69db969d/selene_sim-0.2.15-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:aaf5dbee4c988d02f42b18fe3208aaec020743c17faf103e3e749b5459e5d059", size = 4672594, upload-time = "2026-04-28T13:14:03.59Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ed/db54b8a8a23533754d2b4fd7befa38097f82c38e532a6b2dc529db3a7cb6/selene_sim-0.2.15-py3-none-win_amd64.whl", hash = "sha256:564e2ab77eebe4e193fdc373c31f24c650302a00877f3735dc1f5d97718e88eb", size = 9602115, upload-time = "2026-04-28T13:14:05.331Z" }, + { url = "https://files.pythonhosted.org/packages/7d/6b/bf8f5775c0d0d56c712955ea591d61c467730a88c9de7694d011adf52dcd/selene_sim-0.2.16-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6f2dd9b201641236381ddc0e2b6a630a994d154026bd9db7da6bec8fc2caf428", size = 4838048, upload-time = "2026-05-22T10:29:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/1d/0e/48a157a1c2f05e783be6725ae22c49b76b5b614e756a7b0016377f128172/selene_sim-0.2.16-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:abed123cb9b38f402b5d90b4163089fbbdb082edbd7a0411937648700986d569", size = 5043163, upload-time = "2026-05-22T10:29:58.46Z" }, + { url = "https://files.pythonhosted.org/packages/6b/4e/ad52baec846567b0b106e1a0024eaff9fd625c4c7fa0695bd9f73b79d300/selene_sim-0.2.16-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:cfad4682d1dc9d468fb3d8d238ce1be28a3a65b0758a8bf21d40e15e9dc10e24", size = 4886685, upload-time = "2026-05-22T10:29:59.877Z" }, + { url = "https://files.pythonhosted.org/packages/a9/fd/f63cb665ea020aebe09e750ae24302285e25bfdba9e354c36064c7064489/selene_sim-0.2.16-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:27afd571454c478b6cfe4b80c4f4092e1047d61cab393e88700b2b5346ea0d93", size = 5065403, upload-time = "2026-05-22T10:30:01.61Z" }, + { url = "https://files.pythonhosted.org/packages/65/f0/b57d7191763ed941d9945d7c26457f7e0facf2791096965e1d5d80fce6b3/selene_sim-0.2.16-py3-none-win_amd64.whl", hash = "sha256:45c4bc668557e7d44e7cac10a4f852eec2471cd29c8dd8e9ad03f9cb7d1914d3", size = 10408993, upload-time = "2026-05-22T10:30:03.804Z" }, ] [[package]] @@ -4449,11 +4515,11 @@ wheels = [ [[package]] name = "soupsieve" -version = "2.8.3" +version = "2.8.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7b/ae/2d9c981590ed9999a0d91755b47fc74f74de286b0f5cee14c9269041e6c4/soupsieve-2.8.3.tar.gz", hash = "sha256:3267f1eeea4251fb42728b6dfb746edc9acaffc4a45b27e19450b676586e8349", size = 118627, upload-time = "2026-01-20T04:27:02.457Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/2c/1462b1d0a634697ae9e55b3cecdcb64788e8b7d63f54d923fcd0bb140aed/soupsieve-2.8.3-py3-none-any.whl", hash = "sha256:ed64f2ba4eebeab06cc4962affce381647455978ffc1e36bb79a545b91f45a95", size = 37016, upload-time = "2026-01-20T04:27:01.012Z" }, + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, ] [[package]] @@ -4476,7 +4542,7 @@ version = "1.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/77/15/0218eacd61cda992daf398bc36daf9830c8b430157a3ac0c06379598d24a/stim-1.15.0.tar.gz", hash = "sha256:95236006859d6754be99629d4fb44788e742e962ac8c59caad421ca088f7350e", size = 853226, upload-time = "2025-05-07T06:19:30.452Z" } wheels = [ @@ -4546,7 +4612,7 @@ wheels = [ [[package]] name = "tket" -version = "0.12.13" +version = "0.12.15" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, @@ -4554,13 +4620,13 @@ dependencies = [ { name = "tket-eccs" }, { name = "tket-exts" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/9f/b80e8f9f93e4f6f5af27170a9484f36de1171c939a55b132ad08b43317e7/tket-0.12.13.tar.gz", hash = "sha256:d6b394f3b27e2d9e67438a007592d1c980bee19b0544a7735972aa162543adeb", size = 460359, upload-time = "2025-12-10T18:07:02.743Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/5d/0d063d5b0f90fe197c68fcda5bee384606a368666abe961cd1227ae1afee/tket-0.12.15.tar.gz", hash = "sha256:1ce7c0877c510810ae91be9c0a7a1b638de8b7f5008f065939aa200ca7cde5a5", size = 464051, upload-time = "2026-01-16T16:40:41.838Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/09/30/a755e7b4365c4c89294f152e342734c3d4e9ad3e2ba5ba9b8bbd2f516b11/tket-0.12.13-cp310-abi3-macosx_13_0_arm64.whl", hash = "sha256:8a0ba6c6e7624ba66b908bba1ef88dc9f8165e147a12c69374c121a392e71acc", size = 10291588, upload-time = "2025-12-10T18:06:51.074Z" }, - { url = "https://files.pythonhosted.org/packages/de/09/22f00a9c639e61619980008f8f50cbc6966c55790f309160915e139f54a3/tket-0.12.13-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:c7ec567d8d13fff896f06540c6f5ba61ef0cd2da2850a21b444850dc9b677de5", size = 11275557, upload-time = "2025-12-10T18:06:53.498Z" }, - { url = "https://files.pythonhosted.org/packages/d0/ee/a25579314ec8058d0e96b3e26d7f6862383d0bcf1d150302bb2be543f416/tket-0.12.13-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f60f2a412da68e34328b101b135f807334cd830e97e9b3992b62e708b14c8b1", size = 13376604, upload-time = "2025-12-10T18:06:55.756Z" }, - { url = "https://files.pythonhosted.org/packages/4c/57/bef91d6aba87ad78778a211255bfd64d9a942ad991b17c64f4bc577fd07c/tket-0.12.13-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:cfff346a679334a8f21035abe8867ef36335ffe28e0edbab965a667310294fd5", size = 14891058, upload-time = "2025-12-10T18:06:58.363Z" }, - { url = "https://files.pythonhosted.org/packages/e3/ae/da9b17cedf6f8080a0edec5654b687ae967aca4d6bd0d5f1806b625fbea7/tket-0.12.13-cp310-abi3-win_amd64.whl", hash = "sha256:fb9b5592f58462aaf8769fda6355350a935c0061a344d2bae0a5ed4a16ea907e", size = 10183807, upload-time = "2025-12-10T18:07:00.726Z" }, + { url = "https://files.pythonhosted.org/packages/f9/6f/1ca616db59681f08db1577eb7592ea5b2f3bcdeab423bea61cff30676a3c/tket-0.12.15-cp310-abi3-macosx_13_0_arm64.whl", hash = "sha256:a802b785a09c58a468cb0d7c605837eed328ac441e1ef9fade0913b3082a1a49", size = 10082145, upload-time = "2026-01-16T16:40:29.858Z" }, + { url = "https://files.pythonhosted.org/packages/8c/37/ba7e9d2b15c7d89ce33930640ac9f7fb86f2c1ffd8d63ba62e5819463c22/tket-0.12.15-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:8cd363d59e5d7bad90baf458bfc231db4e0a36329275e4c5cafb8052f3490c23", size = 11022474, upload-time = "2026-01-16T16:40:33.042Z" }, + { url = "https://files.pythonhosted.org/packages/52/1a/ce41490837bb9e4177104ca6e4f7afa837fbb1a32468bc8b57a2b46361eb/tket-0.12.15-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7fda0d91a4281a3a9825676f12c8b8ae466fa6e39fdf9a79b04b3305df34ccad", size = 13088827, upload-time = "2026-01-16T16:21:11.12Z" }, + { url = "https://files.pythonhosted.org/packages/69/39/43dddc3e0143d56af058af612c863b5c3bb09823ed35d915f64a33d4597f/tket-0.12.15-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:730cd5f8103db95ba3387caa2588f1925dfb4473cf3cc383d16b4bea69c6b47f", size = 14678972, upload-time = "2026-01-16T16:40:36.576Z" }, + { url = "https://files.pythonhosted.org/packages/2e/12/7f9fdff4543bc28228b20bb359f2518ec0067b4ab08ddff1fe6b10e5b9eb/tket-0.12.15-cp310-abi3-win_amd64.whl", hash = "sha256:eac19fade07aa07a2967b3775ac65506e197ae1b8723daba8666b1cc27f6abef", size = 9866960, upload-time = "2026-01-16T16:40:39.837Z" }, ] [[package]] @@ -4640,19 +4706,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.5" +version = "6.5.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f8/f1/3173dfa4a18db4a9b03e5d55325559dab51ee653763bb8745a75af491286/tornado-6.5.5.tar.gz", hash = "sha256:192b8f3ea91bd7f1f50c06955416ed76c6b72f96779b962f07f911b91e8d30e9", size = 516006, upload-time = "2026-03-10T21:31:02.067Z" } +sdist = { url = "https://files.pythonhosted.org/packages/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/8c/77f5097695f4dd8255ecbd08b2a1ed8ba8b953d337804dd7080f199e12bf/tornado-6.5.5-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:487dc9cc380e29f58c7ab88f9e27cdeef04b2140862e5076a66fb6bb68bb1bfa", size = 445983, upload-time = "2026-03-10T21:30:44.28Z" }, - { url = "https://files.pythonhosted.org/packages/ab/5e/7625b76cd10f98f1516c36ce0346de62061156352353ef2da44e5c21523c/tornado-6.5.5-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:65a7f1d46d4bb41df1ac99f5fcb685fb25c7e61613742d5108b010975a9a6521", size = 444246, upload-time = "2026-03-10T21:30:46.571Z" }, - { url = "https://files.pythonhosted.org/packages/b2/04/7b5705d5b3c0fab088f434f9c83edac1573830ca49ccf29fb83bf7178eec/tornado-6.5.5-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e74c92e8e65086b338fd56333fb9a68b9f6f2fe7ad532645a290a464bcf46be5", size = 447229, upload-time = "2026-03-10T21:30:48.273Z" }, - { url = "https://files.pythonhosted.org/packages/34/01/74e034a30ef59afb4097ef8659515e96a39d910b712a89af76f5e4e1f93c/tornado-6.5.5-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:435319e9e340276428bbdb4e7fa732c2d399386d1de5686cb331ec8eee754f07", size = 448192, upload-time = "2026-03-10T21:30:51.22Z" }, - { url = "https://files.pythonhosted.org/packages/be/00/fe9e02c5a96429fce1a1d15a517f5d8444f9c412e0bb9eadfbe3b0fc55bf/tornado-6.5.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3f54aa540bdbfee7b9eb268ead60e7d199de5021facd276819c193c0fb28ea4e", size = 448039, upload-time = "2026-03-10T21:30:53.52Z" }, - { url = "https://files.pythonhosted.org/packages/82/9e/656ee4cec0398b1d18d0f1eb6372c41c6b889722641d84948351ae19556d/tornado-6.5.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:36abed1754faeb80fbd6e64db2758091e1320f6bba74a4cf8c09cd18ccce8aca", size = 447445, upload-time = "2026-03-10T21:30:55.541Z" }, - { url = "https://files.pythonhosted.org/packages/5a/76/4921c00511f88af86a33de770d64141170f1cfd9c00311aea689949e274e/tornado-6.5.5-cp39-abi3-win32.whl", hash = "sha256:dd3eafaaeec1c7f2f8fdcd5f964e8907ad788fe8a5a32c4426fbbdda621223b7", size = 448582, upload-time = "2026-03-10T21:30:57.142Z" }, - { url = "https://files.pythonhosted.org/packages/2c/23/f6c6112a04d28eed765e374435fb1a9198f73e1ec4b4024184f21faeb1ad/tornado-6.5.5-cp39-abi3-win_amd64.whl", hash = "sha256:6443a794ba961a9f619b1ae926a2e900ac20c34483eea67be4ed8f1e58d3ef7b", size = 448990, upload-time = "2026-03-10T21:30:58.857Z" }, - { url = "https://files.pythonhosted.org/packages/b7/c8/876602cbc96469911f0939f703453c1157b0c826ecb05bdd32e023397d4e/tornado-6.5.5-cp39-abi3-win_arm64.whl", hash = "sha256:2c9a876e094109333f888539ddb2de4361743e5d21eece20688e3e351e4990a6", size = 448016, upload-time = "2026-03-10T21:31:00.43Z" }, + { url = "https://files.pythonhosted.org/packages/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" }, + { url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" }, + { url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" }, + { url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" }, + { url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" }, ] [[package]] @@ -4678,26 +4744,26 @@ wheels = [ [[package]] name = "types-requests" -version = "2.33.0.20260508" +version = "2.33.0.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/6b/eb226bdd61a982c9a03e02c657fb4ab001733506e6423906ac142331f2e3/types_requests-2.33.0.20260508.tar.gz", hash = "sha256:81b2ae5f0d20967714a6aa5ef9284c05570d7cb06b7de8f2a77b918b63ddd411", size = 23991, upload-time = "2026-05-08T04:50:56.818Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/01/c5a19253fe1ac159159ddf9a3a07cec8bb5e486ec4d9002ad2821da0e5d2/types_requests-2.33.0.20260518.tar.gz", hash = "sha256:df7bd3bfe0ca8402dfb841e7d9be714bb5578203283d66d7dc4ef69343449a5e", size = 24752, upload-time = "2026-05-18T06:07:37.966Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/96/080db0afdf2c5cc5fe512b41354e8d114fe8f65e9510c56ff8dfd40216ce/types_requests-2.33.0.20260508-py3-none-any.whl", hash = "sha256:fa01459cca184229713df03709db46a905325906d27e042cd4fd7ea3d15d3400", size = 20722, upload-time = "2026-05-08T04:50:55.548Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/b139710a3b6018f7fb2b9508b35c8af564e61bf2bf4fa619d088f3e16f85/types_requests-2.33.0.20260518-py3-none-any.whl", hash = "sha256:626d697d1adaaff76e2044dc8c5c051d8f21abc157bdfe204a75558076fe0bf0", size = 21391, upload-time = "2026-05-18T06:07:37.044Z" }, ] [[package]] name = "types-tqdm" -version = "4.67.3.20260508" +version = "4.67.3.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/d9/add71c78db72e934747f7467ffe7b8fa9f3e9fb38ffa5377d5dd390ac036/types_tqdm-4.67.3.20260508.tar.gz", hash = "sha256:9acfdd179bdf5cc81f7ce7353b5b85eb92b16667bba89ec6c187b5e7ce617986", size = 18141, upload-time = "2026-05-08T04:52:34.866Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/8c/8216f9030027dc96c776b0307cb06da9f3c3756ef00e98f1eb332e04a33c/types_tqdm-4.67.3.20260518.tar.gz", hash = "sha256:1f0cf61de56a76a454fbe2c6cc48d05e483470a38f8a9071893b923e7be47190", size = 18208, upload-time = "2026-05-18T06:08:04.417Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/64/e66c98e951deb5985fbff40a11ba0a1f0528505e0734fa6c39534fc0b113/types_tqdm-4.67.3.20260508-py3-none-any.whl", hash = "sha256:0440759cc861a90c1cc98870f2c15ac633c0b6b14651dcafb83f98ab83bad0f4", size = 24546, upload-time = "2026-05-08T04:52:33.995Z" }, + { url = "https://files.pythonhosted.org/packages/56/59/dbe84f71186645b821cf63f71fc746e1455fbce074594a86f486bee8dc89/types_tqdm-4.67.3.20260518-py3-none-any.whl", hash = "sha256:4969f5b97257c6d082c163d7a0715fffe7a83cb6863e108eac45c3fb82305a68", size = 24586, upload-time = "2026-05-18T06:08:03.568Z" }, ] [[package]] @@ -4750,7 +4816,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.1" +version = "21.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -4759,9 +4825,28 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/0d/915c02c94d207b85580eb09bffab54438a709e7288524094fe781da526c2/virtualenv-21.3.1.tar.gz", hash = "sha256:c2305bc1fddeec40699b8370d13f8d431b0701f00ce895061ce493aeded4426b", size = 7613791, upload-time = "2026-05-05T01:34:31.402Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" }, +] + +[[package]] +name = "wasmtime" +version = "42.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/cd/1f110419ed006f91624010f4df4da82490220bd5527650284c97fc758a6c/wasmtime-42.0.0.tar.gz", hash = "sha256:90485655d6e541b817a7baa1b3071b4525d03f76bcb6ad04661774f06a3b02d4", size = 117133, upload-time = "2026-02-24T19:12:53.321Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/4f/f71e641e504111a5a74e3a20bc52d01bd86788b22699dd3fee1c63253cf6/virtualenv-21.3.1-py3-none-any.whl", hash = "sha256:d1a71cf58f2f9228fff23a1f6ec15d39785c6b32e03658d104974247145edd35", size = 7594539, upload-time = "2026-05-05T01:34:28.98Z" }, + { url = "https://files.pythonhosted.org/packages/20/cb/f206f7a839d6843b01c041000056bf7aad23cf72fe2333a0c5dad144e0f2/wasmtime-42.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:214e7d294ce1b5adb94f09a870a2ab6759173dc0194bdde74ee4492b477d8392", size = 6829706, upload-time = "2026-02-24T19:12:36.637Z" }, + { url = "https://files.pythonhosted.org/packages/2d/97/d4f5f46eef74e013c3a0caa9b8625bb1c4162e2b9817258596ee6932c019/wasmtime-42.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:cdd9710fad242dde7cb0eacbe48bf902bb1bac6ecbecd3e743c31af463a795c6", size = 7699640, upload-time = "2026-02-24T19:12:38.471Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d2/5b2bf901b0a9b8050d966dff61e353de7cd86dd58679a79e48372ff8b3a6/wasmtime-42.0.0-py3-none-any.whl", hash = "sha256:7a166bd262608806f3295343fcd07ee3e037f931f6d3b0a24ab1cfc7ccc3e8eb", size = 6403639, upload-time = "2026-02-24T19:12:39.777Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6f/a40322bdd55809441bab7e1ac707aa38ced3572904a700f1dfb4b2520dcd/wasmtime-42.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:21e3dafd74704de0e7ed7668ab76cc5a9df130b4306befbfcb08ddb29673c784", size = 7483525, upload-time = "2026-02-24T19:12:41.422Z" }, + { url = "https://files.pythonhosted.org/packages/47/04/ef61af9fe9e5c0a8d782c8662302535ee6e6dba1a6929191fa3ea371a491/wasmtime-42.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:411bf05df47c8a36c6b31b6012720ac1251b95fdd155e389b25eb6fbbd7e181c", size = 6493225, upload-time = "2026-02-24T19:12:42.9Z" }, + { url = "https://files.pythonhosted.org/packages/44/54/a774313c19c1c0ae2c1897af697c12178904d67911f42c4a9bdddba68640/wasmtime-42.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ca12269ee88aac6b1f64b5f324abf3c6370ff853338d991292f10cb17b906667", size = 7740997, upload-time = "2026-02-24T19:12:44.453Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5d/fae28526b1d42f0365e4fd6c2a212c7c000e47d7320632018fa45735a06e/wasmtime-42.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:78f9353b9fdc2f6e7ed13e28ce0394533f5a62710b75c00434ac82681f738924", size = 6785820, upload-time = "2026-02-24T19:12:45.777Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ae/5c5e96273a36c70753e8ba4db323dd9b1ccf6fcea4ccad99d458ad2ecf13/wasmtime-42.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ba317e879aab71c407e7012f4dc10b221c6daf737496c501005612e11d26e8ee", size = 6810021, upload-time = "2026-02-24T19:12:47.453Z" }, + { url = "https://files.pythonhosted.org/packages/46/68/5c129389f67219a90c3ba0dcf85555249bde9797760f2d715bec03bc198a/wasmtime-42.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e9ef6dbd1a2cff21694ba64f27b90a7ab0af61a54d911a59682005830683dc8a", size = 7779984, upload-time = "2026-02-24T19:12:48.642Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e5/6650c9e7ad904c9a6730c4b762b1dfed4f7d7b0e981e3624a6ecd7abb7ed/wasmtime-42.0.0-py3-none-win_amd64.whl", hash = "sha256:3a360a1285457021efe24369490cd719996596f2cbe1aa62dae6ad68179cf0f9", size = 6403647, upload-time = "2026-02-24T19:12:50.373Z" }, + { url = "https://files.pythonhosted.org/packages/44/b2/e93046661deef4d8fee2f40080a28e5ff201cc98d4fb1929a46367c34778/wasmtime-42.0.0-py3-none-win_arm64.whl", hash = "sha256:8caa13a6ee264969449c008da1dcb8f9f6c954800853527714e7fcddbdda9166", size = 5397896, upload-time = "2026-02-24T19:12:51.639Z" }, ] [[package]] From ec489e33d6b7fc50aa3c8496cacdc3b0e3a37a56 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 1 Jun 2026 11:17:54 -0600 Subject: [PATCH 036/388] Refresh generated lockfiles --- Cargo.lock | 12 ++++++------ uv.lock | 36 ++++++++++++++++++------------------ 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2286043f7..99311349c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -619,9 +619,9 @@ dependencies = [ [[package]] name = "capnp" -version = "0.25.4" +version = "0.25.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63da65e5e9ffc3b8f993d4ad222a548152549351a643f6b850a7773cb6ff2809" +checksum = "1cfd2485d4b36ac9c5aa6572d7d35daa63a5b34f517627d6c34d068e616e4a73" dependencies = [ "embedded-io 0.7.1", ] @@ -5614,9 +5614,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -6838,9 +6838,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" diff --git a/uv.lock b/uv.lock index 99d65a64a..82c34b352 100644 --- a/uv.lock +++ b/uv.lock @@ -845,30 +845,30 @@ wheels = [ [[package]] name = "cupy-cuda13x" -version = "14.1.0" +version = "14.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-pathfinder", marker = "python_full_version >= '3.11'" }, { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/18/e9/19185f325905eada94ed96f649a2b92336dcb6caa62a5adac514bb2f0de5/cupy_cuda13x-14.1.0-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:80f169592f5bbf7cb411ce4eeda315d06addb1a3cb14ed668e9d2ba287e3e4d4", size = 72670820, upload-time = "2026-05-23T01:11:17.547Z" }, - { url = "https://files.pythonhosted.org/packages/25/0a/0796a2465c86b7656e7b4e0aaa2b5e17f1ae2d088db1a43a1ffd83e9a468/cupy_cuda13x-14.1.0-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:de745ef499998d44e96dfa9be1f442047299bbc5d9c7a0738e378375a18ed7c5", size = 68695023, upload-time = "2026-05-23T01:11:21.78Z" }, - { url = "https://files.pythonhosted.org/packages/1c/55/ac9107d7b0d58fcf671b0b32a7905709b7c767abd6d91256648498fb2831/cupy_cuda13x-14.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:49f026a1526f00adbf7b56ee28e986725e5ce1e1cd455f2a1a56ff74e5049e28", size = 35289284, upload-time = "2026-05-23T01:11:24.926Z" }, - { url = "https://files.pythonhosted.org/packages/20/a0/b7dc0aff6097b2fb19535715fe1b98a113bdddf3eea56ac1acd878c77f43/cupy_cuda13x-14.1.0-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:dfc24cceaa8234272159419f2b0763c78968909e1c31120ba83c22fdca91872f", size = 73971015, upload-time = "2026-05-23T01:11:28.359Z" }, - { url = "https://files.pythonhosted.org/packages/4a/31/e6a8e5786d5038417963d50041c6dbb1b0376091f6c170e036a8997120c2/cupy_cuda13x-14.1.0-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:079d99f6c492f24692f7904c013fc164c2b3ce605b2036293d0f2f6ae31dc3a8", size = 70032754, upload-time = "2026-05-23T01:11:32.001Z" }, - { url = "https://files.pythonhosted.org/packages/64/6b/3d4a6d766a055e1dfb36d66655c818ae5b999ba12d6723966ee6c4eb4ba4/cupy_cuda13x-14.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:1ee0839c5bfa801cd6f5181fced40d38ca90538f4723e17614fccd47292c4450", size = 35290568, upload-time = "2026-05-23T01:11:36.073Z" }, - { url = "https://files.pythonhosted.org/packages/23/7e/21afe0ab60bd88acbfa0daa6e1d7beff31fc9ec72702173520f58236daec/cupy_cuda13x-14.1.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:9b83ce4965466bf2bf99aaa6ce594c0547ca696f451676cd1d46124443476fb0", size = 73277053, upload-time = "2026-05-23T01:11:40.406Z" }, - { url = "https://files.pythonhosted.org/packages/2c/2e/d3780ce0a8d951e632349bb989d8536e77e03e8ff1c419922ed933f3ba55/cupy_cuda13x-14.1.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:3d3a64a850f5560239b942a833a6498e2d9a5fff4efbb6e93cddfa8c4f53b9cf", size = 69532518, upload-time = "2026-05-23T01:11:44.38Z" }, - { url = "https://files.pythonhosted.org/packages/99/2d/5faee3a1626deac5feef290fd52460ad57460009e9738ec4f0e62d40af5b/cupy_cuda13x-14.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:9e42ff785aec56e76ffc05fc0d92f7eab2a970dc7c377ad61288a98aa10e7db8", size = 35192581, upload-time = "2026-05-23T01:11:47.759Z" }, - { url = "https://files.pythonhosted.org/packages/04/8a/31c58ffa8e1780c8f15492018997d5fb3548427f5fa0e6327becf26f00c6/cupy_cuda13x-14.1.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:42f85f692a589b92a86627113e1072c534cc9d9047433b4291b8bd7b49fb238a", size = 72812202, upload-time = "2026-05-23T01:11:51.015Z" }, - { url = "https://files.pythonhosted.org/packages/98/41/be34e911811a0369e3e66b1b618dfccdea1f84ad6a2cc17f146ce9095e99/cupy_cuda13x-14.1.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:eef76f0647af5a9bfe3d0bac641f201deb5f15a644f4b13a2e68c0c62e6808fe", size = 69094718, upload-time = "2026-05-23T01:11:57.103Z" }, - { url = "https://files.pythonhosted.org/packages/0a/c1/c92b4c9c2c561c1298dfad2e3dbb75e527ac1a15d567d8fb868fc277c80e/cupy_cuda13x-14.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:18338008d428bf04dcd73ab2489c6f90c39b7ff439ab2b609115b9bb0e16e0ec", size = 35171775, upload-time = "2026-05-23T01:12:00.57Z" }, - { url = "https://files.pythonhosted.org/packages/24/b4/8963aced61d836417ca681d7cd1b6b27916ed877a01f3adf7c902bd8f392/cupy_cuda13x-14.1.0-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:fa77dc442a166e784e28e5c7dd4e1bb4b2ac54a2ec55c59c1af3c0ed27e14f69", size = 72680065, upload-time = "2026-05-23T01:12:03.979Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a8/2ae8d8235a236564e65a73812ee0bf4c1afe96b7104f754f100832f015e9/cupy_cuda13x-14.1.0-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:7824fc7757bac2d91d6c810c4f5376359c1eeaf5d37c2625c4a670705f3d9f36", size = 68432300, upload-time = "2026-05-23T01:12:07.777Z" }, - { url = "https://files.pythonhosted.org/packages/ee/12/8e1d2b9efda12e8ad5c648f1fc289454b7c6210c2f4d9e99d7663178d8e1/cupy_cuda13x-14.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f3c9f5e61fefc7920bcd3da9818679ea8f9319cca080dd88118f463a2ca08e41", size = 35323531, upload-time = "2026-05-23T01:12:11.024Z" }, - { url = "https://files.pythonhosted.org/packages/f6/bc/6cceec523fd467fc6259c4c394d87a9e7e3b8f011685da028d9862bf8497/cupy_cuda13x-14.1.0-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:e5b2a0dcaa5fb27faf034f5e3d271eaa13c4a5351e2c6dec925a866c210f3605", size = 72984031, upload-time = "2026-05-23T01:12:14.388Z" }, - { url = "https://files.pythonhosted.org/packages/d3/43/180777ccc0f85ce4469f3e6e615c471968f482fed85827410090adf40523/cupy_cuda13x-14.1.0-cp314-cp314t-manylinux2014_x86_64.whl", hash = "sha256:69b314f0085d7ad46a671338e62b3f35270c144c31df8c443c0341b97a905980", size = 68662879, upload-time = "2026-05-23T01:12:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ce/71352ce895400844df26553e3d723dd64754d19b657056fc52553ad6b725/cupy_cuda13x-14.1.1-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:ae3a75a7e9510fb03251420d85b4efa48187ee5fd48b08c88b7a0f0620bd779a", size = 72670263, upload-time = "2026-06-01T04:53:40.802Z" }, + { url = "https://files.pythonhosted.org/packages/5f/24/6143d51d9fbcd1aad63759be7840f6e0e4890bf7bfa1173a39689031cf33/cupy_cuda13x-14.1.1-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:223a9e1e6db911002ca5b94c348a3ed5c1d7869ba4dd6524ab3d0263123b2248", size = 68695025, upload-time = "2026-06-01T04:53:46.1Z" }, + { url = "https://files.pythonhosted.org/packages/c1/02/1b3dadb01306b3824c72b6e7da1d7cce5e2b3b7fcf901ce00364ff2a3059/cupy_cuda13x-14.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:2a656fbcdc1ca1c09b039f9ec6540e8d18e77ed66e92ae275a82b4d849bcc185", size = 35291771, upload-time = "2026-06-01T04:53:49.984Z" }, + { url = "https://files.pythonhosted.org/packages/35/a0/33ba64feccedf661960057a9967e2e623853f4757ba5be2f04f6503a4ac0/cupy_cuda13x-14.1.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:3d12e1020066a699f5e27f4c5597167dca042704c0ed1db74066c32c0c2dbfb1", size = 73970864, upload-time = "2026-06-01T04:53:53.903Z" }, + { url = "https://files.pythonhosted.org/packages/c1/99/d1add95f15f3ad0dec954688c012e97f991363c4a74ca970fc0ddb217534/cupy_cuda13x-14.1.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:424caa906c16f31328558151dd5e0596891eda7644f40ca9348c3eb14af084d1", size = 70032752, upload-time = "2026-06-01T04:54:02.944Z" }, + { url = "https://files.pythonhosted.org/packages/30/48/7a9fbc1184bb6288e173903984efbe2dbf9a29308b810d26ecac963d3690/cupy_cuda13x-14.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:0b9e6f6110cbf039dd75159f8adc99c4fb84cbcabf3025c6607e3864b09ba469", size = 35289510, upload-time = "2026-06-01T04:54:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/8e/df/33e14f4648910db90d9aa3ac03c7012eec2e5d05a7ab69ec74a7864d6354/cupy_cuda13x-14.1.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:9226f3279c64aa6f07a79ba0081f3275a99a36c2db3f836f4b687dd8d77fa8bc", size = 73276230, upload-time = "2026-06-01T04:54:10.208Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a7/7105dad3285c35451aae7efcc0a09e5b114de25065e3971552d906354e3e/cupy_cuda13x-14.1.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:a57c3eda202ffd70e34b9031a88f17c810a75e816398b4028360027cb3a4cd4d", size = 69532519, upload-time = "2026-06-01T04:54:14.589Z" }, + { url = "https://files.pythonhosted.org/packages/f3/4b/6f20833eedbfd1d44925780a2fdffaf72b3c78145a228359c7f2d534e0a8/cupy_cuda13x-14.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:c0ce59e54da561eeaebef0460242cfd159518d63b388d9d566d198d1d7614634", size = 35192036, upload-time = "2026-06-01T04:54:17.885Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0f/149a9e6c561f44e6522be6c3fdacdaed17d8fd5a1651004a0782ce045502/cupy_cuda13x-14.1.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:cc671cb766f1c42cb6eed3bf27e9da6092721bcb3d77e09aef28e2f5b089ea38", size = 72811428, upload-time = "2026-06-01T04:54:22.432Z" }, + { url = "https://files.pythonhosted.org/packages/80/f6/f4cd043ad8c8e18410e9cea73a8de8778a0a05c705b8505729a794758bf1/cupy_cuda13x-14.1.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:ff4526725c1398ea19d578044737aa24859b08e1f469aba0ca45dfd6f9aa505d", size = 69094726, upload-time = "2026-06-01T04:54:26.424Z" }, + { url = "https://files.pythonhosted.org/packages/54/64/f2b60a0f4d73820d774906bf8684df0c8494820d78b3494121c7ec57ee25/cupy_cuda13x-14.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:54949b4e4f90e3588eeef25b55037f156578364e4835eb9ae1b93e236f2e58d5", size = 35171412, upload-time = "2026-06-01T04:54:31.313Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/9bc8d6d40003640653161e2dba78740f2055df8bb48e216eed5584cecc3c/cupy_cuda13x-14.1.1-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:8b068f6958811bc50d81bd0a83267ec4cffc3d7e842120b9646ec547f5a69298", size = 72680680, upload-time = "2026-06-01T04:54:35.077Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2d/8c79c577e9b35c60e5600217d1a849ab76c18a289f99a4145aa85388a531/cupy_cuda13x-14.1.1-cp314-cp314-manylinux2014_x86_64.whl", hash = "sha256:39f8aeeb5bad16603956f8ec00094e058a9dd5db92ab514975686750363c3692", size = 68432306, upload-time = "2026-06-01T04:54:38.882Z" }, + { url = "https://files.pythonhosted.org/packages/c4/03/8d4d77138eb5f60a2a1fb5484a89dad2bc6bc6283682179994d722cfb4b5/cupy_cuda13x-14.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:dae6f77edfe96ab8e014d3e589dca33906d212fdf8f72e66850d4612bc031783", size = 35323057, upload-time = "2026-06-01T04:54:42.541Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c4/eb46fec30fffd7a57a80cc7f027e50a1d530b332e81362a8b874905da2d1/cupy_cuda13x-14.1.1-cp314-cp314t-manylinux2014_aarch64.whl", hash = "sha256:509f378c865c0b6bc9ed1dfe9e94b08b4511e9865363fdac55f9d66fe341e91d", size = 72984246, upload-time = "2026-06-01T04:54:46.464Z" }, + { url = "https://files.pythonhosted.org/packages/24/59/e0098ca2e1ba99a0086ad4b515b2d7dbb8ae0f13b9721c02eed0db4adec5/cupy_cuda13x-14.1.1-cp314-cp314t-manylinux2014_x86_64.whl", hash = "sha256:6c0d19789e9f9515988d51dffaff70af8b3dcdddcdf2fbafa9eff08df7be0a51", size = 68662887, upload-time = "2026-06-01T04:54:50.932Z" }, ] [[package]] From 96b1ffe22676b49bf113ad4eb9e6a59a3b881266 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 2 Jun 2026 07:35:27 -0600 Subject: [PATCH 037/388] Fix lint check issues --- .../tests/guppy/test_hugr_compiler_parity.py | 4 +- scripts/native_bench/bench_pecos/Cargo.lock | 549 ++++++++---------- 2 files changed, 255 insertions(+), 298 deletions(-) diff --git a/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py b/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py index 3c79feda8..0cdddf93f 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py @@ -108,9 +108,7 @@ def compare_compilers( # LLVM 21 can peel runtime loops, duplicating static call sites while # preserving the dynamic behavior. In that case exact call-site # multiplicity is not a robust parity signal. - if selene_set == rust_set and ( - "llvm.loop.peeled.count" in selene_ir or "llvm.loop.peeled.count" in rust_ir - ): + if selene_set == rust_set and ("llvm.loop.peeled.count" in selene_ir or "llvm.loop.peeled.count" in rust_ir): return True, "QIS call set matches; static call counts differ only after LLVM loop peeling" only_selene = selene_counts - rust_counts diff --git a/scripts/native_bench/bench_pecos/Cargo.lock b/scripts/native_bench/bench_pecos/Cargo.lock index 7a6893b8c..fd27d0964 100644 --- a/scripts/native_bench/bench_pecos/Cargo.lock +++ b/scripts/native_bench/bench_pecos/Cargo.lock @@ -120,15 +120,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.16.2" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a054912289d18629dc78375ba2c3726a3afe3ff71b4edba9dedfca0e3446d1fc" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" dependencies = [ "aws-lc-sys", "zeroize", @@ -136,9 +136,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" dependencies = [ "cc", "cmake", @@ -178,7 +178,7 @@ dependencies = [ "quote", "regex", "rustc-hash 2.1.2", - "shlex", + "shlex 1.3.0", "syn", ] @@ -214,9 +214,9 @@ checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" [[package]] name = "bitvec" @@ -260,9 +260,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" @@ -316,9 +316,9 @@ dependencies = [ [[package]] name = "cargo-platform" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87a0c0e6148f11f01f32650a2ea02d532b2ad4e81d8bd41e6e565b5adc5e6082" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" dependencies = [ "serde", "serde_core", @@ -335,27 +335,21 @@ dependencies = [ "semver", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror", ] [[package]] name = "cc" -version = "1.2.60" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cexpr" version = "0.6.0" @@ -385,7 +379,7 @@ checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -495,9 +489,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" @@ -551,9 +545,9 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] @@ -579,13 +573,13 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.0", "const-oid", - "crypto-common 0.2.1", + "crypto-common 0.2.2", ] [[package]] @@ -621,9 +615,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -656,9 +650,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "env_filter" @@ -707,13 +701,12 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -878,7 +871,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core 0.10.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -955,7 +948,7 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.18", + "thiserror", "windows", ] @@ -1016,9 +1009,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "heck" @@ -1034,9 +1027,9 @@ checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1073,18 +1066,18 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.10" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1102,15 +1095,14 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.7" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ "http", "hyper", "hyper-util", "rustls", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", @@ -1240,9 +1232,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -1255,7 +1247,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "rayon", "serde", "serde_core", @@ -1267,16 +1259,6 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1309,9 +1291,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "log", @@ -1322,9 +1304,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", @@ -1333,18 +1315,32 @@ dependencies = [ [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys 0.4.1", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -1387,9 +1383,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" dependencies = [ "cfg-if", "futures-util", @@ -1422,15 +1418,15 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libbz2-rs-sys" -version = "0.2.2" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" -version = "0.2.184" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48f5d2a454e16a5ea0f4ced81bd44e4cfc7bd3a507b61887c99fd3538b28e4af" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -1460,14 +1456,11 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags", "libc", - "plain", - "redox_syscall 0.7.4", ] [[package]] @@ -1499,9 +1492,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" [[package]] name = "lru-slab" @@ -1541,9 +1534,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "minimal-lexical" @@ -1563,9 +1556,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -1574,9 +1567,9 @@ dependencies = [ [[package]] name = "naga" -version = "29.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2630921705b9b01dcdd0b6864b9562ca3c1951eecd0f0c4f5f04f61e412647" +checksum = "0dd91265cc2454558f659b3b4b9640f0ddb8cc6521277f166b8a8c181c898079" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -1594,7 +1587,7 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror 2.0.18", + "thiserror", "unicode-ident", ] @@ -1710,9 +1703,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -1857,7 +1850,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -1876,11 +1869,12 @@ dependencies = [ "sevenz-rust", "sha2 0.11.0", "tar", - "thiserror 2.0.18", + "thiserror", "toml", "toml_edit", "xz2", "zip", + "zstd", ] [[package]] @@ -1892,9 +1886,9 @@ dependencies = [ "num-traits", "pecos-random", "rand 0.10.1", - "rand_core 0.10.0", + "rand_core 0.10.1", "smallvec", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1908,7 +1902,7 @@ dependencies = [ "pecos-core", "pecos-cuquantum-sys", "pecos-simulators", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1922,7 +1916,7 @@ dependencies = [ "libloading 0.9.0", "log", "pecos-build", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -1937,7 +1931,7 @@ dependencies = [ "pecos-simulators", "pollster", "rand 0.10.1", - "rand_core 0.10.0", + "rand_core 0.10.1", "serde_json", "wgpu", ] @@ -1977,7 +1971,7 @@ name = "pecos-random" version = "0.2.0-dev.0" dependencies = [ "rand 0.10.1", - "rand_core 0.10.0", + "rand_core 0.10.1", "rapidhash", "wide", ] @@ -2023,15 +2017,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "pollster" @@ -2047,9 +2035,9 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] @@ -2116,9 +2104,9 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] name = "quinn" @@ -2134,7 +2122,7 @@ dependencies = [ "rustc-hash 2.1.2", "rustls", "socket2", - "thiserror 2.0.18", + "thiserror", "tokio", "tracing", "web-time", @@ -2150,13 +2138,13 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.3", + "rand 0.9.4", "ring", "rustc-hash 2.1.2", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -2205,9 +2193,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -2221,7 +2209,7 @@ checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ "chacha20", "getrandom 0.4.2", - "rand_core 0.10.0", + "rand_core 0.10.1", ] [[package]] @@ -2245,9 +2233,9 @@ dependencies = [ [[package]] name = "rand_core" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" @@ -2256,7 +2244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.3", + "rand 0.9.4", ] [[package]] @@ -2310,9 +2298,9 @@ checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] name = "rayon" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ "either", "rayon-core", @@ -2348,15 +2336,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -2365,7 +2344,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror", ] [[package]] @@ -2405,9 +2384,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -2466,6 +2445,15 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -2481,9 +2469,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -2495,9 +2483,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -2507,9 +2495,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" dependencies = [ "web-time", "zeroize", @@ -2517,9 +2505,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -2574,7 +2562,7 @@ dependencies = [ "num-traits", "petgraph", "priority-queue", - "rand 0.9.3", + "rand 0.9.4", "rand_distr", "rand_pcg", "rayon", @@ -2679,9 +2667,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -2735,7 +2723,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -2744,6 +2732,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simba" version = "0.10.0" @@ -2762,6 +2756,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -2785,9 +2795,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -2859,9 +2869,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tar" -version = "0.4.45" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -2877,33 +2887,13 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.18", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2974,9 +2964,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.51.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f66bf9585cda4b724d3e78ab34b73fb2bbaba9011b9bfdf69dc836382ea13b8c" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -3065,20 +3055,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -3126,9 +3116,9 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicode-ident" @@ -3211,11 +3201,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.3+wasi-0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.57.1", ] [[package]] @@ -3224,14 +3214,14 @@ version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ - "wit-bindgen", + "wit-bindgen 0.51.0", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" dependencies = [ "cfg-if", "once_cell", @@ -3242,9 +3232,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" dependencies = [ "js-sys", "wasm-bindgen", @@ -3252,9 +3242,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3262,9 +3252,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" dependencies = [ "bumpalo", "proc-macro2", @@ -3275,9 +3265,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.122" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" dependencies = [ "unicode-ident", ] @@ -3330,9 +3320,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.99" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" dependencies = [ "js-sys", "wasm-bindgen", @@ -3350,18 +3340,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.6" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" dependencies = [ "rustls-pki-types", ] [[package]] name = "wgpu" -version = "29.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c239a9a747bbd379590985bac952c2e53cb19873f7072b3370c6a6a8e06837" +checksum = "bb3feacc458f7bee8bc1737149b42b6c731aa461039a4264a67bb6681646b250" dependencies = [ "arrayvec", "bitflags", @@ -3389,9 +3379,9 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e80ac6cf1895df6342f87d975162108f9d98772a0d74bc404ab7304ac29469e" +checksum = "02da3ad1b568337f25513b317870960ef87073ea0945502e44b864b67a8c77b7" dependencies = [ "arrayvec", "bit-set 0.9.1", @@ -3411,7 +3401,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 1.1.0", "smallvec", - "thiserror 2.0.18", + "thiserror", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-windows-linux-android", @@ -3422,36 +3412,36 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43acd053312501689cd92a01a9638d37f3e41a5fd9534875efa8917ee2d11ac0" +checksum = "62e51b5447e144b3dbba4feb01f80f4fa21696fa0cd99afb2c3df1affd6fdb28" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef043bf135cc68b6f667c55ff4e345ce2b5924d75bad36a47921b0287ca4b24a" +checksum = "3487cd6293a963bc5c0c0396f6a2192043c50003c07f4efdccbad3d90ec9d819" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.0" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "725d5c006a8c02967b6d93ef04f6537ec4593313e330cfe86d9d3f946eb90f28" +checksum = "1bfb01076d0aa08b0ba9bd741e178b5cc440f5abe99d9581323a4c8b5d1a1916" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a47aef47636562f3937285af4c44b4b5b404b46577471411cc5313a921da7e" +checksum = "31f8e1a9e7a8512f276f7c62e018c7fa8d60954303fed2e5750114332049193f" dependencies = [ "android_system_properties", "arrayvec", @@ -3490,7 +3480,7 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", - "thiserror 2.0.18", + "thiserror", "wasm-bindgen", "wayland-sys", "web-sys", @@ -3498,13 +3488,14 @@ dependencies = [ "wgpu-types", "windows", "windows-core", + "windows-result", ] [[package]] name = "wgpu-naga-bridge" -version = "29.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4684f4410da0cf95a4cb63bb5edaac022461dedb6adf0b64d0d9b5f6890d51" +checksum = "59c654c483f058800972c3645e95388a7eca31bf9fe1933bc20e036588a0be02" dependencies = [ "naga", "wgpu-types", @@ -3512,9 +3503,9 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.1" +version = "29.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec2675540fb1a5cfa5ef122d3d5f390e2c75711a0b946410f2d6ac3a0f77d1f6" +checksum = "a9bcc31518a0e9735aefebedb5f7a9ef3ed1c42549c9f4c882fa9060ceaac639" dependencies = [ "bitflags", "bytemuck", @@ -3526,9 +3517,9 @@ dependencies = [ [[package]] name = "wide" -version = "1.2.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "198f6abc41fab83526d10880fa5c17e2b4ee44e763949b4bb34e2fd1e8ca48e4" +checksum = "9a7714cd0430a663154667c74da5d09325c2387695bee18b3f7f72825aa3693a" dependencies = [ "bytemuck", "safe_arch", @@ -3644,15 +3635,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -3680,21 +3662,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -3737,12 +3704,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -3755,12 +3716,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -3773,12 +3728,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -3803,12 +3752,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -3821,12 +3764,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -3839,12 +3776,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -3857,12 +3788,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3877,9 +3802,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -3893,6 +3818,12 @@ dependencies = [ "wit-bindgen-rust-macro", ] +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + [[package]] name = "wit-bindgen-core" version = "0.51.0" @@ -4037,18 +3968,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", @@ -4057,9 +3988,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -4152,3 +4083,31 @@ dependencies = [ "log", "simd-adler32", ] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] From 172400055a86b9585dcfff9321cb9f146d516fc0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 16:12:21 -0600 Subject: [PATCH 038/388] Fix LLVM 21 CI setup --- .github/workflows/julia-release.yml | 35 ++++++++---- .github/workflows/julia-test.yml | 12 ++-- .github/workflows/python-release.yml | 72 ++++++++++++++---------- .github/workflows/python-test.yml | 11 ++-- .github/workflows/rust-test.yml | 15 ++--- .github/workflows/test-docs-examples.yml | 9 +-- Justfile | 24 +++++++- julia/PECOS.jl/src/Decoder.jl | 12 +++- julia/PECOS.jl/src/Simulator.jl | 8 ++- scripts/ci/install-llvm-21-release.sh | 60 ++++++++++++++++++++ 10 files changed, 187 insertions(+), 71 deletions(-) create mode 100644 scripts/ci/install-llvm-21-release.sh diff --git a/.github/workflows/julia-release.yml b/.github/workflows/julia-release.yml index 113a9548e..84a106a68 100644 --- a/.github/workflows/julia-release.yml +++ b/.github/workflows/julia-release.yml @@ -16,6 +16,8 @@ permissions: env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" on: push: @@ -123,35 +125,44 @@ jobs: - name: Set up Rust run: rustup show - - name: Install LLVM 14.0.6 using pecos-llvm (Unix) + - name: Install LLVM 21.1 (Unix) if: runner.os != 'Windows' run: | - echo "Installing LLVM using pecos..." - cargo run --locked -p pecos-cli --release -- install llvm + if [ "$RUNNER_OS" = "macOS" ]; then + echo "Installing LLVM 21 with Homebrew..." + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + LLVM_PREFIX="$(brew --prefix llvm@21)" + else + echo "Installing LLVM 21.1 using PECOS-managed packages..." + cargo run --locked -p pecos-cli --release -- llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-release.sh + LLVM_PREFIX="$HOME/.pecos/deps/llvm-21.1" + fi echo "Setting LLVM environment variables..." - export PECOS_LLVM=$(cargo run --locked -p pecos-cli --release -- llvm find 2>/dev/null) - export LLVM_SYS_140_PREFIX="$PECOS_LLVM" + cargo run --locked -p pecos-cli --release -- llvm configure "$LLVM_PREFIX" + export PECOS_LLVM="$LLVM_PREFIX" + export LLVM_SYS_211_PREFIX="$LLVM_PREFIX" echo "PECOS_LLVM=$PECOS_LLVM" >> $GITHUB_ENV - echo "LLVM_SYS_140_PREFIX=$LLVM_SYS_140_PREFIX" >> $GITHUB_ENV + echo "LLVM_SYS_211_PREFIX=$LLVM_SYS_211_PREFIX" >> $GITHUB_ENV echo "Verifying LLVM installation..." cargo run --locked -p pecos-cli --release -- llvm check - - name: Install LLVM 14.0.6 using pecos-llvm (Windows) + - name: Install LLVM 21.1 (Windows) if: runner.os == 'Windows' shell: pwsh run: | - Write-Host "Installing LLVM using pecos..." - cargo run --locked -p pecos-cli --release -- install llvm + Write-Host "Installing LLVM 21.1 with Chocolatey..." + choco install llvm --version=${{ env.LLVM_RELEASE_VERSION }} -y --no-progress Write-Host "Setting LLVM environment variables..." - $env:PECOS_LLVM = (cargo run --locked -p pecos-cli --release -- llvm find 2>$null) - $env:LLVM_SYS_140_PREFIX = $env:PECOS_LLVM + $env:PECOS_LLVM = "C:\Program Files\LLVM" + $env:LLVM_SYS_211_PREFIX = $env:PECOS_LLVM + cargo run --locked -p pecos-cli --release -- llvm configure "$env:PECOS_LLVM" "PECOS_LLVM=$env:PECOS_LLVM" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - "LLVM_SYS_140_PREFIX=$env:LLVM_SYS_140_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "LLVM_SYS_211_PREFIX=$env:LLVM_SYS_211_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "Verifying LLVM installation..." cargo run --locked -p pecos-cli --release -- llvm check diff --git a/.github/workflows/julia-test.yml b/.github/workflows/julia-test.yml index 8c2c26a3c..e75e253c3 100644 --- a/.github/workflows/julia-test.yml +++ b/.github/workflows/julia-test.yml @@ -65,7 +65,7 @@ jobs: # NOTE: LLVM is not currently needed for Julia FFI since we use pecos with default-features = false # Keeping this commented out in case we need to re-enable LLVM features in the future. # - # - name: Install LLVM 14.0.6 using pecos (Unix) + # - name: Install LLVM 21.1 using pecos (Unix) # if: runner.os != 'Windows' # run: | # echo "Installing LLVM using pecos..." @@ -73,15 +73,15 @@ jobs: # # echo "Setting LLVM environment variables..." # export PECOS_LLVM=$(cargo run -p pecos-cli --release -- llvm find 2>/dev/null) - # export LLVM_SYS_140_PREFIX="$PECOS_LLVM" + # export LLVM_SYS_211_PREFIX="$PECOS_LLVM" # # echo "PECOS_LLVM=$PECOS_LLVM" >> $GITHUB_ENV - # echo "LLVM_SYS_140_PREFIX=$LLVM_SYS_140_PREFIX" >> $GITHUB_ENV + # echo "LLVM_SYS_211_PREFIX=$LLVM_SYS_211_PREFIX" >> $GITHUB_ENV # # echo "Verifying LLVM installation..." # cargo run -p pecos-cli --release -- llvm check # - # - name: Install LLVM 14.0.6 using pecos (Windows) + # - name: Install LLVM 21.1 using pecos (Windows) # if: runner.os == 'Windows' # shell: pwsh # run: | @@ -90,10 +90,10 @@ jobs: # # Write-Host "Setting LLVM environment variables..." # $env:PECOS_LLVM = (cargo run -p pecos-cli --release -- llvm find 2>$null) - # $env:LLVM_SYS_140_PREFIX = $env:PECOS_LLVM + # $env:LLVM_SYS_211_PREFIX = $env:PECOS_LLVM # # "PECOS_LLVM=$env:PECOS_LLVM" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - # "LLVM_SYS_140_PREFIX=$env:LLVM_SYS_140_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + # "LLVM_SYS_211_PREFIX=$env:LLVM_SYS_211_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append # # Write-Host "Verifying LLVM installation..." # cargo run -p pecos-cli --release -- llvm check diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 34ce638ec..f65242a9a 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -5,6 +5,8 @@ permissions: env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" # 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. @@ -98,10 +100,8 @@ jobs: gcc_ld_path: "/opt/rh/gcc-toolset-13/root/usr/lib64:/opt/rh/gcc-toolset-13/root/usr/lib:" gcc_cc: "/opt/rh/gcc-toolset-13/root/usr/bin/gcc" gcc_cxx: "/opt/rh/gcc-toolset-13/root/usr/bin/g++" - # Linux aarch64 - DISABLED: LLVM 14 not available in manylinux_2_28 (AlmaLinux 8) - # The prebuilt LLVM 14 binary is incompatible with the container, and - # the llvm-toolset module only provides LLVM 13. Re-enable when we have - # a solution (custom Docker image, build from source, or inkwell LLVM 13 support) + # Linux aarch64 - temporarily disabled while the LLVM 21.1 + # manylinux_2_28 bootstrap is validated on the primary x86_64 lane. # - os: ubuntu-24.04-arm # architecture: aarch64 # runner: ubuntu-24.04-arm @@ -211,16 +211,16 @@ jobs: CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_28" # Linux configuration - GCC Toolset and CUDA paths are conditional via matrix variables CIBW_ENVIRONMENT_LINUX: > - PATH=${{ matrix.gcc_path_prefix }}$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH + PATH=${{ matrix.gcc_path_prefix }}$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH LD_LIBRARY_PATH=${{ matrix.gcc_ld_path }}$LD_LIBRARY_PATH - LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 + LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin/cmake CUDA_PATH=/usr/local/cuda-12.6 MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH - dnf install libffi-devel -y + dnf install -y libffi-devel xz # Install CUDA Toolkit for GPU support on x86_64 (compile-time only, no GPU needed) if [ "${{ matrix.install_cuda }}" = "true" ]; then echo "Installing GCC 13 (required for CUDA 12.6 compatibility)..." @@ -237,15 +237,16 @@ jobs: else echo "Skipping CUDA installation (GPU support not enabled for this build)" fi - cargo run --locked --release -p pecos-cli -- install llvm --force + cargo run --locked --release -p pecos-cli -- install llvm --force || bash scripts/ci/install-llvm-21-release.sh + cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" cargo run --locked --release -p pecos-cli -- install cmake --force CIBW_REPAIR_WHEEL_COMMAND_LINUX: > auditwheel repair -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} # macOS configuration CIBW_ENVIRONMENT_MACOS: > - PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin:$PATH - LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 + PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin:$PATH + LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin/cmake MACOSX_DEPLOYMENT_TARGET=13.2 SDKROOT=$(xcrun --show-sdk-path) @@ -254,7 +255,10 @@ jobs: bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH rustup update - cargo run --locked --release -p pecos-cli -- install llvm --force + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + mkdir -p "$HOME/.pecos/deps" + ln -sfn "$(brew --prefix llvm@21)" "$HOME/.pecos/deps/llvm-21.1" + cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" cargo run --locked --release -p pecos-cli -- install cmake --force # Create a codesign wrapper that strips DYLD_LIBRARY_PATH to prevent # crashes on macOS 15 when bundled libc++ conflicts with system libc++ @@ -262,21 +266,22 @@ jobs: printf '#!/bin/bash\nunset DYLD_LIBRARY_PATH\nexec /usr/bin/codesign "$@"\n' > $HOME/.pecos/bin/codesign chmod +x $HOME/.pecos/bin/codesign CIBW_REPAIR_WHEEL_COMMAND_MACOS: > - PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-14/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && + PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && pipx run abi3audit --strict --report {wheel} # Windows configuration - CUDA via Jimver/cuda-toolkit (installed before cibuildwheel) CIBW_ENVIRONMENT_WINDOWS: > - PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" - LLVM_SYS_140_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14" + PATH="C:\\Program Files\\LLVM\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" + LLVM_SYS_211_PREFIX="C:\\Program Files\\LLVM" CMAKE="C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin\\cmake.exe" MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_WINDOWS: > - echo "=== Installing LLVM using pecos ===" && + echo "=== Installing LLVM 21.1 with Chocolatey ===" && rustup update && - cargo run --locked --release -p pecos-cli -- install llvm --force && + choco install llvm --version=${{ env.LLVM_RELEASE_VERSION }} -y --no-progress && + cargo run --locked --release -p pecos-cli -- llvm configure "C:\Program Files\LLVM" && cargo run --locked --release -p pecos-cli -- install cmake --force && echo "=== Checking LLVM installation ===" && - (test -d "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14" && echo "LLVM directory exists") || (echo "ERROR: LLVM directory not found!" && exit 1) + if exist "C:\Program Files\LLVM\bin\llvm-config.exe" (echo LLVM directory exists) else (echo ERROR: LLVM directory not found! && exit /b 1) # Install delvewheel and patch it to ignore ext-ms-win-* API sets # (delvewheel ignores api-ms-win-* but not ext-ms-win-* which are also Windows API sets) CIBW_BEFORE_BUILD_WINDOWS: > @@ -285,7 +290,7 @@ jobs: # Note: --no-dll excludes Windows system DLLs that should not be bundled # combase.dll and rmclient.dll are core Windows components that fail when bundled CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && + delvewheel repair -v --add-path "C:\\Program Files\\LLVM\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} - name: Upload pecos-rslib wheels @@ -307,19 +312,20 @@ jobs: CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_28" CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_28" CIBW_ENVIRONMENT_LINUX: > - PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$PATH - LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 + PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH + LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH - dnf install libffi-devel -y - cargo run --locked --release -p pecos-cli -- install llvm --force + dnf install -y libffi-devel xz + cargo run --locked --release -p pecos-cli -- install llvm --force || bash scripts/ci/install-llvm-21-release.sh + cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" CIBW_REPAIR_WHEEL_COMMAND_LINUX: > auditwheel repair -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_ENVIRONMENT_MACOS: > - PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$PATH - LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 + PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH + LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 MACOSX_DEPLOYMENT_TARGET=13.2 SDKROOT=$(xcrun --show-sdk-path) CIBW_BEFORE_ALL_MACOS: | @@ -327,9 +333,12 @@ jobs: bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH fi - if [ ! -d "$HOME/.pecos/deps/llvm-14/bin" ]; then + if [ ! -d "$HOME/.pecos/deps/llvm-21.1/bin" ]; then rustup update - cargo run --locked --release -p pecos-cli -- install llvm --force + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + mkdir -p "$HOME/.pecos/deps" + ln -sfn "$(brew --prefix llvm@21)" "$HOME/.pecos/deps/llvm-21.1" + cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" else echo "LLVM already installed from pecos-rslib build, skipping" fi @@ -337,19 +346,20 @@ jobs: printf '#!/bin/bash\nunset DYLD_LIBRARY_PATH\nexec /usr/bin/codesign "$@"\n' > $HOME/.pecos/bin/codesign chmod +x $HOME/.pecos/bin/codesign CIBW_REPAIR_WHEEL_COMMAND_MACOS: > - PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-14/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && + PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_ENVIRONMENT_WINDOWS: > - PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14\\bin;$PATH" - LLVM_SYS_140_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14" + PATH="C:\\Program Files\\LLVM\\bin;$PATH" + LLVM_SYS_211_PREFIX="C:\\Program Files\\LLVM" CIBW_BEFORE_ALL_WINDOWS: > rustup update && - if not exist "C:\Users\runneradmin\.pecos\deps\llvm-14\bin" (cargo run --locked --release -p pecos-cli -- install llvm --force) else (echo LLVM already installed from pecos-rslib build) + if not exist "C:\Program Files\LLVM\bin\llvm-config.exe" (choco install llvm --version=${{ env.LLVM_RELEASE_VERSION }} -y --no-progress) else (echo LLVM already installed from pecos-rslib build) && + cargo run --locked --release -p pecos-cli -- llvm configure "C:\Program Files\LLVM" CIBW_BEFORE_BUILD_WINDOWS: > pip install delvewheel && python -c "import delvewheel._dll_list as d,inspect,re as r;p=inspect.getfile(d);c=open(p).read();n=chr(10);open(p,'w').write(c.replace(r\"re.compile('api-.*'),\",r\"re.compile('api-.*'),\"+n+r\" re.compile('ext-.*'),\")) if 'ext-.*' not in c else None" CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-14\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && + delvewheel repair -v --add-path "C:\\Program Files\\LLVM\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} - name: Upload pecos-rslib-llvm wheels diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index eebabcc52..fe59e8e54 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -8,7 +8,8 @@ env: RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 PYTHONUTF8: 1 - LLVM_VERSION: "14.0.6" + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" # Force the MWPF decoder feature on in CI so we exercise the cmake-dependent # build path and catch regressions. GitHub-hosted runners ship cmake. PECOS_BUILD_MWPF: "1" @@ -139,8 +140,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 # `just ci-env` is the first cargo invocation. Its `_msvc-bootstrap` # prerequisite writes the MSVC linker + LIB/INCLUDE into @@ -154,7 +155,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev') uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Install PECOS CLI @@ -279,7 +280,7 @@ jobs: # pecos-build (where the toml_edit work lives) and pecos-core are # publishable and need no LLVM/FFI/external toolchain -- the right # minimal canary. LLVM-needing crates additionally require the upstream - # llvm-sys LLVM_SYS_140_PREFIX (a universal llvm-sys requirement, not a + # llvm-sys LLVM_SYS_211_PREFIX (a universal llvm-sys requirement, not a # PECOS workaround) and are out of scope for this contract lane. - name: Vanilla cargo check (publishable crates, no just/bootstrap) shell: pwsh diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index c8a15f63a..e162b6e55 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -7,7 +7,8 @@ env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 - LLVM_VERSION: "14.0.6" + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" on: push: @@ -89,8 +90,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -99,7 +100,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev') uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Check formatting @@ -230,8 +231,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 - name: Bootstrap MSVC for the Cargo build path (Windows) if: runner.os == 'Windows' @@ -249,7 +250,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev') uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Install CUDA Toolkit (Linux) diff --git a/.github/workflows/test-docs-examples.yml b/.github/workflows/test-docs-examples.yml index c633a2a54..53ba5305c 100644 --- a/.github/workflows/test-docs-examples.yml +++ b/.github/workflows/test-docs-examples.yml @@ -22,7 +22,8 @@ env: RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 PYTHONUTF8: 1 - LLVM_VERSION: "14.0.6" + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" jobs: docs-ci: @@ -73,8 +74,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -83,7 +84,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && (github.ref_name == 'main' || github.ref_name == 'master' || github.ref_name == 'development' || github.ref_name == 'dev') uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Install dependencies and build diff --git a/Justfile b/Justfile index c7c5022c8..14574899a 100644 --- a/Justfile +++ b/Justfile @@ -58,7 +58,29 @@ setup-ci: _msvc-bootstrap ci-env: _msvc-bootstrap #!/usr/bin/env bash set -euo pipefail - {{pecos}} llvm ensure --managed --no-configure + LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" + case "${RUNNER_OS:-$(uname -s)}" in + Linux) + {{pecos}} llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-release.sh + {{pecos}} llvm configure + ;; + macOS|Darwin) + if ! {{pecos}} llvm find >/dev/null 2>&1; then + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + fi + {{pecos}} llvm configure "$(brew --prefix llvm@21)" + ;; + Windows*|MINGW*|MSYS*|CYGWIN*) + if ! {{pecos}} llvm find >/dev/null 2>&1; then + powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "choco install llvm --version=$LLVM_RELEASE_VERSION -y --no-progress" + fi + {{pecos}} llvm configure "C:/Program Files/LLVM" + ;; + *) + {{pecos}} llvm ensure --managed --no-configure + {{pecos}} llvm configure + ;; + esac {{pecos}} env --github-actions # Check development environment for common problems 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/scripts/ci/install-llvm-21-release.sh b/scripts/ci/install-llvm-21-release.sh new file mode 100644 index 000000000..b99b99ee8 --- /dev/null +++ b/scripts/ci/install-llvm-21-release.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +set -euo pipefail + +LLVM_VERSION="${LLVM_VERSION:-21.1}" +LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" +INSTALL_DIR="${LLVM_INSTALL_DIR:-$HOME/.pecos/deps/llvm-$LLVM_VERSION}" + +case "$(uname -m)" in + x86_64|amd64) + ASSET="LLVM-${LLVM_RELEASE_VERSION}-Linux-X64.tar.xz" + SHA256="b3b7f2801d15d50736acea3c73982994d025b01c2f035b91ae3b49d1b575732b" + ;; + aarch64|arm64) + ASSET="LLVM-${LLVM_RELEASE_VERSION}-Linux-ARM64.tar.xz" + SHA256="65ce0b329514e5643407db2d02a5bd34bf33d159055dafa82825c8385bd01993" + ;; + *) + echo "Unsupported Linux architecture for LLVM ${LLVM_RELEASE_VERSION}: $(uname -m)" >&2 + exit 1 + ;; +esac + +if [ -x "$INSTALL_DIR/bin/llvm-config" ] && "$INSTALL_DIR/bin/llvm-config" --version | grep -q '^21\.1'; then + echo "LLVM $("$INSTALL_DIR/bin/llvm-config" --version) already installed at $INSTALL_DIR" + exit 0 +fi + +URL="https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_RELEASE_VERSION}/${ASSET}" +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +ARCHIVE="$TMP_DIR/$ASSET" +EXTRACT_DIR="$TMP_DIR/extract" + +echo "Downloading official LLVM ${LLVM_RELEASE_VERSION} package: $ASSET" +if command -v curl >/dev/null 2>&1; then + curl --fail --location --retry 5 --retry-delay 5 --output "$ARCHIVE" "$URL" +else + python3 - "$URL" "$ARCHIVE" <<'PY' +import sys +import urllib.request + +url, dest = sys.argv[1], sys.argv[2] +with urllib.request.urlopen(url) as response, open(dest, "wb") as out: + out.write(response.read()) +PY +fi + +echo "$SHA256 $ARCHIVE" | sha256sum -c - + +mkdir -p "$EXTRACT_DIR" +tar -xJf "$ARCHIVE" -C "$EXTRACT_DIR" --strip-components=1 + +rm -rf "$INSTALL_DIR" +mkdir -p "$(dirname "$INSTALL_DIR")" +mv "$EXTRACT_DIR" "$INSTALL_DIR" + +"$INSTALL_DIR/bin/llvm-config" --version +"$INSTALL_DIR/bin/llvm-config" --shared-mode +echo "Installed LLVM ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" From 3826c3451ae3e914e1e160e95bb6b9e2535bbd34 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 16:30:54 -0600 Subject: [PATCH 039/388] Keep Chocolatey for Windows LLVM setup --- .github/workflows/python-test.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index fe59e8e54..83474847d 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -76,7 +76,6 @@ jobs: Remove-Item -Path "C:\Android" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path "C:\Program Files\dotnet" -Recurse -Force -ErrorAction SilentlyContinue Remove-Item -Path "C:\hostedtoolcache\CodeQL" -Recurse -Force -ErrorAction SilentlyContinue - Remove-Item -Path "C:\ProgramData\chocolatey" -Recurse -Force -ErrorAction SilentlyContinue - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: From 083fecfa87e98aa2f296a03c130a5ab1e95213b2 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 16:46:56 -0600 Subject: [PATCH 040/388] Use LLVM archive on Windows CI --- .github/workflows/julia-release.yml | 7 +-- .github/workflows/python-release.yml | 24 +++++----- Justfile | 5 +- scripts/ci/install-llvm-21-windows.ps1 | 63 ++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 17 deletions(-) create mode 100644 scripts/ci/install-llvm-21-windows.ps1 diff --git a/.github/workflows/julia-release.yml b/.github/workflows/julia-release.yml index 84a106a68..e91d48276 100644 --- a/.github/workflows/julia-release.yml +++ b/.github/workflows/julia-release.yml @@ -153,11 +153,12 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - Write-Host "Installing LLVM 21.1 with Chocolatey..." - choco install llvm --version=${{ env.LLVM_RELEASE_VERSION }} -y --no-progress + $llvmPrefix = Join-Path $env:USERPROFILE ".pecos\deps\llvm-21.1" + Write-Host "Installing LLVM 21.1 development archive..." + ./scripts/ci/install-llvm-21-windows.ps1 -InstallDir $llvmPrefix -Version ${{ env.LLVM_RELEASE_VERSION }} Write-Host "Setting LLVM environment variables..." - $env:PECOS_LLVM = "C:\Program Files\LLVM" + $env:PECOS_LLVM = $llvmPrefix $env:LLVM_SYS_211_PREFIX = $env:PECOS_LLVM cargo run --locked -p pecos-cli --release -- llvm configure "$env:PECOS_LLVM" diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index f65242a9a..6d0bf48fe 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -270,18 +270,18 @@ jobs: pipx run abi3audit --strict --report {wheel} # Windows configuration - CUDA via Jimver/cuda-toolkit (installed before cibuildwheel) CIBW_ENVIRONMENT_WINDOWS: > - PATH="C:\\Program Files\\LLVM\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" - LLVM_SYS_211_PREFIX="C:\\Program Files\\LLVM" + PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" + LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1" CMAKE="C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin\\cmake.exe" MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_WINDOWS: > - echo "=== Installing LLVM 21.1 with Chocolatey ===" && + echo "=== Installing LLVM 21.1 development archive ===" && rustup update && - choco install llvm --version=${{ env.LLVM_RELEASE_VERSION }} -y --no-progress && - cargo run --locked --release -p pecos-cli -- llvm configure "C:\Program Files\LLVM" && + powershell -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }} && + cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1" && cargo run --locked --release -p pecos-cli -- install cmake --force && echo "=== Checking LLVM installation ===" && - if exist "C:\Program Files\LLVM\bin\llvm-config.exe" (echo LLVM directory exists) else (echo ERROR: LLVM directory not found! && exit /b 1) + if exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\bin\llvm-config.exe" (echo LLVM directory exists) else (echo ERROR: LLVM directory not found! && exit /b 1) # Install delvewheel and patch it to ignore ext-ms-win-* API sets # (delvewheel ignores api-ms-win-* but not ext-ms-win-* which are also Windows API sets) CIBW_BEFORE_BUILD_WINDOWS: > @@ -290,7 +290,7 @@ jobs: # Note: --no-dll excludes Windows system DLLs that should not be bundled # combase.dll and rmclient.dll are core Windows components that fail when bundled CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - delvewheel repair -v --add-path "C:\\Program Files\\LLVM\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && + delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} - name: Upload pecos-rslib wheels @@ -349,17 +349,17 @@ jobs: PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_ENVIRONMENT_WINDOWS: > - PATH="C:\\Program Files\\LLVM\\bin;$PATH" - LLVM_SYS_211_PREFIX="C:\\Program Files\\LLVM" + PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin;$PATH" + LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1" CIBW_BEFORE_ALL_WINDOWS: > rustup update && - if not exist "C:\Program Files\LLVM\bin\llvm-config.exe" (choco install llvm --version=${{ env.LLVM_RELEASE_VERSION }} -y --no-progress) else (echo LLVM already installed from pecos-rslib build) && - cargo run --locked --release -p pecos-cli -- llvm configure "C:\Program Files\LLVM" + if not exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\bin\llvm-config.exe" (powershell -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }}) else (echo LLVM already installed from pecos-rslib build) && + cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1" CIBW_BEFORE_BUILD_WINDOWS: > pip install delvewheel && python -c "import delvewheel._dll_list as d,inspect,re as r;p=inspect.getfile(d);c=open(p).read();n=chr(10);open(p,'w').write(c.replace(r\"re.compile('api-.*'),\",r\"re.compile('api-.*'),\"+n+r\" re.compile('ext-.*'),\")) if 'ext-.*' not in c else None" CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - delvewheel repair -v --add-path "C:\\Program Files\\LLVM\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && + delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} - name: Upload pecos-rslib-llvm wheels diff --git a/Justfile b/Justfile index 14574899a..90526c5ba 100644 --- a/Justfile +++ b/Justfile @@ -72,9 +72,10 @@ ci-env: _msvc-bootstrap ;; Windows*|MINGW*|MSYS*|CYGWIN*) if ! {{pecos}} llvm find >/dev/null 2>&1; then - powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "choco install llvm --version=$LLVM_RELEASE_VERSION -y --no-progress" + LLVM_PREFIX="${USERPROFILE:-$HOME}\\.pecos\\deps\\llvm-21.1" + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/ci/install-llvm-21-windows.ps1 -InstallDir "$LLVM_PREFIX" -Version "$LLVM_RELEASE_VERSION" fi - {{pecos}} llvm configure "C:/Program Files/LLVM" + {{pecos}} llvm configure ;; *) {{pecos}} llvm ensure --managed --no-configure diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 new file mode 100644 index 000000000..be01b30d0 --- /dev/null +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -0,0 +1,63 @@ +param( + [string]$InstallDir = (Join-Path $env:USERPROFILE ".pecos\deps\llvm-21.1"), + [string]$Version = "" +) + +$ErrorActionPreference = "Stop" + +if (-not $Version) { + if ($env:LLVM_RELEASE_VERSION) { + $Version = $env:LLVM_RELEASE_VERSION + } + else { + $Version = "21.1.8" + } +} + +$ExpectedSha256 = "749d22f565fcd5718dbed06512572d0e5353b502c03fe1f7f17ee8b8aca21a47" +$RequiredVersion = "21.1" +$Asset = "clang+llvm-$Version-x86_64-pc-windows-msvc.tar.xz" +$Url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$Version/$Asset" +$LlvmConfig = Join-Path $InstallDir "bin\llvm-config.exe" + +if (Test-Path $LlvmConfig) { + $FoundVersion = (& $LlvmConfig --version).Trim() + if ($FoundVersion.StartsWith($RequiredVersion)) { + Write-Host "LLVM $FoundVersion already installed at $InstallDir" + exit 0 + } +} + +$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) "pecos-llvm-$([System.Guid]::NewGuid())" +$Archive = Join-Path $TempDir $Asset +$ExtractDir = Join-Path $TempDir "extract" + +New-Item -ItemType Directory -Force -Path $TempDir | Out-Null +New-Item -ItemType Directory -Force -Path $ExtractDir | Out-Null + +try { + Write-Host "Downloading official LLVM $Version Windows development archive: $Asset" + Invoke-WebRequest -Uri $Url -OutFile $Archive + + $ActualSha256 = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant() + if ($ActualSha256 -ne $ExpectedSha256) { + throw "SHA256 mismatch for $Asset. Expected $ExpectedSha256, got $ActualSha256" + } + + tar -xf $Archive -C $ExtractDir --strip-components=1 + + if (Test-Path $InstallDir) { + Remove-Item -Recurse -Force $InstallDir + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $InstallDir) | Out-Null + Move-Item $ExtractDir $InstallDir + + & $LlvmConfig --version + & $LlvmConfig --shared-mode + Write-Host "Installed LLVM $Version to $InstallDir" +} +finally { + if (Test-Path $TempDir) { + Remove-Item -Recurse -Force $TempDir + } +} From f20055ab3532ccdfaaddf6f17b0ef95f046f5260 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 17:04:54 -0600 Subject: [PATCH 041/388] Use manylinux 2.34 for LLVM wheels --- .github/workflows/python-release.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 6d0bf48fe..dc5914cf2 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -101,7 +101,7 @@ jobs: gcc_cc: "/opt/rh/gcc-toolset-13/root/usr/bin/gcc" gcc_cxx: "/opt/rh/gcc-toolset-13/root/usr/bin/g++" # Linux aarch64 - temporarily disabled while the LLVM 21.1 - # manylinux_2_28 bootstrap is validated on the primary x86_64 lane. + # manylinux_2_34 bootstrap is validated on the primary x86_64 lane. # - os: ubuntu-24.04-arm # architecture: aarch64 # runner: ubuntu-24.04-arm @@ -207,8 +207,8 @@ jobs: CIBW_BUILD: "cp310-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux*" CIBW_ARCHS_LINUX: ${{ matrix.cibw_archs }} - CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_28" - CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_28" + CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_34" + CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_34" # Linux configuration - GCC Toolset and CUDA paths are conditional via matrix variables CIBW_ENVIRONMENT_LINUX: > PATH=${{ matrix.gcc_path_prefix }}$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH @@ -227,7 +227,9 @@ jobs: dnf install -y gcc-toolset-13 source /opt/rh/gcc-toolset-13/enable echo "Installing CUDA Toolkit from NVIDIA repos..." - dnf config-manager --add-repo https://developer.download.nvidia.com/compute/cuda/repos/rhel8/x86_64/cuda-rhel8.repo + . /etc/os-release + CUDA_RHEL_MAJOR="${VERSION_ID%%.*}" + dnf config-manager --add-repo "https://developer.download.nvidia.com/compute/cuda/repos/rhel${CUDA_RHEL_MAJOR}/x86_64/cuda-rhel${CUDA_RHEL_MAJOR}.repo" dnf install -y cuda-nvcc-12-6 cuda-cudart-devel-12-6 libcublas-devel-12-6 export CUDA_PATH=/usr/local/cuda-12.6 export PATH=$CUDA_PATH/bin:$PATH @@ -309,8 +311,8 @@ jobs: CIBW_BUILD: "cp310-*" CIBW_SKIP: "*-win32 *-manylinux_i686 *-musllinux*" CIBW_ARCHS_LINUX: ${{ matrix.cibw_archs }} - CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_28" - CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_28" + CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_34" + CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_34" CIBW_ENVIRONMENT_LINUX: > PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 From 099f54bf0149f0a0442634a0ce02ae1713b747bb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 17:48:14 -0600 Subject: [PATCH 042/388] Fix Linux LLVM wheel setup --- .github/workflows/julia-release.yml | 2 +- .github/workflows/python-release.yml | 11 ++-- Justfile | 2 +- scripts/ci/install-llvm-21-conda-linux.sh | 77 +++++++++++++++++++++++ scripts/ci/install-llvm-21-release.sh | 21 ++++++- 5 files changed, 104 insertions(+), 9 deletions(-) create mode 100755 scripts/ci/install-llvm-21-conda-linux.sh mode change 100644 => 100755 scripts/ci/install-llvm-21-release.sh diff --git a/.github/workflows/julia-release.yml b/.github/workflows/julia-release.yml index e91d48276..45e95119b 100644 --- a/.github/workflows/julia-release.yml +++ b/.github/workflows/julia-release.yml @@ -134,7 +134,7 @@ jobs: LLVM_PREFIX="$(brew --prefix llvm@21)" else echo "Installing LLVM 21.1 using PECOS-managed packages..." - cargo run --locked -p pecos-cli --release -- llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-release.sh + cargo run --locked -p pecos-cli --release -- llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-conda-linux.sh LLVM_PREFIX="$HOME/.pecos/deps/llvm-21.1" fi diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index dc5914cf2..8fa27c1b0 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -212,7 +212,7 @@ jobs: # Linux configuration - GCC Toolset and CUDA paths are conditional via matrix variables CIBW_ENVIRONMENT_LINUX: > PATH=${{ matrix.gcc_path_prefix }}$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH - LD_LIBRARY_PATH=${{ matrix.gcc_ld_path }}$LD_LIBRARY_PATH + LD_LIBRARY_PATH=${{ matrix.gcc_ld_path }}$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin/cmake CUDA_PATH=/usr/local/cuda-12.6 @@ -220,7 +220,7 @@ jobs: CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH - dnf install -y libffi-devel xz + dnf install -y bzip2 libffi-devel xz # Install CUDA Toolkit for GPU support on x86_64 (compile-time only, no GPU needed) if [ "${{ matrix.install_cuda }}" = "true" ]; then echo "Installing GCC 13 (required for CUDA 12.6 compatibility)..." @@ -239,7 +239,7 @@ jobs: else echo "Skipping CUDA installation (GPU support not enabled for this build)" fi - cargo run --locked --release -p pecos-cli -- install llvm --force || bash scripts/ci/install-llvm-21-release.sh + bash scripts/ci/install-llvm-21-conda-linux.sh cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" cargo run --locked --release -p pecos-cli -- install cmake --force CIBW_REPAIR_WHEEL_COMMAND_LINUX: > @@ -315,12 +315,13 @@ jobs: CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_34" CIBW_ENVIRONMENT_LINUX: > PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH + LD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH - dnf install -y libffi-devel xz - cargo run --locked --release -p pecos-cli -- install llvm --force || bash scripts/ci/install-llvm-21-release.sh + dnf install -y bzip2 libffi-devel xz + bash scripts/ci/install-llvm-21-conda-linux.sh cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" CIBW_REPAIR_WHEEL_COMMAND_LINUX: > auditwheel repair -w {dest_dir} {wheel} && diff --git a/Justfile b/Justfile index 90526c5ba..c0425051f 100644 --- a/Justfile +++ b/Justfile @@ -61,7 +61,7 @@ ci-env: _msvc-bootstrap LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" case "${RUNNER_OS:-$(uname -s)}" in Linux) - {{pecos}} llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-release.sh + {{pecos}} llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-conda-linux.sh {{pecos}} llvm configure ;; macOS|Darwin) diff --git a/scripts/ci/install-llvm-21-conda-linux.sh b/scripts/ci/install-llvm-21-conda-linux.sh new file mode 100755 index 000000000..29b2adc4c --- /dev/null +++ b/scripts/ci/install-llvm-21-conda-linux.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +set -euo pipefail + +LLVM_VERSION="${LLVM_VERSION:-21.1}" +LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" +INSTALL_DIR="${LLVM_INSTALL_DIR:-$HOME/.pecos/deps/llvm-$LLVM_VERSION}" +MAMBA_VERSION="${MAMBA_VERSION:-latest}" +MAMBA_ROOT_PREFIX="${MAMBA_ROOT_PREFIX:-$HOME/.cache/pecos-micromamba}" + +case "$(uname -m)" in + x86_64|amd64) + MAMBA_PLATFORM="linux-64" + ;; + aarch64|arm64) + MAMBA_PLATFORM="linux-aarch64" + ;; + *) + echo "Unsupported Linux architecture for conda-forge LLVM ${LLVM_RELEASE_VERSION}: $(uname -m)" >&2 + exit 1 + ;; +esac + +llvm_is_valid() { + local llvm_config="$1" + + [ -x "$llvm_config" ] || return 1 + "$llvm_config" --version | grep -q '^21\.1' || return 1 + [ "$("$llvm_config" --shared-mode)" = "shared" ] || return 1 + "$llvm_config" --libnames --link-shared | grep -q 'libLLVM-21\.so' +} + +LLVM_CONFIG="$INSTALL_DIR/bin/llvm-config" +if llvm_is_valid "$LLVM_CONFIG"; then + echo "Shared LLVM $("$LLVM_CONFIG" --version) already installed at $INSTALL_DIR" + exit 0 +fi + +if [ -e "$INSTALL_DIR" ]; then + echo "Removing invalid or non-shared LLVM install at $INSTALL_DIR" + rm -rf "$INSTALL_DIR" +fi + +TMP_DIR="$(mktemp -d)" +trap 'rm -rf "$TMP_DIR"' EXIT + +if command -v micromamba >/dev/null 2>&1; then + MAMBA_BIN="$(command -v micromamba)" +else + MAMBA_URL="https://micro.mamba.pm/api/micromamba/${MAMBA_PLATFORM}/${MAMBA_VERSION}" + MAMBA_ARCHIVE="$TMP_DIR/micromamba.tar.bz2" + + echo "Downloading micromamba for ${MAMBA_PLATFORM}" + curl --fail --location --retry 5 --retry-delay 5 --output "$MAMBA_ARCHIVE" "$MAMBA_URL" + tar -xjf "$MAMBA_ARCHIVE" -C "$TMP_DIR" bin/micromamba + MAMBA_BIN="$TMP_DIR/bin/micromamba" +fi + +echo "Installing conda-forge LLVM ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" +MAMBA_ROOT_PREFIX="$MAMBA_ROOT_PREFIX" "$MAMBA_BIN" create \ + -y \ + -p "$INSTALL_DIR" \ + --override-channels \ + -c conda-forge \ + "llvmdev=${LLVM_RELEASE_VERSION}" + +if ! llvm_is_valid "$LLVM_CONFIG"; then + echo "conda-forge LLVM install did not provide shared LLVM ${LLVM_RELEASE_VERSION}" >&2 + "$LLVM_CONFIG" --version >&2 || true + "$LLVM_CONFIG" --shared-mode >&2 || true + "$LLVM_CONFIG" --libnames --link-shared >&2 || true + exit 1 +fi + +"$LLVM_CONFIG" --version +"$LLVM_CONFIG" --shared-mode +"$LLVM_CONFIG" --libnames --link-shared +echo "Installed shared LLVM ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" diff --git a/scripts/ci/install-llvm-21-release.sh b/scripts/ci/install-llvm-21-release.sh old mode 100644 new mode 100755 index b99b99ee8..14a553700 --- a/scripts/ci/install-llvm-21-release.sh +++ b/scripts/ci/install-llvm-21-release.sh @@ -20,9 +20,21 @@ case "$(uname -m)" in ;; esac -if [ -x "$INSTALL_DIR/bin/llvm-config" ] && "$INSTALL_DIR/bin/llvm-config" --version | grep -q '^21\.1'; then - echo "LLVM $("$INSTALL_DIR/bin/llvm-config" --version) already installed at $INSTALL_DIR" +llvm_is_shared() { + local llvm_config="$1" + + [ -x "$llvm_config" ] || return 1 + "$llvm_config" --version | grep -q '^21\.1' || return 1 + [ "$("$llvm_config" --shared-mode)" = "shared" ] || return 1 + "$llvm_config" --libnames --link-shared | grep -q 'libLLVM-21\.so' +} + +if llvm_is_shared "$INSTALL_DIR/bin/llvm-config"; then + echo "Shared LLVM $("$INSTALL_DIR/bin/llvm-config" --version) already installed at $INSTALL_DIR" exit 0 +elif [ -e "$INSTALL_DIR" ]; then + echo "Removing invalid or non-shared LLVM install at $INSTALL_DIR" + rm -rf "$INSTALL_DIR" fi URL="https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_RELEASE_VERSION}/${ASSET}" @@ -57,4 +69,9 @@ mv "$EXTRACT_DIR" "$INSTALL_DIR" "$INSTALL_DIR/bin/llvm-config" --version "$INSTALL_DIR/bin/llvm-config" --shared-mode +if ! llvm_is_shared "$INSTALL_DIR/bin/llvm-config"; then + echo "The official LLVM ${LLVM_RELEASE_VERSION} Linux archive does not provide libLLVM-21.so." >&2 + echo "PECOS CI needs a shared LLVM build; use scripts/ci/install-llvm-21-conda-linux.sh instead." >&2 + exit 1 +fi echo "Installed LLVM ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" From 15f7262895a10f1ad495e60766034269d1fea32c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 17:56:45 -0600 Subject: [PATCH 043/388] Fix Windows LLVM setup and docs timeout --- docs/user-guide/qec-guppy.md | 2 +- scripts/ci/install-llvm-21-windows.ps1 | 28 ++++++++++++++++++++++++-- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/docs/user-guide/qec-guppy.md b/docs/user-guide/qec-guppy.md index 5318a6afc..04645d245 100644 --- a/docs/user-guide/qec-guppy.md +++ b/docs/user-guide/qec-guppy.md @@ -389,7 +389,7 @@ results = ( .quantum(state_vector()) .noise(depolarizing_noise().with_uniform_probability(0.001)) .seed(42) - .run(1000) + .run(100) ) ``` diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index be01b30d0..0981b728a 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -20,6 +20,24 @@ $Asset = "clang+llvm-$Version-x86_64-pc-windows-msvc.tar.xz" $Url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$Version/$Asset" $LlvmConfig = Join-Path $InstallDir "bin\llvm-config.exe" +function Get-Sha256Hex { + param([string]$Path) + + $Stream = [System.IO.File]::OpenRead($Path) + try { + $Sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + return -join ($Sha256.ComputeHash($Stream) | ForEach-Object { $_.ToString("x2") }) + } + finally { + $Sha256.Dispose() + } + } + finally { + $Stream.Dispose() + } +} + if (Test-Path $LlvmConfig) { $FoundVersion = (& $LlvmConfig --version).Trim() if ($FoundVersion.StartsWith($RequiredVersion)) { @@ -37,9 +55,15 @@ New-Item -ItemType Directory -Force -Path $ExtractDir | Out-Null try { Write-Host "Downloading official LLVM $Version Windows development archive: $Asset" - Invoke-WebRequest -Uri $Url -OutFile $Archive + $Curl = Get-Command curl.exe -ErrorAction SilentlyContinue + if ($Curl) { + & $Curl.Source --fail --location --retry 5 --retry-delay 5 --output $Archive $Url + } + else { + Invoke-WebRequest -Uri $Url -OutFile $Archive + } - $ActualSha256 = (Get-FileHash -Algorithm SHA256 $Archive).Hash.ToLowerInvariant() + $ActualSha256 = Get-Sha256Hex $Archive if ($ActualSha256 -ne $ExpectedSha256) { throw "SHA256 mismatch for $Asset. Expected $ExpectedSha256, got $ActualSha256" } From 7e4a8aae0415711646aa2db910ea00e2a67b8e7b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 18:21:44 -0600 Subject: [PATCH 044/388] Fix LLVM CI libclang setup --- .github/workflows/python-release.yml | 10 ++++- .github/workflows/python-test.yml | 2 +- .github/workflows/rust-test.yml | 4 +- .github/workflows/test-docs-examples.yml | 2 +- crates/pecos-cli/src/cli/env_cmd.rs | 54 ++++++++++++++++++++--- scripts/ci/install-llvm-21-conda-linux.sh | 17 +++++-- scripts/ci/install-llvm-21-windows.ps1 | 11 ++++- 7 files changed, 84 insertions(+), 16 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 8fa27c1b0..41d5c06ef 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -214,6 +214,7 @@ jobs: PATH=${{ matrix.gcc_path_prefix }}$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH LD_LIBRARY_PATH=${{ matrix.gcc_ld_path }}$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 + LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin/cmake CUDA_PATH=/usr/local/cuda-12.6 MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" @@ -249,6 +250,7 @@ jobs: CIBW_ENVIRONMENT_MACOS: > PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin:$PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 + LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin/cmake MACOSX_DEPLOYMENT_TARGET=13.2 SDKROOT=$(xcrun --show-sdk-path) @@ -274,12 +276,13 @@ jobs: CIBW_ENVIRONMENT_WINDOWS: > PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1" + LIBCLANG_PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin" CMAKE="C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin\\cmake.exe" MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_WINDOWS: > echo "=== Installing LLVM 21.1 development archive ===" && rustup update && - powershell -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }} && + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }} && cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1" && cargo run --locked --release -p pecos-cli -- install cmake --force && echo "=== Checking LLVM installation ===" && @@ -317,6 +320,7 @@ jobs: PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH LD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 + LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH @@ -329,6 +333,7 @@ jobs: CIBW_ENVIRONMENT_MACOS: > PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 + LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib MACOSX_DEPLOYMENT_TARGET=13.2 SDKROOT=$(xcrun --show-sdk-path) CIBW_BEFORE_ALL_MACOS: | @@ -354,9 +359,10 @@ jobs: CIBW_ENVIRONMENT_WINDOWS: > PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin;$PATH" LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1" + LIBCLANG_PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin" CIBW_BEFORE_ALL_WINDOWS: > rustup update && - if not exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\bin\llvm-config.exe" (powershell -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }}) else (echo LLVM already installed from pecos-rslib build) && + if not exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\bin\llvm-config.exe" (powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }}) else (echo LLVM already installed from pecos-rslib build) && cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1" CIBW_BEFORE_BUILD_WINDOWS: > pip install delvewheel && diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 83474847d..289a8f715 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -140,7 +140,7 @@ jobs: uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.pecos/deps/llvm-21.1 - key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 # `just ci-env` is the first cargo invocation. Its `_msvc-bootstrap` # prerequisite writes the MSVC linker + LIB/INCLUDE into diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index e162b6e55..dd43be99f 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -91,7 +91,7 @@ jobs: uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.pecos/deps/llvm-21.1 - key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -232,7 +232,7 @@ jobs: uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.pecos/deps/llvm-21.1 - key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Bootstrap MSVC for the Cargo build path (Windows) if: runner.os == 'Windows' diff --git a/.github/workflows/test-docs-examples.yml b/.github/workflows/test-docs-examples.yml index 53ba5305c..11972e83e 100644 --- a/.github/workflows/test-docs-examples.yml +++ b/.github/workflows/test-docs-examples.yml @@ -75,7 +75,7 @@ jobs: uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.pecos/deps/llvm-21.1 - key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index 70a19412d..f3f3585d6 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -26,9 +26,10 @@ use std::collections::BTreeMap; use std::ffi::OsString; use std::fmt::Write; +use std::fs; use std::fs::OpenOptions; use std::io::Write as IoWrite; -use std::path::Path; +use std::path::{Path, PathBuf}; use pecos_build::Result; use pecos_build::errors::Error; @@ -59,11 +60,16 @@ pub fn collect_env() -> BTreeMap { } } - if pecos_build::llvm::get_llvm_shared_mode(&llvm_path) - .is_ok_and(|mode| mode.trim().eq_ignore_ascii_case("shared")) - && let Ok(libdir) = pecos_build::llvm::get_llvm_libdir(&llvm_path) - { - add_llvm_runtime_library_path(&mut env, &libdir); + if let Ok(libdir) = pecos_build::llvm::get_llvm_libdir(&llvm_path) { + if pecos_build::llvm::get_llvm_shared_mode(&llvm_path) + .is_ok_and(|mode| mode.trim().eq_ignore_ascii_case("shared")) + { + add_llvm_runtime_library_path(&mut env, &libdir); + } + + if let Some(libclang_dir) = find_libclang_dir(&llvm_path, &libdir) { + env.insert("LIBCLANG_PATH".into(), libclang_dir.display().to_string()); + } } } @@ -161,6 +167,42 @@ fn prepend_path_env(env: &mut BTreeMap, key: &str, first: &Path) } } +fn find_libclang_dir(llvm_path: &Path, libdir: &Path) -> Option { + let mut candidates = vec![libdir.to_path_buf()]; + if cfg!(windows) { + candidates.insert(0, llvm_path.join("bin")); + } + + candidates + .into_iter() + .find(|candidate| contains_libclang(candidate)) +} + +fn contains_libclang(dir: &Path) -> bool { + let Ok(entries) = fs::read_dir(dir) else { + return false; + }; + + entries + .filter_map(std::result::Result::ok) + .any(|entry| entry.file_name().to_str().is_some_and(is_libclang_filename)) +} + +fn is_libclang_filename(name: &str) -> bool { + if cfg!(windows) { + return name.eq_ignore_ascii_case("libclang.dll"); + } + + if cfg!(target_os = "macos") { + return name == "libclang.dylib" + || (name.starts_with("libclang.") && name.ends_with(".dylib")); + } + + name == "libclang.so" + || name.starts_with("libclang.so.") + || name.starts_with("libclang-") && name.contains(".so") +} + /// Print environment in shell-eval format: `export KEY="VALUE"` pub fn print_shell(env: &BTreeMap) { for (key, value) in env { diff --git a/scripts/ci/install-llvm-21-conda-linux.sh b/scripts/ci/install-llvm-21-conda-linux.sh index 29b2adc4c..7bb3b845b 100755 --- a/scripts/ci/install-llvm-21-conda-linux.sh +++ b/scripts/ci/install-llvm-21-conda-linux.sh @@ -22,11 +22,17 @@ esac llvm_is_valid() { local llvm_config="$1" + local llvm_dir + + llvm_dir="$(dirname "$(dirname "$llvm_config")")" [ -x "$llvm_config" ] || return 1 "$llvm_config" --version | grep -q '^21\.1' || return 1 [ "$("$llvm_config" --shared-mode)" = "shared" ] || return 1 "$llvm_config" --libnames --link-shared | grep -q 'libLLVM-21\.so' + find "$llvm_dir/lib" -maxdepth 1 \ + \( -name 'libclang.so' -o -name 'libclang-*.so' -o -name 'libclang.so.*' -o -name 'libclang-*.so.*' \) \ + | grep -q . } LLVM_CONFIG="$INSTALL_DIR/bin/llvm-config" @@ -61,17 +67,22 @@ MAMBA_ROOT_PREFIX="$MAMBA_ROOT_PREFIX" "$MAMBA_BIN" create \ -p "$INSTALL_DIR" \ --override-channels \ -c conda-forge \ - "llvmdev=${LLVM_RELEASE_VERSION}" + "llvmdev=${LLVM_RELEASE_VERSION}" \ + "libclang=${LLVM_RELEASE_VERSION}" if ! llvm_is_valid "$LLVM_CONFIG"; then - echo "conda-forge LLVM install did not provide shared LLVM ${LLVM_RELEASE_VERSION}" >&2 + echo "conda-forge LLVM install did not provide shared LLVM and libclang ${LLVM_RELEASE_VERSION}" >&2 "$LLVM_CONFIG" --version >&2 || true "$LLVM_CONFIG" --shared-mode >&2 || true "$LLVM_CONFIG" --libnames --link-shared >&2 || true + find "$INSTALL_DIR/lib" -maxdepth 1 -name 'libclang*' -print >&2 || true exit 1 fi "$LLVM_CONFIG" --version "$LLVM_CONFIG" --shared-mode "$LLVM_CONFIG" --libnames --link-shared -echo "Installed shared LLVM ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" +find "$INSTALL_DIR/lib" -maxdepth 1 \ + \( -name 'libclang.so' -o -name 'libclang-*.so' -o -name 'libclang.so.*' -o -name 'libclang-*.so.*' \) \ + -print +echo "Installed shared LLVM and libclang ${LLVM_RELEASE_VERSION} to $INSTALL_DIR" diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index 0981b728a..ff7b981fc 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -68,7 +68,16 @@ try { throw "SHA256 mismatch for $Asset. Expected $ExpectedSha256, got $ActualSha256" } - tar -xf $Archive -C $ExtractDir --strip-components=1 + $Tar = Join-Path $env:SystemRoot "System32\tar.exe" + if (-not (Test-Path $Tar)) { + $Tar = "tar.exe" + } + + Write-Host "Extracting LLVM archive with $Tar" + & $Tar -xf $Archive -C $ExtractDir --strip-components=1 + if ($LASTEXITCODE -ne 0) { + throw "tar failed with exit code $LASTEXITCODE" + } if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir From d0fcb02a52659b0ef7c05ad844ab870a0776a2de Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 18:51:25 -0600 Subject: [PATCH 045/388] Fix libclang path lint --- crates/pecos-cli/src/cli/env_cmd.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index f3f3585d6..a99dc51fe 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -195,7 +195,10 @@ fn is_libclang_filename(name: &str) -> bool { if cfg!(target_os = "macos") { return name == "libclang.dylib" - || (name.starts_with("libclang.") && name.ends_with(".dylib")); + || (name.starts_with("libclang.") + && Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("dylib"))); } name == "libclang.so" From b2b52735e02343461c1ce3cf9b98300717864546 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 19:32:40 -0600 Subject: [PATCH 046/388] Fix UF predecoder to fall through for non-provably-optimal isolated/pair defects so it suppresses with distance, revert LogicalSubgraphDecoder default inner to pecos_uf:bp, add decode_each/_UfDebug diagnostics and windowed pin test --- crates/pecos-uf-decoder/src/decoder.rs | 113 ++++++++++-------- .../src/fault_tolerance_bindings.rs | 84 +++++++++++-- ...test_logical_subgraph_region_comparison.py | 84 ++++++++++--- 3 files changed, 210 insertions(+), 71 deletions(-) diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 1ce43de0d..35e610f31 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -456,8 +456,9 @@ impl UfDecoder { let root = component[di]; if comp_size[root] == 1 { - // Isolated defect: match to boundary. - obs_mask ^= self.predecode_single(defect_list[di]); + // Isolated defect: provably optimal only if its lightest edge + // is a direct boundary edge; otherwise fall through (`?`). + obs_mask ^= self.predecode_single(defect_list[di])?; handled[di] = true; } else if comp_size[root] == 2 { // Find the other defect in this component. @@ -473,61 +474,56 @@ impl UfDecoder { let d0 = defect_list[di]; let d1 = defect_list[ni]; - // Find lightest direct edge and lightest boundary alternatives. - let mut direct_w = f64::INFINITY; - let mut direct_obs = 0u64; - for &(e, nbr) in self.adj(d0 as usize) { - if nbr == d1 && self.edges[e].weight < direct_w { - direct_w = self.edges[e].weight; - direct_obs = self.edges[e].obs_mask; + // Pairing the two defects directly is provably the global + // minimum-weight correction ONLY when the shared edge is the + // lightest incident edge of BOTH defects (a "mutually nearest" + // adjacent pair -- the signature of a single bulk fault): + // - any d0-d1 path costs >= each defect's lightest edge, so + // the direct edge is the cheapest pairing, and + // - any split to the boundary costs >= lb0 + lb1 = 2*direct_w + // > direct_w. + // In every other case the boundary route (possibly a + // logical-flipping bulk path) may be cheaper, so we fall through + // to the full decoder rather than guess from direct edges alone. + let e0 = self.adj(d0 as usize).first().copied(); + let e1 = self.adj(d1 as usize).first().copied(); + match (e0, e1) { + (Some((edge_idx, nbr0)), Some((_, nbr1))) if nbr0 == d1 && nbr1 == d0 => { + obs_mask ^= self.edges[edge_idx].obs_mask; + handled[di] = true; + handled[ni] = true; } + _ => return None, } - - let mut b0_w = f64::INFINITY; - let mut b0_obs = 0u64; - for &(e, nbr) in self.adj(d0 as usize) { - if nbr == boundary && self.edges[e].weight < b0_w { - b0_w = self.edges[e].weight; - b0_obs = self.edges[e].obs_mask; - } - } - - let mut b1_w = f64::INFINITY; - let mut b1_obs = 0u64; - for &(e, nbr) in self.adj(d1 as usize) { - if nbr == boundary && self.edges[e].weight < b1_w { - b1_w = self.edges[e].weight; - b1_obs = self.edges[e].obs_mask; - } - } - - // Pick min-weight correction. - if direct_w <= b0_w + b1_w { - obs_mask ^= direct_obs; - } else { - obs_mask ^= b0_obs ^ b1_obs; - } - - handled[di] = true; - handled[ni] = true; } } Some(obs_mask) } - /// Predecode: single defect matches to boundary. - fn predecode_single(&self, defect: u32) -> u64 { + /// Predecode an isolated single defect, if it is provably optimal. + /// + /// The optimal correction for an isolated defect is the minimum-weight path + /// to the boundary, whose weight is at least that of the defect's lightest + /// incident edge (adjacency is sorted by weight, so that is `adj[0]`). The + /// predecoder can therefore resolve it cheaply ONLY when the lightest edge + /// goes directly to the boundary -- then that single edge IS the optimal + /// path. Otherwise the optimal path routes through the bulk (and may flip an + /// observable that a direct boundary edge would miss), so we return `None` + /// and fall through to the full decoder. + /// + /// (Returning a direct boundary edge that is not the lightest -- or `0` when + /// no direct boundary edge exists -- was the historical bug that broke + /// distance suppression: e.g. a bulk defect whose min-weight correction is a + /// logical-flipping path was silently decoded as no-flip.) + fn predecode_single(&self, defect: u32) -> Option { let boundary = self.num_detectors as u32; - // Find the lightest boundary edge from this defect. - // Adjacency is sorted by weight, so iterate and pick first boundary edge. - for &(edge_idx, neighbor) in self.adj(defect as usize) { - if neighbor == boundary { - return self.edges[edge_idx].obs_mask; - } + let &(edge_idx, neighbor) = self.adj(defect as usize).first()?; + if neighbor == boundary { + Some(self.edges[edge_idx].obs_mask) + } else { + None } - // No boundary edge found (shouldn't happen for valid surface codes). - 0 } /// Returns true if a cluster (given by its root) still needs to grow. @@ -959,6 +955,29 @@ impl UfDecoder { self.edges.get(edge_idx).map_or(0, |e| e.obs_mask) } + /// Debug: decode forcing the full grow+peel path (bypassing the + /// predecoder), returning the observable mask and the correction edges as + /// `(node1, node2, obs_mask, weight)`. For diagnostics and tests. + pub fn decode_full_with_correction(&mut self, syndrome: &[u8]) -> (u64, Vec<(u32, u32, u64, f64)>) { + self.reset(); + for (i, &v) in syndrome.iter().enumerate() { + if v != 0 && i < self.num_detectors { + self.parity[i] = true; + self.is_defect[i] = true; + } + } + self.grow_clusters(); + let (obs, edge_idxs) = self.peel_correction_with_edges(); + let edges = edge_idxs + .iter() + .map(|&i| { + let e = &self.edges[i]; + (e.node1, e.node2, e.obs_mask, e.weight) + }) + .collect(); + (obs, edges) + } + /// Get node1 of an edge. #[must_use] pub fn edge_node1(&self, edge_idx: usize) -> u32 { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index ef0ac6c0e..2ac86caae 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2768,6 +2768,30 @@ impl PySampleBatch { Ok(errors) } + /// Decode every shot and return the predicted observable mask per shot. + /// + /// Mirrors `decode_count` but returns the raw per-shot predictions instead + /// of an aggregate error count, so callers can localize disagreements + /// against a reference decoder. + /// + /// Args: + /// dem: DEM string for the decoder. + /// `decoder_type`: Decoder type string. + /// + /// Returns: + /// List of predicted observable masks, one per shot. + #[pyo3(signature = (dem, decoder_type="pymatching"))] + fn decode_each(&self, dem: &str, decoder_type: &str) -> PyResult> { + let mut decoder = create_observable_decoder(dem, decoder_type)?; + let mut predictions = Vec::with_capacity(self.num_shots); + let mut syndrome = vec![0u8; self.num_detectors]; + for i in 0..self.num_shots { + self.extract_syndrome(i, &mut syndrome); + predictions.push(decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX)); + } + Ok(predictions) + } + /// Parallel decode: distributes samples across rayon workers. /// /// Each worker creates its own decoder instance. Faster for slow decoders. @@ -4308,6 +4332,46 @@ fn assert_dems_equivalent( } } +// ============================================================================= +// UF debug wrapper (diagnostics only) +// ============================================================================= + +/// Debug wrapper over the matching-graph Union-Find decoder. +/// +/// Exposes the full grow+peel correction (bypassing the predecoder) so tests +/// can inspect exactly which edges UF commits and compare against MWPM. +#[pyclass(name = "_UfDebug", module = "pecos_rslib.qec")] +pub struct PyUfDebug { + inner: pecos_decoders::UfDecoder, +} + +#[pymethods] +impl PyUfDebug { + #[new] + #[pyo3(signature = (dem, config="fast"))] + fn new(dem: &str, config: &str) -> PyResult { + let cfg = match config { + "fast" => pecos_decoders::UfDecoderConfig::fast(), + "balanced" => pecos_decoders::UfDecoderConfig::balanced(), + "windowed" => pecos_decoders::UfDecoderConfig::windowed(), + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "unknown UF config: {other}" + ))); + } + }; + let inner = pecos_decoders::UfDecoder::from_dem(dem, cfg) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Self { inner }) + } + + /// Decode forcing the full grow+peel path (no predecoder). Returns + /// `(obs_mask, [(node1, node2, obs_mask, weight), ...])`. + fn decode_full(&mut self, syndrome: Vec) -> (u64, Vec<(u32, u32, u64, f64)>) { + self.inner.decode_full_with_correction(&syndrome) + } +} + // ============================================================================= // CSS UF Decoder (UIUF) // ============================================================================= @@ -4435,16 +4499,17 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `fusion_blossom_serial` (exact MWPM): it achieves - // distance suppression (LER drops with code distance), matching lomatching - // exactly (memory d=7 -> 0 logical errors). The native union-find inners - // (`pecos_uf:fast`, `pecos_uf:bp`) decode WELL at d=3 but DO NOT suppress at - // d>=5 -- a bug in the union-find decoder, not the subgraph construction - // (region + edge topology are correct, == lomatching). Correctness is - // non-negotiable here, so the default must be an exact MWPM inner. See + // Default inner is `pecos_uf:bp` (native belief-propagation + union-find): + // dependency-free, fast, and it achieves distance suppression (LER drops with + // code distance), tracking exact MWPM closely. The native UF previously did + // NOT suppress at d>=5, so the default was temporarily exact MWPM + // (`fusion_blossom_serial`); that UF bug -- a predecoder that mis-decoded + // isolated defects whose min-weight correction is a bulk path to the boundary + // -- has been fixed (predecoder now falls through to the full grow+peel + // decoder unless provably optimal). See // pecos-docs/design/logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="fusion_blossom_serial", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:bp", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, @@ -4495,7 +4560,7 @@ impl PyLogicalSubgraphDecoder { /// construction (e.g. the paper's back-propagation / detecting-region set) /// and decode with the same machinery for direct comparison. #[staticmethod] - #[pyo3(signature = (dem, membership, inner_decoder="fusion_blossom_serial"))] + #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:bp"))] fn from_membership( dem: &str, membership: Vec>, @@ -5606,6 +5671,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; + qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index 11c26744f..614aba204 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -34,7 +34,11 @@ import pytest from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch -from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem +from pecos_rslib.qec import ( + LogicalSubgraphDecoder, + ParsedDem, + WindowedLogicalSubgraphDecoder, +) def _coflip_membership_from_dem(dem_str: str, num_observables: int) -> list[list[int]]: @@ -226,9 +230,9 @@ def _mem_ler(d, p, n, seed, inner=None): def test_distance_suppression_memory(): """A fault-tolerant decoder must drive LER DOWN as code distance grows below - threshold. The default inner (exact MWPM, fusion_blossom) suppresses, - matching lomatching (d=7 -> 0). Guards against the default reverting to a - non-suppressing inner.""" + threshold. The default inner (`pecos_uf:bp`, native BP + union-find) + suppresses, tracking exact MWPM / lomatching (d=7 -> 0). Guards against the + default reverting to a non-suppressing inner.""" p, n = 0.001, 60000 ler_d3 = _mem_ler(3, p, n, seed=1) ler_d5 = _mem_ler(5, p, n, seed=1) @@ -238,21 +242,71 @@ def test_distance_suppression_memory(): ) -def test_native_union_find_inner_does_not_suppress(): - """KNOWN BUG (separately tracked): PECOS's native union-find inner decoders - (`pecos_uf:*`) decode well at d=3 but do NOT achieve distance suppression at - d>=5 -- which is why the LogicalSubgraphDecoder default is exact MWPM, not - `pecos_uf`. The subgraph region + edge topology are correct (== lomatching); - the failure is in the UF decoder itself. This pins the bug; when UF is fixed - (d=5 LER < d=3), this assertion flips and the test should be updated. +def test_native_union_find_inner_suppresses(): + """PECOS's native union-find inner decoders (`pecos_uf:*`) achieve distance + suppression, as a fault-tolerant decoder must. + + This previously did NOT hold: the UF predecoder mis-decoded isolated defects + whose minimum-weight correction is a bulk *path* to the boundary (it only + looked at direct boundary edges, returning a no-flip / wrong-edge result), + which broke suppression at d>=5. Fixed by making the predecoder fall through + to the full grow+peel decoder whenever its shortcut is not provably optimal + (see `predecode_single` / size-2 handling in pecos-uf-decoder). The full + decoder finds the logical-flipping path and suppresses. See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" p, n = 0.001, 60000 uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") - # Currently the UF inner does NOT suppress: d=5 is not meaningfully below d=3. - assert uf_d5 >= uf_d3 * 0.7, ( - f"union-find inner now suppresses (d3={uf_d3:.5f} d5={uf_d5:.5f}) -- " - "UF bug appears fixed; update this test and reconsider the default inner." + # Below threshold, d=5 must beat d=3 by a clear margin. + assert uf_d5 < uf_d3 * 0.7, ( + f"union-find inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" + ) + + +def _windowed_mem_ler(d, rounds, p, n, seed, step, buffer): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + dec = WindowedLogicalSubgraphDecoder(dem, sc, "pymatching", step, buffer) + return dec.decode_count(batch) / n, dec.num_windows() + + +def test_windowed_logical_subgraph_does_not_suppress(): + """KNOWN BUG (separately tracked): the windowed logical-subgraph decoder + does NOT achieve distance suppression -- it ANTI-suppresses (LER grows with + d) and is ~40-100x worse than the non-windowed decoder on the same DEM and + same exact-MWPM inner. + + Root cause is a windowing-correctness bug, not the inner decoder: each window + independently decodes and XORs its *full-window* observable correction, with + NO core-commit (the ``_is_core`` field in windowed.rs is computed but never + used) and no handling for error chains that cross a window boundary. A + measurement-error chain spanning a boundary becomes two separate boundary + defects -- each window pairs it to its own boundary and spuriously flips the + observable. Overlap (``buffer>0``) makes it worse, not better, because + overlap-region corrections are XORed twice. + + The proper fix is a sliding-window core-commit scheme (decode with buffer + context, commit only core-region corrections), plus the time-like-snake + handling from arXiv:2505.13599 (synchronized resets / shortcut edges). Until + then, use the non-windowed ``LogicalSubgraphDecoder``. This pins the bug; + when windowing is fixed (windowed LER suppresses with d), this assertion + flips and the test should be updated. + See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" + p, n, rounds, step = 0.001, 80000, 24, 4 + ler_d3, nwin = _windowed_mem_ler(3, rounds, p, n, seed=1, step=step, buffer=0) + ler_d5, _ = _windowed_mem_ler(5, rounds, p, n, seed=1, step=step, buffer=0) + # Sanity: the circuit is deep enough to actually exercise windowing. + assert nwin > 1, f"probe degenerated to a single window (nwin={nwin})" + # Currently windowing does NOT suppress: d=5 is no better than d=3 (in fact + # worse). When this flips, the windowed path has been fixed. + assert ler_d5 >= ler_d3 * 0.7, ( + f"windowed logical-subgraph now suppresses (d3={ler_d3:.5f} " + f"d5={ler_d5:.5f}) -- windowing bug appears fixed; update this test." ) From ff49e49e4a9827326cf1f3feba3677591ceb4c78 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 20:13:46 -0600 Subject: [PATCH 047/388] Fix Linux wheel policy --- .github/workflows/python-release.yml | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 41d5c06ef..a8f7d61eb 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -90,16 +90,14 @@ jobs: fail-fast: false matrix: include: - # Linux x86_64 with CUDA support (uses GCC Toolset 13 for CUDA compatibility) + # Linux x86_64 with CUDA support. Use the manylinux 2.34 image's + # default GCC 11 toolchain: CUDA 12.6 supports GCC 6.x-13.2, while + # GCC 13 emits libstdc++ symbols newer than auditwheel's + # manylinux_2_35 policy allows. - os: ubuntu-latest architecture: x86_64 cibw_archs: x86_64 install_cuda: true - # GCC Toolset 13 paths for CUDA compatibility - gcc_path_prefix: "/opt/rh/gcc-toolset-13/root/usr/bin:" - gcc_ld_path: "/opt/rh/gcc-toolset-13/root/usr/lib64:/opt/rh/gcc-toolset-13/root/usr/lib:" - gcc_cc: "/opt/rh/gcc-toolset-13/root/usr/bin/gcc" - gcc_cxx: "/opt/rh/gcc-toolset-13/root/usr/bin/g++" # Linux aarch64 - temporarily disabled while the LLVM 21.1 # manylinux_2_34 bootstrap is validated on the primary x86_64 lane. # - os: ubuntu-24.04-arm @@ -209,10 +207,9 @@ jobs: CIBW_ARCHS_LINUX: ${{ matrix.cibw_archs }} CIBW_MANYLINUX_X86_64_IMAGE: "manylinux_2_34" CIBW_MANYLINUX_AARCH64_IMAGE: "manylinux_2_34" - # Linux configuration - GCC Toolset and CUDA paths are conditional via matrix variables + # Linux configuration - CUDA paths are conditional via matrix variables CIBW_ENVIRONMENT_LINUX: > - PATH=${{ matrix.gcc_path_prefix }}$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH - LD_LIBRARY_PATH=${{ matrix.gcc_ld_path }}$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH + PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin:/usr/local/cuda-12.6/bin:$PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin/cmake @@ -224,9 +221,6 @@ jobs: dnf install -y bzip2 libffi-devel xz # Install CUDA Toolkit for GPU support on x86_64 (compile-time only, no GPU needed) if [ "${{ matrix.install_cuda }}" = "true" ]; then - echo "Installing GCC 13 (required for CUDA 12.6 compatibility)..." - dnf install -y gcc-toolset-13 - source /opt/rh/gcc-toolset-13/enable echo "Installing CUDA Toolkit from NVIDIA repos..." . /etc/os-release CUDA_RHEL_MAJOR="${VERSION_ID%%.*}" @@ -244,7 +238,7 @@ jobs: cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" cargo run --locked --release -p pecos-cli -- install cmake --force CIBW_REPAIR_WHEEL_COMMAND_LINUX: > - auditwheel repair -w {dest_dir} {wheel} && + auditwheel repair --plat manylinux_2_35_${{ matrix.cibw_archs }} -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} # macOS configuration CIBW_ENVIRONMENT_MACOS: > @@ -328,7 +322,7 @@ jobs: bash scripts/ci/install-llvm-21-conda-linux.sh cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" CIBW_REPAIR_WHEEL_COMMAND_LINUX: > - auditwheel repair -w {dest_dir} {wheel} && + auditwheel repair --plat manylinux_2_35_${{ matrix.cibw_archs }} -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_ENVIRONMENT_MACOS: > PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH From 4b42fc81ed31c22e7799dba6eef1827f1559d234 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 20:44:10 -0600 Subject: [PATCH 048/388] Fix macOS wheel deployment target --- .github/workflows/python-release.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index a8f7d61eb..468d2c551 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -7,6 +7,7 @@ env: TRIGGER_ON_PR_PUSH: true # Set to true to enable triggers on PR pushes LLVM_VERSION: "21.1" LLVM_RELEASE_VERSION: "21.1.8" + MACOS_WHEEL_DEPLOYMENT_TARGET: "15.0" # 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. @@ -246,7 +247,7 @@ jobs: LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/CMake.app/Contents/bin/cmake - MACOSX_DEPLOYMENT_TARGET=13.2 + MACOSX_DEPLOYMENT_TARGET=${{ env.MACOS_WHEEL_DEPLOYMENT_TARGET }} SDKROOT=$(xcrun --show-sdk-path) MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_MACOS: | @@ -328,7 +329,7 @@ jobs: PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-21.1/bin:$PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib - MACOSX_DEPLOYMENT_TARGET=13.2 + MACOSX_DEPLOYMENT_TARGET=${{ env.MACOS_WHEEL_DEPLOYMENT_TARGET }} SDKROOT=$(xcrun --show-sdk-path) CIBW_BEFORE_ALL_MACOS: | if ! command -v cargo >/dev/null 2>&1; then From 2706d1fc46e617828a805e2929a52800561152e5 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 3 Jun 2026 21:37:15 -0600 Subject: [PATCH 049/388] Fix Linux wheel compiler --- .github/workflows/python-release.yml | 35 ++++++++++++++++++++++------ 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 468d2c551..eb50d8553 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -91,10 +91,10 @@ jobs: fail-fast: false matrix: include: - # Linux x86_64 with CUDA support. Use the manylinux 2.34 image's - # default GCC 11 toolchain: CUDA 12.6 supports GCC 6.x-13.2, while - # GCC 13 emits libstdc++ symbols newer than auditwheel's - # manylinux_2_35 policy allows. + # Linux x86_64 with CUDA support. The manylinux 2.34 image's default + # compiler is GCC 14, which emits libstdc++ symbols newer than + # auditwheel's manylinux_2_35 policy allows. Install and pin the + # AlmaLinux system GCC 11 toolchain in the Linux build environment. - os: ubuntu-latest architecture: x86_64 cibw_archs: x86_64 @@ -215,11 +215,21 @@ jobs: LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib CMAKE=$HOME/.pecos/deps/cmake-${{ env.PECOS_CMAKE_VERSION }}/bin/cmake CUDA_PATH=/usr/local/cuda-12.6 + CC=/usr/bin/gcc + CXX=/usr/bin/g++ + CUDAHOSTCXX=/usr/bin/g++ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH - dnf install -y bzip2 libffi-devel xz + dnf install -y bzip2 gcc gcc-c++ libffi-devel xz + export CC=/usr/bin/gcc + export CXX=/usr/bin/g++ + export CUDAHOSTCXX=/usr/bin/g++ + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc # Install CUDA Toolkit for GPU support on x86_64 (compile-time only, no GPU needed) if [ "${{ matrix.install_cuda }}" = "true" ]; then echo "Installing CUDA Toolkit from NVIDIA repos..." @@ -231,10 +241,11 @@ jobs: export PATH=$CUDA_PATH/bin:$PATH echo "CUDA installed at $CUDA_PATH" nvcc --version - gcc --version else echo "Skipping CUDA installation (GPU support not enabled for this build)" fi + "$CC" --version + "$CXX" --version bash scripts/ci/install-llvm-21-conda-linux.sh cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" cargo run --locked --release -p pecos-cli -- install cmake --force @@ -316,10 +327,20 @@ jobs: LD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH LLVM_SYS_211_PREFIX=$HOME/.pecos/deps/llvm-21.1 LIBCLANG_PATH=$HOME/.pecos/deps/llvm-21.1/lib + CC=/usr/bin/gcc + CXX=/usr/bin/g++ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc CIBW_BEFORE_ALL_LINUX: | bash scripts/ci/ensure-rust.sh stable minimal export PATH=$HOME/.cargo/bin:$PATH - dnf install -y bzip2 libffi-devel xz + dnf install -y bzip2 gcc gcc-c++ libffi-devel xz + export CC=/usr/bin/gcc + export CXX=/usr/bin/g++ + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + export CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/gcc + "$CC" --version + "$CXX" --version bash scripts/ci/install-llvm-21-conda-linux.sh cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" CIBW_REPAIR_WHEEL_COMMAND_LINUX: > From bcacb19bb021caad4f4c350928ccc90b5fff353e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 4 Jun 2026 06:37:37 -0600 Subject: [PATCH 050/388] Fix Linux wheel repair path --- .github/workflows/python-release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index eb50d8553..bc91d1686 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -250,6 +250,7 @@ jobs: cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" cargo run --locked --release -p pecos-cli -- install cmake --force CIBW_REPAIR_WHEEL_COMMAND_LINUX: > + LD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH auditwheel repair --plat manylinux_2_35_${{ matrix.cibw_archs }} -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} # macOS configuration @@ -344,6 +345,7 @@ jobs: bash scripts/ci/install-llvm-21-conda-linux.sh cargo run --locked --release -p pecos-cli -- llvm configure "$HOME/.pecos/deps/llvm-21.1" CIBW_REPAIR_WHEEL_COMMAND_LINUX: > + LD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib:$LD_LIBRARY_PATH auditwheel repair --plat manylinux_2_35_${{ matrix.cibw_archs }} -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_ENVIRONMENT_MACOS: > From 54456c296746f701ab3f494a847d279eca587fed Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 4 Jun 2026 06:52:10 -0600 Subject: [PATCH 051/388] Rewrite windowed logical-subgraph decoder to correct sliding-window core-commit (per-observable subgraph wrapped in OverlappingWindowedDecoder), delete the broken naive-XOR decoder-core version, and pin single-window-correctness + the known windowed-LOM limitation --- .../src/logical_subgraph.rs | 1 - .../src/logical_subgraph/windowed.rs | 294 ------------------ crates/pecos-decoders/src/lib.rs | 1 + crates/pecos-uf-decoder/src/lib.rs | 2 + .../src/logical_subgraph_windowed.rs | 179 +++++++++++ .../src/fault_tolerance_bindings.rs | 39 +-- ...test_logical_subgraph_region_comparison.py | 134 +++++--- 7 files changed, 295 insertions(+), 355 deletions(-) delete mode 100644 crates/pecos-decoder-core/src/logical_subgraph/windowed.rs create mode 100644 crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 47b28d026..0d074b7a8 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -50,7 +50,6 @@ //! - This preserves the graphlike property of each subgraph pub mod committed; -pub mod windowed; use std::collections::{BTreeMap, BTreeSet}; diff --git a/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs b/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs deleted file mode 100644 index 2bc561611..000000000 --- a/crates/pecos-decoder-core/src/logical_subgraph/windowed.rs +++ /dev/null @@ -1,294 +0,0 @@ -// Copyright 2026 The PECOS Developers -// -// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except -// in compliance with the License. You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software distributed under the License -// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express -// or implied. See the License for the specific language governing permissions and limitations under -// the License. - -//! Windowed observable subgraph decoder. -//! -//! Splits a DEM into time windows, runs per-logical-operator subgraph decoding -//! within each window. This prevents the observing region from spanning -//! the full circuit at deep depths, maintaining decoding accuracy. -//! -//! Window types: -//! - **Non-overlapping**: each detector belongs to exactly one window -//! - **Overlapping**: buffer zones extend beyond the core for matching context -//! -//! The observable correction from each window is XOR'd together. - -use std::collections::BTreeMap; - -use crate::ObservableDecoder; -use crate::dem::{DemCheckMatrix, DemMatchingGraph, MatchingEdge, parse_detector_coords}; -use crate::errors::DecoderError; -use crate::logical_subgraph::{LogicalSubgraphDecoder, StabCoords}; - -/// Configuration for windowed logical-subgraph decoder. -#[derive(Debug, Clone)] -pub struct WindowedLogicalSubgraphConfig { - /// Core window size in time steps. - pub step: usize, - /// Buffer size on each side (0 = non-overlapping). - pub buffer: usize, -} - -impl Default for WindowedLogicalSubgraphConfig { - fn default() -> Self { - Self { step: 8, buffer: 4 } - } -} - -/// A single time window with its own logical-subgraph decoder. -pub struct LogicalSubgraphWindow { - decoder: LogicalSubgraphDecoder, - /// Maps local detector index → global detector index. - local_to_global: Vec, - num_local: usize, - /// Which local detectors are in the core (vs buffer). - _is_core: Vec, -} - -/// Windowed observable subgraph decoder. -/// -/// Splits the DEM into time windows, each decoded with its own logical-subgraph decoder. -/// The observing region within each window is naturally bounded, -/// preventing the scaling degradation seen at deep circuits. -pub struct WindowedLogicalSubgraphDecoder { - pub windows: Vec, - _num_detectors: usize, - /// Reusable window syndrome buffer - window_syn: Vec, -} - -impl WindowedLogicalSubgraphDecoder { - /// Build from a DEM string with time-based windowing. - /// - /// # Errors - /// - /// Returns error if the DEM is malformed. - pub fn from_dem( - dem: &str, - stab_coords: &StabCoords, - config: &WindowedLogicalSubgraphConfig, - mut inner_factory: F, - ) -> Result - where - F: FnMut( - &DemMatchingGraph, - ) -> Result, DecoderError>, - { - // Parse detector coordinates to get time values - let coords = parse_detector_coords(dem); - let mut det_time: BTreeMap = BTreeMap::new(); - for dc in &coords { - if let Some(t) = dc.coords.last() { - det_time.insert(dc.id as usize, *t); - } - } - - let dcm = DemCheckMatrix::from_dem_str(dem) - .map_err(|e| DecoderError::InvalidGraph(e.to_string()))?; - let num_detectors = dcm.num_detectors; - - // Find time range - let min_t = det_time.values().copied().fold(f64::INFINITY, f64::min); - let max_t = det_time.values().copied().fold(f64::NEG_INFINITY, f64::max); - - if max_t <= min_t { - // Single time step or empty — just use full logical-subgraph decoder - let full_osd = - LogicalSubgraphDecoder::from_dem(dem, stab_coords, &mut inner_factory)?; - return Ok(Self { - windows: vec![LogicalSubgraphWindow { - decoder: full_osd, - local_to_global: (0..num_detectors).collect(), - num_local: num_detectors, - _is_core: vec![true; num_detectors], - }], - _num_detectors: num_detectors, - window_syn: vec![0u8; num_detectors], - }); - } - - let step = config.step as f64; - let buffer = config.buffer as f64; - let mut windows = Vec::new(); - let mut t_start = min_t; - let mut max_local = 0; - - while t_start <= max_t { - let core_end = (t_start + step).min(max_t + 1.0); - let win_start = (t_start - buffer).max(min_t); - let win_end = (core_end + buffer).min(max_t + 1.0); - - // Detectors in this window - let mut local_to_global = Vec::new(); - let mut is_core = Vec::new(); - - for d in 0..num_detectors { - if let Some(&t) = det_time.get(&d) - && t >= win_start - && t < win_end - { - local_to_global.push(d); - is_core.push(t >= t_start && t < core_end); - } - } - - if local_to_global.is_empty() { - t_start += step; - continue; - } - - let num_local = local_to_global.len(); - if num_local > max_local { - max_local = num_local; - } - - // Build sub-DEM for this window - let mut inverse = vec![None; num_detectors]; - for (local, &global) in local_to_global.iter().enumerate() { - inverse[global] = Some(local); - } - - let mut edges = Vec::new(); - let mut skipped = 0; - - for m in 0..dcm.num_mechanisms { - let p = dcm.error_priors[m]; - if p <= 0.0 { - continue; - } - - let sub_dets: Vec = (0..dcm.num_detectors) - .filter(|&d| dcm.check_matrix[[d, m]] != 0) - .filter_map(|d| inverse[d].map(|s| s as u32)) - .collect(); - - if sub_dets.is_empty() { - continue; - } - - let weight = if p < 1.0 { ((1.0 - p) / p).ln() } else { 0.0 }; - - // Observable: include if ANY observable is flipped - let mut observables = Vec::new(); - for o in 0..dcm.num_observables { - if dcm.observable_matrix[[o, m]] != 0 { - observables.push(o as u32); - } - } - - match sub_dets.len() { - 1 => edges.push(MatchingEdge { - node1: sub_dets[0], - node2: None, - weight, - observables, - probability: p, - fault_id: m, - }), - 2 => edges.push(MatchingEdge { - node1: sub_dets[0], - node2: Some(sub_dets[1]), - weight, - observables, - probability: p, - fault_id: m, - }), - _ => skipped += 1, - } - } - - let edges = DemMatchingGraph::merge_parallel_edges(edges); - let sub_graph = DemMatchingGraph { - edges, - num_detectors: num_local, - num_observables: dcm.num_observables, - skipped_hyperedges: skipped, - detector_coords: Vec::new(), - }; - - // Build sub-DEM string with detector coordinate declarations. - // The logical-subgraph decoder needs these to classify detectors by (qubit, stab_type). - let mut sub_dem_lines = Vec::new(); - for (local_id, &global_id) in local_to_global.iter().enumerate() { - // Find this detector's coordinates from the parsed coords - if let Some(dc) = coords.iter().find(|dc| dc.id as usize == global_id) { - let coord_str: Vec = dc.coords.iter().map(|c| format!("{c}")).collect(); - sub_dem_lines.push(format!("detector({}) D{local_id}", coord_str.join(", "))); - } - } - sub_dem_lines.push(graph_to_dem_string(&sub_graph)); - let sub_dem = sub_dem_lines.join("\n"); - - // Build logical-subgraph decoder for this window using the sub-DEM - let window_osd = - LogicalSubgraphDecoder::from_dem(&sub_dem, stab_coords, &mut inner_factory)?; - - windows.push(LogicalSubgraphWindow { - decoder: window_osd, - local_to_global, - num_local, - _is_core: is_core, - }); - - t_start += step; - } - - Ok(Self { - windows, - _num_detectors: num_detectors, - window_syn: vec![0u8; max_local], - }) - } -} - -impl ObservableDecoder for WindowedLogicalSubgraphDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = 0u64; - - for window in &mut self.windows { - // Extract window syndrome - let n = window.num_local; - for (local, &global) in window.local_to_global.iter().enumerate() { - self.window_syn[local] = if global < syndrome.len() { - syndrome[global] - } else { - 0 - }; - } - - // Decode this window - let window_obs = window - .decoder - .decode_to_observables(&self.window_syn[..n])?; - obs_mask ^= window_obs; - } - - Ok(obs_mask) - } -} - -fn graph_to_dem_string(graph: &DemMatchingGraph) -> String { - let mut lines = Vec::new(); - for edge in &graph.edges { - let p = edge.probability; - let mut targets = Vec::new(); - targets.push(format!("D{}", edge.node1)); - if let Some(n2) = edge.node2 { - targets.push(format!("D{n2}")); - } - for &obs in &edge.observables { - targets.push(format!("L{obs}")); - } - lines.push(format!("error({p}) {}", targets.join(" "))); - } - lines.join("\n") -} diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 02a9797c9..1b7ad8b46 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -104,6 +104,7 @@ pub use pecos_uf_decoder::{ BpSchedule as UfBpSchedule, BpUfConfig, BpUfDecoder, CssUfDecoder, OverlappingWindowedDecoder, QubitEdgeMapping, SandwichWindowedDecoder, StreamingWindowedDecoder, UfDecoder, UfDecoderConfig, WindowedConfig, WindowedDecoder, + WindowedLogicalSubgraphDecoder, }; // Re-export Relay BP decoder when feature is enabled diff --git a/crates/pecos-uf-decoder/src/lib.rs b/crates/pecos-uf-decoder/src/lib.rs index 180bccf56..754be0c39 100644 --- a/crates/pecos-uf-decoder/src/lib.rs +++ b/crates/pecos-uf-decoder/src/lib.rs @@ -33,6 +33,7 @@ pub mod astar; pub mod bp_uf; pub mod css_decoder; pub mod decoder; +pub mod logical_subgraph_windowed; pub mod mini_bp; pub mod windowed; @@ -45,6 +46,7 @@ pub use astar::{AStarConfig, AStarDecoder}; pub use bp_uf::{BpSchedule, BpUfConfig, BpUfDecoder}; pub use css_decoder::{CssUfDecoder, QubitEdgeMapping}; pub use decoder::{UfDecoder, UfDecoderConfig}; +pub use logical_subgraph_windowed::WindowedLogicalSubgraphDecoder; pub use windowed::{ BeamSearchConfig, BeamSearchWindowedDecoder, OverlappingWindowedDecoder, SandwichWindowedDecoder, StreamingWindowedDecoder, WindowedConfig, WindowedDecoder, diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs new file mode 100644 index 000000000..ea88ed3c0 --- /dev/null +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -0,0 +1,179 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Windowed logical-subgraph decoder with correct sliding-window core-commit. +//! +//! The logical-subgraph decoder partitions a DEM per logical observable and +//! decodes each observable's subgraph independently (the coordinate observing +//! regions of Serra-Peralta et al., arXiv:2505.13599 / the `lomatching` +//! package). For deep circuits an observing region would span the whole circuit, +//! so we additionally window each subgraph in time. +//! +//! **Nesting: subgraph -> window.** Each per-observable subgraph is a clean +//! graphlike matching graph, so we wrap it in an +//! [`OverlappingWindowedDecoder`], which performs proper sliding-window +//! decoding: every window is decoded with a buffer for matching context, but +//! only correction edges whose BOTH endpoints lie in the window core are +//! committed (Tan et al., arXiv:2209.09219). The per-observable committed +//! observable flips are XORed. +//! +//! An earlier implementation windowed the full DEM first and then ran a subgraph +//! decoder per window, combining by a naive full-window observable XOR with no +//! core-commit. That double-counted error chains crossing a window boundary and +//! *anti-suppressed* (LER grew with code distance). The correct nesting here +//! reuses the tested core-commit machinery instead. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_decoder_core::errors::DecoderError; +use pecos_decoder_core::logical_subgraph::{ + LogicalSubgraph, MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, +}; +use std::fmt::Write as _; + +use crate::decoder::{UfDecoder, UfDecoderConfig}; +use crate::windowed::{OverlappingWindowedDecoder, WindowedConfig}; + +/// One per-observable subgraph, windowed with sliding-window core-commit. +struct SubgraphWindowed { + /// Which full-DEM observable this subgraph decodes (the global bit index). + observable_idx: usize, + /// Subgraph-local detector index -> full-DEM detector index. + detector_map: Vec, + /// Number of subgraph-local detectors. + num_local: usize, + /// The time-windowed decoder over this subgraph (returns local bit 0). + decoder: OverlappingWindowedDecoder, +} + +/// Windowed logical-subgraph decoder. +/// +/// Partitions the DEM per observable, then windows each subgraph with an +/// [`OverlappingWindowedDecoder`] (sliding-window core-commit). Per-observable +/// committed observable flips are XORed into the final mask. +pub struct WindowedLogicalSubgraphDecoder { + subgraphs: Vec, + /// Reusable subgraph-local syndrome buffer (sized to the largest subgraph). + local_syn: Vec, +} + +impl WindowedLogicalSubgraphDecoder { + /// Build from a full DEM string and stabilizer coordinates. + /// + /// `max_time_radius` controls the per-observable observing region (see + /// [`partition_dem_by_logical_windowed`]); pass `None` for the full region + /// (the windowing then bounds the time extent instead). + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed or a subgraph decoder + /// fails to build. + pub fn from_dem( + dem: &str, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, + window_config: WindowedConfig, + ) -> Result { + let parts = partition_dem_by_logical_windowed(dem, stab_coords, max_time_radius)?; + + // Subgraph graphs do not carry detector coordinates, but the time-based + // windowing needs them. Pull them from the full DEM and inject (mapped + // to subgraph-local indices) when serializing each sub-DEM. + let full_coords = DemMatchingGraph::from_dem_str(dem)?.detector_coords; + + let mut subgraphs = Vec::with_capacity(parts.len()); + let mut max_local = 0usize; + for part in parts { + if part.detector_map.is_empty() { + // Observable with an empty region never flips: contributes 0. + continue; + } + let sub_dem = subgraph_to_dem_string(&part, &full_coords); + let decoder = OverlappingWindowedDecoder::from_dem(&sub_dem, window_config, |wdem| { + UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) + })?; + max_local = max_local.max(part.graph.num_detectors); + subgraphs.push(SubgraphWindowed { + observable_idx: part.observable_idx, + detector_map: part.detector_map, + num_local: part.graph.num_detectors, + decoder, + }); + } + + Ok(Self { + subgraphs, + local_syn: vec![0u8; max_local], + }) + } + + /// Number of per-observable subgraphs that actually decode (non-empty). + #[must_use] + pub fn num_subgraphs(&self) -> usize { + self.subgraphs.len() + } + + /// Total number of windows across all subgraphs. + #[must_use] + pub fn num_windows(&self) -> usize { + self.subgraphs.iter().map(|s| s.decoder.num_windows()).sum() + } +} + +impl ObservableDecoder for WindowedLogicalSubgraphDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = 0u64; + for sg in &mut self.subgraphs { + let n = sg.num_local; + for (local, &global) in sg.detector_map.iter().enumerate() { + self.local_syn[local] = if global < syndrome.len() { + syndrome[global] + } else { + 0 + }; + } + // The subgraph decodes a single observable as its local bit 0; map + // that back to this observable's global bit. + let sub_obs = sg.decoder.decode_to_observables(&self.local_syn[..n])?; + if sub_obs & 1 != 0 { + obs_mask |= 1u64 << sg.observable_idx; + } + } + Ok(obs_mask) + } +} + +/// Serialize a subgraph back to a DEM string: detector coordinate lines (mapped +/// from the full DEM via `detector_map`) plus error lines. The coordinate lines +/// let [`OverlappingWindowedDecoder::from_dem`] derive per-detector times for +/// the windowing. +fn subgraph_to_dem_string(part: &LogicalSubgraph, full_coords: &[Option>]) -> String { + let mut s = String::new(); + for (local, &global) in part.detector_map.iter().enumerate() { + if let Some(Some(coords)) = full_coords.get(global) { + let coord_str: Vec = coords.iter().map(|c| format!("{c}")).collect(); + let _ = writeln!(s, "detector({}) D{local}", coord_str.join(", ")); + } + } + for edge in &part.graph.edges { + let _ = write!(s, "error({})", edge.probability); + let _ = write!(s, " D{}", edge.node1); + if let Some(n2) = edge.node2 { + let _ = write!(s, " D{n2}"); + } + for &obs in &edge.observables { + let _ = write!(s, " L{obs}"); + } + let _ = writeln!(s); + } + s +} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 2ac86caae..b66d4169c 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4483,13 +4483,14 @@ impl PyCssUfDecoder { /// dem: DEM string with detector coordinate declarations. /// `stab_coords`: List of dicts, one per logical qubit. Each dict has /// keys "X" and "Z" mapping to lists of (x, y) ancilla coordinates. -/// `inner_decoder`: Inner decoder type string (default "`pecos_uf:fast`"). +/// `inner_decoder`: Inner decoder type string (default "`pecos_uf:bp`", +/// native belief-propagation + union-find). /// /// Example: /// >>> decoder = `LogicalSubgraphDecoder`( /// ... `dem_str`, /// ... [{"X": [(1,0), (3,1)], "Z": [(0,3), (1,1)]}], -/// ... "`pecos_uf:fast`", +/// ... "`pecos_uf:bp`", /// ... ) /// >>> obs = decoder.decode(syndrome) #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] @@ -4823,30 +4824,33 @@ impl PyLogicalSubgraphDecoder { /// Splits the DEM into time windows, runs logical-subgraph decoder within each window. /// Prevents the observing region from spanning the full circuit. /// +/// Partitions the DEM per observable, then windows each subgraph with proper +/// sliding-window core-commit (only correction edges whose both endpoints lie +/// in a window's core are committed). The inner decoder is the native +/// edge-tracking union-find decoder, which core-commit requires. +/// /// Args: /// dem: DEM string. /// `stab_coords`: Stabilizer coordinates per logical qubit. -/// `inner_decoder`: Inner MWPM decoder type. /// step: Core window size in time steps. -/// buffer: Buffer size on each side (0 = non-overlapping). +/// buffer: Buffer size on each side for matching context (0 = +/// non-overlapping; recommend ~code distance). #[pyclass(name = "WindowedLogicalSubgraphDecoder", module = "pecos_rslib.qec")] pub struct PyWindowedLogicalSubgraphDecoder { - inner: pecos_decoder_core::logical_subgraph::windowed::WindowedLogicalSubgraphDecoder, + inner: pecos_decoders::WindowedLogicalSubgraphDecoder, } #[pymethods] impl PyWindowedLogicalSubgraphDecoder { #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pymatching", step=8, buffer=4))] + #[pyo3(signature = (dem, stab_coords, step=8, buffer=4))] fn new( dem: &str, stab_coords: Vec>, - inner_decoder: &str, step: usize, buffer: usize, ) -> PyResult { use pecos_decoder_core::logical_subgraph::QubitStabCoords; - use pecos_decoder_core::logical_subgraph::windowed::{WindowedLogicalSubgraphConfig, WindowedLogicalSubgraphDecoder}; let mut sc = Vec::with_capacity(stab_coords.len()); for dict in &stab_coords { @@ -4864,16 +4868,15 @@ impl PyWindowedLogicalSubgraphDecoder { }); } - let config = WindowedLogicalSubgraphConfig { step, buffer }; + let config = pecos_decoders::WindowedConfig { + step_size: step, + buffer_size: buffer, + ..Default::default() + }; - let inner = WindowedLogicalSubgraphDecoder::from_dem(dem, &sc, &config, |subgraph| { - let sub_dem = subgraph_to_dem_string(subgraph); - let d = create_observable_decoder(&sub_dem, inner_decoder) - .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; - Ok(Box::new(SendWrapper(d)) - as Box) - }) - .map_err(|e| PyErr::new::(e.to_string()))?; + let inner = + pecos_decoders::WindowedLogicalSubgraphDecoder::from_dem(dem, &sc, None, config) + .map_err(|e| PyErr::new::(e.to_string()))?; Ok(Self { inner }) } @@ -4903,7 +4906,7 @@ impl PyWindowedLogicalSubgraphDecoder { } fn num_windows(&self) -> usize { - self.inner.windows.len() + self.inner.num_windows() } } diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index 614aba204..1e5f7607d 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -242,24 +242,29 @@ def test_distance_suppression_memory(): ) -def test_native_union_find_inner_suppresses(): - """PECOS's native union-find inner decoders (`pecos_uf:*`) achieve distance - suppression, as a fault-tolerant decoder must. - - This previously did NOT hold: the UF predecoder mis-decoded isolated defects - whose minimum-weight correction is a bulk *path* to the boundary (it only - looked at direct boundary edges, returning a no-flip / wrong-edge result), - which broke suppression at d>=5. Fixed by making the predecoder fall through - to the full grow+peel decoder whenever its shortcut is not provably optimal - (see `predecode_single` / size-2 handling in pecos-uf-decoder). The full - decoder finds the logical-flipping path and suppresses. +def test_default_inner_bp_uf_suppresses(): + """The default native inner decoder (`pecos_uf:bp`, belief-propagation + + union-find) achieves distance suppression, as a fault-tolerant decoder must. + + Context: the UF predecoder used to mis-decode isolated defects whose + minimum-weight correction is a bulk *path* to the boundary (it only looked at + direct boundary edges, returning a no-flip / wrong-edge result). That was a + catastrophic single-defect bug; fixed by making the predecoder fall through to + the full grow+peel decoder unless its shortcut is provably optimal (see + `predecode_single` / size-2 handling in pecos-uf-decoder). + + NOTE: this validates the *default* inner `pecos_uf:bp`, which suppresses + robustly (tracks exact MWPM). Pure `pecos_uf:fast` (no belief propagation) was + also improved by the predecoder fix but its full grow+peel heuristic does NOT + robustly suppress at depth -- a separate, lesser weakness, which is why the + default is `pecos_uf:bp`, not `pecos_uf:fast`. See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" p, n = 0.001, 60000 uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") # Below threshold, d=5 must beat d=3 by a clear margin. assert uf_d5 < uf_d3 * 0.7, ( - f"union-find inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" + f"default bp+uf inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" ) @@ -271,42 +276,87 @@ def _windowed_mem_ler(d, rounds, p, n, seed, step, buffer): dem = b.build_dem(p1=p, p2=p, p_meas=p) sc = b.stab_coords() batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) - dec = WindowedLogicalSubgraphDecoder(dem, sc, "pymatching", step, buffer) + dec = WindowedLogicalSubgraphDecoder(dem, sc, step, buffer) return dec.decode_count(batch) / n, dec.num_windows() -def test_windowed_logical_subgraph_does_not_suppress(): - """KNOWN BUG (separately tracked): the windowed logical-subgraph decoder - does NOT achieve distance suppression -- it ANTI-suppresses (LER grows with - d) and is ~40-100x worse than the non-windowed decoder on the same DEM and - same exact-MWPM inner. - - Root cause is a windowing-correctness bug, not the inner decoder: each window - independently decodes and XORs its *full-window* observable correction, with - NO core-commit (the ``_is_core`` field in windowed.rs is computed but never - used) and no handling for error chains that cross a window boundary. A - measurement-error chain spanning a boundary becomes two separate boundary - defects -- each window pairs it to its own boundary and spuriously flips the - observable. Overlap (``buffer>0``) makes it worse, not better, because - overlap-region corrections are XORed twice. - - The proper fix is a sliding-window core-commit scheme (decode with buffer - context, commit only core-region corrections), plus the time-like-snake - handling from arXiv:2505.13599 (synchronized resets / shortcut edges). Until - then, use the non-windowed ``LogicalSubgraphDecoder``. This pins the bug; - when windowing is fixed (windowed LER suppresses with d), this assertion - flips and the test should be updated. +def _nonwindowed_mem_ler(d, rounds, p, n, seed, inner): + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + return LogicalSubgraphDecoder(dem, sc, inner).decode_count(batch) / n + + +def test_windowed_logical_subgraph_single_window_matches_nonwindowed(): + """Correctness guarantee for the windowed decoder's construction: with a + window larger than the circuit depth (a single window, all-core), the + windowed logical-subgraph decoder reproduces the non-windowed decoder with + the same native union-find inner. + + This pins that the per-observable subgraph serialization, the local<->global + detector mapping, and the local-bit-0 -> global-observable-bit remapping are + all correct -- independently of the time-windowing behaviour exercised + below.""" + p, n, rounds = 0.001, 40000, 18 + for d in (3, 5): + win, nwin = _windowed_mem_ler(d, rounds, p, n, seed=7, step=10_000, buffer=0) + non = _nonwindowed_mem_ler(d, rounds, p, n, seed=7, inner="pecos_uf:fast") + assert nwin == 1, f"expected a single window, got {nwin}" + # Same decode up to negligible tie-breaking differences. + assert abs(win - non) <= max(0.0005, 0.15 * non), ( + f"single-window windowed != non-windowed at d={d}: win={win:.5f} non={non:.5f}" + ) + + +def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): + """KNOWN LIMITATION (separately tracked): the windowed logical-subgraph + decoder does not yet achieve full distance suppression on memory. + + The decoder was rewritten to do proper sliding-window core-commit (each + per-observable subgraph is wrapped in an ``OverlappingWindowedDecoder``, which + commits only correction edges whose both endpoints lie in a window's core). + That removed the old double-counting bug -- a single window now reproduces the + non-windowed decoder exactly (see the test above), and multi-window LER is no + longer catastrophic (the old naive-XOR decoder anti-suppressed to ~10-25%). + + What remains is the *windowed logical-observable-matching* limitation + identified in Serra-Peralta et al. (arXiv:2505.13599, Sec. V): per-observable + windowing admits "time-like snake" error patterns that scale sublinearly in + d, so LER does not fully suppress without their additional machinery + (synchronized resets every Omega(d) and/or a two-step decoder with short-cut + edges). Standard *full-DEM* sliding-window decoding does not have this issue + (PECOS's ``windowed:`` decoder suppresses on a graphlike/Stim-decomposed DEM + -- it is not exercised here because the native PECOS DEM has undecomposed + hyperedges that the full-DEM windowed path's matching inner rejects). For a + single-observable memory prefer either the non-windowed + ``LogicalSubgraphDecoder`` or full-DEM windowing on a decomposed DEM. This + pins the limitation; implementing the anti-snake machinery is the remaining + work. See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" - p, n, rounds, step = 0.001, 80000, 24, 4 - ler_d3, nwin = _windowed_mem_ler(3, rounds, p, n, seed=1, step=step, buffer=0) - ler_d5, _ = _windowed_mem_ler(5, rounds, p, n, seed=1, step=step, buffer=0) - # Sanity: the circuit is deep enough to actually exercise windowing. + p, n, rounds = 0.001, 40000, 18 + ler_d3, nwin = _windowed_mem_ler(3, rounds, p, n, seed=7, step=3, buffer=3) + ler_d5, _ = _windowed_mem_ler(5, rounds, p, n, seed=7, step=5, buffer=5) + ler_d7, _ = _windowed_mem_ler(7, rounds, p, n, seed=7, step=7, buffer=7) + # The circuit is deep enough to actually exercise windowing. assert nwin > 1, f"probe degenerated to a single window (nwin={nwin})" - # Currently windowing does NOT suppress: d=5 is no better than d=3 (in fact - # worse). When this flips, the windowed path has been fixed. - assert ler_d5 >= ler_d3 * 0.7, ( + # Still does not fully suppress (the paper's windowed-LOM limitation): LER does + # not fall with distance (it in fact grows). When the anti-snake machinery + # lands and this suppresses, flip the assertions and update the test. + assert ler_d5 >= ler_d3 * 0.7 and ler_d7 >= ler_d5 * 0.7, ( f"windowed logical-subgraph now suppresses (d3={ler_d3:.5f} " - f"d5={ler_d5:.5f}) -- windowing bug appears fixed; update this test." + f"d5={ler_d5:.5f} d7={ler_d7:.5f}) -- anti-snake machinery appears to " + "have landed; update this test." + ) + # Guard against regressing to the old catastrophic anti-suppression (the + # naive-XOR decoder reached ~0.1-0.25 here); the core-commit rewrite keeps it + # well below that across distances. + assert max(ler_d3, ler_d5, ler_d7) < 0.1, ( + f"windowed LER regressed toward catastrophic: " + f"d3={ler_d3:.5f} d5={ler_d5:.5f} d7={ler_d7:.5f}" ) From 52da0483fdf6691d90a5fdbad42a678894bc2a37 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 4 Jun 2026 23:02:59 -0600 Subject: [PATCH 052/388] Fix Windows LLVM extraction --- scripts/ci/install-llvm-21-windows.ps1 | 61 +++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index ff7b981fc..29630d8b0 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -20,6 +20,34 @@ $Asset = "clang+llvm-$Version-x86_64-pc-windows-msvc.tar.xz" $Url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$Version/$Asset" $LlvmConfig = Join-Path $InstallDir "bin\llvm-config.exe" +function Find-SevenZip { + foreach ($Name in @("7z.exe", "7zz.exe", "7za.exe")) { + $Command = Get-Command $Name -ErrorAction SilentlyContinue + if ($Command) { + return $Command.Source + } + } + + $Candidates = @() + if ($env:ProgramFiles) { + $Candidates += Join-Path $env:ProgramFiles "7-Zip\7z.exe" + } + if (${env:ProgramFiles(x86)}) { + $Candidates += Join-Path ${env:ProgramFiles(x86)} "7-Zip\7z.exe" + } + if ($env:ChocolateyInstall) { + $Candidates += Join-Path $env:ChocolateyInstall "bin\7z.exe" + } + + foreach ($Candidate in $Candidates) { + if ($Candidate -and (Test-Path $Candidate)) { + return $Candidate + } + } + + return $null +} + function Get-Sha256Hex { param([string]$Path) @@ -48,9 +76,11 @@ if (Test-Path $LlvmConfig) { $TempDir = Join-Path ([System.IO.Path]::GetTempPath()) "pecos-llvm-$([System.Guid]::NewGuid())" $Archive = Join-Path $TempDir $Asset +$TarDir = Join-Path $TempDir "tar" $ExtractDir = Join-Path $TempDir "extract" New-Item -ItemType Directory -Force -Path $TempDir | Out-Null +New-Item -ItemType Directory -Force -Path $TarDir | Out-Null New-Item -ItemType Directory -Force -Path $ExtractDir | Out-Null try { @@ -68,22 +98,39 @@ try { throw "SHA256 mismatch for $Asset. Expected $ExpectedSha256, got $ActualSha256" } - $Tar = Join-Path $env:SystemRoot "System32\tar.exe" - if (-not (Test-Path $Tar)) { - $Tar = "tar.exe" + $SevenZip = Find-SevenZip + if (-not $SevenZip) { + throw "7-Zip is required to extract $Asset on Windows. Windows tar.exe can hang for hours on this archive in CI; install 7-Zip or provide an existing LLVM 21.1 install." } - Write-Host "Extracting LLVM archive with $Tar" - & $Tar -xf $Archive -C $ExtractDir --strip-components=1 + Write-Host "Extracting compressed LLVM archive with $SevenZip" + & $SevenZip x -y -bb0 "-o$TarDir" $Archive if ($LASTEXITCODE -ne 0) { - throw "tar failed with exit code $LASTEXITCODE" + throw "7-Zip failed to decompress $Asset with exit code $LASTEXITCODE" + } + + $TarArchive = Get-ChildItem -Path $TarDir -File -Filter "*.tar" | Select-Object -First 1 + if (-not $TarArchive) { + throw "7-Zip did not produce a .tar payload from $Asset" + } + + Write-Host "Extracting LLVM payload with $SevenZip" + & $SevenZip x -y -bb0 "-o$ExtractDir" $TarArchive.FullName + if ($LASTEXITCODE -ne 0) { + throw "7-Zip failed to extract $($TarArchive.Name) with exit code $LASTEXITCODE" + } + + $PayloadRoots = @(Get-ChildItem -Path $ExtractDir -Directory) + if ($PayloadRoots.Count -ne 1) { + throw "Expected one LLVM payload directory in $ExtractDir, found $($PayloadRoots.Count)" } + $PayloadDir = $PayloadRoots[0].FullName if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir } New-Item -ItemType Directory -Force -Path (Split-Path -Parent $InstallDir) | Out-Null - Move-Item $ExtractDir $InstallDir + Move-Item -Path $PayloadDir -Destination $InstallDir & $LlvmConfig --version & $LlvmConfig --shared-mode From 87306177872aa0d7331f85c9f68ad9a1b3e73248 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 4 Jun 2026 23:03:26 -0600 Subject: [PATCH 053/388] Add shared coord-preserving LogicalSubgraphWindowPlan and make the logical-circuit windowed-budget mode fail-loud: explicit full_fallback with effective_windowing/actual_num_windows/can_window introspection and a strict option that errors on an unmet bounded-latency budget --- .../src/logical_algorithm.rs | 8 +- .../src/logical_subgraph.rs | 17 ++ .../src/logical_subgraph/window_plan.rs | 230 ++++++++++++++++++ .../src/fault_tolerance_bindings.rs | 102 ++++++-- .../test_logical_circuit_decoder_windowing.py | 80 ++++++ 5 files changed, 414 insertions(+), 23 deletions(-) create mode 100644 crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs create mode 100644 python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index 9c366e209..ed41d038f 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -680,7 +680,13 @@ impl DecodeStrategy for WindowedLogicalSubgraphStrategy { } fn commit(&mut self, _region: &DetectorRegion) -> Result { - // Commitment is handled internally by the windowed inner decoders + // NOTE (abstraction caveat): this strategy is currently a *batch* decoder + // exposed through the streaming `DecodeStrategy` trait. It decodes the + // whole syndrome in one `decode()` call; per-observable subgraph windowing + // (when enabled) is handled inside each inner decoder, not via incremental + // region commits. So `commit()` is intentionally a no-op and + // `committed_obs()` returns 0. Real streaming commit semantics are a + // follow-up (see the windowed logical-subgraph proper-solution design). Ok(0) } diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 0d074b7a8..cc2061819 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -50,6 +50,7 @@ //! - This preserves the graphlike property of each subgraph pub mod committed; +pub mod window_plan; use std::collections::{BTreeMap, BTreeSet}; @@ -553,6 +554,22 @@ impl LogicalSubgraphDecoder { self.subgraphs.get(obs_idx) } + /// Build a coord-preserving per-observable window plan from these subgraphs + /// and the full-DEM detector coordinates (indexed by global detector id). + /// + /// Subgraph matching graphs drop detector coordinates, so the windowed + /// decoders need the full-DEM coords re-injected to time-window correctly. + /// The plan also reports whether real windowing would happen or it + /// degenerates to a single-window full decode (see + /// [`window_plan::LogicalSubgraphWindowPlan`]). + #[must_use] + pub fn window_plan( + &self, + full_coords: &[Option>], + ) -> window_plan::LogicalSubgraphWindowPlan { + window_plan::LogicalSubgraphWindowPlan::new(&self.subgraphs, full_coords) + } + /// Per-observable observing regions: entry `k` is the sorted full-DEM /// detector ids in observable `k`'s subgraph. This is the membership the /// region source produced — exposed for differential testing against diff --git a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs new file mode 100644 index 000000000..abc0e596f --- /dev/null +++ b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs @@ -0,0 +1,230 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Coord-preserving per-observable subgraph/window plan with honest +//! windowing-mode introspection. +//! +//! Both windowed logical-subgraph decoders (the standalone +//! `WindowedLogicalSubgraphDecoder` in `pecos-uf-decoder`, and the streaming +//! `WindowedLogicalSubgraphStrategy` here) need the same inputs: per-observable +//! graphlike sub-DEMs that PRESERVE detector coordinates (so time-based +//! windowing has real times), the local↔global detector maps, and -- crucially +//! -- a way to report whether real time-windowing will actually happen or the +//! decode silently degenerates to a single full window. +//! +//! Subgraph matching graphs drop detector coordinates +//! ([`crate::logical_subgraph::subgraphs_from_membership`] sets +//! `detector_coords: Vec::new()`), so a sub-DEM serialized from the graph alone +//! has no `detector(...)` lines; any windowed inner then sees `total_t = 1` and +//! builds a single window. This plan injects the full-DEM coordinates (mapped to +//! subgraph-local indices) and exposes the resulting window structure, so +//! callers can FAIL LOUD instead of silently full-decoding behind a +//! bounded-latency API. +//! +//! This lives in `pecos-decoder-core` as shared data/modeling so both the +//! downstream UF decoder and the logical-circuit strategy consume one plan +//! (avoiding a `decoder-core -> pecos-uf-decoder` dependency). + +use crate::logical_subgraph::LogicalSubgraph; +use std::fmt::Write as _; + +/// Whether a windowed logical-subgraph decode actually time-windows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EffectiveWindowing { + /// Every per-observable subgraph fits in a single window: a full + /// (non-windowed) decode -- accurate, but unbounded latency. Selecting this + /// when bounded latency was requested is a silent fallback unless surfaced. + FullFallback, + /// At least one subgraph spans multiple time windows: real sliding-window + /// decoding (bounded latency, subject to the windowed-LOM accuracy limit). + RealWindowed, +} + +impl EffectiveWindowing { + /// Stable string label for APIs / tests. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + EffectiveWindowing::FullFallback => "full_fallback", + EffectiveWindowing::RealWindowed => "real_windowed", + } + } +} + +/// One per-observable subgraph with detector coordinates preserved. +pub struct PlanEntry { + /// Global observable (logical) index this subgraph decodes. + pub observable_idx: usize, + /// Subgraph-local detector index -> full-DEM detector index. + pub detector_map: Vec, + /// Coord-preserving sub-DEM: `detector(...)` lines + `error(...)` lines. + pub sub_dem: String, + /// Per-local-detector time (coordinate element 2; `0.0` if unknown). + pub detector_times: Vec, +} + +/// Coord-preserving per-observable subgraph/window plan. +pub struct LogicalSubgraphWindowPlan { + entries: Vec, +} + +impl LogicalSubgraphWindowPlan { + /// Build from per-observable subgraphs and the full-DEM detector + /// coordinates (indexed by global detector id). Empty-region observables + /// (no detectors) are skipped -- they never flip and contribute nothing. + #[must_use] + pub fn new(subgraphs: &[LogicalSubgraph], full_coords: &[Option>]) -> Self { + let mut entries = Vec::new(); + for sg in subgraphs { + if sg.detector_map.is_empty() { + continue; + } + let mut detector_times = Vec::with_capacity(sg.detector_map.len()); + let mut sub_dem = String::new(); + for (local, &global) in sg.detector_map.iter().enumerate() { + let coords = full_coords.get(global).and_then(|c| c.as_ref()); + let t = coords.and_then(|c| c.get(2).copied()).unwrap_or(0.0); + detector_times.push(t); + if let Some(c) = coords { + let cs: Vec = c.iter().map(|v| format!("{v}")).collect(); + let _ = writeln!(sub_dem, "detector({}) D{local}", cs.join(", ")); + } + } + for edge in &sg.graph.edges { + let _ = write!(sub_dem, "error({})", edge.probability); + let _ = write!(sub_dem, " D{}", edge.node1); + if let Some(n2) = edge.node2 { + let _ = write!(sub_dem, " D{n2}"); + } + for &obs in &edge.observables { + let _ = write!(sub_dem, " L{obs}"); + } + let _ = writeln!(sub_dem); + } + entries.push(PlanEntry { + observable_idx: sg.observable_idx, + detector_map: sg.detector_map.clone(), + sub_dem, + detector_times, + }); + } + Self { entries } + } + + /// Number of non-empty per-observable subgraphs in the plan. + #[must_use] + pub fn num_observables(&self) -> usize { + self.entries.len() + } + + /// The per-observable plan entries. + #[must_use] + pub fn entries(&self) -> &[PlanEntry] { + &self.entries + } + + /// Coord-preserving sub-DEM strings (one per non-empty observable). + #[must_use] + pub fn sub_dems(&self) -> Vec { + self.entries.iter().map(|e| e.sub_dem.clone()).collect() + } + + /// Local->global detector maps (one per non-empty observable). + #[must_use] + pub fn detector_maps(&self) -> Vec> { + self.entries.iter().map(|e| e.detector_map.clone()).collect() + } + + /// Number of time windows observable `i` would use at `step` rounds per + /// window. Mirrors the core-window loop in the sliding-window decoders + /// (`t_start` from 0 by `step` while `< total_t`, `total_t = max_time + 1`), + /// counting only windows that contain at least one detector. + #[must_use] + pub fn window_count(&self, i: usize, step: usize) -> usize { + self.entries + .get(i) + .map_or(0, |e| window_count_for_times(&e.detector_times, step)) + } + + /// Total windows across all observables at `step`. + #[must_use] + pub fn total_windows(&self, step: usize) -> usize { + (0..self.entries.len()) + .map(|i| self.window_count(i, step)) + .sum() + } + + /// Whether real time-windowing happens at `step`, or it degenerates to a + /// single-window full decode for every observable. + #[must_use] + pub fn effective_windowing(&self, step: usize) -> EffectiveWindowing { + if (0..self.entries.len()).any(|i| self.window_count(i, step) > 1) { + EffectiveWindowing::RealWindowed + } else { + EffectiveWindowing::FullFallback + } + } +} + +/// Count the time windows for a set of detector times at `step` rounds per +/// window, matching the sliding-window loop's core ranges. Only windows that +/// contain at least one detector are counted. With no coordinates (all times +/// `0.0`) this returns 1 -- the silent-fallback signal. +fn window_count_for_times(times: &[f64], step: usize) -> usize { + if times.is_empty() { + return 0; + } + let max_time = times.iter().copied().fold(0.0f64, f64::max); + let total_t = max_time + 1.0; + let step = step.max(1) as f64; + + let mut count = 0usize; + let mut t_start = 0.0f64; + while t_start < total_t { + let is_last = t_start + 2.0 * step > total_t; + let t_core_end = if is_last { total_t + 1.0 } else { t_start + step }; + if times.iter().any(|&t| t >= t_start && t < t_core_end) { + count += 1; + } + if is_last { + break; + } + t_start += step; + } + count +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coordless_times_are_single_window() { + // All detectors at time 0 (the subgraph-graph / no-coords case). + assert_eq!(window_count_for_times(&[0.0, 0.0, 0.0], 4), 1); + assert_eq!(window_count_for_times(&[0.0], 1), 1); + } + + #[test] + fn empty_times_are_zero_windows() { + assert_eq!(window_count_for_times(&[], 4), 0); + } + + #[test] + fn multi_round_times_window_by_step() { + // Times 0..=23 (24 rounds), step 4 -> several windows (> 1). + let times: Vec = (0..24).map(f64::from).collect(); + assert!(window_count_for_times(×, 4) > 1); + // A step covering the whole range -> a single window. + assert_eq!(window_count_for_times(×, 1000), 1); + } +} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index b66d4169c..4d954ab30 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -5173,16 +5173,26 @@ impl PyLogicalAlgorithmDecoder { #[pyclass(name = "LogicalCircuitDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalCircuitDecoder { inner: pecos_decoder_core::logical_algorithm::LogicalCircuitDecoder, + /// How the decode actually windows: "unlimited" (full circuit), + /// "full_fallback" (per-observable full decode behind a windowed budget), + /// or "real_windowed" (genuine sliding-window; not yet enabled). + effective_windowing: String, + /// Per-observable window count actually used (1 == full decode). + actual_num_windows: Vec, + /// Whether genuine time-windowing is *possible* for this circuit (deep + /// enough), independent of whether it is enabled. False for "unlimited". + can_window: bool, } #[pymethods] impl PyLogicalCircuitDecoder { #[new] - #[pyo3(signature = (descriptor, budget="unlimited", inner_decoder="pymatching"))] + #[pyo3(signature = (descriptor, budget="unlimited", inner_decoder="pymatching", strict=false))] fn new( descriptor: &pyo3::Bound<'_, pyo3::types::PyDict>, budget: &str, inner_decoder: &str, + strict: bool, ) -> PyResult { use pecos_decoder_core::decode_budget::DecodeBudget; use pecos_decoder_core::logical_algorithm::{ @@ -5347,36 +5357,54 @@ impl PyLogicalCircuitDecoder { }; // Select strategy based on budget. + let mut effective_windowing = String::from("unlimited"); + let mut actual_num_windows: Vec = Vec::new(); + let mut can_window = false; let strategy: Box = if decode_budget.is_unlimited() { // Unlimited: full-circuit logical-subgraph decoder (maximum accuracy) Box::new(FullCircuitStrategy::new(Box::new(full_osd))) } else { - // Windowed: per-subgraph sandwich decoding. - // Extract per-subgraph DEMs and detector maps from the full logical-subgraph decoder. + // A bounded-latency ("windowed") budget was requested. Genuine + // per-observable sliding-window LOM decoding does not yet + // suppress (the windowed-LOM time-like-snake limitation; needs + // the anti-snake machinery), so we do an EXPLICIT full-decode + // fallback per observable -- accurate, but NOT bounded latency -- + // and surface that honestly via `effective_windowing()` / + // `actual_num_windows()`. No silent fallback. `strict=True` turns + // the unmet latency budget into a hard error. use pecos_decoder_core::logical_algorithm::WindowedLogicalSubgraphStrategy; - - let mut sub_dems = Vec::new(); - let mut det_maps = Vec::new(); - for i in 0..full_osd.num_observables() { - if let Some(sg) = full_osd.subgraph(i) { - sub_dems.push(subgraph_to_dem_string(&sg.graph)); - det_maps.push(sg.detector_map.clone()); - } + use pecos_decoder_core::logical_subgraph::window_plan::EffectiveWindowing; + + // Coord-preserving window plan (reports whether real windowing is + // even possible for this circuit depth). + let full_coords = pecos_decoder_core::DemMatchingGraph::from_dem_str(&full_dem) + .map_err(|e| PyErr::new::(e.to_string()))? + .detector_coords; + let plan = full_osd.window_plan(&full_coords); + let step = decode_budget.code_distance.max(1); + can_window = plan.effective_windowing(step) == EffectiveWindowing::RealWindowed; + + if strict { + return Err(PyErr::new::( + "bounded-latency ('windowed') budget requested with strict=True, \ + but accurate windowed logical-subgraph decoding is not yet \ + available (windowed-LOM anti-snake machinery pending). It would \ + fall back to a full per-observable decode (unbounded latency). \ + Use budget='unlimited', or pass strict=False to accept the \ + full-decode fallback." + .to_string(), + )); } - let d = decode_budget.code_distance; - let buf = decode_budget.overlap_rounds.min(d * 2); // cap at 2d - let windowed_str = if buf > 0 { - format!("windowed:step={d},buf={buf},wmax=2.5") - } else { - // No overlap: use plain PM (faster, but accuracy limited - // to non-overlapping windowed matching) - format!("windowed:step={d},buf=0") - }; + let sub_dems = plan.sub_dems(); + let det_maps = plan.detector_maps(); + effective_windowing = String::from("full_fallback"); + actual_num_windows = vec![1usize; sub_dems.len()]; + let fallback_inner = inner_decoder.to_string(); let wosd = WindowedLogicalSubgraphStrategy::new(sub_dems, det_maps, |dem_str| { - let dec = create_observable_decoder(dem_str, &windowed_str) + let dec = create_observable_decoder(dem_str, &fallback_inner) .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; Ok(Box::new(SendWrapper(dec)) as Box) @@ -5387,7 +5415,37 @@ impl PyLogicalCircuitDecoder { }; let inner = LogicalCircuitDecoder::new(algo_desc, strategy, decode_budget, num_qubits); - Ok(Self { inner }) + Ok(Self { + inner, + effective_windowing, + actual_num_windows, + can_window, + }) + } + + /// How the decode actually windows: ``"unlimited"`` (full-circuit decode), + /// ``"full_fallback"`` (per-observable full decode behind a windowed + /// budget -- accurate but NOT bounded latency), or ``"real_windowed"`` + /// (genuine sliding-window; not yet enabled pending the windowed-LOM + /// anti-snake machinery). Lets callers/tests assert the effective mode + /// instead of trusting a silent fallback. + #[getter] + fn effective_windowing(&self) -> &str { + &self.effective_windowing + } + + /// Per-observable window count actually used (``1`` == full decode). Empty + /// for the unlimited budget. + #[getter] + fn actual_num_windows(&self) -> Vec { + self.actual_num_windows.clone() + } + + /// Whether genuine time-windowing is *possible* for this circuit (deep + /// enough), independent of whether it is enabled. ``False`` for unlimited. + #[getter] + fn can_window(&self) -> bool { + self.can_window } /// Decode a single syndrome. diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py new file mode 100644 index 000000000..fc5c35527 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py @@ -0,0 +1,80 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Honest windowing-mode reporting for the logical-circuit decoder (Layer 0). + +The windowed-budget path used to silently perform a single-window full decode +(the per-observable sub-DEMs were serialized without detector coordinates, so the +inner windowed decoder degenerated to one window) while the API advertised a +bounded-latency budget. This pins that the effective mode is now surfaced +explicitly: ``effective_windowing`` / ``actual_num_windows`` are introspectable, +``can_window`` distinguishes "real windowing is possible" from "real windowing is +enabled", and a ``strict`` request hard-errors instead of silently falling back. + +See pecos-docs/design/windowed-logical-subgraph-proper-solution.md. +""" + +from __future__ import annotations + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalCircuitDecoder + + +def _memory_descriptor(d: int, rounds: int) -> dict: + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + return b.build_algorithm_descriptor(p1=0.001, p2=0.001, p_meas=0.001) + + +def test_unlimited_budget_reports_unlimited(): + dec = LogicalCircuitDecoder(_memory_descriptor(3, 9), budget="unlimited") + assert dec.effective_windowing == "unlimited" + assert dec.can_window is False + assert dec.actual_num_windows == [] + + +def test_windowed_budget_is_explicit_full_fallback_not_silent(): + """The windowed budget must NOT silently claim bounded latency: it reports a + full-decode fallback with one window per observable, while still signalling + that genuine windowing is possible for this (deep enough) circuit.""" + dec = LogicalCircuitDecoder(_memory_descriptor(3, 9), budget="windowed") + assert dec.effective_windowing == "full_fallback" + assert len(dec.actual_num_windows) >= 1 + assert all(n == 1 for n in dec.actual_num_windows) + # The circuit is deep enough that real windowing *could* happen (coords are + # preserved in the plan); it is just not enabled until the anti-snake work. + assert dec.can_window is True + + +def test_strict_windowed_budget_hard_errors(): + """With strict=True, an unmet bounded-latency budget is a hard error rather + than a silent full-decode fallback.""" + desc = _memory_descriptor(3, 9) + with pytest.raises(Exception, match="strict"): + LogicalCircuitDecoder(desc, budget="windowed", strict=True) + + +def test_windowed_full_fallback_still_decodes(): + """The full-fallback path is still a working decoder (accurate per-observable + decode), not a stub.""" + desc = _memory_descriptor(3, 9) + dec = LogicalCircuitDecoder(desc, budget="windowed") + ndet = sum(1 for ln in desc["full_dem"].splitlines() if ln.strip().startswith("detector(")) + # Zero syndrome -> zero correction. + assert dec.decode([0] * ndet) == 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From abdb945720022ee90878e262a252bd4fa02355b8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 08:40:53 -0600 Subject: [PATCH 054/388] Fix Windows LLVM CI linking --- .github/workflows/rust-test.yml | 24 ++++++++- Justfile | 8 ++- scripts/ci/install-llvm-21-windows.ps1 | 37 ++++++++++++++ scripts/ci/llvm-config-wrapper.rs | 70 ++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 scripts/ci/llvm-config-wrapper.rs diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index dd43be99f..51bd1eed2 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -294,4 +294,26 @@ jobs: - name: Run tests (Windows) if: runner.os == 'Windows' - run: cargo run --locked -p pecos-cli --release -- rust test + shell: bash + run: | + set -euo pipefail + # Windows CI uses the official LLVM development archive, which is + # static-only for MSVC. Keep this lane targeted so it still exercises + # LLVM without fanning out many static HUGR linker jobs at once. + cargo test --locked --workspace --release \ + --exclude pecos-rslib \ + --exclude pecos-rslib-cuda \ + --exclude pecos-julia-ffi \ + --exclude pecos-go-ffi \ + --exclude pecos-rslib-exp \ + --exclude pecos-rslib-llvm \ + --exclude pecos-cuquantum \ + --exclude pecos-cli \ + --exclude pecos-decoders \ + --exclude pecos-gpu-sims \ + --exclude pecos-hugr-qis \ + --exclude pecos-llvm + cargo test --locked -p pecos-cli --features=runtime --release + cargo test --locked -p pecos-llvm --release + cargo test --locked -p pecos-qis --no-default-features --features=llvm --release + cargo test --locked -p pecos-hugr-qis --release diff --git a/Justfile b/Justfile index c0425051f..1760a430e 100644 --- a/Justfile +++ b/Justfile @@ -71,11 +71,9 @@ ci-env: _msvc-bootstrap {{pecos}} llvm configure "$(brew --prefix llvm@21)" ;; Windows*|MINGW*|MSYS*|CYGWIN*) - if ! {{pecos}} llvm find >/dev/null 2>&1; then - LLVM_PREFIX="${USERPROFILE:-$HOME}\\.pecos\\deps\\llvm-21.1" - powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/ci/install-llvm-21-windows.ps1 -InstallDir "$LLVM_PREFIX" -Version "$LLVM_RELEASE_VERSION" - fi - {{pecos}} llvm configure + LLVM_PREFIX="${USERPROFILE:-$HOME}\\.pecos\\deps\\llvm-21.1" + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/ci/install-llvm-21-windows.ps1 -InstallDir "$LLVM_PREFIX" -Version "$LLVM_RELEASE_VERSION" + {{pecos}} llvm configure "$LLVM_PREFIX" ;; *) {{pecos}} llvm ensure --managed --no-configure diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index 29630d8b0..8deee25d3 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -19,6 +19,7 @@ $RequiredVersion = "21.1" $Asset = "clang+llvm-$Version-x86_64-pc-windows-msvc.tar.xz" $Url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$Version/$Asset" $LlvmConfig = Join-Path $InstallDir "bin\llvm-config.exe" +$LlvmConfigReal = Join-Path $InstallDir "bin\llvm-config.real.exe" function Find-SevenZip { foreach ($Name in @("7z.exe", "7zz.exe", "7za.exe")) { @@ -66,9 +67,44 @@ function Get-Sha256Hex { } } +function Install-LlvmConfigWrapper { + $WrapperSource = Join-Path $PSScriptRoot "llvm-config-wrapper.rs" + if (-not (Test-Path $WrapperSource)) { + throw "LLVM config wrapper source not found: $WrapperSource" + } + + if (-not (Test-Path $LlvmConfigReal)) { + if (-not (Test-Path $LlvmConfig)) { + throw "llvm-config.exe not found at $LlvmConfig" + } + Move-Item -Force -Path $LlvmConfig -Destination $LlvmConfigReal + } + elseif (Test-Path $LlvmConfig) { + Remove-Item -Force $LlvmConfig + } + + $Rustc = Get-Command rustc.exe -ErrorAction SilentlyContinue + if (-not $Rustc) { + throw "rustc.exe is required to repair the LLVM Windows llvm-config system library output" + } + + Write-Host "Installing PECOS llvm-config wrapper" + & $Rustc.Source --edition=2021 -O -o $LlvmConfig $WrapperSource + if ($LASTEXITCODE -ne 0) { + throw "rustc failed to build llvm-config wrapper with exit code $LASTEXITCODE" + } + + $SystemLibs = (& $LlvmConfig --system-libs --link-static).Trim() + $Libxml2Static = Join-Path $InstallDir "lib\libxml2s.lib" + if ((-not (Test-Path $Libxml2Static)) -and $SystemLibs -match "(^|\s)libxml2s\.lib($|\s)") { + throw "LLVM config wrapper did not filter missing libxml2s.lib from --system-libs" + } +} + if (Test-Path $LlvmConfig) { $FoundVersion = (& $LlvmConfig --version).Trim() if ($FoundVersion.StartsWith($RequiredVersion)) { + Install-LlvmConfigWrapper Write-Host "LLVM $FoundVersion already installed at $InstallDir" exit 0 } @@ -134,6 +170,7 @@ try { & $LlvmConfig --version & $LlvmConfig --shared-mode + Install-LlvmConfigWrapper Write-Host "Installed LLVM $Version to $InstallDir" } finally { diff --git a/scripts/ci/llvm-config-wrapper.rs b/scripts/ci/llvm-config-wrapper.rs new file mode 100644 index 000000000..c136c97aa --- /dev/null +++ b/scripts/ci/llvm-config-wrapper.rs @@ -0,0 +1,70 @@ +use std::env; +use std::io::{self, Write}; +use std::path::Path; +use std::process::{Command, ExitCode}; + +fn llvm_lib_exists(current_exe: &Path, lib_name: &str) -> bool { + let Some(bin_dir) = current_exe.parent() else { + return false; + }; + let Some(prefix_dir) = bin_dir.parent() else { + return false; + }; + prefix_dir.join("lib").join(lib_name).exists() +} + +fn filter_system_libs(current_exe: &Path, stdout: &[u8]) -> Vec { + let output = String::from_utf8_lossy(stdout); + let mut filtered = output + .split_whitespace() + .filter(|token| { + !token.eq_ignore_ascii_case("libxml2s.lib") || llvm_lib_exists(current_exe, token) + }) + .collect::>() + .join(" "); + + if output.ends_with('\n') { + filtered.push('\n'); + } + filtered.into_bytes() +} + +fn main() -> ExitCode { + let args = env::args_os().skip(1).collect::>(); + let current_exe = match env::current_exe() { + Ok(path) => path, + Err(error) => { + let _ = writeln!( + io::stderr(), + "failed to locate llvm-config wrapper: {error}" + ); + return ExitCode::FAILURE; + } + }; + let real_config = current_exe.with_file_name("llvm-config.real.exe"); + let output = match Command::new(&real_config).args(&args).output() { + Ok(output) => output, + Err(error) => { + let _ = writeln!( + io::stderr(), + "failed to run {}: {error}", + real_config.display() + ); + return ExitCode::FAILURE; + } + }; + + let _ = io::stderr().write_all(&output.stderr); + let is_system_libs = args.iter().any(|arg| arg == "--system-libs"); + let stdout = if is_system_libs && output.status.success() { + filter_system_libs(¤t_exe, &output.stdout) + } else { + output.stdout + }; + let _ = io::stdout().write_all(&stdout); + + match output.status.code() { + Some(code) if (0..=255).contains(&code) => ExitCode::from(code as u8), + Some(_) | None => ExitCode::FAILURE, + } +} From a671a33416cbca2c2c7f97885f2b3e49e984bf80 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 11:29:32 -0600 Subject: [PATCH 055/388] Unify standalone WindowedLogicalSubgraphDecoder onto the shared LogicalSubgraphWindowPlan and delete its duplicate coord-injecting serializer (single serialization path; single-window==non-windowed correctness preserved) --- .../src/logical_subgraph_windowed.rs | 63 ++++++------------- 1 file changed, 18 insertions(+), 45 deletions(-) diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index ea88ed3c0..5f84fbba1 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -35,10 +35,10 @@ use pecos_decoder_core::ObservableDecoder; use pecos_decoder_core::dem::DemMatchingGraph; use pecos_decoder_core::errors::DecoderError; +use pecos_decoder_core::logical_subgraph::window_plan::LogicalSubgraphWindowPlan; use pecos_decoder_core::logical_subgraph::{ - LogicalSubgraph, MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, + MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, }; -use std::fmt::Write as _; use crate::decoder::{UfDecoder, UfDecoderConfig}; use crate::windowed::{OverlappingWindowedDecoder, WindowedConfig}; @@ -85,27 +85,26 @@ impl WindowedLogicalSubgraphDecoder { ) -> Result { let parts = partition_dem_by_logical_windowed(dem, stab_coords, max_time_radius)?; - // Subgraph graphs do not carry detector coordinates, but the time-based - // windowing needs them. Pull them from the full DEM and inject (mapped - // to subgraph-local indices) when serializing each sub-DEM. + // Shared coord-preserving plan: subgraph graphs carry no detector + // coordinates, so the plan re-injects the full-DEM coords (mapped to + // subgraph-local indices) into each sub-DEM, giving the time-based + // windowing real detector times. Empty-region observables are dropped. let full_coords = DemMatchingGraph::from_dem_str(dem)?.detector_coords; + let plan = LogicalSubgraphWindowPlan::new(&parts, &full_coords); - let mut subgraphs = Vec::with_capacity(parts.len()); + let mut subgraphs = Vec::with_capacity(plan.num_observables()); let mut max_local = 0usize; - for part in parts { - if part.detector_map.is_empty() { - // Observable with an empty region never flips: contributes 0. - continue; - } - let sub_dem = subgraph_to_dem_string(&part, &full_coords); - let decoder = OverlappingWindowedDecoder::from_dem(&sub_dem, window_config, |wdem| { - UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) - })?; - max_local = max_local.max(part.graph.num_detectors); + for entry in plan.entries() { + let decoder = + OverlappingWindowedDecoder::from_dem(&entry.sub_dem, window_config, |wdem| { + UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) + })?; + let num_local = entry.detector_map.len(); + max_local = max_local.max(num_local); subgraphs.push(SubgraphWindowed { - observable_idx: part.observable_idx, - detector_map: part.detector_map, - num_local: part.graph.num_detectors, + observable_idx: entry.observable_idx, + detector_map: entry.detector_map.clone(), + num_local, decoder, }); } @@ -151,29 +150,3 @@ impl ObservableDecoder for WindowedLogicalSubgraphDecoder { Ok(obs_mask) } } - -/// Serialize a subgraph back to a DEM string: detector coordinate lines (mapped -/// from the full DEM via `detector_map`) plus error lines. The coordinate lines -/// let [`OverlappingWindowedDecoder::from_dem`] derive per-detector times for -/// the windowing. -fn subgraph_to_dem_string(part: &LogicalSubgraph, full_coords: &[Option>]) -> String { - let mut s = String::new(); - for (local, &global) in part.detector_map.iter().enumerate() { - if let Some(Some(coords)) = full_coords.get(global) { - let coord_str: Vec = coords.iter().map(|c| format!("{c}")).collect(); - let _ = writeln!(s, "detector({}) D{local}", coord_str.join(", ")); - } - } - for edge in &part.graph.edges { - let _ = write!(s, "error({})", edge.probability); - let _ = write!(s, " D{}", edge.node1); - if let Some(n2) = edge.node2 { - let _ = write!(s, " D{n2}"); - } - for &obs in &edge.observables { - let _ = write!(s, " L{obs}"); - } - let _ = writeln!(s); - } - s -} From d417c72f5456f8ec2e9b52ee7767cf958a7fa3fb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 11:57:33 -0600 Subject: [PATCH 056/388] Use conda-forge LLVM on Windows --- .github/workflows/julia-release.yml | 9 +- .github/workflows/python-release.yml | 26 +-- .github/workflows/rust-test.yml | 21 +- Justfile | 5 +- crates/pecos-build/src/llvm.rs | 44 ++-- crates/pecos-build/src/llvm/installer.rs | 11 +- docs/development/dev-tools.md | 10 +- docs/user-guide/llvm-setup.md | 30 ++- scripts/ci/install-llvm-21-windows.ps1 | 259 +++++++++++++++-------- scripts/ci/llvm-config-wrapper.rs | 70 ------ 10 files changed, 250 insertions(+), 235 deletions(-) delete mode 100644 scripts/ci/llvm-config-wrapper.rs diff --git a/.github/workflows/julia-release.yml b/.github/workflows/julia-release.yml index 45e95119b..8ca225664 100644 --- a/.github/workflows/julia-release.yml +++ b/.github/workflows/julia-release.yml @@ -153,17 +153,20 @@ jobs: if: runner.os == 'Windows' shell: pwsh run: | - $llvmPrefix = Join-Path $env:USERPROFILE ".pecos\deps\llvm-21.1" - Write-Host "Installing LLVM 21.1 development archive..." - ./scripts/ci/install-llvm-21-windows.ps1 -InstallDir $llvmPrefix -Version ${{ env.LLVM_RELEASE_VERSION }} + $llvmRoot = Join-Path $env:USERPROFILE ".pecos\deps\llvm-21.1" + $llvmPrefix = Join-Path $llvmRoot "Library" + Write-Host "Installing conda-forge LLVM 21.1..." + ./scripts/ci/install-llvm-21-windows.ps1 -InstallDir $llvmRoot -Version ${{ env.LLVM_RELEASE_VERSION }} Write-Host "Setting LLVM environment variables..." $env:PECOS_LLVM = $llvmPrefix $env:LLVM_SYS_211_PREFIX = $env:PECOS_LLVM + $env:LIBCLANG_PATH = Join-Path $llvmPrefix "bin" cargo run --locked -p pecos-cli --release -- llvm configure "$env:PECOS_LLVM" "PECOS_LLVM=$env:PECOS_LLVM" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append "LLVM_SYS_211_PREFIX=$env:LLVM_SYS_211_PREFIX" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append + "LIBCLANG_PATH=$env:LIBCLANG_PATH" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append Write-Host "Verifying LLVM installation..." cargo run --locked -p pecos-cli --release -- llvm check diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index bc91d1686..423fca4b2 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -281,19 +281,19 @@ jobs: pipx run abi3audit --strict --report {wheel} # Windows configuration - CUDA via Jimver/cuda-toolkit (installed before cibuildwheel) CIBW_ENVIRONMENT_WINDOWS: > - PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" - LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1" - LIBCLANG_PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin" + PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin;C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin;$PATH" + LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library" + LIBCLANG_PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin" CMAKE="C:\\Users\\runneradmin\\.pecos\\deps\\cmake-${{ env.PECOS_CMAKE_VERSION }}\\bin\\cmake.exe" MATURIN_PEP517_ARGS="--locked --features=extension-module,mwpf" CIBW_BEFORE_ALL_WINDOWS: > - echo "=== Installing LLVM 21.1 development archive ===" && + echo "=== Installing conda-forge LLVM 21.1 ===" && rustup update && powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }} && - cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1" && + cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library" && cargo run --locked --release -p pecos-cli -- install cmake --force && echo "=== Checking LLVM installation ===" && - if exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\bin\llvm-config.exe" (echo LLVM directory exists) else (echo ERROR: LLVM directory not found! && exit /b 1) + if exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library\bin\llvm-config.exe" (echo LLVM directory exists) else (echo ERROR: LLVM directory not found! && exit /b 1) # Install delvewheel and patch it to ignore ext-ms-win-* API sets # (delvewheel ignores api-ms-win-* but not ext-ms-win-* which are also Windows API sets) CIBW_BEFORE_BUILD_WINDOWS: > @@ -302,7 +302,7 @@ jobs: # Note: --no-dll excludes Windows system DLLs that should not be bundled # combase.dll and rmclient.dll are core Windows components that fail when bundled CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && + delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} - name: Upload pecos-rslib wheels @@ -375,18 +375,18 @@ jobs: PATH=$HOME/.pecos/bin:$PATH DYLD_LIBRARY_PATH=$HOME/.pecos/deps/llvm-21.1/lib delocate-wheel --require-archs {delocate_archs} -w {dest_dir} -v {wheel} && pipx run abi3audit --strict --report {wheel} CIBW_ENVIRONMENT_WINDOWS: > - PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin;$PATH" - LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1" - LIBCLANG_PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin" + PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin;$PATH" + LLVM_SYS_211_PREFIX="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library" + LIBCLANG_PATH="C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin" CIBW_BEFORE_ALL_WINDOWS: > rustup update && - if not exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\bin\llvm-config.exe" (powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }}) else (echo LLVM already installed from pecos-rslib build) && - cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1" + if not exist "C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library\bin\llvm-config.exe" (powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts\ci\install-llvm-21-windows.ps1 -InstallDir "C:\Users\runneradmin\.pecos\deps\llvm-21.1" -Version ${{ env.LLVM_RELEASE_VERSION }}) else (echo LLVM already installed from pecos-rslib build) && + cargo run --locked --release -p pecos-cli -- llvm configure "C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library" CIBW_BEFORE_BUILD_WINDOWS: > pip install delvewheel && python -c "import delvewheel._dll_list as d,inspect,re as r;p=inspect.getfile(d);c=open(p).read();n=chr(10);open(p,'w').write(c.replace(r\"re.compile('api-.*'),\",r\"re.compile('api-.*'),\"+n+r\" re.compile('ext-.*'),\")) if 'ext-.*' not in c else None" CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: > - delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && + delvewheel repair -v --add-path "C:\\Users\\runneradmin\\.pecos\\deps\\llvm-21.1\\Library\\bin" --no-dll "combase.dll;rmclient.dll" -w {dest_dir} {wheel} && pipx run abi3audit --strict --report {wheel} - name: Upload pecos-rslib-llvm wheels diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 51bd1eed2..003f43fac 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -297,23 +297,4 @@ jobs: shell: bash run: | set -euo pipefail - # Windows CI uses the official LLVM development archive, which is - # static-only for MSVC. Keep this lane targeted so it still exercises - # LLVM without fanning out many static HUGR linker jobs at once. - cargo test --locked --workspace --release \ - --exclude pecos-rslib \ - --exclude pecos-rslib-cuda \ - --exclude pecos-julia-ffi \ - --exclude pecos-go-ffi \ - --exclude pecos-rslib-exp \ - --exclude pecos-rslib-llvm \ - --exclude pecos-cuquantum \ - --exclude pecos-cli \ - --exclude pecos-decoders \ - --exclude pecos-gpu-sims \ - --exclude pecos-hugr-qis \ - --exclude pecos-llvm - cargo test --locked -p pecos-cli --features=runtime --release - cargo test --locked -p pecos-llvm --release - cargo test --locked -p pecos-qis --no-default-features --features=llvm --release - cargo test --locked -p pecos-hugr-qis --release + cargo run --locked -p pecos-cli --release -- rust test diff --git a/Justfile b/Justfile index 1760a430e..6ce2129e7 100644 --- a/Justfile +++ b/Justfile @@ -71,8 +71,9 @@ ci-env: _msvc-bootstrap {{pecos}} llvm configure "$(brew --prefix llvm@21)" ;; Windows*|MINGW*|MSYS*|CYGWIN*) - LLVM_PREFIX="${USERPROFILE:-$HOME}\\.pecos\\deps\\llvm-21.1" - powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/ci/install-llvm-21-windows.ps1 -InstallDir "$LLVM_PREFIX" -Version "$LLVM_RELEASE_VERSION" + LLVM_ENV_ROOT="${USERPROFILE:-$HOME}\\.pecos\\deps\\llvm-21.1" + LLVM_PREFIX="${LLVM_ENV_ROOT}\\Library" + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/ci/install-llvm-21-windows.ps1 -InstallDir "$LLVM_ENV_ROOT" -Version "$LLVM_RELEASE_VERSION" {{pecos}} llvm configure "$LLVM_PREFIX" ;; *) diff --git a/crates/pecos-build/src/llvm.rs b/crates/pecos-build/src/llvm.rs index a3c98f1bb..7a2d4ebb4 100644 --- a/crates/pecos-build/src/llvm.rs +++ b/crates/pecos-build/src/llvm.rs @@ -62,9 +62,9 @@ pub fn is_required_llvm_version(version: &str) -> bool { pub fn find_llvm(repo_root: Option) -> Option { // 1. Check versioned deps path: ~/.pecos/deps/llvm-{version}/ if let Ok(deps_llvm) = crate::home::get_llvm_dir_path() - && is_valid_llvm(&deps_llvm) + && let Some(llvm_prefix) = valid_llvm_prefix(&deps_llvm) { - return Some(deps_llvm); + return Some(llvm_prefix); } // 2. Check legacy top-level path: ~/.pecos/llvm/ @@ -74,24 +74,24 @@ pub fn find_llvm(repo_root: Option) -> Option { #[cfg(target_os = "windows")] { let user_llvm_new = pecos_dir.join(format!("LLVM-{REQUIRED_VERSION}")); - if is_valid_llvm(&user_llvm_new) { - crate::home::print_legacy_warning("LLVM", &user_llvm_new); - return Some(user_llvm_new); + if let Some(llvm_prefix) = valid_llvm_prefix(&user_llvm_new) { + crate::home::print_legacy_warning("LLVM", &llvm_prefix); + return Some(llvm_prefix); } } let user_llvm_legacy = pecos_dir.join("llvm"); - if is_valid_llvm(&user_llvm_legacy) { - crate::home::print_legacy_warning("LLVM", &user_llvm_legacy); - return Some(user_llvm_legacy); + if let Some(llvm_prefix) = valid_llvm_prefix(&user_llvm_legacy) { + crate::home::print_legacy_warning("LLVM", &llvm_prefix); + return Some(llvm_prefix); } } // 3. Check for project-local LLVM if let Some(root) = repo_root { let local_llvm = root.join("llvm"); - if is_valid_llvm(&local_llvm) { - return Some(local_llvm); + if let Some(llvm_prefix) = valid_llvm_prefix(&local_llvm) { + return Some(llvm_prefix); } } @@ -99,6 +99,22 @@ pub fn find_llvm(repo_root: Option) -> Option { find_system_llvm() } +fn valid_llvm_prefix(path: &Path) -> Option { + if is_valid_llvm(path) { + return Some(path.to_path_buf()); + } + + #[cfg(target_os = "windows")] + { + let conda_library_prefix = path.join("Library"); + if is_valid_llvm(&conda_library_prefix) { + return Some(conda_library_prefix); + } + } + + None +} + /// Find the LLVM installation Cargo should use for this project. /// /// An explicit `.cargo/config.toml` setting takes priority because `cargo` @@ -405,11 +421,11 @@ pub fn print_llvm_not_found_error() { #[cfg(target_os = "windows")] { - eprintln!("Windows MSVC LLVM builds do not provide shared libLLVM."); - eprintln!("Use WSL2/Linux for the full HUGR test lane, or configure a full"); - eprintln!("LLVM 21 package for targeted static LLVM builds:"); + eprintln!("The official Windows LLVM installer is not sufficient for PECOS."); + eprintln!("Use scripts\\ci\\install-llvm-21-windows.ps1 for the conda-forge"); + eprintln!("LLVM 21.1 toolchain, then configure its Library prefix:"); eprintln!(); - eprintln!(" {cmd} llvm configure C:\\path\\to\\llvm"); + eprintln!(" {cmd} llvm configure %USERPROFILE%\\.pecos\\deps\\llvm-21.1\\Library"); eprintln!(); } diff --git a/crates/pecos-build/src/llvm/installer.rs b/crates/pecos-build/src/llvm/installer.rs index d0a327f62..3c4fcf64b 100644 --- a/crates/pecos-build/src/llvm/installer.rs +++ b/crates/pecos-build/src/llvm/installer.rs @@ -37,11 +37,12 @@ pub fn managed_install_unavailable_reason() -> Option<&'static str> { #[cfg(target_os = "windows")] { Some( - "PECOS-managed shared LLVM is not available on Windows MSVC because \ - LLVM does not provide the libLLVM shared-library target there. Use \ - WSL2/Linux for the full HUGR test lane, or configure a full LLVM 21 \ - package for targeted static LLVM builds with `pecos llvm configure \ - C:\\path\\to\\llvm`.", + "PECOS-managed LLVM is not implemented in the CLI on Windows yet. \ + Use `scripts\\ci\\install-llvm-21-windows.ps1` to install the \ + conda-forge LLVM 21.1 toolchain under `%USERPROFILE%\\.pecos\\deps`, \ + then run `pecos llvm configure \ + %USERPROFILE%\\.pecos\\deps\\llvm-21.1\\Library`, or configure \ + your own full LLVM 21.1 install.", ) } diff --git a/docs/development/dev-tools.md b/docs/development/dev-tools.md index 1be1a64f5..e6747b24b 100644 --- a/docs/development/dev-tools.md +++ b/docs/development/dev-tools.md @@ -121,9 +121,9 @@ large toolchain install. `pecos install llvm` prints what it is about to install and asks for confirmation before downloading. On macOS, install Homebrew LLVM 21 (`brew install llvm@21`) and run -`pecos llvm configure`. On native Windows MSVC, LLVM does not provide shared -`libLLVM`; use WSL2/Linux for the full HUGR test lane, or configure a full LLVM -development package for targeted static builds. +`pecos llvm configure`. On native Windows MSVC, use +`scripts\ci\install-llvm-21-windows.ps1` to install the conda-forge LLVM 21.1 +toolchain, then configure `~\.pecos\deps\llvm-21.1\Library`. `pecos rust test` requires shared LLVM for the workspace HUGR test lane. LLVM 21.1 static test links can use multiple GB of RAM each, so PECOS fails early @@ -231,8 +231,8 @@ cargo build -p pecos --features llvm ``` On macOS use `brew install llvm@21 && pecos llvm configure`. On native Windows -MSVC, use WSL2/Linux for the full HUGR test lane or configure a full LLVM 21 -package for targeted static builds. +MSVC, use `scripts\ci\install-llvm-21-windows.ps1` and configure +`~\.pecos\deps\llvm-21.1\Library`. Or using Justfile: ```bash diff --git a/docs/user-guide/llvm-setup.md b/docs/user-guide/llvm-setup.md index 32de5edcc..fccc1f4a1 100644 --- a/docs/user-guide/llvm-setup.md +++ b/docs/user-guide/llvm-setup.md @@ -42,10 +42,10 @@ The `install` command automatically: This is the **recommended approach** where PECOS can provide a verified shared LLVM package. On Debian/Ubuntu-compatible Linux distributions, PECOS downloads the apt.llvm.org LLVM 21 packages into `~/.pecos/deps/llvm-21.1/` without using -`sudo`. On macOS, use Homebrew for LLVM 21. On Windows MSVC, LLVM does not -provide the shared `libLLVM` target PECOS requires for the full workspace HUGR -test lane; use WSL2/Linux for that lane, or configure a full LLVM package for -targeted static LLVM builds. +`sudo`. On macOS, use Homebrew for LLVM 21. On Windows MSVC, use the +conda-forge helper in the Windows section below; it installs a full LLVM +development environment under `~/.pecos/deps/llvm-21.1/` and configures +`~/.pecos/deps/llvm-21.1/Library` as the LLVM prefix. This is a developer toolchain install: the CLI prints the install size/behavior and asks for confirmation before downloading. Use `--yes` to accept the prompt @@ -92,11 +92,17 @@ Install LLVM 21.1 using your system's package manager, then configure PECOS: === "Windows" !!! warning "Windows LLVM Requirement" - The official LLVM Windows installer (`LLVM-*.exe`) is **toolchain-only** and lacks required development files (`llvm-config.exe` and headers). LLVM's MSVC builds also do not provide shared `libLLVM`, so `pecos rust test` / `just dev` with the full HUGR test lane is not supported on native Windows. + The official LLVM Windows installer (`LLVM-*.exe`) is **toolchain-only** and lacks required development files (`llvm-config.exe`, headers, and `libclang.dll`). Use a full LLVM development package built for the MSVC dynamic runtime. - **Recommended for full development tests:** Use WSL2/Linux and Option 1 above. + **Recommended for full development tests:** Use the PECOS conda-forge helper: - **Alternative for targeted static LLVM builds:** Download a full development package from: + ```powershell + .\scripts\ci\install-llvm-21-windows.ps1 -InstallDir "$env:USERPROFILE\.pecos\deps\llvm-21.1" + cargo run -p pecos-cli -- llvm configure "$env:USERPROFILE\.pecos\deps\llvm-21.1\Library" + cargo build --features llvm + ``` + + **Alternative:** Configure another full LLVM 21.1 development package that includes `llvm-config.exe`, headers, static MSVC libraries built against the dynamic runtime, and `libclang.dll`. - [bitgate/llvm-windows-full-builds](https://github.com/bitgate/llvm-windows-full-builds) (recommended) - [vovkos/llvm-package-windows](https://github.com/vovkos/llvm-package-windows) @@ -297,13 +303,15 @@ this order: **Windows:** -- Native Windows LLVM is static-only for PECOS's purposes -- Use WSL2/Linux for the full HUGR test lane -- For targeted static LLVM builds, configure a full development package with `pecos llvm configure C:\path\to\llvm` +- The official LLVM installer is not sufficient for PECOS development builds +- Use `scripts\ci\install-llvm-21-windows.ps1` for the conda-forge LLVM 21.1 toolchain +- Configure `~\.pecos\deps\llvm-21.1\Library`, not the conda environment root ### Security -All downloaded LLVM packages are verified with SHA256 checksums to ensure integrity. +Linux managed packages are checked against apt metadata hashes. Windows helper +packages are installed by micromamba from conda-forge, using conda package +metadata and checksums. ## Troubleshooting diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index 8deee25d3..5b5a69103 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -4,6 +4,7 @@ param( ) $ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" if (-not $Version) { if ($env:LLVM_RELEASE_VERSION) { @@ -14,12 +15,17 @@ if (-not $Version) { } } -$ExpectedSha256 = "749d22f565fcd5718dbed06512572d0e5353b502c03fe1f7f17ee8b8aca21a47" $RequiredVersion = "21.1" -$Asset = "clang+llvm-$Version-x86_64-pc-windows-msvc.tar.xz" -$Url = "https://github.com/llvm/llvm-project/releases/download/llvmorg-$Version/$Asset" -$LlvmConfig = Join-Path $InstallDir "bin\llvm-config.exe" -$LlvmConfigReal = Join-Path $InstallDir "bin\llvm-config.real.exe" +$MambaVersion = if ($env:MAMBA_VERSION) { $env:MAMBA_VERSION } else { "latest" } +$MambaRoot = if ($env:MAMBA_ROOT_PREFIX) { + $env:MAMBA_ROOT_PREFIX +} +else { + Join-Path $env:USERPROFILE ".cache\pecos-micromamba" +} +$LlvmPrefix = Join-Path $InstallDir "Library" +$LlvmConfig = Join-Path $LlvmPrefix "bin\llvm-config.exe" +$Libclang = Join-Path $LlvmPrefix "bin\libclang.dll" function Find-SevenZip { foreach ($Name in @("7z.exe", "7zz.exe", "7za.exe")) { @@ -49,129 +55,198 @@ function Find-SevenZip { return $null } -function Get-Sha256Hex { - param([string]$Path) +function Invoke-DownloadFile { + param( + [string]$Url, + [string]$Output + ) - $Stream = [System.IO.File]::OpenRead($Path) - try { - $Sha256 = [System.Security.Cryptography.SHA256]::Create() - try { - return -join ($Sha256.ComputeHash($Stream) | ForEach-Object { $_.ToString("x2") }) - } - finally { - $Sha256.Dispose() + $Curl = Get-Command curl.exe -ErrorAction SilentlyContinue + if ($Curl) { + & $Curl.Source --fail --location --retry 5 --retry-delay 5 --output $Output $Url + if ($LASTEXITCODE -ne 0) { + throw "curl failed to download $Url with exit code $LASTEXITCODE" } } - finally { - $Stream.Dispose() + else { + Invoke-WebRequest -Uri $Url -OutFile $Output } } -function Install-LlvmConfigWrapper { - $WrapperSource = Join-Path $PSScriptRoot "llvm-config-wrapper.rs" - if (-not (Test-Path $WrapperSource)) { - throw "LLVM config wrapper source not found: $WrapperSource" - } +function Expand-TarBz2 { + param( + [string]$Archive, + [string]$Destination + ) - if (-not (Test-Path $LlvmConfigReal)) { - if (-not (Test-Path $LlvmConfig)) { - throw "llvm-config.exe not found at $LlvmConfig" + New-Item -ItemType Directory -Force -Path $Destination | Out-Null + + $SevenZip = Find-SevenZip + if ($SevenZip) { + $Stage = Join-Path $Destination "_stage" + New-Item -ItemType Directory -Force -Path $Stage | Out-Null + + & $SevenZip x -y -bb0 "-o$Stage" $Archive + if ($LASTEXITCODE -ne 0) { + throw "7-Zip failed to decompress $Archive with exit code $LASTEXITCODE" + } + + $TarArchive = Get-ChildItem -Path $Stage -File -Filter "*.tar" | Select-Object -First 1 + if (-not $TarArchive) { + throw "7-Zip did not produce a .tar payload from $Archive" + } + + & $SevenZip x -y -bb0 "-o$Destination" $TarArchive.FullName + if ($LASTEXITCODE -ne 0) { + throw "7-Zip failed to extract $($TarArchive.Name) with exit code $LASTEXITCODE" } - Move-Item -Force -Path $LlvmConfig -Destination $LlvmConfigReal - } - elseif (Test-Path $LlvmConfig) { - Remove-Item -Force $LlvmConfig - } - $Rustc = Get-Command rustc.exe -ErrorAction SilentlyContinue - if (-not $Rustc) { - throw "rustc.exe is required to repair the LLVM Windows llvm-config system library output" + Remove-Item -Recurse -Force $Stage + return } - Write-Host "Installing PECOS llvm-config wrapper" - & $Rustc.Source --edition=2021 -O -o $LlvmConfig $WrapperSource - if ($LASTEXITCODE -ne 0) { - throw "rustc failed to build llvm-config wrapper with exit code $LASTEXITCODE" + $Tar = Get-Command tar.exe -ErrorAction SilentlyContinue + if (-not $Tar) { + throw "7-Zip or tar.exe is required to extract micromamba on Windows" } - $SystemLibs = (& $LlvmConfig --system-libs --link-static).Trim() - $Libxml2Static = Join-Path $InstallDir "lib\libxml2s.lib" - if ((-not (Test-Path $Libxml2Static)) -and $SystemLibs -match "(^|\s)libxml2s\.lib($|\s)") { - throw "LLVM config wrapper did not filter missing libxml2s.lib from --system-libs" + & $Tar.Source -xjf $Archive -C $Destination + if ($LASTEXITCODE -ne 0) { + throw "tar.exe failed to extract $Archive with exit code $LASTEXITCODE" } } -if (Test-Path $LlvmConfig) { - $FoundVersion = (& $LlvmConfig --version).Trim() - if ($FoundVersion.StartsWith($RequiredVersion)) { - Install-LlvmConfigWrapper - Write-Host "LLVM $FoundVersion already installed at $InstallDir" - exit 0 +function Get-Micromamba { + param([string]$TempDir) + + foreach ($Name in @("micromamba.exe", "micromamba")) { + $Command = Get-Command $Name -ErrorAction SilentlyContinue + if ($Command) { + return $Command.Source + } } -} -$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) "pecos-llvm-$([System.Guid]::NewGuid())" -$Archive = Join-Path $TempDir $Asset -$TarDir = Join-Path $TempDir "tar" -$ExtractDir = Join-Path $TempDir "extract" + $Url = "https://micro.mamba.pm/api/micromamba/win-64/$MambaVersion" + $Archive = Join-Path $TempDir "micromamba.tar.bz2" + $ExtractDir = Join-Path $TempDir "micromamba" -New-Item -ItemType Directory -Force -Path $TempDir | Out-Null -New-Item -ItemType Directory -Force -Path $TarDir | Out-Null -New-Item -ItemType Directory -Force -Path $ExtractDir | Out-Null + Write-Host "Downloading micromamba for win-64" + Invoke-DownloadFile -Url $Url -Output $Archive + Expand-TarBz2 -Archive $Archive -Destination $ExtractDir -try { - Write-Host "Downloading official LLVM $Version Windows development archive: $Asset" - $Curl = Get-Command curl.exe -ErrorAction SilentlyContinue - if ($Curl) { - & $Curl.Source --fail --location --retry 5 --retry-delay 5 --output $Archive $Url - } - else { - Invoke-WebRequest -Uri $Url -OutFile $Archive + $MambaBin = Join-Path $ExtractDir "Library\bin\micromamba.exe" + if (-not (Test-Path $MambaBin)) { + throw "micromamba.exe not found at $MambaBin after extraction" } - $ActualSha256 = Get-Sha256Hex $Archive - if ($ActualSha256 -ne $ExpectedSha256) { - throw "SHA256 mismatch for $Asset. Expected $ExpectedSha256, got $ActualSha256" + return $MambaBin +} + +function Test-LlvmInstall { + if (-not (Test-Path $LlvmConfig)) { + return $false } - $SevenZip = Find-SevenZip - if (-not $SevenZip) { - throw "7-Zip is required to extract $Asset on Windows. Windows tar.exe can hang for hours on this archive in CI; install 7-Zip or provide an existing LLVM 21.1 install." + if (-not (Test-Path $Libclang)) { + return $false } - Write-Host "Extracting compressed LLVM archive with $SevenZip" - & $SevenZip x -y -bb0 "-o$TarDir" $Archive - if ($LASTEXITCODE -ne 0) { - throw "7-Zip failed to decompress $Asset with exit code $LASTEXITCODE" + try { + $FoundVersion = (& $LlvmConfig --version).Trim() + if (-not $FoundVersion.StartsWith($RequiredVersion)) { + return $false + } + + $LibDir = (& $LlvmConfig --libdir).Trim() + if (-not (Test-Path $LibDir)) { + return $false + } + + $StaticLibs = (& $LlvmConfig --libnames --link-static).Trim() + if ([string]::IsNullOrWhiteSpace($StaticLibs)) { + return $false + } } + catch { + return $false + } + + return $true +} - $TarArchive = Get-ChildItem -Path $TarDir -File -Filter "*.tar" | Select-Object -First 1 - if (-not $TarArchive) { - throw "7-Zip did not produce a .tar payload from $Asset" +function Write-LlvmDiagnostics { + Write-Host "LLVM prefix: $LlvmPrefix" + if (Test-Path $LlvmConfig) { + & $LlvmConfig --version + & $LlvmConfig --shared-mode + & $LlvmConfig --libdir + & $LlvmConfig --libnames --link-static core + & $LlvmConfig --system-libs --link-static + } + else { + Write-Host "llvm-config.exe not found at $LlvmConfig" } - Write-Host "Extracting LLVM payload with $SevenZip" - & $SevenZip x -y -bb0 "-o$ExtractDir" $TarArchive.FullName - if ($LASTEXITCODE -ne 0) { - throw "7-Zip failed to extract $($TarArchive.Name) with exit code $LASTEXITCODE" + if (Test-Path $Libclang) { + Write-Host "Found libclang: $Libclang" + } + else { + Write-Host "libclang.dll not found at $Libclang" } +} + +if (Test-LlvmInstall) { + $FoundVersion = (& $LlvmConfig --version).Trim() + Write-Host "conda-forge LLVM $FoundVersion already installed at $LlvmPrefix" + exit 0 +} + +if (Test-Path $InstallDir) { + Write-Host "Removing invalid or incompatible LLVM environment at $InstallDir" + Remove-Item -Recurse -Force $InstallDir +} + +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $InstallDir) | Out-Null +New-Item -ItemType Directory -Force -Path $MambaRoot | Out-Null + +$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) "pecos-micromamba-$([System.Guid]::NewGuid())" +New-Item -ItemType Directory -Force -Path $TempDir | Out-Null - $PayloadRoots = @(Get-ChildItem -Path $ExtractDir -Directory) - if ($PayloadRoots.Count -ne 1) { - throw "Expected one LLVM payload directory in $ExtractDir, found $($PayloadRoots.Count)" +try { + $MambaBin = Get-Micromamba -TempDir $TempDir + + Write-Host "Installing conda-forge LLVM $Version, clang $Version, and libclang $Version to $InstallDir" + $OldMambaRoot = $env:MAMBA_ROOT_PREFIX + $env:MAMBA_ROOT_PREFIX = $MambaRoot + try { + & $MambaBin create ` + -y ` + -p $InstallDir ` + --override-channels ` + -c conda-forge ` + "llvmdev=$Version" ` + "clang=$Version" ` + "libclang=$Version" + if ($LASTEXITCODE -ne 0) { + throw "micromamba failed to create LLVM environment with exit code $LASTEXITCODE" + } + } + finally { + if ($null -eq $OldMambaRoot) { + Remove-Item Env:MAMBA_ROOT_PREFIX -ErrorAction SilentlyContinue + } + else { + $env:MAMBA_ROOT_PREFIX = $OldMambaRoot + } } - $PayloadDir = $PayloadRoots[0].FullName - if (Test-Path $InstallDir) { - Remove-Item -Recurse -Force $InstallDir + if (-not (Test-LlvmInstall)) { + Write-LlvmDiagnostics + throw "conda-forge LLVM install did not provide usable LLVM $RequiredVersion static libraries and libclang" } - New-Item -ItemType Directory -Force -Path (Split-Path -Parent $InstallDir) | Out-Null - Move-Item -Path $PayloadDir -Destination $InstallDir - & $LlvmConfig --version - & $LlvmConfig --shared-mode - Install-LlvmConfigWrapper - Write-Host "Installed LLVM $Version to $InstallDir" + Write-LlvmDiagnostics + Write-Host "Installed conda-forge LLVM $Version to $LlvmPrefix" } finally { if (Test-Path $TempDir) { diff --git a/scripts/ci/llvm-config-wrapper.rs b/scripts/ci/llvm-config-wrapper.rs deleted file mode 100644 index c136c97aa..000000000 --- a/scripts/ci/llvm-config-wrapper.rs +++ /dev/null @@ -1,70 +0,0 @@ -use std::env; -use std::io::{self, Write}; -use std::path::Path; -use std::process::{Command, ExitCode}; - -fn llvm_lib_exists(current_exe: &Path, lib_name: &str) -> bool { - let Some(bin_dir) = current_exe.parent() else { - return false; - }; - let Some(prefix_dir) = bin_dir.parent() else { - return false; - }; - prefix_dir.join("lib").join(lib_name).exists() -} - -fn filter_system_libs(current_exe: &Path, stdout: &[u8]) -> Vec { - let output = String::from_utf8_lossy(stdout); - let mut filtered = output - .split_whitespace() - .filter(|token| { - !token.eq_ignore_ascii_case("libxml2s.lib") || llvm_lib_exists(current_exe, token) - }) - .collect::>() - .join(" "); - - if output.ends_with('\n') { - filtered.push('\n'); - } - filtered.into_bytes() -} - -fn main() -> ExitCode { - let args = env::args_os().skip(1).collect::>(); - let current_exe = match env::current_exe() { - Ok(path) => path, - Err(error) => { - let _ = writeln!( - io::stderr(), - "failed to locate llvm-config wrapper: {error}" - ); - return ExitCode::FAILURE; - } - }; - let real_config = current_exe.with_file_name("llvm-config.real.exe"); - let output = match Command::new(&real_config).args(&args).output() { - Ok(output) => output, - Err(error) => { - let _ = writeln!( - io::stderr(), - "failed to run {}: {error}", - real_config.display() - ); - return ExitCode::FAILURE; - } - }; - - let _ = io::stderr().write_all(&output.stderr); - let is_system_libs = args.iter().any(|arg| arg == "--system-libs"); - let stdout = if is_system_libs && output.status.success() { - filter_system_libs(¤t_exe, &output.stdout) - } else { - output.stdout - }; - let _ = io::stdout().write_all(&stdout); - - match output.status.code() { - Some(code) if (0..=255).contains(&code) => ExitCode::from(code as u8), - Some(_) | None => ExitCode::FAILURE, - } -} From e5936a31ea2c8f4b0996b6c58b26c54e1c8acdce Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 20:49:13 -0600 Subject: [PATCH 057/388] Fix Windows micromamba path detection --- scripts/ci/install-llvm-21-windows.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index 5b5a69103..e5fb57f7a 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -86,7 +86,7 @@ function Expand-TarBz2 { $Stage = Join-Path $Destination "_stage" New-Item -ItemType Directory -Force -Path $Stage | Out-Null - & $SevenZip x -y -bb0 "-o$Stage" $Archive + & $SevenZip x -y -bb0 "-o$Stage" $Archive | Out-Null if ($LASTEXITCODE -ne 0) { throw "7-Zip failed to decompress $Archive with exit code $LASTEXITCODE" } @@ -96,7 +96,7 @@ function Expand-TarBz2 { throw "7-Zip did not produce a .tar payload from $Archive" } - & $SevenZip x -y -bb0 "-o$Destination" $TarArchive.FullName + & $SevenZip x -y -bb0 "-o$Destination" $TarArchive.FullName | Out-Null if ($LASTEXITCODE -ne 0) { throw "7-Zip failed to extract $($TarArchive.Name) with exit code $LASTEXITCODE" } @@ -110,7 +110,7 @@ function Expand-TarBz2 { throw "7-Zip or tar.exe is required to extract micromamba on Windows" } - & $Tar.Source -xjf $Archive -C $Destination + & $Tar.Source -xjf $Archive -C $Destination | Out-Null if ($LASTEXITCODE -ne 0) { throw "tar.exe failed to extract $Archive with exit code $LASTEXITCODE" } From ca52fd67a36a1077e1652c4faccf9f3d888db776 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 20:51:33 -0600 Subject: [PATCH 058/388] Address deep review: fix WindowedLogicalSubgraphStrategy to flip the global observable bit not the list position (real multi-observable bug, + length/>64 guards + tests), propagate decode_each errors instead of a sentinel, gate strict on can_window, remove dead _UfDebug/decode_full_with_correction surface, single-source the windowing label, assert the predecoder positive-weight premise, and document window_count as an estimate --- .../src/logical_algorithm.rs | 94 ++++++++++++++++--- .../src/logical_subgraph/window_plan.rs | 17 +++- crates/pecos-uf-decoder/src/decoder.rs | 34 +++---- .../src/fault_tolerance_bindings.rs | 89 +++++++----------- ...test_logical_subgraph_region_comparison.py | 14 +++ 5 files changed, 155 insertions(+), 93 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index ed41d038f..f516d2275 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -604,33 +604,61 @@ pub struct WindowedLogicalSubgraphStrategy { subgraph_decoders: Vec>, /// Per-subgraph detector maps: `subgraph_detector_maps`[i][local] = global. detector_maps: Vec>, + /// Global observable (logical) index each subgraph decodes. Required because + /// callers may pass only the non-empty subgraphs (empty-region observables + /// dropped), so the subgraph's list position is NOT its observable index. + observable_indices: Vec, /// Per-subgraph sub-syndrome buffers (reusable). sub_syndromes: Vec>, - /// Number of observables. - _num_observables: usize, } impl WindowedLogicalSubgraphStrategy { - /// Build from pre-extracted subgraph DEMs and detector maps. + /// Build from pre-extracted subgraph DEMs, detector maps, and the global + /// observable index each subgraph decodes. /// - /// `subgraph_dems`: per-observable DEM strings (graphlike). - /// `detector_maps`: per-observable local→global detector index maps. + /// `subgraph_dems`: per-subgraph DEM strings (graphlike). + /// `detector_maps`: per-subgraph local→global detector index maps. + /// `observable_indices`: the global observable bit each subgraph flips + /// (each subgraph reports its observable as local bit 0). MUST line up + /// with `subgraph_dems` — when empty-region observables are filtered out, + /// pass the surviving observables' true indices, not `0..n`. /// `factory`: creates the inner decoder for each subgraph DEM. + /// + /// # Errors + /// + /// Returns `DecoderError` if the factory fails, if the three input vectors + /// disagree in length, or if any observable index is >= 64 (the u64 + /// observable mask cannot hold it). pub fn new( subgraph_dems: Vec, detector_maps: Vec>, + observable_indices: Vec, mut factory: F, ) -> Result where F: FnMut(&str) -> Result, DecoderError>, { - let num_observables = subgraph_dems.len(); - let mut decoders = Vec::with_capacity(num_observables); - let mut sub_syndromes = Vec::with_capacity(num_observables); + let num = subgraph_dems.len(); + if detector_maps.len() != num || observable_indices.len() != num { + return Err(DecoderError::InvalidConfiguration(format!( + "WindowedLogicalSubgraphStrategy: mismatched inputs (dems={num}, \ + maps={}, obs={})", + detector_maps.len(), + observable_indices.len(), + ))); + } + if let Some(&bad) = observable_indices.iter().find(|&&o| o >= 64) { + return Err(DecoderError::InvalidConfiguration(format!( + "WindowedLogicalSubgraphStrategy: observable index {bad} >= 64 \ + exceeds the u64 observable-mask capacity" + ))); + } + let mut decoders = Vec::with_capacity(num); + let mut sub_syndromes = Vec::with_capacity(num); for (i, dem_str) in subgraph_dems.iter().enumerate() { let dec = factory(dem_str)?; - let n = detector_maps.get(i).map_or(0, std::vec::Vec::len); + let n = detector_maps[i].len(); sub_syndromes.push(vec![0u8; n]); decoders.push(dec); } @@ -638,8 +666,8 @@ impl WindowedLogicalSubgraphStrategy { Ok(Self { subgraph_decoders: decoders, detector_maps, + observable_indices, sub_syndromes, - _num_observables: num_observables, }) } } @@ -669,10 +697,12 @@ impl DecodeStrategy for WindowedLogicalSubgraphStrategy { }; } - // Decode this subgraph + // Decode this subgraph: it reports its observable as local bit 0; + // map that to the subgraph's *global* observable bit (not its list + // position `i`, which differs once empty observables are filtered). let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - obs_mask |= 1 << i; + obs_mask |= 1u64 << self.observable_indices[i]; } } @@ -726,6 +756,46 @@ mod tests { assert_eq!(dec.decode_shot(&[0, 1, 0, 1]).unwrap(), 0b01); } + #[test] + fn windowed_strategy_maps_to_global_observable_index() { + // Two surviving subgraphs whose true (global) observable indices are + // NON-contiguous -- as happens when earlier observables had empty + // regions and were filtered out. Each reports its observable as local + // bit 0; the strategy must flip the GLOBAL bit, not the list position. + // (The pre-fix `1 << i` would have produced bits {0,1} = 0b0011.) + let mut strat = WindowedLogicalSubgraphStrategy::new( + vec!["error(0.1) D0 L0".to_string(), "error(0.1) D0 L0".to_string()], + vec![vec![0usize], vec![1usize]], + vec![1usize, 3usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ) + .unwrap(); + let obs = strat.decode(&[1, 1]).unwrap(); + assert_eq!(obs, (1u64 << 1) | (1u64 << 3)); + } + + #[test] + fn windowed_strategy_rejects_observable_index_over_63() { + let r = WindowedLogicalSubgraphStrategy::new( + vec!["error(0.1) D0 L0".to_string()], + vec![vec![0usize]], + vec![64usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ); + assert!(r.is_err()); + } + + #[test] + fn windowed_strategy_rejects_mismatched_input_lengths() { + let r = WindowedLogicalSubgraphStrategy::new( + vec!["error(0.1) D0 L0".to_string()], + vec![vec![0usize], vec![1usize]], // 2 maps for 1 dem + vec![0usize], + |_dem| Ok(Box::new(FixedDecoder(1)) as Box), + ); + assert!(r.is_err()); + } + #[test] fn test_hadamard_frame() { let mut frame = 0b01u64; // X correction on bit 0 diff --git a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs index abc0e596f..1159582fb 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs @@ -144,10 +144,19 @@ impl LogicalSubgraphWindowPlan { self.entries.iter().map(|e| e.detector_map.clone()).collect() } - /// Number of time windows observable `i` would use at `step` rounds per - /// window. Mirrors the core-window loop in the sliding-window decoders - /// (`t_start` from 0 by `step` while `< total_t`, `total_t = max_time + 1`), - /// counting only windows that contain at least one detector. + /// Estimated number of time windows observable `i` would use at `step` + /// rounds per window. + /// + /// This is an ESTIMATE, not a guaranteed match of the exact window count an + /// `OverlappingWindowedDecoder` builds: it counts core ranges that contain a + /// detector and ignores the buffer overlap, and it requires an explicit + /// `step` (the real decoder auto-derives `step` from the graph when none is + /// given). It is sufficient for the load-bearing use here -- the + /// [`Self::effective_windowing`] FullFallback-vs-RealWindowed *boolean*, + /// which depends only on `total_t` vs `step`, not on buffer details. Exact + /// counts should single-source the decoder's own loop (a Layer C item when + /// the windowing construction is revisited; see the proper-solution design + /// doc). #[must_use] pub fn window_count(&self, i: usize, step: usize) -> usize { self.entries diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 35e610f31..dd57ee9bc 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -373,6 +373,17 @@ impl UfDecoder { /// predecoding them individually gives the same result as joint decoding. #[must_use] pub fn predecode_clusters(&self, syndrome: &[u8]) -> Option { + // Invariant the shortcut proofs rely on: edge weights are non-negative + // (true for `ln((1-p)/p)` when p < 0.5, i.e. real sub-threshold error + // priors). With a negative weight the "lightest edge is the min-weight + // correction" / "direct pair <= split" arguments break. Asserted in + // debug builds; if it ever fires, the predecoder must be disabled for + // that graph (the full decoder handles negative weights correctly). + debug_assert!( + self.edges.iter().all(|e| e.weight >= 0.0), + "predecoder requires non-negative edge weights (p < 0.5)" + ); + let boundary = self.num_detectors as u32; // Mark defects. @@ -955,29 +966,6 @@ impl UfDecoder { self.edges.get(edge_idx).map_or(0, |e| e.obs_mask) } - /// Debug: decode forcing the full grow+peel path (bypassing the - /// predecoder), returning the observable mask and the correction edges as - /// `(node1, node2, obs_mask, weight)`. For diagnostics and tests. - pub fn decode_full_with_correction(&mut self, syndrome: &[u8]) -> (u64, Vec<(u32, u32, u64, f64)>) { - self.reset(); - for (i, &v) in syndrome.iter().enumerate() { - if v != 0 && i < self.num_detectors { - self.parity[i] = true; - self.is_defect[i] = true; - } - } - self.grow_clusters(); - let (obs, edge_idxs) = self.peel_correction_with_edges(); - let edges = edge_idxs - .iter() - .map(|&i| { - let e = &self.edges[i]; - (e.node1, e.node2, e.obs_mask, e.weight) - }) - .collect(); - (obs, edges) - } - /// Get node1 of an edge. #[must_use] pub fn edge_node1(&self, edge_idx: usize) -> u32 { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 4d954ab30..abdfa0c00 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2787,7 +2787,12 @@ impl PySampleBatch { let mut syndrome = vec![0u8; self.num_detectors]; for i in 0..self.num_shots { self.extract_syndrome(i, &mut syndrome); - predictions.push(decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX)); + // Propagate a decode failure rather than masking it as a sentinel + // observable value (which would read as a spurious disagreement). + let predicted = decoder + .decode_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + predictions.push(predicted); } Ok(predictions) } @@ -4332,46 +4337,6 @@ fn assert_dems_equivalent( } } -// ============================================================================= -// UF debug wrapper (diagnostics only) -// ============================================================================= - -/// Debug wrapper over the matching-graph Union-Find decoder. -/// -/// Exposes the full grow+peel correction (bypassing the predecoder) so tests -/// can inspect exactly which edges UF commits and compare against MWPM. -#[pyclass(name = "_UfDebug", module = "pecos_rslib.qec")] -pub struct PyUfDebug { - inner: pecos_decoders::UfDecoder, -} - -#[pymethods] -impl PyUfDebug { - #[new] - #[pyo3(signature = (dem, config="fast"))] - fn new(dem: &str, config: &str) -> PyResult { - let cfg = match config { - "fast" => pecos_decoders::UfDecoderConfig::fast(), - "balanced" => pecos_decoders::UfDecoderConfig::balanced(), - "windowed" => pecos_decoders::UfDecoderConfig::windowed(), - other => { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "unknown UF config: {other}" - ))); - } - }; - let inner = pecos_decoders::UfDecoder::from_dem(dem, cfg) - .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(Self { inner }) - } - - /// Decode forcing the full grow+peel path (no predecoder). Returns - /// `(obs_mask, [(node1, node2, obs_mask, weight), ...])`. - fn decode_full(&mut self, syndrome: Vec) -> (u64, Vec<(u32, u32, u64, f64)>) { - self.inner.decode_full_with_correction(&syndrome) - } -} - // ============================================================================= // CSS UF Decoder (UIUF) // ============================================================================= @@ -5385,30 +5350,47 @@ impl PyLogicalCircuitDecoder { let step = decode_budget.code_distance.max(1); can_window = plan.effective_windowing(step) == EffectiveWindowing::RealWindowed; - if strict { + // `strict` rejects only when genuine windowing was POSSIBLE (the + // circuit is deep enough) but is being skipped. When `!can_window` + // the circuit is a single window anyway, so a full decode IS the + // bounded-latency answer -- no degradation to reject. + if strict && can_window { return Err(PyErr::new::( "bounded-latency ('windowed') budget requested with strict=True, \ but accurate windowed logical-subgraph decoding is not yet \ - available (windowed-LOM anti-snake machinery pending). It would \ - fall back to a full per-observable decode (unbounded latency). \ - Use budget='unlimited', or pass strict=False to accept the \ - full-decode fallback." + available (windowed-LOM anti-snake machinery pending). This \ + circuit is deep enough to time-window (can_window=True), so a \ + full per-observable decode would forgo the requested latency \ + bound. Use budget='unlimited', or pass strict=False to accept \ + the full-decode fallback." .to_string(), )); } let sub_dems = plan.sub_dems(); let det_maps = plan.detector_maps(); - effective_windowing = String::from("full_fallback"); + let obs_indices: Vec = + plan.entries().iter().map(|e| e.observable_idx).collect(); + // The fallback runs a full (non-windowed) inner per observable, so + // the actual window count is 1 each by construction. (The Layer C + // real-windowed path must instead derive these from the windowed + // inners.) The label is single-sourced from the plan's enum. + effective_windowing = EffectiveWindowing::FullFallback.as_str().to_string(); actual_num_windows = vec![1usize; sub_dems.len()]; let fallback_inner = inner_decoder.to_string(); - let wosd = WindowedLogicalSubgraphStrategy::new(sub_dems, det_maps, |dem_str| { - let dec = create_observable_decoder(dem_str, &fallback_inner) - .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string()))?; - Ok(Box::new(SendWrapper(dec)) - as Box) - }) + let wosd = WindowedLogicalSubgraphStrategy::new( + sub_dems, + det_maps, + obs_indices, + |dem_str| { + let dec = create_observable_decoder(dem_str, &fallback_inner).map_err( + |e| pecos_decoders::DecoderError::InternalError(e.to_string()), + )?; + Ok(Box::new(SendWrapper(dec)) + as Box) + }, + ) .map_err(|e| PyErr::new::(e.to_string()))?; Box::new(wosd) @@ -5732,7 +5714,6 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; - qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index 1e5f7607d..f05da9998 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -360,5 +360,19 @@ def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): ) +def test_decode_each_matches_decode_count(): + """`SampleBatch.decode_each` returns per-shot predictions consistent with the + aggregate `decode_count` -- it is the per-shot primitive used to localize + where two decoders disagree.""" + b = _cx_circuit() + dem = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + n = 3000 + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=5) + preds = batch.decode_each(dem, "pecos_uf:bp") + assert len(preds) == n + wrong = sum(1 for i, p in enumerate(preds) if p != batch.get_observable_mask(i)) + assert wrong == batch.decode_count(dem, "pecos_uf:bp") + + if __name__ == "__main__": pytest.main([__file__, "-v", "-s"]) From 90125b928ead76e0235c9c936856e722f9fbe0d8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 5 Jun 2026 23:01:19 -0600 Subject: [PATCH 059/388] Second-review hygiene: harden parse_dem_metadata (max+1 observable count + reject repeat/shift_detectors), move predecoder positive-weight debug_assert to construction, add local <64 assert at the standalone windowed shift site, fix XORed clippy doc lint, and clarify actual_num_windows doc --- crates/pecos-decoder-core/src/dem.rs | 50 +++++++++++++------ crates/pecos-uf-decoder/src/decoder.rs | 21 ++++---- .../src/logical_subgraph_windowed.rs | 13 +++-- .../src/fault_tolerance_bindings.rs | 9 ++-- 4 files changed, 60 insertions(+), 33 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index acf4003e3..c380ae578 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -103,7 +103,11 @@ pub mod utils { /// Returns [`DecoderError`] if the DEM format is invalid pub fn parse_dem_metadata(dem: &str) -> Result<(usize, usize), DecoderError> { let mut max_detector = None; - let mut observables = std::collections::BTreeSet::new(); + // Count as `max index + 1` (not distinct-id count) to match the other + // parsers (`SparseDem`, `DemCheckMatrix`, `DemMatchingGraph`) and to size + // index-addressed buffers correctly when ids are non-contiguous + // (e.g. only `L2` present -> 3 observables, not 1). + let mut max_observable: Option = None; for line in dem.lines() { let line = line.trim(); @@ -111,6 +115,16 @@ pub mod utils { continue; } + // This is a flat single-pass counter; reject loop/offset commands + // rather than miscounting them (same contract as the other parsers). + if line.starts_with("repeat") || line.starts_with("shift_detectors") { + return Err(DecoderError::InvalidConfiguration( + "parse_dem_metadata requires a flattened DEM: `repeat` / \ + `shift_detectors` are not supported. Flatten the DEM first." + .into(), + )); + } + let parts: Vec<&str> = line.split_whitespace().collect(); if parts.is_empty() { continue; @@ -124,8 +138,10 @@ pub mod utils { }; match command { - "error" => { - // Parse error line for detector and observable indices + // `error` and `logical_observable` both contribute observable + // ids; `logical_observable` declares deterministic logicals that + // Stim emits with no flipping mechanism but still count. + "error" | "logical_observable" => { for part in &parts[1..] { if let Some(d_str) = part.strip_prefix('D') { if let Ok(d) = d_str.parse::() { @@ -134,7 +150,7 @@ pub mod utils { } else if let Some(l_str) = part.strip_prefix('L') && let Ok(l) = l_str.parse::() { - observables.insert(l); + max_observable = Some(max_observable.map_or(l, |m: usize| m.max(l))); } } } @@ -148,23 +164,12 @@ pub mod utils { } } } - "logical_observable" => { - // Declared observable with no flipping mechanism (Stim emits - // these for deterministic logicals) still counts. - for part in &parts[1..] { - if let Some(l_str) = part.strip_prefix('L') - && let Ok(l) = l_str.parse::() - { - observables.insert(l); - } - } - } _ => {} } } let detector_count = max_detector.map_or(0, |m| m + 1); - let observable_count = observables.len(); + let observable_count = max_observable.map_or(0, |m| m + 1); Ok((detector_count, observable_count)) } @@ -1085,6 +1090,17 @@ mod tests { assert_eq!(obs, 2, "parse_dem_metadata must count L1"); } + #[test] + fn test_parser_observable_count_is_max_plus_one_for_noncontiguous_ids() { + // Only L2 present: index-addressed buffers need 3 slots, not 1. All + // parsers must agree on `max + 1`, not distinct-id count. + let dem = "error(0.01) D0 L2\ndetector(0,0,0) D0\n"; + assert_eq!(DemMatchingGraph::from_dem_str(dem).unwrap().num_observables, 3); + assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_observables, 3); + let (_d, obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!(obs, 3, "parse_dem_metadata must agree (max+1, not distinct count)"); + } + #[test] fn test_non_flattened_dem_rejected() { // repeat blocks and shift_detectors would corrupt detector ids if parsed @@ -1093,9 +1109,11 @@ mod tests { assert!(SparseDem::from_dem_str(repeat_dem).is_err()); assert!(DemCheckMatrix::from_dem_str(repeat_dem).is_err()); assert!(DemMatchingGraph::from_dem_str(repeat_dem).is_err()); + assert!(utils::parse_dem_metadata(repeat_dem).is_err()); let shift_dem = "error(0.01) D0 L0\nshift_detectors 1\nerror(0.01) D0 L0\n"; assert!(SparseDem::from_dem_str(shift_dem).is_err()); + assert!(utils::parse_dem_metadata(shift_dem).is_err()); } #[test] diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index dd57ee9bc..5a1c4e29a 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -224,6 +224,16 @@ impl UfDecoder { temp_adj[n2 as usize].push((idx, n1)); } + // Construction-time invariant: edge weights are non-negative (true for + // `ln((1-p)/p)` when p < 0.5, i.e. real sub-threshold priors). The + // predecoder's shortcut proofs ("lightest edge is the min-weight + // correction", "direct pair <= boundary split") depend on it; a negative + // weight would silently break them. Checked once here, not per shot. + debug_assert!( + edges.iter().all(|e| e.weight >= 0.0), + "UfDecoder requires non-negative edge weights (error priors p < 0.5)" + ); + // Sort each node's adjacency by weight (lightest first). for adj in &mut temp_adj { adj.sort_by(|a, b| { @@ -373,17 +383,6 @@ impl UfDecoder { /// predecoding them individually gives the same result as joint decoding. #[must_use] pub fn predecode_clusters(&self, syndrome: &[u8]) -> Option { - // Invariant the shortcut proofs rely on: edge weights are non-negative - // (true for `ln((1-p)/p)` when p < 0.5, i.e. real sub-threshold error - // priors). With a negative weight the "lightest edge is the min-weight - // correction" / "direct pair <= split" arguments break. Asserted in - // debug builds; if it ever fires, the predecoder must be disabled for - // that graph (the full decoder handles negative weights correctly). - debug_assert!( - self.edges.iter().all(|e| e.weight >= 0.0), - "predecoder requires non-negative edge weights (p < 0.5)" - ); - let boundary = self.num_detectors as u32; // Mark defects. diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index 5f84fbba1..2a41f3b35 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -24,7 +24,7 @@ //! decoding: every window is decoded with a buffer for matching context, but //! only correction edges whose BOTH endpoints lie in the window core are //! committed (Tan et al., arXiv:2209.09219). The per-observable committed -//! observable flips are XORed. +//! observable flips are XOR-combined. //! //! An earlier implementation windowed the full DEM first and then ran a subgraph //! decoder per window, combining by a naive full-window observable XOR with no @@ -59,7 +59,7 @@ struct SubgraphWindowed { /// /// Partitions the DEM per observable, then windows each subgraph with an /// [`OverlappingWindowedDecoder`] (sliding-window core-commit). Per-observable -/// committed observable flips are XORed into the final mask. +/// committed observable flips are XOR-combined into the final mask. pub struct WindowedLogicalSubgraphDecoder { subgraphs: Vec, /// Reusable subgraph-local syndrome buffer (sized to the largest subgraph). @@ -141,7 +141,14 @@ impl ObservableDecoder for WindowedLogicalSubgraphDecoder { }; } // The subgraph decodes a single observable as its local bit 0; map - // that back to this observable's global bit. + // that back to this observable's global bit. `observable_idx < 64` is + // guaranteed upstream (`subgraphs_from_membership` rejects >64 + // observables); assert it locally where the u64 shift consumes it. + debug_assert!( + sg.observable_idx < 64, + "observable index {} exceeds u64 observable-mask capacity", + sg.observable_idx + ); let sub_obs = sg.decoder.decode_to_observables(&self.local_syn[..n])?; if sub_obs & 1 != 0 { obs_mask |= 1u64 << sg.observable_idx; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index abdfa0c00..ca3c29c76 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -5142,7 +5142,8 @@ pub struct PyLogicalCircuitDecoder { /// "full_fallback" (per-observable full decode behind a windowed budget), /// or "real_windowed" (genuine sliding-window; not yet enabled). effective_windowing: String, - /// Per-observable window count actually used (1 == full decode). + /// Window count actually used, one entry per non-empty subgraph (1 == full + /// decode); not indexed by global observable id. actual_num_windows: Vec, /// Whether genuine time-windowing is *possible* for this circuit (deep /// enough), independent of whether it is enabled. False for "unlimited". @@ -5416,8 +5417,10 @@ impl PyLogicalCircuitDecoder { &self.effective_windowing } - /// Per-observable window count actually used (``1`` == full decode). Empty - /// for the unlimited budget. + /// Window count actually used, one entry per *non-empty* subgraph in + /// surviving-subgraph order (empty-region observables are dropped, so this + /// is not indexed by global observable id). ``1`` == full decode. All ``1`` + /// in the current full-fallback path; empty for the unlimited budget. #[getter] fn actual_num_windows(&self) -> Vec { self.actual_num_windows.clone() From af2fc5baaa6afd0d0ba5e91ec5672bba4aa53313 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 6 Jun 2026 10:00:01 -0600 Subject: [PATCH 060/388] Fix Windows conda LLVM setup --- crates/pecos-cli/src/cli/rust_cmd.rs | 7 +++++++ scripts/ci/install-llvm-21-windows.ps1 | 28 ++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/crates/pecos-cli/src/cli/rust_cmd.rs b/crates/pecos-cli/src/cli/rust_cmd.rs index 4ac013805..c833dcc6d 100644 --- a/crates/pecos-cli/src/cli/rust_cmd.rs +++ b/crates/pecos-cli/src/cli/rust_cmd.rs @@ -98,6 +98,13 @@ fn reject_static_llvm_workspace_test() -> Result<()> { return Ok(()); } + if cfg!(target_os = "windows") && matches!(link_mode, LlvmLinkMode::Static) { + println!( + "Windows MSVC uses static LLVM libraries because llvm-sys does not support dynamic LLVM linking on this target." + ); + return Ok(()); + } + let mode = if matches!(link_mode, LlvmLinkMode::Static) { "static" } else { diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index e5fb57f7a..5c298827d 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -25,6 +25,7 @@ else { } $LlvmPrefix = Join-Path $InstallDir "Library" $LlvmConfig = Join-Path $LlvmPrefix "bin\llvm-config.exe" +$LlvmBin = Join-Path $LlvmPrefix "bin" $Libclang = Join-Path $LlvmPrefix "bin\libclang.dll" function Find-SevenZip { @@ -143,6 +144,8 @@ function Get-Micromamba { } function Test-LlvmInstall { + Repair-LibclangForBindgen + if (-not (Test-Path $LlvmConfig)) { return $false } @@ -174,6 +177,27 @@ function Test-LlvmInstall { return $true } +function Repair-LibclangForBindgen { + if (Test-Path $Libclang) { + return + } + + if (-not (Test-Path $LlvmBin)) { + return + } + + $PackagedLibclang = Get-ChildItem -Path $LlvmBin -File -Filter "libclang-*.dll" | + Sort-Object Name | + Select-Object -First 1 + + if (-not $PackagedLibclang) { + return + } + + Write-Host "Creating bindgen-compatible libclang.dll from $($PackagedLibclang.Name)" + Copy-Item -Force -Path $PackagedLibclang.FullName -Destination $Libclang +} + function Write-LlvmDiagnostics { Write-Host "LLVM prefix: $LlvmPrefix" if (Test-Path $LlvmConfig) { @@ -192,6 +216,10 @@ function Write-LlvmDiagnostics { } else { Write-Host "libclang.dll not found at $Libclang" + if (Test-Path $LlvmBin) { + Get-ChildItem -Path $LlvmBin -File -Filter "*clang*.dll" | + ForEach-Object { Write-Host " candidate: $($_.FullName)" } + } } } From 9cbe64f621a5584488061cfd657a529b34893183 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 6 Jun 2026 10:52:35 -0600 Subject: [PATCH 061/388] Install Windows LLVM zlib import library --- scripts/ci/install-llvm-21-windows.ps1 | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index 5c298827d..6d3511b44 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -26,6 +26,7 @@ else { $LlvmPrefix = Join-Path $InstallDir "Library" $LlvmConfig = Join-Path $LlvmPrefix "bin\llvm-config.exe" $LlvmBin = Join-Path $LlvmPrefix "bin" +$LlvmLib = Join-Path $LlvmPrefix "lib" $Libclang = Join-Path $LlvmPrefix "bin\libclang.dll" function Find-SevenZip { @@ -169,6 +170,13 @@ function Test-LlvmInstall { if ([string]::IsNullOrWhiteSpace($StaticLibs)) { return $false } + + if ($StaticLibs -match "(^|\s)z\.lib($|\s)") { + $ZlibImportLib = Join-Path $LlvmLib "z.lib" + if (-not (Test-Path $ZlibImportLib)) { + return $false + } + } } catch { return $false @@ -243,7 +251,7 @@ New-Item -ItemType Directory -Force -Path $TempDir | Out-Null try { $MambaBin = Get-Micromamba -TempDir $TempDir - Write-Host "Installing conda-forge LLVM $Version, clang $Version, and libclang $Version to $InstallDir" + Write-Host "Installing conda-forge LLVM $Version, clang $Version, libclang $Version, and zlib to $InstallDir" $OldMambaRoot = $env:MAMBA_ROOT_PREFIX $env:MAMBA_ROOT_PREFIX = $MambaRoot try { @@ -254,7 +262,8 @@ try { -c conda-forge ` "llvmdev=$Version" ` "clang=$Version" ` - "libclang=$Version" + "libclang=$Version" ` + "zlib" if ($LASTEXITCODE -ne 0) { throw "micromamba failed to create LLVM environment with exit code $LASTEXITCODE" } From 2ac6edfdf5cd6697dbc5c67676db8691ee011722 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 6 Jun 2026 21:15:24 -0600 Subject: [PATCH 062/388] Repair Windows LLVM system libs --- scripts/ci/install-llvm-21-windows.ps1 | 36 +++++++++++++++++++++----- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index 6d3511b44..cfa7054f7 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -166,15 +166,20 @@ function Test-LlvmInstall { return $false } + Repair-LlvmSystemLibsForMsvc + $StaticLibs = (& $LlvmConfig --libnames --link-static).Trim() if ([string]::IsNullOrWhiteSpace($StaticLibs)) { return $false } - if ($StaticLibs -match "(^|\s)z\.lib($|\s)") { - $ZlibImportLib = Join-Path $LlvmLib "z.lib" - if (-not (Test-Path $ZlibImportLib)) { - return $false + $SystemLibs = (& $LlvmConfig --system-libs --link-static).Trim() + foreach ($LibName in @("z.lib", "zstd.dll.lib", "xml2.lib")) { + if ($SystemLibs -match "(^|\s)$([regex]::Escape($LibName))($|\s)") { + $ImportLib = Join-Path $LlvmLib $LibName + if (-not (Test-Path $ImportLib)) { + return $false + } } } } @@ -185,6 +190,24 @@ function Test-LlvmInstall { return $true } +function Repair-LlvmSystemLibsForMsvc { + if (-not (Test-Path $LlvmLib)) { + return + } + + $ZstdDllImportLib = Join-Path $LlvmLib "zstd.dll.lib" + if (-not (Test-Path $ZstdDllImportLib)) { + foreach ($CandidateName in @("zstd.lib", "libzstd.lib")) { + $Candidate = Join-Path $LlvmLib $CandidateName + if (Test-Path $Candidate) { + Write-Host "Creating LLVM-compatible zstd.dll.lib from $CandidateName" + Copy-Item -Force -Path $Candidate -Destination $ZstdDllImportLib + break + } + } + } +} + function Repair-LibclangForBindgen { if (Test-Path $Libclang) { return @@ -251,7 +274,7 @@ New-Item -ItemType Directory -Force -Path $TempDir | Out-Null try { $MambaBin = Get-Micromamba -TempDir $TempDir - Write-Host "Installing conda-forge LLVM $Version, clang $Version, libclang $Version, and zlib to $InstallDir" + Write-Host "Installing conda-forge LLVM $Version, clang $Version, libclang $Version, zlib, and libxml2-devel to $InstallDir" $OldMambaRoot = $env:MAMBA_ROOT_PREFIX $env:MAMBA_ROOT_PREFIX = $MambaRoot try { @@ -263,7 +286,8 @@ try { "llvmdev=$Version" ` "clang=$Version" ` "libclang=$Version" ` - "zlib" + "zlib" ` + "libxml2-devel" if ($LASTEXITCODE -ne 0) { throw "micromamba failed to create LLVM environment with exit code $LASTEXITCODE" } From 66f377f5549bd04a1b967a647a33073a2dbcd5fd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 00:14:43 -0600 Subject: [PATCH 063/388] Fix 3 Codex-review blockers: emit real physical code distance in the algorithm descriptor (was a fake sqrt-of-patch-count that made can_window/strict dishonest), return DecoderError instead of panicking on out-of-range from_membership detector ids, and make all four DEM parsers agree on declared-but-unreferenced detectors (max+1); narrow the coordless subgraph_dems doc --- crates/pecos-decoder-core/src/dem.rs | 51 ++++++++++++++++++- .../src/logical_subgraph.rs | 17 +++++++ .../src/fault_tolerance_bindings.rs | 35 ++++++++++--- .../src/pecos/qec/surface/logical_circuit.py | 9 ++++ .../test_logical_circuit_decoder_windowing.py | 13 +++++ 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index c380ae578..d7e351633 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -130,9 +130,15 @@ pub mod utils { continue; } - // Handle commands with probability parameters like "error(0.01)" + // Normalize commands that carry parenthesized parameters: + // `error(0.01) ...` and `detector(x,y,t) Dk` (Stim always parenthesizes + // detector coordinates, so a literal `parts[0] == "detector"` check + // would miss every real declaration and undercount detectors that are + // declared but never referenced by an error mechanism). let command = if parts[0].starts_with("error(") { "error" + } else if parts[0].starts_with("detector(") { + "detector" } else { parts[0] }; @@ -447,8 +453,22 @@ impl DemCheckMatrix { .into(), )); } + if let Some(rest) = line.strip_prefix("detector(") { + // Count the declared detector id, which may not be referenced by + // any error mechanism. All parsers agree on + // `max(declared, error-referenced) + 1`. + if let Some(close) = rest.find(')') { + for token in rest[close + 1..].split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) + { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + } + continue; + } if !line.starts_with("error(") { - // Skip non-error lines (detector, etc.) + // Skip other non-error lines (logical_observable handled above). continue; } @@ -645,6 +665,20 @@ impl DemMatchingGraph { .into(), )); } + if let Some(rest) = line.strip_prefix("detector(") { + // Count the declared detector id (may not be error-referenced) so + // `num_detectors` matches the other parsers and its coordinate is + // not later dropped from `detector_coords`. + if let Some(close) = rest.find(')') { + for token in rest[close + 1..].split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) + { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); + } + } + } + continue; + } if line.is_empty() || line.starts_with('#') || !line.starts_with("error(") { continue; } @@ -1090,6 +1124,19 @@ mod tests { assert_eq!(obs, 2, "parse_dem_metadata must count L1"); } + #[test] + fn test_parsers_count_declared_but_unreferenced_detectors() { + // A detector declared via `detector(coords) Dk` but never referenced by + // an error mechanism must still count (max declared id + 1). All four + // parsers must agree; index-addressed buffers depend on it. + let dem = "detector(0, 0, 0) D2\nlogical_observable L0\n"; + assert_eq!(SparseDem::from_dem_str(dem).unwrap().num_detectors, 3); + assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_detectors, 3); + assert_eq!(DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, 3); + let (dets, _obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!(dets, 3, "parse_dem_metadata must count declared detector D2"); + } + #[test] fn test_parser_observable_count_is_max_plus_one_for_noncontiguous_ids() { // Only L2 present: index-addressed buffers need 3 slots, not 1. All diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index cc2061819..52bbb728e 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -366,7 +366,17 @@ pub fn subgraphs_from_membership( } // Build detector mapping (full DEM id -> subgraph-local index). + // Validate caller-provided ids first: an out-of-range id would index + // past `inverse_map` and panic, so return a decoder error instead. let detector_map: Vec = detectors.clone(); + if let Some(&bad) = detector_map.iter().find(|&&d| d >= sdem.num_detectors) { + return Err(DecoderError::InvalidConfiguration(format!( + "subgraphs_from_membership: observable {obs_idx} membership references \ + detector {bad}, but the DEM has only {} detectors (D0..D{})", + sdem.num_detectors, + sdem.num_detectors.saturating_sub(1), + ))); + } let mut inverse_map = vec![None; sdem.num_detectors]; for (sub_idx, &full_idx) in detector_map.iter().enumerate() { inverse_map[full_idx] = Some(sub_idx); @@ -895,6 +905,13 @@ mod tests { subgraphs_from_membership(&sdem, &big), Err(DecoderError::InvalidConfiguration(_)) )); + + // An out-of-range membership detector id must error, not panic + // (the DEM has 2 detectors D0,D1; detector 5 is past `inverse_map`). + assert!(matches!( + subgraphs_from_membership(&sdem, &vec![vec![5usize]]), + Err(DecoderError::InvalidConfiguration(_)) + )); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index ca3c29c76..8b5261477 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4754,10 +4754,15 @@ impl PyLogicalSubgraphDecoder { Ok((edges.len(), num_qubits)) } - /// Get the per-subgraph DEM strings (graphlike, suitable for windowed decoding). - /// - /// Each string is a DEM with local detector IDs (0..N) that can be - /// passed to windowed or sandwich decoders. + /// Get the per-subgraph DEM strings (graphlike, local detector IDs 0..N). + /// + /// NOTE: these strings carry NO `detector(...)` coordinate lines (subgraph + /// graphs drop coordinates), so they are NOT suitable for *time-windowed* + /// decoding -- a windowed decoder would see no detector times and collapse to + /// a single window. For windowing, use the coord-preserving + /// `LogicalSubgraphWindowPlan` path (the `WindowedLogicalSubgraphDecoder` / + /// logical-circuit windowed budget already do). These strings are fine for + /// full (non-windowed) per-subgraph decoding. fn subgraph_dems(&self) -> Vec { (0..self.inner.num_observables()) .map(|i| { @@ -5290,10 +5295,24 @@ impl PyLogicalCircuitDecoder { // Select budget: "unlimited" for full-circuit, "windowed" for // bounded-latency, or a cycle time in microseconds like "1000us". - let mut distance = 0usize; - while distance.saturating_mul(distance) < num_qubits { - distance += 1; - } + // + // Use the REAL physical code distance from the descriptor (used for the + // windowing step / latency bound). `num_qubits = rust_sc.len()` is the + // number of logical patches, NOT a distance -- deriving distance from it + // (e.g. sqrt) is wrong (a single d=7 patch would yield distance 1 and + // make `can_window`/`strict` dishonest). Fall back to the old patch-count + // heuristic only for legacy descriptors that predate the `distance` field. + let distance: usize = descriptor + .get_item("distance")? + .and_then(|v| v.extract::().ok()) + .filter(|&d| d > 0) + .unwrap_or_else(|| { + let mut d = 0usize; + while d.saturating_mul(d) < num_qubits { + d += 1; + } + d.max(1) + }); let decode_budget = match budget { "unlimited" | "offline" => DecodeBudget::unlimited(), "windowed" => { diff --git a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py index c443f389c..4c5f94c1f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py +++ b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py @@ -836,6 +836,14 @@ def build_algorithm_descriptor( seg_dems.append("\n".join(lines)) + # Physical code distance for latency/windowing decisions. With multiple + # patches use the minimum (the weakest bound governs latency). This is the + # real surface-code distance, NOT a count of logical patches. + distance = min( + (min(ps.patch.geometry.dx, ps.patch.geometry.dz) for ps in self._patches.values()), + default=0, + ) + return { "segments": [ { @@ -848,6 +856,7 @@ def build_algorithm_descriptor( "boundary_gates": boundary_gates, "num_observables": num_patches * 2, "full_dem": full_dem, + "distance": distance, } def build_decoder( diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py index fc5c35527..5f1dd9a79 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py @@ -66,6 +66,19 @@ def test_strict_windowed_budget_hard_errors(): LogicalCircuitDecoder(desc, budget="windowed", strict=True) +def test_strict_accepts_shallow_circuit_using_real_distance(): + """`can_window`/`strict` must use the REAL physical code distance from the + descriptor, not a fake distance derived from the patch count. A single d=5 + patch with only 2 rounds is one window at step=d=5, so strict=True must NOT + reject and can_window must be False. (The prior code derived distance=1 from + the 1-patch count and wrongly reported real windowing / rejected.)""" + desc = _memory_descriptor(5, 2) + assert desc["distance"] == 5 + dec = LogicalCircuitDecoder(desc, budget="windowed", strict=True) # must not raise + assert dec.can_window is False + assert dec.effective_windowing == "full_fallback" + + def test_windowed_full_fallback_still_decodes(): """The full-fallback path is still a working decoder (accurate per-observable decode), not a stub.""" From 03d4d02aacb72dc019d67e2857f8ad2780e8d695 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 09:26:41 -0600 Subject: [PATCH 064/388] Prototype reliable_observables: derive decodable logical-observable combinations from reset structure via GF(2) null space of the reset/observable anticommutation matrix (port of lomatching get_reliable_observables); validated against the paper's Bell-state fragile example, memory, and a real PECOS to_stim circuit --- .../src/pecos/qec/reliable_observables.py | 198 ++++++++++++++++++ .../tests/qec/test_reliable_observables.py | 103 +++++++++ 2 files changed, 301 insertions(+) create mode 100644 python/quantum-pecos/src/pecos/qec/reliable_observables.py create mode 100644 python/quantum-pecos/tests/qec/test_reliable_observables.py diff --git a/python/quantum-pecos/src/pecos/qec/reliable_observables.py b/python/quantum-pecos/src/pecos/qec/reliable_observables.py new file mode 100644 index 000000000..86d6fb451 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/reliable_observables.py @@ -0,0 +1,198 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""PROTOTYPE: reliable logical-observable combinations from reset structure. + +Port of lomatching's ``get_reliable_observables`` (Serra-Peralta et al., +arXiv:2505.13599, "logical observable matching"). A logical observable is +*fragile* when its back-propagated observing region anticommutes with a reset +stabilizer -- decoding it directly is unreliable (a weight-2 space-time +stabilizer flips its decoded outcome without a logical fault). The paper's fix +is to decode only an **independent generating set of reliable observables** and +infer fragile ones as products. + +This module computes that reliable set: build the anticommutation matrix +``A[reset, obs]`` (1 iff reset ``reset`` and observable ``obs`` anticommute) and +take its right null space over GF(2). Each null vector is a combination of raw +observables that commutes with *every* reset -- i.e. a reliable observable. + +Status: prototype / proof-of-concept. Dependency-free (own GF(2) null space, no +``galois``). The eventual production path would compute the null space via +pecos-num's GF(2) and feed the reliable set into the logical-subgraph decoder +front-end. See ``pecos-docs/design/lomatching-paper-additional-learnings.md``. +""" + +from __future__ import annotations + +import numpy as np + +try: + import stim +except ImportError as exc: # pragma: no cover - stim is an optional dep + msg = "reliable_observables requires the `stim` package" + raise ImportError(msg) from exc + +# Stim reset instruction names (Pauli basis is the last character; default Z). +_RESET_INSTRS = frozenset(["R", "RX", "RY", "RZ", "MR", "MRX", "MRY", "MRZ"]) + +# A Pauli region is {tick_index: stim.PauliString}. +PauliRegion = dict[int, "stim.PauliString"] + + +def reliable_observables(circuit: stim.Circuit) -> list[set[int]]: + """Return a complete basis of reliable observable combinations. + + Args: + circuit: A ``stim.Circuit`` whose observables are defined via + ``OBSERVABLE_INCLUDE``. Qubits must be explicitly reset, and a reset + must be the only operation on its qubit within its ``TICK`` (the + lomatching precondition). + + Returns: + One ``set[int]`` per basis element of the reliable space; each set is the + raw-observable indices whose XOR is a reliable observable. An empty list + means no nontrivial reliable combination exists. + """ + if not isinstance(circuit, stim.Circuit): + msg = f"`circuit` must be a stim.Circuit, got {type(circuit)}" + raise TypeError(msg) + + resets = _reset_pauli_regions(circuit) + num_obs = circuit.num_observables + obs_regions = {o: _observing_region(circuit, o) for o in range(num_obs)} + + # A[reset, obs] = 1 iff the reset and the observable's region anticommute. + a = np.zeros((len(resets), num_obs), dtype=np.uint8) + for obs_id, region in obs_regions.items(): + for reset_id, reset_region in resets.items(): + if _anticommute(reset_region, region): + a[reset_id, obs_id] = 1 + + return [set(np.nonzero(vec)[0].tolist()) for vec in _gf2_right_null_space(a)] + + +def is_reliable(circuit: stim.Circuit, observable: set[int] | int) -> bool: + """Whether a single observable (or XOR of observables) is reliable. + + A combination is reliable iff its region commutes with every reset. + """ + obs = {observable} if isinstance(observable, int) else set(observable) + resets = _reset_pauli_regions(circuit) + # Combine the regions of the chosen observables by tick-wise Pauli product. + combined: PauliRegion = {} + for o in obs: + for tick, ps in _observing_region(circuit, o).items(): + combined[tick] = combined[tick] * ps if tick in combined else ps + return all(not _anticommute(r, combined) for r in resets.values()) + + +# --------------------------------------------------------------------------- # +# Internals (faithful to lomatching's util.py) +# --------------------------------------------------------------------------- # + + +def _reset_pauli_regions(circuit: stim.Circuit) -> dict[int, PauliRegion]: + """Per-reset single-tick Pauli region: the reset's Pauli on its qubit.""" + flat = circuit.flattened() + n = flat.num_qubits + resets: dict[int, PauliRegion] = {} + reset_idx = 0 + tick = 0 + for instr in flat: + if instr.name == "TICK": + tick += 1 + continue + if instr.name not in _RESET_INSTRS: + continue + pauli = "Z" + if instr.name.endswith("X"): + pauli = "X" + elif instr.name.endswith("Y"): + pauli = "Y" + for target in instr.targets_copy(): + ps = stim.PauliString(n) + ps[target.value] = pauli + resets[reset_idx] = {tick: ps} + reset_idx += 1 + return resets + + +def _observing_region(circuit: stim.Circuit, observable: int) -> PauliRegion: + """Back-propagated observing region of one observable, via stim. + + Rewrites the circuit so only `observable` survives, renamed to L0, then uses + `stim.Circuit.detecting_regions` to get its {tick: PauliString} region. + """ + new_circuit = stim.Circuit() + for instr in circuit.flattened(): + if instr.name != "OBSERVABLE_INCLUDE": + new_circuit.append(instr) + continue + if instr.gate_args_copy()[0] != observable: + continue + new_circuit.append( + stim.CircuitInstruction( + name="OBSERVABLE_INCLUDE", gate_args=[0], targets=instr.targets_copy() + ) + ) + target = stim.DemTarget("L0") + regions = new_circuit.detecting_regions( + targets=[target], ignore_anticommutation_errors=True + ) + return regions.get(target, {}) + + +def _anticommute(region_a: PauliRegion, region_b: PauliRegion) -> bool: + """True iff two Pauli regions anticommute (odd number of anticommuting ticks).""" + anti = 0 + for tick in set(region_a).intersection(region_b): + if not region_a[tick].commutes(region_b[tick]): + anti += 1 + return anti % 2 == 1 + + +def _gf2_right_null_space(a: np.ndarray) -> list[np.ndarray]: + """Basis of {x : a @ x == 0 (mod 2)} over GF(2), via row reduction. + + Returns a list of 0/1 vectors of length ``a.shape[1]``. + """ + a = (np.asarray(a, dtype=np.uint8) % 2).copy() + rows, cols = a.shape + pivot_col_of_row: list[int] = [] + pivot_cols: set[int] = set() + r = 0 + for c in range(cols): + # find a pivot in column c at or below row r + piv = next((i for i in range(r, rows) if a[i, c]), None) + if piv is None: + continue + a[[r, piv]] = a[[piv, r]] + for i in range(rows): + if i != r and a[i, c]: + a[i] ^= a[r] + pivot_col_of_row.append(c) + pivot_cols.add(c) + r += 1 + if r == rows: + break + + free_cols = [c for c in range(cols) if c not in pivot_cols] + basis: list[np.ndarray] = [] + for f in free_cols: + x = np.zeros(cols, dtype=np.uint8) + x[f] = 1 + # back-substitute: pivot row i fixes its pivot col from the free col + for i, pc in enumerate(pivot_col_of_row): + if a[i, f]: + x[pc] = 1 + basis.append(x) + return basis diff --git a/python/quantum-pecos/tests/qec/test_reliable_observables.py b/python/quantum-pecos/tests/qec/test_reliable_observables.py new file mode 100644 index 000000000..6e7715625 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_reliable_observables.py @@ -0,0 +1,103 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Validate the reliable-observable prototype against the paper's examples. + +Reproduces the fragile-observable example from Serra-Peralta et al. +(arXiv:2505.13599): two fragile observables whose product is reliable. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +stim = pytest.importorskip("stim") + +from pecos.qec.reliable_observables import ( # noqa: E402 + _gf2_right_null_space, + is_reliable, + reliable_observables, +) + + +def test_gf2_right_null_space(): + a = np.array([[1, 1, 0], [0, 1, 1]], dtype=np.uint8) + basis = _gf2_right_null_space(a) + # rank 2, 3 cols -> 1-d null space, spanned by [1,1,1]. + assert len(basis) == 1 + assert basis[0].tolist() == [1, 1, 1] + for v in basis: + assert np.all((a @ v) % 2 == 0) + + +def test_gf2_full_rank_has_trivial_null_space(): + a = np.eye(3, dtype=np.uint8) + assert _gf2_right_null_space(a) == [] + + +def _bell_circuit() -> stim.Circuit: + # Paper's fragile example (eq. Bell_state_fragile_observables): + # q0=|0>, q1=|+>, CX(1,0), measure both in Z. O0={M(q0)}, O1={M(q1)} are + # each fragile; their product is reliable (and deterministic). + c = stim.Circuit() + c.append("RZ", [0]) + c.append("RX", [1]) + c.append("TICK") + c.append("CX", [1, 0]) + c.append("TICK") + c.append("M", [0]) + c.append("M", [1]) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-2)], 0) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-1)], 1) + return c + + +def test_bell_fragile_observables_product_is_reliable(): + """The paper's headline example: O0, O1 fragile; O0*O1 reliable.""" + c = _bell_circuit() + assert reliable_observables(c) == [{0, 1}] + assert is_reliable(c, {0, 1}) + assert not is_reliable(c, 0) + assert not is_reliable(c, 1) + + +def test_memory_single_observable_is_reliable(): + c = stim.Circuit() + c.append("RZ", [0]) + c.append("TICK") + c.append("M", [0]) + c.append("OBSERVABLE_INCLUDE", [stim.target_rec(-1)], 0) + assert reliable_observables(c) == [{0}] + assert is_reliable(c, 0) + + +def test_runs_on_pecos_surface_memory_circuit(): + """End-to-end on a real PECOS-generated circuit: a surface-code Z memory has + a single reliable logical observable.""" + from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch + + patch = SurfacePatch.create(distance=3) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + circuit = stim.Circuit(b.to_stim(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0)) + assert circuit.num_observables >= 1 + rel = reliable_observables(circuit) + # Every raw observable should be reliable on its own for a plain memory. + for o in range(circuit.num_observables): + assert is_reliable(circuit, o), f"observable {o} unexpectedly fragile" + assert rel # non-empty reliable basis + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) From 76554b3df4b63604914d727f1b607ad20f806aca Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 7 Jun 2026 11:21:17 -0600 Subject: [PATCH 065/388] Preserve runtime result provenance for traced surface DEMs --- crates/pecos-qis-ffi-types/src/lib.rs | 2 +- crates/pecos-qis-ffi-types/src/operations.rs | 11 + crates/pecos-qis-ffi/src/ffi.rs | 38 ++- crates/pecos-qis-ffi/src/lib.rs | 111 ++++++- crates/pecos-qis/src/ccengine.rs | 95 +++++- crates/pecos-qis/src/executor.rs | 50 +++ crates/pecos-qis/src/qis_interface.rs | 11 + .../src/pecos/qec/surface/decode.py | 306 +++++++++++++----- .../tests/pecos/test_selene_sim_parity.py | 24 ++ .../tests/qec/test_from_guppy_dem.py | 39 +++ 10 files changed, 593 insertions(+), 94 deletions(-) diff --git a/crates/pecos-qis-ffi-types/src/lib.rs b/crates/pecos-qis-ffi-types/src/lib.rs index 31a0f2f84..94cc36807 100644 --- a/crates/pecos-qis-ffi-types/src/lib.rs +++ b/crates/pecos-qis-ffi-types/src/lib.rs @@ -7,7 +7,7 @@ mod operations; -pub use operations::{Operation, QuantumOp}; +pub use operations::{NamedResultTrace, Operation, QuantumOp}; const DEFAULT_OPERATION_CAPACITY: usize = 1024; const DEFAULT_MEASUREMENT_CAPACITY: usize = 256; diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 1ded1d33b..61cf38b11 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -3,6 +3,17 @@ //! This module defines the quantum operations that can be collected by the interface //! and later executed by a runtime. +/// Runtime provenance for a named `result(...)` output. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct NamedResultTrace { + /// Name passed to `result(name, value)`. + pub name: String, + /// Boolean values emitted for this result call. + pub values: Vec, + /// Runtime measurement result IDs read to produce `values`, in element order. + pub result_ids: Vec, +} + /// High-level quantum operations that include both QIS and control flow #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] pub enum Operation { diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 9a2f3bfd4..120f53e3a 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -266,6 +266,13 @@ pub unsafe extern "C" fn __quantum__rt__result_allocate() -> i64 { // --- Result Retrieval --- +fn record_result_read(result_id: usize) { + if let Some(ctx) = crate::get_execution_context() { + // SAFETY: Context is valid for duration of execution. + unsafe { &*ctx }.record_result_read(result_id); + } +} + /// Get measurement result (returns 1 if result is One, 0 otherwise) /// /// This function supports dynamic circuits: if the result is not yet available and @@ -284,6 +291,7 @@ pub unsafe extern "C" fn __quantum__rt__result_get_one(result: i64) -> i32 { let existing_result = with_interface(|interface| interface.get_result(result_id)); if let Some(value) = existing_result { + record_result_read(result_id); return i32::from(value); } @@ -300,7 +308,10 @@ pub unsafe extern "C" fn __quantum__rt__result_get_one(result: i64) -> i32 { ); 0 }, - i32::from, + |value| { + record_result_read(result_id); + i32::from(value) + }, ) }) } else { @@ -473,6 +484,7 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { log::debug!("___read_future_bool: existing_result={existing_result:?}"); if let Some(result) = existing_result { + record_result_read(result_id); return result; } @@ -484,6 +496,7 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { log::debug!( "___read_future_bool: result already in context for result_id={result_id}: {result}" ); + record_result_read(result_id); return result; } @@ -498,6 +511,9 @@ pub unsafe extern "C" fn ___read_future_bool(future_id: i64) -> bool { // The main thread stores results there to cross the thread boundary let result = crate::get_measurement_result(result_id as u64); log::debug!("___read_future_bool: got result after waiting: {result:?}"); + if result.is_some() { + record_result_read(result_id); + } return result.unwrap_or(false); } log::debug!("___read_future_bool: timeout waiting for result"); @@ -1505,6 +1521,26 @@ mod tests { assert!(result); } + #[test] + fn test_named_result_trace_consumes_recorded_result_reads() { + let ctx = crate::ExecutionContext::new(); + + ctx.record_result_read(7); + ctx.store_named_bool("m", true); + ctx.record_result_read(8); + ctx.record_result_read(9); + ctx.store_named_array("arr", &[false, true]); + + let traces = ctx.get_named_result_traces(); + assert_eq!(traces.len(), 2); + assert_eq!(traces[0].name, "m"); + assert_eq!(traces[0].values, vec![true]); + assert_eq!(traces[0].result_ids, vec![7]); + assert_eq!(traces[1].name, "arr"); + assert_eq!(traces[1].values, vec![false, true]); + assert_eq!(traces[1].result_ids, vec![8, 9]); + } + #[test] fn test_read_future_bool_default() { setup_test(); diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index b772a8749..e83988691 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -66,6 +66,10 @@ pub struct ExecutionContext { pub measurement_results: Mutex>>, /// Storage for named results from `print_bool`/`print_bool_arr` (e.g., "synx", "final") pub named_results: Mutex>>, + /// Runtime provenance for each `result(...)` output call. + pub named_result_traces: Mutex>, + /// Result IDs read since the last named output consumed them. + pub pending_result_reads: Mutex>, } impl ExecutionContext { @@ -80,6 +84,8 @@ impl ExecutionContext { pending_ops: Mutex::new(Vec::new()), measurement_results: Mutex::new(Vec::new()), named_results: Mutex::new(BTreeMap::new()), + named_result_traces: Mutex::new(Vec::new()), + pending_result_reads: Mutex::new(Vec::new()), } } @@ -101,6 +107,53 @@ impl ExecutionContext { if let Ok(mut named) = self.named_results.lock() { named.clear(); } + if let Ok(mut traces) = self.named_result_traces.lock() { + traces.clear(); + } + if let Ok(mut reads) = self.pending_result_reads.lock() { + reads.clear(); + } + } + + /// Record that program execution read a runtime measurement result. + pub fn record_result_read(&self, result_id: usize) { + if let Ok(mut reads) = self.pending_result_reads.lock() { + reads.push(result_id); + } else { + log::error!("ExecutionContext::record_result_read failed to acquire lock"); + } + } + + fn take_result_reads(&self, count: usize) -> Vec { + if count == 0 { + return Vec::new(); + } + let Ok(mut reads) = self.pending_result_reads.lock() else { + log::error!("ExecutionContext::take_result_reads failed to acquire lock"); + return Vec::new(); + }; + if reads.len() < count { + log::warn!( + "Named result output expected {count} result read(s), but only {} were recorded", + reads.len() + ); + return Vec::new(); + } + reads.drain(..count).collect() + } + + fn store_named_result_trace(&self, name: &str, values: &[bool], result_ids: Vec) { + if let Ok(mut traces) = self.named_result_traces.lock() { + traces.push(NamedResultTrace { + name: name.to_string(), + values: values.to_vec(), + result_ids, + }); + } else { + log::error!( + "ExecutionContext::store_named_result_trace failed to acquire lock for '{name}'" + ); + } } /// Store a named result (single bool value) @@ -122,6 +175,8 @@ impl ExecutionContext { "ExecutionContext::store_named_bool: thread {thread_id:?} failed to acquire lock for '{name}'" ); } + let result_ids = self.take_result_reads(1); + self.store_named_result_trace(name, &[value], result_ids); } /// Store a named result array (multiple bool values) @@ -130,6 +185,8 @@ impl ExecutionContext { let entry = named.entry(name.to_string()).or_default(); entry.extend_from_slice(values); } + let result_ids = self.take_result_reads(values.len()); + self.store_named_result_trace(name, values, result_ids); } /// Get all named results (returns a clone) @@ -140,6 +197,15 @@ impl ExecutionContext { .map(|guard| guard.clone()) .unwrap_or_default() } + + /// Get all named result provenance records (returns a clone) + #[must_use] + pub fn get_named_result_traces(&self) -> Vec { + self.named_result_traces + .lock() + .map(|guard| guard.clone()) + .unwrap_or_default() + } } impl Default for ExecutionContext { @@ -201,7 +267,9 @@ fn get_execution_context() -> Option<*mut ExecutionContext> { } // Re-export all types from pecos-qis-ffi-types -pub use pecos_qis_ffi_types::{Operation, OperationCollector, OperationList, QuantumOp}; +pub use pecos_qis_ffi_types::{ + NamedResultTrace, Operation, OperationCollector, OperationList, QuantumOp, +}; /// Type alias for the quantum executor callback /// @@ -745,10 +813,49 @@ pub extern "C" fn pecos_get_named_results_json() -> *mut std::ffi::c_char { } } +/// Get named result runtime provenance from execution context as JSON. +/// +/// Returns a pointer to a heap-allocated null-terminated JSON string containing +/// records of `result(...)` calls with the measurement result IDs used to +/// produce each output value. +/// +/// The caller must free the returned string using `pecos_free_named_results_json`. +/// Returns null if no context is registered or traces are empty. +#[unsafe(no_mangle)] +pub extern "C" fn pecos_get_named_result_traces_json() -> *mut std::ffi::c_char { + let Some(ctx) = get_execution_context() else { + return std::ptr::null_mut(); + }; + + // SAFETY: Context is valid for duration of execution + let ctx = unsafe { &*ctx }; + let traces = ctx.get_named_result_traces(); + if traces.is_empty() { + return std::ptr::null_mut(); + } + + let json = match serde_json::to_string(&traces) { + Ok(s) => s, + Err(e) => { + log::error!("pecos_get_named_result_traces_json: serialization error: {e}"); + return std::ptr::null_mut(); + } + }; + + match std::ffi::CString::new(json) { + Ok(cstr) => cstr.into_raw(), + Err(e) => { + log::error!("pecos_get_named_result_traces_json: CString error: {e}"); + std::ptr::null_mut() + } + } +} + /// Free a JSON string allocated by `pecos_get_named_results_json` /// /// # Safety -/// The pointer must have been allocated by `pecos_get_named_results_json`. +/// The pointer must have been allocated by `pecos_get_named_results_json` or +/// `pecos_get_named_result_traces_json`. #[unsafe(no_mangle)] pub unsafe extern "C" fn pecos_free_named_results_json(ptr: *mut std::ffi::c_char) { if !ptr.is_null() { diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index f448c9080..1711e5831 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -23,7 +23,9 @@ use pecos_engines::shot_results::{Data, Shot}; use pecos_engines::{ ByteMessage, ByteMessageBuilder, ClassicalEngine, ControlEngine, Engine, EngineStage, }; -use pecos_qis_ffi_types::{Operation, OperationCollector as OperationList, QuantumOp}; +use pecos_qis_ffi_types::{ + NamedResultTrace, Operation, OperationCollector as OperationList, QuantumOp, +}; use pecos_random::PecosRng; use std::collections::{BTreeMap, BTreeSet}; use std::fs; @@ -58,6 +60,7 @@ pub struct OperationTraceChunk { pub num_operations: usize, pub operations: Vec, pub lowered_quantum_ops: Vec, + pub named_result_traces: Vec, } /// Shared in-memory store for traced QIS operation batches. @@ -892,6 +895,7 @@ impl QisEngine { num_operations: ops.len(), operations: ops.to_vec(), lowered_quantum_ops: lowered_trace, + named_result_traces: Vec::new(), }; if let Some(ref collector) = self.operation_trace_collector { @@ -931,6 +935,92 @@ impl QisEngine { } } + fn trace_named_result_traces_chunk(&mut self, named_result_traces: &[NamedResultTrace]) { + if named_result_traces.is_empty() + || (self.operation_trace_dir.is_none() && self.operation_trace_collector.is_none()) + { + return; + } + + let stage = "named_results"; + let file_name = format!( + "engine_{:04}_shot_{:06}_chunk_{:04}_{}.json", + self.trace_engine_id, self.trace_shot_index, self.trace_chunk_index, stage + ); + let chunk_index = self.trace_chunk_index; + self.trace_chunk_index = self + .trace_chunk_index + .checked_add(1) + .expect("trace_chunk_index overflow: too many chunks for a single trace shot"); + let chunk = OperationTraceChunk { + format: "pecos_qis_operation_trace_v1", + engine_trace_id: self.trace_engine_id, + shot_index: self.trace_shot_index, + chunk_index, + stage: stage.to_string(), + waiting_for_result_id: None, + current_shot_seed: self.current_shot_seed, + simulated_op_count: self.simulated_op_count, + num_operations: 0, + operations: Vec::new(), + lowered_quantum_ops: Vec::new(), + named_result_traces: named_result_traces.to_vec(), + }; + + if let Some(ref collector) = self.operation_trace_collector { + match collector.lock() { + Ok(mut guard) => guard.push(chunk.clone()), + Err(err) => warn!("Failed to store named result trace chunk in memory: {err}"), + } + } + + if let Some(ref trace_dir) = self.operation_trace_dir { + if let Err(err) = fs::create_dir_all(trace_dir) { + warn!( + "Failed to create operation trace directory {}: {err}", + trace_dir.display() + ); + return; + } + + let trace_path = trace_dir.join(file_name); + let serialized = match serde_json::to_string_pretty(&chunk) { + Ok(serialized) => serialized, + Err(err) => { + warn!( + "Failed to serialize named result trace chunk for {}: {err}", + trace_path.display() + ); + return; + } + }; + + if let Err(err) = fs::write(&trace_path, serialized) { + warn!( + "Failed to write named result trace chunk {}: {err}", + trace_path.display() + ); + } + } + } + + fn trace_named_result_traces_from_dynamic_handle(&mut self) { + let named_result_traces = if let Some(state) = &self.dynamic_state + && let Some(handle) = &state.sync_handle + { + match handle.get_named_result_traces() { + Ok(named_result_traces) => named_result_traces, + Err(e) => { + debug!("QisEngine: Failed to get named result traces: {e}"); + Vec::new() + } + } + } else { + Vec::new() + }; + self.trace_named_result_traces_chunk(&named_result_traces); + } + /// Start the LLVM program execution in a worker thread /// /// Uses a persistent worker thread to avoid TLS allocation issues from @@ -1411,6 +1501,7 @@ impl ControlEngine for QisEngine { return Ok(EngineStage::NeedsProcessing(commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } @@ -1457,6 +1548,7 @@ impl ControlEngine for QisEngine { return Ok(EngineStage::NeedsProcessing(commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } @@ -1521,6 +1613,7 @@ impl ControlEngine for QisEngine { return Ok(EngineStage::NeedsProcessing(commands)); } } + self.trace_named_result_traces_from_dynamic_handle(); let shot = self.get_results()?; return Ok(EngineStage::Complete(shot)); } diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index d1ba56774..2b1a76990 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -303,6 +303,7 @@ type SetMeasurementResultFn = unsafe extern "C" fn(u64, bool); type SignalResultReadyFn = unsafe extern "C" fn(); type AbortExecutionFn = unsafe extern "C" fn(); type GetNamedResultsJsonFn = unsafe extern "C" fn() -> *mut std::ffi::c_char; +type GetNamedResultTracesJsonFn = unsafe extern "C" fn() -> *mut std::ffi::c_char; type FreeNamedResultsJsonFn = unsafe extern "C" fn(*mut std::ffi::c_char); /// Synchronization handle for main thread communication with worker thread @@ -454,6 +455,55 @@ impl DynamicSyncHandle for HeliosSyncHandle { debug!("HeliosSyncHandle: Got {} named results", result.len()); Ok(result) } + + fn get_named_result_traces( + &self, + ) -> Result, InterfaceError> { + let lib = Self::get_lib()?; + + let get_fn: Symbol = unsafe { + lib.get(b"pecos_get_named_result_traces_json\0") + .map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_get_named_result_traces_json: {e}" + )) + })? + }; + + let ptr = unsafe { get_fn() }; + if ptr.is_null() { + return Ok(Vec::new()); + } + + let c_str = unsafe { std::ffi::CStr::from_ptr(ptr) }; + let json_str = c_str.to_str().map_err(|e| { + InterfaceError::ExecutionError(format!( + "Invalid UTF-8 in named result traces JSON: {e}" + )) + })?; + + let result: Vec = serde_json::from_str(json_str) + .map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to parse named result traces JSON: {e}" + )) + })?; + + let free_fn: Symbol = unsafe { + lib.get(b"pecos_free_named_results_json\0").map_err(|e| { + InterfaceError::ExecutionError(format!( + "Failed to find pecos_free_named_results_json: {e}" + )) + })? + }; + unsafe { free_fn(ptr) }; + + debug!( + "HeliosSyncHandle: Got {} named result trace records", + result.len() + ); + Ok(result) + } } /// Derive the project target directory from the compile-time embedded Helios path. diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index 305add2e1..12c040a42 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -273,6 +273,17 @@ pub trait DynamicSyncHandle: Send + Sync { fn get_named_results( &self, ) -> Result>, InterfaceError>; + + /// Get named result provenance from the execution context. + /// + /// Returns one record per `result(...)` output call, including the runtime + /// measurement result IDs read to produce that output. + /// + /// # Errors + /// Returns an error if the FFI call fails or JSON parsing fails. + fn get_named_result_traces( + &self, + ) -> Result, InterfaceError>; } /// Box type for interface implementations diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 0754a16f7..8f474c42d 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -572,6 +572,93 @@ def _remap_surface_record_metadata_json( return json.dumps(entries) +def _surface_runtime_measurement_remap_from_result_traces( + patch: SurfacePatch, + num_rounds: int, + result_traces: list[dict[str, Any]], +) -> dict[int, int]: + """Map abstract surface measurement indices to runtime ``result_id``s. + + The generated surface Guppy emits scalar ``result("sx*/sz*:meas:N", bit)`` + calls for stabilizer measurements and one ``result("final", array(...))`` + call for data readout. Those tags survive runtime scheduling changes and + are the stable detector/observable anchor. Aggregate ``synx``/``synz`` tags + are deliberately ignored because they reread existing futures. + """ + import re + from collections import defaultdict + + syndrome_per_round = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) + expected_syndrome_measurements = syndrome_per_round * num_rounds + expected_measurements = expected_syndrome_measurements + patch.geometry.num_data + + scalar_tag_re = re.compile(r"^s[xz]\d+:meas:(\d+)$") + occurrence_by_tag: defaultdict[str, int] = defaultdict(int) + remap: dict[int, int] = {} + final_seen = False + + for trace in result_traces: + name = trace.get("name") + result_ids = trace.get("result_ids") or [] + values = trace.get("values") or [] + if not isinstance(name, str): + continue + + match = scalar_tag_re.match(name) + if match is not None: + if len(result_ids) != 1 or len(values) != 1: + msg = f"Surface scalar result tag {name!r} must map to exactly one measurement result" + raise ValueError(msg) + meas_in_round = int(match.group(1)) + if not 0 <= meas_in_round < syndrome_per_round: + msg = ( + f"Surface scalar result tag {name!r} has per-round measurement index " + f"{meas_in_round}, outside [0, {syndrome_per_round})" + ) + raise ValueError(msg) + round_index = occurrence_by_tag[name] + occurrence_by_tag[name] += 1 + if round_index >= num_rounds: + msg = f"Surface scalar result tag {name!r} appears more than {num_rounds} round(s)" + raise ValueError(msg) + abstract_index = round_index * syndrome_per_round + meas_in_round + remap[abstract_index] = int(result_ids[0]) + elif name == "final": + if final_seen: + msg = "Surface traced result provenance has more than one final data result" + raise ValueError(msg) + final_seen = True + if len(result_ids) != patch.geometry.num_data or len(values) != patch.geometry.num_data: + msg = ( + "Surface final result tag must map to exactly " + f"{patch.geometry.num_data} data measurements" + ) + raise ValueError(msg) + for offset, result_id in enumerate(result_ids): + remap[expected_syndrome_measurements + offset] = int(result_id) + + if len(remap) != expected_measurements: + missing = sorted(set(range(expected_measurements)) - set(remap)) + msg = ( + "Runtime trace did not provide complete surface result-tag provenance: " + f"mapped {len(remap)}/{expected_measurements} measurements" + ) + if missing: + msg += f"; first missing abstract measurement index {missing[0]}" + raise ValueError(msg) + + runtime_ids = sorted(remap.values()) + if runtime_ids != list(range(expected_measurements)): + msg = ( + "Runtime result-tag provenance is not a dense measurement-id range " + f"0..{expected_measurements - 1}; got first/last " + f"{runtime_ids[:3]}...{runtime_ids[-3:]}" + ) + raise ValueError(msg) + + return remap + + def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: """Convert runtime idle seconds into PECOS nanosecond time units.""" import math @@ -931,6 +1018,58 @@ def _reject_partially_lowered_trace(chunks: list[dict[str, Any]]) -> None: raise ValueError(msg) +def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> Any: + """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" + if any(chunk.get("lowered_quantum_ops") for chunk in chunks): + _reject_partially_lowered_trace(chunks) + return _replay_lowered_qis_trace_into_tick_circuit(chunks) + + operations: list[dict[str, Any]] = [] + for chunk in chunks: + operations.extend(list(chunk.get("operations", []))) + return _replay_qis_trace_into_tick_circuit(operations) + + +def named_result_traces_from_operation_trace(chunks: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Return runtime `result(...)` provenance records from operation trace chunks.""" + traces: list[dict[str, Any]] = [] + for chunk in chunks: + traces.extend(trace for trace in (chunk.get("named_result_traces") or []) if isinstance(trace, dict)) + return traces + + +def capture_guppy_operation_trace( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, +) -> list[dict[str, Any]]: + """Capture a Guppy/QIS program's Selene operation trace chunks.""" + import pecos + + sim_builder = ( + pecos.sim(program) + .classical(pecos.selene_engine(runtime)) + .quantum(pecos.stabilizer()) + .qubits(num_qubits) + .seed(seed) + ) + return list(sim_builder.capture_operation_trace()) + + +def trace_guppy_into_tick_circuit_with_result_traces( + program: Any, + num_qubits: int, + *, + seed: int = 0, + runtime: object | None = None, +) -> tuple[Any, list[dict[str, Any]]]: + """Trace a Guppy/QIS program into a ``TickCircuit`` plus result-tag provenance.""" + chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) + return _replay_qis_trace_chunks_into_tick_circuit(chunks), named_result_traces_from_operation_trace(chunks) + + def trace_guppy_into_tick_circuit( program: Any, num_qubits: int, @@ -966,31 +1105,8 @@ def trace_guppy_into_tick_circuit( A ``TickCircuit`` with no detector/observable metadata attached; the caller supplies that. """ - import pecos - - sim_builder = ( - pecos.sim(program) - .classical(pecos.selene_engine(runtime)) - .quantum(pecos.stabilizer()) - .qubits(num_qubits) - .seed(seed) - ) - chunks = list(sim_builder.capture_operation_trace()) - - # Selene lowers QIS gates into per-chunk `lowered_quantum_ops` (the gate - # shape actually executed; e.g. cx -> RZZ + rotations). When any chunk is - # lowered we replay from those, but first reject a mixed/partially-lowered - # trace that would silently drop a chunk's raw gates (see - # `_reject_partially_lowered_trace`). - if any(chunk.get("lowered_quantum_ops") for chunk in chunks): - _reject_partially_lowered_trace(chunks) - return _replay_lowered_qis_trace_into_tick_circuit(chunks) - - # No chunk was lowered: replay the uniformly-raw QIS operation stream. - operations: list[dict[str, Any]] = [] - for chunk in chunks: - operations.extend(list(chunk.get("operations", []))) - return _replay_qis_trace_into_tick_circuit(operations) + chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) + return _replay_qis_trace_chunks_into_tick_circuit(chunks) def _generate_traced_surface_tick_circuit( @@ -1015,6 +1131,25 @@ def _generate_traced_surface_tick_circuit( traced faithfully rather than silently substituting the default rotated patch of the same distance. """ + tc, _ = _generate_traced_surface_tick_circuit_with_result_traces( + patch, + num_rounds, + basis, + ancilla_budget=ancilla_budget, + runtime=runtime, + ) + return tc + + +def _generate_traced_surface_tick_circuit_with_result_traces( + patch: SurfacePatch, + num_rounds: int, + basis: str, + *, + ancilla_budget: int | None = None, + runtime: object | None = None, +) -> tuple[Any, list[dict[str, Any]]]: + """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" from pecos.guppy import get_num_qubits from pecos.guppy.surface import generate_memory_experiment @@ -1024,7 +1159,7 @@ def _generate_traced_surface_tick_circuit( basis, ancilla_budget=ancilla_budget, ) - return trace_guppy_into_tick_circuit( + return trace_guppy_into_tick_circuit_with_result_traces( program, get_num_qubits(patch=patch, ancilla_budget=ancilla_budget), seed=0, @@ -1061,57 +1196,43 @@ def _build_surface_tick_circuit_for_native_model( msg = f"Unknown circuit_source {circuit_source!r}" raise ValueError(msg) - traced_tc = _generate_traced_surface_tick_circuit( + traced_tc, result_traces = _generate_traced_surface_tick_circuit_with_result_traces( patch, num_rounds, basis, ancilla_budget=ancilla_budget, runtime=runtime, ) - # Coarse sanity check: the traced and abstract circuits must either agree - # on measured-qubit order or be remappable by measured-qubit occurrence. - # This catches gross drift (a dropped/added/wrong-qubit measurement) while - # allowing runtimes that preserve stabilizer identity but schedule - # measurements in a different order. It is NOT an identity-level check: - # `_extract_measurement_order` returns physical qubit indices, and under - # ancilla reuse the same physical qubit appears in many measurements -- so - # two different stabilizer orderings can produce an identical qubit-index - # sequence and pass here. - # There is no independent stabilizer-identity oracle in the stack today: - # the detector/observable record offsets are the production binding (not a - # validator), and the byte-identical traced-vs-traced DEM regression shares - # the same shared batching policy on both sides (so it cannot catch a - # policy bug). The current safeguards against identity drift are the shared - # `batched_stabilizers` source-of-truth and the source-level CX-emission - # pins; a true identity check here would need stabilizer provenance the - # replayed TickCircuit does not currently carry (future work). + + measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces( + patch, + num_rounds, + result_traces, + ) + _copy_surface_tick_circuit_metadata( + abstract_tc, + traced_tc, + measurement_index_remap=measurement_index_remap, + ) + traced_tc.set_meta("surface_metadata_record_binding", "runtime_result_tags") + + # Coarse sanity check: detector metadata is bound by runtime result tags, + # but still reject traces with dropped/extra/wrong measured physical qubits. traced_measurement_order = _extract_measurement_order(traced_tc) abstract_measurement_order = _extract_measurement_order(abstract_tc) - measurement_index_remap = None - if traced_measurement_order != abstract_measurement_order: - try: - measurement_index_remap = _measurement_index_remap_for_orders( - abstract_measurement_order, - traced_measurement_order, - ) - except ValueError as exc: - msg = ( - "Traced and abstract surface circuits disagree on the measured-qubit " - "sequence (a dropped/added/wrong-qubit measurement or a different " - "schedule shape); refusing to build a native DEM/sampler from a " - "circuit that does not match the abstract detector/observable metadata" - ) - raise ValueError(msg) from exc - - if measurement_index_remap is None: - _copy_surface_tick_circuit_metadata(abstract_tc, traced_tc) - else: - _copy_surface_tick_circuit_metadata( - abstract_tc, - traced_tc, - measurement_index_remap=measurement_index_remap, + try: + _measurement_index_remap_for_orders( + abstract_measurement_order, + traced_measurement_order, ) - traced_tc.set_meta("surface_metadata_record_binding", "runtime_measurement_order") + except ValueError as exc: + msg = ( + "Traced and abstract surface circuits disagree on the measured-qubit " + "multiset (a dropped/added/wrong-qubit measurement); refusing to " + "build a native DEM/sampler from a circuit that does not match the " + "surface memory experiment" + ) + raise ValueError(msg) from exc traced_tc.set_meta("circuit_source", circuit_source) return traced_tc @@ -1337,28 +1458,35 @@ def _dem_string_from_cached_surface_topology( """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder + noise_kwargs = { + "p_idle": noise.p_idle, + "t1": noise.t1, + "t2": noise.t2, + "p_idle_linear_rate": noise.p_idle_linear_rate, + "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, + "p2_weights": _p2_weights_dict(noise.p2_weights), + } + if noise.p2_replacement_approximation is not None: + noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation + + builder = DemBuilder(topology.influence_map).with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + **noise_kwargs, + ) + if hasattr(builder, "with_exact_branch_replay_circuit"): + builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) + dem = ( - DemBuilder(topology.influence_map) - .with_noise( - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - p_idle_linear_rate=noise.p_idle_linear_rate, - p_idle_quadratic_rate=noise.p_idle_quadratic_rate, - p_idle_x_linear_rate=noise.p_idle_x_linear_rate, - p_idle_y_linear_rate=noise.p_idle_y_linear_rate, - p_idle_z_linear_rate=noise.p_idle_z_linear_rate, - p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, - p2_weights=_p2_weights_dict(noise.p2_weights), - p2_replacement_approximation=noise.p2_replacement_approximation, - ) - .with_exact_branch_replay_circuit(topology.dag_circuit) + builder .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) .with_detectors_json(topology.detectors_json) diff --git a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py index 1e7ec9cb4..3ec397f6d 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -178,6 +178,30 @@ def test_capture_operation_trace_returns_in_memory_batches() -> None: assert trace[0]["lowered_quantum_ops"] +def test_capture_operation_trace_includes_named_result_provenance() -> None: + """Trace capture must preserve result(...) -> measurement-id provenance.""" + import pecos + from pecos.qec.surface.decode import named_result_traces_from_operation_trace + + _require_selene_runtime() + + trace = ( + pecos.sim(make_tiny_x_syndrome_memory(1)) + .classical(pecos.selene_engine()) + .quantum(pecos.stabilizer()) + .qubits(2) + .seed(123) + .capture_operation_trace() + ) + + named_traces = named_result_traces_from_operation_trace(trace) + names = {trace["name"] for trace in named_traces} + assert {"synx", "final"} <= names + assert any(chunk.get("stage") == "named_results" for chunk in trace) + for named_trace in named_traces: + assert len(named_trace["result_ids"]) == len(named_trace["values"]) + + def _collect_selene_named_results( instance: object, *, diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 8dc266600..9f6a3b13a 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -19,6 +19,8 @@ _remap_surface_record_metadata_json, _replay_lowered_qis_trace_into_tick_circuit, _replay_qis_trace_into_tick_circuit, + _surface_runtime_measurement_remap_from_result_traces, + trace_guppy_into_tick_circuit_with_result_traces, ) @@ -763,6 +765,43 @@ def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: ] +def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: + patch = SurfacePatch.create(distance=3) + program = make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=2) + _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( + program, + get_num_qubits(3, ancilla_budget=2), + seed=0, + ) + + remap = _surface_runtime_measurement_remap_from_result_traces( + patch, + 2, + result_traces, + ) + + assert len(remap) == 25 # 2 rounds * 8 stabilizers + 9 final data measurements + assert sorted(remap) == list(range(25)) + assert sorted(remap.values()) == list(range(25)) + + +def test_traced_surface_metadata_uses_runtime_result_tags() -> None: + patch = SurfacePatch.create(distance=3) + traced_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + ) + + assert traced_tc.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert traced_tc.get_meta("circuit_source") == "traced_qis" + assert int(traced_tc.get_meta("num_measurements")) == 25 + assert len(json.loads(traced_tc.get_meta("detectors"))) > 0 + assert len(json.loads(traced_tc.get_meta("observables"))) == 1 + + def test_surface_metadata_record_remap_rejects_measurement_drift() -> None: with pytest.raises(ValueError, match="measured-qubit multiset"): _measurement_index_remap_for_orders([0, 1, 0], [0, 1, 2]) From 3fd994268f6c909817cc26b14be0fd07240686b0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 12:12:14 -0600 Subject: [PATCH 066/388] Default LogicalSubgraphDecoder inner to fusion_blossom_serial (exact MWPM): measured more accurate AND faster than pecos_uf:bp at depth/multi-observable (d=7 transversal-CX 1.9s vs 12.5s, 2-5x lower LER), bundled; pecos_uf:bp kept as the native dependency-free option; rename test_default_inner_bp_uf_suppresses -> test_native_bp_uf_inner_suppresses --- .../src/fault_tolerance_bindings.rs | 38 ++++++++++++------- ...test_logical_subgraph_region_comparison.py | 24 ++++++------ 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 8b5261477..c3f5b5ea3 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4448,14 +4448,21 @@ impl PyCssUfDecoder { /// dem: DEM string with detector coordinate declarations. /// `stab_coords`: List of dicts, one per logical qubit. Each dict has /// keys "X" and "Z" mapping to lists of (x, y) ancilla coordinates. -/// `inner_decoder`: Inner decoder type string (default "`pecos_uf:bp`", -/// native belief-propagation + union-find). +/// `inner_decoder`: Inner decoder type string (default +/// "`fusion_blossom_serial`", exact MWPM -- accurate and fast across +/// distances, bundled). The best choice is circuit-dependent: +/// `pecos_uf:bp` (PECOS-native belief-propagation + union-find, +/// dependency-free) is competitive on memory and at small distance and is +/// the right pick when you want the pure-native path, but its grow+peel +/// matching is both LESS accurate and SLOWER at higher distance / +/// multi-observable circuits. `belief_matching` matches fusion's accuracy +/// but is slower. /// /// Example: /// >>> decoder = `LogicalSubgraphDecoder`( /// ... `dem_str`, /// ... [{"X": [(1,0), (3,1)], "Z": [(0,3), (1,1)]}], -/// ... "`pecos_uf:bp`", +/// ... "`fusion_blossom_serial`", /// ... ) /// >>> obs = decoder.decode(syndrome) #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] @@ -4465,17 +4472,20 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `pecos_uf:bp` (native belief-propagation + union-find): - // dependency-free, fast, and it achieves distance suppression (LER drops with - // code distance), tracking exact MWPM closely. The native UF previously did - // NOT suppress at d>=5, so the default was temporarily exact MWPM - // (`fusion_blossom_serial`); that UF bug -- a predecoder that mis-decoded - // isolated defects whose min-weight correction is a bulk path to the boundary - // -- has been fixed (predecoder now falls through to the full grow+peel - // decoder unless provably optimal). See - // pecos-docs/design/logical-subgraph-backprop-region-builder.md. + // Default inner is `fusion_blossom_serial` (exact MWPM, bundled). MEASURED + // accuracy/speed tradeoff (memory vs transversal algorithms, d=3..7, p=0.001) + // picks it: it is accurate AND fast across distances, whereas the native + // `pecos_uf:bp` -- competitive on memory and at d=3 -- is BOTH less accurate + // and slower at higher distance / multi-observable circuits (its grow+peel + // matching blows up at high d, e.g. d=7 transversal-CX: ~12.5s vs fusion + // ~1.9s, and ~2-5x higher LER at d=5/7). `pecos_uf:bp` remains the right pick + // for the pure-native, dependency-free path (and it does achieve distance + // suppression -- the predecoder bug that broke it at d>=5 is fixed). + // `belief_matching` matches fusion's accuracy but is slower. See + // pecos-docs/design/lomatching-paper-additional-learnings.md and + // logical-subgraph-backprop-region-builder.md. #[new] - #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:bp", max_time_radius=None))] + #[pyo3(signature = (dem, stab_coords, inner_decoder="fusion_blossom_serial", max_time_radius=None))] fn new( dem: &str, stab_coords: Vec>, @@ -4526,7 +4536,7 @@ impl PyLogicalSubgraphDecoder { /// construction (e.g. the paper's back-propagation / detecting-region set) /// and decode with the same machinery for direct comparison. #[staticmethod] - #[pyo3(signature = (dem, membership, inner_decoder="pecos_uf:bp"))] + #[pyo3(signature = (dem, membership, inner_decoder="fusion_blossom_serial"))] fn from_membership( dem: &str, membership: Vec>, diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index f05da9998..980f75e05 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -230,9 +230,9 @@ def _mem_ler(d, p, n, seed, inner=None): def test_distance_suppression_memory(): """A fault-tolerant decoder must drive LER DOWN as code distance grows below - threshold. The default inner (`pecos_uf:bp`, native BP + union-find) - suppresses, tracking exact MWPM / lomatching (d=7 -> 0). Guards against the - default reverting to a non-suppressing inner.""" + threshold. The default inner (`fusion_blossom_serial`, exact MWPM) suppresses, + matching lomatching (d=7 -> 0). Guards against the default reverting to a + non-suppressing inner.""" p, n = 0.001, 60000 ler_d3 = _mem_ler(3, p, n, seed=1) ler_d5 = _mem_ler(5, p, n, seed=1) @@ -242,9 +242,12 @@ def test_distance_suppression_memory(): ) -def test_default_inner_bp_uf_suppresses(): - """The default native inner decoder (`pecos_uf:bp`, belief-propagation + - union-find) achieves distance suppression, as a fault-tolerant decoder must. +def test_native_bp_uf_inner_suppresses(): + """The native `pecos_uf:bp` inner (belief-propagation + union-find) achieves + distance suppression, as a fault-tolerant decoder must. (It is the + dependency-free native option; the decoder *default* is exact MWPM + `fusion_blossom_serial`, which is more accurate and faster at depth -- see + pecos-docs/design/lomatching-paper-additional-learnings.md.) Context: the UF predecoder used to mis-decode isolated defects whose minimum-weight correction is a bulk *path* to the boundary (it only looked at @@ -253,11 +256,10 @@ def test_default_inner_bp_uf_suppresses(): the full grow+peel decoder unless its shortcut is provably optimal (see `predecode_single` / size-2 handling in pecos-uf-decoder). - NOTE: this validates the *default* inner `pecos_uf:bp`, which suppresses - robustly (tracks exact MWPM). Pure `pecos_uf:fast` (no belief propagation) was - also improved by the predecoder fix but its full grow+peel heuristic does NOT - robustly suppress at depth -- a separate, lesser weakness, which is why the - default is `pecos_uf:bp`, not `pecos_uf:fast`. + NOTE: pure `pecos_uf:fast` (no belief propagation) was also improved by the + predecoder fix but its full grow+peel heuristic does NOT robustly suppress at + depth -- a separate, lesser weakness, which is why the native option is + `pecos_uf:bp`, not `pecos_uf:fast`. See pecos-docs/design/logical-subgraph-backprop-region-builder.md.""" p, n = 0.001, 60000 uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") From 7d1244a996903b6943ee6a62ec8d33c20b57dafc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 15:01:39 -0600 Subject: [PATCH 067/388] Reuse constructor inner decoder in LogicalSubgraphDecoder.decode_count_parallel, add default-backend regressions, quarantine reliable_observables prototype --- .../src/fault_tolerance_bindings.rs | 62 ++++++++++++++----- .../src/pecos/qec/reliable_observables.py | 14 +++-- ...test_logical_subgraph_region_comparison.py | 39 ++++++++++++ 3 files changed, 95 insertions(+), 20 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c3f5b5ea3..e4ef8ef52 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4468,20 +4468,31 @@ impl PyCssUfDecoder { #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalSubgraphDecoder { inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, + /// The inner per-observable decoder backend selected at construction + /// (e.g. `"fusion_blossom_serial"`). `decode_count_parallel` reuses this so + /// the parallel workers match the serial path unless the caller overrides. + inner_decoder: String, } #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `fusion_blossom_serial` (exact MWPM, bundled). MEASURED - // accuracy/speed tradeoff (memory vs transversal algorithms, d=3..7, p=0.001) - // picks it: it is accurate AND fast across distances, whereas the native - // `pecos_uf:bp` -- competitive on memory and at d=3 -- is BOTH less accurate - // and slower at higher distance / multi-observable circuits (its grow+peel - // matching blows up at high d, e.g. d=7 transversal-CX: ~12.5s vs fusion - // ~1.9s, and ~2-5x higher LER at d=5/7). `pecos_uf:bp` remains the right pick - // for the pure-native, dependency-free path (and it does achieve distance - // suppression -- the predecoder bug that broke it at d>=5 is fixed). - // `belief_matching` matches fusion's accuracy but is slower. See + // Default inner is `fusion_blossom_serial`: a POLICY choice of accuracy-first + // exact MWPM, bundled by default. It is exact minimum-weight matching on each + // per-observable subgraph, so it cannot be beaten on that projected graph, and + // it ships with PECOS (no optional dependency to install). + // + // A spot benchmark (memory + transversal-CX, d=3..7, p=0.001, n=30k, SINGLE + // SEED) is consistent with this: fusion is at least as accurate as the native + // `pecos_uf:bp` everywhere and markedly faster at depth (e.g. d=7 transversal-CX + // ~1.9s vs ~12.5s). But that table is UNDER-POWERED to prove a long-term + // default -- single seed, single p, point LERs with overlapping confidence + // intervals. Treat it as supporting evidence, not proof; a proper threshold/CI + // sweep is a tracked benchmark TODO. The default rests on the policy above. + // + // `pecos_uf:bp` remains the right pick for the pure-native, dependency-free + // path (it does achieve distance suppression -- the predecoder bug that broke + // it at d>=5 is fixed). `belief_matching` matched fusion's accuracy in the spot + // benchmark but was slower. See // pecos-docs/design/lomatching-paper-additional-learnings.md and // logical-subgraph-backprop-region-builder.md. #[new] @@ -4525,7 +4536,10 @@ impl PyLogicalSubgraphDecoder { ) .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(Self { inner }) + Ok(Self { + inner, + inner_decoder: inner_decoder.to_string(), + }) } /// Build from a precomputed per-observable detector membership instead of @@ -4553,7 +4567,10 @@ impl PyLogicalSubgraphDecoder { }) .map_err(|e| PyErr::new::(e.to_string()))?; - Ok(Self { inner }) + Ok(Self { + inner, + inner_decoder: inner_decoder.to_string(), + }) } /// Decode a syndrome and return observable flip predictions. @@ -4569,6 +4586,15 @@ impl PyLogicalSubgraphDecoder { self.inner.num_observables() } + /// The inner per-observable decoder backend selected at construction. + /// + /// `decode_count_parallel` reuses this unless the caller overrides it, so + /// the serial and parallel paths agree by default. + #[getter] + fn inner_decoder(&self) -> &str { + &self.inner_decoder + } + /// Decode a batch of syndromes and return observable predictions. /// /// Args: @@ -4617,14 +4643,16 @@ impl PyLogicalSubgraphDecoder { /// Decode a `SampleBatch` in parallel using rayon. /// /// Creates per-worker decoder instances to avoid lock contention. - /// Requires the DEM string and inner decoder type for reconstruction. - #[pyo3(signature = (batch, dem, stab_coords, inner_decoder="pymatching", num_workers=None, max_time_radius=None))] + /// Requires the DEM string for reconstruction. `inner_decoder` defaults to + /// the backend selected at construction (so the parallel path matches the + /// serial `decode_count` path); pass an explicit value only to override it. + #[pyo3(signature = (batch, dem, stab_coords, inner_decoder=None, num_workers=None, max_time_radius=None))] fn decode_count_parallel( &self, batch: &PySampleBatch, dem: &str, stab_coords: Vec>, - inner_decoder: &str, + inner_decoder: Option<&str>, num_workers: Option, max_time_radius: Option, ) -> PyResult { @@ -4649,7 +4677,9 @@ impl PyLogicalSubgraphDecoder { } let dem_str = dem.to_string(); - let inner_str = inner_decoder.to_string(); + // Reuse the backend chosen at construction unless the caller overrides, + // so parallel workers decode identically to the serial path. + let inner_str = inner_decoder.unwrap_or(self.inner_decoder.as_str()).to_string(); let n = batch.num_shots; // Materialize row-major data for parallel decode. diff --git a/python/quantum-pecos/src/pecos/qec/reliable_observables.py b/python/quantum-pecos/src/pecos/qec/reliable_observables.py index 86d6fb451..f8d9433b4 100644 --- a/python/quantum-pecos/src/pecos/qec/reliable_observables.py +++ b/python/quantum-pecos/src/pecos/qec/reliable_observables.py @@ -25,10 +25,16 @@ take its right null space over GF(2). Each null vector is a combination of raw observables that commutes with *every* reset -- i.e. a reliable observable. -Status: prototype / proof-of-concept. Dependency-free (own GF(2) null space, no -``galois``). The eventual production path would compute the null space via -pecos-num's GF(2) and feed the reliable set into the logical-subgraph decoder -front-end. See ``pecos-docs/design/lomatching-paper-additional-learnings.md``. +Status: prototype / proof-of-concept -- NOT part of the supported ``pecos.qec`` +API. It is deliberately *not* re-exported by ``pecos.qec.__init__`` (importing +``pecos.qec`` does not pull it in). QUARANTINE: this module imports ``stim`` at +module load and uses ``stim.Circuit.detecting_regions`` at runtime, which +violates the project rule that externals (stim, numpy, ...) are dev/test oracles, +never on a runtime path. Do NOT import this on any decode path. A production +version must compute observing regions natively (pecos-eeg / EEG-Heisenberg) and +the GF(2) null space via pecos-num. It is also currently a no-op on the circuits +PECOS emits today (every observable is already reliable). See +``pecos-docs/design/lomatching-paper-additional-learnings.md``. """ from __future__ import annotations diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index 980f75e05..80f97001e 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -270,6 +270,45 @@ def test_native_bp_uf_inner_suppresses(): ) +def _mem_dem_batch(d, p, n, seed): + """Build a memory DEM + stab_coords + a sample batch (shared fixture for the + non-stochastic default/parallel regressions below).""" + patch = SurfacePatch.create(d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", d, "Z") + dem = b.build_dem(p1=p, p2=p, p_meas=p) + sc = b.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + return dem, sc, batch + + +def test_default_inner_is_fusion_blossom_serial(): + """Pin the constructor default backend deterministically (no stochastic LER + margin): the default-built decoder reports ``fusion_blossom_serial`` as its + inner, and decodes identically to one built with that inner explicitly. + Guards the default-flip decision recorded in + pecos-docs/design/lomatching-paper-additional-learnings.md.""" + dem, sc, batch = _mem_dem_batch(5, p=0.003, n=4000, seed=7) + default = LogicalSubgraphDecoder(dem, sc) + explicit = LogicalSubgraphDecoder(dem, sc, "fusion_blossom_serial") + assert default.inner_decoder == "fusion_blossom_serial" + assert default.decode_count(batch) == explicit.decode_count(batch) + + +def test_decode_count_parallel_matches_serial_default(): + """Finding #1 regression: `decode_count_parallel` must reuse the inner + backend chosen at construction (not silently fall back to a different + default), so the parallel path agrees with the serial `decode_count` on the + same shots. Previously the parallel helper defaulted to `pymatching` + regardless of the constructor's inner.""" + dem, sc, batch = _mem_dem_batch(5, p=0.003, n=4000, seed=11) + dec = LogicalSubgraphDecoder(dem, sc) + serial = dec.decode_count(batch) + parallel = dec.decode_count_parallel(batch, dem, sc) + assert serial == parallel + + def _windowed_mem_ler(d, rounds, p, n, seed, step, buffer): patch = SurfacePatch.create(d) b = LogicalCircuitBuilder() From c90c883aa33259afc8005c95fc1a4deefd96608a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 7 Jun 2026 18:44:51 -0600 Subject: [PATCH 068/388] Fix constrained ancilla DAG propagation --- .../src/fault_tolerance/propagator/dag.rs | 103 +++++++++++++++++- .../src/pecos/qec/surface/circuit_builder.py | 15 ++- .../tests/qec/surface/test_surface_decoder.py | 36 ++++++ .../qec/surface/test_surface_metadata.py | 9 +- 4 files changed, 153 insertions(+), 10 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 74ab1c3d9..8e11576d7 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -1891,8 +1891,11 @@ impl<'a> DagFaultAnalyzer<'a> { map.detectors.push(DetectorId::single(measurement_id)); } - // Use forest propagation: per-ancilla Phase 1/Phase 2 split. - let recorder = self.propagate_all_forest(); + // Use the generic per-measurement path for correctness with physical + // qubit reuse. The forest shortcut groups by measured qubit ID and is + // only valid when that physical qubit represents one fixed measurement + // stream, not a reusable ancilla slot. + let recorder = self.propagate_all_parallel(); // Convert buckets to SoA format (O(n) flattening) map.influences = recorder.into_soa(); @@ -2336,9 +2339,14 @@ impl<'a> DagFaultAnalyzer<'a> { } } - /// Parallel forest propagation: groups measurements by ancilla qubit, - /// propagates the latest measurement fully with capture, replays the - /// shared tail prefix for earlier measurements. + /// Parallel forest propagation for fixed ancilla streams. + /// + /// This groups measurements by physical ancilla qubit, propagates the latest + /// measurement fully with capture, and replays the shared tail prefix for + /// earlier measurements. It is an optimization for circuits where each + /// measured physical qubit represents one fixed logical measurement stream. + /// It must not be used for circuits that reuse one physical measurement + /// qubit for multiple logical checks. #[must_use] pub fn propagate_all_forest(&self) -> BucketRecorder { use rayon::prelude::*; @@ -2542,6 +2550,71 @@ mod tests { dag } + /// Reuses one physical ancilla slot for two different checks that share a data qubit. + fn reused_physical_ancilla_circuit() -> DagCircuit { + let mut dag = DagCircuit::new(); + for _ in 0..2 { + dag.qalloc(&[10]); + dag.cx(&[(0, 10)]); + dag.cx(&[(1, 10)]); + dag.mz_free(&[10]); + + dag.qalloc(&[10]); + dag.cx(&[(1, 10)]); + dag.cx(&[(2, 10)]); + dag.mz_free(&[10]); + } + dag + } + + fn build_parallel_map(analyzer: &DagFaultAnalyzer<'_>) -> DagFaultInfluenceMap { + build_map_with_influences(analyzer, analyzer.propagate_all_parallel().into_soa()) + } + + fn build_forest_map(analyzer: &DagFaultAnalyzer<'_>) -> DagFaultInfluenceMap { + build_map_with_influences(analyzer, analyzer.propagate_all_forest().into_soa()) + } + + fn build_map_with_influences( + analyzer: &DagFaultAnalyzer<'_>, + influences: InfluencesSoA, + ) -> DagFaultInfluenceMap { + let mut map = DagFaultInfluenceMap::with_capacity(analyzer.locations.len()); + map.locations = analyzer.locations.to_dag_spacetime_locations(); + + let (measurements, meas_ids) = analyzer.extract_measurements(); + map.measurements.clone_from(&measurements); + map.meas_ids = meas_ids; + + for &(node, qubit, basis) in &measurements { + map.detectors.push(DetectorId::single(MeasurementId { + tick: node, + qubit, + basis, + })); + } + + map.influences = influences; + map + } + + fn detector_fingerprint(map: &DagFaultInfluenceMap) -> Vec<(Vec, Vec)> { + vec![ + ( + map.influences.detectors_x.offsets.clone(), + map.influences.detectors_x.data.clone(), + ), + ( + map.influences.detectors_y.offsets.clone(), + map.influences.detectors_y.data.clone(), + ), + ( + map.influences.detectors_z.offsets.clone(), + map.influences.detectors_z.data.clone(), + ), + ] + } + /// Circuit with CZ gates for testing multi-qubit symmetric faults fn cz_syndrome_circuit() -> DagCircuit { let mut dag = DagCircuit::new(); @@ -2688,6 +2761,26 @@ mod tests { assert!(locations.len() >= 4); } + #[test] + fn test_build_influence_map_uses_generic_propagation_for_reused_physical_ancilla_slots() { + let dag = reused_physical_ancilla_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + + let built = analyzer.build_influence_map(); + let parallel = build_parallel_map(&analyzer); + let forest = build_forest_map(&analyzer); + + assert_eq!( + detector_fingerprint(&built), + detector_fingerprint(¶llel) + ); + assert_ne!( + detector_fingerprint(&forest), + detector_fingerprint(¶llel), + "this regression circuit should distinguish physical-slot reuse from fixed-ancilla reuse" + ); + } + #[test] fn test_dag_spacetime_location_ordering() { // Verify that DagSpacetimeLocation has consistent ordering diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index bf5989773..b748a5eea 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -728,7 +728,12 @@ def render( circuit.cx([(op.qubits[0], op.qubits[1])]) elif op.op_type == OpType.MEASURE: - circuit.mz([op.qubits[0]]) + q = op.qubits[0] + if op.label.startswith(("sx", "sz")): + circuit.mz_free([q]) + allocated.discard(q) + else: + circuit.mz([q]) elif op.op_type == OpType.TICK: pass # DagCircuit doesn't have explicit ticks @@ -1038,7 +1043,11 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non elif op.op_type == OpType.MEASURE: q = op.qubits[0] - meas_refs = get_tick_for_qubits([q]).mz([q]) + if op.label.startswith(("sx", "sz")): + meas_refs = get_tick_for_qubits([q]).mz_free([q]) + allocated.discard(q) + else: + meas_refs = get_tick_for_qubits([q]).mz([q]) mark_qubits_used([q]) # Label helps identify measurement (e.g., "sx0", "sz0", "final[0]") meta = get_ancilla_gate_metadata(q, op.label) @@ -2091,7 +2100,7 @@ def _extract_measurement_order(tc: TickCircuit) -> list[int]: gates = tick.gate_batches() for gate in gates: gate_type = str(gate.gate_type) - if "MZ" in gate_type: + if "MZ" in gate_type or "MeasureFree" in gate_type: # Add each measured qubit to the order for qubit in gate.qubits: # Qubit might be an int or a QubitId object diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index 0cdcd1486..88982a279 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -473,6 +473,42 @@ def test_constrained_budget_sampler_builds_for_all_models(self) -> None: ) assert sampler.num_detectors == expected_detectors + def test_constrained_budget_dem_remains_strictly_decodable(self) -> None: + """Constrained ancilla reuse should not produce ungraphlike DEM artifacts. + + This pins a regression where DAG fault propagation grouped measurements + by physical ancilla slot. That shortcut is invalid when the same slot is + reused for different stabilizers and produced high-degree mechanisms + that strict PyMatching could not parse. + """ + from pecos.qec import ParsedDem + + patch = SurfacePatch.create(distance=5) + params = { + "p1": 0.0, + "p2": 0.001, + "p_meas": 0.0, + "p_prep": 0.0, + "decompose_errors": True, + } + + for basis in ("X", "Z"): + tc = generate_tick_circuit_from_patch( + patch, + num_rounds=5, + basis=basis, + ancilla_budget=8, + ) + dem = generate_dem_from_tick_circuit(tc, **params) + sampler = ParsedDem.from_string(dem).to_dem_sampler() + + assert sampler.sample_decode_count( + dem, + 16, + decoder_type="pymatching", + seed=1234, + ) >= 0 + def test_traced_qis_traces_the_given_patch_not_its_distance(self) -> None: """A non-rotated patch must be traced from its OWN Guppy program, not the default rotated patch of the same distance. Before the patch- diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index f5f7fa606..ec75274fe 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -199,19 +199,23 @@ def test_tick_circuit_exposes_observable_descriptors() -> None: def test_tick_circuit_exposes_measurement_order() -> None: - """Tick circuits should expose measurement order matching their MZ gates.""" + """Tick circuits should expose measurement order matching measurement gates.""" patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") observed = get_measurement_order_from_tick_circuit(tc) expected: list[int] = [] + has_measure_free = False for tick_index in range(tc.num_ticks()): tick = tc.get_tick(tick_index) if tick is None: continue for gate in tick.gate_batches(): - if "MZ" not in str(gate.gate_type): + gate_type = str(gate.gate_type) + if "MeasureFree" in gate_type: + has_measure_free = True + if "MZ" not in gate_type and "MeasureFree" not in gate_type: continue for qubit in gate.qubits: if hasattr(qubit, "index"): @@ -219,6 +223,7 @@ def test_tick_circuit_exposes_measurement_order() -> None: else: expected.append(int(qubit)) + assert has_measure_free assert observed == expected assert len(observed) == int(tc.get_meta("num_measurements") or "0") From 98236acf16f24951058dd3d889d9396f6edefcef Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 19:03:35 -0600 Subject: [PATCH 069/388] Normalize Windows LLVM paths for bindgen --- crates/pecos-build/src/llvm.rs | 67 +++++++++++++++++++++++++++ crates/pecos-build/src/llvm/config.rs | 8 ++-- crates/pecos-cli/src/cli/env_cmd.rs | 13 ++++-- 3 files changed, 81 insertions(+), 7 deletions(-) diff --git a/crates/pecos-build/src/llvm.rs b/crates/pecos-build/src/llvm.rs index 7a2d4ebb4..8f6ed03cd 100644 --- a/crates/pecos-build/src/llvm.rs +++ b/crates/pecos-build/src/llvm.rs @@ -36,6 +36,41 @@ pub const REQUIRED_VERSION: &str = "21.1"; /// Cargo/llvm-sys environment variable for the required LLVM version. pub const LLVM_SYS_PREFIX_ENV: &str = "LLVM_SYS_211_PREFIX"; +/// Convert a path into a stable string for build environment variables and +/// Cargo config values. +/// +/// Windows `canonicalize()` returns verbatim paths such as `\\?\C:\...`. +/// Most Rust and Windows APIs accept those, but bindgen's libclang loader does +/// not treat them as valid DLL search directories. Cargo config also does not +/// need the verbatim prefix, so strip it while keeping the path absolute. +#[must_use] +pub fn path_to_env_string(path: &Path) -> String { + normalize_path_string(&path.to_string_lossy()) +} + +/// Normalize a stored path string before turning it back into a [`PathBuf`]. +#[must_use] +pub fn normalize_path_string(path: &str) -> String { + let path = path.replace('\\', "/"); + + if let Some(rest) = path.strip_prefix("//?/UNC/") { + return format!("//{rest}"); + } + + if let Some(rest) = path.strip_prefix("//?/") + && is_windows_drive_path(rest) + { + return rest.to_string(); + } + + path +} + +fn is_windows_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/' +} + /// Return whether an `llvm-config --version` string is compatible with PECOS. #[must_use] pub fn is_required_llvm_version(version: &str) -> bool { @@ -547,4 +582,36 @@ mod tests { assert!(!is_required_llvm_version("21.10.0")); assert!(!is_required_llvm_version("22.0.0")); } + + #[test] + fn normalize_path_string_strips_windows_verbatim_drive_prefix() { + assert_eq!( + normalize_path_string(r"\\?\C:\Users\runneradmin\.pecos\deps\llvm-21.1\Library"), + "C:/Users/runneradmin/.pecos/deps/llvm-21.1/Library" + ); + assert_eq!( + normalize_path_string("//?/C:/Users/runneradmin/.pecos/deps/llvm-21.1/Library"), + "C:/Users/runneradmin/.pecos/deps/llvm-21.1/Library" + ); + } + + #[test] + fn normalize_path_string_strips_windows_verbatim_unc_prefix() { + assert_eq!( + normalize_path_string(r"\\?\UNC\server\share\llvm-21.1"), + "//server/share/llvm-21.1" + ); + } + + #[test] + fn normalize_path_string_leaves_non_verbatim_paths_alone() { + assert_eq!( + normalize_path_string("/home/ciaranra/.pecos/deps/llvm-21.1"), + "/home/ciaranra/.pecos/deps/llvm-21.1" + ); + assert_eq!( + normalize_path_string("//server/share/llvm-21.1"), + "//server/share/llvm-21.1" + ); + } } diff --git a/crates/pecos-build/src/llvm/config.rs b/crates/pecos-build/src/llvm/config.rs index 0702c41f1..1756b57f5 100644 --- a/crates/pecos-build/src/llvm/config.rs +++ b/crates/pecos-build/src/llvm/config.rs @@ -3,7 +3,7 @@ use crate::errors::{Error, Result}; use crate::llvm::{ LLVM_SYS_PREFIX_ENV, REQUIRED_VERSION, find_cargo_project_root, find_llvm, get_pecos_command, - get_repo_root_from_manifest, is_valid_llvm, + get_repo_root_from_manifest, is_valid_llvm, normalize_path_string, path_to_env_string, }; use std::fs; use std::path::{Path, PathBuf}; @@ -108,14 +108,14 @@ pub fn read_configured_llvm_path() -> Option { // Simple string: LLVM_SYS_211_PREFIX = "/path" if let Some(s) = entry.as_str() { - return Some(PathBuf::from(s)); + return Some(PathBuf::from(normalize_path_string(s))); } // Inline table: LLVM_SYS_211_PREFIX = { value = "/path", force = true } if let Some(t) = entry.as_table() && let Some(v) = t.get("value").and_then(|v| v.as_str()) { - return Some(PathBuf::from(v)); + return Some(PathBuf::from(normalize_path_string(v))); } None @@ -237,7 +237,7 @@ pub fn auto_configure_llvm(project_root: Option) -> Result { /// cannot be written. pub fn write_cargo_config(project_root: &Path, llvm_path: &Path, force: bool) -> Result<()> { // Forward slashes keep the value backslash-escape-free in TOML. - let llvm_path_str = llvm_path.to_string_lossy().replace('\\', "/"); + let llvm_path_str = path_to_env_string(llvm_path); let mut cfg = crate::cargo_config::CargoConfig::open(project_root)?; cfg.set_env(LLVM_SYS_PREFIX_ENV, &llvm_path_str, force)?; cfg.save()?; diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index a99dc51fe..11b232a8c 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -45,7 +45,7 @@ pub fn collect_env() -> BTreeMap { // LLVM if let Some(llvm_path) = pecos_build::llvm::find_configured_or_detected_llvm(None) { - let llvm_str = llvm_path.display().to_string(); + let llvm_str = pecos_build::llvm::path_to_env_string(&llvm_path); env.insert("PECOS_LLVM".into(), llvm_str.clone()); env.insert(LLVM_SYS_PREFIX_ENV.into(), llvm_str); @@ -68,7 +68,10 @@ pub fn collect_env() -> BTreeMap { } if let Some(libclang_dir) = find_libclang_dir(&llvm_path, &libdir) { - env.insert("LIBCLANG_PATH".into(), libclang_dir.display().to_string()); + env.insert( + "LIBCLANG_PATH".into(), + pecos_build::llvm::path_to_env_string(&libclang_dir), + ); } } } @@ -276,7 +279,11 @@ fn write_github_actions_files( .create(true) .open(github_path)?; if let Some(llvm_path) = env.get(LLVM_SYS_PREFIX_ENV) { - writeln!(path_file, "{}", Path::new(llvm_path).join("bin").display())?; + writeln!( + path_file, + "{}", + pecos_build::llvm::path_to_env_string(&Path::new(llvm_path).join("bin")) + )?; } Ok(()) From d3976652acf62f6205188b5b3bc85d9403e16a5a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 23:13:53 -0600 Subject: [PATCH 070/388] Add powered inner-decoder threshold/LER study establishing fusion_blossom_serial as the default with Jeffreys CIs --- examples/surface/inner_decoder_study.py | 445 ++++++++++++++++++ .../results/inner_decoder_study_report.md | 183 +++++++ .../results/inner_decoder_study_speed.jsonl | 8 + .../inner_decoder_study_suppress.jsonl | 216 +++++++++ .../inner_decoder_study_threshold.jsonl | 72 +++ .../src/fault_tolerance_bindings.rs | 37 +- 6 files changed, 945 insertions(+), 16 deletions(-) create mode 100644 examples/surface/inner_decoder_study.py create mode 100644 examples/surface/results/inner_decoder_study_report.md create mode 100644 examples/surface/results/inner_decoder_study_speed.jsonl create mode 100644 examples/surface/results/inner_decoder_study_suppress.jsonl create mode 100644 examples/surface/results/inner_decoder_study_threshold.jsonl diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py new file mode 100644 index 000000000..c3baaaf2a --- /dev/null +++ b/examples/surface/inner_decoder_study.py @@ -0,0 +1,445 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +# in compliance with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License +# is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +# or implied. See the License for the specific language governing permissions and limitations under +# the License. + +"""Rigorous inner-decoder study for ``LogicalSubgraphDecoder``. + +Answers, with statistics that can actually separate the candidates: + +* Fault tolerance / distance suppression -- does logical error rate (LER) fall + as code distance ``d`` grows below threshold, for each inner decoder? +* Threshold -- where do the per-distance LER curves cross (so above it more + distance hurts)? Estimated per inner. +* Lowest LER -- at fixed sub-threshold ``p``, which inner wins, and is the gap + statistically real (non-overlapping Jeffreys intervals)? +* Speed -- decoder build cost vs per-shot decode throughput, separated. + +Design choices that fix the under-powered earlier spot-check: + +* PAIRED comparison: one sampled batch per (family, d, p, seed) is decoded by + every inner, so decoder differences are not confounded by sampling noise. +* Sub-threshold ``p`` chosen so LER is large enough (~1e-3..1e-2) that 1e5 shots + yield hundreds of failures -> tight intervals that resolve 2x differences. +* Multiple seeds for the headline cells (batch-to-batch stability). +* Jeffreys (Bayesian Beta(k+1/2, n-k+1/2)) intervals -- the project's preferred + binomial CI -- computed via scipy as an analysis oracle (never a runtime dep). +* Build time (decoder construction) separated from decode time (decode_count). + +Results are appended as JSON lines to ``results/inner_decoder_study_.jsonl`` +so a run is resumable and analysable independently (see ``--phase analyze``). +""" + +from __future__ import annotations + +import argparse +import json +import math +import time +from dataclasses import asdict, dataclass +from pathlib import Path + +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem + +# Candidate inner decoders for the library default. fusion_blossom_serial is the +# current default (exact MWPM, bundled); pecos_uf:bp is the native option; +# belief_matching is BP+MWPM; pymatching/tesseract are external baselines. +CANDIDATES = [ + "fusion_blossom_serial", + "pecos_uf:bp", + "belief_matching", + "pymatching", + "tesseract", +] + +RESULTS_DIR = Path(__file__).resolve().parent / "results" + + +@dataclass(frozen=True) +class Cell: + """One measured (family, d, p, seed, inner) point.""" + + family: str + distance: int + rounds: int + p: float + seed: int + inner: str + num_shots: int + num_errors: int + ler: float + build_seconds: float + decode_seconds: float + + +# --------------------------------------------------------------------------- # +# Circuit families +# --------------------------------------------------------------------------- # + + +def _memory_builder(d: int, rounds: int) -> LogicalCircuitBuilder: + patch = SurfacePatch.create(distance=d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds, "Z") + return b + + +def _cx_builder(d: int, rounds: int) -> LogicalCircuitBuilder: + patch = SurfacePatch.create(distance=d) + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], rounds, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], rounds, "Z") + return b + + +FAMILIES = {"memory": _memory_builder, "cx": _cx_builder} + + +# --------------------------------------------------------------------------- # +# Statistics (Jeffreys interval as an analysis oracle) +# --------------------------------------------------------------------------- # + + +def jeffreys_ci(k: int, n: int, alpha: float = 0.05) -> tuple[float, float]: + """Two-sided Jeffreys (Beta) credible interval for a binomial proportion. + + Posterior under the Jeffreys prior Beta(1/2, 1/2) is Beta(k+1/2, n-k+1/2). + Endpoints clamped to (0, 1) at k=0 / k=n per the standard convention. + """ + from scipy.stats import beta # analysis-only oracle, not a PECOS runtime dep + + lo = 0.0 if k == 0 else float(beta.ppf(alpha / 2.0, k + 0.5, n - k + 0.5)) + hi = 1.0 if k == n else float(beta.ppf(1.0 - alpha / 2.0, k + 0.5, n - k + 0.5)) + return lo, hi + + +def intervals_disjoint(a: Cell, b: Cell) -> bool: + """True if the two cells' Jeffreys 95% intervals do not overlap.""" + a_lo, a_hi = jeffreys_ci(a.num_errors, a.num_shots) + b_lo, b_hi = jeffreys_ci(b.num_errors, b.num_shots) + return a_hi < b_lo or b_hi < a_lo + + +# --------------------------------------------------------------------------- # +# Measurement +# --------------------------------------------------------------------------- # + + +def measure_cell(family: str, d: int, rounds: int, p: float, seed: int, inners: list[str], n: int) -> list[Cell]: + """Sample ONE batch and decode it with every inner (paired comparison).""" + builder = FAMILIES[family](d, rounds) + dem = builder.build_dem(p1=p, p2=p, p_meas=p) + sc = builder.stab_coords() + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) + + cells: list[Cell] = [] + for inner in inners: + t0 = time.perf_counter() + dec = LogicalSubgraphDecoder(dem, sc, inner) + t1 = time.perf_counter() + wrong = dec.decode_count(batch) + t2 = time.perf_counter() + cells.append( + Cell( + family=family, + distance=d, + rounds=rounds, + p=p, + seed=seed, + inner=inner, + num_shots=n, + num_errors=wrong, + ler=wrong / n, + build_seconds=t1 - t0, + decode_seconds=t2 - t1, + ) + ) + return cells + + +def _append(path: Path, cells: list[Cell]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a") as fh: + for c in cells: + fh.write(json.dumps(asdict(c)) + "\n") + + +def _load(path: Path) -> list[Cell]: + if not path.exists(): + return [] + out = [] + for line in path.read_text().splitlines(): + if line.strip(): + out.append(Cell(**json.loads(line))) + return out + + +# --------------------------------------------------------------------------- # +# Phases +# --------------------------------------------------------------------------- # + + +def run_suppress(path: Path) -> None: + """Distance suppression + decoder ranking with resolving statistics. + + Sub-threshold p, large n, multiple seeds; memory (1 obs) and transversal-CX + (multi-obs, where the earlier spot-check saw fusion beat bp).""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + plan = [ + ("memory", [3, 5, 7], [0.002, 0.003, 0.005], CANDIDATES, [1, 2, 3], 100_000), + ("cx", [3, 5, 7], [0.002, 0.003, 0.005], ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"], [1, 2, 3], 50_000), + ] + for family, ds, ps, inners, seeds, n in plan: + for d in ds: + for p in ps: + for seed in seeds: + todo = [i for i in inners if (family, d, p, seed, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell(family, d, d, p, seed, todo, n) + _append(path, cells) + best = min(cells, key=lambda c: c.ler) + print( + f"[suppress] {family:6s} d={d} p={p:.3f} seed={seed} n={n}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.num_errors}" for c in cells) + + f" best={best.inner.split(':')[0]} ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + + +def run_threshold(path: Path) -> None: + """Threshold sweep over p for the policy candidates -- locate the crossing.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pecos_uf:bp", "pymatching"] + ps = [0.004, 0.005, 0.006, 0.007, 0.008, 0.009, 0.010, 0.012] + for d in [3, 5, 7]: + for p in ps: + todo = [i for i in inners if ("memory", d, p, 1, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell("memory", d, d, p, 1, todo, 50_000) + _append(path, cells) + print( + f"[threshold] memory d={d} p={p:.3f}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.ler:.4f}" for c in cells) + + f" ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + + +def run_speed(path: Path) -> None: + """Per-shot decode throughput (build vs decode) at the costly d=7 point.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + for family in ["memory", "cx"]: + inners = CANDIDATES if family == "memory" else ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"] + todo = [i for i in inners if (family, 7, 0.003, 1, i) not in done] + if not todo: + continue + cells = measure_cell(family, 7, 7, 0.003, 1, todo, 50_000) + _append(path, cells) + for c in cells: + us = c.decode_seconds / c.num_shots * 1e6 + print( + f"[speed] {family:6s} d=7 {c.inner:24s}: build={c.build_seconds * 1e3:7.1f}ms " + f"decode={c.decode_seconds:6.2f}s {us:8.1f}us/shot", + flush=True, + ) + + +# --------------------------------------------------------------------------- # +# Analysis +# --------------------------------------------------------------------------- # + + +def _pool(cells: list[Cell]) -> dict: + """Pool repeated seeds for the same (family,d,p,inner) into one binomial.""" + agg: dict[tuple, list[int]] = {} + for c in cells: + key = (c.family, c.distance, c.p, c.inner) + k, n = agg.setdefault(key, [0, 0]) + agg[key] = [k + c.num_errors, n + c.num_shots] + return agg + + +def analyze(out_dir: Path) -> str: + lines: list[str] = [] + + def w(s: str = "") -> None: + lines.append(s) + + sup = _load(out_dir / "inner_decoder_study_suppress.jsonl") + thr = _load(out_dir / "inner_decoder_study_threshold.jsonl") + spd = _load(out_dir / "inner_decoder_study_speed.jsonl") + + w("# Inner-decoder study results") + w() + w("LER with Jeffreys 95% intervals (Beta(k+1/2, n-k+1/2)); seeds pooled into one") + w("binomial per (family, d, p, inner). `k/n` = failures / shots.") + w() + + if sup: + agg = _pool(sup) + families = sorted({k[0] for k in agg}) + inners = [i for i in CANDIDATES if any(k[3] == i for k in agg)] + for fam in families: + ps = sorted({k[2] for k in agg if k[0] == fam}) + ds = sorted({k[1] for k in agg if k[0] == fam}) + w(f"## {fam}: distance suppression + ranking") + w() + for p in ps: + w(f"### p = {p}") + w() + w("| inner | " + " | ".join(f"d={d}" for d in ds) + " |") + w("|---|" + "---|" * len(ds)) + for inner in inners: + cells = [] + for d in ds: + kv = agg.get((fam, d, p, inner)) + cells.append(kv) + row = [inner] + for kv in cells: + if kv is None: + row.append("--") + continue + k, n = kv + lo, hi = jeffreys_ci(k, n) + row.append(f"{k}/{n} {k / n:.2e} [{lo:.1e},{hi:.1e}]") + w("| " + " | ".join(row) + " |") + w() + # Decision-relevant contrast per distance: the best inner vs the + # native pecos_uf:bp candidate (the MWPM-family members are + # accuracy-tied on these graphlike DEMs, so best-vs-2nd is + # uninformative -- best-vs-bp is the contrast that picks a default). + for d in ds: + present = [(i, agg[(fam, d, p, i)]) for i in inners if (fam, d, p, i) in agg] + if len(present) < 2: + continue + present.sort(key=lambda t: t[1][0] / t[1][1]) + bi, (bk, bn) = present[0] + bench = "pecos_uf:bp" + if (fam, d, p, bench) not in agg or bi == bench: + continue + rk, rn = agg[(fam, d, p, bench)] + blo, bhi = jeffreys_ci(bk, bn) + rlo, rhi = jeffreys_ci(rk, rn) + sep = "DISJOINT" if bhi < rlo else "overlap" + ratio = (rk / rn) / (bk / bn) if bk else float("inf") + w(f"- d={d}: best **{bi}** {bk / bn:.2e} vs {bench} {rk / rn:.2e} " + f"({ratio:.1f}x) -- Jeffreys intervals {sep}") + w() + # Suppression check + exponent per inner (pooled across seeds). + w(f"### {fam}: suppression exponent (LER ~ (p/p_th)^((d+1)/2))") + w() + for p in ps: + for inner in inners: + seq = [(d, agg[(fam, d, p, inner)]) for d in ds if (fam, d, p, inner) in agg] + seq = [(d, kv) for d, kv in seq if kv[0] > 0] # need nonzero to log + if len(seq) < 2: + continue + suppresses = all( + seq[i + 1][1][0] / seq[i + 1][1][1] < seq[i][1][0] / seq[i][1][1] + for i in range(len(seq) - 1) + ) + ratios = [ + (seq[i][1][0] / seq[i][1][1]) / (seq[i + 1][1][0] / seq[i + 1][1][1]) + for i in range(len(seq) - 1) + ] + tag = "suppresses" if suppresses else "NOT monotone" + w(f"- p={p} {inner}: {tag}; per-step LER ratio " + + ", ".join(f"{r:.1f}x" for r in ratios)) + w() + + if thr: + agg = _pool(thr) + inners = sorted({k[3] for k in agg}) + ds = sorted({k[1] for k in agg}) + ps = sorted({k[2] for k in agg}) + w("## memory: threshold crossing") + w() + for inner in inners: + w(f"### {inner}") + w() + w("| p | " + " | ".join(f"d={d}" for d in ds) + " |") + w("|---|" + "---|" * len(ds)) + for p in ps: + row = [f"{p:.3f}"] + for d in ds: + kv = agg.get(("memory", d, p, inner)) + row.append(f"{kv[0] / kv[1]:.2e}" if kv else "--") + w("| " + " | ".join(row) + " |") + # crossing estimate: smallest p where d=max no longer beats d=min + cross = None + d_lo, d_hi = ds[0], ds[-1] + for p in ps: + a = agg.get(("memory", d_lo, p, inner)) + b = agg.get(("memory", d_hi, p, inner)) + if a and b and b[0] / b[1] >= a[0] / a[1]: + cross = p + break + w() + w(f"- threshold estimate (d={d_hi} stops beating d={d_lo}): " + + (f"~{cross}" if cross else f"above {ps[-1]} (not reached)")) + w() + + if spd: + w("## speed (d=7, p=0.003, n per cell as sampled)") + w() + w("| family | inner | build ms | decode s | us/shot |") + w("|---|---|---:|---:|---:|") + for c in sorted(spd, key=lambda c: (c.family, c.decode_seconds)): + us = c.decode_seconds / c.num_shots * 1e6 + w(f"| {c.family} | {c.inner} | {c.build_seconds * 1e3:.1f} | " + f"{c.decode_seconds:.2f} | {us:.1f} |") + w() + + return "\n".join(lines) + + +# --------------------------------------------------------------------------- # + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--phase", required=True, choices=["suppress", "threshold", "speed", "analyze", "smoke"]) + ap.add_argument("--out", type=Path, default=RESULTS_DIR) + args = ap.parse_args() + + if args.phase == "smoke": + cells = measure_cell("memory", 3, 3, 0.005, 1, ["fusion_blossom_serial", "pecos_uf:bp"], 2000) + for c in cells: + lo, hi = jeffreys_ci(c.num_errors, c.num_shots) + print(f"smoke {c.inner}: {c.num_errors}/{c.num_shots} ler={c.ler:.4f} " + f"CI=[{lo:.4f},{hi:.4f}] build={c.build_seconds * 1e3:.1f}ms decode={c.decode_seconds:.3f}s") + cx = measure_cell("cx", 3, 3, 0.005, 1, ["fusion_blossom_serial"], 2000) + print(f"smoke cx: {cx[0].num_errors}/{cx[0].num_shots} ler={cx[0].ler:.4f}") + return + + if args.phase == "analyze": + report = analyze(args.out) + print(report) + (args.out / "inner_decoder_study_report.md").write_text(report + "\n") + print(f"\n[written] {args.out / 'inner_decoder_study_report.md'}") + return + + path = args.out / f"inner_decoder_study_{args.phase}.jsonl" + {"suppress": run_suppress, "threshold": run_threshold, "speed": run_speed}[args.phase](path) + print(f"[done] {args.phase} -> {path}", flush=True) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/results/inner_decoder_study_report.md b/examples/surface/results/inner_decoder_study_report.md new file mode 100644 index 000000000..0925e703d --- /dev/null +++ b/examples/surface/results/inner_decoder_study_report.md @@ -0,0 +1,183 @@ +# Inner-decoder study results + +LER with Jeffreys 95% intervals (Beta(k+1/2, n-k+1/2)); seeds pooled into one +binomial per (family, d, p, inner). `k/n` = failures / shots. + +## cx: distance suppression + ranking + +### p = 0.002 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 1039/150000 6.93e-03 [6.5e-03,7.4e-03] | 294/150000 1.96e-03 [1.7e-03,2.2e-03] | 59/150000 3.93e-04 [3.0e-04,5.0e-04] | +| pecos_uf:bp | 1069/150000 7.13e-03 [6.7e-03,7.6e-03] | 556/150000 3.71e-03 [3.4e-03,4.0e-03] | 159/150000 1.06e-03 [9.0e-04,1.2e-03] | +| belief_matching | 1042/150000 6.95e-03 [6.5e-03,7.4e-03] | 294/150000 1.96e-03 [1.7e-03,2.2e-03] | 59/150000 3.93e-04 [3.0e-04,5.0e-04] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 6.93e-03 vs pecos_uf:bp 7.13e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 1.96e-03 vs pecos_uf:bp 3.71e-03 (1.9x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 3.93e-04 vs pecos_uf:bp 1.06e-03 (2.7x) -- Jeffreys intervals DISJOINT + +### p = 0.003 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 2158/150000 1.44e-02 [1.4e-02,1.5e-02] | 1010/150000 6.73e-03 [6.3e-03,7.2e-03] | 333/150000 2.22e-03 [2.0e-03,2.5e-03] | +| pecos_uf:bp | 2217/150000 1.48e-02 [1.4e-02,1.5e-02] | 1641/150000 1.09e-02 [1.0e-02,1.1e-02] | 662/150000 4.41e-03 [4.1e-03,4.8e-03] | +| belief_matching | 2159/150000 1.44e-02 [1.4e-02,1.5e-02] | 1010/150000 6.73e-03 [6.3e-03,7.2e-03] | 333/150000 2.22e-03 [2.0e-03,2.5e-03] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 1.44e-02 vs pecos_uf:bp 1.48e-02 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 6.73e-03 vs pecos_uf:bp 1.09e-02 (1.6x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 2.22e-03 vs pecos_uf:bp 4.41e-03 (2.0x) -- Jeffreys intervals DISJOINT + +### p = 0.005 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 5711/150000 3.81e-02 [3.7e-02,3.9e-02] | 4316/150000 2.88e-02 [2.8e-02,3.0e-02] | 2636/150000 1.76e-02 [1.7e-02,1.8e-02] | +| pecos_uf:bp | 5803/150000 3.87e-02 [3.8e-02,4.0e-02] | 5967/150000 3.98e-02 [3.9e-02,4.1e-02] | 4051/150000 2.70e-02 [2.6e-02,2.8e-02] | +| belief_matching | 5730/150000 3.82e-02 [3.7e-02,3.9e-02] | 4316/150000 2.88e-02 [2.8e-02,3.0e-02] | 2636/150000 1.76e-02 [1.7e-02,1.8e-02] | +| pymatching | -- | -- | -- | +| tesseract | -- | -- | -- | + +- d=3: best **fusion_blossom_serial** 3.81e-02 vs pecos_uf:bp 3.87e-02 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 2.88e-02 vs pecos_uf:bp 3.98e-02 (1.4x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 1.76e-02 vs pecos_uf:bp 2.70e-02 (1.5x) -- Jeffreys intervals DISJOINT + +### cx: suppression exponent (LER ~ (p/p_th)^((d+1)/2)) + +- p=0.002 fusion_blossom_serial: suppresses; per-step LER ratio 3.5x, 5.0x +- p=0.002 pecos_uf:bp: suppresses; per-step LER ratio 1.9x, 3.5x +- p=0.002 belief_matching: suppresses; per-step LER ratio 3.5x, 5.0x +- p=0.003 fusion_blossom_serial: suppresses; per-step LER ratio 2.1x, 3.0x +- p=0.003 pecos_uf:bp: suppresses; per-step LER ratio 1.4x, 2.5x +- p=0.003 belief_matching: suppresses; per-step LER ratio 2.1x, 3.0x +- p=0.005 fusion_blossom_serial: suppresses; per-step LER ratio 1.3x, 1.6x +- p=0.005 pecos_uf:bp: NOT monotone; per-step LER ratio 1.0x, 1.5x +- p=0.005 belief_matching: suppresses; per-step LER ratio 1.3x, 1.6x + +## memory: distance suppression + ranking + +### p = 0.002 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| pecos_uf:bp | 425/300000 1.42e-03 [1.3e-03,1.6e-03] | 280/300000 9.33e-04 [8.3e-04,1.0e-03] | 62/300000 2.07e-04 [1.6e-04,2.6e-04] | +| belief_matching | 420/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| pymatching | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | +| tesseract | 421/300000 1.40e-03 [1.3e-03,1.5e-03] | 111/300000 3.70e-04 [3.1e-04,4.4e-04] | 28/300000 9.33e-05 [6.3e-05,1.3e-04] | + +- d=3: best **belief_matching** 1.40e-03 vs pecos_uf:bp 1.42e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 3.70e-04 vs pecos_uf:bp 9.33e-04 (2.5x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 9.33e-05 vs pecos_uf:bp 2.07e-04 (2.2x) -- Jeffreys intervals DISJOINT + +### p = 0.003 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 375/300000 1.25e-03 [1.1e-03,1.4e-03] | 129/300000 4.30e-04 [3.6e-04,5.1e-04] | +| pecos_uf:bp | 944/300000 3.15e-03 [3.0e-03,3.4e-03] | 738/300000 2.46e-03 [2.3e-03,2.6e-03] | 233/300000 7.77e-04 [6.8e-04,8.8e-04] | +| belief_matching | 933/300000 3.11e-03 [2.9e-03,3.3e-03] | 375/300000 1.25e-03 [1.1e-03,1.4e-03] | 129/300000 4.30e-04 [3.6e-04,5.1e-04] | +| pymatching | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 376/300000 1.25e-03 [1.1e-03,1.4e-03] | 130/300000 4.33e-04 [3.6e-04,5.1e-04] | +| tesseract | 934/300000 3.11e-03 [2.9e-03,3.3e-03] | 376/300000 1.25e-03 [1.1e-03,1.4e-03] | 131/300000 4.37e-04 [3.7e-04,5.2e-04] | + +- d=3: best **belief_matching** 3.11e-03 vs pecos_uf:bp 3.15e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 1.25e-03 vs pecos_uf:bp 2.46e-03 (2.0x) -- Jeffreys intervals DISJOINT +- d=7: best **fusion_blossom_serial** 4.30e-04 vs pecos_uf:bp 7.77e-04 (1.8x) -- Jeffreys intervals DISJOINT + +### p = 0.005 + +| inner | d=3 | d=5 | d=7 | +|---|---|---|---| +| fusion_blossom_serial | 2418/300000 8.06e-03 [7.7e-03,8.4e-03] | 1616/300000 5.39e-03 [5.1e-03,5.7e-03] | 830/300000 2.77e-03 [2.6e-03,3.0e-03] | +| pecos_uf:bp | 2481/300000 8.27e-03 [8.0e-03,8.6e-03] | 2625/300000 8.75e-03 [8.4e-03,9.1e-03] | 1519/300000 5.06e-03 [4.8e-03,5.3e-03] | +| belief_matching | 2429/300000 8.10e-03 [7.8e-03,8.4e-03] | 1616/300000 5.39e-03 [5.1e-03,5.7e-03] | 830/300000 2.77e-03 [2.6e-03,3.0e-03] | +| pymatching | 2415/300000 8.05e-03 [7.7e-03,8.4e-03] | 1618/300000 5.39e-03 [5.1e-03,5.7e-03] | 828/300000 2.76e-03 [2.6e-03,3.0e-03] | +| tesseract | 2416/300000 8.05e-03 [7.7e-03,8.4e-03] | 1619/300000 5.40e-03 [5.1e-03,5.7e-03] | 833/300000 2.78e-03 [2.6e-03,3.0e-03] | + +- d=3: best **pymatching** 8.05e-03 vs pecos_uf:bp 8.27e-03 (1.0x) -- Jeffreys intervals overlap +- d=5: best **fusion_blossom_serial** 5.39e-03 vs pecos_uf:bp 8.75e-03 (1.6x) -- Jeffreys intervals DISJOINT +- d=7: best **pymatching** 2.76e-03 vs pecos_uf:bp 5.06e-03 (1.8x) -- Jeffreys intervals DISJOINT + +### memory: suppression exponent (LER ~ (p/p_th)^((d+1)/2)) + +- p=0.002 fusion_blossom_serial: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 pecos_uf:bp: suppresses; per-step LER ratio 1.5x, 4.5x +- p=0.002 belief_matching: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 pymatching: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.002 tesseract: suppresses; per-step LER ratio 3.8x, 4.0x +- p=0.003 fusion_blossom_serial: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 pecos_uf:bp: suppresses; per-step LER ratio 1.3x, 3.2x +- p=0.003 belief_matching: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 pymatching: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.003 tesseract: suppresses; per-step LER ratio 2.5x, 2.9x +- p=0.005 fusion_blossom_serial: suppresses; per-step LER ratio 1.5x, 1.9x +- p=0.005 pecos_uf:bp: NOT monotone; per-step LER ratio 0.9x, 1.7x +- p=0.005 belief_matching: suppresses; per-step LER ratio 1.5x, 1.9x +- p=0.005 pymatching: suppresses; per-step LER ratio 1.5x, 2.0x +- p=0.005 tesseract: suppresses; per-step LER ratio 1.5x, 1.9x + +## memory: threshold crossing + +### fusion_blossom_serial + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.82e-03 | 2.72e-03 | 1.44e-03 | +| 0.005 | 7.40e-03 | 5.16e-03 | 3.16e-03 | +| 0.006 | 1.13e-02 | 8.86e-03 | 5.90e-03 | +| 0.007 | 1.44e-02 | 1.36e-02 | 1.06e-02 | +| 0.008 | 1.87e-02 | 2.05e-02 | 1.68e-02 | +| 0.009 | 2.35e-02 | 2.77e-02 | 2.53e-02 | +| 0.010 | 2.85e-02 | 3.55e-02 | 3.69e-02 | +| 0.012 | 3.95e-02 | 5.50e-02 | 6.57e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.009 + +### pecos_uf:bp + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.88e-03 | 5.30e-03 | 2.52e-03 | +| 0.005 | 7.62e-03 | 9.02e-03 | 5.18e-03 | +| 0.006 | 1.14e-02 | 1.38e-02 | 9.64e-03 | +| 0.007 | 1.47e-02 | 1.96e-02 | 1.62e-02 | +| 0.008 | 1.94e-02 | 2.77e-02 | 2.50e-02 | +| 0.009 | 2.41e-02 | 3.69e-02 | 3.50e-02 | +| 0.010 | 2.92e-02 | 4.65e-02 | 5.00e-02 | +| 0.012 | 4.03e-02 | 6.90e-02 | 8.44e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.007 + +### pymatching + +| p | d=3 | d=5 | d=7 | +|---|---|---|---| +| 0.004 | 4.82e-03 | 2.74e-03 | 1.44e-03 | +| 0.005 | 7.40e-03 | 5.18e-03 | 3.16e-03 | +| 0.006 | 1.12e-02 | 8.86e-03 | 5.92e-03 | +| 0.007 | 1.44e-02 | 1.37e-02 | 1.06e-02 | +| 0.008 | 1.87e-02 | 2.04e-02 | 1.68e-02 | +| 0.009 | 2.36e-02 | 2.78e-02 | 2.53e-02 | +| 0.010 | 2.86e-02 | 3.55e-02 | 3.69e-02 | +| 0.012 | 3.94e-02 | 5.51e-02 | 6.58e-02 | + +- threshold estimate (d=7 stops beating d=3): ~0.009 + +## speed (d=7, p=0.003, n per cell as sampled) + +| family | inner | build ms | decode s | us/shot | +|---|---|---:|---:|---:| +| cx | fusion_blossom_serial | 55.1 | 61.22 | 1224.5 | +| cx | belief_matching | 234.4 | 140.59 | 2811.8 | +| cx | pecos_uf:bp | 228.1 | 356.84 | 7136.8 | +| memory | pymatching | 11.6 | 1.56 | 31.1 | +| memory | fusion_blossom_serial | 11.3 | 9.03 | 180.5 | +| memory | belief_matching | 27.7 | 24.13 | 482.5 | +| memory | tesseract | 16.9 | 44.44 | 888.8 | +| memory | pecos_uf:bp | 26.3 | 49.61 | 992.2 | + diff --git a/examples/surface/results/inner_decoder_study_speed.jsonl b/examples/surface/results/inner_decoder_study_speed.jsonl new file mode 100644 index 000000000..67662bee2 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_speed.jsonl @@ -0,0 +1,8 @@ +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.011340677971020341, "decode_seconds": 9.026471441960894} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 47, "ler": 0.00094, "build_seconds": 0.026346575003117323, "decode_seconds": 49.61077240493614} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.027656523045152426, "decode_seconds": 24.126216560951434} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.011642899014987051, "decode_seconds": 1.5562470420263708} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 50000, "num_errors": 27, "ler": 0.00054, "build_seconds": 0.016912557068280876, "decode_seconds": 44.43782857491169} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.055101476958952844, "decode_seconds": 61.224118691985495} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 217, "ler": 0.00434, "build_seconds": 0.2280546340625733, "decode_seconds": 356.84065975493286} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.2343938680132851, "decode_seconds": 140.59061060997192} diff --git a/examples/surface/results/inner_decoder_study_suppress.jsonl b/examples/surface/results/inner_decoder_study_suppress.jsonl new file mode 100644 index 000000000..a5df4b383 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_suppress.jsonl @@ -0,0 +1,216 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.0006421069847419858, "decode_seconds": 0.4497824680292979} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 149, "ler": 0.00149, "build_seconds": 0.0014974679797887802, "decode_seconds": 0.4129953379742801} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 146, "ler": 0.00146, "build_seconds": 0.0009097249712795019, "decode_seconds": 1.0400518350070342} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.000768975936807692, "decode_seconds": 0.30314706708304584} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 151, "ler": 0.00151, "build_seconds": 0.0011475470382720232, "decode_seconds": 3.57746625400614} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0005435589700937271, "decode_seconds": 0.437479944084771} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 136, "ler": 0.00136, "build_seconds": 0.0008776090107858181, "decode_seconds": 0.395081341965124} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 137, "ler": 0.00137, "build_seconds": 0.0009364159777760506, "decode_seconds": 1.0369742000475526} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0007549140136688948, "decode_seconds": 0.2959932100493461} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0011641409946605563, "decode_seconds": 3.5544276010477915} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0005864349659532309, "decode_seconds": 0.43750640004873276} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 140, "ler": 0.0014, "build_seconds": 0.0008039840031415224, "decode_seconds": 0.3960577129619196} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 137, "ler": 0.00137, "build_seconds": 0.0009138500317931175, "decode_seconds": 1.032312617986463} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0007354009430855513, "decode_seconds": 0.28613320307340473} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 135, "ler": 0.00135, "build_seconds": 0.0010396430734544992, "decode_seconds": 3.470524953911081} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0005425120471045375, "decode_seconds": 0.633861496928148} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 308, "ler": 0.00308, "build_seconds": 0.0007895899470895529, "decode_seconds": 0.5555906310910359} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 298, "ler": 0.00298, "build_seconds": 0.0008792480221018195, "decode_seconds": 1.4799694110406563} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0007408609380945563, "decode_seconds": 0.33822497306391597} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.0010694570373743773, "decode_seconds": 4.372640290064737} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0005225250497460365, "decode_seconds": 0.6251439020270482} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 300, "ler": 0.003, "build_seconds": 0.0007970969891175628, "decode_seconds": 0.5422393509652466} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 303, "ler": 0.00303, "build_seconds": 0.0009121129987761378, "decode_seconds": 1.4369446540949866} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0007219089893624187, "decode_seconds": 0.33977618906646967} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 305, "ler": 0.00305, "build_seconds": 0.0010611469624564052, "decode_seconds": 4.464067224995233} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0005535660311579704, "decode_seconds": 0.6583235840080306} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 336, "ler": 0.00336, "build_seconds": 0.0008649560622870922, "decode_seconds": 0.5674660198856145} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0009156609885394573, "decode_seconds": 1.5248000029241666} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0007595749339088798, "decode_seconds": 0.35373285110108554} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 332, "ler": 0.00332, "build_seconds": 0.0011283719213679433, "decode_seconds": 4.603742911014706} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 839, "ler": 0.00839, "build_seconds": 0.0005783980013802648, "decode_seconds": 1.0560591499088332} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 859, "ler": 0.00859, "build_seconds": 0.0008050329051911831, "decode_seconds": 0.8307753570843488} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 842, "ler": 0.00842, "build_seconds": 0.0008946330053731799, "decode_seconds": 2.2287630209466442} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 838, "ler": 0.00838, "build_seconds": 0.0007100310176610947, "decode_seconds": 0.4149702109862119} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 838, "ler": 0.00838, "build_seconds": 0.0010134490439668298, "decode_seconds": 6.129969451925717} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 819, "ler": 0.00819, "build_seconds": 0.0005436060018837452, "decode_seconds": 1.0267086579697207} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 836, "ler": 0.00836, "build_seconds": 0.0008376280311495066, "decode_seconds": 0.8533820679876953} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 827, "ler": 0.00827, "build_seconds": 0.0009307489963248372, "decode_seconds": 2.2911229039309546} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 818, "ler": 0.00818, "build_seconds": 0.000733512919396162, "decode_seconds": 0.42581556702498347} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 818, "ler": 0.00818, "build_seconds": 0.0010085488902404904, "decode_seconds": 6.200439296080731} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0005461819237098098, "decode_seconds": 1.023707817075774} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 786, "ler": 0.00786, "build_seconds": 0.0008072979981079698, "decode_seconds": 0.8395827020285651} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0008926040027290583, "decode_seconds": 2.319871476967819} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 759, "ler": 0.00759, "build_seconds": 0.0007133319741114974, "decode_seconds": 0.43179583409801126} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 760, "ler": 0.0076, "build_seconds": 0.0010716660181060433, "decode_seconds": 6.314711899962276} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.003559389035217464, "decode_seconds": 3.2722078029764816} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 86, "ler": 0.00086, "build_seconds": 0.0060471040196716785, "decode_seconds": 10.21703472896479} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.006321229040622711, "decode_seconds": 8.985465016914532} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.003913899068720639, "decode_seconds": 0.9927647470030934} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 38, "ler": 0.00038, "build_seconds": 0.005649265018291771, "decode_seconds": 17.831412710016593} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.0034006189089268446, "decode_seconds": 3.2840063450857997} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 88, "ler": 0.00088, "build_seconds": 0.005910877021960914, "decode_seconds": 10.341198201989755} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.006320501095615327, "decode_seconds": 9.032301379018463} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.0038215159438550472, "decode_seconds": 0.9886277749901637} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 32, "ler": 0.00032, "build_seconds": 0.005684125004336238, "decode_seconds": 17.790226757060736} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0034657069481909275, "decode_seconds": 3.2388331450056285} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 106, "ler": 0.00106, "build_seconds": 0.005772155011072755, "decode_seconds": 10.084181787911803} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.006229009013622999, "decode_seconds": 9.035242390003987} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0038418869953602552, "decode_seconds": 0.989809827064164} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.0056845389772206545, "decode_seconds": 17.67726430098992} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.0035957690561190248, "decode_seconds": 5.040996249997988} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 229, "ler": 0.00229, "build_seconds": 0.005812851013615727, "decode_seconds": 14.782907075015828} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.006486502010375261, "decode_seconds": 12.593983304919675} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.003774462966248393, "decode_seconds": 1.2669156150659546} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 112, "ler": 0.00112, "build_seconds": 0.0057538190158084035, "decode_seconds": 26.34577722591348} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 143, "ler": 0.00143, "build_seconds": 0.0033786309650167823, "decode_seconds": 5.02246251096949} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 270, "ler": 0.0027, "build_seconds": 0.005627467064186931, "decode_seconds": 14.253194133983925} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 143, "ler": 0.00143, "build_seconds": 0.00640254991594702, "decode_seconds": 12.500117123010568} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 144, "ler": 0.00144, "build_seconds": 0.0037747110472992063, "decode_seconds": 1.2520127579336986} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 144, "ler": 0.00144, "build_seconds": 0.005499309976585209, "decode_seconds": 25.5488203499699} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.003438101033680141, "decode_seconds": 4.9035661809612066} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 239, "ler": 0.00239, "build_seconds": 0.0058507469948381186, "decode_seconds": 14.442335027037188} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.006360596977174282, "decode_seconds": 12.397279483033344} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.0038107429863885045, "decode_seconds": 1.262787204934284} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 120, "ler": 0.0012, "build_seconds": 0.005702464026398957, "decode_seconds": 25.919863046961837} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.0036036160308867693, "decode_seconds": 9.057322234031744} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 878, "ler": 0.00878, "build_seconds": 0.006103449035435915, "decode_seconds": 21.981844869907945} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.006224778015166521, "decode_seconds": 18.28489999193698} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.0037976870080456138, "decode_seconds": 1.8186134689021856} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 531, "ler": 0.00531, "build_seconds": 0.005775927915237844, "decode_seconds": 42.00563556107227} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 576, "ler": 0.00576, "build_seconds": 0.0034860699670389295, "decode_seconds": 8.885354830999859} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 875, "ler": 0.00875, "build_seconds": 0.006064192042686045, "decode_seconds": 21.99228063202463} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 576, "ler": 0.00576, "build_seconds": 0.006546488031744957, "decode_seconds": 18.443165402975865} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 577, "ler": 0.00577, "build_seconds": 0.003976376028731465, "decode_seconds": 1.8343121459474787} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 578, "ler": 0.00578, "build_seconds": 0.005711263045668602, "decode_seconds": 42.36042152903974} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 509, "ler": 0.00509, "build_seconds": 0.003392826998606324, "decode_seconds": 8.768765309010632} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 872, "ler": 0.00872, "build_seconds": 0.005992374033667147, "decode_seconds": 21.8824789240025} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 509, "ler": 0.00509, "build_seconds": 0.006520363036543131, "decode_seconds": 18.35858091490809} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 510, "ler": 0.0051, "build_seconds": 0.003585992963053286, "decode_seconds": 1.739908825024031} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 510, "ler": 0.0051, "build_seconds": 0.005436684004962444, "decode_seconds": 42.01746710005682} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.01198889291845262, "decode_seconds": 12.00824419897981} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 14, "ler": 0.00014, "build_seconds": 0.0289649119367823, "decode_seconds": 83.20337125204969} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.028456886997446418, "decode_seconds": 41.73888665006962} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.012472887057811022, "decode_seconds": 2.5712421860080212} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 2, "ler": 2e-05, "build_seconds": 0.01786184194497764, "decode_seconds": 61.58867626695428} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.012055136961862445, "decode_seconds": 12.108281126944348} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 24, "ler": 0.00024, "build_seconds": 0.027866789023391902, "decode_seconds": 83.1739500790136} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.028778383042663336, "decode_seconds": 40.38781055691652} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.011742246919311583, "decode_seconds": 2.514463031082414} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 18, "ler": 0.00018, "build_seconds": 0.017679139971733093, "decode_seconds": 61.27467135200277} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.01150249200873077, "decode_seconds": 11.53979802003596} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 24, "ler": 0.00024, "build_seconds": 0.02652843203395605, "decode_seconds": 80.4659172029933} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.027839287999086082, "decode_seconds": 39.69481441995595} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.011705885990522802, "decode_seconds": 2.405015271040611} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 8, "ler": 8e-05, "build_seconds": 0.01666771702002734, "decode_seconds": 59.89127721195109} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.011185246054083109, "decode_seconds": 18.059501508949324} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 73, "ler": 0.00073, "build_seconds": 0.02570761798415333, "decode_seconds": 103.05704250000417} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.027981007006019354, "decode_seconds": 49.33750203391537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 44, "ler": 0.00044, "build_seconds": 0.012218795018270612, "decode_seconds": 3.1121007680194452} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.017130585969425738, "decode_seconds": 88.9494686650578} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.011477769003249705, "decode_seconds": 17.931140725966543} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 82, "ler": 0.00082, "build_seconds": 0.025771027081646025, "decode_seconds": 100.77474934596103} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.02863633690867573, "decode_seconds": 49.05798025405966} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 46, "ler": 0.00046, "build_seconds": 0.011999138980172575, "decode_seconds": 3.068818107014522} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 45, "ler": 0.00045, "build_seconds": 0.016575157991610467, "decode_seconds": 88.56133615795989} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.011320184916257858, "decode_seconds": 18.141521485056728} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 78, "ler": 0.00078, "build_seconds": 0.02584795793518424, "decode_seconds": 101.07802409003489} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.029297174070961773, "decode_seconds": 48.16394220502116} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 40, "ler": 0.0004, "build_seconds": 0.011357437004335225, "decode_seconds": 3.0276183639653027} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 41, "ler": 0.00041, "build_seconds": 0.01649698195978999, "decode_seconds": 88.96721229003742} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.011295111034996808, "decode_seconds": 33.22787524398882} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 477, "ler": 0.00477, "build_seconds": 0.02625328896101564, "decode_seconds": 123.47734216309618} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.026949655963107944, "decode_seconds": 64.25777647399809} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 243, "ler": 0.00243, "build_seconds": 0.011809082003310323, "decode_seconds": 4.572257385938428} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "tesseract", "num_shots": 100000, "num_errors": 246, "ler": 0.00246, "build_seconds": 0.016887197038158774, "decode_seconds": 153.30489724094514} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 290, "ler": 0.0029, "build_seconds": 0.011330893030390143, "decode_seconds": 33.7523007580312} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 527, "ler": 0.00527, "build_seconds": 0.027414581971243024, "decode_seconds": 125.70785540400539} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 100000, "num_errors": 290, "ler": 0.0029, "build_seconds": 0.028182174894027412, "decode_seconds": 65.73475300311111} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 288, "ler": 0.00288, "build_seconds": 0.012029790901578963, "decode_seconds": 4.614306694013067} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "tesseract", "num_shots": 100000, "num_errors": 288, "ler": 0.00288, "build_seconds": 0.018439115956425667, "decode_seconds": 153.3892400059849} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.011190642951987684, "decode_seconds": 33.50160719000269} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 515, "ler": 0.00515, "build_seconds": 0.026636217022314668, "decode_seconds": 123.7905317270197} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.02799793507438153, "decode_seconds": 65.43267240794376} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 297, "ler": 0.00297, "build_seconds": 0.012005220050923526, "decode_seconds": 4.573971158941276} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "tesseract", "num_shots": 100000, "num_errors": 299, "ler": 0.00299, "build_seconds": 0.017136757029220462, "decode_seconds": 152.66755335300695} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.0027666680980473757, "decode_seconds": 1.2426382739795372} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 347, "ler": 0.00694, "build_seconds": 0.003978513996116817, "decode_seconds": 1.9058792720315978} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 341, "ler": 0.00682, "build_seconds": 0.0043051280081272125, "decode_seconds": 4.2842509569600224} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 361, "ler": 0.00722, "build_seconds": 0.0029481599340215325, "decode_seconds": 1.277993755065836} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 373, "ler": 0.00746, "build_seconds": 0.004023088025860488, "decode_seconds": 1.8912213450530544} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 362, "ler": 0.00724, "build_seconds": 0.0045119550777599216, "decode_seconds": 4.184288542019203} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.0025959149934351444, "decode_seconds": 1.2000236450694501} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 349, "ler": 0.00698, "build_seconds": 0.003879523021169007, "decode_seconds": 1.831096573965624} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 339, "ler": 0.00678, "build_seconds": 0.004237468005158007, "decode_seconds": 4.154158256016672} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 695, "ler": 0.0139, "build_seconds": 0.0026430010329931974, "decode_seconds": 1.7629588880809024} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 723, "ler": 0.01446, "build_seconds": 0.003869635984301567, "decode_seconds": 2.6145379460649565} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 694, "ler": 0.01388, "build_seconds": 0.004219696973450482, "decode_seconds": 5.9179590420098975} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 736, "ler": 0.01472, "build_seconds": 0.002717138035222888, "decode_seconds": 1.831788704963401} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 760, "ler": 0.0152, "build_seconds": 0.004061102983541787, "decode_seconds": 2.6863492289558053} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 739, "ler": 0.01478, "build_seconds": 0.004333141027018428, "decode_seconds": 6.091107173007913} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 727, "ler": 0.01454, "build_seconds": 0.0027309979777783155, "decode_seconds": 1.8216422849800438} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 734, "ler": 0.01468, "build_seconds": 0.003968781093135476, "decode_seconds": 2.7072006149683148} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 726, "ler": 0.01452, "build_seconds": 0.004328525043092668, "decode_seconds": 5.964680672972463} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1900, "ler": 0.038, "build_seconds": 0.0027084420435130596, "decode_seconds": 3.0450374559732154} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1933, "ler": 0.03866, "build_seconds": 0.004028124967589974, "decode_seconds": 4.224082892993465} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1922, "ler": 0.03844, "build_seconds": 0.004390742047689855, "decode_seconds": 8.884535679011606} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1924, "ler": 0.03848, "build_seconds": 0.0029172690119594336, "decode_seconds": 3.0538512399652973} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1959, "ler": 0.03918, "build_seconds": 0.004021001048386097, "decode_seconds": 4.20720031298697} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1936, "ler": 0.03872, "build_seconds": 0.0043352419743314385, "decode_seconds": 8.902371066971682} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1887, "ler": 0.03774, "build_seconds": 0.002712189918383956, "decode_seconds": 3.042711588088423} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1911, "ler": 0.03822, "build_seconds": 0.0040241440292447805, "decode_seconds": 4.225115368957631} +{"family": "cx", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1872, "ler": 0.03744, "build_seconds": 0.004651825060136616, "decode_seconds": 8.91394612903241} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 101, "ler": 0.00202, "build_seconds": 0.017809053068049252, "decode_seconds": 10.11937194596976} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 202, "ler": 0.00404, "build_seconds": 0.040036563063040376, "decode_seconds": 56.461156163946725} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 101, "ler": 0.00202, "build_seconds": 0.041228037094697356, "decode_seconds": 31.25634205795359} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 105, "ler": 0.0021, "build_seconds": 0.017188239027746022, "decode_seconds": 9.704132576938719} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 188, "ler": 0.00376, "build_seconds": 0.0385180430021137, "decode_seconds": 55.710626682965085} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 105, "ler": 0.0021, "build_seconds": 0.042297302978113294, "decode_seconds": 31.37941742199473} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 88, "ler": 0.00176, "build_seconds": 0.01705125393345952, "decode_seconds": 9.60310909000691} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 166, "ler": 0.00332, "build_seconds": 0.039060045033693314, "decode_seconds": 56.601782400975935} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 88, "ler": 0.00176, "build_seconds": 0.04300876404158771, "decode_seconds": 32.065633705002256} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 342, "ler": 0.00684, "build_seconds": 0.017805316019803286, "decode_seconds": 15.855886935023591} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 543, "ler": 0.01086, "build_seconds": 0.04020364605821669, "decode_seconds": 75.02097634994425} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 342, "ler": 0.00684, "build_seconds": 0.04101130703929812, "decode_seconds": 39.980677679996006} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 308, "ler": 0.00616, "build_seconds": 0.019196123001165688, "decode_seconds": 15.783406346919946} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 530, "ler": 0.0106, "build_seconds": 0.03991413407493383, "decode_seconds": 74.85742094798479} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 308, "ler": 0.00616, "build_seconds": 0.04262624797411263, "decode_seconds": 40.36505612905603} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 360, "ler": 0.0072, "build_seconds": 0.017643426079303026, "decode_seconds": 15.781059438944794} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 568, "ler": 0.01136, "build_seconds": 0.040128167951479554, "decode_seconds": 72.37126195908058} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 360, "ler": 0.0072, "build_seconds": 0.042749025975354016, "decode_seconds": 40.39476935600396} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1419, "ler": 0.02838, "build_seconds": 0.017737832968123257, "decode_seconds": 28.877137659001164} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1997, "ler": 0.03994, "build_seconds": 0.038523137918673456, "decode_seconds": 92.57560077100061} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1419, "ler": 0.02838, "build_seconds": 0.04114609200041741, "decode_seconds": 54.23134226095863} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1495, "ler": 0.0299, "build_seconds": 0.018085039919242263, "decode_seconds": 29.113411615020595} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2000, "ler": 0.04, "build_seconds": 0.04000170307699591, "decode_seconds": 95.11584063793998} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1495, "ler": 0.0299, "build_seconds": 0.04291122790891677, "decode_seconds": 54.54225459403824} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1402, "ler": 0.02804, "build_seconds": 0.017674515023827553, "decode_seconds": 28.85841549898032} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1970, "ler": 0.0394, "build_seconds": 0.040045556030236185, "decode_seconds": 94.39712701388635} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 1402, "ler": 0.02804, "build_seconds": 0.04084432800300419, "decode_seconds": 54.54200694710016} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 17, "ler": 0.00034, "build_seconds": 0.05726063495967537, "decode_seconds": 37.58960096305236} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 51, "ler": 0.00102, "build_seconds": 0.2170823459746316, "decode_seconds": 316.8492327040294} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 17, "ler": 0.00034, "build_seconds": 0.23379869293421507, "decode_seconds": 115.71196917700581} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.05516764707863331, "decode_seconds": 37.62585578695871} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 58, "ler": 0.00116, "build_seconds": 0.2244842719519511, "decode_seconds": 319.665760110016} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.23318979307077825, "decode_seconds": 116.37066508294083} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.056982876965776086, "decode_seconds": 37.65939962002449} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 50, "ler": 0.001, "build_seconds": 0.22406468004919589, "decode_seconds": 317.6037418489577} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.002, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 21, "ler": 0.00042, "build_seconds": 0.2336903209798038, "decode_seconds": 114.94639900908805} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.0555530849378556, "decode_seconds": 60.70033219899051} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 217, "ler": 0.00434, "build_seconds": 0.22634913795627654, "decode_seconds": 360.4764091570396} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 109, "ler": 0.00218, "build_seconds": 0.23468924802727997, "decode_seconds": 139.93496759200934} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 122, "ler": 0.00244, "build_seconds": 0.06277166202198714, "decode_seconds": 60.998767197015695} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 215, "ler": 0.0043, "build_seconds": 0.22652721300255507, "decode_seconds": 359.99144811194856} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 122, "ler": 0.00244, "build_seconds": 0.2339047659188509, "decode_seconds": 139.8659792370163} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 102, "ler": 0.00204, "build_seconds": 0.05706561403349042, "decode_seconds": 61.76871059194673} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 230, "ler": 0.0046, "build_seconds": 0.22566778305917978, "decode_seconds": 358.26359176193364} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.003, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 102, "ler": 0.00204, "build_seconds": 0.23369164892937988, "decode_seconds": 140.8752816610504} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 860, "ler": 0.0172, "build_seconds": 0.05626340501476079, "decode_seconds": 116.39792538096663} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1320, "ler": 0.0264, "build_seconds": 0.22628114593680948, "decode_seconds": 381.43563097610604} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "belief_matching", "num_shots": 50000, "num_errors": 860, "ler": 0.0172, "build_seconds": 0.2335918080061674, "decode_seconds": 195.28046238503885} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 878, "ler": 0.01756, "build_seconds": 0.055321692023426294, "decode_seconds": 116.20386328105815} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1370, "ler": 0.0274, "build_seconds": 0.222290173987858, "decode_seconds": 382.33848310704343} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 2, "inner": "belief_matching", "num_shots": 50000, "num_errors": 878, "ler": 0.01756, "build_seconds": 0.2357181420084089, "decode_seconds": 196.46588010096457} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 898, "ler": 0.01796, "build_seconds": 0.058892372995615005, "decode_seconds": 117.09821905102581} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1361, "ler": 0.02722, "build_seconds": 0.226338843931444, "decode_seconds": 382.2485167060513} +{"family": "cx", "distance": 7, "rounds": 7, "p": 0.005, "seed": 3, "inner": "belief_matching", "num_shots": 50000, "num_errors": 898, "ler": 0.01796, "build_seconds": 0.23742589203175157, "decode_seconds": 197.39164293906651} diff --git a/examples/surface/results/inner_decoder_study_threshold.jsonl b/examples/surface/results/inner_decoder_study_threshold.jsonl new file mode 100644 index 000000000..29010b06b --- /dev/null +++ b/examples/surface/results/inner_decoder_study_threshold.jsonl @@ -0,0 +1,72 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 241, "ler": 0.00482, "build_seconds": 0.000553303980268538, "decode_seconds": 0.41356655303388834} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 244, "ler": 0.00488, "build_seconds": 0.0007363270269706845, "decode_seconds": 0.35552097705658525} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 241, "ler": 0.00482, "build_seconds": 0.0006616739556193352, "decode_seconds": 0.19352870399598032} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 370, "ler": 0.0074, "build_seconds": 0.0005259430035948753, "decode_seconds": 0.5087154989596456} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 381, "ler": 0.00762, "build_seconds": 0.0007031150162220001, "decode_seconds": 0.42045116203371435} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 370, "ler": 0.0074, "build_seconds": 0.0005987919867038727, "decode_seconds": 0.21348594094160944} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 566, "ler": 0.01132, "build_seconds": 0.0005060170078650117, "decode_seconds": 0.6070600069360808} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 571, "ler": 0.01142, "build_seconds": 0.0008120449492707849, "decode_seconds": 0.4770000349963084} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 561, "ler": 0.01122, "build_seconds": 0.0006387030007317662, "decode_seconds": 0.2350762509740889} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 720, "ler": 0.0144, "build_seconds": 0.0005051549524068832, "decode_seconds": 0.7224671969888732} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 736, "ler": 0.01472, "build_seconds": 0.0007587990257889032, "decode_seconds": 0.5536875569960102} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 720, "ler": 0.0144, "build_seconds": 0.0006870540091767907, "decode_seconds": 0.2616626820527017} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 935, "ler": 0.0187, "build_seconds": 0.0005344880046322942, "decode_seconds": 0.8156614169711247} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 972, "ler": 0.01944, "build_seconds": 0.0007926550460979342, "decode_seconds": 0.6286538590211421} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 936, "ler": 0.01872, "build_seconds": 0.0006048210198059678, "decode_seconds": 0.282362152938731} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1177, "ler": 0.02354, "build_seconds": 0.0005155650433152914, "decode_seconds": 0.9224442170234397} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1203, "ler": 0.02406, "build_seconds": 0.0007628179155290127, "decode_seconds": 0.681600435054861} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1181, "ler": 0.02362, "build_seconds": 0.0007024700753390789, "decode_seconds": 0.30697357503231615} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1427, "ler": 0.02854, "build_seconds": 0.0005175839178264141, "decode_seconds": 1.0248573770513758} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1458, "ler": 0.02916, "build_seconds": 0.0008263279451057315, "decode_seconds": 0.7313043309841305} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1432, "ler": 0.02864, "build_seconds": 0.0006042990135028958, "decode_seconds": 0.33015464805066586} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1973, "ler": 0.03946, "build_seconds": 0.0005137789994478226, "decode_seconds": 1.2339211829239503} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2017, "ler": 0.04034, "build_seconds": 0.0007613759953528643, "decode_seconds": 0.8449526780750602} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1969, "ler": 0.03938, "build_seconds": 0.0006781750125810504, "decode_seconds": 0.38146582897752523} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 136, "ler": 0.00272, "build_seconds": 0.003294265945442021, "decode_seconds": 3.3930116021074355} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 265, "ler": 0.0053, "build_seconds": 0.005730609060265124, "decode_seconds": 9.166729047894478} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 137, "ler": 0.00274, "build_seconds": 0.0036312530282884836, "decode_seconds": 0.7465279749594629} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 258, "ler": 0.00516, "build_seconds": 0.0033381300745531917, "decode_seconds": 4.302522073034197} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 451, "ler": 0.00902, "build_seconds": 0.005688800010830164, "decode_seconds": 10.679329155012965} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 259, "ler": 0.00518, "build_seconds": 0.0035879879724234343, "decode_seconds": 0.8774489300558344} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 443, "ler": 0.00886, "build_seconds": 0.003356348956003785, "decode_seconds": 5.301479918998666} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 689, "ler": 0.01378, "build_seconds": 0.005740055930800736, "decode_seconds": 12.126563983038068} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 443, "ler": 0.00886, "build_seconds": 0.0035532600013539195, "decode_seconds": 1.0043239099904895} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 682, "ler": 0.01364, "build_seconds": 0.0032987690065056086, "decode_seconds": 6.343367288005538} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 978, "ler": 0.01956, "build_seconds": 0.005682545015588403, "decode_seconds": 13.27012179105077} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 684, "ler": 0.01368, "build_seconds": 0.003600129042752087, "decode_seconds": 1.148259557900019} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1023, "ler": 0.02046, "build_seconds": 0.0033241230994462967, "decode_seconds": 7.480666225892492} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1384, "ler": 0.02768, "build_seconds": 0.005689051002264023, "decode_seconds": 14.328783028991893} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1021, "ler": 0.02042, "build_seconds": 0.0036275250604376197, "decode_seconds": 1.2811408209381625} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1385, "ler": 0.0277, "build_seconds": 0.00330440909601748, "decode_seconds": 8.675665647955611} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1845, "ler": 0.0369, "build_seconds": 0.005962638999335468, "decode_seconds": 15.303825345006771} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1391, "ler": 0.02782, "build_seconds": 0.003699503024108708, "decode_seconds": 1.4179591269930825} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1776, "ler": 0.03552, "build_seconds": 0.0033197120064869523, "decode_seconds": 9.890155210043304} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2327, "ler": 0.04654, "build_seconds": 0.005759961088187993, "decode_seconds": 15.909984825993888} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1773, "ler": 0.03546, "build_seconds": 0.0034103929065167904, "decode_seconds": 1.5061595120932907} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 2749, "ler": 0.05498, "build_seconds": 0.0032265339978039265, "decode_seconds": 12.273728820029646} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 3452, "ler": 0.06904, "build_seconds": 0.005713721038773656, "decode_seconds": 17.117338757030666} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 2753, "ler": 0.05506, "build_seconds": 0.0035725990310311317, "decode_seconds": 1.8775908190291375} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 72, "ler": 0.00144, "build_seconds": 0.01094578206539154, "decode_seconds": 12.749464866006747} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 126, "ler": 0.00252, "build_seconds": 0.026317083975300193, "decode_seconds": 58.79540476400871} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 72, "ler": 0.00144, "build_seconds": 0.011541299987584352, "decode_seconds": 1.924756177002564} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 158, "ler": 0.00316, "build_seconds": 0.011019375058822334, "decode_seconds": 16.652649068040773} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 259, "ler": 0.00518, "build_seconds": 0.026470276061445475, "decode_seconds": 63.40331910492387} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 158, "ler": 0.00316, "build_seconds": 0.011802835972048342, "decode_seconds": 2.3041705150390044} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 295, "ler": 0.0059, "build_seconds": 0.011194723076187074, "decode_seconds": 20.679654374951497} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 482, "ler": 0.00964, "build_seconds": 0.0258271771017462, "decode_seconds": 65.64840204094071} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 296, "ler": 0.00592, "build_seconds": 0.011505651054903865, "decode_seconds": 2.7034300840459764} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 532, "ler": 0.01064, "build_seconds": 0.011078166076913476, "decode_seconds": 25.844283557962626} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 812, "ler": 0.01624, "build_seconds": 0.025456094066612422, "decode_seconds": 68.38099301594775} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.007, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 532, "ler": 0.01064, "build_seconds": 0.01105475495569408, "decode_seconds": 2.9668737499741837} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 841, "ler": 0.01682, "build_seconds": 0.010775579023174942, "decode_seconds": 31.2043084789766} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1248, "ler": 0.02496, "build_seconds": 0.02808927302248776, "decode_seconds": 69.41384809394367} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 840, "ler": 0.0168, "build_seconds": 0.011174535960890353, "decode_seconds": 3.4064176470274106} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1267, "ler": 0.02534, "build_seconds": 0.010881343041546643, "decode_seconds": 36.750298622995615} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 1748, "ler": 0.03496, "build_seconds": 0.02647972700651735, "decode_seconds": 72.80723491904791} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.009, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1267, "ler": 0.02534, "build_seconds": 0.011633733054623008, "decode_seconds": 3.9785961559973657} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 1846, "ler": 0.03692, "build_seconds": 0.011132820975035429, "decode_seconds": 42.67777827405371} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 2501, "ler": 0.05002, "build_seconds": 0.026440824032761157, "decode_seconds": 72.88538435997907} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.01, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 1846, "ler": 0.03692, "build_seconds": 0.012233932968229055, "decode_seconds": 4.604135178029537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 50000, "num_errors": 3286, "ler": 0.06572, "build_seconds": 0.011454413994215429, "decode_seconds": 55.201751724933274} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 50000, "num_errors": 4218, "ler": 0.08436, "build_seconds": 0.02668835991062224, "decode_seconds": 74.66283174906857} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.012, "seed": 1, "inner": "pymatching", "num_shots": 50000, "num_errors": 3288, "ler": 0.06576, "build_seconds": 0.012077316991053522, "decode_seconds": 5.50001355400309} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index e4ef8ef52..caa04793f 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4476,23 +4476,28 @@ pub struct PyLogicalSubgraphDecoder { #[pymethods] impl PyLogicalSubgraphDecoder { - // Default inner is `fusion_blossom_serial`: a POLICY choice of accuracy-first - // exact MWPM, bundled by default. It is exact minimum-weight matching on each - // per-observable subgraph, so it cannot be beaten on that projected graph, and - // it ships with PECOS (no optional dependency to install). + // Default inner is `fusion_blossom_serial`: exact MWPM on each per-observable + // subgraph, bundled (no optional dependency). This is now backed by a powered + // threshold/CI study (`examples/surface/inner_decoder_study.py`; memory + + // transversal-CX, d=3/5/7, 3 seeds pooled = 150-300k shots/cell, Jeffreys + // intervals), NOT just policy: + // * Accuracy: fusion is statistically tied with pymatching/belief_matching/ + // tesseract (these per-observable DEMs are graphlike, so exact MWPM is + // optimal) and STRICTLY beats `pecos_uf:bp` at every d>=5 cell -- 1.4-2.7x + // lower LER with DISJOINT Jeffreys intervals, both families. Tied at d=3. + // * Threshold: fusion ~0.9% vs `pecos_uf:bp` ~0.7% (bp also breaks down sooner). + // * Speed: at d=7 bp's grow+peel blows up (CX 7.1ms/shot vs fusion 1.2ms); + // "bp is the fast native one" is false at depth. + // Only `pymatching` is faster (~6x) but it is an EXTERNAL dep with zero accuracy + // or threshold gain, so it is the documented speed option, not the default. + // SCOPE: the study families are graphlike, so it does not distinguish fusion + // from hyperedge decoders (tesseract/mwpf) -- re-run with those if non-graphlike + // per-observable DEMs (biased/correlated noise) ever arise. See + // pecos-docs/design/inner-decoder-threshold-study.md. // - // A spot benchmark (memory + transversal-CX, d=3..7, p=0.001, n=30k, SINGLE - // SEED) is consistent with this: fusion is at least as accurate as the native - // `pecos_uf:bp` everywhere and markedly faster at depth (e.g. d=7 transversal-CX - // ~1.9s vs ~12.5s). But that table is UNDER-POWERED to prove a long-term - // default -- single seed, single p, point LERs with overlapping confidence - // intervals. Treat it as supporting evidence, not proof; a proper threshold/CI - // sweep is a tracked benchmark TODO. The default rests on the policy above. - // - // `pecos_uf:bp` remains the right pick for the pure-native, dependency-free - // path (it does achieve distance suppression -- the predecoder bug that broke - // it at d>=5 is fixed). `belief_matching` matched fusion's accuracy in the spot - // benchmark but was slower. See + // `pecos_uf:bp` remains the pure-native, dependency-free path (it does suppress + // with distance -- the predecoder bug that broke it at d>=5 is fixed -- just at + // a worse prefactor and lower threshold). See // pecos-docs/design/lomatching-paper-additional-learnings.md and // logical-subgraph-backprop-region-builder.md. #[new] From 95bae61566b4e5a4c46eb417bc3db32178946cd9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 7 Jun 2026 23:14:24 -0600 Subject: [PATCH 071/388] Fix Windows LLVM path test --- crates/pecos-cli/src/cli/env_cmd.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index 11b232a8c..e883d5a7c 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -319,7 +319,7 @@ mod tests { let llvm_prefix = Path::new("/opt/pecos/llvm-21.1"); let llvm_prefix_str = llvm_prefix.display().to_string(); - let llvm_bin_str = llvm_prefix.join("bin").display().to_string(); + let llvm_bin_str = pecos_build::llvm::path_to_env_string(&llvm_prefix.join("bin")); let mut env = BTreeMap::new(); env.insert(LLVM_SYS_PREFIX_ENV.to_string(), llvm_prefix_str.clone()); From 4c5a546d7e2ec682d8a15812ca12df56339fcd88 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 8 Jun 2026 07:25:35 -0600 Subject: [PATCH 072/388] Add hyperedge-regime phase closing the threshold study's graphlike caveat: hyperedge-aware inners tie fusion shot-for-shot --- examples/surface/inner_decoder_study.py | 33 ++++++++++++++- .../inner_decoder_study_hyperedge.jsonl | 40 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 examples/surface/results/inner_decoder_study_hyperedge.jsonl diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py index c3baaaf2a..bbe281f00 100644 --- a/examples/surface/inner_decoder_study.py +++ b/examples/surface/inner_decoder_study.py @@ -242,6 +242,35 @@ def run_threshold(path: Path) -> None: ) +def run_hyperedge(path: Path) -> None: + """Closes the graphlike-scope caveat: PECOS full DEMs are genuinely + non-graphlike (weight-8 hyperedges, ~70% of CX errors weight>=3), so test + whether hyperedge-aware inners beat plain MWPM on the per-observable-subgraph + path NEAR threshold (where the 2026-04-24 audit saw a 23% hyperedge effect on + the full memory DEM). If they merely tie, the LogicalSubgraphDecoder default + is optimal even in the hyperedge regime, not just on graphlike DEMs.""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pymatching", "tesseract", "belief_matching", "belief_matching_correlated"] + plan = [ + ("memory", 5, [0.006, 0.008], [1, 2], 30_000), + ("memory", 7, [0.008], [1, 2], 20_000), + ("cx", 5, [0.004], [1, 2], 30_000), + ] + for family, d, ps, seeds, n in plan: + for p in ps: + for seed in seeds: + todo = [i for i in inners if (family, d, p, seed, i) not in done] + if not todo: + continue + cells = measure_cell(family, d, d, p, seed, todo, n) + _append(path, cells) + print( + f"[hyperedge] {family:6s} d={d} p={p:.3f} seed={seed}: " + + " ".join(f"{c.inner.split('_')[0][:6]}={c.num_errors}" for c in cells), + flush=True, + ) + + def run_speed(path: Path) -> None: """Per-shot decode throughput (build vs decode) at the costly d=7 point.""" done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} @@ -415,7 +444,7 @@ def w(s: str = "") -> None: def main() -> None: ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--phase", required=True, choices=["suppress", "threshold", "speed", "analyze", "smoke"]) + ap.add_argument("--phase", required=True, choices=["suppress", "threshold", "hyperedge", "speed", "analyze", "smoke"]) ap.add_argument("--out", type=Path, default=RESULTS_DIR) args = ap.parse_args() @@ -437,7 +466,7 @@ def main() -> None: return path = args.out / f"inner_decoder_study_{args.phase}.jsonl" - {"suppress": run_suppress, "threshold": run_threshold, "speed": run_speed}[args.phase](path) + {"suppress": run_suppress, "threshold": run_threshold, "hyperedge": run_hyperedge, "speed": run_speed}[args.phase](path) print(f"[done] {args.phase} -> {path}", flush=True) diff --git a/examples/surface/results/inner_decoder_study_hyperedge.jsonl b/examples/surface/results/inner_decoder_study_hyperedge.jsonl new file mode 100644 index 000000000..d8b94fe04 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_hyperedge.jsonl @@ -0,0 +1,40 @@ +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.003566185012459755, "decode_seconds": 3.37455288390629} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0038856130558997393, "decode_seconds": 0.6466185129247606} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.005911809974350035, "decode_seconds": 15.393886688980274} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0071361170848831534, "decode_seconds": 6.31418666895479} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 271, "ler": 0.009033333333333334, "build_seconds": 0.0071890190010890365, "decode_seconds": 6.367211817996576} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.0032669759821146727, "decode_seconds": 3.3683419070439413} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.0037438629660755396, "decode_seconds": 0.6292870710603893} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.005560801015235484, "decode_seconds": 14.951133309979923} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.006299200002104044, "decode_seconds": 6.250429176958278} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.006, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 260, "ler": 0.008666666666666666, "build_seconds": 0.00739661802072078, "decode_seconds": 6.481872584903613} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.0032418479677289724, "decode_seconds": 4.569911486003548} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.003733533900231123, "decode_seconds": 0.7791196580510587} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.00560479296837002, "decode_seconds": 19.93792283802759} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.0068061730125918984, "decode_seconds": 7.923268544021994} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 564, "ler": 0.0188, "build_seconds": 0.007016985095106065, "decode_seconds": 7.727108254912309} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.0034840760054066777, "decode_seconds": 4.617438982008025} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 588, "ler": 0.0196, "build_seconds": 0.003798080957494676, "decode_seconds": 0.7727671850007027} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 589, "ler": 0.019633333333333332, "build_seconds": 0.005618234979920089, "decode_seconds": 20.43235256697517} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.006501637981273234, "decode_seconds": 8.125532574020326} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.008, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 587, "ler": 0.019566666666666666, "build_seconds": 0.007528997026383877, "decode_seconds": 8.493251777952537} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.012017961009405553, "decode_seconds": 13.148979397956282} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "pymatching", "num_shots": 20000, "num_errors": 374, "ler": 0.0187, "build_seconds": 0.01236077700741589, "decode_seconds": 1.443247208953835} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "tesseract", "num_shots": 20000, "num_errors": 377, "ler": 0.01885, "build_seconds": 0.017561979009769857, "decode_seconds": 58.6753382619936} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "belief_matching", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.030035778996534646, "decode_seconds": 20.313708811998367} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 20000, "num_errors": 371, "ler": 0.01855, "build_seconds": 0.03070830798242241, "decode_seconds": 20.43584023998119} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.011728838086128235, "decode_seconds": 12.78964776895009} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "pymatching", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.012101199012249708, "decode_seconds": 1.4187101080315188} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "tesseract", "num_shots": 20000, "num_errors": 335, "ler": 0.01675, "build_seconds": 0.01718469092156738, "decode_seconds": 55.972942068008706} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "belief_matching", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.029899114975705743, "decode_seconds": 20.612918598926626} +{"family": "memory", "distance": 7, "rounds": 7, "p": 0.008, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 20000, "num_errors": 333, "ler": 0.01665, "build_seconds": 0.03118286095559597, "decode_seconds": 20.239125563995913} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.019938747980631888, "decode_seconds": 14.27238380908966} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "pymatching", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.01992121199145913, "decode_seconds": 2.0521467130165547} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "tesseract", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.030081987963058054, "decode_seconds": 58.16961134807207} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "belief_matching", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.047197493026033044, "decode_seconds": 30.490634263958782} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 1, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 458, "ler": 0.015266666666666666, "build_seconds": 0.04685803898610175, "decode_seconds": 30.193152333027683} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.018612609012052417, "decode_seconds": 13.599516084999777} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "pymatching", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.01944837300106883, "decode_seconds": 1.9902514149434865} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "tesseract", "num_shots": 30000, "num_errors": 439, "ler": 0.014633333333333333, "build_seconds": 0.029277449008077383, "decode_seconds": 58.933918961905874} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "belief_matching", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.04867564095184207, "decode_seconds": 30.084009875077754} +{"family": "cx", "distance": 5, "rounds": 5, "p": 0.004, "seed": 2, "inner": "belief_matching_correlated", "num_shots": 30000, "num_errors": 438, "ler": 0.0146, "build_seconds": 0.04650826100260019, "decode_seconds": 30.7477054920746} From 978b5b2d8a2490b1a36549ea2eaf73c88de4d0d5 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 8 Jun 2026 09:29:47 -0600 Subject: [PATCH 073/388] Fix Windows Python LLVM DLL loading --- .../pecos_rslib_llvm/__init__.py | 56 +++++++++++++++++++ python/pecos-rslib/pecos_rslib/__init__.py | 56 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py create mode 100644 python/pecos-rslib/pecos_rslib/__init__.py diff --git a/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py b/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py new file mode 100644 index 000000000..ca325a77e --- /dev/null +++ b/python/pecos-rslib-llvm/pecos_rslib_llvm/__init__.py @@ -0,0 +1,56 @@ +"""Python package wrapper for the native ``pecos_rslib_llvm`` extension.""" + +from __future__ import annotations + +import os +from pathlib import Path + +_DLL_DIRECTORY_HANDLES = [] + + +def _add_dll_directory(path: Path) -> None: + if os.name != "nt" or not hasattr(os, "add_dll_directory") or not path.is_dir(): + return + + try: + _DLL_DIRECTORY_HANDLES.append(os.add_dll_directory(str(path))) + except OSError: + pass + + +def _add_windows_llvm_dll_directories() -> None: + if os.name != "nt": + return + + seen: set[str] = set() + candidates: list[Path] = [] + + for env_name in ("PECOS_LLVM", "LLVM_SYS_211_PREFIX"): + if raw_path := os.environ.get(env_name): + prefix = Path(raw_path) + candidates.extend((prefix / "bin", prefix)) + + home = Path.home() + candidates.extend( + ( + home / ".pecos" / "deps" / "llvm-21.1" / "Library" / "bin", + home / ".pecos" / "deps" / "llvm-21.1" / "bin", + ) + ) + + for candidate in candidates: + key = os.path.normcase(os.path.normpath(str(candidate))) + if key in seen: + continue + seen.add(key) + _add_dll_directory(candidate) + + +_add_windows_llvm_dll_directories() + +from . import pecos_rslib_llvm as _native # noqa: E402 +from .pecos_rslib_llvm import * # noqa: E402,F403 + +__doc__ = _native.__doc__ +if hasattr(_native, "__all__"): + __all__ = _native.__all__ diff --git a/python/pecos-rslib/pecos_rslib/__init__.py b/python/pecos-rslib/pecos_rslib/__init__.py new file mode 100644 index 000000000..9095b6cab --- /dev/null +++ b/python/pecos-rslib/pecos_rslib/__init__.py @@ -0,0 +1,56 @@ +"""Python package wrapper for the native ``pecos_rslib`` extension.""" + +from __future__ import annotations + +import os +from pathlib import Path + +_DLL_DIRECTORY_HANDLES = [] + + +def _add_dll_directory(path: Path) -> None: + if os.name != "nt" or not hasattr(os, "add_dll_directory") or not path.is_dir(): + return + + try: + _DLL_DIRECTORY_HANDLES.append(os.add_dll_directory(str(path))) + except OSError: + pass + + +def _add_windows_llvm_dll_directories() -> None: + if os.name != "nt": + return + + seen: set[str] = set() + candidates: list[Path] = [] + + for env_name in ("PECOS_LLVM", "LLVM_SYS_211_PREFIX"): + if raw_path := os.environ.get(env_name): + prefix = Path(raw_path) + candidates.extend((prefix / "bin", prefix)) + + home = Path.home() + candidates.extend( + ( + home / ".pecos" / "deps" / "llvm-21.1" / "Library" / "bin", + home / ".pecos" / "deps" / "llvm-21.1" / "bin", + ) + ) + + for candidate in candidates: + key = os.path.normcase(os.path.normpath(str(candidate))) + if key in seen: + continue + seen.add(key) + _add_dll_directory(candidate) + + +_add_windows_llvm_dll_directories() + +from . import pecos_rslib as _native # noqa: E402 +from .pecos_rslib import * # noqa: E402,F403 + +__doc__ = _native.__doc__ +if hasattr(_native, "__all__"): + __all__ = _native.__all__ From ccf9a334500aa32c43af1f8a8b816a4a9b0029e3 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 8 Jun 2026 12:14:19 -0600 Subject: [PATCH 074/388] Fix Windows lint dead code --- crates/pecos-cli/src/cli/env_cmd.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs index e883d5a7c..ef71100d1 100644 --- a/crates/pecos-cli/src/cli/env_cmd.rs +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -24,13 +24,15 @@ //! pecos env --github-actions # write GitHub Actions env/path files use std::collections::BTreeMap; -use std::ffi::OsString; use std::fmt::Write; use std::fs; use std::fs::OpenOptions; use std::io::Write as IoWrite; use std::path::{Path, PathBuf}; +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] +use std::ffi::OsString; + use pecos_build::Result; use pecos_build::errors::Error; use pecos_build::llvm::LLVM_SYS_PREFIX_ENV; @@ -155,6 +157,7 @@ fn add_llvm_runtime_library_path(_env: &mut BTreeMap, _libdir: & // Windows LLVM DLLs are expected in bin/, which is already prepended to PATH. } +#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "macos"))] fn prepend_path_env(env: &mut BTreeMap, key: &str, first: &Path) { let current = env .get(key) From 39d44be621ac896b7cf0ede3dee8590ec3308a70 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 8 Jun 2026 12:23:39 -0600 Subject: [PATCH 075/388] Harden Windows LLVM downloads --- scripts/ci/install-llvm-21-windows.ps1 | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/scripts/ci/install-llvm-21-windows.ps1 b/scripts/ci/install-llvm-21-windows.ps1 index cfa7054f7..c772a220b 100644 --- a/scripts/ci/install-llvm-21-windows.ps1 +++ b/scripts/ci/install-llvm-21-windows.ps1 @@ -65,13 +65,29 @@ function Invoke-DownloadFile { $Curl = Get-Command curl.exe -ErrorAction SilentlyContinue if ($Curl) { - & $Curl.Source --fail --location --retry 5 --retry-delay 5 --output $Output $Url + & $Curl.Source --fail --location --retry 8 --retry-all-errors --retry-delay 5 --retry-max-time 300 --connect-timeout 30 --output $Output $Url if ($LASTEXITCODE -ne 0) { throw "curl failed to download $Url with exit code $LASTEXITCODE" } } else { - Invoke-WebRequest -Uri $Url -OutFile $Output + $LastError = $null + for ($Attempt = 1; $Attempt -le 8; $Attempt++) { + try { + Invoke-WebRequest -Uri $Url -OutFile $Output -TimeoutSec 300 + return + } + catch { + $LastError = $_ + if ($Attempt -eq 8) { + break + } + + Start-Sleep -Seconds 5 + } + } + + throw "Invoke-WebRequest failed to download $Url after 8 attempts: $LastError" } } From 29eafe6cd512fc7e27250642f9675ee44554d122 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 8 Jun 2026 13:16:33 -0600 Subject: [PATCH 076/388] Fix dynamic result wait synchronization --- crates/pecos-qis-ffi/src/lib.rs | 46 +++++++++++++++++++-------------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index b772a8749..a14c288dc 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -538,18 +538,24 @@ pub fn wait_for_result_ready(result_id: u64, timeout_ms: u64) -> bool { } ctx.sync_condvar.notify_all(); - // Wait for result to be ready + // Wait for result to be ready. Condition variables may wake spuriously, so + // keep waiting until the predicate changes or the timeout expires. let timeout = Duration::from_millis(timeout_ms); - let Ok(mut state) = ctx.sync_state.lock() else { + let Ok(state) = ctx.sync_state.lock() else { return false; }; - if !state.result_ready { - let result = ctx.sync_condvar.wait_timeout(state, timeout); - state = match result { - Ok((s, _)) => s, - Err(_) => return false, - }; + let result = ctx + .sync_condvar + .wait_timeout_while(state, timeout, |state| { + !state.result_ready && !state.worker_complete + }); + let Ok((state, timed_out)) = result else { + return false; + }; + + if timed_out.timed_out() && !state.result_ready { + log::debug!("wait_for_result_ready: timeout"); } log::debug!("wait_for_result_ready: result_ready={}", state.result_ready); @@ -763,6 +769,8 @@ mod tests { use std::sync::Arc; use std::thread; + const TEST_SYNC_TIMEOUT_MS: u64 = 5_000; + /// Helper to create and register an execution context for tests fn setup_context() -> *mut ExecutionContext { let ctx = pecos_create_execution_context(); @@ -1014,7 +1022,7 @@ mod tests { barrier.wait(); // Now worker has definitely set need_result - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 5); // Provide the result @@ -1224,7 +1232,7 @@ mod tests { worker_barrier.wait(); // Wait for main thread to signal it needs a result - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 5); pecos_signal_result_ready(); @@ -1234,7 +1242,7 @@ mod tests { barrier.wait(); // Wait for result - this should export operations to context storage - let result = wait_for_result_ready(5, 500); + let result = wait_for_result_ready(5, TEST_SYNC_TIMEOUT_MS); assert!(result); // Verify operations were exported to context storage @@ -1272,7 +1280,7 @@ mod tests { worker_barrier.wait(); - let result = wait_for_result_ready(42, 500); + let result = wait_for_result_ready(42, TEST_SYNC_TIMEOUT_MS); unsafe { pecos_register_execution_context(std::ptr::null_mut()) }; result @@ -1281,7 +1289,7 @@ mod tests { barrier.wait(); // Main thread: wait for worker to signal it needs result - let needed = pecos_wait_for_need_result(500); + let needed = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed, 42); // Signal result ready @@ -1323,7 +1331,7 @@ mod tests { worker_barrier.wait(); // This will export ops and wait for result - let result = if wait_for_result_ready(0, 500) { + let result = if wait_for_result_ready(0, TEST_SYNC_TIMEOUT_MS) { get_measurement_result(0) } else { None @@ -1336,7 +1344,7 @@ mod tests { barrier.wait(); // Main thread: wait for worker to need result - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 0); // Verify operations were exported @@ -1383,7 +1391,7 @@ mod tests { worker_barrier.wait(); - assert!(wait_for_result_ready(0, 500)); + assert!(wait_for_result_ready(0, TEST_SYNC_TIMEOUT_MS)); with_interface(|iface| { assert!(iface.operations.is_empty()); @@ -1393,7 +1401,7 @@ mod tests { iface.queue_operation(Operation::Quantum(QuantumOp::H(0))); }); - assert!(wait_for_result_ready(1, 500)); + assert!(wait_for_result_ready(1, TEST_SYNC_TIMEOUT_MS)); with_interface(|iface| { assert!(iface.operations.is_empty()); @@ -1404,7 +1412,7 @@ mod tests { barrier.wait(); - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 0); let ops_ptr = pecos_get_pending_operations(); // SAFETY: see `pecos_get_pending_operations` -- null-or-leaked-Box invariant. @@ -1413,7 +1421,7 @@ mod tests { unsafe { pecos_free_operations(ops_ptr) }; pecos_signal_result_ready(); - let needed_id = pecos_wait_for_need_result(500); + let needed_id = pecos_wait_for_need_result(TEST_SYNC_TIMEOUT_MS); assert_eq!(needed_id, 1); let ops_ptr = pecos_get_pending_operations(); // SAFETY: see `pecos_get_pending_operations` -- null-or-leaked-Box invariant. From 885ebf99f7eba6262e354253b1582b54b8f30e9a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 8 Jun 2026 16:23:15 -0600 Subject: [PATCH 077/388] Improve local LLVM setup guidance --- Justfile | 136 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 108 insertions(+), 28 deletions(-) diff --git a/Justfile b/Justfile index 6ce2129e7..9faec5354 100644 --- a/Justfile +++ b/Justfile @@ -11,9 +11,11 @@ default: @echo "Quick start:" @echo " just install-cli # Optional: install pecos CLI for direct use" @echo " just setup # First time: detect and install dependencies" + @echo " just setup-env # Configure LLVM 21.1 for local development" + @echo " just dev-preflight # Check LLVM before the longer dev workflow" @echo " just build # Build PECOS (runs setup if needed)" @echo " just test # Run all tests" - @echo " just dev # Build + test (daily workflow)" + @echo " just dev # Build + test (runs dev-preflight first)" @echo " just lint # Check formatting and linting" @echo " just security-check # Check dependency/security policy" @echo " just doctor # Diagnose environment problems" @@ -53,34 +55,14 @@ setup: _msvc-bootstrap setup-ci: _msvc-bootstrap {{pecos}} setup --yes +# Install/configure local LLVM 21.1 build environment +[group('setup')] +setup-env: ensure-local-build-env + @echo "Development environment is configured. Run: just doctor" + # Ensure CI has a runtime-valid LLVM and export PECOS build env files [group('setup')] -ci-env: _msvc-bootstrap - #!/usr/bin/env bash - set -euo pipefail - LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" - case "${RUNNER_OS:-$(uname -s)}" in - Linux) - {{pecos}} llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-conda-linux.sh - {{pecos}} llvm configure - ;; - macOS|Darwin) - if ! {{pecos}} llvm find >/dev/null 2>&1; then - HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 - fi - {{pecos}} llvm configure "$(brew --prefix llvm@21)" - ;; - Windows*|MINGW*|MSYS*|CYGWIN*) - LLVM_ENV_ROOT="${USERPROFILE:-$HOME}\\.pecos\\deps\\llvm-21.1" - LLVM_PREFIX="${LLVM_ENV_ROOT}\\Library" - powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/ci/install-llvm-21-windows.ps1 -InstallDir "$LLVM_ENV_ROOT" -Version "$LLVM_RELEASE_VERSION" - {{pecos}} llvm configure "$LLVM_PREFIX" - ;; - *) - {{pecos}} llvm ensure --managed --no-configure - {{pecos}} llvm configure - ;; - esac +ci-env: ensure-ci-build-env {{pecos}} env --github-actions # Check development environment for common problems @@ -392,9 +374,51 @@ bench profile="release" features="" pattern="": _msvc-bootstrap (validate-bench- # Dev Workflows # ============================================================================= +# Check local prerequisites before the longer dev workflow +[group('dev')] +dev-preflight: _msvc-bootstrap + #!/usr/bin/env bash + set -euo pipefail + print_llvm_hint() { + echo "Run: just setup-env" + echo "Or configure your own LLVM 21.1 install:" + echo " cargo run --locked -p pecos-cli -- llvm configure /path/to/llvm" + } + + if ! LLVM_CHECK_OUTPUT=$({{pecos}} llvm check 2>&1); then + echo "$LLVM_CHECK_OUTPUT" + echo "" + echo "PECOS dev preflight failed: LLVM 21.1 is not ready." + print_llvm_hint + exit 1 + fi + + if [ ! -f .cargo/config.toml ] || ! grep -q "LLVM_SYS_211_PREFIX" .cargo/config.toml 2>/dev/null; then + echo "PECOS dev preflight failed: .cargo/config.toml does not set LLVM_SYS_211_PREFIX." + print_llvm_hint + exit 1 + fi + + case "$(uname -s)" in + MINGW*|MSYS*|CYGWIN*|Windows*) ;; + *) + LLVM_DIR=$({{pecos}} llvm find 2>/dev/null || true) + if [ -n "$LLVM_DIR" ] && [ -x "$LLVM_DIR/bin/llvm-config" ]; then + LINK_MODE=$("$LLVM_DIR/bin/llvm-config" --shared-mode 2>/dev/null || echo "unknown") + if [ "$LINK_MODE" != "shared" ]; then + echo "PECOS dev preflight failed: LLVM at $LLVM_DIR reports '$LINK_MODE' link mode." + echo "Full workspace HUGR tests need shared LLVM 21.1 to avoid high-memory static links." + print_llvm_hint + exit 1 + fi + fi + ;; + esac + echo "PECOS dev preflight passed." + # Fast dev cycle: build + test only (lang: all, rust, python, julia, go) [group('dev')] -dev lang="all": (validate-dev-lang lang) +dev lang="all": (validate-dev-lang lang) dev-preflight #!/usr/bin/env bash set -euo pipefail DEV_LANG="{{lang}}" @@ -808,6 +832,62 @@ setup-quiet: set -euo pipefail {{pecos}} setup --quiet +[private] +ensure-local-build-env: _msvc-bootstrap + #!/usr/bin/env bash + set -euo pipefail + + has_llvm_config() { + [ -f .cargo/config.toml ] && grep -q "LLVM_SYS_211_PREFIX" .cargo/config.toml 2>/dev/null + } + + if {{pecos}} llvm check >/dev/null 2>&1 && has_llvm_config; then + exit 0 + fi + + if {{pecos}} llvm find >/dev/null 2>&1; then + {{pecos}} llvm configure + exit 0 + fi + + just install-build-llvm + +[private] +ensure-ci-build-env: install-build-llvm + +[private] +install-build-llvm: _msvc-bootstrap + #!/usr/bin/env bash + set -euo pipefail + LLVM_RELEASE_VERSION="${LLVM_RELEASE_VERSION:-21.1.8}" + case "${RUNNER_OS:-$(uname -s)}" in + Linux) + {{pecos}} llvm ensure --managed --no-configure || bash scripts/ci/install-llvm-21-conda-linux.sh + {{pecos}} llvm configure + ;; + macOS|Darwin) + if ! command -v brew >/dev/null 2>&1; then + echo "PECOS-managed LLVM is not available on macOS yet." >&2 + echo "Install Homebrew LLVM 21 or configure your own shared LLVM 21.1:" >&2 + echo " brew install llvm@21" >&2 + echo " cargo run --locked -p pecos-cli -- llvm configure /path/to/llvm" >&2 + exit 1 + fi + HOMEBREW_NO_AUTO_UPDATE=1 brew install llvm@21 + {{pecos}} llvm configure "$(brew --prefix llvm@21)" + ;; + Windows*|MINGW*|MSYS*|CYGWIN*) + LLVM_ENV_ROOT="${USERPROFILE:-$HOME}\\.pecos\\deps\\llvm-21.1" + LLVM_PREFIX="${LLVM_ENV_ROOT}\\Library" + powershell.exe -NoProfile -ExecutionPolicy Bypass -File scripts/ci/install-llvm-21-windows.ps1 -InstallDir "$LLVM_ENV_ROOT" -Version "$LLVM_RELEASE_VERSION" + {{pecos}} llvm configure "$LLVM_PREFIX" + ;; + *) + {{pecos}} llvm ensure --managed --no-configure + {{pecos}} llvm configure + ;; + esac + # Sync Python deps (fast if already installed, skips maturin rebuilds) [private] sync-deps: From 6da12ffefececb7cdbae5c2c8b0f84e8128e974a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 00:31:57 -0600 Subject: [PATCH 078/388] Tighten surface DEM result handling --- .../src/fault_tolerance/dem_builder.rs | 10 +- .../fault_tolerance/dem_builder/builder.rs | 249 +- .../src/fault_tolerance/dem_builder/types.rs | 2102 +++++++++++++++-- python/pecos-rslib/src/decoder_bindings.rs | 16 +- .../src/fault_tolerance_bindings.rs | 48 +- .../quantum-pecos/src/pecos/guppy/surface.py | 129 +- python/quantum-pecos/src/pecos/qec/dem.py | 10 +- .../src/pecos/qec/surface/circuit_builder.py | 358 ++- .../src/pecos/qec/surface/decode.py | 370 ++- .../pecos/decoders/test_decoder_bindings.py | 10 + .../tests/qec/surface/test_surface_decoder.py | 102 +- .../qec/surface/test_surface_metadata.py | 40 +- .../tests/qec/test_from_guppy_dem.py | 90 +- .../qec/test_traced_qis_clifford_pipeline.py | 29 +- 14 files changed, 3244 insertions(+), 319 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index f96ef6633..391dcd7b5 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -46,10 +46,12 @@ //! //! # Error Decomposition //! -//! When using decomposed DEM output, hyperedge errors (affecting 3+ -//! detectors) are decomposed into combinations of graphlike errors (affecting -//! 1-2 detectors). This is necessary for MWPM decoders which only work on -//! graphs, not hypergraphs. +//! When using decomposed DEM output, PECOS decomposes only through component +//! structure carried by the original fault source (for example `Y = X ^ Z` or +//! recorded per-location components for multi-qubit sources). Residual +//! hyperedges remain hyperedges. Graphlike decoders should consume this output +//! only after checking that no residual hyperedge components remain; hypergraph +//! decoders can consume the faithful hypergraph model directly. //! //! # Comparison with Python Implementation //! diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 20428e3e7..1125a0af9 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -1215,9 +1215,12 @@ impl<'a> DemBuilder<'a> { .expect("exact_branch_replay replacement Pauli was validated"); let (p1, p2) = two_qubit_label_to_pauli_indices(&label) .expect("exact_branch_replay label must be a two-qubit Pauli"); - let effect = self - .exact_replacement_branch_effect(context, request, &label) + let analysis = self + .exact_branch_analysis(context, request) .expect("exact_branch_replay effect was validated"); + let base_effect = analysis.base_effect.clone(); + let branch_pauli_effect = analysis.branch_effects[p1 as usize][p2 as usize].clone(); + let effect = base_effect.xor(&branch_pauli_effect); if effect.is_empty() { continue; } @@ -1226,7 +1229,7 @@ impl<'a> DemBuilder<'a> { let source_paulis = [Pauli::from_u8(p1), Pauli::from_u8(p2)]; let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; let source_before_flags = [loc1_meta.before, loc2_meta.before]; - dem.add_direct_contribution_with_source( + dem.add_direct_contribution_with_source_components( effect, self.noise.p2 * *relative_probability, SourceMetadata::new( @@ -1237,6 +1240,7 @@ impl<'a> DemBuilder<'a> { ) .with_direct_source_family(DirectSourceFamily::TwoLocationExactReplacementBranch) .with_replacement_branch(), + DirectSourceComponents::new(&base_effect, &branch_pauli_effect), ); } } @@ -1504,6 +1508,26 @@ impl<'a> DemBuilder<'a> { let source_gate_types = [loc1_meta.gate_type, loc2_meta.gate_type]; let source_before_flags = [loc1_meta.before, loc2_meta.before]; + let source_frame_components = if direct_source_family.is_none() { + Self::two_qubit_clifford_source_frame_components(loc1_meta.gate_type, p1, p2, effects) + } else { + None + }; + if let Some(parts) = source_frame_components.as_ref() { + dem.add_direct_contribution_with_source_components( + effect.clone(), + prob, + SourceMetadata::new( + &source_locations, + &source_paulis, + &source_gate_types, + &source_before_flags, + ), + DirectSourceComponents::from_slice(parts.as_slice()), + ); + return; + } + if let Some((a1, a2, b1, b2)) = get_y_decomposition(p1, p2) { let e_a = &effects[a1 as usize][a2 as usize]; let e_b = &effects[b1 as usize][b2 as usize]; @@ -1529,6 +1553,7 @@ impl<'a> DemBuilder<'a> { .with_direct_source_family(family) .with_replacement_branch(); } + dem.add_direct_contribution_with_source_components( effect.clone(), prob, @@ -1538,6 +1563,47 @@ impl<'a> DemBuilder<'a> { } } + /// Builds exact source-frame components for ordinary post-gate Pauli noise + /// on supported two-qubit Clifford gates. + /// + /// A post-gate Pauli can be pulled back through the Clifford into a pre-gate + /// Pauli. Decomposing that pre-gate Pauli into X/Z generators often exposes + /// the graphlike source pieces that were hidden by the native gate frame. + /// Each generator is then pushed forward again and looked up in the existing + /// post-gate effect table, so the XOR of returned components is exactly the + /// original post-gate effect. + fn two_qubit_clifford_source_frame_components( + gate_type: GateType, + post_p1: u8, + post_p2: u8, + effects: &[[FaultMechanism; 4]; 4], + ) -> Option> { + let images = two_qubit_pre_generator_post_images(gate_type)?; + let (pre_p1, pre_p2) = invert_two_qubit_clifford_post_pauli(images, (post_p1, post_p2))?; + + let mut components = SmallVec::new(); + for image in two_qubit_pre_pauli_generator_images(images, pre_p1, pre_p2) { + toggle_source_component( + &mut components, + effects[image.0 as usize][image.1 as usize].clone(), + ); + } + + if components.is_empty() { + return None; + } + + #[cfg(debug_assertions)] + { + let combined = components + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + debug_assert_eq!(combined, effects[post_p1 as usize][post_p2 as usize]); + } + + Some(components) + } + /// Builds mappings from measurement indices to detector/DEM-output IDs. /// /// When `measurement_order` is provided, this properly maps between @@ -1850,6 +1916,133 @@ fn per_channel_probability(total_prob: f64, num_channels: u32) -> f64 { // Intra-Channel Decomposition // ============================================================================ +type TwoQubitPauli = (u8, u8); +type TwoQubitGeneratorImages = [TwoQubitPauli; 4]; + +/// Returns post-gate images of the pre-gate generators +/// `[X1, Z1, X2, Z2]`, ignoring phase. +#[inline] +fn two_qubit_pre_generator_post_images(gate_type: GateType) -> Option { + match gate_type { + GateType::CX => Some([ + (1, 1), // X1 -> XX + (3, 0), // Z1 -> ZI + (0, 1), // X2 -> IX + (3, 3), // Z2 -> ZZ + ]), + GateType::CZ => Some([ + (1, 3), // X1 -> XZ + (3, 0), // Z1 -> ZI + (3, 1), // X2 -> ZX + (0, 3), // Z2 -> IZ + ]), + GateType::SZZ | GateType::SZZdg => Some([ + (2, 3), // X1 -> YZ + (3, 0), // Z1 -> ZI + (3, 2), // X2 -> ZY + (0, 3), // Z2 -> IZ + ]), + _ => None, + } +} + +#[inline] +fn invert_two_qubit_clifford_post_pauli( + images: TwoQubitGeneratorImages, + post: TwoQubitPauli, +) -> Option { + for pre_p1 in 0..4 { + for pre_p2 in 0..4 { + if forward_two_qubit_pauli(images, pre_p1, pre_p2) == post { + return Some((pre_p1, pre_p2)); + } + } + } + None +} + +fn two_qubit_pre_pauli_generator_images( + images: TwoQubitGeneratorImages, + pre_p1: u8, + pre_p2: u8, +) -> SmallVec<[TwoQubitPauli; 4]> { + let mut out = SmallVec::new(); + if pauli_has_x(pre_p1) { + out.push(images[0]); + } + if pauli_has_z(pre_p1) { + out.push(images[1]); + } + if pauli_has_x(pre_p2) { + out.push(images[2]); + } + if pauli_has_z(pre_p2) { + out.push(images[3]); + } + out +} + +#[inline] +fn forward_two_qubit_pauli( + images: TwoQubitGeneratorImages, + pre_p1: u8, + pre_p2: u8, +) -> TwoQubitPauli { + two_qubit_pre_pauli_generator_images(images, pre_p1, pre_p2) + .into_iter() + .fold((0, 0), xor_two_qubit_pauli) +} + +#[inline] +fn xor_two_qubit_pauli(a: TwoQubitPauli, b: TwoQubitPauli) -> TwoQubitPauli { + (xor_pauli(a.0, b.0), xor_pauli(a.1, b.1)) +} + +#[inline] +fn xor_pauli(a: u8, b: u8) -> u8 { + pauli_from_bits( + pauli_has_x(a) ^ pauli_has_x(b), + pauli_has_z(a) ^ pauli_has_z(b), + ) +} + +#[inline] +fn pauli_has_x(pauli: u8) -> bool { + matches!(pauli, 1 | 2) +} + +#[inline] +fn pauli_has_z(pauli: u8) -> bool { + matches!(pauli, 2 | 3) +} + +#[inline] +fn pauli_from_bits(has_x: bool, has_z: bool) -> u8 { + match (has_x, has_z) { + (false, false) => 0, + (true, false) => 1, + (true, true) => 2, + (false, true) => 3, + } +} + +fn toggle_source_component( + components: &mut SmallVec<[FaultMechanism; 4]>, + component: FaultMechanism, +) { + if component.is_empty() { + return; + } + if let Some(index) = components + .iter() + .position(|existing| existing == &component) + { + components.remove(index); + } else { + components.push(component); + } +} + /// Returns the intra-channel decomposition for Y-containing Pauli cases. /// /// For any two-qubit Pauli case (p1, p2) that contains Y, returns the @@ -2792,6 +2985,47 @@ impl std::error::Error for DemBuilderError {} mod tests { use super::*; + #[test] + fn test_szz_source_frame_components_pull_post_error_to_pre_generators() { + fn dets(indices: &[u32]) -> FaultMechanism { + FaultMechanism::from_unsorted(indices.iter().copied(), std::iter::empty()) + } + + let a = dets(&[0, 1]); + let b = dets(&[2]); + let c = dets(&[3, 4]); + + let mut effects: [[FaultMechanism; 4]; 4] = Default::default(); + effects[2][3] = a.clone(); // SZZ maps pre X1 to post YZ. + effects[3][0] = b.clone(); // SZZ maps pre Z1 to post ZI. + effects[0][3] = c.clone(); // SZZ maps pre Z2 to post IZ. + effects[1][0] = a.xor(&b).xor(&c); + + let parts = + DemBuilder::two_qubit_clifford_source_frame_components(GateType::SZZ, 1, 0, &effects) + .expect("post XI should pull back through SZZ to pre YZ"); + + assert_eq!(parts.len(), 3); + assert!(parts.contains(&a)); + assert!(parts.contains(&b)); + assert!(parts.contains(&c)); + assert_eq!( + parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)), + effects[1][0] + ); + + effects[3][0] = a.clone(); + effects[1][0] = c.clone(); + + let parts = + DemBuilder::two_qubit_clifford_source_frame_components(GateType::SZZ, 1, 0, &effects) + .expect("duplicate source components should cancel by XOR"); + + assert_eq!(parts.as_slice(), &[c]); + } + #[test] fn test_from_circuit_tracks_tracked_pauli() { use pecos_core::pauli::X; @@ -3463,6 +3697,15 @@ mod tests { .all(|pauli| *pauli == Pauli::I), "omission-only replacement branch should be recorded as *II" ); + let (base_effect, branch_pauli_effect) = contributions[0] + .direct_component_effects() + .expect("exact branch replay should preserve base/branch components"); + assert_eq!( + base_effect.xor(&branch_pauli_effect), + contributions[0].effect + ); + assert_eq!(base_effect.detectors.as_slice(), &[0]); + assert!(branch_pauli_effect.is_empty()); } #[test] diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 91cf82cce..866800dab 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -39,10 +39,11 @@ //! - [`DetectorErrorModel::to_string()`] - Non-decomposed format. Each //! mechanism is output once with its combined probability. //! -//! - [`DetectorErrorModel::to_string_decomposed()`] - Decomposed format. -//! Hyperedge errors (3+ detectors) are decomposed into graphlike components, -//! and 2-detector mechanisms may have multiple representations for decoder -//! compatibility. +//! - [`DetectorErrorModel::to_string_decomposed()`] - Source-decomposed format. +//! Faults are decomposed only using component structure carried by the source +//! contribution itself. Residual hyperedges stay hyperedges so hypergraph +//! decoders see the faithful model and graphlike decoders can reject them +//! loudly. //! //! Decomposed errors use the `^` separator to indicate XOR composition: //! @@ -50,15 +51,16 @@ //! error(0.01) D0 D1 ^ D2 D3 //! ``` //! -//! This indicates an error decomposed into two parts whose XOR equals the -//! original mechanism. +//! This indicates one correlated source event decomposed into two source +//! components whose XOR equals the original mechanism. Components may still be +//! hyperedges if the physical source component flips 3+ detectors. use pecos_core::PauliString; use pecos_core::gate_type::GateType; use rand::RngExt; use smallvec::SmallVec; use std::cmp::Ordering; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::fmt; use std::hash::{Hash, Hasher}; @@ -168,13 +170,20 @@ pub struct FaultContribution { /// Coarse direct-source family for read-only analysis. pub direct_source_family: Option, - /// Optional per-location component effects for direct multi-location sources. + /// Optional legacy per-location component effects for direct multi-location sources. /// /// These are builder-time component effects whose XOR equals `effect`. They are - /// currently recorded for direct two-qubit channel sources to aid decomposition - /// analysis without changing emitted DEM behavior. + /// kept for the original two-component diagnostics and Python bindings. pub direct_component_effects: Option<(FaultMechanism, FaultMechanism)>, + /// Optional source-frame component effects for direct multi-location sources. + /// + /// These are builder-time component effects whose XOR equals `effect`. Unlike + /// `direct_component_effects`, this can carry more than two pieces, which is + /// needed when a native two-qubit Clifford's exact source frame is generated + /// by multiple Pauli generators. + pub source_component_effects: Option>, + /// True when this contribution came from a replacement branch rather than /// ordinary post-gate Pauli noise. pub replacement_branch: bool, @@ -218,15 +227,26 @@ impl<'a, Index> SourceMetadata<'a, Index> { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(crate) struct DirectSourceComponents<'a> { - first: &'a FaultMechanism, - second: &'a FaultMechanism, + components: SmallVec<[&'a FaultMechanism; 4]>, } impl<'a> DirectSourceComponents<'a> { - pub(crate) const fn new(first: &'a FaultMechanism, second: &'a FaultMechanism) -> Self { - Self { first, second } + pub(crate) fn new(first: &'a FaultMechanism, second: &'a FaultMechanism) -> Self { + Self { + components: smallvec::smallvec![first, second], + } + } + + pub(crate) fn from_slice(components: &'a [FaultMechanism]) -> Self { + Self { + components: components.iter().collect(), + } + } + + fn as_slice(&self) -> &[&'a FaultMechanism] { + &self.components } } @@ -234,7 +254,7 @@ impl FaultContribution { fn classify_direct_source_family( location_indices: &[u32], paulis: &[Pauli], - direct_component_effects: Option<(&FaultMechanism, &FaultMechanism)>, + direct_component_effects: Option<&[&FaultMechanism]>, ) -> Option { if location_indices.is_empty() { return None; @@ -249,8 +269,12 @@ impl FaultContribution { DirectSourceFamily::SingleLocation }), 2 => { - if let Some((first, second)) = direct_component_effects { - if first.is_empty() ^ second.is_empty() { + if let Some(components) = direct_component_effects { + let non_empty = components + .iter() + .filter(|component| !component.is_empty()) + .count(); + if components.len() == 2 && non_empty == 1 { Some(DirectSourceFamily::TwoLocationOneSidedComponent) } else { Some(DirectSourceFamily::TwoLocationComponent) @@ -278,6 +302,7 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, replacement_branch: false, } } @@ -304,6 +329,7 @@ impl FaultContribution { Self::classify_direct_source_family(source.location_indices, source.paulis, None) }), direct_component_effects: None, + source_component_effects: None, replacement_branch: source.replacement_branch, } } @@ -320,13 +346,26 @@ impl FaultContribution { debug_assert_eq!(source.location_indices.len(), source.paulis.len()); debug_assert_eq!(source.location_indices.len(), source.gate_types.len()); debug_assert_eq!(source.location_indices.len(), source.before_flags.len()); - let source_type = if (components.first == &effect && components.second.is_empty()) - || (components.second == &effect && components.first.is_empty()) - { + let component_refs = components.as_slice(); + let non_empty_components: SmallVec<[&FaultMechanism; 4]> = component_refs + .iter() + .copied() + .filter(|component| !component.is_empty()) + .collect(); + let source_type = if non_empty_components.len() == 1 && non_empty_components[0] == &effect { FaultSourceType::DirectOneSidedComponent } else { FaultSourceType::Direct }; + let direct_component_effects = if let [first, second] = component_refs { + Some(((*first).clone(), (*second).clone())) + } else { + None + }; + let source_component_effects = component_refs + .iter() + .map(|component| (*component).clone()) + .collect(); Self { effect, probability, @@ -339,10 +378,11 @@ impl FaultContribution { Self::classify_direct_source_family( source.location_indices, source.paulis, - Some((components.first, components.second)), + Some(component_refs), ) }), - direct_component_effects: Some((components.first.clone(), components.second.clone())), + direct_component_effects, + source_component_effects: Some(source_component_effects), replacement_branch: source.replacement_branch, } } @@ -373,6 +413,7 @@ impl FaultContribution { source_before_flags: SmallVec::new(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, replacement_branch: false, } } @@ -404,6 +445,7 @@ impl FaultContribution { source_before_flags: source.before_flags.iter().copied().collect(), direct_source_family: None, direct_component_effects: None, + source_component_effects: None, replacement_branch: source.replacement_branch, } } @@ -440,6 +482,12 @@ impl FaultContribution { pub fn direct_component_effects(&self) -> Option<(FaultMechanism, FaultMechanism)> { self.direct_component_effects.clone() } + + /// Returns source-frame component effects for a direct multi-location source. + #[must_use] + pub fn source_component_effects(&self) -> Option> { + self.source_component_effects.clone() + } } /// Aggregated source-tracked information for one unique effect. @@ -535,6 +583,22 @@ pub enum TwoDetectorDirectRenderPolicy { PreferRecordedComponents, } +/// Policy for decomposing hyperedges in decomposed DEM output. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +enum HyperedgeDecompositionRenderPolicy { + /// Preserve source-carried components exactly, even when a component is a + /// hyperedge. This is the faithful source-component view. + PreserveSourceComponents, + + /// Decompose hyperedge components using graphlike pieces learned from + /// source-carried components and full source-tracked alternatives. + SourceGraphlikeComponents, + + /// Historical compatibility mode: search graphlike full effects elsewhere + /// in the DEM. This is not source proof and is kept explicit. + GlobalGraphlikeSearch, +} + // ============================================================================ // Error Mechanism // ============================================================================ @@ -927,15 +991,122 @@ fn find_hyperedge_decomposition( GraphlikeDecompositionIndex::new(graphlike_set).find_hyperedge_decomposition(hyperedge) } +const GRAPH_PATH_BOUNDARY: GraphPathNode = GraphPathNode::Boundary; +const MAX_GRAPH_PATH_TERMINALS: usize = 12; +const MAX_GRAPH_PATH_LENGTH: usize = 16; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +enum GraphPathNode { + Boundary, + Detector(u32), +} + +#[derive(Debug, Clone)] +struct GraphPathEdge { + next: GraphPathNode, + mechanism: FaultMechanism, +} + +#[derive(Debug, Clone)] +struct GraphPathCandidate { + dem_outputs: SmallVec<[u32; 2]>, + parts: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct GraphPathSearchCacheKey { + start: GraphPathNode, + end: GraphPathNode, + excluded_origin: Option, +} + +impl GraphPathSearchCacheKey { + fn new(a: GraphPathNode, b: GraphPathNode, excluded_origin: Option<&FaultMechanism>) -> Self { + let (start, end) = ordered_graph_path_pair(a, b); + Self { + start, + end, + excluded_origin: excluded_origin.cloned(), + } + } +} + +type GraphPathSearchCache = BTreeMap>; + +#[derive(Clone, Default)] +struct SourceGraphlikeClosure { + mechanisms: BTreeSet, + primitive: BTreeSet, + derived_origins: BTreeMap>, +} + +impl SourceGraphlikeClosure { + fn from_primitives(primitives: BTreeSet) -> Self { + Self { + mechanisms: primitives.clone(), + primitive: primitives, + derived_origins: BTreeMap::new(), + } + } + + fn len(&self) -> usize { + self.mechanisms.len() + } + + fn insert_derived(&mut self, mechanism: FaultMechanism, origin: &FaultMechanism) { + let is_new = self.mechanisms.insert(mechanism.clone()); + if is_new && !self.primitive.contains(&mechanism) { + self.derived_origins + .entry(mechanism) + .or_default() + .insert(origin.clone()); + } + } +} + struct GraphlikeDecompositionIndex { graphlike_set: BTreeSet, + primitive_graphlike_set: BTreeSet, + derived_origins: BTreeMap>, /// Indexed by detector ID; see `SingletonDecompositionIndex` for the same /// pattern and rationale. Detector IDs are dense `0..num_detectors`. candidates_by_detector: Vec>, + /// Maps one- and two-detector symptoms to a known graphlike mechanism with + /// those symptoms, including any associated logical frame changes. + symptoms: BTreeMap, Vec>, + /// Source-derived detector graph used for exact path decompositions. + /// + /// A one-detector mechanism is represented as an edge to `Boundary`; a + /// two-detector mechanism is represented as an ordinary detector edge. + /// Paths may introduce intermediate detector vertices only when they cancel + /// pairwise in the XOR of path components. + path_adjacency: BTreeMap>, + /// Pure logical graphlike components, keyed by their DEM-output mask. + pure_logical_components: BTreeMap, Vec>, } impl GraphlikeDecompositionIndex { fn new(graphlike_set: &BTreeSet) -> Self { + Self::from_parts( + graphlike_set.clone(), + graphlike_set.clone(), + BTreeMap::new(), + ) + } + + fn from_source_closure(closure: &SourceGraphlikeClosure) -> Self { + Self::from_parts( + closure.mechanisms.clone(), + closure.primitive.clone(), + closure.derived_origins.clone(), + ) + } + + fn from_parts( + graphlike_set: BTreeSet, + primitive_graphlike_set: BTreeSet, + derived_origins: BTreeMap>, + ) -> Self { let max_det = graphlike_set .iter() .flat_map(|c| c.detectors.iter().copied()) @@ -944,11 +1115,70 @@ impl GraphlikeDecompositionIndex { let mut candidates_by_detector: Vec> = max_det.map_or_else(Vec::new, |m| vec![Vec::new(); m as usize + 1]); - for candidate in graphlike_set { + for candidate in &graphlike_set { for &det in &candidate.detectors { candidates_by_detector[det as usize].push(candidate.clone()); } } + let mut symptoms: BTreeMap, Vec> = BTreeMap::new(); + for candidate in &graphlike_set { + if candidate.detectors.is_empty() || candidate.detectors.len() > 2 { + continue; + } + symptoms + .entry(candidate.detectors.clone()) + .or_default() + .push(candidate.clone()); + } + for values in symptoms.values_mut() { + values.sort_by(Self::compare_symptom_candidates); + values.dedup(); + } + let mut path_adjacency: BTreeMap> = BTreeMap::new(); + let mut pure_logical_components: BTreeMap, Vec> = + BTreeMap::new(); + for candidate in &graphlike_set { + if !candidate.is_graphlike() || candidate.is_standard_empty() { + continue; + } + match candidate.detectors.as_slice() { + [] => { + pure_logical_components + .entry(candidate.dem_outputs.clone()) + .or_default() + .push(candidate.clone()); + } + [det] => { + Self::add_path_edge( + &mut path_adjacency, + GRAPH_PATH_BOUNDARY, + GraphPathNode::Detector(*det), + candidate.clone(), + ); + } + [d0, d1] => { + Self::add_path_edge( + &mut path_adjacency, + GraphPathNode::Detector(*d0), + GraphPathNode::Detector(*d1), + candidate.clone(), + ); + } + _ => {} + } + } + for values in pure_logical_components.values_mut() { + values.sort_by(Self::compare_symptom_candidates); + values.dedup(); + } + for edges in path_adjacency.values_mut() { + edges.sort_by(|a, b| { + a.next + .cmp(&b.next) + .then_with(|| a.mechanism.cmp(&b.mechanism)) + }); + edges.dedup_by(|a, b| a.next == b.next && a.mechanism == b.mechanism); + } for values in &mut candidates_by_detector { values.sort_by(|a, b| { b.detectors @@ -959,13 +1189,94 @@ impl GraphlikeDecompositionIndex { } Self { graphlike_set: graphlike_set.clone(), + primitive_graphlike_set, + derived_origins, candidates_by_detector, + symptoms, + path_adjacency, + pure_logical_components, + } + } + + fn add_path_edge( + path_adjacency: &mut BTreeMap>, + a: GraphPathNode, + b: GraphPathNode, + mechanism: FaultMechanism, + ) { + path_adjacency.entry(a).or_default().push(GraphPathEdge { + next: b, + mechanism: mechanism.clone(), + }); + path_adjacency + .entry(b) + .or_default() + .push(GraphPathEdge { next: a, mechanism }); + } + + fn compare_symptom_candidates(a: &FaultMechanism, b: &FaultMechanism) -> Ordering { + a.dem_outputs + .len() + .cmp(&b.dem_outputs.len()) + .then_with(|| a.dem_outputs.cmp(&b.dem_outputs)) + .then_with(|| a.cmp(b)) + } + + fn candidate_allowed( + &self, + candidate: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> bool { + let Some(origin) = excluded_origin else { + return true; + }; + if self.primitive_graphlike_set.contains(candidate) { + return true; } + self.derived_origins.get(candidate).is_none_or(|origins| { + origins + .iter() + .any(|candidate_origin| candidate_origin != origin) + }) + } + + fn first_allowed_symptom_candidate( + &self, + key: &SmallVec<[u32; 4]>, + excluded_origin: Option<&FaultMechanism>, + ) -> Option<&FaultMechanism> { + self.symptoms.get(key).and_then(|candidates| { + candidates + .iter() + .find(|candidate| self.candidate_allowed(candidate, excluded_origin)) + }) + } + + fn first_allowed_logical_component( + &self, + key: &SmallVec<[u32; 2]>, + excluded_origin: Option<&FaultMechanism>, + ) -> Option<&FaultMechanism> { + self.pure_logical_components + .get(key) + .and_then(|candidates| { + candidates + .iter() + .find(|candidate| self.candidate_allowed(candidate, excluded_origin)) + }) } fn find_hyperedge_decomposition( &self, hyperedge: &FaultMechanism, + ) -> Option> { + self.find_hyperedge_decomposition_for_origin(hyperedge, None) + } + + fn find_hyperedge_decomposition_for_origin( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, ) -> Option> { // If already graphlike, no decomposition needed if hyperedge.is_graphlike() { @@ -983,13 +1294,341 @@ impl GraphlikeDecompositionIndex { }; let mut memo = BTreeMap::new(); - let result = self.search_decomposition(hyperedge, &mut memo); + let result = self.search_decomposition(hyperedge, excluded_origin, &mut memo); result.filter(|decomp| decomp_dets_valid(decomp)) } + fn find_hyperedge_decomposition_with_remnants_for_origin_cached( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + ) -> Option> { + if let Some(decomp) = + self.find_hyperedge_decomposition_for_origin(hyperedge, excluded_origin) + { + return Some(decomp); + } + + let remnant = self.find_remnant_decomposition(hyperedge, excluded_origin); + if remnant + .as_ref() + .is_some_and(|parts| self.remnant_decomposition_is_preferred(parts, excluded_origin)) + { + return remnant; + } + + self.find_graph_path_decomposition_with_cache(hyperedge, excluded_origin, path_cache) + .or(remnant) + } + + fn find_hyperedge_discovery_decomposition_for_origin( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + self.find_hyperedge_decomposition_for_origin(hyperedge, excluded_origin) + .or_else(|| self.find_remnant_decomposition(hyperedge, excluded_origin)) + } + + fn remnant_decomposition_is_preferred( + &self, + parts: &[FaultMechanism], + excluded_origin: Option<&FaultMechanism>, + ) -> bool { + parts.iter().all(|part| { + part.is_graphlike() + && self.graphlike_set.contains(part) + && self.candidate_allowed(part, excluded_origin) + }) + } + + #[cfg(test)] + fn find_graph_path_decomposition( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + let mut path_cache = GraphPathSearchCache::new(); + self.find_graph_path_decomposition_with_cache(hyperedge, excluded_origin, &mut path_cache) + } + + fn find_graph_path_decomposition_with_cache( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + ) -> Option> { + if hyperedge.is_graphlike() { + return Some(vec![hyperedge.clone()]); + } + if self.path_adjacency.is_empty() || hyperedge.detectors.len() > MAX_GRAPH_PATH_TERMINALS { + return None; + } + + let mut terminals: Vec = hyperedge + .detectors + .iter() + .copied() + .map(GraphPathNode::Detector) + .collect(); + if terminals.len() % 2 == 1 { + terminals.push(GRAPH_PATH_BOUNDARY); + } + + let full_mask = (1u16 << terminals.len()) - 1; + let mut pairing_state_cache: BTreeMap< + u16, + BTreeMap, Vec>, + > = BTreeMap::new(); + let states = self.graph_path_pairing_states( + full_mask, + &terminals, + excluded_origin, + path_cache, + &mut pairing_state_cache, + ); + let mut best: Option> = None; + + for (outputs, mut parts) in states { + let missing_outputs = symmetric_difference_2(&outputs, &hyperedge.dem_outputs); + if !missing_outputs.is_empty() { + let Some(logical_part) = + self.first_allowed_logical_component(&missing_outputs, excluded_origin) + else { + continue; + }; + parts.push(logical_part.clone()); + } + parts = parity_reduce_mechanisms(parts); + if parts.is_empty() { + continue; + } + let recomposed = parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + if recomposed == *hyperedge && parts.iter().all(FaultMechanism::is_graphlike) { + match best.as_ref() { + Some(existing) if !prefer_graph_path_parts(&parts, existing) => {} + _ => best = Some(parts), + } + } + } + + best + } + + fn graph_path_pairing_states( + &self, + mask: u16, + terminals: &[GraphPathNode], + excluded_origin: Option<&FaultMechanism>, + path_cache: &mut GraphPathSearchCache, + pairing_state_cache: &mut BTreeMap, Vec>>, + ) -> BTreeMap, Vec> { + if let Some(cached) = pairing_state_cache.get(&mask) { + return cached.clone(); + } + + if mask == 0 { + let mut base = BTreeMap::new(); + base.insert(SmallVec::new(), Vec::new()); + pairing_state_cache.insert(mask, base.clone()); + return base; + } + + let first = mask.trailing_zeros() as usize; + let without_first = mask & !(1u16 << first); + let mut states: BTreeMap, Vec> = BTreeMap::new(); + + for second in (first + 1)..terminals.len() { + if without_first & (1u16 << second) == 0 { + continue; + } + + let tail_mask = without_first & !(1u16 << second); + let tail_states = self.graph_path_pairing_states( + tail_mask, + terminals, + excluded_origin, + path_cache, + pairing_state_cache, + ); + if tail_states.is_empty() { + continue; + } + + let candidates = path_cache + .entry(GraphPathSearchCacheKey::new( + terminals[first], + terminals[second], + excluded_origin, + )) + .or_insert_with(|| { + self.graph_path_candidates(terminals[first], terminals[second], excluded_origin) + }); + if candidates.is_empty() { + continue; + } + + for candidate in candidates.iter() { + for (tail_outputs, tail_parts) in &tail_states { + let outputs = symmetric_difference_2(&candidate.dem_outputs, tail_outputs); + let mut parts = Vec::with_capacity(candidate.parts.len() + tail_parts.len()); + parts.extend(candidate.parts.iter().cloned()); + parts.extend(tail_parts.iter().cloned()); + + match states.get(&outputs) { + Some(existing) if !prefer_graph_path_parts(&parts, existing) => {} + _ => { + states.insert(outputs, parts); + } + } + } + } + } + + pairing_state_cache.insert(mask, states.clone()); + states + } + + fn graph_path_candidates( + &self, + start: GraphPathNode, + end: GraphPathNode, + excluded_origin: Option<&FaultMechanism>, + ) -> Vec { + let mut queue = VecDeque::new(); + queue.push_back((start, SmallVec::<[u32; 2]>::new(), Vec::new())); + + let mut seen: BTreeSet<(GraphPathNode, SmallVec<[u32; 2]>)> = BTreeSet::new(); + seen.insert((start, SmallVec::new())); + let mut best_by_outputs: BTreeMap, Vec> = + BTreeMap::new(); + + while let Some((node, outputs, parts)) = queue.pop_front() { + if node == end && !parts.is_empty() { + best_by_outputs + .entry(outputs.clone()) + .and_modify(|existing| { + if prefer_graph_path_parts(&parts, existing) { + *existing = parts.clone(); + } + }) + .or_insert_with(|| parts.clone()); + } + if parts.len() >= MAX_GRAPH_PATH_LENGTH { + continue; + } + + let Some(edges) = self.path_adjacency.get(&node) else { + continue; + }; + for edge in edges { + if !self.candidate_allowed(&edge.mechanism, excluded_origin) { + continue; + } + let next_outputs = symmetric_difference_2(&outputs, &edge.mechanism.dem_outputs); + let state = (edge.next, next_outputs.clone()); + if !seen.insert(state) { + continue; + } + + let mut next_parts = Vec::with_capacity(parts.len() + 1); + next_parts.extend(parts.iter().cloned()); + next_parts.push(edge.mechanism.clone()); + queue.push_back((edge.next, next_outputs, next_parts)); + } + } + + let mut candidates: Vec<_> = best_by_outputs + .into_iter() + .map(|(dem_outputs, parts)| GraphPathCandidate { dem_outputs, parts }) + .collect(); + candidates.sort_by(|a, b| { + a.parts + .len() + .cmp(&b.parts.len()) + .then_with(|| a.dem_outputs.cmp(&b.dem_outputs)) + .then_with(|| a.parts.cmp(&b.parts)) + }); + candidates + } + + fn find_remnant_decomposition( + &self, + hyperedge: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, + ) -> Option> { + if hyperedge.is_graphlike() { + return Some(vec![hyperedge.clone()]); + } + + let mut done = BTreeSet::new(); + let mut remaining = hyperedge.clone(); + let mut parts = Vec::new(); + + for (i, &d0) in hyperedge.detectors.iter().enumerate() { + if done.contains(&d0) { + continue; + } + for &d1 in hyperedge.detectors.iter().skip(i + 1) { + if done.contains(&d1) { + continue; + } + let key = SmallVec::from_slice(&[d0, d1]); + let Some(candidate) = self.first_allowed_symptom_candidate(&key, excluded_origin) + else { + continue; + }; + done.insert(d0); + done.insert(d1); + remaining = remaining.xor(candidate); + parts.push(candidate.clone()); + break; + } + } + + for &det in &hyperedge.detectors { + if done.contains(&det) { + continue; + } + let key = SmallVec::from_slice(&[det]); + let Some(candidate) = self.first_allowed_symptom_candidate(&key, excluded_origin) + else { + continue; + }; + done.insert(det); + remaining = remaining.xor(candidate); + parts.push(candidate.clone()); + } + + let missed = hyperedge + .detectors + .iter() + .filter(|det| !done.contains(det)) + .count(); + if missed > 2 { + return None; + } + + if !remaining.is_standard_empty() { + parts.push(remaining); + } + let recomposed = parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + if recomposed == *hyperedge && parts.iter().all(FaultMechanism::is_graphlike) { + Some(parts) + } else { + None + } + } + fn search_decomposition( &self, remaining: &FaultMechanism, + excluded_origin: Option<&FaultMechanism>, memo: &mut BTreeMap>>, ) -> Option> { if let Some(cached) = memo.get(remaining) { @@ -1002,7 +1641,10 @@ impl GraphlikeDecompositionIndex { return result; } - if remaining.is_graphlike() && self.graphlike_set.contains(remaining) { + if remaining.is_graphlike() + && self.graphlike_set.contains(remaining) + && self.candidate_allowed(remaining, excluded_origin) + { let result = Some(vec![remaining.clone()]); memo.insert(remaining.clone(), result.clone()); return result; @@ -1012,6 +1654,9 @@ impl GraphlikeDecompositionIndex { && let Some(candidates) = self.candidates_by_detector.get(pivot as usize) { for candidate in candidates { + if !self.candidate_allowed(candidate, excluded_origin) { + continue; + } if !candidate .detectors .iter() @@ -1030,7 +1675,7 @@ impl GraphlikeDecompositionIndex { continue; } - if let Some(suffix) = self.search_decomposition(&next, memo) { + if let Some(suffix) = self.search_decomposition(&next, excluded_origin, memo) { let mut combined = Vec::with_capacity(suffix.len() + 1); combined.push(candidate.clone()); combined.extend(suffix); @@ -1047,12 +1692,40 @@ impl GraphlikeDecompositionIndex { } } -/// Finds a decomposition of a graphlike effect into singleton detector components. -/// -/// This is used for "maximal" decomposition modes that prefer singleton -/// detector symptoms whenever the required singleton effects already exist as -/// standalone mechanisms in the DEM. -fn find_singleton_decomposition( +fn ordered_graph_path_pair(a: GraphPathNode, b: GraphPathNode) -> (GraphPathNode, GraphPathNode) { + if a <= b { (a, b) } else { (b, a) } +} + +fn prefer_graph_path_parts(candidate: &[FaultMechanism], existing: &[FaultMechanism]) -> bool { + candidate + .len() + .cmp(&existing.len()) + .then_with(|| candidate.cmp(existing)) + == Ordering::Less +} + +fn parity_reduce_mechanisms(parts: Vec) -> Vec { + let mut reduced = Vec::new(); + for part in parts { + if part.is_empty() { + continue; + } + if let Some(index) = reduced.iter().position(|existing| existing == &part) { + reduced.remove(index); + } else { + reduced.push(part); + } + } + reduced.sort(); + reduced +} + +/// Finds a decomposition of a graphlike effect into singleton detector components. +/// +/// This is used for "maximal" decomposition modes that prefer singleton +/// detector symptoms whenever the required singleton effects already exist as +/// standalone mechanisms in the DEM. +fn find_singleton_decomposition( effect: &FaultMechanism, index: &SingletonDecompositionIndex, ) -> Option> { @@ -3589,10 +4262,13 @@ impl DetectorErrorModel { } } - let graphlike_set = self.collect_graphlike_mechanisms(); + let graphlike_set = BTreeSet::new(); let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); let mut by_render: BTreeMap<(FaultMechanism, String), Accumulator> = BTreeMap::new(); for contrib in &self.contributions { @@ -3604,7 +4280,10 @@ impl DetectorErrorModel { contrib, &graphlike_index, None, + None, two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); let acc = by_render @@ -3671,10 +4350,59 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> Vec { - let graphlike_set = self.collect_graphlike_mechanisms(); + self.contribution_render_records_inner( + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) + } + + /// Returns per-contribution render records for the source-informed + /// graphlike renderer. + #[must_use] + pub fn contribution_source_graphlike_render_records(&self) -> Vec { + self.contribution_render_records_inner( + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents, + ) + } + + fn contribution_render_records_inner( + &self, + two_detector_direct_policy: TwoDetectorDirectRenderPolicy, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + ) -> Vec { + let graphlike_set = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + self.collect_graphlike_mechanisms() + } else { + BTreeSet::new() + }; let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); + let source_graphlike_closure = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + self.collect_source_graphlike_closure() + } else { + SourceGraphlikeClosure::default() + }; + let source_graphlike_index = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + Some(GraphlikeDecompositionIndex::from_source_closure( + &source_graphlike_closure, + )) + } else { + None + }; + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); let mut records = Vec::new(); for contrib in &self.contributions { @@ -3686,8 +4414,11 @@ impl DetectorErrorModel { Self::contribution_render_details( contrib, &graphlike_index, + source_graphlike_index.as_ref(), None, two_detector_direct_policy, + hyperedge_policy, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); records.push(ContributionRenderRecord { @@ -4057,23 +4788,178 @@ impl DetectorErrorModel { out } + fn source_component_parts(contrib: &FaultContribution) -> Vec { + if let Some((x_effect, z_effect)) = contrib.decomposition_components() { + return [x_effect, z_effect] + .into_iter() + .map(|part| part.standard_effect()) + .filter(|part| !part.is_empty()) + .collect(); + } + + if let Some(parts) = contrib.source_component_effects() { + return parts + .into_iter() + .map(|part| part.standard_effect()) + .filter(|part| !part.is_empty()) + .collect(); + } + + let effect = contrib.effect.standard_effect(); + if effect.is_empty() || contrib.location_indices.is_empty() { + Vec::new() + } else { + vec![effect] + } + } + + fn collect_source_graphlike_mechanisms(&self) -> BTreeSet { + let mut graphlike = BTreeSet::new(); + for contrib in &self.contributions { + let effect = contrib.effect.standard_effect(); + if !contrib.location_indices.is_empty() && effect.is_graphlike() { + graphlike.insert(effect); + } + for part in Self::source_component_parts(contrib) { + if part.is_graphlike() { + graphlike.insert(part); + } + } + } + graphlike + } + + fn collect_source_graphlike_closure(&self) -> SourceGraphlikeClosure { + let mut graphlike = + SourceGraphlikeClosure::from_primitives(self.collect_source_graphlike_mechanisms()); + + loop { + let before = graphlike.len(); + let index = GraphlikeDecompositionIndex::from_source_closure(&graphlike); + + for contrib in &self.contributions { + let mut parts = Self::source_component_parts(contrib); + let effect = contrib.effect.standard_effect(); + if !contrib.location_indices.is_empty() && !effect.is_empty() { + parts.push(effect); + } + + for part in parts { + let origin = part.clone(); + let decomposed = if part.is_graphlike() { + vec![part] + } else { + index + .find_hyperedge_discovery_decomposition_for_origin(&part, Some(&origin)) + .unwrap_or_else(|| vec![part]) + }; + + for piece in decomposed { + if piece.is_graphlike() && !piece.is_standard_empty() { + graphlike.insert_derived(piece, &origin); + } + } + } + } + + if graphlike.len() == before { + break; + } + } + + graphlike + } + + fn source_graphlike_decompose_parts( + parts: Vec, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + let mut out = Vec::new(); + for part in parts { + if part.is_empty() { + continue; + } + + let decomposed = if part.is_graphlike() { + vec![part] + } else if let Some(index) = source_graphlike_index { + index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &part, + Some(&part), + source_graphlike_path_cache, + ) + .unwrap_or_else(|| vec![part]) + } else { + vec![part] + }; + + out.extend(Self::maybe_maximally_decompose_parts( + decomposed, + singleton_set, + )); + } + out + } + + fn source_graphlike_decompose_recorded_or_full_effect( + effect: &FaultMechanism, + parts: Vec, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + let recorded_parts = Self::source_graphlike_decompose_parts( + parts, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + if recorded_parts.iter().all(FaultMechanism::is_graphlike) { + return recorded_parts; + } + + if let Some(index) = source_graphlike_index { + if let Some(effect_parts) = index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + effect, + Some(effect), + source_graphlike_path_cache, + ) + { + if effect_parts.iter().all(FaultMechanism::is_graphlike) { + return Self::maybe_maximally_decompose_parts(effect_parts, singleton_set); + } + } + } + + recorded_parts + } + + fn format_decomposed_parts(parts: Vec) -> String { + Self::parity_reduce_decomposed_parts(parts) + .iter() + .map(format_mechanism_targets) + .filter(|targets| !targets.is_empty()) + .collect::>() + .join(" ^ ") + } + + fn parity_reduce_decomposed_parts(parts: Vec) -> Vec { + parity_reduce_mechanisms(parts) + } + fn recorded_component_targets( contrib: &FaultContribution, singleton_set: Option<&SingletonDecompositionIndex>, ) -> Option { - let (first, second) = contrib.direct_component_effects()?; - let targets = Self::maybe_maximally_decompose_parts( - [first, second] - .into_iter() - .filter(|part| !part.is_empty()) - .collect(), + let parts = contrib.source_component_effects()?; + let targets = Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + parts.into_iter().filter(|part| !part.is_empty()).collect(), singleton_set, - ) - .iter() - .map(format_mechanism_targets) - .filter(|targets| !targets.is_empty()) - .collect::>() - .join(" ^ "); + )); if targets.is_empty() { None } else { @@ -4095,60 +4981,42 @@ impl DetectorErrorModel { fn contribution_render_details( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, singleton_set: Option<&SingletonDecompositionIndex>, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, - cache: &mut BTreeMap<(FaultMechanism, FaultSourceType), String>, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + source_graphlike_path_cache: &mut GraphPathSearchCache, + cache: &mut BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + >, ) -> (String, ContributionRenderStrategy, Option) { let recorded_component_targets = Self::recorded_component_targets(contrib, singleton_set); - let key = (contrib.effect.clone(), contrib.source_type.clone()); - if let Some(cached) = cache.get(&key) { - let strategy = if contrib.decomposition_components().is_some() { - ContributionRenderStrategy::SourceComponents - } else if contrib.effect.num_detectors() == 2 && contrib.effect.dem_outputs.is_empty() { - let direct_targets = - Self::two_detector_direct_targets(&contrib.effect, singleton_set); - if matches!( - two_detector_direct_policy, - TwoDetectorDirectRenderPolicy::PreferRecordedComponents - ) && recorded_component_targets.as_deref() == Some(cached.as_str()) - && cached != &direct_targets - { - ContributionRenderStrategy::RecordedComponents - } else { - ContributionRenderStrategy::TwoDetectorDirect - } - } else if contrib.effect.is_hyperedge() { - ContributionRenderStrategy::HyperedgeGraphlike - } else { - ContributionRenderStrategy::EffectDirect - }; - return (cached.clone(), strategy, recorded_component_targets); + let key = ( + contrib.effect.clone(), + contrib.source_type.clone(), + recorded_component_targets.clone(), + ); + if let Some((cached_targets, cached_strategy)) = cache.get(&key) { + return ( + cached_targets.clone(), + *cached_strategy, + recorded_component_targets, + ); } let effect = contrib.effect.standard_effect(); let (targets, strategy) = if let Some((x_effect, z_effect)) = contrib.decomposition_components() { - let x_graphlike = x_effect.is_empty() || x_effect.is_graphlike(); - let z_graphlike = z_effect.is_empty() || z_effect.is_graphlike(); - - if !x_effect.is_empty() && !z_effect.is_empty() && x_graphlike && z_graphlike { - let x_parts = - Self::maybe_maximally_decompose_parts(vec![x_effect.clone()], singleton_set); - let z_parts = - Self::maybe_maximally_decompose_parts(vec![z_effect.clone()], singleton_set); - let targets = x_parts - .iter() - .chain(z_parts.iter()) - .map(format_mechanism_targets) - .filter(|targets| !targets.is_empty()) - .collect::>() - .join(" ^ "); - let targets = if targets.is_empty() { - String::new() - } else { - targets - }; + if !x_effect.is_empty() && !z_effect.is_empty() { + let parts = Self::source_graphlike_decompose_parts( + vec![x_effect.clone(), z_effect.clone()], + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + let targets = Self::format_decomposed_parts(parts); (targets, ContributionRenderStrategy::SourceComponents) } else if effect.num_detectors() == 2 && effect.dem_outputs.is_empty() { let direct_targets = Self::two_detector_direct_targets(&effect, singleton_set); @@ -4181,15 +5049,65 @@ impl DetectorErrorModel { ) } } else if effect.is_hyperedge() { - if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { - ( - Self::maybe_maximally_decompose_parts(decomp, singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), - ContributionRenderStrategy::HyperedgeGraphlike, - ) + if let Some(parts) = contrib.source_component_effects() { + let targets = Self::format_decomposed_parts( + Self::source_graphlike_decompose_recorded_or_full_effect( + &effect, + parts.into_iter().collect(), + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ), + ); + (targets, ContributionRenderStrategy::RecordedComponents) + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + if let Some(index) = source_graphlike_index { + if let Some(decomp) = index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &effect, + Some(&effect), + source_graphlike_path_cache, + ) + { + ( + Self::format_decomposed_parts( + Self::maybe_maximally_decompose_parts(decomp, singleton_set), + ), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } } else { ( format_mechanism_targets(&effect), @@ -4198,11 +5116,10 @@ impl DetectorErrorModel { } } else { ( - Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + vec![effect.clone()], + singleton_set, + )), ContributionRenderStrategy::EffectDirect, ) } @@ -4237,15 +5154,66 @@ impl DetectorErrorModel { ) } } else if effect.is_hyperedge() { - if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { - ( - Self::maybe_maximally_decompose_parts(decomp, singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), - ContributionRenderStrategy::HyperedgeGraphlike, - ) + if let Some(parts) = contrib.source_component_effects() { + let targets = Self::format_decomposed_parts( + Self::source_graphlike_decompose_recorded_or_full_effect( + &effect, + parts.into_iter().collect(), + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ), + ); + (targets, ContributionRenderStrategy::RecordedComponents) + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + if let Some(index) = source_graphlike_index { + if let Some(decomp) = index + .find_hyperedge_decomposition_with_remnants_for_origin_cached( + &effect, + Some(&effect), + source_graphlike_path_cache, + ) + { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } + } else if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { + ( + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + decomp, + singleton_set, + )), + ContributionRenderStrategy::HyperedgeGraphlike, + ) + } else { + ( + format_mechanism_targets(&effect), + ContributionRenderStrategy::EffectDirect, + ) + } } else { ( format_mechanism_targets(&effect), @@ -4254,31 +5222,39 @@ impl DetectorErrorModel { } } else { ( - Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set) - .iter() - .map(format_mechanism_targets) - .collect::>() - .join(" ^ "), + Self::format_decomposed_parts(Self::maybe_maximally_decompose_parts( + vec![effect.clone()], + singleton_set, + )), ContributionRenderStrategy::EffectDirect, ) }; - cache.insert(key, targets.clone()); + cache.insert(key, (targets.clone(), strategy)); (targets, strategy, recorded_component_targets) } fn contribution_targets( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, singleton_set: Option<&SingletonDecompositionIndex>, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, - cache: &mut BTreeMap<(FaultMechanism, FaultSourceType), String>, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, + source_graphlike_path_cache: &mut GraphPathSearchCache, + cache: &mut BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + >, ) -> String { Self::contribution_render_details( contrib, graphlike_index, + source_graphlike_index, singleton_set, two_detector_direct_policy, + hyperedge_policy, + source_graphlike_path_cache, cache, ) .0 @@ -4303,15 +5279,32 @@ impl DetectorErrorModel { /// because the edge is already graphlike and extra L0 terms can change /// decoder behavior without adding new information. /// - /// Hyperedges (3+ detectors) are decomposed into graphlike forms when - /// possible. Mechanisms with up to 2 detectors are already graphlike even + /// Hyperedges (3+ detectors) are decomposed only when source-tracked + /// component structure justifies the split. Residual hyperedges remain + /// hyperedges. Mechanisms with up to 2 detectors are already graphlike even /// when they carry multiple DEM outputs. #[must_use] fn to_string_decomposed_inner( &self, maximal_decomposition: bool, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, + hyperedge_policy: HyperedgeDecompositionRenderPolicy, ) -> String { + let profile_enabled = std::env::var_os("PECOS_DEM_RENDER_PROFILE").is_some(); + let profile_start = std::time::Instant::now(); + let mut profile_last = profile_start; + let profile_step = |label: &str, last: &mut std::time::Instant| { + if profile_enabled { + let now = std::time::Instant::now(); + eprintln!( + "[pecos-dem-render] {label}: step={:.3}s total={:.3}s", + now.duration_since(*last).as_secs_f64(), + now.duration_since(profile_start).as_secs_f64(), + ); + *last = now; + } + }; + let mut lines = Vec::new(); // Add detector coordinate annotations @@ -4327,40 +5320,91 @@ impl DetectorErrorModel { for obs in &self.observables { lines.push(format!("logical_observable L{}", obs.id)); } + profile_step("annotations", &mut profile_last); - let graphlike_set = self.collect_graphlike_mechanisms(); + let graphlike_set = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch + ) { + self.collect_graphlike_mechanisms() + } else { + BTreeSet::new() + }; let graphlike_index = GraphlikeDecompositionIndex::new(&graphlike_set); + profile_step("global_graphlike_index", &mut profile_last); + let source_graphlike_closure = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + self.collect_source_graphlike_closure() + } else { + SourceGraphlikeClosure::default() + }; + if profile_enabled { + eprintln!( + "[pecos-dem-render] source_graphlike_closure_size={}", + source_graphlike_closure.len() + ); + } + profile_step("source_graphlike_closure", &mut profile_last); + let source_graphlike_index = if matches!( + hyperedge_policy, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents + ) { + Some(GraphlikeDecompositionIndex::from_source_closure( + &source_graphlike_closure, + )) + } else { + None + }; + profile_step("source_graphlike_index", &mut profile_last); let singleton_set = maximal_decomposition.then(|| self.collect_singleton_index()); + profile_step("singleton_index", &mut profile_last); let mut by_targets: BTreeMap = BTreeMap::new(); - let mut rendered_targets_cache: BTreeMap<(FaultMechanism, FaultSourceType), String> = - BTreeMap::new(); - - let mut add_targets = |targets: String, probability: f64| { - if targets.is_empty() || probability <= 0.0 { - return; - } - by_targets - .entry(targets) - .and_modify(|p| *p = combine_independent_probs(*p, probability)) - .or_insert(probability); - }; + let mut rendered_targets_cache: BTreeMap< + (FaultMechanism, FaultSourceType, Option), + (String, ContributionRenderStrategy), + > = BTreeMap::new(); + let mut source_graphlike_path_cache = GraphPathSearchCache::new(); // Process each tracked contribution individually, then regroup identical // decomposed outputs. Rewriting each error class before merging keeps // source-aware decompositions stable. + let mut rendered_contribs = 0usize; for contrib in &self.contributions { if contrib.effect.is_empty() || contrib.probability <= 0.0 { continue; } + rendered_contribs += 1; let targets = Self::contribution_targets( contrib, &graphlike_index, + source_graphlike_index.as_ref(), singleton_set.as_ref(), two_detector_direct_policy, + hyperedge_policy, + &mut source_graphlike_path_cache, &mut rendered_targets_cache, ); - add_targets(targets, contrib.probability); + if !targets.is_empty() && contrib.probability > 0.0 { + by_targets + .entry(targets) + .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) + .or_insert(contrib.probability); + } + if profile_enabled && rendered_contribs % 5000 == 0 { + let now = std::time::Instant::now(); + eprintln!( + "[pecos-dem-render] rendered_contributions={} render_cache={} path_cache={} target_buckets={} total={:.3}s", + rendered_contribs, + rendered_targets_cache.len(), + source_graphlike_path_cache.len(), + by_targets.len(), + now.duration_since(profile_start).as_secs_f64(), + ); + } } + profile_step("render_contributions", &mut profile_last); for (targets, total_prob) in by_targets { if !targets.is_empty() && total_prob > 0.0 { @@ -4371,13 +5415,65 @@ impl DetectorErrorModel { )); } } + profile_step("format_output", &mut profile_last); lines.join("\n") } #[must_use] pub fn to_string_decomposed(&self) -> String { - self.to_string_decomposed_inner(false, TwoDetectorDirectRenderPolicy::KeepDirect) + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) + } + + /// Converts the DEM to a source-decomposed string. + /// + /// This is an explicit alias for [`Self::to_string_decomposed`]. The + /// renderer uses only decomposition structure attached to the original + /// fault source (for example Y=X^Z components and recorded per-location + /// components for multi-qubit sources). Hyperedges without a + /// source-legitimate decomposition remain hyperedges. + #[must_use] + pub fn to_string_source_decomposed(&self) -> String { + self.to_string_decomposed() + } + + /// Converts the DEM to a source-informed graphlike decomposition. + /// + /// This renderer first uses source-carried component structure (for + /// example Y=X^Z and recorded per-location components). If a source + /// component is still a hyperedge, it follows Stim's intra/inter-channel + /// decomposition strategy to a fixed point: use graphlike mechanisms that + /// appear as source-carried components or full source-tracked alternatives + /// in this DEM, then introduce graphlike remnants when needed and make + /// those available to later decompositions. Residual hyperedges remain + /// hyperedges if this source-informed graphlike closure cannot explain them. + #[must_use] + pub fn to_string_source_graphlike_decomposed(&self) -> String { + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::SourceGraphlikeComponents, + ) + } + + /// Converts the DEM to a graphlike-search decomposed string. + /// + /// This keeps the historical compatibility behavior that may decompose a + /// residual hyperedge by searching for graphlike mechanisms elsewhere in + /// the DEM. It is intentionally separate from the source-decomposed path: + /// use it only for representation experiments, not as proof that the source + /// mechanism itself has graphlike components. + #[must_use] + pub fn to_string_graphlike_search_decomposed(&self) -> String { + self.to_string_decomposed_inner( + false, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::GlobalGraphlikeSearch, + ) } /// Converts the DEM to decomposed format with an explicit direct-2det @@ -4387,7 +5483,11 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> String { - self.to_string_decomposed_inner(false, two_detector_direct_policy) + self.to_string_decomposed_inner( + false, + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Converts the DEM to a maximally decomposed graphlike form when possible. @@ -4402,7 +5502,11 @@ impl DetectorErrorModel { /// resulting matching graph. #[must_use] pub fn to_string_decomposed_maximally(&self) -> String { - self.to_string_decomposed_inner(true, TwoDetectorDirectRenderPolicy::KeepDirect) + self.to_string_decomposed_inner( + true, + TwoDetectorDirectRenderPolicy::KeepDirect, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Converts the DEM to a maximally decomposed graphlike form with an @@ -4412,7 +5516,11 @@ impl DetectorErrorModel { &self, two_detector_direct_policy: TwoDetectorDirectRenderPolicy, ) -> String { - self.to_string_decomposed_inner(true, two_detector_direct_policy) + self.to_string_decomposed_inner( + true, + two_detector_direct_policy, + HyperedgeDecompositionRenderPolicy::PreserveSourceComponents, + ) } /// Collects all graphlike mechanisms from contributions. @@ -5738,6 +6846,710 @@ mod tests { assert!(matches!(contribution.source_type, FaultSourceType::Direct)); } + #[test] + fn test_decomposed_render_uses_recorded_graphlike_components_for_direct_hyperedge() { + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 ^ D2 D3")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[0].recorded_component_targets.as_deref(), + Some("D0 D1 ^ D2 D3") + ); + } + + #[test] + fn test_decomposed_render_preserves_recorded_hyperedge_components() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3, 4], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let records = dem.contribution_render_records(); + let record = records + .iter() + .find(|record| (record.contribution.probability - 0.01).abs() < 1e-12) + .expect("expected recorded-component contribution"); + + assert_eq!( + record.render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!(record.rendered_targets, "D0 D1 D2 ^ D3 D4"); + } + + #[test] + fn test_decomposed_render_cache_distinguishes_recorded_components() { + let effect = FaultMechanism::from_unsorted([0, 1, 2, 3], std::iter::empty()); + let a_first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let a_second = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let b_first = FaultMechanism::from_unsorted([0, 2], std::iter::empty()); + let b_second = FaultMechanism::from_unsorted([1, 3], std::iter::empty()); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect.clone(), + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&a_first, &a_second), + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.02, + SourceMetadata::new( + &[5usize, 6usize], + &[Pauli::Z, Pauli::X], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&b_first, &b_second), + ); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].rendered_targets, "D0 D1 ^ D2 D3"); + assert_eq!(records[1].rendered_targets, "D0 D2 ^ D1 D3"); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[1].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 ^ D2 D3")); + assert!(decomposed.contains("error(0.02) D0 D2 ^ D1 D3")); + } + + #[test] + fn test_decomposed_render_uses_recorded_hyperedge_components_without_global_search() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::CX, GateType::CX], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let decomposed = dem.to_string_decomposed(); + assert!(decomposed.contains("error(0.01) D0 D1 D2 ^ D3")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[0].recorded_component_targets.as_deref(), + Some("D0 D1 D2 ^ D3") + ); + } + + #[test] + fn test_graphlike_search_decomposed_is_explicit_compatibility_path() { + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()), + 0.01, + ); + + let source_decomposed = dem.to_string_decomposed(); + assert!(source_decomposed.contains("error(0.01) D0 D1 D2")); + assert!(!source_decomposed.contains("error(0.01) D0 D1 ^ D2")); + + let graphlike_search = dem.to_string_graphlike_search_decomposed(); + assert!(graphlike_search.contains("error(0.01) D0 D1 ^ D2")); + } + + #[test] + fn test_source_graphlike_decomposed_uses_source_component_graphlike_pieces() { + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + // These graphlike pieces are not arbitrary full effects; they are + // carried as source components on tracked contributions. + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + SourceMetadata::new( + &[10usize, 11usize], + &[Pauli::X, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new( + &FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + &FaultMechanism::new(), + ), + ); + dem.add_direct_contribution_with_source_components( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + SourceMetadata::new( + &[12usize, 13usize], + &[Pauli::Z, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new( + &FaultMechanism::from_unsorted([2], std::iter::empty()), + &FaultMechanism::new(), + ), + ); + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 ^ D3")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 ^ D2 ^ D3")); + } + + #[test] + fn test_source_graphlike_decomposed_rejects_global_only_decomposition() { + let mut dem = DetectorErrorModel::new(); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([2], std::iter::empty()), + 0.001, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()), + 0.01, + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 D2")); + assert!(!source_graphlike.contains("error(0.01) D0 D1 ^ D2")); + + let graphlike_search = dem.to_string_graphlike_search_decomposed(); + assert!(graphlike_search.contains("error(0.01) D0 D1 ^ D2")); + } + + #[test] + fn test_source_graphlike_decomposed_uses_source_remnant_edges() { + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([1, 2], std::iter::empty()), + 0.001, + SourceMetadata::new(&[10usize], &[Pauli::X], &[GateType::H], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([0, 1, 2], [0]), + 0.01, + SourceMetadata::new(&[11usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 L0")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!( + source_graphlike.contains("error(0.01) D1 D2 ^ D0 L0") + || source_graphlike.contains("error(0.01) D0 L0 ^ D1 D2") + ); + } + + #[test] + fn test_source_graphlike_decomposed_uses_exact_detector_graph_paths() { + let mut dem = DetectorErrorModel::new(); + let graphlike_parts = [ + FaultMechanism::from_unsorted([10, 13], std::iter::empty()), + FaultMechanism::from_unsorted([13, 40], std::iter::empty()), + FaultMechanism::from_unsorted([651, 655], std::iter::empty()), + FaultMechanism::from_unsorted([658, 661], std::iter::empty()), + FaultMechanism::from_unsorted([661, 664], std::iter::empty()), + FaultMechanism::from_unsorted([659], std::iter::empty()), + ]; + for (idx, part) in graphlike_parts.iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = graphlike_parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([10, 40, 651, 655, 658, 659, 664], std::iter::empty(),) + ); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[99usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for target hyperedge"); + for expected in [ + "D10 D13", + "D13 D40", + "D651 D655", + "D658 D661", + "D659", + "D661 D664", + ] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!( + !target_line.contains("D10 D40 D651"), + "target hyperedge should be split through exact graph paths: {target_line}", + ); + } + + #[test] + fn test_source_graphlike_path_decomposition_matches_logical_frame() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], [0]); + let third = FaultMechanism::from_unsorted([3], std::iter::empty()); + for (idx, part) in [&first, &second, &third].into_iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third); + assert_eq!(effect, FaultMechanism::from_unsorted([0, 2, 3], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[9usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for logical target"); + assert!(target_line.contains("D0 D1")); + assert!(target_line.contains("D1 D2 L0")); + assert!(target_line.contains("D3")); + assert!(!target_line.contains("D0 D2 D3 L0")); + } + + #[test] + fn test_source_graphlike_path_decomposition_handles_boundary_cluster() { + let mut dem = DetectorErrorModel::new(); + let graphlike_parts = [ + FaultMechanism::from_unsorted([14, 40], std::iter::empty()), + FaultMechanism::from_unsorted([40, 44], std::iter::empty()), + FaultMechanism::from_unsorted([654], [0]), + FaultMechanism::from_unsorted([662], std::iter::empty()), + FaultMechanism::from_unsorted([663, 666], std::iter::empty()), + FaultMechanism::from_unsorted([668, 671], std::iter::empty()), + FaultMechanism::from_unsorted([671], std::iter::empty()), + ]; + for (idx, part) in graphlike_parts.iter().enumerate() { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = graphlike_parts + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([14, 44, 654, 662, 663, 666, 668], [0]) + ); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[99usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected source graphlike decomposition for boundary cluster"); + for expected in [ + "D14 D40", + "D40 D44", + "D654 L0", + "D662", + "D663 D666", + "D668 D671", + "D671", + ] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!(!target_line.contains("D14 D44 D654")); + } + + #[test] + fn test_source_graphlike_closure_learns_full_source_alternatives() { + let mut dem = DetectorErrorModel::new(); + + let hidden_pair = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let first = FaultMechanism::from_unsorted([2, 3, 4], std::iter::empty()); + let second = FaultMechanism::from_unsorted([0, 1, 2, 3, 4], std::iter::empty()); + assert_eq!(first.xor(&second), hidden_pair); + + dem.add_direct_contribution_with_source_components( + hidden_pair, + 0.001, + SourceMetadata::new( + &[20usize, 21usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([6], std::iter::empty()), + 0.001, + SourceMetadata::new(&[22usize], &[Pauli::X], &[GateType::H], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([0, 1, 6], std::iter::empty()), + 0.01, + SourceMetadata::new(&[23usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D6")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("error(0.01) D0 D1 ^ D6")); + } + + #[test] + fn test_source_graphlike_closure_promotes_remnant_edges_for_later_paths() { + let mut dem = DetectorErrorModel::new(); + + for (loc, effect) in [ + ( + 10usize, + FaultMechanism::from_unsorted([100, 101], std::iter::empty()), + ), + ( + 11usize, + FaultMechanism::from_unsorted([102, 103], std::iter::empty()), + ), + ( + 12usize, + FaultMechanism::from_unsorted([200, 201], std::iter::empty()), + ), + ] { + dem.add_direct_contribution_with_source( + effect, + 0.001, + SourceMetadata::new(&[loc], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + // These two source mechanisms introduce graphlike remnant edges D2-D28 + // and D28-D32. A later source mechanism needs both as a detector-graph + // path from D2 to D32. + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([2, 28, 100, 101], std::iter::empty()), + 0.002, + SourceMetadata::new(&[20usize], &[Pauli::X], &[GateType::SX], &[false]), + ); + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([28, 32, 102, 103], std::iter::empty()), + 0.002, + SourceMetadata::new(&[21usize], &[Pauli::X], &[GateType::SX], &[false]), + ); + + dem.add_direct_contribution_with_source( + FaultMechanism::from_unsorted([2, 32, 200, 201], std::iter::empty()), + 0.01, + SourceMetadata::new(&[22usize], &[Pauli::Z], &[GateType::SZZ], &[false]), + ); + + let closure = dem.collect_source_graphlike_closure(); + assert!( + closure + .mechanisms + .contains(&FaultMechanism::from_unsorted([2, 28], std::iter::empty(),)) + ); + assert!( + closure + .mechanisms + .contains(&FaultMechanism::from_unsorted([28, 32], std::iter::empty(),)) + ); + let target = FaultMechanism::from_unsorted([2, 32, 200, 201], std::iter::empty()); + let index = GraphlikeDecompositionIndex::from_source_closure(&closure); + let self_remnant = FaultMechanism::from_unsorted([2, 32], std::iter::empty()); + assert!( + !index.candidate_allowed(&self_remnant, Some(&target)), + "self remnant provenance: primitive={} origins={:?}", + closure.primitive.contains(&self_remnant), + closure.derived_origins.get(&self_remnant), + ); + let graph_path = index + .find_graph_path_decomposition(&target, Some(&target)) + .expect("expected promoted remnant edges to produce a graph path"); + assert!( + graph_path.contains(&FaultMechanism::from_unsorted([2, 28], std::iter::empty(),)), + "{graph_path:?}", + ); + assert!( + graph_path.contains(&FaultMechanism::from_unsorted([28, 32], std::iter::empty(),)), + "{graph_path:?}", + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected later hyperedge to decompose through promoted remnant edges"); + for expected in ["D2 D28", "D28 D32", "D200 D201"] { + assert!( + target_line.contains(expected), + "missing {expected} in {target_line}", + ); + } + assert!(!target_line.contains("D2 D32 D200 D201")); + } + + #[test] + fn test_source_graphlike_recorded_components_can_use_full_effect_decomposition() { + let mut dem = DetectorErrorModel::new(); + + for (loc, effect) in [ + ( + 30usize, + FaultMechanism::from_unsorted([0, 3], std::iter::empty()), + ), + ( + 31usize, + FaultMechanism::from_unsorted([1, 4], std::iter::empty()), + ), + ( + 32usize, + FaultMechanism::from_unsorted([2, 5], std::iter::empty()), + ), + ] { + dem.add_direct_contribution_with_source( + effect, + 0.001, + SourceMetadata::new(&[loc], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let first = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let second = FaultMechanism::from_unsorted([3, 4, 5], std::iter::empty()); + let effect = first.xor(&second); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[40usize, 41usize], + &[Pauli::X, Pauli::X], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_preserved = dem.to_string_source_decomposed(); + assert!(source_preserved.contains("error(0.01) D0 D1 D2 ^ D3 D4 D5")); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + assert!(source_graphlike.contains("D0 D3")); + assert!(source_graphlike.contains("D1 D4")); + assert!(source_graphlike.contains("D2 D5")); + assert!(!source_graphlike.contains("error(0.01) D0 D1 D2 ^ D3 D4 D5")); + } + + #[test] + fn test_decomposed_render_cancels_duplicate_components_by_parity() { + let mut dem = DetectorErrorModel::new(); + let repeated = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let survivor = FaultMechanism::from_unsorted([2, 3], std::iter::empty()); + let effect = survivor.clone(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[50usize, 51usize, 52usize], + &[Pauli::X, Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ, GateType::SZZ], + &[false, false, false], + ), + DirectSourceComponents::from_slice(&[repeated.clone(), repeated, survivor]), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.01) D2 D3")); + assert!(!source_decomposed.contains("D0 D1")); + } + + #[test] + fn test_spp_source_components_preserve_recorded_hypergraph_structure() { + for gate_type in [GateType::SZZ, GateType::SZZdg] { + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2, 3, 4], std::iter::empty()); + let effect = first.xor(&second); + let mut dem = DetectorErrorModel::new(); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[3usize, 4usize], + &[Pauli::X, Pauli::Z], + &[gate_type, gate_type], + &[false, false], + ), + DirectSourceComponents::new(&first, &second), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.01) D0 D1 ^ D2 D3 D4")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::RecordedComponents + ); + assert_eq!( + records[0].contribution.source_gate_types.as_slice(), + &[gate_type, gate_type] + ); + } + } + + #[test] + fn test_spp_y_source_decomposition_preserves_hyperedge_branch() { + let x_effect = FaultMechanism::from_unsorted([0, 1, 2], std::iter::empty()); + let z_effect = FaultMechanism::from_unsorted([3, 4], std::iter::empty()); + let mut dem = DetectorErrorModel::new(); + + dem.add_y_decomposed_contribution_with_source( + &x_effect, + &z_effect, + 0.02, + SourceMetadata::new( + &[5usize, 6usize], + &[Pauli::Y, Pauli::I], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + ); + + let source_decomposed = dem.to_string_source_decomposed(); + assert!(source_decomposed.contains("error(0.02) D0 D1 D2 ^ D3 D4")); + + let records = dem.contribution_render_records(); + assert_eq!(records.len(), 1); + assert_eq!( + records[0].render_strategy, + ContributionRenderStrategy::SourceComponents + ); + assert_eq!( + records[0].contribution.source_gate_types.as_slice(), + &[GateType::SZZ, GateType::SZZ] + ); + } + #[test] fn test_direct_with_source_components_marks_one_sided_component_sources() { let effect = FaultMechanism::from_unsorted([7, 11], std::iter::empty()); diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index bf9d27879..ac9f51ac5 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -321,7 +321,7 @@ impl PyCheckMatrix { /// H = [[1, 1, 0], [0, 1, 1]] /// decoder = PyMatchingDecoder.from_check_matrix(CheckMatrix.from_dense(H)) /// -/// # From Stim detector error model +/// # From detector error model /// decoder = PyMatchingDecoder.from_dem(dem_string) /// /// # Manual graph construction (like PyMatching's add_edge) @@ -420,7 +420,7 @@ impl PyPyMatchingDecoder { .map_err(|e| PyErr::new::(e.to_string())) } - /// Create decoder from a Stim Detector Error Model. + /// Create decoder from a Detector Error Model. /// /// This mirrors `PyMatching`'s `Matching.from_detector_error_model()`. /// @@ -441,6 +441,18 @@ impl PyPyMatchingDecoder { .map_err(|e| PyErr::new::(e.to_string())) } + /// Create decoder from a Detector Error Model with correlation support. + /// + /// When enabled, PyMatching preserves DEM decomposition correlations while + /// constructing and decoding the matching graph. + #[staticmethod] + #[pyo3(signature = (dem, enable_correlations=true))] + fn from_dem_with_correlations(dem: &str, enable_correlations: bool) -> PyResult { + RustPyMatchingDecoder::from_dem_with_correlations(dem, enable_correlations) + .map(|inner| Self { inner }) + .map_err(|e| PyErr::new::(e.to_string())) + } + /// Add an edge between two detector nodes. /// /// This mirrors `PyMatching`'s `Matching.add_edge()`. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 6942b5a40..95d9051b5 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1227,11 +1227,11 @@ impl PyDetectorErrorModel { self.inner.to_string() } - /// Convert the DEM to a string with decomposed representations. + /// Convert the DEM to a string with source-decomposed representations. /// - /// For 2-detector mechanisms, outputs multiple equivalent representations - /// including L0 cancellation forms where available. Hyperedge errors - /// (affecting 3+ detectors) are decomposed into graphlike components. + /// Faults are decomposed only using component structure attached to the + /// original source contribution. Residual hyperedges remain hyperedges + /// instead of being rewritten by an ambient graphlike search. /// /// Returns: /// A string in DEM format with decomposed representations. @@ -1239,6 +1239,33 @@ impl PyDetectorErrorModel { self.inner.to_string_decomposed() } + /// Convert the DEM to source-decomposed text. + /// + /// Only decomposition components attached to the original fault source are + /// used. Residual hyperedges remain hyperedges instead of being rewritten + /// by an ambient graphlike search. + fn to_string_source_decomposed(&self) -> String { + self.inner.to_string_source_decomposed() + } + + /// Convert the DEM to a source-informed graphlike decomposition. + /// + /// Source-carried components are recursively decomposed only using + /// graphlike pieces that are themselves source-carried components in this + /// DEM. Residual hyperedges remain hyperedges. + fn to_string_source_graphlike_decomposed(&self) -> String { + self.inner.to_string_source_graphlike_decomposed() + } + + /// Convert the DEM using the explicit historical graphlike-search renderer. + /// + /// This may decompose residual hyperedges by searching for graphlike + /// mechanisms elsewhere in the DEM, so it should be treated as a + /// compatibility/diagnostic representation rather than source proof. + fn to_string_graphlike_search_decomposed(&self) -> String { + self.inner.to_string_graphlike_search_decomposed() + } + /// Convert the DEM to a string with an explicit direct-2det render policy. fn to_string_decomposed_with_two_detector_direct_policy( &self, @@ -1342,6 +1369,19 @@ impl PyDetectorErrorModel { .collect() } + /// Returns per-contribution render records for the source-informed + /// graphlike renderer. + fn contribution_source_graphlike_render_records( + &self, + py: Python<'_>, + ) -> PyResult>> { + self.inner + .contribution_source_graphlike_render_records() + .into_iter() + .map(|record| contribution_render_record_to_pydict(py, record, &self.inner)) + .collect() + } + /// Returns per-contribution render records under an explicit direct-2det /// render policy. fn contribution_render_records_with_two_detector_direct_policy( diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 3307f294c..9bd615176 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -67,7 +67,8 @@ def generate_guppy_source( 4-round CX schedule restricted to that batch's stabilizers, measure, then move to the next batch (which allocates fresh qubits whose physical slots are reused by Selene's lowering). - The same per-stabilizer ``result("...:meas:N", …)`` calls fire + Per-stabilizer counted-round ``result("...:meas:N", …)`` calls + and prep-boundary ``result("...:init:meas:N", …)`` calls fire in the abstract's batched measurement order, keeping detector record offsets transferable between abstract and traced paths. @@ -289,6 +290,128 @@ def generate_guppy_source( ], ) + def append_init_syndrome_function(function_name: str, stab_type: str) -> None: + """Append a basis-prep syndrome-establishment helper.""" + if stab_type == "X": + stabs = list(geom.x_stabilizers) + return_type = f"array[bool, {num_x_stab}]" + return_calls = ", ".join(f"sx{s.index}" for s in stabs) + doc = "Establish initial X stabilizer signs after Z-basis data prep." + else: + stabs = list(geom.z_stabilizers) + return_type = f"array[bool, {num_z_stab}]" + return_calls = ", ".join(f"sz{s.index}" for s in stabs) + doc = "Establish initial Z stabilizer signs after X-basis data prep." + + lines.extend( + [ + "", + "", + "@guppy", + f"def {function_name}(surf: SurfaceCode_{dx}x{dz}) -> {return_type}:", + f' """{doc}"""', + ], + ) + + if not constrained: + if stab_type == "X": + lines.extend(f" ax{stab.index} = qubit()" for stab in stabs) + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.extend(f" az{stab.index} = qubit()" for stab in stabs) + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] + if not filtered: + continue + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for _stab_type, stab_idx, data_q in filtered: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + + lines.append("") + lines.append(" # Measure init ancillas") + for idx, stab in enumerate(stabs): + if stab_type == "X": + lines.append(f" sx{stab.index} = measure(ax{stab.index})") + lines.append(f' result("sx{stab.index}:init:meas:{idx}", sx{stab.index})') + else: + lines.append(f" sz{stab.index} = measure(az{stab.index})") + lines.append(f' result("sz{stab.index}:init:meas:{idx}", sz{stab.index})') + else: + batches = batched_stabilizers(patch, effective_budget) + idx = 0 + for batch_idx, batch in enumerate(batches): + init_batch = [(t, i) for t, i in batch if t == stab_type] + if not init_batch: + continue + lines.append("") + lines.append(f" # Batch {batch_idx + 1}/{len(batches)} of {stab_type} stabilizers") + + batch_anc_var: dict[tuple[str, int], str] = {} + for pos, (selected_type, stab_idx) in enumerate(init_batch): + var = f"_init_a_b{batch_idx}_p{pos}" + batch_anc_var[(selected_type, stab_idx)] = var + lines.append(f" {var} = qubit()") + + if stab_type == "X": + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + + batch_keys = set(batch_anc_var.keys()) + for rnd_idx, rnd_gates in enumerate(rounds): + rnd_in_batch = [ + (selected_type, stab_idx, data_q) + for selected_type, stab_idx, data_q in rnd_gates + if (selected_type, stab_idx) in batch_keys + ] + if not rnd_in_batch: + continue + lines.append("") + lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") + for selected_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(selected_type, stab_idx)] + if selected_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + + lines.append("") + lines.append(f" # Measure init batch {batch_idx + 1} ancillas") + for selected_type, stab_idx in init_batch: + anc = batch_anc_var[(selected_type, stab_idx)] + syn_var = f"sx{stab_idx}" if selected_type == "X" else f"sz{stab_idx}" + lines.append(f" {syn_var} = measure({anc})") + lines.append(f' result("{syn_var}:init:meas:{idx}", {syn_var})') + idx += 1 + + lines.extend( + [ + "", + f" return array({return_calls})", + ], + ) + + append_init_syndrome_function("init_z_basis", "X") + append_init_syndrome_function("init_x_basis", "Z") + # Generate measurement lines.extend( [ @@ -359,6 +482,8 @@ def generate_guppy_source( " def memory_z() -> None:", f' """Z-basis memory experiment for dx={dx}, dz={dz}."""', " surf = prep_z_basis()", + " init_syn = init_z_basis(surf)", + ' result("init_synx", init_syn)', "", " for _t in range(comptime(num_rounds)):", " syn = syndrome_extraction(surf)", @@ -379,6 +504,8 @@ def generate_guppy_source( " def memory_x() -> None:", f' """X-basis memory experiment for dx={dx}, dz={dz}."""', " surf = prep_x_basis()", + " init_syn = init_x_basis(surf)", + ' result("init_synz", init_syn)', "", " for _t in range(comptime(num_rounds)):", " syn = syndrome_extraction(surf)", diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index e7ae7fd45..e4f1a9a46 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -210,6 +210,7 @@ def from_guppy( scalar ``result(tag, measure(q))`` in straight-line programs; the runtime-loop case (per-occurrence binding) remains deferred. """ + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import trace_guppy_into_tick_circuit # Tag-referenced detectors require the compiled HUGR (to recover the @@ -240,11 +241,10 @@ def from_guppy( tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed, runtime=runtime) # Compilation passes required for traced QIS circuits before fault - # analysis: normalize parameterized Clifford rotations to named gates - # and stamp stable MeasIds onto measurement gates. After this every - # MZ carries the stable id the Rust builder resolves meas_ids against. - tc.lower_clifford_rotations() - tc.assign_missing_meas_ids() + # analysis: normalize parameterized Clifford rotations to named gates, + # stamp stable MeasIds onto measurement gates, and fail loudly if raw + # traced-QIS rotations survived normalization. + normalize_traced_qis_tick_circuit(tc, context="DetectorErrorModel.from_guppy") # Resolve `result_tags` -> record offsets via Rust (sound HUGR # extraction + runtime-loop guard via static-vs-traced measurement diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b748a5eea..e7e4e5862 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -161,8 +161,9 @@ def build_surface_code_circuit( This generates the circuit structure matching the Guppy implementation: 1. prep_{basis}_basis: Allocate and prepare data qubits - 2. syndrome_extraction x num_rounds: Syndrome extraction with fresh ancillas - 3. measure_{basis}_basis: Final data qubit measurement + 2. init syndrome establishment for the random-sign stabilizer family + 3. syndrome_extraction x num_rounds: Syndrome extraction with fresh ancillas + 4. measure_{basis}_basis: Final data qubit measurement Args: patch: Surface code patch with geometry @@ -240,6 +241,152 @@ def z_anc_q(stab_idx: int) -> int: ops.append(SurfaceCircuitStep(OpType.TICK)) + # ========================================================================= + # init_{basis}_basis syndrome establishment + # ========================================================================= + # Data prep fixes only the stabilizers matching the memory basis. Measure + # the complementary stabilizer family once to establish its random signs; + # this is logical state prep and is intentionally not counted in + # `num_rounds`. + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + ops.append( + SurfaceCircuitStep( + OpType.COMMENT, + label=f"init_{init_stabilizer_type.lower()}_syndrome", + ), + ) + if effective_ancilla_budget == total_ancilla: + init_stabilizers = geom.x_stabilizers if init_stabilizer_type == "X" else geom.z_stabilizers + init_anc_q = x_anc_q if init_stabilizer_type == "X" else z_anc_q + + ops.extend( + SurfaceCircuitStep( + OpType.ALLOC, + [init_anc_q(s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in init_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cx_round in enumerate(cnot_rounds): + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + for stab_type, stab_idx, data_idx in cx_round: + if stab_type != init_stabilizer_type: + continue + if stab_type == "X": + ops.append( + SurfaceCircuitStep( + OpType.CX, + [x_anc_q(stab_idx), data_q(data_idx)], + f"X{stab_idx}", + ), + ) + else: + ops.append( + SurfaceCircuitStep( + OpType.CX, + [data_q(data_idx), z_anc_q(stab_idx)], + f"Z{stab_idx}", + ), + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in init_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + init_label_prefix = "sx" if init_stabilizer_type == "X" else "sz" + ops.extend( + SurfaceCircuitStep( + OpType.MEASURE, + [init_anc_q(s.index)], + f"{init_label_prefix}{s.index}", + ) + for s in init_stabilizers + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + else: + stabilizer_batches = _batched_stabilizers(patch, effective_ancilla_budget) + for batch in stabilizer_batches: + init_batch = [(stab_type, stab_idx) for stab_type, stab_idx in batch if stab_type == init_stabilizer_type] + if not init_batch: + continue + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Prepare ancillas")) + batch_ancillas = { + (stab_type, stab_idx): x_anc_q(stab_idx) if stab_type == "X" else z_anc_q(stab_idx) + for stab_type, stab_idx in init_batch + } + + for stab_type, stab_idx in init_batch: + ops.append( + SurfaceCircuitStep( + OpType.ALLOC, + [batch_ancillas[(stab_type, stab_idx)]], + f"a{stab_type.lower()}{stab_idx}", + ), + ) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend( + SurfaceCircuitStep(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") + for _stab_type, stab_idx in init_batch + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cx_round in enumerate(cnot_rounds): + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + for stab_type, stab_idx, data_idx in cx_round: + ancilla_q = batch_ancillas.get((stab_type, stab_idx)) + if ancilla_q is None: + continue + if stab_type == "X": + ops.append( + SurfaceCircuitStep( + OpType.CX, + [ancilla_q, data_q(data_idx)], + f"X{stab_idx}", + ), + ) + else: + ops.append( + SurfaceCircuitStep( + OpType.CX, + [data_q(data_idx), ancilla_q], + f"Z{stab_idx}", + ), + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + if init_stabilizer_type == "X": + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.extend( + SurfaceCircuitStep(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") + for _stab_type, stab_idx in init_batch + ) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + for stab_type, stab_idx in init_batch: + measure_label = f"sx{stab_idx}" if stab_type == "X" else f"sz{stab_idx}" + ops.append( + SurfaceCircuitStep( + OpType.MEASURE, + [batch_ancillas[(stab_type, stab_idx)]], + measure_label, + ), + ) + + ops.append(SurfaceCircuitStep(OpType.TICK)) + # ========================================================================= # syndrome_extraction (called num_rounds times) # ========================================================================= @@ -569,10 +716,11 @@ def render( lines.append("") lines.append("# Detectors") - # Determine which stabilizer types are deterministic in round 0 - # Z-basis: Z stabilizers are deterministic (eigenvalue +1 on |0>) - # X-basis: X stabilizers are deterministic (eigenvalue +1 on |+>) + # Data prep fixes stabilizers matching the memory basis. The + # complementary family is random but has an explicit init + # measurement, which round 0 compares against. deterministic_type_round0 = "Z" if basis.upper() == "Z" else "X" + init_baseline_type = "X" if basis.upper() == "Z" else "Z" # Syndrome detectors for X stabilizers for rnd in range(num_rounds): @@ -583,12 +731,16 @@ def render( curr_offset = meas_count - curr_idx if rnd == 0: - # Only X stabilizers have deterministic round-0 detectors in X-basis - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_idx = stab_meas_record[("X", s.index, -1)] + init_offset = meas_count - init_idx + lines.append( + f"DETECTOR({s.index}, 0, {rnd}) rec[{-curr_offset}] rec[{-init_offset}]", + ) + elif deterministic_type_round0 == "X": lines.append( f"DETECTOR({s.index}, 0, {rnd}) rec[{-curr_offset}]", ) - # In Z-basis, X stabilizers are random in round 0, skip single-record detector else: # Compare consecutive rounds (always valid) prev_idx = stab_meas_record[("X", s.index, rnd - 1)] @@ -607,12 +759,16 @@ def render( det_x = num_x_anc + s.index if rnd == 0: - # Only Z stabilizers have deterministic round-0 detectors in Z-basis - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_idx = stab_meas_record[("Z", s.index, -1)] + init_offset = meas_count - init_idx + lines.append( + f"DETECTOR({det_x}, 1, {rnd}) rec[{-curr_offset}] rec[{-init_offset}]", + ) + elif deterministic_type_round0 == "Z": lines.append( f"DETECTOR({det_x}, 1, {rnd}) rec[{-curr_offset}]", ) - # In X-basis, Z stabilizers are random in round 0, skip single-record detector else: # Compare consecutive rounds (always valid) prev_idx = stab_meas_record[("Z", s.index, rnd - 1)] @@ -634,13 +790,15 @@ def render( for s in stabilizers: data_rec_offsets = [meas_count - (final_meas_start + dq) for dq in s.data_qubits] - last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] - syn_offset = meas_count - last_syn_idx - rec_str = " ".join(f"rec[{-off}]" for off in data_rec_offsets) + record_offsets = [*data_rec_offsets] + if num_rounds > 0: + last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] + record_offsets.append(meas_count - last_syn_idx) + rec_str = " ".join(f"rec[{-off}]" for off in record_offsets) det_x = s.index if stab_type == "X" else num_x_anc + s.index det_y = 0 if stab_type == "X" else 1 lines.append( - f"DETECTOR({det_x}, {det_y}, {num_rounds}) {rec_str} rec[{-syn_offset}]", + f"DETECTOR({det_x}, {det_y}, {num_rounds}) {rec_str}", ) # Logical observable @@ -930,6 +1088,15 @@ def mark_qubits_used(qubits: list[int]) -> None: """Mark qubits as used in current tick.""" qubits_in_current_tick.update(qubits) + def is_syndrome_context(phase: str, round_index: int) -> bool: + """Return whether the current context belongs to syndrome extraction.""" + if round_index >= 0 or phase.startswith("init_syndrome"): + return True + return round_index == -1 and ( + phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} + or phase.startswith("cx_round_") + ) + def gate_metadata(meta: dict | None = None) -> dict: """Build metadata for the current gate context. @@ -939,7 +1106,7 @@ def gate_metadata(meta: dict | None = None) -> dict: context: dict[str, object] = { "phase": current_phase, } - if current_round >= 0: + if is_syndrome_context(current_phase, current_round): context["syndrome_round"] = current_round if current_cx_round > 0: context["cx_round"] = current_cx_round @@ -966,11 +1133,19 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non current_round = int(op.label.split()[-1]) - 1 current_phase = "syndrome_prep" current_cx_round = 0 + elif "init_" in op.label and "syndrome" in op.label: + current_round = -1 + current_phase = "init_syndrome_prep" + current_cx_round = 0 elif "Prepare ancillas" in op.label: - current_phase = "syndrome_prep" + current_phase = "init_syndrome_prep" if current_round < 0 else "syndrome_prep" current_cx_round = 0 elif "Hadamard on X ancillas" in op.label: - current_phase = "syndrome_h_pre" if current_phase == "syndrome_prep" else "syndrome_h_post" + current_phase = ( + "syndrome_h_pre" + if current_phase in {"syndrome_prep", "init_syndrome_prep"} + else "syndrome_h_post" + ) elif "CX round" in op.label: current_cx_round = int(op.label.split()[-1]) current_phase = f"cx_round_{current_cx_round}" @@ -1080,7 +1255,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non for tick_idx, tick_meta in all_tick_metadata.items(): # Set tick-level metadata circuit.set_tick_meta(tick_idx, "phase", tick_meta["phase"]) - if tick_meta["round"] >= 0: + if is_syndrome_context(str(tick_meta["phase"]), int(tick_meta["round"])): circuit.set_tick_meta(tick_idx, "syndrome_round", tick_meta["round"]) if tick_meta["cx_round"] > 0: circuit.set_tick_meta(tick_idx, "cx_round", tick_meta["cx_round"]) @@ -1090,6 +1265,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non geom = patch.geometry num_x_anc = len(geom.x_stabilizers) deterministic_type_round0 = "Z" if basis.upper() == "Z" else "X" + init_baseline_type = "X" if basis.upper() == "Z" else "Z" detectors = [] detector_id = 0 @@ -1103,7 +1279,18 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non curr_offset = meas_count - curr_idx if rnd == 0: - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_idx = stab_meas_record[("X", s.index, -1)] + init_offset = meas_count - init_idx + detectors.append( + { + "id": detector_id, + "coords": [s.index, 0, rnd], + "records": [-curr_offset, -init_offset], + }, + ) + detector_id += 1 + elif deterministic_type_round0 == "X": detectors.append( { "id": detector_id, @@ -1134,7 +1321,18 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non det_x = num_x_anc + s.index if rnd == 0: - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_idx = stab_meas_record[("Z", s.index, -1)] + init_offset = meas_count - init_idx + detectors.append( + { + "id": detector_id, + "coords": [det_x, 1, rnd], + "records": [-curr_offset, -init_offset], + }, + ) + detector_id += 1 + elif deterministic_type_round0 == "Z": detectors.append( { "id": detector_id, @@ -1167,15 +1365,17 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non for s in stabilizers: data_rec_offsets = [-(meas_count - (final_meas_start + dq)) for dq in s.data_qubits] - last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] - syn_offset = -(meas_count - last_syn_idx) + records = [*data_rec_offsets] + if num_rounds > 0: + last_syn_idx = stab_meas_record[(stab_type, s.index, num_rounds - 1)] + records.append(-(meas_count - last_syn_idx)) det_x = s.index if stab_type == "X" else num_x_anc + s.index det_y = 0 if stab_type == "X" else 1 detectors.append( { "id": detector_id, "coords": [det_x, det_y, num_rounds], - "records": [*data_rec_offsets, syn_offset], + "records": records, }, ) detector_id += 1 @@ -1204,6 +1404,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non stab_meas_refs, final_meas_refs_by_qubit, deterministic_type_round0, + init_baseline_type, ) circuit.set_meta("basis", basis.upper()) circuit.set_meta("ancilla_budget", str(allocation.total - len(allocation.data_qubits))) @@ -1219,6 +1420,7 @@ def _add_typed_annotations( stab_meas_refs: dict, final_meas_refs_by_qubit: dict, deterministic_type_round0: str, + init_baseline_type: str, ) -> None: """Add typed PauliAnnotation detectors and observables to the circuit. @@ -1232,7 +1434,10 @@ def _add_typed_annotations( if curr_refs is None: continue if rnd == 0: - if deterministic_type_round0 == "X": + if init_baseline_type == "X": + init_refs = stab_meas_refs.get(("X", s.index, -1), []) + circuit.detector(init_refs + curr_refs, label=f"Sx{s.index}_r{rnd}") + elif deterministic_type_round0 == "X": circuit.detector(curr_refs, label=f"Sx{s.index}_r{rnd}") else: prev_refs = stab_meas_refs.get(("X", s.index, rnd - 1), []) @@ -1245,7 +1450,10 @@ def _add_typed_annotations( if curr_refs is None: continue if rnd == 0: - if deterministic_type_round0 == "Z": + if init_baseline_type == "Z": + init_refs = stab_meas_refs.get(("Z", s.index, -1), []) + circuit.detector(init_refs + curr_refs, label=f"Sz{s.index}_r{rnd}") + elif deterministic_type_round0 == "Z": circuit.detector(curr_refs, label=f"Sz{s.index}_r{rnd}") else: prev_refs = stab_meas_refs.get(("Z", s.index, rnd - 1), []) @@ -1409,6 +1617,101 @@ def generate_tick_circuit_from_patch( return renderer.render(ops, allocation, patch, num_rounds, basis) +def normalize_traced_qis_tick_circuit( + tick_circuit: object, + *, + context: str = "traced-QIS DEM construction", +) -> None: + """Normalize a traced-QIS TickCircuit before DEM/DAG analysis. + + Selene/QIS traces may contain parameterized Clifford rotations such as + ``RZZ(pi/2)``. Fault analysis and replacement-branch noise models operate + on the named Clifford gates (``SZZ`` / ``SZZdg``), so callers should run + this helper at every traced-QIS boundary before converting to a DAG. + """ + _call_required_tick_circuit_method(tick_circuit, "lower_clifford_rotations", context) + _call_required_tick_circuit_method(tick_circuit, "assign_missing_meas_ids", context) + assert_traced_qis_tick_circuit_dem_ready(tick_circuit, context=context) + + +def assert_traced_qis_tick_circuit_dem_ready( + tick_circuit: object, + *, + context: str = "traced-QIS DEM construction", +) -> None: + """Fail loudly if raw traced-QIS rotations survived normalization.""" + offenders = _raw_traced_qis_rzz_gates(tick_circuit, context=context) + if not offenders: + return + + preview = "; ".join(offenders[:5]) + suffix = f"; ... {len(offenders) - 5} more" if len(offenders) > 5 else "" + msg = ( + f"{context}: traced-QIS circuit still contains raw RZZ gates after Clifford " + "normalization. DEM/DAG analysis expects Clifford RZZ(pi/2) and " + "RZZ(-pi/2) gates to be lowered to SZZ/SZZdg before noise attachment " + "and fault propagation. Call normalize_traced_qis_tick_circuit(...) " + "before to_dag_circuit(), or extend lower_clifford_rotations() for the " + f"runtime-emitted angle. First offending gates: {preview}{suffix}" + ) + raise ValueError(msg) + + +def _call_required_tick_circuit_method(tick_circuit: object, method_name: str, context: str) -> None: + method = getattr(tick_circuit, method_name, None) + if not callable(method): + msg = f"{context}: expected a TickCircuit with callable {method_name}()." + raise TypeError(msg) + method() + + +def _raw_traced_qis_rzz_gates(tick_circuit: object, *, context: str) -> list[str]: + try: + num_ticks = int(tick_circuit.num_ticks()) # type: ignore[attr-defined] + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with num_ticks() before DEM/DAG analysis." + raise TypeError(msg) from exc + + offenders: list[str] = [] + for tick_index in range(num_ticks): + try: + tick = tick_circuit.get_tick(tick_index) # type: ignore[attr-defined] + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with get_tick() before DEM/DAG analysis." + raise TypeError(msg) from exc + try: + gate_batches = tick.gate_batches() + except AttributeError as exc: + msg = f"{context}: expected TickCircuit ticks with gate_batches() before DEM/DAG analysis." + raise TypeError(msg) from exc + for gate_index, gate in enumerate(gate_batches): + if _gate_type_name(gate) != "RZZ": + continue + qubits = [int(q) for q in getattr(gate, "qubits", [])] + offenders.append( + f"tick={tick_index} gate={gate_index} qubits={qubits} angles={_gate_angles_for_message(gate)}", + ) + return offenders + + +def _gate_type_name(gate: object) -> str: + gate_type = getattr(gate, "gate_type", "") + return str(getattr(gate_type, "name", str(gate_type).rsplit(".", maxsplit=1)[-1])) + + +def _gate_angles_for_message(gate: object) -> list[str]: + angles = getattr(gate, "angles", None) + if angles is None: + angles = getattr(gate, "params", []) + formatted = [] + for angle in angles: + try: + formatted.append(repr(float(angle))) + except (TypeError, ValueError): + formatted.append(repr(angle)) + return formatted + + def get_detector_descriptors_from_tick_circuit( tick_circuit: TickCircuit, patch: SurfacePatch, @@ -2299,6 +2602,9 @@ def generate_dem_from_tick_circuit( if maximal_decomposition: return _maximally_decompose_graphlike_dem(dem.to_string_decomposed()) if decompose_errors: + source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) + if source_graphlike is not None: + return source_graphlike() return dem.to_string_decomposed() return dem.to_string() diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8f474c42d..46a05888e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -573,90 +573,189 @@ def _remap_surface_record_metadata_json( def _surface_runtime_measurement_remap_from_result_traces( - patch: SurfacePatch, - num_rounds: int, + abstract_tc: Any, result_traces: list[dict[str, Any]], ) -> dict[int, int]: """Map abstract surface measurement indices to runtime ``result_id``s. - The generated surface Guppy emits scalar ``result("sx*/sz*:meas:N", bit)`` - calls for stabilizer measurements and one ``result("final", array(...))`` - call for data readout. Those tags survive runtime scheduling changes and - are the stable detector/observable anchor. Aggregate ``synx``/``synz`` tags - are deliberately ignored because they reread existing futures. + The generated surface Guppy emits scalar counted-round + ``result("sx*/sz*:meas:N", bit)`` tags, prep-boundary + ``result("sx*/sz*:init:meas:N", bit)`` tags, and one + ``result("final", array(...))`` call for data readout. The abstract + TickCircuit labels each measurement with the result tag it should bind to. + Those tags survive runtime scheduling changes and are the stable + detector/observable anchor. """ - import re - from collections import defaultdict - - syndrome_per_round = len(patch.geometry.x_stabilizers) + len(patch.geometry.z_stabilizers) - expected_syndrome_measurements = syndrome_per_round * num_rounds - expected_measurements = expected_syndrome_measurements + patch.geometry.num_data + num_measurements = int(abstract_tc.get_meta("num_measurements")) + scalar_trace_ids, array_trace_ids = _index_surface_result_trace_ids(result_traces) + abstract_refs = _surface_abstract_measurement_result_refs(abstract_tc) + if len(abstract_refs) != num_measurements: + msg = f"expected {num_measurements} abstract measurement refs, got {len(abstract_refs)}" + raise ValueError(msg) - scalar_tag_re = re.compile(r"^s[xz]\d+:meas:(\d+)$") - occurrence_by_tag: defaultdict[str, int] = defaultdict(int) + occurrence_by_tag: dict[str, int] = {} remap: dict[int, int] = {} - final_seen = False + for abstract_index, ref in enumerate(abstract_refs): + if ref[0] == "scalar": + _, name = ref + occurrence = occurrence_by_tag.get(name, 0) + occurrence_by_tag[name] = occurrence + 1 + try: + remap[abstract_index] = scalar_trace_ids[name][occurrence] + except (KeyError, IndexError) as exc: + msg = f"result tag {name!r} occurrence {occurrence} is missing from the runtime trace" + raise ValueError(msg) from exc + else: + _, name, element = ref + try: + remap[abstract_index] = array_trace_ids[name][0][element] + except (KeyError, IndexError) as exc: + msg = f"result tag {name!r}[{element}] is missing from the runtime trace" + raise ValueError(msg) from exc + runtime_ids = sorted(remap.values()) + if runtime_ids != list(range(num_measurements)): + msg = ( + "Runtime result-tag provenance is not a dense measurement-id range " + f"0..{num_measurements - 1}; got first/last " + f"{runtime_ids[:3]}...{runtime_ids[-3:]}" + ) + raise ValueError(msg) + return remap + + +def _index_surface_result_trace_ids( + result_traces: Sequence[Mapping[str, Any]], +) -> tuple[dict[str, list[int]], dict[str, list[list[int]]]]: + """Index runtime named-result provenance by tag name.""" + scalar_trace_ids: dict[str, list[int]] = {} + array_trace_ids: dict[str, list[list[int]]] = {} for trace in result_traces: name = trace.get("name") - result_ids = trace.get("result_ids") or [] - values = trace.get("values") or [] - if not isinstance(name, str): + values = trace.get("values") + result_ids = trace.get("result_ids") + if not isinstance(name, str) or not isinstance(values, list) or not isinstance(result_ids, list): continue + if len(values) != len(result_ids): + msg = ( + f"runtime result tag {name!r} has {len(values)} value(s) but " + f"{len(result_ids)} result id(s); cannot bind surface metadata" + ) + raise ValueError(msg) + ids = [int(result_id) for result_id in result_ids] + is_scalar_syndrome_tag = name.startswith(("sx", "sz")) and ":meas:" in name + if is_scalar_syndrome_tag and len(ids) == 1: + scalar_trace_ids.setdefault(name, []).append(ids[0]) + else: + array_trace_ids.setdefault(name, []).append(ids) + if not scalar_trace_ids and not array_trace_ids: + msg = "runtime trace does not contain named_result_traces; rebuild PECOS with result-tag provenance support" + raise ValueError(msg) + return scalar_trace_ids, array_trace_ids - match = scalar_tag_re.match(name) - if match is not None: - if len(result_ids) != 1 or len(values) != 1: - msg = f"Surface scalar result tag {name!r} must map to exactly one measurement result" - raise ValueError(msg) - meas_in_round = int(match.group(1)) - if not 0 <= meas_in_round < syndrome_per_round: - msg = ( - f"Surface scalar result tag {name!r} has per-round measurement index " - f"{meas_in_round}, outside [0, {syndrome_per_round})" - ) - raise ValueError(msg) - round_index = occurrence_by_tag[name] - occurrence_by_tag[name] += 1 - if round_index >= num_rounds: - msg = f"Surface scalar result tag {name!r} appears more than {num_rounds} round(s)" - raise ValueError(msg) - abstract_index = round_index * syndrome_per_round + meas_in_round - remap[abstract_index] = int(result_ids[0]) - elif name == "final": - if final_seen: - msg = "Surface traced result provenance has more than one final data result" - raise ValueError(msg) - final_seen = True - if len(result_ids) != patch.geometry.num_data or len(values) != patch.geometry.num_data: + +def _surface_abstract_measurement_result_refs(abstract_tc: Any) -> list[tuple[str, str] | tuple[str, str, int]]: + """Return the result-tag reference for each abstract surface measurement.""" + refs: list[tuple[str, str] | tuple[str, str, int]] = [] + syndrome_measure_index_by_round: dict[int, int] = {} + measurement_gate_types = {"MZ", "MeasureFree"} + for tick_index in range(abstract_tc.num_ticks()): + tick = abstract_tc.get_tick(tick_index) + if tick is None: + continue + for gate_index, gate in enumerate(tick.gate_batches()): + gate_type = str(getattr(gate, "gate_type", "")).rsplit(".", maxsplit=1)[-1] + if gate_type not in measurement_gate_types: + continue + label = str(abstract_tc.get_gate_meta(tick_index, gate_index, "label") or "") + if label.startswith(("sx", "sz")): + round_value = abstract_tc.get_gate_meta(tick_index, gate_index, "syndrome_round") + if round_value is None: + msg = f"surface syndrome measurement {label!r} is missing syndrome_round metadata" + raise ValueError(msg) + round_index = int(round_value) + measurement_index = syndrome_measure_index_by_round.get(round_index, 0) + syndrome_measure_index_by_round[round_index] = measurement_index + 1 + phase = "init:meas" if round_index < 0 else "meas" + refs.append(("scalar", f"{label}:{phase}:{measurement_index}")) + continue + if label.startswith("final[") and label.endswith("]"): + refs.append(("array", "final", int(label.removeprefix("final[").removesuffix("]")))) + continue + msg = f"surface measurement is missing a result-tag-compatible label: {label!r}" + raise ValueError(msg) + return refs + + +def _extract_measurement_meas_ids(tc: Any) -> list[int]: + """Return stable measurement ids in TickCircuit execution order.""" + ids: list[int] = [] + for tick_idx in range(tc.num_ticks()): + tick = tc.get_tick(tick_idx) + if tick is None: + continue + for gate in tick.gate_batches(): + gate_type = str(getattr(gate, "gate_type", "")).rsplit(".", maxsplit=1)[-1] + if gate_type not in {"MZ", "MeasureFree"}: + continue + qubits = list(getattr(gate, "qubits", [])) + meas_ids = list(getattr(gate, "meas_ids", [])) + if len(meas_ids) != len(qubits): msg = ( - "Surface final result tag must map to exactly " - f"{patch.geometry.num_data} data measurements" + f"traced measurement gate {gate_type} in tick {tick_idx} carries " + f"{len(meas_ids)} MeasId(s) for {len(qubits)} qubit(s)" ) raise ValueError(msg) - for offset, result_id in enumerate(result_ids): - remap[expected_syndrome_measurements + offset] = int(result_id) + ids.extend(int(meas_id) for meas_id in meas_ids) + return ids + - if len(remap) != expected_measurements: - missing = sorted(set(range(expected_measurements)) - set(remap)) +def _validate_result_tag_remap_against_traced_measurements( + traced_tc: Any, + measurement_index_remap: Mapping[int, int], + *, + expected_measurements: int, +) -> None: + """Fail loudly unless result-tag bindings exactly cover traced MeasIds.""" + expected_abstract_indices = list(range(expected_measurements)) + actual_abstract_indices = sorted(measurement_index_remap) + if actual_abstract_indices != expected_abstract_indices: msg = ( - "Runtime trace did not provide complete surface result-tag provenance: " - f"mapped {len(remap)}/{expected_measurements} measurements" + "runtime result-tag remap does not cover every abstract measurement; " + f"expected indices {expected_abstract_indices[:3]}...{expected_abstract_indices[-3:]}, " + f"got {actual_abstract_indices[:3]}...{actual_abstract_indices[-3:]}" ) - if missing: - msg += f"; first missing abstract measurement index {missing[0]}" raise ValueError(msg) - runtime_ids = sorted(remap.values()) - if runtime_ids != list(range(expected_measurements)): + traced_meas_ids = _extract_measurement_meas_ids(traced_tc) + if len(traced_meas_ids) != expected_measurements: msg = ( - "Runtime result-tag provenance is not a dense measurement-id range " - f"0..{expected_measurements - 1}; got first/last " - f"{runtime_ids[:3]}...{runtime_ids[-3:]}" + "traced circuit contains " + f"{len(traced_meas_ids)} measured MeasId(s), but result-tag metadata " + f"expects {expected_measurements}" + ) + raise ValueError(msg) + if len(set(traced_meas_ids)) != len(traced_meas_ids): + duplicates = sorted( + meas_id + for meas_id in set(traced_meas_ids) + if traced_meas_ids.count(meas_id) > 1 ) + msg = f"traced circuit contains duplicate measured MeasId(s): {duplicates[:8]}" raise ValueError(msg) - return remap + expected_meas_ids = sorted(int(meas_id) for meas_id in measurement_index_remap.values()) + actual_meas_ids = sorted(traced_meas_ids) + if actual_meas_ids != expected_meas_ids: + expected_set = set(expected_meas_ids) + actual_set = set(actual_meas_ids) + missing = sorted(expected_set - actual_set) + extra = sorted(actual_set - expected_set) + msg = ( + "runtime result-tag bindings do not exactly match the traced circuit's " + f"measured MeasIds; missing={missing[:8]}, extra={extra[:8]}" + ) + raise ValueError(msg) def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: @@ -1177,10 +1276,7 @@ def _build_surface_tick_circuit_for_native_model( runtime: object | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" - from pecos.qec.surface.circuit_builder import ( - _extract_measurement_order, - generate_tick_circuit_from_patch, - ) + from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1204,10 +1300,11 @@ def _build_surface_tick_circuit_for_native_model( runtime=runtime, ) - measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces( - patch, - num_rounds, - result_traces, + measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) + _validate_result_tag_remap_against_traced_measurements( + traced_tc, + measurement_index_remap, + expected_measurements=int(abstract_tc.get_meta("num_measurements")), ) _copy_surface_tick_circuit_metadata( abstract_tc, @@ -1216,24 +1313,6 @@ def _build_surface_tick_circuit_for_native_model( ) traced_tc.set_meta("surface_metadata_record_binding", "runtime_result_tags") - # Coarse sanity check: detector metadata is bound by runtime result tags, - # but still reject traces with dropped/extra/wrong measured physical qubits. - traced_measurement_order = _extract_measurement_order(traced_tc) - abstract_measurement_order = _extract_measurement_order(abstract_tc) - try: - _measurement_index_remap_for_orders( - abstract_measurement_order, - traced_measurement_order, - ) - except ValueError as exc: - msg = ( - "Traced and abstract surface circuits disagree on the measured-qubit " - "multiset (a dropped/added/wrong-qubit measurement); refusing to " - "build a native DEM/sampler from a circuit that does not match the " - "surface memory experiment" - ) - raise ValueError(msg) from exc - traced_tc.set_meta("circuit_source", circuit_source) return traced_tc @@ -1277,8 +1356,8 @@ def build_memory_circuit( """ from pecos.qec.surface.patch import SurfacePatch - if rounds < 1: - msg = f"rounds must be >= 1, got {rounds}" + if rounds < 0: + msg = f"rounds must be >= 0, got {rounds}" raise ValueError(msg) if patch is None: if distance is None: @@ -1391,7 +1470,10 @@ def _surface_native_topology( import json from pecos.qec import DagFaultAnalyzer - from pecos.qec.surface.circuit_builder import _extract_measurement_order + from pecos.qec.surface.circuit_builder import ( + _extract_measurement_order, + normalize_traced_qis_tick_circuit, + ) patch = _cached_surface_patch(patch_key) tc = _build_surface_tick_circuit_for_native_model( @@ -1402,6 +1484,11 @@ def _surface_native_topology( circuit_source=circuit_source, runtime=runtime, ) + if circuit_source == "traced_qis": + # Keep this surface helper aligned with DetectorErrorModel.from_guppy: + # traced QIS emits parameterized Clifford rotations, while DEM + # replacement-branch approximations operate on named Clifford gates. + normalize_traced_qis_tick_circuit(tc, context="surface traced-QIS native topology") if include_idle_gates: # Insert idle gates only when the requested noise model includes a # dedicated idle channel. Otherwise inserted idle gates receive ordinary @@ -1493,7 +1580,12 @@ def _dem_string_from_cached_surface_topology( .with_observables_json(topology.observables_json) .build_with_source_tracking() ) - return dem.to_string_decomposed() if decompose_errors else dem.to_string() + if not decompose_errors: + return dem.to_string() + source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) + if source_graphlike is not None: + return source_graphlike() + return dem.to_string_decomposed() @cache @@ -2646,15 +2738,18 @@ def _compute_dem_detection_events_z( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synx: NDArray[np.uint8] | None = None, ) -> NDArray[np.uint8]: """Compute full detection events for Z-basis DEM-based decoding. The circuit-level DEM defines detectors in this order: - 1. X stabilizer detectors for rounds 1..num_rounds-1 - (X stabs are non-deterministic at round 0 for Z-basis) + 1. X stabilizer detectors for rounds 0..num_rounds-1 + (round 0 compares against the init X-syndrome baseline when present) 2. Z stabilizer detectors for rounds 0..num_rounds-1 (round 0 is deterministic for Z-basis) - 3. Final round detectors: last Z syndrome vs final data parity + 3. Final detectors: last known Z syndrome vs final data parity + (for r=0, the known Z syndrome is the deterministic prep sign) Args: synx_list: X syndrome arrays, one per round @@ -2667,22 +2762,36 @@ def _compute_dem_detection_events_z( geom = self.patch.geometry synx = np.array(synx_list, dtype=np.uint8) synz = np.array(synz_list, dtype=np.uint8) + if self.num_rounds > 0 and init_synx is None: + msg = ( + "Z-basis circuit-level DEM decoding requires init_synx, the prep-baseline " + "X syndrome measured before counted syndrome-extraction rounds." + ) + raise ValueError(msg) events: list[int] = [] - # 1. X stabilizer detection events (rounds 1 to num_rounds-1) - for r in range(1, self.num_rounds): - events.extend((synx[r] ^ synx[r - 1]).tolist()) + if self.num_rounds > 0: + assert init_synx is not None + init_synx_array = np.array(init_synx, dtype=np.uint8) + if init_synx_array.shape != synx[0].shape: + msg = f"init_synx has shape {init_synx_array.shape}, expected {synx[0].shape}" + raise ValueError(msg) + + # 1. X stabilizer detection events + events.extend((synx[0] ^ init_synx_array).tolist()) + for r in range(1, self.num_rounds): + events.extend((synx[r] ^ synx[r - 1]).tolist()) - # 2. Z stabilizer detection events (all rounds) - events.extend(synz[0].tolist()) # round 0: compare to expected 0 - for r in range(1, self.num_rounds): - events.extend((synz[r] ^ synz[r - 1]).tolist()) + # 2. Z stabilizer detection events (all rounds) + events.extend(synz[0].tolist()) # round 0: compare to expected 0 + for r in range(1, self.num_rounds): + events.extend((synz[r] ^ synz[r - 1]).tolist()) - # 3. Final round: parity of final data on each Z stabilizer XOR last syndrome + # 3. Final readout: final Z parity XOR the last known Z syndrome. for stab in geom.z_stabilizers: data_parity = sum(int(final[q]) for q in stab.data_qubits) % 2 - last_syn = int(synz[-1][stab.index]) + last_syn = int(synz[-1][stab.index]) if self.num_rounds > 0 else 0 events.append((data_parity ^ last_syn) & 1) return np.array(events, dtype=np.uint8) @@ -2692,15 +2801,18 @@ def _compute_dem_detection_events_x( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synz: NDArray[np.uint8] | None = None, ) -> NDArray[np.uint8]: """Compute full detection events for X-basis DEM-based decoding. The circuit-level DEM defines detectors in this order: 1. X stabilizer detectors for rounds 0..num_rounds-1 (X stabs are deterministic at round 0 for X-basis) - 2. Z stabilizer detectors for rounds 1..num_rounds-1 - (Z stabs are non-deterministic at round 0 for X-basis) - 3. Final round detectors: last X syndrome vs final data parity + 2. Z stabilizer detectors for rounds 0..num_rounds-1 + (round 0 compares against the init Z-syndrome baseline when present) + 3. Final detectors: last known X syndrome vs final data parity + (for r=0, the known X syndrome is the deterministic prep sign) Args: synx_list: X syndrome arrays, one per round @@ -2713,22 +2825,36 @@ def _compute_dem_detection_events_x( geom = self.patch.geometry synx = np.array(synx_list, dtype=np.uint8) synz = np.array(synz_list, dtype=np.uint8) + if self.num_rounds > 0 and init_synz is None: + msg = ( + "X-basis circuit-level DEM decoding requires init_synz, the prep-baseline " + "Z syndrome measured before counted syndrome-extraction rounds." + ) + raise ValueError(msg) events: list[int] = [] - # 1. X stabilizer detection events (all rounds) - events.extend(synx[0].tolist()) # round 0: compare to expected 0 - for r in range(1, self.num_rounds): - events.extend((synx[r] ^ synx[r - 1]).tolist()) + if self.num_rounds > 0: + assert init_synz is not None + init_synz_array = np.array(init_synz, dtype=np.uint8) + if init_synz_array.shape != synz[0].shape: + msg = f"init_synz has shape {init_synz_array.shape}, expected {synz[0].shape}" + raise ValueError(msg) + + # 1. X stabilizer detection events (all rounds) + events.extend(synx[0].tolist()) # round 0: compare to expected 0 + for r in range(1, self.num_rounds): + events.extend((synx[r] ^ synx[r - 1]).tolist()) - # 2. Z stabilizer detection events (rounds 1 to num_rounds-1) - for r in range(1, self.num_rounds): - events.extend((synz[r] ^ synz[r - 1]).tolist()) + # 2. Z stabilizer detection events + events.extend((synz[0] ^ init_synz_array).tolist()) + for r in range(1, self.num_rounds): + events.extend((synz[r] ^ synz[r - 1]).tolist()) - # 3. Final round: parity of final data on each X stabilizer XOR last syndrome + # 3. Final readout: final X parity XOR the last known X syndrome. for stab in geom.x_stabilizers: data_parity = sum(int(final[q]) for q in stab.data_qubits) % 2 - last_syn = int(synx[-1][stab.index]) + last_syn = int(synx[-1][stab.index]) if self.num_rounds > 0 else 0 events.append((data_parity ^ last_syn) & 1) return np.array(events, dtype=np.uint8) @@ -2738,6 +2864,8 @@ def decode_memory_z( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synx: NDArray[np.uint8] | None = None, ) -> tuple[bool, DecodingResult]: """Decode a Z-basis memory experiment. @@ -2759,6 +2887,8 @@ def decode_memory_z( synx_list: List of X syndrome arrays, one per round synz_list: List of Z syndrome arrays, one per round final: Final data qubit measurements + init_synx: Optional prep-baseline X syndrome for the random + stabilizer signs established before counted Z-memory rounds. Returns: (is_logical_error, decoding_result) @@ -2772,7 +2902,7 @@ def decode_memory_z( DecoderType.PYMATCHING, DecoderType.TESSERACT, ): - events = self._compute_dem_detection_events_z(synx_list, synz_list, final) + events = self._compute_dem_detection_events_z(synx_list, synz_list, final, init_synx=init_synx) events_flat = events.ravel().astype(np.uint8) decoder = self._get_z_decoder() @@ -2833,6 +2963,8 @@ def decode_memory_x( synx_list: list[NDArray[np.uint8]], synz_list: list[NDArray[np.uint8]], final: NDArray[np.uint8], + *, + init_synz: NDArray[np.uint8] | None = None, ) -> tuple[bool, DecodingResult]: """Decode an X-basis memory experiment. @@ -2854,6 +2986,8 @@ def decode_memory_x( synx_list: List of X syndrome arrays, one per round synz_list: List of Z syndrome arrays, one per round final: Final data qubit measurements + init_synz: Optional prep-baseline Z syndrome for the random + stabilizer signs established before counted X-memory rounds. Returns: (is_logical_error, decoding_result) @@ -2867,7 +3001,7 @@ def decode_memory_x( DecoderType.PYMATCHING, DecoderType.TESSERACT, ): - events = self._compute_dem_detection_events_x(synx_list, synz_list, final) + events = self._compute_dem_detection_events_x(synx_list, synz_list, final, init_synz=init_synz) events_flat = events.ravel().astype(np.uint8) decoder = self._get_x_decoder() @@ -3082,8 +3216,8 @@ def surface_code_memory( msg = f"shots must be >= 0, got {shots}" raise ValueError(msg) num_rounds = distance if rounds is None else rounds - if num_rounds < 1: - msg = f"rounds must be >= 1, got {num_rounds}" + if num_rounds < 0: + msg = f"rounds must be >= 0, got {num_rounds}" raise ValueError(msg) noise_model = _memory_noise_model(physical_error_rate, noise_model) diff --git a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py index 0d1f815a7..490e373fd 100644 --- a/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py +++ b/python/quantum-pecos/tests/pecos/decoders/test_decoder_bindings.py @@ -157,6 +157,16 @@ def test_manual_graph_construction(self) -> None: assert decoder.num_nodes >= 3 assert decoder.num_edges >= 4 + def test_from_dem_with_correlations(self) -> None: + """Test construction from DEM with decomposition correlations enabled.""" + from pecos_rslib.decoders import PyMatchingDecoder + + dem = "error(0.1) D0 D1 ^ D2 L0" + decoder = PyMatchingDecoder.from_dem_with_correlations(dem) + + result = decoder.decode([0, 0, 0]) + assert result.correction == [0] + class TestFusionBlossomDecoder: """Tests for FusionBlossomDecoder (mirrors fusion_blossom).""" diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index 88982a279..4df6535ab 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -249,8 +249,14 @@ def test_decode_trivial_syndrome_z(self) -> None: synx_list = [np.zeros(num_x_stab, dtype=np.uint8)] synz_list = [np.zeros(num_z_stab, dtype=np.uint8)] final = np.zeros(patch.num_data, dtype=np.uint8) + init_synx = np.zeros(num_x_stab, dtype=np.uint8) - is_error, _result = decoder.decode_memory_z(synx_list, synz_list, final) + is_error, _result = decoder.decode_memory_z( + synx_list, + synz_list, + final, + init_synx=init_synx, + ) # No errors should be detected assert not is_error @@ -267,12 +273,80 @@ def test_decode_trivial_syndrome_x(self) -> None: synx_list = [np.zeros(num_x_stab, dtype=np.uint8)] synz_list = [np.zeros(num_z_stab, dtype=np.uint8)] final = np.zeros(patch.num_data, dtype=np.uint8) + init_synz = np.zeros(num_z_stab, dtype=np.uint8) - is_error, _result = decoder.decode_memory_x(synx_list, synz_list, final) + is_error, _result = decoder.decode_memory_x( + synx_list, + synz_list, + final, + init_synz=init_synz, + ) # No errors should be detected assert not is_error + def test_dem_detection_events_require_prep_baseline(self) -> None: + """Circuit-level DEM event construction should fail loudly without prep baselines.""" + patch = SurfacePatch.create(distance=3) + decoder = SurfaceDecoder(patch, num_rounds=2) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + synx_list = [np.zeros(num_x_stab, dtype=np.uint8) for _ in range(2)] + synz_list = [np.zeros(num_z_stab, dtype=np.uint8) for _ in range(2)] + final = np.zeros(patch.num_data, dtype=np.uint8) + + with pytest.raises(ValueError, match="requires init_synx"): + decoder._compute_dem_detection_events_z(synx_list, synz_list, final) + with pytest.raises(ValueError, match="requires init_synz"): + decoder._compute_dem_detection_events_x(synx_list, synz_list, final) + + def test_dem_detection_events_count_only_syndrome_rounds_as_duration(self) -> None: + """Prep baselines and destructive readout should not add counted syndrome rounds.""" + patch = SurfacePatch.create(distance=3) + rounds = 2 + decoder = SurfaceDecoder(patch, num_rounds=rounds) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + synx_list = [np.zeros(num_x_stab, dtype=np.uint8) for _ in range(rounds)] + synz_list = [np.zeros(num_z_stab, dtype=np.uint8) for _ in range(rounds)] + final = np.zeros(patch.num_data, dtype=np.uint8) + + z_events = decoder._compute_dem_detection_events_z( + synx_list, + synz_list, + final, + init_synx=np.zeros(num_x_stab, dtype=np.uint8), + ) + x_events = decoder._compute_dem_detection_events_x( + synx_list, + synz_list, + final, + init_synz=np.zeros(num_z_stab, dtype=np.uint8), + ) + + assert z_events.shape == (rounds * (num_x_stab + num_z_stab) + num_z_stab,) + assert x_events.shape == (rounds * (num_x_stab + num_z_stab) + num_x_stab,) + + def test_zero_round_dem_events_use_readout_against_prep_boundary(self) -> None: + """r=0 still has terminal detectors from final readout versus prep signs.""" + patch = SurfacePatch.create(distance=3) + decoder = SurfaceDecoder(patch, num_rounds=0) + num_x_stab = len(patch.geometry.x_stabilizers) + num_z_stab = len(patch.geometry.z_stabilizers) + + z_final = np.zeros(patch.num_data, dtype=np.uint8) + z_final[patch.geometry.z_stabilizers[0].data_qubits[0]] = 1 + x_final = np.zeros(patch.num_data, dtype=np.uint8) + x_final[patch.geometry.x_stabilizers[0].data_qubits[0]] = 1 + + z_events = decoder._compute_dem_detection_events_z([], [], z_final) + x_events = decoder._compute_dem_detection_events_x([], [], x_final) + + assert z_events.shape == (num_z_stab,) + assert x_events.shape == (num_x_stab,) + assert z_events[0] == 1 + assert x_events[0] == 1 + class TestDemGeneration: """Tests for DEM generation functions.""" @@ -528,6 +602,30 @@ def traced_dem(*, rotated: bool) -> str: assert traced_dem(rotated=True) != traced_dem(rotated=False) + def test_traced_qis_native_topology_lowers_clifford_rotations(self) -> None: + """Surface native topology should match from_guppy's traced-QIS normalization.""" + from pecos.qec.surface.decode import _surface_native_topology, _surface_patch_cache_key + + _require_selene_runtime() + + patch = SurfacePatch.create(distance=3) + topology = _surface_native_topology( + _surface_patch_cache_key(patch), + 2, + "Z", + None, + "traced_qis", + False, + ) + gate_names = { + topology.dag_circuit.gate(node).gate_type.name + for node in topology.dag_circuit.nodes() + if topology.dag_circuit.gate(node) is not None + } + + assert "RZZ" not in gate_names + assert "SZZ" in gate_names + def test_guppy_module_cache_keys_on_full_patch_identity(self) -> None: """Rotated and non-rotated patches of the same dx/dz/budget must NOT share a cached Guppy module (they generate different circuits).""" diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index ec75274fe..e52803d90 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -178,6 +178,40 @@ def test_tick_circuit_exposes_detector_descriptors() -> None: assert final_x["coords"] == [0, 0, 2] +@pytest.mark.parametrize( + ("basis", "baseline_kind", "detector_y"), + [("Z", "X", 0), ("X", "Z", 1)], +) +def test_tick_circuit_uses_explicit_prep_syndrome_baseline( + basis: str, + baseline_kind: str, + detector_y: int, +) -> None: + """Round-0 random-sign detectors should compare against prep syndrome measurements.""" + patch = SurfacePatch.create(distance=3) + tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis=basis) + detectors = json.loads(tc.get_meta("detectors") or "[]") + num_measurements = int(tc.get_meta("num_measurements") or "0") + init_count = len(patch.x_stabilizers if baseline_kind == "X" else patch.z_stabilizers) + + assert num_measurements == init_count + 2 * patch.num_ancilla + patch.num_data + + init_tick_rounds = [ + tc.get_tick_meta(tick_index, "syndrome_round") + for tick_index in range(tc.num_ticks()) + if tc.get_tick_meta(tick_index, "syndrome_round") == -1 + ] + assert init_tick_rounds + + first_random_detector = next( + det for det in detectors if det["coords"][1] == detector_y and det["coords"][2] == 0 + ) + assert len(first_random_detector["records"]) == 2 + record_indices = [num_measurements + int(record) for record in first_random_detector["records"]] + assert record_indices[0] >= init_count + assert record_indices[1] < init_count + + def test_tick_circuit_exposes_observable_descriptors() -> None: """Tick circuits should publish observable descriptors derived from logical metadata.""" patch = SurfacePatch.create(distance=3) @@ -243,9 +277,11 @@ def test_tick_circuit_respects_ancilla_budget_in_measurement_order() -> None: batched_order = get_measurement_order_from_tick_circuit(batched_tc) num_ancilla = patch.geometry.num_ancilla - full_ancilla_measures = full_order[:num_ancilla] - batched_ancilla_measures = batched_order[:num_ancilla] + init_count = len(patch.x_stabilizers) + full_ancilla_measures = full_order[init_count : init_count + num_ancilla] + batched_ancilla_measures = batched_order[init_count : init_count + num_ancilla] + assert len(set(full_order[:init_count])) == init_count assert len(set(full_ancilla_measures)) == num_ancilla assert len(set(batched_ancilla_measures)) == 2 assert max(batched_ancilla_measures) == patch.num_data + 1 diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 9f6a3b13a..455b26348 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -12,14 +12,17 @@ from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, + _extract_measurement_meas_ids, _measurement_index_remap_for_orders, _reject_partially_lowered_trace, _remap_surface_record_metadata_json, _replay_lowered_qis_trace_into_tick_circuit, _replay_qis_trace_into_tick_circuit, _surface_runtime_measurement_remap_from_result_traces, + _validate_result_tag_remap_against_traced_measurements, trace_guppy_into_tick_circuit_with_result_traces, ) @@ -767,6 +770,12 @@ def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: patch = SurfacePatch.create(distance=3) + abstract_tc = generate_tick_circuit_from_patch( + patch, + num_rounds=2, + basis="Z", + ancilla_budget=2, + ) program = make_surface_code(distance=3, num_rounds=2, basis="Z", ancilla_budget=2) _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( program, @@ -775,14 +784,83 @@ def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: ) remap = _surface_runtime_measurement_remap_from_result_traces( - patch, - 2, + abstract_tc, result_traces, ) - assert len(remap) == 25 # 2 rounds * 8 stabilizers + 9 final data measurements - assert sorted(remap) == list(range(25)) - assert sorted(remap.values()) == list(range(25)) + assert len(remap) == 29 # 4 prep X stabilizers + 2 rounds * 8 stabilizers + 9 final data measurements + assert sorted(remap) == list(range(29)) + assert sorted(remap.values()) == list(range(29)) + + +def test_result_tag_remap_validation_accepts_exact_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [10, 3]) + + remap = {0: 3, 1: 10} + + assert _extract_measurement_meas_ids(tc) == [10, 3] + _validate_result_tag_remap_against_traced_measurements( + tc, + remap, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_duplicate_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [7, 7]) + + with pytest.raises(ValueError, match="duplicate measured MeasId"): + _validate_result_tag_remap_against_traced_measurements( + tc, + {0: 7, 1: 8}, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_unbound_traced_meas_ids() -> None: + from pecos_rslib.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz_with_ids([0, 1], [0, 2]) + + with pytest.raises(ValueError, match="do not exactly match"): + _validate_result_tag_remap_against_traced_measurements( + tc, + {0: 0, 1: 1}, + expected_measurements=2, + ) + + +def test_result_tag_remap_validation_rejects_unstamped_measurements() -> None: + class FakeGate: + gate_type = "MZ" + qubits = [0] + meas_ids: list[int] = [] + + class FakeTick: + def gate_batches(self): + return [FakeGate()] + + class FakeCircuit: + def num_ticks(self) -> int: + return 1 + + def get_tick(self, tick_idx: int): + assert tick_idx == 0 + return FakeTick() + + with pytest.raises(ValueError, match="carries 0 MeasId"): + _validate_result_tag_remap_against_traced_measurements( + FakeCircuit(), + {0: 0}, + expected_measurements=1, + ) def test_traced_surface_metadata_uses_runtime_result_tags() -> None: @@ -797,7 +875,7 @@ def test_traced_surface_metadata_uses_runtime_result_tags() -> None: assert traced_tc.get_meta("surface_metadata_record_binding") == "runtime_result_tags" assert traced_tc.get_meta("circuit_source") == "traced_qis" - assert int(traced_tc.get_meta("num_measurements")) == 25 + assert int(traced_tc.get_meta("num_measurements")) == 29 assert len(json.loads(traced_tc.get_meta("detectors"))) > 0 assert len(json.loads(traced_tc.get_meta("observables"))) == 1 diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 47b0ca312..7cc55b476 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -3,9 +3,13 @@ """Smoke tests for the traced-QIS surface-code route after Clifford lowering.""" +import math import random +import pytest + from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model from pecos.quantum import TickCircuit from pecos_rslib_exp import ( @@ -79,7 +83,7 @@ def build_lowered_traced_qis_surface_code(rounds=3): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, rounds, "Z", circuit_source="traced_qis") - tc.lower_clifford_rotations() + normalize_traced_qis_tick_circuit(tc, context="test traced-QIS surface code") return tc @@ -213,6 +217,29 @@ def test_lowered_traced_qis_pipeline_sampling_and_catalog_smoke(): assert len(first_fault.faults) == 1 +def test_normalize_traced_qis_tick_circuit_lowers_clifford_rzz(): + tc = TickCircuit() + tc.tick().rzz(math.pi / 2, [(0, 1)]) + + normalize_traced_qis_tick_circuit(tc, context="test Clifford RZZ normalization") + + gate_names = [ + gate.gate_type.name + for tick_index in range(tc.num_ticks()) + for gate in tc.get_tick(tick_index).gate_batches() + ] + assert "RZZ" not in gate_names + assert "SZZ" in gate_names + + +def test_normalize_traced_qis_tick_circuit_rejects_raw_rzz_after_lowering(): + tc = TickCircuit() + tc.tick().rzz(math.pi / 4, [(0, 1)]) + + with pytest.raises(ValueError, match="still contains raw RZZ"): + normalize_traced_qis_tick_circuit(tc, context="test non-Clifford RZZ normalization") + + def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) From 16d459673a63068f3cd4f53fe953e8843cd1c4ce Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 15:34:29 -0600 Subject: [PATCH 079/388] Preserve runtime surface DEM metadata through traced QIS --- .../src/fault_tolerance/dem_builder/types.rs | 296 ++++++++++++++---- crates/pecos-qis/src/ccengine.rs | 84 ++++- .../src/fault_tolerance_bindings.rs | 10 + .../src/pecos/qec/surface/circuit_builder.py | 98 ++++-- .../src/pecos/qec/surface/decode.py | 166 +++++----- .../tests/qec/test_from_guppy_dem.py | 215 +++++++++++-- 6 files changed, 656 insertions(+), 213 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 866800dab..e629bfe59 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -1081,8 +1081,6 @@ struct GraphlikeDecompositionIndex { /// Paths may introduce intermediate detector vertices only when they cancel /// pairwise in the XOR of path components. path_adjacency: BTreeMap>, - /// Pure logical graphlike components, keyed by their DEM-output mask. - pure_logical_components: BTreeMap, Vec>, } impl GraphlikeDecompositionIndex { @@ -1135,19 +1133,12 @@ impl GraphlikeDecompositionIndex { values.dedup(); } let mut path_adjacency: BTreeMap> = BTreeMap::new(); - let mut pure_logical_components: BTreeMap, Vec> = - BTreeMap::new(); for candidate in &graphlike_set { - if !candidate.is_graphlike() || candidate.is_standard_empty() { + if !is_detectable_graphlike_component(candidate) || candidate.is_standard_empty() { continue; } match candidate.detectors.as_slice() { - [] => { - pure_logical_components - .entry(candidate.dem_outputs.clone()) - .or_default() - .push(candidate.clone()); - } + [] => {} [det] => { Self::add_path_edge( &mut path_adjacency, @@ -1167,10 +1158,6 @@ impl GraphlikeDecompositionIndex { _ => {} } } - for values in pure_logical_components.values_mut() { - values.sort_by(Self::compare_symptom_candidates); - values.dedup(); - } for edges in path_adjacency.values_mut() { edges.sort_by(|a, b| { a.next @@ -1194,7 +1181,6 @@ impl GraphlikeDecompositionIndex { candidates_by_detector, symptoms, path_adjacency, - pure_logical_components, } } @@ -1252,20 +1238,6 @@ impl GraphlikeDecompositionIndex { }) } - fn first_allowed_logical_component( - &self, - key: &SmallVec<[u32; 2]>, - excluded_origin: Option<&FaultMechanism>, - ) -> Option<&FaultMechanism> { - self.pure_logical_components - .get(key) - .and_then(|candidates| { - candidates - .iter() - .find(|candidate| self.candidate_allowed(candidate, excluded_origin)) - }) - } - fn find_hyperedge_decomposition( &self, hyperedge: &FaultMechanism, @@ -1279,7 +1251,7 @@ impl GraphlikeDecompositionIndex { excluded_origin: Option<&FaultMechanism>, ) -> Option> { // If already graphlike, no decomposition needed - if hyperedge.is_graphlike() { + if is_detectable_graphlike_component(hyperedge) { return Some(vec![hyperedge.clone()]); } @@ -1337,7 +1309,7 @@ impl GraphlikeDecompositionIndex { excluded_origin: Option<&FaultMechanism>, ) -> bool { parts.iter().all(|part| { - part.is_graphlike() + is_detectable_graphlike_component(part) && self.graphlike_set.contains(part) && self.candidate_allowed(part, excluded_origin) }) @@ -1359,7 +1331,7 @@ impl GraphlikeDecompositionIndex { excluded_origin: Option<&FaultMechanism>, path_cache: &mut GraphPathSearchCache, ) -> Option> { - if hyperedge.is_graphlike() { + if is_detectable_graphlike_component(hyperedge) { return Some(vec![hyperedge.clone()]); } if self.path_adjacency.is_empty() || hyperedge.detectors.len() > MAX_GRAPH_PATH_TERMINALS { @@ -1393,12 +1365,13 @@ impl GraphlikeDecompositionIndex { for (outputs, mut parts) in states { let missing_outputs = symmetric_difference_2(&outputs, &hyperedge.dem_outputs); if !missing_outputs.is_empty() { - let Some(logical_part) = - self.first_allowed_logical_component(&missing_outputs, excluded_origin) - else { - continue; - }; - parts.push(logical_part.clone()); + // Do not repair logical-frame parity by appending a pure `L` + // component. Although this preserves the full XOR effect, it + // creates an undetectable decomposition component (`L0 ^ D1 D2`) + // that graphlike decoders cannot attach to a syndrome edge. + // A valid graph-path decomposition must carry observable parity + // on one of its detector/boundary components. + continue; } parts = parity_reduce_mechanisms(parts); if parts.is_empty() { @@ -1407,7 +1380,7 @@ impl GraphlikeDecompositionIndex { let recomposed = parts .iter() .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); - if recomposed == *hyperedge && parts.iter().all(FaultMechanism::is_graphlike) { + if recomposed == *hyperedge && parts_are_detectable_graphlike(&parts) { match best.as_ref() { Some(existing) if !prefer_graph_path_parts(&parts, existing) => {} _ => best = Some(parts), @@ -1560,7 +1533,7 @@ impl GraphlikeDecompositionIndex { hyperedge: &FaultMechanism, excluded_origin: Option<&FaultMechanism>, ) -> Option> { - if hyperedge.is_graphlike() { + if is_detectable_graphlike_component(hyperedge) { return Some(vec![hyperedge.clone()]); } @@ -1618,7 +1591,7 @@ impl GraphlikeDecompositionIndex { let recomposed = parts .iter() .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); - if recomposed == *hyperedge && parts.iter().all(FaultMechanism::is_graphlike) { + if recomposed == *hyperedge && parts_are_detectable_graphlike(&parts) { Some(parts) } else { None @@ -1641,7 +1614,7 @@ impl GraphlikeDecompositionIndex { return result; } - if remaining.is_graphlike() + if is_detectable_graphlike_component(remaining) && self.graphlike_set.contains(remaining) && self.candidate_allowed(remaining, excluded_origin) { @@ -1720,6 +1693,22 @@ fn parity_reduce_mechanisms(parts: Vec) -> Vec { reduced } +fn is_pure_logical_mechanism(part: &FaultMechanism) -> bool { + part.detectors.is_empty() && !part.dem_outputs.is_empty() +} + +fn is_detectable_graphlike_component(part: &FaultMechanism) -> bool { + part.num_detectors() <= 2 && !is_pure_logical_mechanism(part) +} + +fn parts_have_pure_logical_component(parts: &[FaultMechanism]) -> bool { + parts.iter().any(is_pure_logical_mechanism) +} + +fn parts_are_detectable_graphlike(parts: &[FaultMechanism]) -> bool { + parts.iter().all(is_detectable_graphlike_component) +} + /// Finds a decomposition of a graphlike effect into singleton detector components. /// /// This is used for "maximal" decomposition modes that prefer singleton @@ -1733,6 +1722,9 @@ fn find_singleton_decomposition( return Some(Vec::new()); } if effect.num_detectors() <= 1 { + if is_pure_logical_mechanism(effect) { + return None; + } return Some(vec![effect.clone()]); } if index.is_empty() { @@ -4776,7 +4768,7 @@ impl DetectorErrorModel { let mut out = Vec::new(); for part in parts { - if part.is_graphlike() { + if is_detectable_graphlike_component(&part) { out.extend(Self::maximally_decompose_graphlike_effect( &part, singleton_set, @@ -4788,6 +4780,32 @@ impl DetectorErrorModel { out } + fn source_graphlike_decompose_full_effect_or_raw( + effect: &FaultMechanism, + source_graphlike_index: Option<&GraphlikeDecompositionIndex>, + singleton_set: Option<&SingletonDecompositionIndex>, + source_graphlike_path_cache: &mut GraphPathSearchCache, + ) -> Vec { + if is_detectable_graphlike_component(effect) { + return Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set); + } + + if let Some(index) = source_graphlike_index { + if let Some(parts) = index.find_hyperedge_decomposition_with_remnants_for_origin_cached( + effect, + Some(effect), + source_graphlike_path_cache, + ) { + let parts = Self::maybe_maximally_decompose_parts(parts, singleton_set); + if parts_are_detectable_graphlike(&parts) { + return parts; + } + } + } + + vec![effect.clone()] + } + fn source_component_parts(contrib: &FaultContribution) -> Vec { if let Some((x_effect, z_effect)) = contrib.decomposition_components() { return [x_effect, z_effect] @@ -4817,11 +4835,11 @@ impl DetectorErrorModel { let mut graphlike = BTreeSet::new(); for contrib in &self.contributions { let effect = contrib.effect.standard_effect(); - if !contrib.location_indices.is_empty() && effect.is_graphlike() { + if !contrib.location_indices.is_empty() && is_detectable_graphlike_component(&effect) { graphlike.insert(effect); } for part in Self::source_component_parts(contrib) { - if part.is_graphlike() { + if is_detectable_graphlike_component(&part) { graphlike.insert(part); } } @@ -4846,7 +4864,7 @@ impl DetectorErrorModel { for part in parts { let origin = part.clone(); - let decomposed = if part.is_graphlike() { + let decomposed = if is_detectable_graphlike_component(&part) { vec![part] } else { index @@ -4855,7 +4873,7 @@ impl DetectorErrorModel { }; for piece in decomposed { - if piece.is_graphlike() && !piece.is_standard_empty() { + if is_detectable_graphlike_component(&piece) && !piece.is_standard_empty() { graphlike.insert_derived(piece, &origin); } } @@ -4882,7 +4900,7 @@ impl DetectorErrorModel { continue; } - let decomposed = if part.is_graphlike() { + let decomposed = if is_detectable_graphlike_component(&part) { vec![part] } else if let Some(index) = source_graphlike_index { index @@ -4917,22 +4935,19 @@ impl DetectorErrorModel { singleton_set, source_graphlike_path_cache, ); - if recorded_parts.iter().all(FaultMechanism::is_graphlike) { + let has_pure_logical_component = parts_have_pure_logical_component(&recorded_parts); + if !has_pure_logical_component && parts_are_detectable_graphlike(&recorded_parts) { return recorded_parts; } - if let Some(index) = source_graphlike_index { - if let Some(effect_parts) = index - .find_hyperedge_decomposition_with_remnants_for_origin_cached( - effect, - Some(effect), - source_graphlike_path_cache, - ) - { - if effect_parts.iter().all(FaultMechanism::is_graphlike) { - return Self::maybe_maximally_decompose_parts(effect_parts, singleton_set); - } - } + let full_effect_parts = Self::source_graphlike_decompose_full_effect_or_raw( + effect, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ); + if has_pure_logical_component || parts_are_detectable_graphlike(&full_effect_parts) { + return full_effect_parts; } recorded_parts @@ -5010,12 +5025,22 @@ impl DetectorErrorModel { contrib.decomposition_components() { if !x_effect.is_empty() && !z_effect.is_empty() { - let parts = Self::source_graphlike_decompose_parts( + let source_parts = Self::source_graphlike_decompose_parts( vec![x_effect.clone(), z_effect.clone()], source_graphlike_index, singleton_set, source_graphlike_path_cache, ); + let parts = if parts_have_pure_logical_component(&source_parts) { + Self::source_graphlike_decompose_full_effect_or_raw( + &effect, + source_graphlike_index, + singleton_set, + source_graphlike_path_cache, + ) + } else { + source_parts + }; let targets = Self::format_decomposed_parts(parts); (targets, ContributionRenderStrategy::SourceComponents) } else if effect.num_detectors() == 2 && effect.dem_outputs.is_empty() { @@ -5531,7 +5556,7 @@ impl DetectorErrorModel { let mut graphlike = BTreeSet::new(); for contrib in &self.contributions { let standard = contrib.effect.standard_effect(); - if !standard.is_standard_empty() && standard.is_graphlike() { + if !standard.is_standard_empty() && is_detectable_graphlike_component(&standard) { graphlike.insert(standard); } } @@ -7222,6 +7247,149 @@ mod tests { assert!(!target_line.contains("D0 D2 D3 L0")); } + #[test] + fn test_source_graphlike_path_decomposition_rejects_pure_logical_fixup() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], std::iter::empty()); + let third = FaultMechanism::from_unsorted([3], std::iter::empty()); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + for (idx, part) in [&first, &second, &third, &pure_logical] + .into_iter() + .enumerate() + { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third).xor(&pure_logical); + assert_eq!(effect, FaultMechanism::from_unsorted([0, 2, 3], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[9usize], &[Pauli::X], &[GateType::SZZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + assert!(target_line.contains("L0")); + let (_, targets) = target_line + .split_once(") ") + .expect("expected error line target separator"); + for component in targets.split(" ^ ") { + let has_detector = component + .split_whitespace() + .any(|target| target.starts_with('D')); + let has_logical = component + .split_whitespace() + .any(|target| target.starts_with('L')); + assert!( + has_detector || !has_logical, + "logical component must be detector-bearing in {target_line}", + ); + } + } + + #[test] + fn test_source_graphlike_decomposition_rejects_pure_logical_singleton_piece() { + let mut dem = DetectorErrorModel::new(); + let first = FaultMechanism::from_unsorted([1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([2], std::iter::empty()); + let third = FaultMechanism::from_unsorted([121], std::iter::empty()); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + for (idx, part) in [&first, &second, &third, &pure_logical] + .into_iter() + .enumerate() + { + dem.add_direct_contribution_with_source( + part.clone(), + 0.001, + SourceMetadata::new(&[idx], &[Pauli::X], &[GateType::H], &[false]), + ); + } + + let effect = first.xor(&second).xor(&third).xor(&pure_logical); + assert_eq!(effect, FaultMechanism::from_unsorted([1, 2, 121], [0])); + dem.add_direct_contribution_with_source( + effect, + 0.01, + SourceMetadata::new(&[20usize], &[Pauli::X], &[GateType::PZ], &[false]), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + assert!( + !target_line.contains("L0 ^"), + "pure logical must not be emitted as a decomposed component: {target_line}", + ); + assert!( + !target_line.contains("^ L0"), + "pure logical must not be emitted as a decomposed component: {target_line}", + ); + assert!( + target_line.contains("D1 D2 D121 L0"), + "effect should remain as the raw source hyperedge when no detector-bearing graphlike decomposition exists: {target_line}", + ); + } + + #[test] + fn test_source_graphlike_recorded_components_reject_pure_logical_component() { + let mut dem = DetectorErrorModel::new(); + let pure_logical = FaultMechanism::from_unsorted(std::iter::empty(), [0]); + let first = FaultMechanism::from_unsorted([0, 1], std::iter::empty()); + let second = FaultMechanism::from_unsorted([1, 2], [0]); + let components = [pure_logical.clone(), first.clone(), second.clone()]; + let effect = components + .iter() + .fold(FaultMechanism::new(), |acc, part| acc.xor(part)); + assert_eq!( + effect, + FaultMechanism::from_unsorted([0, 2], std::iter::empty()) + ); + + dem.add_direct_contribution_with_source_components( + effect, + 0.01, + SourceMetadata::new( + &[10usize, 11usize], + &[Pauli::X, Pauli::Z], + &[GateType::SZZ, GateType::SZZ], + &[false, false], + ), + DirectSourceComponents::from_slice(&components), + ); + + let source_graphlike = dem.to_string_source_graphlike_decomposed(); + let target_line = source_graphlike + .lines() + .find(|line| line.starts_with("error(0.01)")) + .expect("expected target error line"); + let (_, targets) = target_line + .split_once(") ") + .expect("expected error line target separator"); + for component in targets.split(" ^ ") { + let has_detector = component + .split_whitespace() + .any(|target| target.starts_with('D')); + let has_logical = component + .split_whitespace() + .any(|target| target.starts_with('L')); + assert!( + has_detector || !has_logical, + "logical component must be detector-bearing in {target_line}", + ); + } + } + #[test] fn test_source_graphlike_path_decomposition_handles_boundary_cluster() { let mut dem = DetectorErrorModel::new(); diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 1711e5831..377ea7a64 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -44,6 +44,7 @@ pub struct LoweredQuantumGateTrace { pub angles: Vec, pub params: Vec, pub qubits: Vec, + pub measurement_result_ids: Vec, } /// One traced batch of QIS operations and their lowered simulator commands. @@ -834,25 +835,57 @@ impl QisEngine { self.trace_chunk_index = 0; } - fn lowered_quantum_ops_trace(commands: &ByteMessage) -> Vec { + fn lowered_quantum_ops_trace( + commands: &ByteMessage, + measurement_mapping: &[usize], + ) -> Vec { match commands.quantum_ops() { - Ok(gates) => gates - .iter() - .map(|gate| LoweredQuantumGateTrace { - gate_type: gate.gate_type.to_string(), - angles: gate - .angles - .iter() - .map(Angle64::to_radians) - .collect::>(), - params: gate.params.iter().copied().collect::>(), - qubits: gate + Ok(gates) => { + let mut measurement_cursor = 0usize; + let mut traces = Vec::with_capacity(gates.len()); + for gate in gates { + let gate_type = gate.gate_type.to_string(); + let qubits = gate .qubits .iter() .map(|q| usize::from(*q)) - .collect::>(), - }) - .collect::>(), + .collect::>(); + let measurement_result_ids = if gate_type == "MZ" { + let end = measurement_cursor + qubits.len(); + if end > measurement_mapping.len() { + warn!( + "Lowered operation trace has more measured qubits than result-id mappings" + ); + Vec::new() + } else { + let ids = measurement_mapping[measurement_cursor..end].to_vec(); + measurement_cursor = end; + ids + } + } else { + Vec::new() + }; + traces.push(LoweredQuantumGateTrace { + gate_type, + angles: gate + .angles + .iter() + .map(Angle64::to_radians) + .collect::>(), + params: gate.params.iter().copied().collect::>(), + qubits, + measurement_result_ids, + }); + } + if measurement_cursor != measurement_mapping.len() { + warn!( + "Lowered operation trace consumed {} measurement mapping(s), but {} were present", + measurement_cursor, + measurement_mapping.len() + ); + } + traces + } Err(err) => { warn!("Failed to parse lowered quantum ops for tracing: {err}"); Vec::new() @@ -872,7 +905,7 @@ impl QisEngine { } let lowered_trace = lowered_quantum_ops - .map(Self::lowered_quantum_ops_trace) + .map(|commands| Self::lowered_quantum_ops_trace(commands, &self.measurement_mapping)) .unwrap_or_default(); let file_name = format!( "engine_{:04}_shot_{:06}_chunk_{:04}_{}.json", @@ -1722,6 +1755,10 @@ mod tests { assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "Idle"); assert_eq!(value["lowered_quantum_ops"][2]["params"][0], 20e-9); assert_eq!(value["lowered_quantum_ops"][3]["gate_type"], "MZ"); + assert_eq!( + value["lowered_quantum_ops"][3]["measurement_result_ids"], + serde_json::json!([7]) + ); let in_memory = collector.lock().expect("collector lock"); assert_eq!(in_memory.len(), 1); @@ -1729,6 +1766,10 @@ mod tests { assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "PZ"); assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "Idle"); assert_eq!(in_memory[0].lowered_quantum_ops[2].params, vec![20e-9]); + assert_eq!( + in_memory[0].lowered_quantum_ops[3].measurement_result_ids, + vec![7] + ); } #[derive(Clone, Default)] @@ -1773,7 +1814,11 @@ mod tests { } fn lower_operations(&mut self, _operations: &[Operation]) -> RuntimeResult> { - Ok(vec![QuantumOp::Idle(20e-9, 0), QuantumOp::H(0)]) + Ok(vec![ + QuantumOp::Idle(20e-9, 0), + QuantumOp::H(0), + QuantumOp::Measure(0, 17), + ]) } } @@ -1796,6 +1841,11 @@ mod tests { assert_eq!(in_memory[0].lowered_quantum_ops[0].params, vec![20e-9]); assert_eq!(in_memory[0].lowered_quantum_ops[0].qubits, vec![0]); assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "H"); + assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "MZ"); + assert_eq!( + in_memory[0].lowered_quantum_ops[2].measurement_result_ids, + vec![17] + ); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 95d9051b5..fa6c36b52 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -485,6 +485,16 @@ impl PyDagFaultInfluenceMap { self.inner.has_observable_flips(loc_idx, pauli) } + /// Replace this map's non-detector DEM outputs with another map's outputs. + /// + /// This is the Python equivalent of the canonical Rust DEM builder's + /// annotation merge: detector influence from `DagFaultAnalyzer` is kept, + /// while observable/tracked-Pauli outputs from `InfluenceBuilder` are used + /// for DEM output propagation. + fn merge_dem_outputs_from(&mut self, other: &PyDagFaultInfluenceMap) { + self.inner.merge_dem_outputs_from(&other.inner); + } + /// Check if a fault at the given location flips any tracked Pauli. /// /// Args: diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index e7e4e5862..1eb5245d9 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -913,13 +913,20 @@ class TickCircuitRenderer(CircuitRenderer): are stored as circuit metadata and preserved when converting to DagCircuit. """ - def __init__(self, *, add_detectors: bool = True) -> None: + def __init__( + self, + *, + add_detectors: bool = True, + add_typed_annotations: bool = True, + ) -> None: """Initialize TickCircuit renderer. Args: - add_detectors: Whether to add detector annotations as metadata + add_detectors: Whether to add detector/observable metadata. + add_typed_annotations: Whether to also add typed Pauli annotations. """ self.add_detectors = add_detectors + self.add_typed_annotations = add_typed_annotations def render( self, @@ -1093,8 +1100,7 @@ def is_syndrome_context(phase: str, round_index: int) -> bool: if round_index >= 0 or phase.startswith("init_syndrome"): return True return round_index == -1 and ( - phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} - or phase.startswith("cx_round_") + phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} or phase.startswith("cx_round_") ) def gate_metadata(meta: dict | None = None) -> dict: @@ -1395,17 +1401,23 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non circuit.set_meta("num_measurements", str(meas_count)) circuit.set_meta("num_detectors", str(len(detectors))) - # Also add typed PauliAnnotation annotations (new path) - self._add_typed_annotations( - circuit, - geom, - num_rounds, - basis, - stab_meas_refs, - final_meas_refs_by_qubit, - deterministic_type_round0, - init_baseline_type, - ) + # Also add typed PauliAnnotation annotations (new path) when the + # caller wants direct Pauli annotations in addition to legacy + # measurement-record metadata. Native surface DEM construction uses + # the JSON metadata below and disables these annotations to avoid + # mixing two independent observable sources in the same influence + # map. + if self.add_typed_annotations: + self._add_typed_annotations( + circuit, + geom, + num_rounds, + basis, + stab_meas_refs, + final_meas_refs_by_qubit, + deterministic_type_round0, + init_baseline_type, + ) circuit.set_meta("basis", basis.upper()) circuit.set_meta("ancilla_budget", str(allocation.total - len(allocation.data_qubits))) @@ -1585,6 +1597,7 @@ def generate_tick_circuit_from_patch( basis: str = "Z", *, add_detectors: bool = True, + add_typed_annotations: bool = True, ancilla_budget: int | None = None, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -1606,14 +1619,18 @@ def generate_tick_circuit_from_patch( patch: Surface code patch num_rounds: Number of syndrome rounds basis: 'Z' or 'X' - add_detectors: Whether to add detector annotations as metadata + add_detectors: Whether to add detector/observable metadata. + add_typed_annotations: Whether to also add typed Pauli annotations. ancilla_budget: Optional cap on simultaneously live ancillas Returns: PECOS TickCircuit instance """ ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) - renderer = TickCircuitRenderer(add_detectors=add_detectors) + renderer = TickCircuitRenderer( + add_detectors=add_detectors, + add_typed_annotations=add_typed_annotations, + ) return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -2252,11 +2269,7 @@ def simulate_error( if abs(weight_sum - 1.0) >= 1.0e-6: message = f"p2_weights relative probabilities must sum to 1.0, got {weight_sum}" raise ValueError(message) - two_paulis = tuple( - (label[0], label[1], weight) - for label, weight in sorted(weights.items()) - if weight > 0.0 - ) + two_paulis = tuple((label[0], label[1], weight) for label, weight in sorted(weights.items()) if weight > 0.0) # Process each gate as a potential error location for op_idx, (_tick_idx, gate_name, qubits, meas_idx) in enumerate(circuit_ops): @@ -2476,6 +2489,38 @@ def _maximally_decompose_graphlike_dem(dem_text: str) -> str: return "\n".join(rewritten_lines) +def _build_canonical_dem_influence_map(dag: DagCircuit): + """Build the influence map used by the canonical Rust DEM builder. + + `DagFaultAnalyzer` supplies the detector influence map. Observable and + tracked-Pauli annotations need positional propagation from + `InfluenceBuilder`; merging those non-detector outputs keeps this legacy + surface helper aligned with `DetectorErrorModel.from_circuit`. + """ + from pecos.qec import DagFaultAnalyzer, InfluenceBuilder + + analyzer = DagFaultAnalyzer(dag) + influence_map = analyzer.build_influence_map() + annotation_builder = InfluenceBuilder(dag) + annotation_builder.with_circuit_annotations() + annotation_map = annotation_builder.build() + influence_map.merge_dem_outputs_from(annotation_map) + return influence_map + + +def _metadata_uses_record_offsets(*metadata_jsons: str | None) -> bool: + """Return whether detector/observable metadata uses positional records.""" + import json + + for metadata_json in metadata_jsons: + if not metadata_json: + continue + for entry in json.loads(metadata_json): + if entry.get("records"): + return True + return False + + def generate_dem_from_tick_circuit( tc: TickCircuit, *, @@ -2549,7 +2594,7 @@ def generate_dem_from_tick_circuit( Returns: DEM string in Stim-compatible format """ - from pecos.qec import DagFaultAnalyzer, DemBuilder + from pecos.qec import DemBuilder # Get detector and observable metadata detectors_json = tc.get_meta("detectors") @@ -2565,11 +2610,11 @@ def generate_dem_from_tick_circuit( # This allows proper mapping between record offsets (TickCircuit order) and # influence map indices (DAG topological order). measurement_order = _extract_measurement_order(tc) + metadata_uses_records = _metadata_uses_record_offsets(detectors_json, observables_json) # Convert TickCircuit to DagCircuit and build influence map dag = tc.to_dag_circuit() - analyzer = DagFaultAnalyzer(dag) - influence_map = analyzer.build_influence_map() + influence_map = _build_canonical_dem_influence_map(dag) # Build DEM using Rust DemBuilder builder = DemBuilder(influence_map) @@ -2592,7 +2637,8 @@ def generate_dem_from_tick_circuit( p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, ) builder.with_num_measurements(num_measurements) - builder.with_measurement_order(measurement_order) + if metadata_uses_records: + builder.with_measurement_order(measurement_order) builder.with_detectors_json(detectors_json) if observables_json: builder.with_observables_json(observables_json) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 46a05888e..d72dda77e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -552,23 +552,38 @@ def _remap_surface_record_metadata_json( measurement_index_remap: dict[int, int], num_measurements: int, ) -> str: - """Remap negative ``records`` offsets inside detector/observable metadata.""" + """Bind abstract measurement refs to runtime-stable ``meas_ids``. + + ``measurement_index_remap`` maps abstract measurement indices to the + stable result ids emitted by the runtime trace. Those ids are not + positional record offsets, so remapped runtime metadata must use + ``meas_ids`` and must drop stale ``records``. + """ import json entries = json.loads(metadata_json) for entry in entries: - records = entry.get("records") - if records is None: + records = entry.pop("records", None) + if records is not None: + abstract_indices = [] + for record in records: + abstract_index = num_measurements + int(record) + if abstract_index not in measurement_index_remap: + msg = f"Surface metadata record {record!r} is out of range for remapping" + raise ValueError(msg) + abstract_indices.append(abstract_index) + elif "meas_ids" in entry: + abstract_indices = [int(meas_id) for meas_id in entry["meas_ids"]] + else: continue - remapped_records = [] - for record in records: - abstract_index = num_measurements + int(record) + + remapped_meas_ids = [] + for abstract_index in abstract_indices: if abstract_index not in measurement_index_remap: - msg = f"Surface metadata record {record!r} is out of range for remapping" + msg = f"Surface metadata meas_id {abstract_index!r} is out of range for remapping" raise ValueError(msg) - traced_index = measurement_index_remap[abstract_index] - remapped_records.append(traced_index - num_measurements) - entry["records"] = remapped_records + remapped_meas_ids.append(int(measurement_index_remap[abstract_index])) + entry["meas_ids"] = remapped_meas_ids return json.dumps(entries) @@ -736,11 +751,7 @@ def _validate_result_tag_remap_against_traced_measurements( ) raise ValueError(msg) if len(set(traced_meas_ids)) != len(traced_meas_ids): - duplicates = sorted( - meas_id - for meas_id in set(traced_meas_ids) - if traced_meas_ids.count(meas_id) > 1 - ) + duplicates = sorted(meas_id for meas_id in set(traced_meas_ids) if traced_meas_ids.count(meas_id) > 1) msg = f"traced circuit contains duplicate measured MeasId(s): {duplicates[:8]}" raise ValueError(msg) @@ -958,31 +969,15 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick, then compact (ASAP schedule) so that gates on disjoint qubits share a tick --- matching the parallel structure of the abstract circuit. - MeasIds flow from the QIS measurement result slot: Quantum.Measure carries - ``[qubit, result_id]``, and those IDs are stamped on MZ gates via - mz_with_ids(). + MeasIds flow from runtime-lowered measurement provenance: + ``lowered_quantum_ops`` MZ entries must carry ``measurement_result_ids``. + This avoids inferring lowered measurement IDs from raw QIS operation order, + which is not stable under runtime scheduling or transport. """ from pecos_rslib.quantum import TickCircuit tick_circuit = TickCircuit() - # Pass 1: the ordered MeasIds, read directly from each Measure op. A - # ``Quantum.Measure`` op carries ``[qubit, result_id]`` where ``result_id`` - # is the QIS result slot the runtime allocated for it (== the MeasId we - # stamp). Using it directly needs no AllocateResult/Measure pairing - # heuristic and no interleave assumption -- batched - # allocate-allocate-measure-measure (a valid QIS pattern) works the same - # as interleaved. (The order of Measure ops here matches the order of MZ - # gates in ``lowered_quantum_ops``, consumed in pass 2.) - meas_ids_in_order: list[int] = [] - for chunk in chunks: - for op in chunk.get("operations") or []: - quantum = dict(op).get("Quantum") - if isinstance(quantum, dict) and "Measure" in quantum: - meas_ids_in_order.append(int(quantum["Measure"][1])) - - # Pass 2: replay gates, stamping MeasIds on MZ gates in global trace order. - meas_cursor = 0 for chunk in chunks: for gate in chunk.get("lowered_quantum_ops") or []: gate_type = str(gate["gate_type"]) @@ -1015,16 +1010,19 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> raise ValueError(msg) tick.idle(_runtime_idle_seconds_to_time_units(params[0]), qubits) elif gate_type == "MZ": - end = meas_cursor + len(qubits) - if end > len(meas_ids_in_order): + meas_ids = gate.get("measurement_result_ids") + if not isinstance(meas_ids, list): msg = ( - "More measured qubits than result(...)-anchored " - "MeasIds in the traced program; a measurement is " - "missing its result(...) call." + "Lowered MZ trace is missing measurement_result_ids; " + "rebuild PECOS so runtime-lowered measurements carry " + "their result-id provenance instead of relying on " + "operation-order inference." ) raise ValueError(msg) - tick.mz_with_ids(qubits, meas_ids_in_order[meas_cursor:end]) - meas_cursor = end + if len(meas_ids) != len(qubits): + msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" + raise ValueError(msg) + tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) elif gate_type == "RX": tick.rx(angles[0], qubits) elif gate_type == "RY": @@ -1055,14 +1053,6 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> msg = f"Unsupported lowered traced gate {gate_type!r}" raise ValueError(msg) - if meas_cursor != len(meas_ids_in_order): - msg = ( - f"Traced program has {len(meas_ids_in_order)} result(...)-anchored " - f"measurements but only {meas_cursor} measured qubit(s) in the " - "lowered gate stream; result()/measurement mismatch." - ) - raise ValueError(msg) - # Compact: ASAP-schedule gates into minimal ticks tick_circuit.compact_ticks() @@ -1283,6 +1273,7 @@ def _build_surface_tick_circuit_for_native_model( num_rounds, basis, ancilla_budget=ancilla_budget, + add_typed_annotations=False, ) if circuit_source == "abstract": @@ -1469,9 +1460,10 @@ def _surface_native_topology( """Build topology-only native analysis shared across noise parameters.""" import json - from pecos.qec import DagFaultAnalyzer from pecos.qec.surface.circuit_builder import ( + _build_canonical_dem_influence_map, _extract_measurement_order, + _metadata_uses_record_offsets, normalize_traced_qis_tick_circuit, ) @@ -1496,12 +1488,13 @@ def _surface_native_topology( tc.fill_idle_gates() dag = tc.to_dag_circuit() - analyzer = DagFaultAnalyzer(dag) - influence_map = analyzer.build_influence_map() + influence_map = _build_canonical_dem_influence_map(dag) detectors_json = tc.get_meta("detectors") or "[]" observables_json = tc.get_meta("observables") or "[]" - measurement_order = tuple(_extract_measurement_order(tc)) + measurement_order = ( + tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () + ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) return _CachedNativeSurfaceTopology( @@ -1572,12 +1565,14 @@ def _dem_string_from_cached_surface_topology( if hasattr(builder, "with_exact_branch_replay_circuit"): builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) + builder = builder.with_num_measurements(topology.num_measurements) + if topology.measurement_order: + builder = builder.with_measurement_order(list(topology.measurement_order)) dem = ( - builder - .with_num_measurements(topology.num_measurements) - .with_measurement_order(list(topology.measurement_order)) - .with_detectors_json(topology.detectors_json) - .with_observables_json(topology.observables_json) + builder.with_detectors_json(topology.detectors_json) + .with_observables_json( + topology.observables_json, + ) .build_with_source_tracking() ) if not decompose_errors: @@ -1690,32 +1685,35 @@ def _build_native_sampler_from_cached_surface_topology( ) sampler = ParsedDem.from_string(dem_str).to_dem_sampler() elif sampling_model in ("influence_dem", "mnm"): - import json - - det_records = [d["records"] for d in json.loads(topology.detectors_json)] - obs_records = [o["records"] for o in json.loads(topology.observables_json)] if topology.observables_json else [] - sampler = DemSampler.with_detectors( - topology.influence_map, - det_records, - obs_records, - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - p_idle_linear_rate=noise.p_idle_linear_rate, - p_idle_quadratic_rate=noise.p_idle_quadratic_rate, - p_idle_x_linear_rate=noise.p_idle_x_linear_rate, - p_idle_y_linear_rate=noise.p_idle_y_linear_rate, - p_idle_z_linear_rate=noise.p_idle_z_linear_rate, - p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, - p2_weights=_p2_weights_dict(noise.p2_weights), - p2_replacement_approximation=noise.p2_replacement_approximation, + from pecos.qec import DemSamplerBuilder + + sampler_builder = ( + DemSamplerBuilder(topology.influence_map) + .with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, + p_idle_linear_rate=noise.p_idle_linear_rate, + p_idle_quadratic_rate=noise.p_idle_quadratic_rate, + p_idle_x_linear_rate=noise.p_idle_x_linear_rate, + p_idle_y_linear_rate=noise.p_idle_y_linear_rate, + p_idle_z_linear_rate=noise.p_idle_z_linear_rate, + p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p2_weights=_p2_weights_dict(noise.p2_weights), + p2_replacement_approximation=noise.p2_replacement_approximation, + ) + .with_detectors_json(topology.detectors_json) + .with_observables_json(topology.observables_json) ) + if topology.measurement_order: + sampler_builder = sampler_builder.with_measurement_order(list(topology.measurement_order)) + sampler = sampler_builder.build() # Remap sampling_model for NativeSampler dispatch sampling_model = "influence_dem" else: diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 455b26348..7364ff563 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -11,10 +11,11 @@ from guppylang.std.quantum import h, measure, qubit, x from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel -from pecos.qec.surface import SurfacePatch +from pecos.qec.surface import NoiseModel, SurfacePatch from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, + _copy_surface_tick_circuit_metadata, _extract_measurement_meas_ids, _measurement_index_remap_for_orders, _reject_partially_lowered_trace, @@ -23,6 +24,7 @@ _replay_qis_trace_into_tick_circuit, _surface_runtime_measurement_remap_from_result_traces, _validate_result_tag_remap_against_traced_measurements, + generate_circuit_level_dem_from_builder, trace_guppy_into_tick_circuit_with_result_traces, ) @@ -145,26 +147,40 @@ def test_lowered_replay_uses_measure_result_ids_directly() -> None: {"Quantum": {"Measure": [1, 42]}}, ], "lowered_quantum_ops": [ - {"gate_type": "MZ", "qubits": [0], "angles": []}, - {"gate_type": "MZ", "qubits": [1], "angles": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "measurement_result_ids": [42]}, + {"gate_type": "MZ", "qubits": [1], "angles": [], "measurement_result_ids": [99]}, ], }, ] tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) - assert _flat_mz_ids(tc) == [99, 42] + assert _flat_mz_ids(tc) == [42, 99] def test_lowered_replay_fails_on_measurement_count_mismatch() -> None: chunks = [ { "operations": [{"Quantum": {"Measure": [0, 7]}}], - "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0, 1], "angles": []}], + "lowered_quantum_ops": [ + {"gate_type": "MZ", "qubits": [0, 1], "angles": [], "measurement_result_ids": [7]}, + ], + }, + ] + + with pytest.raises(ValueError, match="carries 1 measurement_result_ids for 2"): + _replay_lowered_qis_trace_into_tick_circuit(chunks) + + +def test_lowered_replay_fails_on_missing_measurement_result_ids() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 7]}}], + "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": []}], }, ] - with pytest.raises(ValueError, match="More measured qubits"): + with pytest.raises(ValueError, match="missing measurement_result_ids"): _replay_lowered_qis_trace_into_tick_circuit(chunks) @@ -195,7 +211,7 @@ def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [20e-9]}, {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, - {"gate_type": "MZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, ], }, ] @@ -225,7 +241,7 @@ def test_lowered_runtime_idles_accept_axis_memory_noise_dem() -> None: "lowered_quantum_ops": [ {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, {"gate_type": "Idle", "qubits": [0], "angles": [], "params": [10e-9]}, - {"gate_type": "MZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, ], }, ] @@ -257,7 +273,7 @@ def test_from_circuit_accepts_biased_p2_weights() -> None: {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, {"gate_type": "PZ", "qubits": [1], "angles": [], "params": []}, {"gate_type": "CX", "qubits": [0, 1], "angles": [], "params": []}, - {"gate_type": "MZ", "qubits": [1], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [1], "angles": [], "params": [], "measurement_result_ids": [0]}, ], }, ] @@ -305,7 +321,7 @@ def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: chunks = [ { "operations": [{"Quantum": {"Measure": [0, 7]}}], - "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": []}], + "lowered_quantum_ops": [{"gate_type": "MZ", "qubits": [0], "angles": [], "measurement_result_ids": [7]}], }, { # allocation/output bookkeeping only; legitimately has no lowered ops "operations": [{"AllocateResult": {"id": 7}}, {"RecordOutput": {"id": 7}}], @@ -509,17 +525,18 @@ def test_from_guppy_constrained_surface_dem_byte_identical( noise=noise, ) assert got == ref_dem, ( - f"constrained surface from_guppy not byte-identical for " - f"d={d}, budget={budget}, basis={basis}, rounds={rounds}" + f"constrained surface from_guppy not byte-identical for d={d}, budget={budget}, basis={basis}, rounds={rounds}" ) def test_constrained_surface_traced_metadata_matches_abstract() -> None: - """The traced TickCircuit's surface metadata is copied verbatim from the - abstract reference. Specifically pins that - ``_copy_surface_tick_circuit_metadata`` propagates ``ancilla_budget`` - (the new key added when the constrained codegen landed) alongside the - existing detectors/observables/counts.""" + """Traced surface metadata preserves structure but binds via MeasIds. + + Runtime traces may reorder measurements, so detector/observable metadata + cannot be copied as positional ``records``. It should preserve the same + detector/observable IDs and descriptors while replacing abstract records + with runtime-stable ``meas_ids``. + """ patch = SurfacePatch.create(distance=3) abstract_tc = _build_surface_tick_circuit_for_native_model( patch, @@ -537,8 +554,6 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: ) for key in ( "basis", - "detectors", - "observables", "num_measurements", "num_detectors", "ancilla_budget", @@ -546,10 +561,109 @@ def test_constrained_surface_traced_metadata_matches_abstract() -> None: a = abstract_tc.get_meta(key) b = traced_tc.get_meta(key) assert a == b, f"metadata mismatch on key {key!r}: abstract={a!r}, traced={b!r}" + + for key in ("detectors", "observables"): + abstract_entries = json.loads(abstract_tc.get_meta(key) or "[]") + traced_entries = json.loads(traced_tc.get_meta(key) or "[]") + assert len(abstract_entries) == len(traced_entries) + for abstract_entry, traced_entry in zip(abstract_entries, traced_entries, strict=True): + assert "records" in abstract_entry + assert "records" not in traced_entry + assert "meas_ids" in traced_entry + assert {k: v for k, v in abstract_entry.items() if k != "records"} == { + k: v for k, v in traced_entry.items() if k != "meas_ids" + } # ancilla_budget specifically must be the requested budget (stored as a string by set_meta). assert traced_tc.get_meta("ancilla_budget") == "2" +@pytest.mark.parametrize("basis", ["X", "Z"]) +def test_native_abstract_surface_dem_uses_record_metadata_only_for_r0(basis: str) -> None: + """Native abstract DEM construction must not mix typed Pauli annotations + with legacy record metadata. + + Public abstract circuits keep typed annotations by default, but the native + surface DEM helper consumes JSON record metadata. Mixing both sources makes + r=0 prep/readout DEMs carry a detectorless logical source that is absent + from the traced-QIS metadata path. + """ + patch = SurfacePatch.create(distance=3) + public_tc = generate_tick_circuit_from_patch(patch, num_rounds=0, basis=basis) + native_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=0, + basis=basis, + circuit_source="abstract", + ) + + assert public_tc.annotations() + assert native_tc.annotations() == [] + assert json.loads(native_tc.get_meta("detectors") or "[]") + assert json.loads(native_tc.get_meta("observables") or "[]") + + noise = NoiseModel(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0) + for decompose_errors in (False, True): + dem_text = generate_circuit_level_dem_from_builder( + patch, + num_rounds=0, + noise=noise, + basis=basis, + circuit_source="abstract", + decompose_errors=decompose_errors, + ) + detectorless_logical_errors = [ + line + for line in dem_text.splitlines() + if line.startswith("error") and "L" in line and "D" not in line + ] + assert detectorless_logical_errors == [] + + +@pytest.mark.parametrize( + ("distance", "ancilla_budget"), + [ + (3, None), + (3, 2), + (9, None), + (9, 17), + ], +) +@pytest.mark.parametrize("basis", ["X", "Z"]) +@pytest.mark.parametrize("rounds", [0, 1, 3]) +def test_surface_memory_round_count_contract( + distance: int, + ancilla_budget: int | None, + basis: str, + rounds: int, +) -> None: + """Surface memory circuits count only full X/Z syndrome rounds as ``r``. + + Logical SPAM is outside ``r``: the prep phase measures only the random-sign + stabilizer family (X checks for Z-basis memory, Z checks for X-basis + memory), and readout measures all data qubits. Restricted and unrestricted + ancilla schedules must preserve that experiment contract. + """ + patch = SurfacePatch.create(distance=distance) + geom = patch.geometry + num_x_checks = len(geom.x_stabilizers) + num_z_checks = len(geom.z_stabilizers) + init_checks = num_z_checks if basis == "X" else num_x_checks + final_check_detectors = num_x_checks if basis == "X" else num_z_checks + expected_measurements = init_checks + rounds * (num_x_checks + num_z_checks) + geom.num_data + expected_detectors = rounds * (num_x_checks + num_z_checks) + final_check_detectors + + tc = generate_tick_circuit_from_patch( + patch, + num_rounds=rounds, + basis=basis, + ancilla_budget=ancilla_budget, + ) + + assert int(tc.get_meta("num_measurements")) == expected_measurements + assert len(json.loads(tc.get_meta("detectors") or "[]")) == expected_detectors + assert json.loads(tc.get_meta("observables") or "[]") + + @pytest.mark.parametrize(("d", "budget"), [(3, 1), (3, 2), (5, 3)]) def test_constrained_surface_lowered_qubit_stream_within_budget(d: int, budget: int) -> None: """The lowered-trace physical qubit IDs must stay within the budgeted @@ -742,7 +856,7 @@ def test_copy_surface_metadata_propagates_descriptors() -> None: assert len(obs_desc) > 0 -def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: +def test_surface_metadata_records_bind_to_runtime_meas_ids() -> None: remap = _measurement_index_remap_for_orders( [0, 1, 0, 2], [1, 0, 2, 0], @@ -763,10 +877,20 @@ def test_surface_metadata_records_remap_to_runtime_measurement_order() -> None: ), ) assert remapped == [ - {"id": 0, "records": [-3, -1]}, - {"id": 1, "records": [-4]}, + {"id": 0, "meas_ids": [1, 3]}, + {"id": 1, "meas_ids": [0]}, ] + existing_meas_ids = json.dumps([{"id": 2, "meas_ids": [0, 3]}]) + rebound = json.loads( + _remap_surface_record_metadata_json( + existing_meas_ids, + measurement_index_remap=remap, + num_measurements=4, + ), + ) + assert rebound == [{"id": 2, "meas_ids": [1, 2]}] + def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: patch = SurfacePatch.create(distance=3) @@ -793,6 +917,53 @@ def test_surface_metadata_records_remap_to_runtime_result_tags() -> None: assert sorted(remap.values()) == list(range(29)) +def test_runtime_result_tags_bind_metadata_when_lowered_measurements_reorder() -> None: + from pecos_rslib.quantum import TickCircuit + + patch = SurfacePatch.create(distance=3) + abstract_tc = generate_tick_circuit_from_patch(patch, num_rounds=0, basis="Z") + result_traces = [ + {"name": "sx0:init:meas:0", "values": [False], "result_ids": [0]}, + {"name": "sx1:init:meas:1", "values": [False], "result_ids": [1]}, + {"name": "sx2:init:meas:2", "values": [False], "result_ids": [2]}, + {"name": "sx3:init:meas:3", "values": [False], "result_ids": [3]}, + { + "name": "final", + "values": [False] * 9, + # Semantic data-qubit order, independent of runtime MZ order. + "result_ids": list(range(4, 13)), + }, + ] + remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) + + traced_tc = TickCircuit() + traced_tc.tick().mz_with_ids([9, 10, 11, 12], [0, 1, 2, 3]) + traced_tc.tick().mz_with_ids( + [5, 0, 4, 1, 8, 3, 7, 6, 2], + [9, 4, 8, 5, 12, 7, 11, 10, 6], + ) + + assert _extract_measurement_meas_ids(traced_tc) != list(range(13)) + _validate_result_tag_remap_against_traced_measurements( + traced_tc, + remap, + expected_measurements=13, + ) + + _copy_surface_tick_circuit_metadata( + abstract_tc, + traced_tc, + measurement_index_remap=remap, + ) + detectors = json.loads(traced_tc.get_meta("detectors")) + observables = json.loads(traced_tc.get_meta("observables")) + assert all("records" not in entry for entry in detectors + observables) + assert all("meas_ids" in entry for entry in detectors + observables) + + logical_z_qubits = list(patch.geometry.logical_z.data_qubits) + assert observables == [{"id": 0, "meas_ids": [4 + q for q in logical_z_qubits]}] + + def test_result_tag_remap_validation_accepts_exact_traced_meas_ids() -> None: from pecos_rslib.quantum import TickCircuit From b50419b352f52db10e7978f2dae1655f9cd18188 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 18:20:15 -0600 Subject: [PATCH 080/388] Expose measurement crosstalk noise bindings --- python/pecos-rslib/src/engine_builders.rs | 26 +++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 2585b87bd..9fd76a2ca 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -1218,6 +1218,32 @@ impl PyGeneralNoiseModelBuilder { }) } + /// Set the probability of global crosstalk during measurement operations + fn with_p_meas_crosstalk_global(&self, prob: f64) -> PyResult { + Ok(Self { + inner: self.inner.clone().with_p_meas_crosstalk_global(prob), + }) + } + + /// Set the probability of local crosstalk during measurement operations + fn with_p_meas_crosstalk_local(&self, prob: f64) -> PyResult { + Ok(Self { + inner: self.inner.clone().with_p_meas_crosstalk_local(prob), + }) + } + + /// Set the transition model for measurement crosstalk + fn with_p_meas_crosstalk_model( + &self, + model: std::collections::BTreeMap, + ) -> PyResult { + use std::collections::BTreeMap; + let btree_map: BTreeMap = model.into_iter().collect(); + Ok(Self { + inner: self.inner.clone().with_p_meas_crosstalk_model(&btree_map), + }) + } + /// Set the scaling factor for measurement errors fn with_meas_scale(&self, scale: f64) -> PyResult { Ok(Self { From 5c4c38806e097b798c37ea8b63f9b0ecff010927 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 18:37:54 -0600 Subject: [PATCH 081/388] Document measurement crosstalk DEM semantics --- docs/development/measurement-crosstalk-dem.md | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 docs/development/measurement-crosstalk-dem.md diff --git a/docs/development/measurement-crosstalk-dem.md b/docs/development/measurement-crosstalk-dem.md new file mode 100644 index 000000000..947395b7f --- /dev/null +++ b/docs/development/measurement-crosstalk-dem.md @@ -0,0 +1,69 @@ +# Measurement Crosstalk DEM Semantics + +This note records the intended long-term semantics for adding measurement-crosstalk +sources to PECOS detector error model generation. The goal is to keep simulator +noise, traced-QIS circuits, raw hypergraph DEMs, and decomposed decoder inputs +consistent without silently substituting an unrelated scalar noise model. + +## Runtime Source Model + +The general noise model represents measurement crosstalk with payload gates: + +- `MeasCrosstalkLocalPayload` identifies local victim qubits. +- `MeasCrosstalkGlobalPayload` identifies excluded active measurement qubits; the + victims are the live prepared qubits not listed in the payload. + +For each candidate victim, the simulator independently samples a crosstalk event +with the local or global payload probability. When an event occurs, the simulator +performs a hidden `MZ` on the victim and samples a transition from the configured +model: + +- `0->0` and `1->1` leave the victim unchanged. +- `0->1` and `1->0` apply `X`. +- `0->L` and `1->L` leak the victim; with `leak2depolar`, leakage is replaced by + an explicit depolarized Pauli/no-op branch in the simulator. + +The transition is conditioned on the hidden measurement outcome, so the exact +channel is not always a fixed Pauli channel independent of circuit state. + +## DEM Requirements + +A crosstalk DEM implementation should satisfy these constraints: + +- Preserve crosstalk as a first-class source family in source metadata. +- Use the actual payload placement from the traced/lowered circuit. +- Derive global-payload victims from the live prepared qubit set at that point in + the circuit, not from static qubit count alone. +- For exact mode, replay each hidden-measurement/transition branch against the + same detector and observable metadata used by the ideal circuit. +- Fail loudly if a crosstalk branch changes measurement dependencies in a way that + cannot be represented as a detector/observable flip against the ideal record. +- Keep any Pauli-twirled or averaged treatment explicit and opt-in; it must not be + used under a name that implies exact crosstalk DEM support. + +## Implementation Plan + +1. Extend `NoiseConfig` with measurement-crosstalk local/global probabilities, + transition weights, and an explicit approximation mode. +2. Add crosstalk payload source extraction in the circuit-aware DEM path. +3. Reuse the exact branch replay machinery where possible: compute the ideal + measurement parity expressions once, then evaluate branch effects by replaying + hidden `MZ` plus the transition action at each payload victim. +4. Emit raw hypergraph DEM contributions with crosstalk source metadata. +5. Extend source-level decomposition so graph-like decoder inputs preserve the + same crosstalk source identity and fail loudly on irreducible branch effects. +6. Thread the new options through Python bindings and surface helper APIs. +7. Keep coverage diagnostics reporting crosstalk as omitted until one of these DEM + modes is explicitly enabled and tested. + +## Minimal Tests + +The first implementation tests should cover: + +- Local payload with a single deterministic victim where `0->1` produces the same + detector flip as an `X` at that spacetime point. +- Local payload with `0->0`/`1->1` only, producing no DEM contribution. +- Global payload victim selection excluding the active measured qubits. +- `leak2depolar` transition expansion into explicit Pauli/no-op branches. +- Fail-loud behavior when hidden measurement changes detector dependencies rather + than only flipping deterministic parities. From 6c1caf78ef95f685de8dfac6251b3ad74dfbad94 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 18:56:02 -0600 Subject: [PATCH 082/388] Add exact deterministic local crosstalk DEM support --- .../src/fault_tolerance/dem_builder.rs | 3 +- .../fault_tolerance/dem_builder/builder.rs | 403 +++++++++++++++++- .../src/fault_tolerance/dem_builder/types.rs | 142 ++++++ docs/development/measurement-crosstalk-dem.md | 34 +- 4 files changed, 570 insertions(+), 12 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 391dcd7b5..01a6649dd 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -100,7 +100,8 @@ pub use sampler::{ pub use types::{ ContributionEffectSummary, ContributionRenderRecord, ContributionRenderStrategy, ContributionRenderSummary, DecomposedFault, DemOutput, DetectorDef, DetectorErrorModel, - DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, MeasurementMechanism, + DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, + MeasurementCrosstalkDemMode, MeasurementCrosstalkTransitionModel, MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, PAULI_1Q_ORDER, PAULI_2Q_ORDER, PauliProbs, PauliWeights, PecosDemMetadataError, PerGateTypeNoise, ReplacementBranchApproximation, ReplacementBranchImpact, TwoDetectorDirectRenderPolicy, combine_probabilities, diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 1125a0af9..8afc2fc71 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -17,14 +17,16 @@ use super::types::{ DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, DirectSourceFamily, - FaultMechanism, NoiseConfig, PerGateTypeNoise, ReplacementBranchApproximation, SourceMetadata, - record_offset_to_absolute_index, + FaultMechanism, MeasurementCrosstalkDemMode, NoiseConfig, PerGateTypeNoise, + ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; use pecos_core::BitSet; use pecos_core::gate_type::GateType; -use pecos_simulators::symbolic_sparse_stab::MeasurementHistory; +use pecos_simulators::{ + SymbolicMeasurementResult, SymbolicSparseStab, symbolic_sparse_stab::MeasurementHistory, +}; use smallvec::SmallVec; use std::cell::RefCell; use std::collections::BTreeMap; @@ -733,6 +735,7 @@ impl<'a> DemBuilder<'a> { self.validate_measurement_count()?; self.validate_metadata_refs()?; self.validate_replacement_branch_approximation()?; + self.validate_measurement_crosstalk_dem_mode()?; Ok(self.build()) } @@ -749,6 +752,8 @@ impl<'a> DemBuilder<'a> { pub fn build(&self) -> DetectorErrorModel { self.validate_replacement_branch_approximation() .expect("invalid DEM replacement branch approximation"); + self.validate_measurement_crosstalk_dem_mode() + .expect("invalid DEM measurement crosstalk configuration"); let num_influence_dem_outputs = self .num_influence_dem_outputs() .max(self.influence_map.dem_output_metadata.len()); @@ -850,6 +855,258 @@ impl<'a> DemBuilder<'a> { Ok(()) } + fn validate_measurement_crosstalk_dem_mode(&self) -> Result<(), DemBuilderError> { + if self.noise.measurement_crosstalk_dem_mode == MeasurementCrosstalkDemMode::Omitted { + return Ok(()); + } + + let has_local_payloads = self + .influence_map + .locations + .iter() + .any(|loc| !loc.before && loc.gate_type == GateType::MeasCrosstalkLocalPayload); + let has_global_payloads = self + .influence_map + .locations + .iter() + .any(|loc| !loc.before && loc.gate_type == GateType::MeasCrosstalkGlobalPayload); + + if !self.noise.p_meas_crosstalk_model.is_valid() { + return Err(DemBuilderError::ConfigurationError( + "measurement crosstalk transition probabilities must be finite, non-negative, and have each hidden-outcome row sum <= 1" + .to_string(), + )); + } + + if has_global_payloads && self.noise.p_meas_crosstalk_global > 0.0 { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay does not yet support global payloads" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_local <= 0.0 || !has_local_payloads { + return Ok(()); + } + + if self.noise.p_meas_crosstalk_model.has_leakage() { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay does not yet support leakage transitions" + .to_string(), + )); + } + + let Some(context) = self.exact_branch_context else { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay requires a circuit-aware builder context" + .to_string(), + )); + }; + + for (loc_idx, loc) in self.influence_map.locations.iter().enumerate() { + if loc.before || loc.gate_type != GateType::MeasCrosstalkLocalPayload { + continue; + } + let result = self.hidden_mz_result_before_crosstalk_payload(context, loc)?; + if !result.is_deterministic || !result.outcome.is_empty() { + return Err(DemBuilderError::ConfigurationError(format!( + "exact deterministic measurement crosstalk DEM replay requires a state-independent hidden MZ result at location {loc_idx} (node {}, qubit {:?}); got deterministic={}, dependencies={:?}", + loc.node, + loc.qubits.first(), + result.is_deterministic, + result.outcome + ))); + } + } + + Ok(()) + } + + fn hidden_mz_result_before_crosstalk_payload( + &self, + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + ) -> Result { + let qubit = loc.qubits.first().copied().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload at node {} has no victim qubit", + loc.node + )) + })?; + let qubit_index = qubit.index(); + let topo_order = context.circuit.topological_order(); + let max_qubit = topo_order + .iter() + .filter_map(|&node| context.circuit.gate(node)) + .flat_map(|gate| gate.qubits.iter()) + .map(pecos_core::QubitId::index) + .max() + .unwrap_or(qubit_index); + let mut sim = SymbolicSparseStab::new(max_qubit.max(qubit_index) + 1); + + for node in topo_order { + if node == loc.node { + return sim.mz(&[qubit_index]).into_iter().next().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "failed to synthesize hidden MZ for measurement crosstalk payload at node {}", + loc.node + )) + }); + } + + if let Some(gate) = context.circuit.gate(node) { + let qubits: Vec = + gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + Self::apply_symbolic_gate_for_crosstalk_hidden_mz( + &mut sim, + node, + gate.gate_type, + &qubits, + )?; + } + } + + Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload node {} was not found in the replay circuit", + loc.node + ))) + } + + fn apply_symbolic_gate_for_crosstalk_hidden_mz( + sim: &mut SymbolicSparseStab, + node: usize, + gate_type: GateType, + qubits: &[usize], + ) -> Result<(), DemBuilderError> { + let require = |n: usize| -> Result<(), DemBuilderError> { + if qubits.len() < n { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk replay expected gate {:?} at node {} to have at least {} qubit(s), got {}", + gate_type, + node, + n, + qubits.len() + ))); + } + Ok(()) + }; + + match gate_type { + GateType::H => { + require(1)?; + sim.h(&[qubits[0]]); + } + GateType::F => { + require(1)?; + sim.sx(&[qubits[0]]); + sim.sz(&[qubits[0]]); + } + GateType::Fdg => { + require(1)?; + sim.szdg(&[qubits[0]]); + sim.sxdg(&[qubits[0]]); + } + GateType::SX => { + require(1)?; + sim.sx(&[qubits[0]]); + } + GateType::SXdg => { + require(1)?; + sim.sxdg(&[qubits[0]]); + } + GateType::SY => { + require(1)?; + sim.sy(&[qubits[0]]); + } + GateType::SYdg => { + require(1)?; + sim.sydg(&[qubits[0]]); + } + GateType::SZ => { + require(1)?; + sim.sz(&[qubits[0]]); + } + GateType::SZdg => { + require(1)?; + sim.szdg(&[qubits[0]]); + } + GateType::X => { + require(1)?; + sim.x(&[qubits[0]]); + } + GateType::Y => { + require(1)?; + sim.y(&[qubits[0]]); + } + GateType::Z => { + require(1)?; + sim.z(&[qubits[0]]); + } + GateType::CX => { + require(2)?; + sim.cx(&[(qubits[0], qubits[1])]); + } + GateType::CY => { + require(2)?; + sim.cy(&[(qubits[0], qubits[1])]); + } + GateType::CZ => { + require(2)?; + sim.cz(&[(qubits[0], qubits[1])]); + } + GateType::SXX => { + require(2)?; + sim.sxx(&[(qubits[0], qubits[1])]); + } + GateType::SXXdg => { + require(2)?; + sim.sxxdg(&[(qubits[0], qubits[1])]); + } + GateType::SYY => { + require(2)?; + sim.syy(&[(qubits[0], qubits[1])]); + } + GateType::SYYdg => { + require(2)?; + sim.syydg(&[(qubits[0], qubits[1])]); + } + GateType::SZZ => { + require(2)?; + sim.szz(&[(qubits[0], qubits[1])]); + } + GateType::SZZdg => { + require(2)?; + sim.szzdg(&[(qubits[0], qubits[1])]); + } + GateType::SWAP => { + require(2)?; + sim.swap(&[(qubits[0], qubits[1])]); + } + GateType::MZ | GateType::MeasureFree => { + require(1)?; + sim.mz(&[qubits[0]]); + } + GateType::PZ | GateType::QAlloc => { + require(1)?; + sim.pz(qubits[0]); + } + GateType::I + | GateType::Idle + | GateType::QFree + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload + | GateType::TrackedPauliMeta => {} + _ => { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact deterministic replay does not support gate {:?} before payload node {}", + gate_type, node + ))); + } + } + + Ok(()) + } + #[cfg(test)] fn exact_omitted_branch_base_effect( &self, @@ -1032,6 +1289,19 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } + GateType::MeasCrosstalkLocalPayload + if !loc.before + && self.noise.measurement_crosstalk_dem_mode + == MeasurementCrosstalkDemMode::ExactDeterministic + && self.noise.p_meas_crosstalk_local > 0.0 => + { + self.process_measurement_crosstalk_local_source_tracked( + loc_idx, + dem, + meas_to_detectors, + meas_to_observables, + ); + } gate_type if is_two_qubit_noise_gate(gate_type) && !loc.before => {} GateType::H | GateType::F @@ -1267,6 +1537,44 @@ impl<'a> DemBuilder<'a> { } } + /// Processes local measurement-crosstalk payloads when hidden outcomes are + /// deterministic and state-independent. + fn process_measurement_crosstalk_local_source_tracked( + &self, + loc_idx: usize, + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let context = self + .exact_branch_context + .expect("measurement crosstalk exact deterministic mode was validated with context"); + let loc = &self.influence_map.locations[loc_idx]; + let hidden = self + .hidden_mz_result_before_crosstalk_payload(context, loc) + .expect("measurement crosstalk exact deterministic hidden result was validated"); + let transition_probability = if hidden.flip { + self.noise.p_meas_crosstalk_model.p_1_to_0 + } else { + self.noise.p_meas_crosstalk_model.p_0_to_1 + }; + let p = self.noise.p_meas_crosstalk_local * transition_probability; + if p <= 0.0 { + return; + } + + let mechanism = + self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); + if !mechanism.is_empty() { + dem.add_direct_contribution_with_source( + mechanism, + p, + SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } + } + /// Processes a single-qubit gate fault with source tracking. /// `rates` is `[rate_X, rate_Y, rate_Z]` -- zero entries are skipped. fn process_single_qubit_fault_source_tracked( @@ -3708,6 +4016,95 @@ mod tests { assert!(branch_pauli_effect.is_empty()); } + fn single_qubit_local_crosstalk_circuit(pre_payload_h: bool) -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + if pre_payload_h { + circuit.add_gate_auto_wire(Gate::h(&[QubitId(0)])); + } + circuit.add_gate_auto_wire(Gate::meas_crosstalk_local_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_emits_dem_source() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic local measurement crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!(contributions.len(), 1); + assert!((contributions[0].probability - 0.1).abs() < 1.0e-12); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::MeasurementCrosstalk) + ); + assert_eq!( + contributions[0].source_gate_types.as_slice(), + &[GateType::MeasCrosstalkLocalPayload] + ); + assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_identity_transition_is_empty() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.0, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("identity local measurement crosstalk should be representable"); + + assert_eq!(dem.num_contributions(), 0); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_rejects_hidden_randomness() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(true); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("nondeterministic hidden measurement must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("state-independent hidden MZ result"), + "unexpected error: {err}" + ); + } + #[test] fn test_from_circuit_exact_branch_replay_skips_empty_replacement_effect() { use crate::fault_tolerance::dem_builder::PauliWeights; diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index e629bfe59..d637d19ed 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -134,6 +134,9 @@ pub enum DirectSourceFamily { /// Two-location direct source produced by exact replacement-branch replay. TwoLocationExactReplacementBranch, + /// Single-location direct source produced by measurement-crosstalk replay. + MeasurementCrosstalk, + /// Fallback for other direct-source shapes. Other, } @@ -2496,6 +2499,97 @@ pub struct NoiseConfig { pub p_idle_x_quadratic_rate: f64, /// Stochastic Y-memory error rate quadratic in idle duration. pub p_idle_y_quadratic_rate: f64, + /// Per-payload local measurement-crosstalk event rate. + /// + /// This rate is multiplied by the selected hidden-measurement transition + /// probability from [`MeasurementCrosstalkTransitionModel`] when crosstalk + /// DEM replay is enabled. + pub p_meas_crosstalk_local: f64, + /// Per-payload global measurement-crosstalk event rate. + /// + /// Global payload DEM replay is intentionally not implemented yet because + /// the source semantics need to be represented explicitly. If exact + /// crosstalk DEM replay is requested with a positive global rate, the DEM + /// builder fails loudly. + pub p_meas_crosstalk_global: f64, + /// Hidden-measurement transition probabilities used by measurement + /// crosstalk DEM replay. + pub p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel, + /// Policy for converting measurement-crosstalk payloads into DEM sources. + pub measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode, +} + +/// Policy for converting runtime measurement-crosstalk payloads into DEMs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum MeasurementCrosstalkDemMode { + /// Ignore measurement-crosstalk payloads when constructing DEMs. + #[default] + Omitted, + /// Replay payloads exactly when the hidden measurement outcome is + /// deterministic and state-independent. + ExactDeterministic, +} + +/// Hidden-measurement transition probabilities for local measurement crosstalk. +/// +/// The no-op probabilities are implicit: +/// `p(0->0) = 1 - p_0_to_1 - p_0_to_leak` and +/// `p(1->1) = 1 - p_1_to_0 - p_1_to_leak`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MeasurementCrosstalkTransitionModel { + /// Probability that a hidden 0 result flips to 1. + pub p_0_to_1: f64, + /// Probability that a hidden 0 result leaks. + pub p_0_to_leak: f64, + /// Probability that a hidden 1 result flips to 0. + pub p_1_to_0: f64, + /// Probability that a hidden 1 result leaks. + pub p_1_to_leak: f64, +} + +impl MeasurementCrosstalkTransitionModel { + /// Creates a no-leakage measurement-crosstalk transition model. + #[must_use] + pub const fn bit_flip(p_0_to_1: f64, p_1_to_0: f64) -> Self { + Self { + p_0_to_1, + p_0_to_leak: 0.0, + p_1_to_0, + p_1_to_leak: 0.0, + } + } + + /// Returns true if all transition probabilities are finite and valid. + #[must_use] + pub fn is_valid(&self) -> bool { + self.p_0_to_1.is_finite() + && self.p_0_to_leak.is_finite() + && self.p_1_to_0.is_finite() + && self.p_1_to_leak.is_finite() + && self.p_0_to_1 >= 0.0 + && self.p_0_to_leak >= 0.0 + && self.p_1_to_0 >= 0.0 + && self.p_1_to_leak >= 0.0 + && self.p_0_to_1 + self.p_0_to_leak <= 1.0 + f64::EPSILON + && self.p_1_to_0 + self.p_1_to_leak <= 1.0 + f64::EPSILON + } + + /// Returns true when any leakage transition probability is non-zero. + #[must_use] + pub fn has_leakage(&self) -> bool { + self.p_0_to_leak > 0.0 || self.p_1_to_leak > 0.0 + } +} + +impl Default for MeasurementCrosstalkTransitionModel { + fn default() -> Self { + Self { + p_0_to_1: 0.0, + p_0_to_leak: 0.0, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + } + } } /// Per-Pauli error probabilities for a single qubit. @@ -2568,6 +2662,10 @@ impl Default for NoiseConfig { p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } } @@ -2594,6 +2692,10 @@ impl NoiseConfig { p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -2618,6 +2720,10 @@ impl NoiseConfig { p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -2642,6 +2748,10 @@ impl NoiseConfig { p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_meas_crosstalk_local: 0.0, + p_meas_crosstalk_global: 0.0, + p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), + measurement_crosstalk_dem_mode: MeasurementCrosstalkDemMode::default(), } } @@ -2748,6 +2858,37 @@ impl NoiseConfig { self } + /// Sets the local measurement-crosstalk payload event rate. + #[must_use] + pub fn set_measurement_crosstalk_local_rate(mut self, rate: f64) -> Self { + self.p_meas_crosstalk_local = rate.max(0.0); + self + } + + /// Sets the global measurement-crosstalk payload event rate. + #[must_use] + pub fn set_measurement_crosstalk_global_rate(mut self, rate: f64) -> Self { + self.p_meas_crosstalk_global = rate.max(0.0); + self + } + + /// Sets the hidden-measurement transition model for crosstalk DEM replay. + #[must_use] + pub fn set_measurement_crosstalk_transition_model( + mut self, + model: MeasurementCrosstalkTransitionModel, + ) -> Self { + self.p_meas_crosstalk_model = model; + self + } + + /// Sets the policy for converting measurement-crosstalk payloads into DEMs. + #[must_use] + pub fn set_measurement_crosstalk_dem_mode(mut self, mode: MeasurementCrosstalkDemMode) -> Self { + self.measurement_crosstalk_dem_mode = mode; + self + } + /// Sets idle noise from a coherent RZ rotation angle per time unit. /// /// Converts `idle_rz` (the angle theta of an RZ(theta) rotation applied @@ -4250,6 +4391,7 @@ impl DetectorErrorModel { DirectSourceFamily::TwoLocationExactReplacementBranch => { "TwoLocationExactReplacementBranch" } + DirectSourceFamily::MeasurementCrosstalk => "MeasurementCrosstalk", DirectSourceFamily::Other => "Other", } } diff --git a/docs/development/measurement-crosstalk-dem.md b/docs/development/measurement-crosstalk-dem.md index 947395b7f..fc50d5c3b 100644 --- a/docs/development/measurement-crosstalk-dem.md +++ b/docs/development/measurement-crosstalk-dem.md @@ -41,20 +41,38 @@ A crosstalk DEM implementation should satisfy these constraints: - Keep any Pauli-twirled or averaged treatment explicit and opt-in; it must not be used under a name that implies exact crosstalk DEM support. +## Implemented Mode + +`NoiseConfig` now exposes `MeasurementCrosstalkDemMode::ExactDeterministic` +for the local-payload subset. In this mode the circuit-aware DEM builder: + +- Replays the ideal Clifford circuit up to each `MeasCrosstalkLocalPayload`. +- Synthesizes the hidden `MZ` on the payload victim. +- Requires that hidden result to be deterministic and state-independent. +- Emits an `X`-equivalent DEM source with `DirectSourceFamily::MeasurementCrosstalk` + for `0->1` or `1->0` transitions. +- Emits no contribution for implicit `0->0` or `1->1` transitions. +- Fails loudly if global payloads, leakage transitions, missing circuit context, + unsupported pre-payload gates, or nondeterministic hidden outcomes are present. + +This mode is intentionally narrow: it is exact for the deterministic local cases +it accepts, and it rejects cases that still need a branch-level representation. + ## Implementation Plan -1. Extend `NoiseConfig` with measurement-crosstalk local/global probabilities, - transition weights, and an explicit approximation mode. -2. Add crosstalk payload source extraction in the circuit-aware DEM path. +1. Extend the exact crosstalk DEM path beyond deterministic local bit-flip + transitions. +2. Add global-payload victim selection from the live prepared qubit set. 3. Reuse the exact branch replay machinery where possible: compute the ideal measurement parity expressions once, then evaluate branch effects by replaying hidden `MZ` plus the transition action at each payload victim. -4. Emit raw hypergraph DEM contributions with crosstalk source metadata. -5. Extend source-level decomposition so graph-like decoder inputs preserve the +4. Add `leak2depolar` transition expansion into explicit Pauli/no-op branches. +5. Emit raw hypergraph DEM contributions with crosstalk source metadata. +6. Extend source-level decomposition so graph-like decoder inputs preserve the same crosstalk source identity and fail loudly on irreducible branch effects. -6. Thread the new options through Python bindings and surface helper APIs. -7. Keep coverage diagnostics reporting crosstalk as omitted until one of these DEM - modes is explicitly enabled and tested. +7. Thread the new options through Python bindings and surface helper APIs. +8. Keep coverage diagnostics reporting unsupported crosstalk branches as omitted + until the relevant DEM modes are explicitly enabled and tested. ## Minimal Tests From 96425bb9aa251f873f05a2e5b9cc385090095819 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 19:41:57 -0600 Subject: [PATCH 083/388] Expose measurement crosstalk DEM options to Python --- .../src/fault_tolerance_bindings.rs | 130 +++++++++++++++++- 1 file changed, 123 insertions(+), 7 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index fa6c36b52..021316745 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -51,8 +51,9 @@ use pecos_qec::fault_tolerance::dem_builder::{ DemSampler as RustNewDemSampler, DemSamplerBuilder as RustNewDemSamplerBuilder, DetectorErrorModel as RustDetectorErrorModel, DirectSourceFamily as RustDirectSourceFamily, EquivalenceResult as RustEquivalenceResult, FaultContribution as RustFaultContribution, - FaultSourceType as RustFaultSourceType, NoiseConfig, PAULI_2Q_ORDER, - ParsedDem as RustParsedDem, PauliWeights, ReplacementBranchApproximation, + FaultSourceType as RustFaultSourceType, MeasurementCrosstalkDemMode, + MeasurementCrosstalkTransitionModel, NoiseConfig, PAULI_2Q_ORDER, ParsedDem as RustParsedDem, + PauliWeights, ReplacementBranchApproximation, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, compare_dems_exact as rust_compare_dems_exact, compare_dems_statistical as rust_compare_dems_statistical, @@ -169,6 +170,64 @@ fn parse_replacement_approximation( } } +fn parse_measurement_crosstalk_dem_mode( + value: Option, +) -> PyResult { + let Some(value) = value else { + return Ok(MeasurementCrosstalkDemMode::default()); + }; + match value + .trim() + .to_ascii_lowercase() + .replace(['-', ' '], "_") + .as_str() + { + "omitted" | "omit" | "none" | "off" => Ok(MeasurementCrosstalkDemMode::Omitted), + "exact_deterministic" | "exact" | "deterministic" => { + Ok(MeasurementCrosstalkDemMode::ExactDeterministic) + } + _ => Err(pyo3::exceptions::PyValueError::new_err( + "measurement_crosstalk_dem_mode must be 'omitted' or 'exact_deterministic'", + )), + } +} + +fn parse_measurement_crosstalk_transition_model( + value: Option>, +) -> PyResult { + let Some(value) = value else { + return Ok(MeasurementCrosstalkTransitionModel::default()); + }; + let mut model = MeasurementCrosstalkTransitionModel::default(); + for (key, probability) in value { + if !probability.is_finite() || probability < 0.0 { + let msg = format!( + "measurement crosstalk transition probability for {key:?} must be finite and non-negative" + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + match key.trim().to_ascii_uppercase().replace(' ', "").as_str() { + "0->0" | "1->1" => {} + "0->1" => model.p_0_to_1 = probability, + "0->L" => model.p_0_to_leak = probability, + "1->0" => model.p_1_to_0 = probability, + "1->L" => model.p_1_to_leak = probability, + _ => { + let msg = format!( + "unsupported measurement crosstalk transition key {key:?}; expected 0->0, 0->1, 0->L, 1->0, 1->1, or 1->L" + ); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + } + } + if !model.is_valid() { + return Err(pyo3::exceptions::PyValueError::new_err( + "measurement crosstalk transition rows must sum to <= 1", + )); + } + Ok(model) +} + fn apply_noise_options( mut noise: NoiseConfig, p_idle: Option, @@ -185,6 +244,10 @@ fn apply_noise_options( p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { @@ -223,6 +286,18 @@ fn apply_noise_options( noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( p2_replacement_approximation, )?); + if let Some(rate) = p_meas_crosstalk_local { + noise = noise.set_measurement_crosstalk_local_rate(rate); + } + if let Some(rate) = p_meas_crosstalk_global { + noise = noise.set_measurement_crosstalk_global_rate(rate); + } + noise = noise.set_measurement_crosstalk_transition_model( + parse_measurement_crosstalk_transition_model(p_meas_crosstalk_model)?, + ); + noise = noise.set_measurement_crosstalk_dem_mode(parse_measurement_crosstalk_dem_mode( + measurement_crosstalk_dem_mode, + )?); Ok(noise) } @@ -1071,6 +1146,7 @@ fn contribution_record_to_pydict( RustDirectSourceFamily::TwoLocationExactReplacementBranch => { "TwoLocationExactReplacementBranch" } + RustDirectSourceFamily::MeasurementCrosstalk => "MeasurementCrosstalk", RustDirectSourceFamily::Other => "Other", }; dict.set_item("direct_source_family", family_label)?; @@ -1125,7 +1201,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1147,6 +1223,10 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; @@ -1166,6 +1246,10 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; if let Ok(dag) = circuit.extract::>() @@ -1517,7 +1601,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1539,6 +1623,10 @@ impl PyDemBuilder { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -1556,6 +1644,10 @@ impl PyDemBuilder { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; Ok(slf) } @@ -3418,7 +3510,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3440,6 +3532,10 @@ impl PyDemSampler { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3457,6 +3553,10 @@ impl PyDemSampler { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; // Accept both DagCircuit and TickCircuit @@ -3567,7 +3667,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3591,6 +3691,10 @@ impl PyDemSampler { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3608,6 +3712,10 @@ impl PyDemSampler { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) @@ -4061,7 +4169,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4083,6 +4191,10 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_rate: Option, p2_weights: Option>, p2_replacement_approximation: Option, + p_meas_crosstalk_local: Option, + p_meas_crosstalk_global: Option, + p_meas_crosstalk_model: Option>, + measurement_crosstalk_dem_mode: Option, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -4100,6 +4212,10 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_rate, p2_weights, p2_replacement_approximation, + p_meas_crosstalk_local, + p_meas_crosstalk_global, + p_meas_crosstalk_model, + measurement_crosstalk_dem_mode, )?; Ok(slf) } From e20eeaf6db765ec0fdcb03efccbe4f72c307d1e9 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 20:35:51 -0600 Subject: [PATCH 084/388] Fail loudly for crosstalk DEMs without payloads --- .../fault_tolerance/dem_builder/builder.rs | 79 ++++++++++++++++++- 1 file changed, 77 insertions(+), 2 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 8afc2fc71..ab0b8fa5a 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -878,14 +878,28 @@ impl<'a> DemBuilder<'a> { )); } - if has_global_payloads && self.noise.p_meas_crosstalk_global > 0.0 { + if self.noise.p_meas_crosstalk_global > 0.0 && !has_global_payloads { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay requested a positive global rate, but the influence map contains no MeasCrosstalkGlobalPayload locations" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_global > 0.0 { return Err(DemBuilderError::ConfigurationError( "exact deterministic measurement crosstalk DEM replay does not yet support global payloads" .to_string(), )); } - if self.noise.p_meas_crosstalk_local <= 0.0 || !has_local_payloads { + if self.noise.p_meas_crosstalk_local > 0.0 && !has_local_payloads { + return Err(DemBuilderError::ConfigurationError( + "exact deterministic measurement crosstalk DEM replay requested a positive local rate, but the influence map contains no MeasCrosstalkLocalPayload locations" + .to_string(), + )); + } + + if self.noise.p_meas_crosstalk_local <= 0.0 { return Ok(()); } @@ -4035,6 +4049,21 @@ mod tests { circuit } + fn single_qubit_no_crosstalk_payload_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(0)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_emits_dem_source() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; @@ -4064,6 +4093,52 @@ mod tests { assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); } + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_requires_payloads() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_no_crosstalk_payload_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("positive local crosstalk rate without payloads must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("no MeasCrosstalkLocalPayload locations"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_exact_deterministic_global_measurement_crosstalk_requires_payloads() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_no_crosstalk_payload_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("positive global crosstalk rate without payloads must fail loudly"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string() + .contains("no MeasCrosstalkGlobalPayload locations"), + "unexpected error: {err}" + ); + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_identity_transition_is_empty() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; From 4424532b4e238989c703d1052b234891e8268410 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 21:00:27 -0600 Subject: [PATCH 085/388] Expose crosstalk payload gate types to Python --- python/pecos-rslib/pecos_rslib.pyi | 2 ++ .../pecos-rslib/src/dag_circuit_bindings.rs | 16 +++++++++ .../tests/qec/test_dem_metadata_fail_loud.py | 33 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index afab181c1..1d78f7d76 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1442,6 +1442,8 @@ class GateType: QAlloc: GateType QFree: GateType TrackedPauliMeta: GateType + MeasCrosstalkGlobalPayload: GateType + MeasCrosstalkLocalPayload: GateType Custom: GateType @property diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index f033a6b22..14fb496c7 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -613,6 +613,22 @@ impl PyGateType { } } + #[classattr] + #[pyo3(name = "MeasCrosstalkGlobalPayload")] + fn meas_crosstalk_global_payload() -> Self { + Self { + inner: GateType::MeasCrosstalkGlobalPayload, + } + } + + #[classattr] + #[pyo3(name = "MeasCrosstalkLocalPayload")] + fn meas_crosstalk_local_payload() -> Self { + Self { + inner: GateType::MeasCrosstalkLocalPayload, + } + } + #[classattr] #[pyo3(name = "Custom")] fn custom() -> Self { diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py index 97da0d66a..584104062 100644 --- a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -11,6 +11,7 @@ import pytest from pecos_rslib import DagCircuit +from pecos_rslib.quantum import Gate, GateType from pecos_rslib.qec import ( DagFaultAnalyzer, DemBuilder, @@ -49,6 +50,38 @@ def test_valid_metadata_builds_on_all_paths() -> None: assert builder.build().num_detectors == 1 +def test_exact_measurement_crosstalk_payload_emits_python_source_record() -> None: + dag = DagCircuit() + prep = dag.add_gate(Gate(GateType.Prep, qubits=[0])) + payload = dag.add_gate(Gate(GateType.MeasCrosstalkLocalPayload, qubits=[0])) + meas = dag.add_gate(Gate(GateType.Measure, qubits=[0])) + dag.connect(prep, payload, 0) + dag.connect(payload, meas, 0) + dag.set_attr("num_measurements", "1") + dag.set_attr("detectors", '[{"id": 0, "records": [-1]}]') + + im = DagFaultAnalyzer(dag).build_influence_map() + builder = DemBuilder(im) + builder.with_noise( + p1=0.0, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p_meas_crosstalk_local=0.25, + p_meas_crosstalk_model={"0->1": 0.4}, + measurement_crosstalk_dem_mode="exact_deterministic", + ) + builder.with_num_measurements(1) + builder.with_detectors_json('[{"id": 0, "records": [-1]}]') + builder.with_exact_branch_replay_circuit(dag) + dem = builder.build_with_source_tracking() + + records = dem.contribution_render_records() + assert len(records) == 1 + assert records[0]["direct_source_family"] == "MeasurementCrosstalk" + assert records[0]["gate_type_labels"] == ["MeasCrosstalkLocalPayload"] + + # --- out-of-range record offsets ------------------------------------------- From 594dc8b83353aeb78a06c6724464f70d762e983a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 21:23:26 -0600 Subject: [PATCH 086/388] Preserve crosstalk payload gates in traced replay --- crates/pecos-core/src/gate_type.rs | 36 +++++++++++--- .../pecos-rslib/src/dag_circuit_bindings.rs | 15 ++++-- .../src/pecos/qec/surface/decode.py | 4 ++ .../tests/qec/test_from_guppy_dem.py | 49 +++++++++++++++++-- 4 files changed, 90 insertions(+), 14 deletions(-) diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index 63c532ca8..66ab8046d 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -271,8 +271,9 @@ impl GateType { /// /// # Returns /// - /// The number of qubits this gate type requires. All current gate types - /// have a fixed number of qubits (1 or 2). + /// The number of qubits this gate type requires. Variable-arity + /// payload/meta gates return 1 for compatibility with validation code; the + /// concrete gate stores the actual qubit count. #[must_use] pub const fn quantum_arity(self) -> usize { match self { @@ -304,13 +305,12 @@ impl GateType { | GateType::QAlloc | GateType::QFree | GateType::Idle + | GateType::Custom + // Payload/meta gates are variable-arity but return 1 here because + // validation checks `is_multiple_of(quantum_arity())`, and any + // count is a multiple of 1. The actual qubit count is in the gate. | GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload - | GateType::Custom - // TrackedPauliMeta and Channel are variable-arity but return 1 - // here because gate validation checks - // `is_multiple_of(quantum_arity())` and any count is a multiple - // of 1. The actual qubit count is in the gate. | GateType::Channel | GateType::TrackedPauliMeta => 1, @@ -527,6 +527,12 @@ impl std::str::FromStr for GateType { "QFREE" => Ok(GateType::QFree), "IDLE" => Ok(GateType::Idle), "TRACKEDPAULI" | "TRACKEDPAULIMETA" | "TP" => Ok(GateType::TrackedPauliMeta), + "MEASCROSSTALKGLOBALPAYLOAD" | "MEAS_CROSSTALK_GLOBAL_PAYLOAD" => { + Ok(GateType::MeasCrosstalkGlobalPayload) + } + "MEASCROSSTALKLOCALPAYLOAD" | "MEAS_CROSSTALK_LOCAL_PAYLOAD" => { + Ok(GateType::MeasCrosstalkLocalPayload) + } "CHANNEL" => Ok(GateType::Channel), _ => Err(format!("Unknown gate type: {s}")), } @@ -611,6 +617,14 @@ mod tests { assert_eq!(GateType::from_str("Channel").unwrap(), GateType::Channel); assert_eq!(GateType::from_str("SWAP").unwrap(), GateType::SWAP); assert_eq!(GateType::from_str("CCX").unwrap(), GateType::CCX); + assert_eq!( + GateType::from_str("MeasCrosstalkGlobalPayload").unwrap(), + GateType::MeasCrosstalkGlobalPayload + ); + assert_eq!( + GateType::from_str("MeasCrosstalkLocalPayload").unwrap(), + GateType::MeasCrosstalkLocalPayload + ); // Aliases assert_eq!(GateType::from_str("CNOT").unwrap(), GateType::CX); @@ -618,6 +632,14 @@ mod tests { assert_eq!(GateType::from_str("S").unwrap(), GateType::SZ); assert_eq!(GateType::from_str("TOFFOLI").unwrap(), GateType::CCX); assert_eq!(GateType::from_str("init |0>").unwrap(), GateType::PZ); + assert_eq!( + GateType::from_str("meas_crosstalk_global_payload").unwrap(), + GateType::MeasCrosstalkGlobalPayload + ); + assert_eq!( + GateType::from_str("meas_crosstalk_local_payload").unwrap(), + GateType::MeasCrosstalkLocalPayload + ); // Case-insensitive matching assert_eq!(GateType::from_str("h").unwrap(), GateType::H); diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 14fb496c7..515f688a1 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -3496,11 +3496,18 @@ impl PyTickHandle { ))); } - // Determine if we need to broadcast (e.g. single-qubit gate on multiple qubits) - let needs_broadcast = - arity > 0 && qubits.len() > arity && qubits.len().is_multiple_of(arity); + let variable_arity_payload = gate_type.is_crosstalk_payload() + || matches!(gate_type, GateType::Channel | GateType::TrackedPauliMeta); - if arity > 0 && qubits.len() != arity && !needs_broadcast { + // Determine if we need to broadcast (e.g. single-qubit gate on multiple qubits). + // Payload/meta gates carry their qubit list as data and must remain a single gate. + let needs_broadcast = !variable_arity_payload + && arity > 0 + && qubits.len() > arity + && qubits.len().is_multiple_of(arity); + + if !variable_arity_payload && arity > 0 && qubits.len() != arity && !needs_broadcast + { return Err(pyo3::exceptions::PyValueError::new_err(format!( "Gate '{name}' requires {} qubit(s), got {} (not a valid multiple)", arity, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index d72dda77e..927aa6913 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1023,6 +1023,10 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" raise ValueError(msg) tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) + elif gate_type == "MeasCrosstalkGlobalPayload": + tick.add_gate("MeasCrosstalkGlobalPayload", qubits) + elif gate_type == "MeasCrosstalkLocalPayload": + tick.add_gate("MeasCrosstalkLocalPayload", qubits) elif gate_type == "RX": tick.rx(angles[0], qubits) elif gate_type == "RY": diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 7364ff563..ba6b1d623 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -84,6 +84,16 @@ def _flat_idle_gates(tc) -> list[tuple[list[int], float]]: return idles +def _flat_gate_qubits(tc, gate_type_name: str) -> list[list[int]]: + dag = tc.to_dag_circuit() + gate_qubits: list[list[int]] = [] + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate is not None and gate.gate_type.name == gate_type_name: + gate_qubits.append(list(gate.qubits)) + return gate_qubits + + def test_from_guppy_meas_ids_are_normalized_to_records() -> None: assert _dem_text(detectors_json='[{"id":0,"meas_ids":[0]}]') == _dem_text( detectors_json='[{"id":0,"records":[-1]}]', @@ -200,6 +210,41 @@ def test_lowered_replay_preserves_runtime_idles() -> None: assert _flat_idle_gates(tc) == [([0], 20.0)] +def test_lowered_replay_preserves_measurement_crosstalk_payloads() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + { + "gate_type": "MeasCrosstalkLocalPayload", + "qubits": [1, 2], + "angles": [], + "params": [], + }, + { + "gate_type": "MeasCrosstalkGlobalPayload", + "qubits": [3, 4], + "angles": [], + "params": [], + }, + { + "gate_type": "MZ", + "qubits": [0], + "angles": [], + "params": [], + "measurement_result_ids": [0], + }, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert _flat_gate_qubits(tc, "MeasCrosstalkLocalPayload") == [[1, 2]] + assert _flat_gate_qubits(tc, "MeasCrosstalkGlobalPayload") == [[3, 4]] + + def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: from pecos.qec import DetectorErrorModel @@ -612,9 +657,7 @@ def test_native_abstract_surface_dem_uses_record_metadata_only_for_r0(basis: str decompose_errors=decompose_errors, ) detectorless_logical_errors = [ - line - for line in dem_text.splitlines() - if line.startswith("error") and "L" in line and "D" not in line + line for line in dem_text.splitlines() if line.startswith("error") and "L" in line and "D" not in line ] assert detectorless_logical_errors == [] From 53c1074bff486860edc0d78a93d3d383dabbe38e Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 10 Jun 2026 21:31:26 -0600 Subject: [PATCH 087/388] Document noise event replay diagnostics --- docs/development/noise-event-replay.md | 146 +++++++++++++++++++++++++ mkdocs.yml | 1 + 2 files changed, 147 insertions(+) create mode 100644 docs/development/noise-event-replay.md diff --git a/docs/development/noise-event-replay.md b/docs/development/noise-event-replay.md new file mode 100644 index 000000000..3b8ed9955 --- /dev/null +++ b/docs/development/noise-event-replay.md @@ -0,0 +1,146 @@ +# Noise Event Replay and Failure Diagnostics + +This note records a future debugging path for understanding logical failures in +noisy circuit simulations. The goal is to make sampled noise events +reproducible and inspectable without turning normal simulation runs into large +trace dumps. + +## Motivation + +Detector error models tell us how physical error mechanisms can affect +detectors and observables, but they do not show which stochastic noise events +actually occurred in a sampled shot. When a decoder reports a logical failure, +we often want to inspect the concrete sampled events that produced that failure: + +- which noise source sampled an event, +- which ideal gate or payload location it was attached to, +- which branch was selected, +- which qubits and measurement results were involved, +- and whether the resulting syndrome pattern looked decoder-ambiguous. + +This is especially useful for local emulator studies where PECOS controls the +noise sampling. For hardware data, the physical events are not directly known, +but the same machinery can still be used to compare hardware syndromes against +failure patterns from calibrated local simulations. + +## Decoder-Dependent Failure Selection + +"Failed shots only" is a useful logging mode, but it is not intrinsic to the +simulator. Whether a shot is a logical failure depends on the analysis layer: + +- the detector and observable metadata, +- the DEM used for decoding, +- the decoder backend, +- decoder options such as graph-like decomposition or correlated matching, and +- the logical-failure convention used by the experiment. + +For that reason, the simulator should not decide by itself which shots are +interesting. Instead, the preferred long-term flow is: + +1. Run or replay shots with deterministic per-shot seeds. +2. Decode the resulting detection events in the analysis layer. +3. Select failed or otherwise interesting shot ids. +4. Replay only those shot ids with noise-event tracing enabled. + +This keeps logging sparse while preserving a clean separation between simulation +and decoder-specific analysis. + +## Per-Shot Reproducibility + +The key primitive is deterministic replay from stable seeds. A run should be +able to derive all randomness from a root seed and a shot identity: + +```text +run_seed + -> shot_seed(run_seed, shot_index) + -> component_seed(shot_seed, "noise") + -> component_seed(shot_seed, "runtime") + -> component_seed(shot_seed, "decoder" or analysis-only randomness) +``` + +The exact derivation should be explicit, versioned, and independent of worker +count. Replaying `(program, noise_config, runtime_config, run_seed, shot_index)` +should reproduce the same sampled noise events even if the original run used a +different number of workers. + +This is more important than logging everything during the first pass. Sparse +noise logs may be small at low physical error rates, but deterministic replay +lets us defer expensive diagnostics until we know which shots matter. + +## Optional Event Trace Schema + +When tracing is enabled, each sampled non-identity noise event should carry +enough source information to connect it back to the ideal program and DEM source +metadata. A compact JSONL-style record could contain: + +```json +{ + "shot": 17, + "shot_seed": "0x...", + "event_index": 42, + "tick": 19, + "gate_index": 3, + "gate_type": "SZZ", + "gate_qubits": [4, 12], + "source_family": "TwoQubitGate", + "noise_parameter": "p2", + "probability": 0.001, + "branch": "IX", + "random_draw": 0.00042 +} +``` + +Some source families need additional payload fields: + +- idle events should record duration and the axis-specific rate terms, +- replacement branches should record whether the ideal gate was omitted, +- measurement crosstalk should record payload gate type, candidate victim, and + transition label, +- measurement errors should record the measurement result id when available. + +Normal runs should default to no trace. Useful opt-in modes include: + +- trace every sampled event for small debugging runs, +- trace the first `N` shots, +- replay and trace a caller-provided list of shot ids, +- summarize event counts by source family without writing per-event records. + +## Data Volume + +Full logs may be acceptable for small studies because physical error events are +sparse. They can still become large quickly when the number of gates, shots, +distances, or parameter scans increases. The implementation should therefore +avoid coupling ordinary simulation output to full event logs. + +Recommended defaults: + +- store shot seeds or enough metadata to reconstruct them, +- store detection events and logical outcomes as usual, +- write detailed event records only in explicit diagnostic/replay modes, +- support streaming event records so failed-shot replays do not require keeping + all events in memory. + +## Implementation Sketch + +1. Add a worker-independent per-shot seed derivation API. +2. Ensure each simulator/noise layer receives deterministic component RNGs + derived from the shot seed. +3. Add an optional `NoiseEventSink` trait or equivalent callback in Rust. +4. Emit event records only for sampled non-identity branches, with source + metadata attached at the noise-model location. +5. Expose a replay API that accepts explicit shot ids or shot seeds. +6. Add Python helpers that decode first, select failed shots, then replay those + shots with tracing enabled. +7. Add compact summaries that aggregate event counts by source family, gate + type, branch, and qubit region. + +## Open Questions + +- How should runtime-generated events be traced when a runtime has its own RNGs? +- Should event traces include raw random draws, or only branch outcomes plus the + seed needed to reproduce them? +- Which event identifiers should be stable across circuit recompilation, and + which should be explicitly tied to the lowered/traced circuit version? +- How should traces link back to DEM contribution/source records when one sampled + event corresponds to multiple detector/observable effects? + diff --git a/mkdocs.yml b/mkdocs.yml index 521043e26..40b9679f1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,6 +90,7 @@ nav: - AST Infrastructure: development/ast-infrastructure.md - Foreign Language Plugins: development/foreign-plugins.md - Parallel Blocks: development/parallel-blocks-and-optimization.md + - Noise Event Replay: development/noise-event-replay.md - Experimental: - experimental/index.md - Composable Noise (pecos-neo): experimental/composable-noise.md From c7f7fda4d233c204b6c651fa0ff109b1bff9b6a4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 09:14:36 -0600 Subject: [PATCH 088/388] Make sim_neo sampling a builder component (sampling(monte_carlo(shots).workers(n)), importance_sampling(shots)) and enforce explicit .quantum()/.auto() with no silent shot/seed/backend defaults, mirrored across Rust and Python --- exp/pecos-neo/README.md | 8 +- exp/pecos-neo/docs/README.md | 18 +- exp/pecos-neo/docs/dev/design-patterns.md | 63 +- exp/pecos-neo/docs/dev/importance-sampling.md | 12 +- exp/pecos-neo/docs/dev/runner.md | 2 +- exp/pecos-neo/docs/user-guides/events.md | 2 +- .../docs/user-guides/importance-sampling.md | 10 +- exp/pecos-neo/docs/user-guides/noise-model.md | 4 +- exp/pecos-neo/docs/user-guides/noise.md | 6 +- exp/pecos-neo/examples/noise_cookbook.rs | 14 +- exp/pecos-neo/src/noise/composer.rs | 2 +- exp/pecos-neo/src/tool.rs | 7 +- exp/pecos-neo/src/tool/design.md | 25 +- exp/pecos-neo/src/tool/importance.rs | 2 +- exp/pecos-neo/src/tool/simulation.rs | 1178 +++++++++++------ exp/pecos-neo/tests/event_handlers_test.rs | 28 +- exp/pecos-neo/tests/hugr_integration_test.rs | 24 +- .../tests/sim_neo_comparison_test.rs | 112 +- python/pecos-rslib-exp/src/lib.rs | 2 + .../pecos-rslib-exp/src/sim_neo_bindings.rs | 265 +++- .../quantum-pecos/src/pecos/qec/analysis.py | 10 +- .../tests/qec/test_inline_channel_sim_neo.py | 15 +- .../tests/qec/test_meas_sampling_backend.py | 34 +- .../qec/test_meas_sampling_generality.py | 34 +- .../tests/qec/test_qec_ux_entrypoints.py | 2 +- .../tests/qec/test_raw_measurement_result.py | 10 +- .../tests/qec/test_sim_neo_explicit_config.py | 76 ++ .../qec/test_traced_qis_clifford_pipeline.py | 11 +- .../qec/test_traced_qis_slow_integration.py | 6 +- 29 files changed, 1310 insertions(+), 672 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py diff --git a/exp/pecos-neo/README.md b/exp/pecos-neo/README.md index f16a143ae..7768eadff 100644 --- a/exp/pecos-neo/README.md +++ b/exp/pecos-neo/README.md @@ -7,7 +7,7 @@ Composable quantum simulation with event-driven noise modeling. The `sim_neo` Tool API is the recommended entry point: ```rust -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_neo::command::CommandBuilder; let circuit = CommandBuilder::new() @@ -17,9 +17,9 @@ let circuit = CommandBuilder::new() .build(); // Run 1000 shots with depolarizing noise -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .depolarizing(0.01) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .run(); @@ -33,7 +33,7 @@ for outcome in &results.outcomes { - **Composable Noise**: Event-driven channels that combine freely -- depolarizing, measurement, idle, crosstalk, leakage, and custom channels - **Typed Commands**: `GateCommand` and `CommandQueue` with signal support for metadata alongside gates - **Plugin System**: ECS-inspired architecture for bundling simulation functionality -- **Parallel Execution**: Monte Carlo across multiple workers with `.workers(n)` +- **Parallel Execution**: Monte Carlo across multiple workers with `.sampling(monte_carlo(shots).workers(n))` - **Advanced Sampling**: Importance sampling and subset simulation for rare event estimation - **Extensible Gates**: `GateId`-based system with runtime overrides and decomposition - **Program Support**: Classical control engines (QASM, HUGR) with mid-circuit measurement and feedback diff --git a/exp/pecos-neo/docs/README.md b/exp/pecos-neo/docs/README.md index 29680c275..3c37383ae 100644 --- a/exp/pecos-neo/docs/README.md +++ b/exp/pecos-neo/docs/README.md @@ -3,7 +3,7 @@ Build a circuit, add noise, run it: ```rust -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_neo::command::CommandBuilder; let circuit = CommandBuilder::new() @@ -12,9 +12,9 @@ let circuit = CommandBuilder::new() .mz(0).mz(1) .build(); -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .depolarizing(0.01) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .run(); ``` @@ -30,18 +30,18 @@ Everything chains off `sim_neo()`. The sections below show what you can plug in. The simplest option -- uniform depolarizing noise: ```rust -sim_neo(circuit).depolarizing(0.01).shots(1000).run(); +sim_neo(circuit).auto().depolarizing(0.01).sampling(monte_carlo(1000)).run(); ``` Need per-channel control (single-qubit, two-qubit, measurement)? ```rust -sim_neo(circuit) +sim_neo(circuit).auto() .noise(GeneralNoiseModelBuilder::new() .with_p1(0.001) .with_p2(0.01) .with_p_meas_symmetric(0.005)) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); ``` @@ -57,15 +57,15 @@ Full guide: [Adding Noise](user-guides/noise.md) | Switch to the state vector backend for T gates, arbitrary rotations, etc.: ```rust -sim_neo(circuit).quantum(state_vector()).shots(1000).run(); +sim_neo(circuit).quantum(state_vector()).sampling(monte_carlo(1000)).run(); ``` ### Run in parallel -Add `.workers(n)` -- works with or without noise: +Add `.workers(n)` on the sampler -- works with or without noise: ```rust -sim_neo(circuit).depolarizing(0.01).workers(4).shots(10000).seed(42).run(); +sim_neo(circuit).auto().depolarizing(0.01).sampling(monte_carlo(10000).workers(4)).seed(42).run(); ``` ### Estimate rare event probabilities diff --git a/exp/pecos-neo/docs/dev/design-patterns.md b/exp/pecos-neo/docs/dev/design-patterns.md index 0654d4067..1c54e597d 100644 --- a/exp/pecos-neo/docs/dev/design-patterns.md +++ b/exp/pecos-neo/docs/dev/design-patterns.md @@ -30,7 +30,7 @@ Need to run a quantum circuit simulation? ├─► Estimating rare event probabilities? │ │ │ ├─► P ~ 10^-3 to 10^-6? -│ │ └─► Use sim_neo() with importance_sampling() +│ │ └─► Use sim_neo() with importance_sampling(shots) │ │ │ └─► P ~ 10^-6 or smaller? │ └─► Use SubsetSimulation or ProperSubsetSimulation @@ -52,58 +52,55 @@ Need to run a quantum circuit simulation? ### sim_neo() - The Recommended Entry Point ```rust -use pecos_neo::tool::{sim_neo, importance_sampling}; +use pecos_neo::tool::{importance_sampling, monte_carlo, sim_neo}; // Simple case -let results = sim_neo(circuit) - .shots(1000) +let results = sim_neo(circuit).auto() + .sampling(monte_carlo(1000)) .seed(42) .run(); // With noise -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .depolarizing(0.01) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); // With importance sampling -let results = sim_neo(circuit) - .sampling(importance_sampling() +let results = sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_p1(0.001) .with_boost(10.0)) - .shots(10000) .run(); // Parallel execution -let results = sim_neo(circuit) - .workers(4) - .shots(1000) +let results = sim_neo(circuit).auto() + .sampling(monte_carlo(1000).workers(4)) .run(); // Parallel execution with noise -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .depolarizing(0.01) - .workers(4) - .shots(1000) + .sampling(monte_carlo(1000).workers(4)) .run(); // With custom gate definitions let defs = GateDefinitions::new(); -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .gate_definitions(defs) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); // State vector with non-Clifford gates (rotations auto-enabled) let results = sim_neo(circuit) .quantum(state_vector()) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); // Control decomposition depth for deeply nested custom gates -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .max_decomp_depth(20) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); // Gate overrides (swap gate implementations at runtime) @@ -112,9 +109,9 @@ let overrides = GateOverrides::::new() // Custom implementation true }); -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .gate_overrides(overrides) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); ``` @@ -125,7 +122,7 @@ Event handlers (gate and signal handlers) can be passed through `sim_neo()` usin ```rust use pecos_neo::prelude::*; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; @@ -138,10 +135,9 @@ let handlers = EventHandlers::new() NoiseResponse::None }); -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .event_handlers(handlers) - .workers(4) // handlers are cloned per worker - .shots(1000) + .sampling(monte_carlo(1000).workers(4)) // handlers are cloned per worker .run(); ``` @@ -224,14 +220,13 @@ Complex configuration uses nested builders that compose naturally: ```rust // Top-level builder accepts nested builders sim_neo(circuit) - .sampling(importance_sampling() // Nested builder + .sampling(importance_sampling(10000) // Nested builder .with_p1(0.001) .with_boost(10.0)) .quantum(sparse_stab()) // Another nested builder .noise(GeneralNoiseModelBuilder::new() // And another .with_p1(0.001) .with_p2(0.01)) - .shots(1000) .run(); ``` @@ -239,9 +234,9 @@ sim_neo(circuit) | Parameters | Pattern | Example | |------------|---------|---------| -| 0-2 simple | Convenience method | `.shots(1000)`, `.seed(42)` | +| 0-2 simple | Convenience method | `.seed(42)`, `.qubits(5)` | | 0-2 with type | Free function → method | `.quantum(sparse_stab())` | -| 3+ related | Builder struct | `importance_sampling().with_p1().with_boost()` | +| 3+ related | Builder struct | `importance_sampling(shots).with_p1().with_boost()` | | Complex tree | Nested builders | Multiple levels of composition | ### Implementing New Builders @@ -388,10 +383,9 @@ trait objects. This enables parallel Monte Carlo execution for noisy circuits -- each worker gets an independent clone of the noise model: ```rust -sim_neo(circuit) +sim_neo(circuit).auto() .depolarizing(0.01) - .workers(4) // Each worker clones the noise model - .shots(10000) + .sampling(monte_carlo(10000).workers(4)) // Each worker clones the noise model .run(); ``` @@ -474,7 +468,8 @@ Entry-point builders use lowercase snake_case functions: ```rust pub fn sim_neo(input: impl SimNeoInput) -> SimNeoBuilder -pub fn importance_sampling() -> ImportanceSamplingBuilder +pub fn monte_carlo(shots: usize) -> MonteCarloBuilder +pub fn importance_sampling(shots: usize) -> ImportanceSamplingBuilder pub fn sparse_stab() -> SparseStabBuilder pub fn state_vector() -> StateVecBuilder ``` diff --git a/exp/pecos-neo/docs/dev/importance-sampling.md b/exp/pecos-neo/docs/dev/importance-sampling.md index b044bf133..9c829b996 100644 --- a/exp/pecos-neo/docs/dev/importance-sampling.md +++ b/exp/pecos-neo/docs/dev/importance-sampling.md @@ -13,7 +13,7 @@ Importance sampling instead: ## Quick Start with sim_neo -The easiest way to use importance sampling is via the `sim_neo` Tool API with the `importance_sampling()` builder: +The easiest way to use importance sampling is via the `sim_neo` Tool API with the `importance_sampling(shots)` builder: ```rust use pecos_neo::tool::{sim_neo, importance_sampling}; @@ -27,13 +27,12 @@ let circuit = CommandBuilder::new() .build(); // Run with importance sampling -let results = sim_neo(circuit) - .sampling(importance_sampling() +let results = sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_p1(0.001) // Single-qubit error rate .with_p2(0.01) // Two-qubit error rate .with_p_meas(0.001) // Measurement error rate .with_boost(10.0)) // Boost factor - .shots(10000) .seed(42) .run(); @@ -49,11 +48,10 @@ if let Some(error_rate) = results.weighted_mean(|outcome| { For uniform error rates, use the `with_uniform_error()` method: ```rust -let results = sim_neo(circuit) - .sampling(importance_sampling() +let results = sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_uniform_error(0.001) // Same rate for all gate types .with_boost(10.0)) - .shots(10000) .run(); ``` diff --git a/exp/pecos-neo/docs/dev/runner.md b/exp/pecos-neo/docs/dev/runner.md index a3ee77198..ce8bd48ac 100644 --- a/exp/pecos-neo/docs/dev/runner.md +++ b/exp/pecos-neo/docs/dev/runner.md @@ -296,7 +296,7 @@ let handlers = EventHandlers::new() .on_signal(|sig: &MySignal| { /* observe */ }); // Pass to sim_neo (cloned per worker in parallel mode) -sim_neo(circuit).event_handlers(handlers).workers(4).shots(1000).run(); +sim_neo(circuit).auto().event_handlers(handlers).sampling(monte_carlo(1000).workers(4)).run(); // Or merge into a CircuitRunner let runner = CircuitRunner::::new().with_event_handlers(handlers); diff --git a/exp/pecos-neo/docs/user-guides/events.md b/exp/pecos-neo/docs/user-guides/events.md index 38cb0b330..72d7cda21 100644 --- a/exp/pecos-neo/docs/user-guides/events.md +++ b/exp/pecos-neo/docs/user-guides/events.md @@ -37,7 +37,7 @@ let handlers = EventHandlers::new() NoiseResponse::None }); -sim_neo(circuit).event_handlers(handlers).shots(1000).run(); +sim_neo(circuit).auto().event_handlers(handlers).sampling(monte_carlo(1000)).run(); ``` Handlers return `NoiseResponse` to modify execution -- not just observe it: diff --git a/exp/pecos-neo/docs/user-guides/importance-sampling.md b/exp/pecos-neo/docs/user-guides/importance-sampling.md index 6d5d73f6a..86e97a700 100644 --- a/exp/pecos-neo/docs/user-guides/importance-sampling.md +++ b/exp/pecos-neo/docs/user-guides/importance-sampling.md @@ -8,12 +8,11 @@ reweighting the results. Much more efficient than brute-force Monte Carlo. ```rust use pecos_neo::tool::{sim_neo, importance_sampling}; -let results = sim_neo(circuit) - .sampling(importance_sampling() +let results = sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_p1(0.001) // Single-qubit error rate .with_p2(0.01) // Two-qubit error rate .with_boost(10.0)) // Sample 10x more errors - .shots(10000) .seed(42) .run(); ``` @@ -21,11 +20,10 @@ let results = sim_neo(circuit) For uniform rates across all gate types: ```rust -sim_neo(circuit) - .sampling(importance_sampling() +sim_neo(circuit).auto() + .sampling(importance_sampling(10000) .with_uniform_error(0.001) .with_boost(10.0)) - .shots(10000) .run(); ``` diff --git a/exp/pecos-neo/docs/user-guides/noise-model.md b/exp/pecos-neo/docs/user-guides/noise-model.md index 440469a08..d945db14b 100644 --- a/exp/pecos-neo/docs/user-guides/noise-model.md +++ b/exp/pecos-neo/docs/user-guides/noise-model.md @@ -76,7 +76,7 @@ Pass the noise model to `sim_neo` or `CircuitRunner`: ```rust // Via sim_neo (handles shots, parallelism, seeding) -sim_neo(circuit).noise(noise).shots(1000).seed(42).run(); +sim_neo(circuit).auto().noise(noise).sampling(monte_carlo(1000)).seed(42).run(); // Via CircuitRunner (direct control) let mut runner = CircuitRunner::::new() @@ -89,7 +89,7 @@ let outcomes = runner.apply_circuit(&mut state, &circuit)?; independent copy with their own state: ```rust -sim_neo(circuit).noise(noise).workers(4).shots(10000).run(); +sim_neo(circuit).auto().noise(noise).sampling(monte_carlo(10000).workers(4)).run(); ``` ## Going Deeper diff --git a/exp/pecos-neo/docs/user-guides/noise.md b/exp/pecos-neo/docs/user-guides/noise.md index 38b702092..9f9536d68 100644 --- a/exp/pecos-neo/docs/user-guides/noise.md +++ b/exp/pecos-neo/docs/user-guides/noise.md @@ -5,7 +5,7 @@ One-liner on `sim_neo`: ```rust -sim_neo(circuit).depolarizing(0.01).shots(1000).run(); +sim_neo(circuit).auto().depolarizing(0.01).sampling(monte_carlo(1000)).run(); ``` ## Per-Channel Control @@ -13,12 +13,12 @@ sim_neo(circuit).depolarizing(0.01).shots(1000).run(); Set different rates for single-qubit, two-qubit, and measurement errors: ```rust -sim_neo(circuit) +sim_neo(circuit).auto() .noise(GeneralNoiseModelBuilder::new() .with_p1(0.001) .with_p2(0.01) .with_p_meas_symmetric(0.005)) - .shots(1000) + .sampling(monte_carlo(1000)) .run(); ``` diff --git a/exp/pecos-neo/examples/noise_cookbook.rs b/exp/pecos-neo/examples/noise_cookbook.rs index 4354dfe95..1cb3396e5 100644 --- a/exp/pecos-neo/examples/noise_cookbook.rs +++ b/exp/pecos-neo/examples/noise_cookbook.rs @@ -25,7 +25,7 @@ use pecos_neo::noise::prelude::*; use pecos_neo::prelude::*; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_simulators::SparseStab; fn main() { @@ -86,8 +86,9 @@ fn recipe_sim_neo_integration() { // Method 1: Pass pre-built pattern directly to .noise() let results = sim_neo(circuit.clone()) + .auto() .noise(depolarizing_with_measurement(0.01, 0.05, 0.02)) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .build() .run(); @@ -102,8 +103,9 @@ fn recipe_sim_neo_integration() { // Method 2: Use the convenience .depolarizing() method let results = sim_neo(circuit.clone()) + .auto() .depolarizing(0.01) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .build() .run(); @@ -121,13 +123,14 @@ fn recipe_sim_neo_integration() { // Method 3: Pass NoiseModelBuilder (auto-converts via Into) let results = sim_neo(circuit.clone()) + .auto() .noise( NoiseModelBuilder::new() .with_depolarizing(0.01, 0.05) .with_measurement_error(0.02) .build(), ) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .build() .run(); @@ -142,13 +145,14 @@ fn recipe_sim_neo_integration() { // Method 4: Reusable simulation with noise let mut sim = sim_neo(circuit) + .auto() .noise(realistic_device_noise( &DeviceNoiseParams::new() .with_p1(0.001) .with_p2(0.01) .with_measurement_error(0.02), )) - .shots(500) + .sampling(monte_carlo(500)) .build(); // Run multiple times with different seeds diff --git a/exp/pecos-neo/src/noise/composer.rs b/exp/pecos-neo/src/noise/composer.rs index e874055cd..a7ec9cd4b 100644 --- a/exp/pecos-neo/src/noise/composer.rs +++ b/exp/pecos-neo/src/noise/composer.rs @@ -511,7 +511,7 @@ impl From for ComposableNoiseModel { /// let circuit = CommandQueue::new(); /// /// // Both of these work: - /// sim_neo(circuit.clone()).noise(GeneralNoiseModelBuilder::new().with_p1(0.01).build()); + /// sim_neo(circuit.clone()).auto().noise(GeneralNoiseModelBuilder::new().with_p1(0.01).build()); /// sim_neo(circuit).noise(GeneralNoiseModelBuilder::new().with_p1(0.01)); // No .build()! /// ``` fn from(builder: super::GeneralNoiseModelBuilder) -> Self { diff --git a/exp/pecos-neo/src/tool.rs b/exp/pecos-neo/src/tool.rs index 7393aa270..1ce54cca3 100644 --- a/exp/pecos-neo/src/tool.rs +++ b/exp/pecos-neo/src/tool.rs @@ -96,11 +96,12 @@ pub use importance::{ pub use plugin::{Plugin, PluginGroup}; pub use resource::{Resource, Resources}; pub use simulation::{ - Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, NoiseResource, QuantumBackend, - Sampling, SimConfig, SimNeoBuilder, SimNeoInput, Simulation, SimulationResults, + Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, MonteCarloBuilder, NoiseResource, + QuantumBackend, Sampling, SimConfig, SimNeoBuilder, SimNeoInput, Simulation, SimulationResults, SimulatorFactory, SparseStabBuilder, StabilizerBuilder, StateVecBuilder, StoredOverrides, custom_backend, custom_backend_from_factory, custom_backend_with_rotations, - importance_sampling, sim_neo, sim_neo_builder, sparse_stab, stabilizer, state_vector, + importance_sampling, monte_carlo, sim_neo, sim_neo_builder, sparse_stab, stabilizer, + state_vector, }; pub use simulation::{PendingEngineBuilder, TypedProgram}; pub use system::{IntoSystem, Schedule, System}; diff --git a/exp/pecos-neo/src/tool/design.md b/exp/pecos-neo/src/tool/design.md index 7ccdcd3f4..0126c7985 100644 --- a/exp/pecos-neo/src/tool/design.md +++ b/exp/pecos-neo/src/tool/design.md @@ -41,7 +41,7 @@ builder-of-builders pattern: sim_neo(circuit) .classical(qasm_engine()) .quantum(stabilizer()) - .sampling(importance_sampling()) + .sampling(importance_sampling(10_000)) .build() .run(); ``` @@ -186,8 +186,9 @@ Convenience function that creates a simulation-configured builder: /// # Example /// ``` /// let results = sim_neo(circuit) +/// .auto() /// .noise(SingleQubitChannel::depolarizing(0.01)) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -205,22 +206,20 @@ Builder that configures a Tool for simulation: pub struct SimNeoBuilder { circuit: CommandQueue, noise: Option, - shots: usize, seed: Option, - workers: usize, - importance_sampling: Option, + sampling: Option, // monte_carlo(shots).workers(n) | importance_sampling(shots) } impl SimNeoBuilder { pub fn new(circuit: CommandQueue) -> Self { ... } // Configuration (consumes self, returns Self) - pub fn shots(mut self, shots: usize) -> Self { ... } pub fn seed(mut self, seed: u64) -> Self { ... } - pub fn workers(mut self, workers: usize) -> Self { ... } - pub fn auto_workers(mut self) -> Self { ... } pub fn noise(mut self, noise: impl Into) -> Self { ... } - pub fn importance_sampling(mut self, base_rate: f64, boost: f64) -> Self { ... } + // Sampling strategy carries its own shots/workers: + // .sampling(monte_carlo(1000).workers(8)) + // .sampling(importance_sampling(10_000).with_boost(10.0)) + pub fn sampling(mut self, sampling: impl Into) -> Self { ... } /// Build the simulation handle pub fn build(self) -> Simulation { @@ -271,17 +270,17 @@ impl Simulation { ```rust // Pattern 1: One-shot (builder consumed) -let results = sim_neo(circuit) +let results = sim_neo(circuit).auto() .noise(depolarizing(0.01)) - .shots(1000) + .sampling(monte_carlo(1000)) .seed(42) .build() .run(); // Pattern 2: Build once, run many -let mut sim = sim_neo(circuit) +let mut sim = sim_neo(circuit).auto() .noise(depolarizing(0.01)) - .shots(1000) + .sampling(monte_carlo(1000)) .build(); let results1 = sim.run(); diff --git a/exp/pecos-neo/src/tool/importance.rs b/exp/pecos-neo/src/tool/importance.rs index faa154040..53d836c1e 100644 --- a/exp/pecos-neo/src/tool/importance.rs +++ b/exp/pecos-neo/src/tool/importance.rs @@ -25,7 +25,7 @@ //! let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); //! //! // Create simulation with importance sampling -//! let mut sim = sim_neo(circuit) +//! let mut sim = sim_neo(circuit).auto() //! .depolarizing(0.001) // True error rate //! .build(); //! diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 022e66aae..287eeb787 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -28,16 +28,16 @@ //! For circuits without mid-circuit classical control: //! //! ```no_run -//! use pecos_neo::tool::sim_neo; +//! use pecos_neo::tool::{monte_carlo, sim_neo}; //! use pecos_neo::prelude::*; //! //! let circuit = CommandBuilder::new() //! .pz(&[0]).h(&[0]).mz(&[0]) //! .build(); //! -//! let results = sim_neo(circuit) +//! let results = sim_neo(circuit).auto() //! .depolarizing(0.01) -//! .shots(1000) +//! .sampling(monte_carlo(1000)) //! .seed(42) //! .build() //! .run(); @@ -48,7 +48,7 @@ //! For QASM programs with classical control flow: //! //! ```no_run -//! use pecos_neo::tool::sim_neo; +//! use pecos_neo::tool::{monte_carlo, sim_neo}; //! use pecos_qasm::qasm_engine; //! //! let qasm = r#" @@ -63,10 +63,10 @@ //! "#; //! //! // Pass QASM source, then set the engine -//! let results = sim_neo(qasm) +//! let results = sim_neo(qasm).auto() //! .classical(qasm_engine()) //! .depolarizing(0.01) -//! .shots(1000) +//! .sampling(monte_carlo(1000)) //! .seed(42) //! .build() //! .run(); @@ -77,19 +77,19 @@ //! Any `ClassicalControlEngineBuilder` works with `sim_neo()`: //! //! ```text -//! use pecos_neo::tool::sim_neo; +//! use pecos_neo::tool::{monte_carlo, sim_neo}; //! use pecos_hugr::hugr_engine; //! use pecos_qis::qis_engine; //! //! // HUGR programs -//! let results = sim_neo(hugr_engine().hugr(&hugr_module)) -//! .shots(1000) +//! let results = sim_neo(hugr_engine().hugr(&hugr_module)).auto() +//! .sampling(monte_carlo(1000)) //! .build() //! .run(); //! //! // QIS programs -//! let results = sim_neo(qis_engine().qis(&qis_program)) -//! .shots(1000) +//! let results = sim_neo(qis_engine().qis(&qis_program)).auto() +//! .sampling(monte_carlo(1000)) //! .build() //! .run(); //! ``` @@ -99,12 +99,12 @@ //! Build once, run multiple times: //! //! ```no_run -//! use pecos_neo::tool::sim_neo; +//! use pecos_neo::tool::{monte_carlo, sim_neo}; //! use pecos_neo::prelude::*; //! //! let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -//! let mut sim = sim_neo(circuit) -//! .shots(1000) +//! let mut sim = sim_neo(circuit).auto() +//! .sampling(monte_carlo(1000)) //! .build(); //! //! let results1 = sim.run(); @@ -137,13 +137,17 @@ use super::{Plugin, Stage, Tool}; /// /// This enum represents the choice of quantum simulator. The actual simulator /// is constructed at build time, following the builder-of-builders pattern. -#[derive(Default)] +/// +/// There is no default: select a backend explicitly via +/// [`SimNeoBuilder::quantum()`](SimNeoBuilder::quantum), or call +/// [`SimNeoBuilder::auto()`](SimNeoBuilder::auto) to opt into automatic +/// selection (currently `SparseStab`). pub enum QuantumBackend { - /// Sparse stabilizer simulator (default). + /// Sparse stabilizer simulator. /// /// Efficient for Clifford circuits and QEC simulations. /// Only supports Clifford gates (H, S, CNOT, CZ, etc.). - #[default] + /// This is what `.auto()` selects. SparseStab, /// Public stabilizer simulator. @@ -167,10 +171,10 @@ pub enum QuantumBackend { /// Custom simulator backend via factory function. /// /// Allows any simulator implementing `CliffordGateable + RngManageable` - /// to be used through the `sim_neo()` API. Use [`custom_backend()`] to create. + /// to be used through the `sim_neo().auto()` API. Use [`custom_backend()`] to create. /// - /// Custom backends only support sequential execution. Using `.workers()`, - /// `.auto_workers()`, or importance sampling will panic at `.run()` time. + /// Custom backends only support sequential execution. Parallel Monte + /// Carlo (workers > 1) is rejected at `.build()` time. Custom(Box), } @@ -250,19 +254,19 @@ impl From for QuantumBackend { /// Create a sparse stabilizer backend builder. /// -/// The sparse stabilizer is the default backend, efficient for Clifford circuits +/// The sparse stabilizer is the backend `.auto()` selects, efficient for Clifford circuits /// and quantum error correction simulations. /// /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, sparse_stab}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, sparse_stab}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// let results = sim_neo(circuit) /// .quantum(sparse_stab()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -280,13 +284,13 @@ pub fn sparse_stab() -> SparseStabBuilder { /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, stabilizer}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, stabilizer}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// let results = sim_neo(circuit) /// .quantum(stabilizer()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -303,13 +307,13 @@ pub fn stabilizer() -> StabilizerBuilder { /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, state_vector}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, state_vector}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// let results = sim_neo(circuit) /// .quantum(state_vector()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -450,7 +454,7 @@ impl From for QuantumBackend { /// Create a custom backend from a factory closure. /// /// This allows any simulator implementing `CliffordGateable + RngManageable` -/// to be used through `sim_neo()`. The closure receives the number of qubits +/// to be used through `sim_neo().auto()`. The closure receives the number of qubits /// and should return a new simulator instance. /// /// **Note:** Custom backends only support sequential execution. Using `.workers()`, @@ -460,7 +464,7 @@ impl From for QuantumBackend { /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, custom_backend}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, custom_backend}; /// use pecos_neo::prelude::*; /// use pecos_simulators::SparseStab; /// @@ -469,7 +473,7 @@ impl From for QuantumBackend { /// // Use a custom simulator backend /// let results = sim_neo(circuit) /// .quantum(custom_backend(|n| SparseStab::new(n))) -/// .shots(100) +/// .sampling(monte_carlo(100)) /// .seed(42) /// .build() /// .run(); @@ -508,7 +512,7 @@ pub fn custom_backend_from_factory( /// # Example /// /// ```no_run -/// use pecos_neo::tool::{sim_neo, custom_backend_with_rotations}; +/// use pecos_neo::tool::{monte_carlo, sim_neo, custom_backend_with_rotations}; /// use pecos_neo::prelude::*; /// use pecos_simulators::StateVec; /// @@ -516,7 +520,7 @@ pub fn custom_backend_from_factory( /// /// let results = sim_neo(circuit) /// .quantum(custom_backend_with_rotations(|n| StateVec::new(n))) -/// .shots(100) +/// .sampling(monte_carlo(100)) /// .seed(42) /// .build() /// .run(); @@ -644,13 +648,13 @@ impl SimNeoInput for &pecos_quantum::DagCircuit { /// When passing a string, use `.classical(engine)` to specify how to interpret it: /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; -/// sim_neo(qasm_code) +/// sim_neo(qasm_code).auto() /// .classical(qasm_engine()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -673,7 +677,7 @@ impl SimNeoInput for String { /// `.classical(engine)` for explicit control: /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_programs::Qasm; /// use pecos_qasm::qasm_engine; /// @@ -682,14 +686,14 @@ impl SimNeoInput for String { /// // Auto mode - uses qasm_engine() automatically /// sim_neo(Qasm::from_string(qasm_code.clone())) /// .auto() -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// /// // Explicit mode -/// sim_neo(Qasm::from_string(qasm_code)) +/// sim_neo(Qasm::from_string(qasm_code)).auto() /// .classical(qasm_engine()) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -704,13 +708,13 @@ impl SimNeoInput for pecos_programs::Qasm { /// Use `.auto()` to automatically select the HUGR interpreter engine: /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_programs::Hugr; /// /// let hugr = Hugr::from_file("program.hugr").unwrap(); /// sim_neo(hugr) /// .auto() -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -726,13 +730,13 @@ impl SimNeoInput for pecos_programs::Hugr { /// the program type: /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_programs::{Program, Qasm}; /// /// let qasm = Qasm::from_string("OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];".to_string()); /// sim_neo(Program::Qasm(qasm)) /// .auto() -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -773,8 +777,8 @@ impl Default for SimConfig { /// Builder for importance sampling configuration. /// -/// Specifies the true error rates and boost factor for biased sampling. -/// Use the [`importance_sampling()`] function to create an instance. +/// Specifies the shot count, true error rates, and boost factor for biased +/// sampling. Use the [`importance_sampling()`] function to create an instance. /// /// # Example /// @@ -783,18 +787,19 @@ impl Default for SimConfig { /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let results = sim_neo(circuit) -/// .sampling(importance_sampling() +/// let results = sim_neo(circuit).auto() +/// .sampling(importance_sampling(10000) /// .with_p1(0.001) /// .with_p2(0.01) /// .with_p_meas(0.001) /// .with_boost(10.0)) -/// .shots(10000) /// .build() /// .run(); /// ``` #[derive(Debug, Clone)] pub struct ImportanceSamplingBuilder { + /// Number of (boosted) trials to run. + shots: usize, /// Single-qubit gate error rate (true distribution). p1: f64, /// Two-qubit gate error rate (true distribution). @@ -806,12 +811,13 @@ pub struct ImportanceSamplingBuilder { } impl ImportanceSamplingBuilder { - /// Create a new importance sampling builder with default values. + /// Create a new importance sampling builder running `shots` trials. /// - /// Default: p1=0.001, p2=0.01, `p_meas=0.001`, boost=10.0 + /// Default rates: p1=0.001, p2=0.01, `p_meas=0.001`, boost=10.0 #[must_use] - pub fn new() -> Self { + pub fn new(shots: usize) -> Self { Self { + shots, p1: 0.001, p2: 0.01, p_meas: 0.001, @@ -888,11 +894,11 @@ impl ImportanceSamplingBuilder { pub fn boost(&self) -> f64 { self.boost } -} -impl Default for ImportanceSamplingBuilder { - fn default() -> Self { - Self::new() + /// Get the number of (boosted) trials to run. + #[must_use] + pub fn shots(&self) -> usize { + self.shots } } @@ -902,7 +908,7 @@ impl From for Sampling { } } -/// Create an importance sampling strategy builder. +/// Create an importance sampling strategy builder running `shots` trials. /// /// Importance sampling biases noise toward higher error rates to observe /// rare events more frequently, then reweights results for unbiased estimates. @@ -914,12 +920,11 @@ impl From for Sampling { /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let results = sim_neo(circuit) -/// .sampling(importance_sampling() +/// let results = sim_neo(circuit).auto() +/// .sampling(importance_sampling(10000) /// .with_p1(0.001) /// .with_p2(0.01) /// .with_boost(10.0)) -/// .shots(10000) /// .build() /// .run(); /// @@ -932,8 +937,85 @@ impl From for Sampling { /// } /// ``` #[must_use] -pub fn importance_sampling() -> ImportanceSamplingBuilder { - ImportanceSamplingBuilder::new() +pub fn importance_sampling(shots: usize) -> ImportanceSamplingBuilder { + ImportanceSamplingBuilder::new(shots) +} + +/// Builder for the Monte Carlo sampling strategy. +/// +/// Created by [`monte_carlo()`]. Shots is the defining argument; workers +/// defaults to 1 (sequential). +#[derive(Debug, Clone)] +pub struct MonteCarloBuilder { + shots: usize, + workers: usize, +} + +impl MonteCarloBuilder { + /// Set the number of parallel workers. + /// + /// Parallel execution distributes shots across workers using rayon, + /// with each worker getting its own simulator, command source, and + /// noise model built from the shared configuration. Per-shot seeding + /// uses global shot indices, so results are identical for any worker + /// count. + /// + /// Requires a per-worker construction path: a static circuit or a + /// classical engine builder source, on a built-in or adapted backend. + /// Pre-built dynamic command sources and custom backends cannot build + /// per-worker state; `.build()` rejects those combinations. + #[must_use] + pub fn workers(mut self, workers: usize) -> Self { + self.workers = workers; + self + } + + /// Set the worker count from available parallelism. + /// + /// See [`workers()`](Self::workers) for requirements. + #[must_use] + pub fn auto_workers(mut self) -> Self { + self.workers = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); + self + } +} + +impl From for Sampling { + fn from(builder: MonteCarloBuilder) -> Self { + Sampling::MonteCarlo { + shots: builder.shots, + workers: builder.workers, + } + } +} + +/// Create a Monte Carlo sampling strategy builder running `shots` shots. +/// +/// This is the standard execution strategy: each shot runs the program +/// once and records its outcomes. Sequential by default; add +/// `.workers(n)` or `.auto_workers()` for parallel execution. +/// +/// # Example +/// +/// ```no_run +/// use pecos_neo::tool::{monte_carlo, sim_neo}; +/// use pecos_neo::prelude::*; +/// +/// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); +/// +/// // Sequential +/// let results = sim_neo(circuit.clone()).auto() +/// .sampling(monte_carlo(1000)) +/// .run(); +/// +/// // Parallel with 8 workers +/// let results = sim_neo(circuit).auto() +/// .sampling(monte_carlo(1000).workers(8)) +/// .run(); +/// ``` +#[must_use] +pub fn monte_carlo(shots: usize) -> MonteCarloBuilder { + MonteCarloBuilder { shots, workers: 1 } } /// Sampling strategy for simulation execution. @@ -943,9 +1025,10 @@ pub fn importance_sampling() -> ImportanceSamplingBuilder { /// /// Stored as data in the builder, the actual execution is set up at run time. /// -/// The default is `MonteCarlo { workers: 1 }`, which runs shots sequentially -/// using the Tool/Schedule/Plugin system. Use `.workers(n)` or `.auto_workers()` -/// for parallel execution. +/// Construct via the builder functions [`monte_carlo()`] and +/// [`importance_sampling()`] and pass to +/// [`SimNeoBuilder::sampling()`](SimNeoBuilder::sampling). There is no +/// default: the shot count is part of the strategy and must be explicit. #[derive(Debug, Clone)] pub enum Sampling { /// Monte Carlo execution (sequential with 1 worker, parallel with >1). @@ -953,8 +1036,12 @@ pub enum Sampling { /// Each worker runs a batch of shots independently with deterministic seeding. /// Supports both noiseless and noisy circuits (noise model is cloned per worker). /// With 1 worker, runs via the Tool's schedule directly. + /// + /// Use the [`monte_carlo()`] builder function to create this variant. MonteCarlo { - /// Number of parallel workers (default: 1). + /// Number of shots to run. + shots: usize, + /// Number of parallel workers (1 = sequential). workers: usize, }, @@ -970,24 +1057,14 @@ pub enum Sampling { }, } -impl Default for Sampling { - fn default() -> Self { - Self::MonteCarlo { workers: 1 } - } -} - impl Sampling { - /// Create a Monte Carlo sampling strategy with specified workers. - #[must_use] - pub fn monte_carlo(workers: usize) -> Self { - Self::MonteCarlo { workers } - } - - /// Create a Monte Carlo sampling strategy with auto-detected worker count. + /// Number of shots/trials this strategy will run. #[must_use] - pub fn monte_carlo_auto() -> Self { - let workers = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); - Self::MonteCarlo { workers } + pub fn shots(&self) -> usize { + match self { + Self::MonteCarlo { shots, .. } => *shots, + Self::ImportanceSampling { config } => config.shots(), + } } } @@ -1085,7 +1162,7 @@ impl SimulationResults { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::outcome::RegisterMap; /// use pecos_neo::prelude::*; /// @@ -1096,7 +1173,7 @@ impl SimulationResults { /// let mut reg = RegisterMap::new(); /// reg.add_register("c", &[QubitId(0), QubitId(1)]); /// - /// let results = sim_neo(circuit).shots(100).seed(42).run(); + /// let results = sim_neo(circuit).auto().sampling(monte_carlo(100)).seed(42).run(); /// let columns = results.as_register_columns(®); /// assert_eq!(columns["c"].len(), 100); /// ``` @@ -1133,7 +1210,7 @@ impl SimulationResults { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::outcome::RegisterMap; /// use pecos_neo::prelude::*; /// @@ -1144,7 +1221,7 @@ impl SimulationResults { /// let mut reg = RegisterMap::new(); /// reg.add_register("c", &[QubitId(0)]); /// - /// let results = sim_neo(circuit).shots(1000).seed(42).run(); + /// let results = sim_neo(circuit).auto().sampling(monte_carlo(1000)).seed(42).run(); /// let counts = results.register_counts(®, "c"); /// // Should have entries for [false] and [true] /// ``` @@ -1388,13 +1465,13 @@ fn infer_num_qubits_from_circuit(circuit: &CommandQueue) -> usize { /// ## Static Circuit /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let results = sim_neo(circuit) +/// let results = sim_neo(circuit).auto() /// .depolarizing(0.01) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -1403,14 +1480,14 @@ fn infer_num_qubits_from_circuit(circuit: &CommandQueue) -> usize { /// ## QASM Program (builder-of-builders pattern) /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// // Pass program source first, then engine factory -/// let results = sim_neo(qasm_code) +/// let results = sim_neo(qasm_code).auto() /// .classical(qasm_engine()) // Engine configured with source at build time -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -1419,14 +1496,14 @@ fn infer_num_qubits_from_circuit(circuit: &CommandQueue) -> usize { /// ## Pre-configured Engine Builder /// /// ```no_run -/// use pecos_neo::tool::sim_neo_builder; +/// use pecos_neo::tool::{monte_carlo, sim_neo_builder}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// // Or pass already-configured engine builder /// let results = sim_neo_builder() /// .with_engine(qasm_engine().qasm(qasm_code)) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -1441,10 +1518,20 @@ pub struct SimNeoBuilder { definitions: Option, /// Simulation configuration (data). config: SimConfig, - /// Sampling strategy (data). - sampling: Sampling, - /// Quantum backend configuration (data). - quantum_backend: QuantumBackend, + /// Sampling strategy (data). None until `.sampling()` is called. + sampling: Option, + /// Shot count from the deprecated top-level `.shots()` forwarder. + legacy_shots: Option, + /// Worker count from the deprecated top-level `.workers()` forwarder. + legacy_workers: Option, + /// Auto worker-count request from `.auto()`/deprecated `.auto_workers()`, + /// honored only on the legacy `.shots()` path. + auto_workers_hint: bool, + /// Backend auto-selection opt-in from `.auto()`. + auto_backend: bool, + /// Quantum backend configuration (data). None until `.quantum()` is + /// called; `.auto()` opts into automatic selection at build time. + quantum_backend: Option, /// Explicit qubit count override (data). explicit_num_qubits: Option, /// Maximum decomposition depth for gate resolution. @@ -1456,17 +1543,20 @@ pub struct SimNeoBuilder { } impl SimNeoBuilder { - /// Create a new simulation builder for a circuit. - #[must_use] - pub fn with_circuit(circuit: CommandQueue) -> Self { + /// Create a builder with the given source and all other fields unset. + fn from_source(source: Option) -> Self { Self { - source: Some(ProgramSource::Static(circuit)), + source, pending_builder: None, noise: None, definitions: None, config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), + sampling: None, + legacy_shots: None, + legacy_workers: None, + auto_workers_hint: false, + auto_backend: false, + quantum_backend: None, explicit_num_qubits: None, max_decomp_depth: None, overrides: None, @@ -1474,22 +1564,16 @@ impl SimNeoBuilder { } } + /// Create a new simulation builder for a circuit. + #[must_use] + pub fn with_circuit(circuit: CommandQueue) -> Self { + Self::from_source(Some(ProgramSource::Static(circuit))) + } + /// Create a simulation builder for a dynamic command source. #[must_use] pub fn with_command_source(source: Box) -> Self { - Self { - source: Some(ProgramSource::Dynamic(source)), - pending_builder: None, - noise: None, - definitions: None, - config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), - explicit_num_qubits: None, - max_decomp_depth: None, - overrides: None, - event_handlers: None, - } + Self::from_source(Some(ProgramSource::Dynamic(source))) } /// Create a simulation builder with raw program source. @@ -1497,19 +1581,7 @@ impl SimNeoBuilder { /// Use `.classical(builder)` to specify how to interpret the source. #[must_use] pub fn with_program_source(source: String) -> Self { - Self { - source: Some(ProgramSource::RawSource(source)), - pending_builder: None, - noise: None, - definitions: None, - config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), - explicit_num_qubits: None, - max_decomp_depth: None, - overrides: None, - event_handlers: None, - } + Self::from_source(Some(ProgramSource::RawSource(source))) } /// Create a simulation builder with a typed program. @@ -1518,19 +1590,7 @@ impl SimNeoBuilder { /// `.classical(builder)` for explicit control. #[must_use] pub fn with_typed_program(program: TypedProgram) -> Self { - Self { - source: Some(ProgramSource::Typed(program)), - pending_builder: None, - noise: None, - definitions: None, - config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), - explicit_num_qubits: None, - max_decomp_depth: None, - overrides: None, - event_handlers: None, - } + Self::from_source(Some(ProgramSource::Typed(program))) } /// Create a new simulation builder for a circuit (legacy alias). @@ -1544,19 +1604,7 @@ impl SimNeoBuilder { /// Use this when you want to set the program source via `.classical()`. #[must_use] pub fn empty() -> Self { - Self { - source: None, - pending_builder: None, - noise: None, - definitions: None, - config: SimConfig::default(), - sampling: Sampling::default(), - quantum_backend: QuantumBackend::default(), - explicit_num_qubits: None, - max_decomp_depth: None, - overrides: None, - event_handlers: None, - } + Self::from_source(None) } /// Set the classical control engine builder (builder-of-builders pattern). @@ -1566,14 +1614,14 @@ impl SimNeoBuilder { /// it all together when building the Tool. /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// // Builder is stored as data, source injected at build time - /// let results = sim_neo(qasm_code) + /// let results = sim_neo(qasm_code).auto() /// .classical(qasm_engine()) // stores builder as data - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() // configures builder, builds engine, creates Tool /// .run(); /// ``` @@ -1581,13 +1629,13 @@ impl SimNeoBuilder { /// For pre-configured engine builders, use `.with_engine()` instead: /// /// ```no_run - /// use pecos_neo::tool::sim_neo_builder; + /// use pecos_neo::tool::{monte_carlo, sim_neo_builder}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// let results = sim_neo_builder() /// .with_engine(qasm_engine().qasm(qasm_code)) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -1660,13 +1708,13 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo_builder; + /// use pecos_neo::tool::{monte_carlo, sim_neo_builder}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; /// let results = sim_neo_builder() /// .with_engine(qasm_engine().qasm(qasm_code)) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` @@ -1682,49 +1730,53 @@ impl SimNeoBuilder { self } - /// Automatically select the appropriate engine based on program type. + /// Opt into automatic selection of unset components. + /// + /// `.auto()` is explicit-about-being-implicit: it lets the builder fill + /// in components you did not set, instead of failing at build time. + /// Currently it selects: + /// - The classical engine for typed programs (`Qasm` uses `qasm_engine()`, + /// `Hugr` uses `hugr_engine()`); other sources are left unchanged. + /// - The quantum backend, if `.quantum()` was not called + /// (currently `SparseStab`). /// - /// This is a convenience method that selects good defaults: - /// - `Qasm` programs use `qasm_engine()` - /// - Future: `Hugr`, `PhirJson`, `Qis` will use their respective engines + /// The sampling strategy is never auto-selected: a shot count cannot be + /// guessed, so `.sampling(monte_carlo(shots))` is always required. (On + /// the deprecated top-level `.shots()` path, `.auto()` additionally + /// requests an auto-detected worker count to preserve legacy behavior.) /// /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_programs::Qasm; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];".to_string(); - /// // Auto-select engine based on program type + /// // Auto-select engine and backend /// let results = sim_neo(Qasm::from_string(qasm_code)) /// .auto() - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` /// /// # Panics /// - /// Panics if: - /// - No typed program was provided (use `sim_neo(Qasm::from_string(...))`) - /// - The program type is not yet supported for auto-selection - /// - /// Note: `.auto()` also selects the default Monte Carlo sampling strategy. The - /// parallel execution plan decides whether the selected command source and - /// quantum backend can safely build independent worker state. + /// Panics if a typed program's type is not yet supported for + /// auto-selection, or its engine's cargo feature is disabled. #[must_use] pub fn auto(mut self) -> Self { - match self.source.take() { + self.auto_backend = true; + self.auto_workers_hint = true; + self.source = match self.source.take() { Some(ProgramSource::Typed(typed)) => match typed { #[cfg(feature = "qasm")] TypedProgram::Qasm(qasm) => { // Auto-select qasm_engine() and configure with the program. let builder = pecos_qasm::qasm_engine().qasm(qasm.source); - self.source = Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { + Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { builder, - }))); - self.sampling = Sampling::monte_carlo_auto(); - self + }))) } #[cfg(not(feature = "qasm"))] TypedProgram::Qasm(_) => { @@ -1737,11 +1789,9 @@ impl SimNeoBuilder { TypedProgram::Hugr(hugr) => { // Auto-select hugr_engine() and configure with the program. let builder = pecos_hugr::hugr_engine().hugr_bytes(hugr.hugr); - self.source = Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { + Some(ProgramSource::Classical(Box::new(EngineBuilderWrapper { builder, - }))); - self.sampling = Sampling::monte_carlo_auto(); - self + }))) } #[cfg(not(feature = "hugr"))] TypedProgram::Hugr(_) => { @@ -1757,38 +1807,9 @@ impl SimNeoBuilder { ); } }, - Some(ProgramSource::RawSource(_)) => { - panic!( - "Cannot use .auto() with raw string source. \ - Use sim_neo(Qasm::from_string(...)).auto() or \ - sim_neo(source).classical(engine) instead." - ); - } - Some(ProgramSource::Static(_)) => { - panic!( - "Cannot use .auto() with static circuits. \ - Static circuits don't need an engine - just call .build() directly." - ); - } - Some(ProgramSource::Dynamic(_)) => { - panic!( - "Cannot use .auto() with an existing dynamic command source. \ - Command sources are already executable." - ); - } - Some(ProgramSource::Classical(_)) => { - panic!( - "Engine already configured. \ - Don't use both .auto() and .classical()/.with_engine()." - ); - } - None => { - panic!( - "No program provided. \ - Use sim_neo(Qasm::from_string(...)).auto() or similar." - ); - } - } + other => other, + }; + self } /// Set the number of qubits explicitly. @@ -1802,9 +1823,13 @@ impl SimNeoBuilder { } /// Set the number of shots. + #[deprecated( + since = "0.2.0", + note = "shots lives on the sampler builder: use .sampling(monte_carlo(shots))" + )] #[must_use] pub fn shots(mut self, shots: usize) -> Self { - self.config.shots = shots; + self.legacy_shots = Some(shots); self } @@ -1817,58 +1842,54 @@ impl SimNeoBuilder { /// Set the sampling strategy for simulation execution. /// + /// The strategy carries its own shot count and execution knobs; build + /// it with [`monte_carlo()`] or [`importance_sampling()`]. + /// /// # Example /// /// ```no_run - /// use pecos_neo::tool::{sim_neo, Sampling}; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// /// // Parallel Monte Carlo with 4 workers - /// let results = sim_neo(circuit.clone()) - /// .sampling(Sampling::monte_carlo(4)) - /// .shots(1000) + /// let results = sim_neo(circuit.clone()).auto() + /// .sampling(monte_carlo(1000).workers(4)) /// .build() /// .run(); /// /// // Auto-detect worker count - /// let results = sim_neo(circuit) - /// .sampling(Sampling::monte_carlo_auto()) - /// .shots(1000) + /// let results = sim_neo(circuit).auto() + /// .sampling(monte_carlo(1000).auto_workers()) /// .build() /// .run(); /// ``` #[must_use] pub fn sampling(mut self, sampling: impl Into) -> Self { - self.sampling = sampling.into(); + self.sampling = Some(sampling.into()); self } /// Convenience method for parallel Monte Carlo with specified workers. - /// - /// Parallel execution distributes shots across workers using rayon, - /// with each worker getting its own simulator and noise model clone. - /// Works with both noiseless and noisy circuits. - /// - /// # Panics - /// - /// Panics at `.run()` time if parallel execution is not possible. - /// Parallel execution requires a static circuit using a built-in - /// backend (`SparseStab` or `StateVec`). Classical engines and custom - /// backends are not supported. + #[deprecated( + since = "0.2.0", + note = "workers lives on the sampler builder: use .sampling(monte_carlo(shots).workers(n))" + )] #[must_use] pub fn workers(mut self, workers: usize) -> Self { - self.sampling = Sampling::monte_carlo(workers); + self.legacy_workers = Some(workers); self } /// Convenience method for parallel Monte Carlo with auto-detected workers. - /// - /// See [`workers()`](Self::workers) for requirements and panics. + #[deprecated( + since = "0.2.0", + note = "workers lives on the sampler builder: use .sampling(monte_carlo(shots).auto_workers())" + )] #[must_use] pub fn auto_workers(mut self) -> Self { - self.sampling = Sampling::monte_carlo_auto(); + self.auto_workers_hint = true; self } @@ -1877,34 +1898,38 @@ impl SimNeoBuilder { /// This selects which quantum simulator to use. Different backends have /// different capabilities and performance characteristics: /// - /// - `sparse_stab()` - Sparse stabilizer (default), efficient for Clifford circuits + /// - `sparse_stab()` - Sparse stabilizer, efficient for Clifford circuits /// - `state_vector()` - State vector, supports arbitrary gates including T and rotations /// + /// A backend must be chosen: either call `.quantum()` explicitly or opt + /// into automatic selection with `.auto()`. A missing backend is a + /// build-time error. + /// /// # Example /// /// ```no_run - /// use pecos_neo::tool::{sim_neo, sparse_stab, state_vector}; + /// use pecos_neo::tool::{monte_carlo, sim_neo, sparse_stab, state_vector}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// - /// // Use sparse stabilizer (default, Clifford-only) + /// // Use sparse stabilizer (Clifford-only) /// let results = sim_neo(circuit.clone()) /// .quantum(sparse_stab()) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// /// // Use state vector (supports T gates, rotations) /// let results = sim_neo(circuit) /// .quantum(state_vector()) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .build() /// .run(); /// ``` #[must_use] pub fn quantum>(mut self, backend: B) -> Self { - self.quantum_backend = backend.into(); + self.quantum_backend = Some(backend.into()); self } @@ -1923,19 +1948,19 @@ impl SimNeoBuilder { /// # Examples /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// use pecos_neo::noise::GeneralNoiseModelBuilder; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// /// // Using GeneralNoiseModelBuilder (no .build() needed) - /// sim_neo(circuit.clone()) + /// sim_neo(circuit.clone()).auto() /// .noise(GeneralNoiseModelBuilder::new().with_p1(0.01).with_p2(0.02)) /// .build(); /// /// // Using a single channel directly - /// sim_neo(circuit.clone()) + /// sim_neo(circuit.clone()).auto() /// .noise(SingleQubitChannel::depolarizing(0.01)) /// .build(); /// ``` @@ -1954,15 +1979,15 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let defs = GateDefinitions::new(); // core gates included by default /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - /// let results = sim_neo(circuit) + /// let results = sim_neo(circuit).auto() /// .gate_definitions(defs) - /// .shots(100) + /// .sampling(monte_carlo(100)) /// .seed(42) /// .build() /// .run(); @@ -1982,13 +2007,13 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - /// let results = sim_neo(circuit) + /// let results = sim_neo(circuit).auto() /// .max_decomp_depth(20) - /// .shots(100) + /// .sampling(monte_carlo(100)) /// .seed(42) /// .run(); /// ``` @@ -2016,7 +2041,7 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// use pecos_simulators::SparseStab; /// @@ -2027,9 +2052,9 @@ impl SimNeoBuilder { /// }); /// /// let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - /// let results = sim_neo(circuit) + /// let results = sim_neo(circuit).auto() /// .gate_overrides(overrides) - /// .shots(100) + /// .sampling(monte_carlo(100)) /// .seed(42) /// .run(); /// ``` @@ -2047,7 +2072,7 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// use std::sync::atomic::{AtomicUsize, Ordering}; /// use std::sync::Arc; @@ -2062,9 +2087,9 @@ impl SimNeoBuilder { /// }); /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - /// let results = sim_neo(circuit) + /// let results = sim_neo(circuit).auto() /// .event_handlers(handlers) - /// .shots(100) + /// .sampling(monte_carlo(100)) /// .seed(42) /// .run(); /// ``` @@ -2102,12 +2127,20 @@ impl SimNeoBuilder { /// /// This is where all the collected builders and configuration come together: /// - Program source is wired with engine factory (if applicable) + /// - Sampling strategy is resolved and validated /// - Noise model is built /// - Tool is constructed with all plugins and systems /// /// # Panics /// - /// Panics if no program source is set (neither circuit nor classical engine). + /// Panics if: + /// - No program source is set (neither circuit nor classical engine) + /// - No sampling strategy is set (use `.sampling(monte_carlo(shots))`) + /// - No quantum backend is set (use `.quantum(..)` or `.auto()`) + /// - Deprecated `.shots()`/`.workers()` are combined with `.sampling()` + /// - Parallel Monte Carlo (`workers > 1`) is requested for a + /// configuration that cannot build per-worker state (pre-built dynamic + /// command sources, custom backends) #[must_use] pub fn build(self) -> Simulation { // Resolve the program source - configure pending builder with source if needed @@ -2151,26 +2184,87 @@ impl SimNeoBuilder { } }; - let parallel_plan = match self.sampling { - Sampling::MonteCarlo { workers } if workers > 1 => build_parallel_execution_plan( - &source, - &self.quantum_backend, - self.explicit_num_qubits, - self.noise.clone(), - self.definitions.clone(), - self.max_decomp_depth, - self.overrides.clone(), - self.event_handlers.clone(), + // Resolve the sampling strategy: the .sampling() path carries its own + // shot count; the deprecated top-level .shots()/.workers() forwarders + // map onto Monte Carlo. Mixing the two is ambiguous and rejected. + let sampling = match (self.sampling, self.legacy_shots) { + (Some(sampling), None) => { + assert!( + self.legacy_workers.is_none(), + "Conflicting sampling configuration: deprecated .workers() cannot be \ + combined with .sampling(). Set workers on the sampler builder, e.g. \ + .sampling(monte_carlo(1000).workers(8))." + ); + sampling + } + (Some(_), Some(_)) => panic!( + "Conflicting sampling configuration: deprecated .shots() cannot be combined \ + with .sampling(). Set shots on the sampler builder, e.g. \ + .sampling(monte_carlo(1000))." ), + (None, Some(shots)) => { + let workers = self.legacy_workers.unwrap_or_else(|| { + if self.auto_workers_hint { + std::thread::available_parallelism().map_or(1, std::num::NonZero::get) + } else { + 1 + } + }); + Sampling::MonteCarlo { shots, workers } + } + (None, None) => panic!( + "No sampling strategy set. Use .sampling(monte_carlo(shots)) for Monte Carlo \ + or .sampling(importance_sampling(shots)) for rare-event estimation." + ), + }; + + // The shot count drives the Tool's run loop via the SimConfig resource. + let mut config = self.config; + config.shots = sampling.shots(); + + // Resolve the quantum backend: explicit .quantum() wins; .auto() opts + // into automatic selection; otherwise fail fast. + let auto_backend = self.auto_backend; + let quantum_backend = self.quantum_backend.unwrap_or_else(|| { + assert!( + auto_backend, + "No quantum backend set. Use .quantum(sparse_stab()) or \ + .quantum(state_vector()), or call .auto() to let sim_neo choose." + ); + QuantumBackend::SparseStab + }); + + let parallel_plan = match &sampling { + Sampling::MonteCarlo { workers, .. } if *workers > 1 => { + let plan = build_parallel_execution_plan( + &source, + &quantum_backend, + self.explicit_num_qubits, + self.noise.clone(), + self.definitions.clone(), + self.max_decomp_depth, + self.overrides.clone(), + self.event_handlers.clone(), + ); + assert!( + plan.is_some(), + "Parallel Monte Carlo (workers > 1) requires per-worker construction: \ + a static circuit or classical engine builder source, on a built-in or \ + adapted backend. Pre-built dynamic command sources and custom backends \ + cannot build per-worker state; remove .workers(..) for sequential \ + execution." + ); + plan + } _ => None, }; let mut tool = Tool::new() .insert_resource(ProgramSourceResource(source)) - .insert_resource(self.config) - .insert_resource(QuantumBackendResource(self.quantum_backend)); + .insert_resource(config) + .insert_resource(QuantumBackendResource(quantum_backend)); - match &self.sampling { + match &sampling { Sampling::ImportanceSampling { config: is_config } => { tool = tool.add_plugin(&ImportanceSamplingSimPlugin { is_config: is_config.clone(), @@ -2211,7 +2305,7 @@ impl SimNeoBuilder { Simulation { tool, - sampling: self.sampling, + sampling, parallel_plan, } } @@ -2224,13 +2318,13 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::sim_neo; + /// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm_code = "OPENQASM 2.0; qreg q[1]; h q[0]; measure q[0];"; - /// let results = sim_neo(qasm_code) + /// let results = sim_neo(qasm_code).auto() /// .classical(qasm_engine()) - /// .shots(1000) + /// .sampling(monte_carlo(1000)) /// .run(); // builds and runs /// ``` #[must_use] @@ -2858,11 +2952,11 @@ fn is_sim_post_shot(resources: &mut Resources) { /// # Example /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let mut sim = sim_neo(circuit).shots(1000).build(); +/// let mut sim = sim_neo(circuit).auto().sampling(monte_carlo(1000)).build(); /// /// let results1 = sim.run(); /// @@ -3144,7 +3238,11 @@ fn build_parallel_execution_plan( } impl Simulation { - /// Set the number of shots for the next run. + /// Override the number of shots for the next run. + /// + /// Rerun convenience: adjusts the shot count of the already-built + /// simulation without rebuilding. The sampling strategy (and its worker + /// count) is fixed at build time. pub fn shots(&mut self, shots: usize) -> &mut Self { self.tool.resource_mut::().shots = shots; self @@ -3162,27 +3260,25 @@ impl Simulation { /// after reconfiguring with [`shots()`](Self::shots) or [`seed()`](Self::seed). /// /// Execution strategy depends on the sampling strategy: - /// - `MonteCarlo { workers: 1 }`: Runs shots via the Tool (default) - /// - `MonteCarlo { workers: n }`: Parallelizes shots across n workers + /// - `MonteCarlo { workers: 1, .. }`: Runs shots via the Tool + /// - `MonteCarlo { workers: n, .. }`: Parallelizes shots across n workers /// - `ImportanceSampling`: Runs via the Tool with `ImportanceSamplingSimPlugin` /// /// # Panics - /// Panics if parallel Monte Carlo is used without per-worker runner construction support. + /// + /// Panics if the parallel execution plan is missing for `workers > 1`; + /// `SimNeoBuilder::build()` validates this, so it cannot happen for + /// simulations constructed through the builder. pub fn run(&mut self) -> SimulationResults { let config = self.tool.resource::().clone(); // Dispatch based on sampling strategy match &self.sampling { - Sampling::MonteCarlo { workers } if *workers > 1 => { - let plan = self.parallel_plan.as_ref().unwrap_or_else(|| { - panic!( - "Parallel Monte Carlo requires per-worker runner construction support. \ - Dynamic programs need a command-source factory, and custom backends \ - need a quantum-runner factory. Remove .workers() / .auto_workers() \ - for single-worker execution, or use a backend/source path with \ - explicit per-worker construction." - ) - }); + Sampling::MonteCarlo { workers, .. } if *workers > 1 => { + let plan = self + .parallel_plan + .as_ref() + .expect("parallel plan validated at build time for workers > 1"); self.run_parallel(&config, plan, *workers) } _ => { @@ -3309,16 +3405,16 @@ impl Simulation { /// ## Static Circuit /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new() /// .pz(&[0]).h(&[0]).mz(&[0]) /// .build(); /// -/// let results = sim_neo(circuit) +/// let results = sim_neo(circuit).auto() /// .depolarizing(0.01) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -3327,7 +3423,7 @@ impl Simulation { /// ## QASM Program /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_qasm::qasm_engine; /// /// let qasm = r#" @@ -3341,10 +3437,10 @@ impl Simulation { /// measure q[1] -> c[1]; /// "#; /// -/// let results = sim_neo(qasm) +/// let results = sim_neo(qasm).auto() /// .classical(qasm_engine()) /// .depolarizing(0.01) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -3353,12 +3449,12 @@ impl Simulation { /// ## Reusable Simulation /// /// ```no_run -/// use pecos_neo::tool::sim_neo; +/// use pecos_neo::tool::{monte_carlo, sim_neo}; /// use pecos_neo::prelude::*; /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// let mut sim = sim_neo(circuit) -/// .shots(1000) +/// let mut sim = sim_neo(circuit).auto() +/// .sampling(monte_carlo(1000)) /// .build(); /// /// let results1 = sim.run(); @@ -3377,7 +3473,7 @@ pub fn sim_neo(input: I) -> SimNeoBuilder { /// # Example /// /// ```no_run -/// use pecos_neo::tool::sim_neo_builder; +/// use pecos_neo::tool::{monte_carlo, sim_neo_builder}; /// use pecos_qasm::qasm_engine; /// /// let qasm = r#" @@ -3394,7 +3490,7 @@ pub fn sim_neo(input: I) -> SimNeoBuilder { /// let results = sim_neo_builder() /// .with_engine(qasm_engine().qasm(qasm)) /// .depolarizing(0.01) -/// .shots(1000) +/// .sampling(monte_carlo(1000)) /// .seed(42) /// .build() /// .run(); @@ -3437,7 +3533,11 @@ mod tests { .mz(&[0]) .build(); - let mut sim = sim_neo(circuit).shots(10).seed(42).build(); + let mut sim = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .seed(42) + .build(); let results = sim.run(); @@ -3456,7 +3556,7 @@ mod tests { fn test_sim_neo_rerun() { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - let mut sim = sim_neo(circuit).shots(5).build(); + let mut sim = sim_neo(circuit).auto().sampling(monte_carlo(5)).build(); let results1 = sim.run(); assert_eq!(results1.len(), 5); @@ -3476,9 +3576,19 @@ mod tests { .build(); // Same seed should produce same results - let results1 = sim_neo(circuit.clone()).shots(20).seed(42).build().run(); + let results1 = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(20)) + .seed(42) + .build() + .run(); - let results2 = sim_neo(circuit).shots(20).seed(42).build().run(); + let results2 = sim_neo(circuit) + .auto() + .sampling(monte_carlo(20)) + .seed(42) + .build() + .run(); assert_eq!(results1.outcomes.len(), results2.outcomes.len()); for (o1, o2) in results1.outcomes.iter().zip(results2.outcomes.iter()) { @@ -3505,8 +3615,9 @@ mod tests { let noise = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.5)); let results = sim_neo(circuit) + .auto() .noise(noise) - .shots(100) + .sampling(monte_carlo(100)) .seed(42) .build() .run(); @@ -3542,15 +3653,17 @@ mod tests { let noise2 = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.5)); let results1 = sim_neo(circuit.clone()) + .auto() .noise(noise1) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .build() .run(); let results2 = sim_neo(circuit) + .auto() .noise(noise2) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .build() .run(); @@ -3571,8 +3684,9 @@ mod tests { // This uses the From impl for ComposableNoiseModel let results = sim_neo(circuit) + .auto() .noise(SingleQubitChannel::depolarizing(0.5)) - .shots(50) + .sampling(monte_carlo(50)) .seed(42) .build() .run(); @@ -3598,8 +3712,9 @@ mod tests { // Pass builder directly - no .build() needed! let results = sim_neo(circuit) + .auto() .noise(GeneralNoiseModelBuilder::new().with_p1(0.3)) - .shots(100) + .sampling(monte_carlo(100)) .seed(42) .build() .run(); @@ -3632,8 +3747,9 @@ mod tests { .build(); let results = sim_neo(circuit) + .auto() .depolarizing(0.2) // 20% on both 1Q and 2Q gates - .shots(100) + .sampling(monte_carlo(100)) .seed(42) .build() .run(); @@ -3663,8 +3779,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .noise(GeneralNoiseModelBuilder::new().with_p_meas_symmetric(0.15)) - .shots(200) + .sampling(monte_carlo(200)) .seed(42) .build() .run(); @@ -3693,8 +3810,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .noise(GeneralNoiseModelBuilder::new().with_p_prep(0.20)) - .shots(200) + .sampling(monte_carlo(200)) .seed(42) .build() .run(); @@ -3732,7 +3850,11 @@ mod tests { // .auto() should automatically select qasm_engine() // Using .run() shortcut (equivalent to .build().run()) - let results = sim_neo(qasm).auto().shots(10).seed(42).run(); + let results = sim_neo(qasm) + .auto() + .sampling(monte_carlo(10)) + .seed(42) + .run(); assert_eq!(results.len(), 10); @@ -3760,8 +3882,9 @@ mod tests { // Direct .run() without explicit .build() let results = sim_neo(qasm_source) + .auto() .classical(pecos_qasm::qasm_engine()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -3793,7 +3916,12 @@ mod tests { let program = pecos_programs::Program::Qasm(pecos_programs::Qasm::from_string(qasm_source)); // .auto() should detect Qasm variant and use qasm_engine() - let results = sim_neo(program).auto().shots(50).seed(42).build().run(); + let results = sim_neo(program) + .auto() + .sampling(monte_carlo(50)) + .seed(42) + .build() + .run(); assert_eq!(results.len(), 50); @@ -3815,7 +3943,11 @@ mod tests { .build(); // Use .workers() convenience method for Monte Carlo - let results = sim_neo(circuit).workers(4).shots(100).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(100).workers(4)) + .seed(42) + .run(); assert_eq!(results.len(), 100); @@ -3837,9 +3969,17 @@ mod tests { .mz(&[0]) .build(); - let results1 = sim_neo(circuit.clone()).workers(4).shots(50).seed(42).run(); + let results1 = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(50).workers(4)) + .seed(42) + .run(); - let results2 = sim_neo(circuit).workers(4).shots(50).seed(42).run(); + let results2 = sim_neo(circuit) + .auto() + .sampling(monte_carlo(50).workers(4)) + .seed(42) + .run(); assert_eq!(results1.outcomes.len(), results2.outcomes.len()); for (o1, o2) in results1.outcomes.iter().zip(results2.outcomes.iter()) { @@ -3853,15 +3993,12 @@ mod tests { #[test] fn test_sim_neo_sampling_explicit() { - // Test explicit sampling configuration - use super::Sampling; - + // Test explicit sampling configuration with workers on the builder let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - // Use explicit Sampling enum let results = sim_neo(circuit) - .sampling(Sampling::monte_carlo(2)) - .shots(20) + .auto() + .sampling(monte_carlo(20).workers(2)) .seed(42) .run(); @@ -3875,6 +4012,174 @@ mod tests { } } + #[test] + fn test_sim_neo_sampling_order_independent() { + // Regression for the old top-level .workers() footgun: builder calls + // must commute. .sampling() before or after other config gives the + // same results. + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + + let r1 = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(30)) + .seed(7) + .run(); + let r2 = sim_neo(circuit) + .auto() + .seed(7) + .sampling(monte_carlo(30)) + .run(); + + assert_eq!(r1.outcomes.len(), r2.outcomes.len()); + for (o1, o2) in r1.outcomes.iter().zip(r2.outcomes.iter()) { + assert_eq!(o1.get_bit(QubitId(0)), o2.get_bit(QubitId(0))); + } + } + + #[test] + #[should_panic(expected = "No sampling strategy set")] + fn test_sim_neo_missing_sampling_is_build_error() { + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit).auto().build(); + } + + #[test] + #[should_panic(expected = "No quantum backend set")] + fn test_sim_neo_missing_quantum_backend_is_build_error() { + // Explicit-by-default: no silent SparseStab. Either .quantum(..) or + // .auto() must be called. + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit).sampling(monte_carlo(10)).build(); + } + + #[test] + fn test_sim_neo_auto_selects_backend_for_static_circuit() { + // .auto() opts into automatic backend selection (SparseStab) for + // static circuits, which previously rejected .auto() entirely. + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .seed(1) + .run(); + assert_eq!(results.len(), 10); + for outcome in &results.outcomes { + assert!(outcome.get_bit(QubitId(0)).unwrap()); + } + } + + #[test] + fn test_sim_neo_explicit_quantum_overrides_auto() { + // .auto() plus explicit .quantum() is allowed; the explicit choice + // wins regardless of call order. + let circuit = CommandBuilder::new() + .pz(&[0]) + .x(&[0]) + .t(&[0]) + .mz(&[0]) + .build(); + // T gate requires the state-vector backend; if auto's SparseStab + // choice won, this would fail to execute. + let results = sim_neo(circuit) + .auto() + .quantum(state_vector()) + .sampling(monte_carlo(5)) + .seed(1) + .run(); + assert_eq!(results.len(), 5); + } + + #[test] + #[should_panic(expected = "deprecated .shots() cannot be combined")] + fn test_sim_neo_legacy_shots_conflicts_with_sampling() { + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + #[allow(deprecated)] + let _ = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .shots(20) + .build(); + } + + #[test] + #[should_panic(expected = "deprecated .workers() cannot be combined")] + fn test_sim_neo_legacy_workers_conflicts_with_sampling() { + // The old footgun: .sampling(importance_sampling(..)).workers(n) + // silently discarded the importance-sampling config. Now it fails + // loudly at build time. + let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); + #[allow(deprecated)] + let _ = sim_neo(circuit) + .auto() + .sampling(importance_sampling(10)) + .workers(4) + .build(); + } + + #[test] + #[should_panic( + expected = "Parallel Monte Carlo (workers > 1) requires per-worker construction" + )] + fn test_sim_neo_parallel_dynamic_source_fails_at_build_not_run() { + // The parallel-incapable combination must be rejected by .build(), + // before any shot executes. + let _ = sim_neo(deterministic_conditional_program()) + .auto() + .sampling(monte_carlo(2).workers(2)) + .build(); + } + + #[test] + fn test_sim_neo_legacy_shots_forwarder_matches_new_api() { + // Deprecated .shots(n) must behave exactly like + // .sampling(monte_carlo(n)) during the transition window. + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + + #[allow(deprecated)] + let legacy = sim_neo(circuit.clone()).auto().shots(40).seed(11).run(); + let new = sim_neo(circuit) + .auto() + .sampling(monte_carlo(40)) + .seed(11) + .run(); + + assert_eq!(legacy.outcomes.len(), 40); + assert_eq!(legacy.outcomes.len(), new.outcomes.len()); + for (o1, o2) in legacy.outcomes.iter().zip(new.outcomes.iter()) { + assert_eq!(o1.get_bit(QubitId(0)), o2.get_bit(QubitId(0))); + } + } + + #[test] + fn test_sim_neo_legacy_shots_workers_combo_still_parallel() { + // Old-style .workers(n).shots(m) (both deprecated) maps onto + // MonteCarlo { shots: m, workers: n } and still runs. + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + #[allow(deprecated)] + let results = sim_neo(circuit).auto().workers(2).shots(30).seed(5).run(); + + assert_eq!(results.len(), 30); + for outcome in &results.outcomes { + assert!(outcome.get_bit(QubitId(0)).unwrap()); + } + } + + #[test] + fn test_sim_neo_importance_sampling_shot_count_on_builder() { + // importance_sampling(shots) drives the trial count directly. + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + + let results = sim_neo(circuit) + .auto() + .sampling(importance_sampling(35).with_uniform_error(0.01)) + .seed(3) + .run(); + + assert_eq!(results.len(), 35); + assert!(results.has_weights()); + } + #[test] fn test_sim_neo_single_worker_matches_parallel() { // Critical test: 1 worker and multiple workers should produce identical @@ -3886,10 +4191,18 @@ mod tests { .build(); // Run with default (1 worker) - let single_results = sim_neo(circuit.clone()).shots(50).seed(42).run(); + let single_results = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(50)) + .seed(42) + .run(); // Run with parallel Monte Carlo sampling (4 workers) - let parallel_results = sim_neo(circuit).workers(4).shots(50).seed(42).run(); + let parallel_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(50).workers(4)) + .seed(42) + .run(); // Results should be identical assert_eq!( @@ -3928,16 +4241,17 @@ mod tests { // Run with single worker (default) let single_results = sim_neo(circuit.clone()) + .auto() .noise(noise_single) - .shots(50) + .sampling(monte_carlo(50)) .seed(42) .run(); // Run with parallel Monte Carlo sampling let parallel_results = sim_neo(circuit) + .auto() .noise(noise_par) - .workers(4) - .shots(50) + .sampling(monte_carlo(50).workers(4)) .seed(42) .run(); @@ -3975,16 +4289,16 @@ mod tests { let noise2 = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.3)); let results1 = sim_neo(circuit.clone()) + .auto() .noise(noise1) - .workers(4) - .shots(50) + .sampling(monte_carlo(50).workers(4)) .seed(42) .run(); let results2 = sim_neo(circuit) + .auto() .noise(noise2) - .workers(4) - .shots(50) + .sampling(monte_carlo(50).workers(4)) .seed(42) .run(); @@ -4011,7 +4325,7 @@ mod tests { let results = sim_neo(circuit) .quantum(sparse_stab()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -4034,7 +4348,7 @@ mod tests { let results = sim_neo(circuit) .quantum(stabilizer()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -4060,7 +4374,7 @@ mod tests { let results = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) - .shots(12) + .sampling(monte_carlo(12)) .seed(42) .run(); @@ -4087,7 +4401,7 @@ mod tests { let results = sim_neo(circuit) .quantum(pecos_engines::state_vector()) - .shots(8) + .sampling(monte_carlo(8)) .seed(123) .run(); @@ -4110,7 +4424,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .noise(noise) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4124,8 +4438,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .noise(noise) - .workers(2) - .shots(2) + .sampling(monte_carlo(2).workers(2)) .run(); } #[test] @@ -4140,7 +4453,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .gate_definitions(GateDefinitions::new()) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4153,7 +4466,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .max_decomp_depth(20) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4171,7 +4484,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .gate_overrides(overrides) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4186,7 +4499,7 @@ mod tests { let _ = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) .event_handlers(handlers) - .shots(1) + .sampling(monte_carlo(1)) .run(); } #[test] @@ -4195,8 +4508,7 @@ mod tests { let results = sim_neo(circuit) .quantum(pecos_engines::stabilizer()) - .workers(2) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4217,8 +4529,7 @@ mod tests { let results = sim_neo(circuit) .quantum(pecos_engines::state_vector()) - .workers(2) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4259,7 +4570,7 @@ mod tests { fn test_sim_neo_dynamic_command_source_native_stabilizer() { let results = sim_neo(deterministic_conditional_program()) .quantum(stabilizer()) - .shots(6) + .sampling(monte_carlo(6)) .seed(42) .run(); @@ -4275,7 +4586,7 @@ mod tests { fn test_sim_neo_dynamic_command_source_rerun() { let mut sim = sim_neo(deterministic_conditional_program()) .quantum(stabilizer()) - .shots(2) + .sampling(monte_carlo(2)) .seed(42) .build(); @@ -4301,7 +4612,7 @@ mod tests { let results = sim_neo(deterministic_conditional_qasm()) .classical(pecos_qasm::qasm_engine()) .quantum(stabilizer()) - .shots(6) + .sampling(monte_carlo(6)) .seed(42) .run(); @@ -4316,7 +4627,7 @@ mod tests { fn test_sim_neo_dynamic_command_source_quantum_engine_adapter() { let results = sim_neo(deterministic_conditional_program()) .quantum(pecos_engines::stabilizer()) - .shots(6) + .sampling(monte_carlo(6)) .seed(42) .run(); @@ -4334,7 +4645,7 @@ mod tests { let results = sim_neo(deterministic_conditional_qasm()) .classical(pecos_qasm::qasm_engine()) .quantum(pecos_engines::stabilizer()) - .shots(6) + .sampling(monte_carlo(6)) .seed(42) .run(); @@ -4352,8 +4663,7 @@ mod tests { let results = sim_neo(deterministic_conditional_qasm()) .classical(pecos_qasm::qasm_engine()) .quantum(stabilizer()) - .workers(2) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4371,8 +4681,7 @@ mod tests { let results = sim_neo(deterministic_conditional_qasm()) .classical(pecos_qasm::qasm_engine()) .quantum(pecos_engines::stabilizer()) - .workers(2) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4390,9 +4699,8 @@ mod tests { let program = pecos_programs::Qasm::from_string(deterministic_conditional_qasm()); let results = sim_neo(program) .auto() - .workers(2) .quantum(pecos_engines::stabilizer()) - .shots(6) + .sampling(monte_carlo(6).workers(2)) .seed(42) .run(); @@ -4405,13 +4713,12 @@ mod tests { } #[test] #[should_panic( - expected = "Parallel Monte Carlo requires per-worker runner construction support" + expected = "Parallel Monte Carlo (workers > 1) requires per-worker construction" )] fn test_sim_neo_dynamic_command_source_quantum_engine_adapter_rejects_parallel_workers() { let _ = sim_neo(deterministic_conditional_program()) .quantum(pecos_engines::stabilizer()) - .workers(2) - .shots(2) + .sampling(monte_carlo(2).workers(2)) .run(); } @@ -4424,7 +4731,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -4448,13 +4755,13 @@ mod tests { // Test sparse_stab determinism let sparse1 = sim_neo(circuit.clone()) .quantum(sparse_stab()) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); let sparse2 = sim_neo(circuit.clone()) .quantum(sparse_stab()) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); @@ -4469,13 +4776,13 @@ mod tests { // Test state_vector determinism let sv1 = sim_neo(circuit.clone()) .quantum(state_vector()) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); let sv2 = sim_neo(circuit) .quantum(state_vector()) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); @@ -4497,8 +4804,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) - .workers(4) - .shots(100) + .sampling(monte_carlo(100).workers(4)) .seed(42) .run(); @@ -4520,8 +4826,8 @@ mod tests { use super::importance_sampling; let _ = sim_neo(deterministic_conditional_program()) - .sampling(importance_sampling()) - .shots(1) + .auto() + .sampling(importance_sampling(1)) .run(); } @@ -4537,14 +4843,14 @@ mod tests { .build(); let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(100) .with_p1(0.01) .with_p2(0.02) .with_p_meas(0.01) .with_boost(5.0), ) - .shots(100) .seed(42) .run(); @@ -4566,12 +4872,12 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(50) .with_uniform_error(0.01) .with_boost(10.0), ) - .shots(50) .seed(123) .run(); @@ -4593,12 +4899,12 @@ mod tests { // Run with importance sampling (boosting noise that doesn't affect this test) let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(2000) .with_uniform_error(0.001) - .with_boost(100.0), - ) // Very aggressive boost - .shots(2000) + .with_boost(100.0), // Very aggressive boost + ) .seed(42) .run(); @@ -4627,21 +4933,17 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - let is_builder = importance_sampling() + let is_builder = importance_sampling(20) .with_uniform_error(0.01) .with_boost(10.0); let results1 = sim_neo(circuit.clone()) + .auto() .sampling(is_builder.clone()) - .shots(20) .seed(42) .run(); - let results2 = sim_neo(circuit) - .sampling(is_builder) - .shots(20) - .seed(42) - .run(); + let results2 = sim_neo(circuit).auto().sampling(is_builder).seed(42).run(); assert_eq!(results1.outcomes.len(), results2.outcomes.len()); for (i, (o1, o2)) in results1 @@ -4683,14 +4985,14 @@ mod tests { .build(); let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(100) .with_p1(0.001) .with_p2(0.01) .with_p_meas(0.001) .with_boost(10.0), ) - .shots(100) .seed(42) .run(); @@ -4706,12 +5008,12 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .sampling( - importance_sampling() + importance_sampling(500) .with_uniform_error(0.01) .with_boost(10.0), ) - .shots(500) .seed(42) .run(); @@ -4747,7 +5049,7 @@ mod tests { let results = sim_neo(circuit) .quantum(custom_backend(SparseStab::new)) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -4774,13 +5076,13 @@ mod tests { let builtin_results = sim_neo(circuit.clone()) .quantum(sparse_stab()) - .shots(50) + .sampling(monte_carlo(50)) .seed(42) .run(); let custom_results = sim_neo(circuit) .quantum(custom_backend(SparseStab::new)) - .shots(50) + .sampling(monte_carlo(50)) .seed(42) .run(); @@ -4815,7 +5117,7 @@ mod tests { let results = sim_neo(circuit) .quantum(custom_backend(SparseStab::new)) .noise(noise) - .shots(100) + .sampling(monte_carlo(100)) .seed(42) .build() .run(); @@ -4841,13 +5143,13 @@ mod tests { let results1 = sim_neo(circuit.clone()) .quantum(custom_backend(SparseStab::new)) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); let results2 = sim_neo(circuit) .quantum(custom_backend(SparseStab::new)) - .shots(20) + .sampling(monte_carlo(20)) .seed(42) .run(); @@ -4867,7 +5169,7 @@ mod tests { let results = sim_neo(circuit) .quantum(custom_backend(StateVec::new)) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -4891,7 +5193,11 @@ mod tests { let mut reg = RegisterMap::new(); reg.add_register("c", &[QubitId(0)]); - let results = sim_neo(circuit).shots(200).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(200)) + .seed(42) + .run(); let counts = results.register_counts(®, "c"); // Should have entries for both [false] and [true] @@ -4919,7 +5225,11 @@ mod tests { let mut reg = RegisterMap::new(); reg.add_register("c", &[QubitId(0), QubitId(1)]); - let results = sim_neo(circuit).shots(100).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(100)) + .seed(42) + .run(); let counts = results.register_counts(®, "c"); // Bell state: only |00> and |11> should appear @@ -4945,7 +5255,11 @@ mod tests { reg.add_register("a", &[QubitId(0)]); reg.add_register("b", &[QubitId(1)]); - let results = sim_neo(circuit).shots(5).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(5)) + .seed(42) + .run(); let columns = results.as_register_columns(®); assert_eq!(columns.len(), 2); @@ -4968,7 +5282,11 @@ mod tests { let mut reg = RegisterMap::new(); reg.add_register("missing", &[QubitId(5)]); // never measured - let results = sim_neo(circuit).shots(10).seed(42).run(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .seed(42) + .run(); let counts = results.register_counts(®, "missing"); assert!( @@ -4990,8 +5308,9 @@ mod tests { let defs = GateDefinitions::new(); let results = sim_neo(circuit) + .auto() .gate_definitions(defs) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5018,7 +5337,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) .gate_definitions(defs) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5046,7 +5365,7 @@ mod tests { // This would fail with ProgramRunner::new() (Clifford-only) let results = sim_neo(circuit) .quantum(state_vector()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5077,7 +5396,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5104,8 +5423,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .build() .run(); @@ -5126,8 +5444,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .max_decomp_depth(20) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5148,9 +5467,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .max_decomp_depth(20) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .build() .run(); @@ -5175,7 +5494,7 @@ mod tests { let results = sim_neo(circuit) .quantum(custom_backend_with_rotations(StateVec::new)) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5206,8 +5525,9 @@ mod tests { .build(); let results = sim_neo(circuit) + .auto() .gate_overrides(overrides) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5235,9 +5555,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .gate_overrides(overrides) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .build() .run(); @@ -5266,7 +5586,7 @@ mod tests { let results = sim_neo(circuit) .quantum(state_vector()) .gate_overrides(overrides) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5290,10 +5610,11 @@ mod tests { let overrides = GateOverrides::::new().register(gates::X, |_sim, _angles, _qubits| true); - // SparseStab is the default backend -- StateVec overrides should panic + // .auto() selects SparseStab -- StateVec overrides should panic sim_neo(CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build()) + .auto() .gate_overrides(overrides) - .shots(1) + .sampling(monte_carlo(1)) .seed(42) .build() .run(); @@ -5311,7 +5632,7 @@ mod tests { sim_neo(CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build()) .quantum(state_vector()) .gate_overrides(overrides) - .shots(1) + .sampling(monte_carlo(1)) .seed(42) .build() .run(); @@ -5334,8 +5655,9 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .gate_overrides(overrides) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .build() .run(); @@ -5358,9 +5680,9 @@ mod tests { let defs = GateDefinitions::new(); let results = sim_neo(circuit) + .auto() .gate_definitions(defs) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .build() .run(); diff --git a/exp/pecos-neo/tests/event_handlers_test.rs b/exp/pecos-neo/tests/event_handlers_test.rs index 080ac2b9f..8df4fcd62 100644 --- a/exp/pecos-neo/tests/event_handlers_test.rs +++ b/exp/pecos-neo/tests/event_handlers_test.rs @@ -14,7 +14,7 @@ use pecos_core::impl_signal; use pecos_neo::prelude::*; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -36,8 +36,9 @@ fn event_handlers_on_before_gate_fires_through_sim_neo() { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); @@ -59,9 +60,9 @@ fn event_handlers_parallel_workers_fire_per_worker() { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .workers(2) - .shots(20) + .sampling(monte_carlo(20).workers(2)) .seed(42) .run(); @@ -79,12 +80,17 @@ fn event_handlers_empty_is_noop() { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let results_with = sim_neo(circuit.clone()) + .auto() .event_handlers(handlers) - .shots(10) + .sampling(monte_carlo(10)) .seed(42) .run(); - let results_without = sim_neo(circuit).shots(10).seed(42).run(); + let results_without = sim_neo(circuit) + .auto() + .sampling(monte_carlo(10)) + .seed(42) + .run(); assert_eq!(results_with.len(), results_without.len()); for (a, b) in results_with @@ -128,8 +134,9 @@ fn event_handlers_multiple_handler_types() { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .shots(5) + .sampling(monte_carlo(5)) .seed(42) .run(); @@ -178,8 +185,9 @@ fn signal_handler_fires_through_sim_neo() { .build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .shots(5) + .sampling(monte_carlo(5)) .seed(42) .run(); @@ -205,9 +213,9 @@ fn signal_handler_fires_through_sim_neo_parallel() { .build(); let results = sim_neo(circuit) + .auto() .event_handlers(handlers) - .workers(2) - .shots(10) + .sampling(monte_carlo(10).workers(2)) .seed(42) .run(); diff --git a/exp/pecos-neo/tests/hugr_integration_test.rs b/exp/pecos-neo/tests/hugr_integration_test.rs index 82a65beb8..e6971ed05 100644 --- a/exp/pecos-neo/tests/hugr_integration_test.rs +++ b/exp/pecos-neo/tests/hugr_integration_test.rs @@ -15,7 +15,7 @@ #![cfg(feature = "hugr")] use pecos_core::QubitId; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_programs::Hugr; use std::path::PathBuf; @@ -37,7 +37,7 @@ fn test_hugr_bell_state_auto() { let results = sim_neo(load_hugr("bell_state.hugr")) .auto() .seed(42) - .shots(100) + .sampling(monte_carlo(100)) .build() .run(); @@ -55,7 +55,7 @@ fn test_hugr_single_hadamard_auto() { let results = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(42) - .shots(200) + .sampling(monte_carlo(200)) .build() .run(); @@ -77,9 +77,10 @@ fn test_hugr_explicit_engine() { let hugr = load_hugr("bell_state.hugr"); let source = String::from_utf8_lossy(&hugr.hugr).into_owned(); let results = sim_neo(source) + .auto() .classical(pecos_hugr::hugr_engine()) .seed(42) - .shots(10) + .sampling(monte_carlo(10)) .build() .run(); @@ -89,7 +90,12 @@ fn test_hugr_explicit_engine() { #[test] fn test_hugr_via_program_enum_auto() { let program = pecos_programs::Program::Hugr(load_hugr("bell_state.hugr")); - let results = sim_neo(program).auto().seed(42).shots(10).build().run(); + let results = sim_neo(program) + .auto() + .seed(42) + .sampling(monte_carlo(10)) + .build() + .run(); assert_eq!(results.len(), 10); } @@ -98,14 +104,14 @@ fn test_hugr_seeded_reproducibility() { let results1 = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(123) - .shots(50) + .sampling(monte_carlo(50)) .build() .run(); let results2 = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(123) - .shots(50) + .sampling(monte_carlo(50)) .build() .run(); @@ -124,14 +130,14 @@ fn test_hugr_different_seeds_differ() { let results1 = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(42) - .shots(50) + .sampling(monte_carlo(50)) .build() .run(); let results2 = sim_neo(load_hugr("single_hadamard.hugr")) .auto() .seed(99) - .shots(50) + .sampling(monte_carlo(50)) .build() .run(); diff --git a/exp/pecos-neo/tests/sim_neo_comparison_test.rs b/exp/pecos-neo/tests/sim_neo_comparison_test.rs index 2383a28ad..08d17996d 100644 --- a/exp/pecos-neo/tests/sim_neo_comparison_test.rs +++ b/exp/pecos-neo/tests/sim_neo_comparison_test.rs @@ -22,7 +22,7 @@ use pecos_engines::GeneralNoiseModelBuilder as EnginesNoiseBuilder; use pecos_engines::sim; use pecos_neo::noise::GeneralNoiseModelBuilder; use pecos_neo::prelude::*; -use pecos_neo::tool::sim_neo; +use pecos_neo::tool::{monte_carlo, sim_neo}; use pecos_qasm::qasm_engine; use std::collections::BTreeMap; @@ -134,10 +134,15 @@ fn test_sim_neo_vs_sim_deterministic_x() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() (Tool architecture) + // Run with sim_neo().auto() (Tool architecture) let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0)]); // Both should produce all 1s @@ -175,10 +180,15 @@ fn test_sim_neo_vs_sim_hadamard() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0)]); // Both should be roughly 50/50 @@ -225,7 +235,7 @@ fn test_sim_neo_vs_sim_bell_state() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -235,7 +245,12 @@ fn test_sim_neo_vs_sim_bell_state() { .mz(&[1]) .build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0), QubitId(1)]); // Both should only have 00 and 11 @@ -287,14 +302,15 @@ fn test_sim_neo_vs_sim_depolarizing_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let neo_noise = GeneralNoiseModelBuilder::new().with_p1(p1).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -351,7 +367,7 @@ fn test_sim_neo_vs_sim_measurement_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); let neo_noise = GeneralNoiseModelBuilder::new() @@ -359,8 +375,9 @@ fn test_sim_neo_vs_sim_measurement_noise() { .build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -665,8 +682,9 @@ fn test_sim_neo_ergonomic_builder_direct() { let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); let neo_results = sim_neo(circuit) + .auto() .noise(GeneralNoiseModelBuilder::new().with_p1(p1)) // No .build()! - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -691,14 +709,16 @@ fn test_sim_neo_convenience_methods() { // Using .depolarizing() convenience method let results_convenience = sim_neo(circuit.clone()) + .auto() .depolarizing(0.05) - .shots(500) + .sampling(monte_carlo(500)) .seed(42) .build() .run(); // Using explicit GeneralNoiseModelBuilder - must match what .depolarizing() does let results_explicit = sim_neo(circuit) + .auto() .noise( GeneralNoiseModelBuilder::new() .with_p1(0.05) @@ -706,7 +726,7 @@ fn test_sim_neo_convenience_methods() { .with_p_prep(0.05) .with_p_meas_symmetric(0.05), ) - .shots(500) + .sampling(monte_carlo(500)) .seed(42) .build() .run(); @@ -737,7 +757,11 @@ fn test_sim_neo_reusable() { // Test that sim_neo Simulation handle can be rerun with different configs let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - let mut sim = sim_neo(circuit).shots(100).seed(42).build(); + let mut sim = sim_neo(circuit) + .auto() + .sampling(monte_carlo(100)) + .seed(42) + .build(); // First run let results1 = sim.run(); @@ -779,9 +803,19 @@ fn test_sim_neo_determinism() { // Same seed should produce identical results let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - let results1 = sim_neo(circuit.clone()).shots(100).seed(42).build().run(); + let results1 = sim_neo(circuit.clone()) + .auto() + .sampling(monte_carlo(100)) + .seed(42) + .build() + .run(); - let results2 = sim_neo(circuit).shots(100).seed(42).build().run(); + let results2 = sim_neo(circuit) + .auto() + .sampling(monte_carlo(100)) + .seed(42) + .build() + .run(); // Results should be identical for (o1, o2) in results1.outcomes.iter().zip(results2.outcomes.iter()) { @@ -816,7 +850,7 @@ fn test_sim_neo_vs_sim_noiseless_exact() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() - no noise + // Run with sim_neo().auto() - no noise let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -826,7 +860,12 @@ fn test_sim_neo_vs_sim_noiseless_exact() { .mz(&[1]) .build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0), QubitId(1)]); // Both should produce 100% |11> @@ -875,8 +914,9 @@ fn test_sim_neo_noise_level_scaling() { let neo_noise = GeneralNoiseModelBuilder::new().with_p1(p1).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -943,7 +983,7 @@ fn test_sim_neo_vs_sim_zero_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() with zero-noise model + // Run with sim_neo().auto() with zero-noise model let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -961,8 +1001,9 @@ fn test_sim_neo_vs_sim_zero_noise() { .build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1010,8 +1051,9 @@ fn test_sim_neo_high_noise_chaos() { let neo_noise = GeneralNoiseModelBuilder::new().with_p1(p1).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1071,7 +1113,7 @@ fn test_sim_neo_vs_sim_two_qubit_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -1084,8 +1126,9 @@ fn test_sim_neo_vs_sim_two_qubit_noise() { let neo_noise = GeneralNoiseModelBuilder::new().with_p2(p2).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1141,14 +1184,15 @@ fn test_sim_neo_vs_sim_preparation_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 1); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); let neo_noise = GeneralNoiseModelBuilder::new().with_p_prep(p_prep).build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1211,7 +1255,7 @@ fn test_sim_neo_vs_sim_combined_noise() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 2); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -1229,8 +1273,9 @@ fn test_sim_neo_vs_sim_combined_noise() { .build(); let neo_results = sim_neo(circuit) + .auto() .noise(neo_noise) - .shots(NUM_SHOTS) + .sampling(monte_carlo(NUM_SHOTS)) .seed(42) .build() .run(); @@ -1291,7 +1336,7 @@ fn test_sim_neo_vs_sim_ghz_state() { .unwrap(); let engines_counts = extract_engines_outcomes(&engines_results, "c", 3); - // Run with sim_neo() + // Run with sim_neo().auto() let circuit = CommandBuilder::new() .pz(&[0]) .pz(&[1]) @@ -1304,7 +1349,12 @@ fn test_sim_neo_vs_sim_ghz_state() { .mz(&[2]) .build(); - let neo_results = sim_neo(circuit).shots(NUM_SHOTS).seed(42).build().run(); + let neo_results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(NUM_SHOTS)) + .seed(42) + .build() + .run(); let neo_counts = extract_neo_outcomes(&neo_results, &[QubitId(0), QubitId(1), QubitId(2)]); // Both should only have 000 and 111 diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs index bf1a8fe67..7babea7a9 100644 --- a/python/pecos-rslib-exp/src/lib.rs +++ b/python/pecos-rslib-exp/src/lib.rs @@ -70,6 +70,8 @@ fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_function(wrap_pyfunction!(sim_neo_bindings::py_sim_neo, m)?)?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::monte_carlo, m)?)?; m.add_function(wrap_pyfunction!(sim_neo_bindings::stab_mps, m)?)?; m.add_function(wrap_pyfunction!(sim_neo_bindings::depolarizing, m)?)?; m.add_class::()?; diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 57fd31c7f..78b355168 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -17,7 +17,7 @@ //! results = (sim_neo(tc) //! .quantum(stab_mps().lazy_measure().max_bond_dim(128)) //! .noise(depolarizing().p1(0.003).p2(0.003).p_meas(0.003).p_prep(0.003).idle_rz(0.05)) -//! .shots(5000) +//! .sampling(monte_carlo(5000)) //! .seed(42) //! .run()) //! ``` @@ -308,7 +308,7 @@ pub fn depolarizing() -> PyNoiseModelBuilder { /// Pass to `.quantum()` to select the stabilizer simulator. /// /// Example: -/// sim_neo(tc).quantum(stabilizer()).noise(depolarizing().p2(0.01)).shots(10000).run() +/// sim_neo(tc).quantum(stabilizer()).noise(depolarizing().p2(0.01)).sampling(monte_carlo(10000)).run() #[pyclass( name = "StabilizerBuilder", skip_from_py_object, @@ -331,7 +331,7 @@ impl PyStabilizerBuilder { /// Supports arbitrary gates including non-Clifford (T, RZ, etc.). /// /// Example: -/// sim_neo(tc).quantum(statevec()).noise(depolarizing().idle_rz(0.05)).shots(10000).run() +/// sim_neo(tc).quantum(statevec()).noise(depolarizing().idle_rz(0.05)).sampling(monte_carlo(10000)).run() #[pyclass( name = "StateVecBuilder", skip_from_py_object, @@ -351,7 +351,7 @@ impl PyStateVecBuilder { /// Create a state vector backend builder. /// /// Example: -/// sim_neo(tc).quantum(statevec()).noise(...).shots(10000).run() +/// sim_neo(tc).quantum(statevec()).noise(...).sampling(monte_carlo(10000)).run() #[pyfunction] pub fn statevec() -> PyStateVecBuilder { PyStateVecBuilder @@ -360,7 +360,7 @@ pub fn statevec() -> PyStateVecBuilder { /// Create a stabilizer (SparseStab) backend builder. /// /// Example: -/// sim_neo(tc).quantum(stabilizer()).noise(...).shots(10000).run() +/// sim_neo(tc).quantum(stabilizer()).noise(...).sampling(monte_carlo(10000)).run() #[pyfunction] pub fn stabilizer() -> PyStabilizerBuilder { PyStabilizerBuilder @@ -460,6 +460,39 @@ pub fn meas_sampling(method: &str) -> PyMeasSamplingBuilder { PyMeasSamplingBuilder::new(method) } +/// Monte Carlo sampling strategy builder. Mirrors Rust `monte_carlo(shots)`. +/// +/// Example: +/// sim_neo(tc).sampling(monte_carlo(1000).workers(4)).run() +#[pyclass( + name = "MonteCarloBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PyMonteCarloBuilder { + pub(crate) shots: usize, + pub(crate) workers: usize, +} + +#[pymethods] +impl PyMonteCarloBuilder { + /// Set the number of parallel workers (1 = sequential). + fn workers(&self, n: usize) -> Self { + let mut c = self.clone(); + c.workers = n; + c + } +} + +/// Create a Monte Carlo sampling strategy running `shots` shots. +/// +/// Sequential by default; chain `.workers(n)` for parallel execution. +#[pyfunction] +pub fn monte_carlo(shots: usize) -> PyMonteCarloBuilder { + PyMonteCarloBuilder { shots, workers: 1 } +} + /// Builder for sim_neo simulations. Mirrors the Rust-side `SimNeoBuilder`. #[pyclass( name = "SimNeoBuilder", @@ -472,10 +505,18 @@ pub struct PySimNeoBuilder { /// Original Rust TickCircuit for meas_sampling (avoids reconstruction). /// Wrapped in Arc for Clone compatibility with pyo3. tick_circuit: std::sync::Arc, - shots: usize, - seed: u64, + /// Sampling strategy as (shots, workers). None until `.sampling()`. + sampling: Option<(usize, usize)>, + /// Shot count from the deprecated top-level `.shots()` forwarder. + legacy_shots: Option, + /// Random seed. None = nondeterministic, mirroring the Rust builder. + seed: Option, + /// Backend auto-selection opt-in from `.auto()`. + auto: bool, noise_config: Option, - backend: String, + /// Backend name. None until `.quantum()` is called; `.auto()` opts into + /// automatic selection at run time. + backend: Option, stabmps_config: Option, meas_sampling_method: Option, } @@ -497,20 +538,20 @@ impl PySimNeoBuilder { let mut c = self.clone(); if builder.is_instance_of::() { let b: PyRef<'_, PyMeasSamplingBuilder> = builder.extract()?; - c.backend = "meas_sampling".to_string(); + c.backend = Some("meas_sampling".to_string()); c.meas_sampling_method = Some(b.method.clone()); c.stabmps_config = None; } else if builder.is_instance_of::() { let b: PyRef<'_, PyStabMpsBuilder> = builder.extract()?; - c.backend = "stabmps".to_string(); + c.backend = Some("stabmps".to_string()); c.stabmps_config = Some(b.inner.clone()); c.meas_sampling_method = None; } else if builder.is_instance_of::() { - c.backend = "stabilizer".to_string(); + c.backend = Some("stabilizer".to_string()); c.stabmps_config = None; c.meas_sampling_method = None; } else if builder.is_instance_of::() { - c.backend = "statevec".to_string(); + c.backend = Some("statevec".to_string()); c.stabmps_config = None; c.meas_sampling_method = None; } else { @@ -521,6 +562,20 @@ impl PySimNeoBuilder { Ok(c) } + /// Opt into automatic selection of unset components. + /// + /// Mirrors the Rust builder's `.auto()`: explicit-about-being-implicit. + /// If `.quantum()` was not called, the backend is selected automatically + /// (the stabilizer backend; circuits with inline channel operations route + /// to the density-matrix path instead, since the stabilizer cannot + /// execute arbitrary channels). The sampling strategy is never + /// auto-selected: `.sampling(monte_carlo(shots))` is always required. + fn auto(&self) -> Self { + let mut c = self.clone(); + c.auto = true; + c + } + /// Set the noise model. fn noise(&self, noise_builder: &PyNoiseModelBuilder) -> Self { let mut c = self.clone(); @@ -528,17 +583,35 @@ impl PySimNeoBuilder { c } - /// Set number of shots. - fn shots(&self, n: usize) -> Self { + /// Set the sampling strategy (shots and workers live on the sampler). + /// + /// Example: + /// sim_neo(tc).sampling(monte_carlo(1000).workers(4)).run() + fn sampling(&self, sampler: &PyMonteCarloBuilder) -> Self { let mut c = self.clone(); - c.shots = n; + c.sampling = Some((sampler.shots, sampler.workers)); c } + /// Set number of shots. + /// + /// Deprecated: use `.sampling(monte_carlo(shots))` instead. + fn shots(&self, py: Python<'_>, n: usize) -> PyResult { + PyErr::warn( + py, + &py.get_type::(), + c"sim_neo(...).shots(n) is deprecated; use .sampling(monte_carlo(n))", + 1, + )?; + let mut c = self.clone(); + c.legacy_shots = Some(n); + Ok(c) + } + /// Set random seed. fn seed(&self, s: u64) -> Self { let mut c = self.clone(); - c.seed = s; + c.seed = Some(s); c } @@ -551,9 +624,11 @@ impl PySimNeoBuilder { return self.run_inline_channel_circuit(); } - if self.backend == "meas_sampling" { + let backend = self.resolved_backend()?; + if backend == "meas_sampling" { return self.run_meas_sampling(); } + let (shots, workers) = self.resolved_sampling()?; let noise = self .noise_config @@ -561,14 +636,17 @@ impl PySimNeoBuilder { .and_then(PyNoiseModelBuilder::build_noise); let mut builder = sim_neo(self.commands.clone()) - .shots(self.shots) - .seed(self.seed); + .sampling(pecos_neo::tool::monte_carlo(shots).workers(workers)); + + if let Some(seed) = self.seed { + builder = builder.seed(seed); + } if let Some(n) = noise { builder = builder.noise(n); } - match self.backend.as_str() { + match backend.as_str() { "stabmps" => { let config = self.stabmps_config.clone().unwrap_or_default(); builder = builder.quantum(pecos_neo::tool::custom_backend_from_factory(config)); @@ -581,8 +659,7 @@ impl PySimNeoBuilder { } _ => { return Err(PyErr::new::(format!( - "Unknown backend: {}", - self.backend + "Unknown backend: {backend}" ))); } } @@ -590,7 +667,7 @@ impl PySimNeoBuilder { let mut sim = builder.build(); let results = sim.run(); - let mut all_shots = Vec::with_capacity(self.shots); + let mut all_shots = Vec::with_capacity(shots); for shot_outcomes in &results.outcomes { let meas: Vec = shot_outcomes.iter().map(|o| u8::from(o.outcome)).collect(); all_shots.push(meas); @@ -601,43 +678,120 @@ impl PySimNeoBuilder { } impl PySimNeoBuilder { + /// Resolve the sampling strategy, mirroring the Rust builder's rules + /// and error messages. + fn resolved_sampling(&self) -> PyResult<(usize, usize)> { + match (self.sampling, self.legacy_shots) { + (Some(sampling), None) => Ok(sampling), + (Some(_), Some(_)) => Err(pyo3::exceptions::PyValueError::new_err( + "Conflicting sampling configuration: deprecated .shots() cannot be combined \ + with .sampling(). Set shots on the sampler builder, e.g. \ + .sampling(monte_carlo(1000)).", + )), + (None, Some(shots)) => Ok((shots, 1)), + (None, None) => Err(pyo3::exceptions::PyValueError::new_err( + "No sampling strategy set. Use .sampling(monte_carlo(shots)).", + )), + } + } + + /// Resolve the quantum backend, mirroring the Rust builder's rules: + /// explicit `.quantum()` wins; `.auto()` opts into automatic selection; + /// otherwise fail fast. Auto selects the stabilizer backend, except for + /// circuits with inline channel operations, which route to the + /// density-matrix path (the stabilizer cannot execute arbitrary + /// channels). + fn resolved_backend(&self) -> PyResult { + if let Some(backend) = &self.backend { + return Ok(backend.clone()); + } + if self.auto { + let auto_backend = if self.tick_circuit.has_channel_operations() { + "statevec" + } else { + "stabilizer" + }; + return Ok(auto_backend.to_string()); + } + Err(pyo3::exceptions::PyValueError::new_err( + "No quantum backend set. Use .quantum(stabilizer()) or .quantum(statevec()), \ + or call .auto() to let sim_neo choose.", + )) + } + + /// Concrete seed for execution paths that require one. Unset seed means + /// nondeterministic (mirroring the Rust builder), so draw fresh entropy. + fn resolved_seed_u64(&self) -> u64 { + use std::hash::{BuildHasher, Hasher}; + self.seed.unwrap_or_else(|| { + std::collections::hash_map::RandomState::new() + .build_hasher() + .finish() + }) + } + fn run_inline_channel_circuit(&self) -> PyResult { if self.noise_config.is_some() { return Err(pyo3::exceptions::PyValueError::new_err( "sim_neo received a TickCircuit with inline channel operations; do not also pass .noise()", )); } + let backend = self.resolved_backend()?; + match backend.as_str() { + "statevec" | "stabilizer" => {} + "stabmps" => { + return Err(pyo3::exceptions::PyValueError::new_err( + "stab_mps backend does not support inline channel operations; use statevec() for density-matrix execution", + )); + } + "meas_sampling" => { + return Err(pyo3::exceptions::PyValueError::new_err( + "meas_sampling backend builds its own measurement model and does not consume inline channel operations", + )); + } + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unknown backend: {other}" + ))); + } + } + let (shots, workers) = self.resolved_sampling()?; + if workers > 1 { + return Err(pyo3::exceptions::PyValueError::new_err( + "inline-channel execution does not support parallel workers; use monte_carlo(shots) without .workers()", + )); + } + let seed = self.resolved_seed_u64(); - match self.backend.as_str() { - "statevec" => self.run_inline_channel_density_matrix(), - "stabilizer" => self.run_inline_pauli_channel_stabilizer(), - "stabmps" => Err(pyo3::exceptions::PyValueError::new_err( - "stab_mps backend does not support inline channel operations; use statevec()/default for density-matrix execution", - )), - "meas_sampling" => Err(pyo3::exceptions::PyValueError::new_err( - "meas_sampling backend builds its own measurement model and does not consume inline channel operations", - )), - other => Err(pyo3::exceptions::PyValueError::new_err(format!( - "Unknown backend: {other}" - ))), + match backend.as_str() { + "statevec" => self.run_inline_channel_density_matrix(shots, seed), + _ => self.run_inline_pauli_channel_stabilizer(shots, seed), } } - fn run_inline_channel_density_matrix(&self) -> PyResult { + fn run_inline_channel_density_matrix( + &self, + shots: usize, + seed: u64, + ) -> PyResult { let rows = pecos_neo::inline_channel::run_inline_channels_density_matrix( &self.tick_circuit, - self.shots, - self.seed, + shots, + seed, ) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; Ok(PyRawMeasurementResult::from_rows(rows)) } - fn run_inline_pauli_channel_stabilizer(&self) -> PyResult { + fn run_inline_pauli_channel_stabilizer( + &self, + shots: usize, + seed: u64, + ) -> PyResult { let rows = pecos_neo::inline_channel::run_inline_pauli_channels_stabilizer( &self.tick_circuit, - self.shots, - self.seed, + shots, + seed, ) .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; Ok(PyRawMeasurementResult::from_rows(rows)) @@ -645,6 +799,12 @@ impl PySimNeoBuilder { /// DEM sampling backend: dispatches to stochastic or coherent path based on method. fn run_meas_sampling(&self) -> PyResult { + let (_, workers) = self.resolved_sampling()?; + if workers > 1 { + return Err(pyo3::exceptions::PyValueError::new_err( + "meas_sampling does its own batch sampling and does not support parallel workers; use monte_carlo(shots) without .workers()", + )); + } let noise_config = self.noise_config.as_ref().ok_or_else(|| { pyo3::exceptions::PyValueError::new_err("DEM sampling requires .noise() to be set") })?; @@ -704,7 +864,8 @@ impl PySimNeoBuilder { .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; let plan = RawMeasurementPlan::new(&history, mechanisms); - let result = plan.sample(self.shots, self.seed); + let (shots, _) = self.resolved_sampling()?; + let result = plan.sample(shots, self.resolved_seed_u64()); Ok(PyRawMeasurementResult::from_columnar(result)) } @@ -781,13 +942,14 @@ impl PySimNeoBuilder { let gates = commands_to_gates(&self.commands); let generator = select_generator(method, noise_config.idle_rz_angle); + let (shots, _) = self.resolved_sampling()?; let result = run_dem_simulation( &gates, &noise, &meta, generator.as_ref(), - self.shots, - self.seed, + shots, + self.resolved_seed_u64(), ); Ok(result.measurements) } @@ -825,11 +987,16 @@ fn commands_to_gates(commands: &pecos_neo::command::CommandQueue) -> Vec) -> PyResult Ok(PySimNeoBuilder { commands, tick_circuit: std::sync::Arc::new(tc), - shots: 1, - seed: 42, + sampling: None, + legacy_shots: None, + seed: None, + auto: false, noise_config: None, - backend: "statevec".to_string(), + backend: None, stabmps_config: None, meas_sampling_method: None, }) diff --git a/python/quantum-pecos/src/pecos/qec/analysis.py b/python/quantum-pecos/src/pecos/qec/analysis.py index 2f22f1f8e..94df65691 100644 --- a/python/quantum-pecos/src/pecos/qec/analysis.py +++ b/python/quantum-pecos/src/pecos/qec/analysis.py @@ -580,16 +580,17 @@ def empirical_correlation_table( """ from pecos_rslib_exp import ( meas_sampling, + monte_carlo, sim_neo, stabilizer, statevec, ) if backend == "meas_sampling": - results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).shots(shots).seed(seed).run() + results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).sampling(monte_carlo(shots)).seed(seed).run() elif backend in ("stabilizer", "statevec"): backend_obj = stabilizer() if backend == "stabilizer" else statevec() - results = sim_neo(tick_circuit).quantum(backend_obj).noise(noise_builder).shots(shots).seed(seed).run() + results = sim_neo(tick_circuit).quantum(backend_obj).noise(noise_builder).sampling(monte_carlo(shots)).seed(seed).run() else: supported = "'stabilizer', 'statevec', 'meas_sampling'" msg = f"Unknown backend {backend!r}. Supported: {supported}." @@ -699,6 +700,7 @@ def fit_dem_from_simulation( ) from pecos_rslib_exp import ( meas_sampling, + monte_carlo, sim_neo, stabilizer, statevec, @@ -736,10 +738,10 @@ def fit_dem_from_simulation( num_dets = len(det_json) if backend == "meas_sampling": - results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).shots(shots).seed(seed).run() + results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).sampling(monte_carlo(shots)).seed(seed).run() elif backend in ("stabilizer", "statevec"): backend_obj = stabilizer() if backend == "stabilizer" else statevec() - results = sim_neo(tick_circuit).quantum(backend_obj).noise(noise_builder).shots(shots).seed(seed).run() + results = sim_neo(tick_circuit).quantum(backend_obj).noise(noise_builder).sampling(monte_carlo(shots)).seed(seed).run() else: supported = "'stabilizer', 'statevec', 'meas_sampling'" msg = f"Unknown backend {backend!r}. Supported: {supported}." diff --git a/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py b/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py index 242cbdcbb..00323d494 100644 --- a/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py +++ b/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py @@ -5,7 +5,7 @@ import pytest from pecos_rslib.quantum import TickCircuit -from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer +from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer def prep_measure_circuit() -> TickCircuit: @@ -43,18 +43,25 @@ def test_tick_circuit_with_noise_rejects_invalid_probabilities() -> None: prep_measure_circuit().with_noise(p2=1.1) -def test_sim_neo_default_routes_inline_channels_through_density_matrix() -> None: +def test_sim_neo_auto_routes_inline_channels_through_density_matrix() -> None: noisy = prep_measure_circuit().with_noise(p_prep=1.0) - result = sim_neo(noisy).shots(5).seed(123).run() + result = sim_neo(noisy).auto().sampling(monte_carlo(5)).seed(123).run() assert measurement_rows(result) == [[1], [1], [1], [1], [1]] +def test_sim_neo_inline_channels_require_explicit_backend_or_auto() -> None: + noisy = prep_measure_circuit().with_noise(p_prep=1.0) + + with pytest.raises(ValueError, match="No quantum backend set"): + sim_neo(noisy).sampling(monte_carlo(5)).run() + + def test_sim_neo_stabilizer_samples_inline_pauli_channels() -> None: noisy = prep_measure_circuit().with_noise(p_prep=1.0) - result = sim_neo(noisy).quantum(stabilizer()).shots(5).seed(123).run() + result = sim_neo(noisy).quantum(stabilizer()).sampling(monte_carlo(5)).seed(123).run() assert measurement_rows(result) == [[1], [1], [1], [1], [1]] diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py index a0564c682..dd7589ad8 100644 --- a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py @@ -11,7 +11,7 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model -from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer +from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer @pytest.fixture @@ -32,18 +32,18 @@ def coherent(): class TestD3SurfaceCode57vs48: def test_raw_output_is_57_measurements(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(10).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(10)).seed(42).run() assert len(r[0]) == 57 def test_nondet_measurement_mean_half(self, d3_tc, depol): shots = 5000 - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() mean_0 = sum(s[0] for s in r) / shots assert abs(mean_0 - 0.5) < 0.05, f"meas[0]={mean_0:.3f}" def test_det_measurement_mean_low(self, d3_tc, depol): shots = 5000 - r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() mean_4 = sum(s[4] for s in r) / shots assert mean_4 < 0.1, f"meas[4]={mean_4:.3f}" @@ -67,8 +67,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -104,8 +104,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -134,8 +134,8 @@ def rates(results): r[i] += 1.0 / len(results) return r - meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(noise).shots(shots).seed(42).run() - stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(noise).shots(shots).seed(43).run() + meas_r = sim_neo(d3_tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(d3_tc).quantum(stabilizer()).noise(noise).sampling(monte_carlo(shots)).seed(43).run() meas_rates = rates(meas_r) stab_rates = rates(stab_r) @@ -149,29 +149,29 @@ def rates(results): class TestMethodDispatch: def test_auto_no_idle_rz(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(depol).shots(10).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(depol).sampling(monte_carlo(10)).seed(42).run() assert len(r[0]) == 57 def test_auto_with_idle_rz(self, d3_tc, coherent): - r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(coherent).shots(10).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() assert len(r[0]) == 57 def test_stochastic_rejects_idle_rz(self, d3_tc, coherent): with pytest.raises(Exception, match="idle_rz"): - sim_neo(d3_tc).quantum(meas_sampling("stochastic")).noise(coherent).shots(10).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling("stochastic")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() def test_coherent_no_idle_rz(self, d3_tc, depol): - r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(depol).shots(10).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(depol).sampling(monte_carlo(10)).seed(42).run() assert len(r[0]) == 57 def test_coherent_with_idle_rz(self, d3_tc, coherent): - r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(coherent).shots(10).seed(42).run() + r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(coherent).sampling(monte_carlo(10)).seed(42).run() assert len(r[0]) == 57 def test_invalid_method(self, d3_tc, depol): with pytest.raises(Exception, match="Unknown"): - sim_neo(d3_tc).quantum(meas_sampling("bogus")).noise(depol).shots(10).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling("bogus")).noise(depol).sampling(monte_carlo(10)).seed(42).run() def test_no_noise_errors(self, d3_tc): with pytest.raises(Exception, match="noise"): - sim_neo(d3_tc).quantum(meas_sampling()).shots(10).seed(42).run() + sim_neo(d3_tc).quantum(meas_sampling()).sampling(monte_carlo(10)).seed(42).run() diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py b/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py index 1532453b9..e8bfc1e02 100644 --- a/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py @@ -11,7 +11,7 @@ import pytest from pecos.quantum import TickCircuit -from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer, statevec +from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer, statevec def build_two_round_x_check(): @@ -75,8 +75,8 @@ def test_two_round_meas_fault_both_fire(self): depol = depolarizing().p1(0).p2(0).p_meas(0.01).p_prep(0) shots = 50000 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() # Extract detector rate def det_rate(results): @@ -101,8 +101,8 @@ def test_prep_fault_reaches_next_measurement(self): depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0.01) shots = 50000 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() def det_rate(results): return sum(s[0] ^ s[1] for s in results) / len(results) @@ -122,8 +122,8 @@ def test_prep_fault_does_not_cross_reset(self): depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0.01) shots = 50000 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() # Extract detector 0 (m0 XOR m1) rate def det_rate(results, d): @@ -149,8 +149,8 @@ def test_three_round_z_check_all_noise(self): depol = depolarizing().p1(0.001).p2(0.005).p_meas(0.005).p_prep(0.005) shots = 50000 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(42).run() def det_rate(results, d): num_meas = 3 @@ -172,14 +172,14 @@ class TestZeroNoise: def test_two_round_x_check_zero_noise(self): tc = build_two_round_x_check() depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) - r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() + r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() det_fires = sum(s[0] ^ s[1] for s in r) assert det_fires == 0, f"Zero-noise detector fired {det_fires}/1000 times" def test_three_round_z_check_zero_noise(self): tc = build_three_round_z_check() depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) - r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() + r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() for d in [0, 1]: num_meas = 3 recs = [{"records": [-2, -3]}, {"records": [-1, -2]}][d]["records"] @@ -206,8 +206,8 @@ def test_new_clifford_gates_match_stabilizer_zero_noise(self): depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) shots = 32 - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(11).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(99).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(shots)).seed(11).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(shots)).seed(99).run() assert len(meas_r) == len(stab_r) == shots for shot in range(shots): @@ -234,7 +234,7 @@ def test_cy_sign_has_circuit_level_measurement_effect(self): depol = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) for backend in (stabilizer(), statevec()): - result = sim_neo(tc).quantum(backend).noise(depol).shots(32).seed(123).run() + result = sim_neo(tc).quantum(backend).noise(depol).sampling(monte_carlo(32)).seed(123).run() for shot in range(result.num_shots): row = list(result[shot]) assert row[0] ^ row[1] == 1 @@ -253,7 +253,7 @@ def test_cy_circuit_shape_and_values(self): tc.set_meta("observables", "[]") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - result = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(100)).seed(42).run() assert len(result) == 100 assert len(result[0]) == 2 @@ -276,8 +276,8 @@ def test_cy_matches_stabilizer_protocol(self): depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(100).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(100)).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(100)).seed(42).run() assert len(meas_r) == len(stab_r) == 100 assert meas_r.num_measurements == stab_r.num_measurements == 2 diff --git a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py index 84e03a7a5..20a388f73 100644 --- a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py +++ b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py @@ -19,7 +19,7 @@ def test_sim_neo_stack_runs_from_exp() -> None: pecos_rslib_exp.sim_neo(tc) .quantum(pecos_rslib_exp.stabilizer()) .noise(pecos_rslib_exp.depolarizing()) - .shots(2) + .sampling(pecos_rslib_exp.monte_carlo(2)) .seed(123) .run() ) diff --git a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py index 7bb169a1e..302a45a13 100644 --- a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py +++ b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py @@ -11,7 +11,7 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model -from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer +from pecos_rslib_exp import depolarizing, meas_sampling, monte_carlo, sim_neo, stabilizer @pytest.fixture @@ -21,8 +21,8 @@ def d3_results(): tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(100).seed(42).run() - meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() + stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(100)).seed(42).run() + meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(100)).seed(42).run() return stab_r, meas_r @@ -170,7 +170,7 @@ def test_generic_consumer_stabilizer(self): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - result = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(1000).seed(42).run() + result = sim_neo(tc).quantum(stabilizer()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() means = self.compute_measurement_means(result) assert len(means) == 57 @@ -182,7 +182,7 @@ def test_generic_consumer_meas_sampling(self): patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) - result = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(depol).sampling(monte_carlo(1000)).seed(42).run() means = self.compute_measurement_means(result) assert len(means) == 57 diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py b/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py new file mode 100644 index 000000000..e0c4ddd6c --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py @@ -0,0 +1,76 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""sim_neo Python bindings mirror the Rust builder's explicit-by-default rules. + +No silent backend, shot count, or seed. Missing required config fails with +the same messages as the Rust builder; .auto() opts into automatic selection. +""" + +from __future__ import annotations + +import pytest + +pecos_rslib_exp = pytest.importorskip("pecos_rslib_exp") + +from pecos.quantum import TickCircuit # noqa: E402 +from pecos_rslib_exp import monte_carlo, sim_neo, stabilizer # noqa: E402 + + +def one_qubit_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().x([0]) + tc.tick().mz([0]) + return tc + + +def test_missing_quantum_backend_is_error() -> None: + with pytest.raises(ValueError, match="No quantum backend set"): + sim_neo(one_qubit_circuit()).sampling(monte_carlo(2)).run() + + +def test_missing_sampling_is_error() -> None: + with pytest.raises(ValueError, match="No sampling strategy set"): + sim_neo(one_qubit_circuit()).quantum(stabilizer()).run() + + +def test_auto_selects_stabilizer_backend() -> None: + result = sim_neo(one_qubit_circuit()).auto().sampling(monte_carlo(3)).seed(7).run() + assert result.num_shots == 3 + assert [list(shot) for shot in result] == [[1], [1], [1]] + + +def test_explicit_quantum_overrides_auto() -> None: + result = ( + sim_neo(one_qubit_circuit()) + .auto() + .quantum(stabilizer()) + .sampling(monte_carlo(2)) + .seed(7) + .run() + ) + assert result.num_shots == 2 + + +def test_deprecated_shots_forwarder_warns_and_works() -> None: + with pytest.deprecated_call(): + builder = sim_neo(one_qubit_circuit()).quantum(stabilizer()).shots(2) + result = builder.seed(7).run() + assert result.num_shots == 2 + + +def test_deprecated_shots_conflicts_with_sampling() -> None: + with pytest.deprecated_call(): + builder = sim_neo(one_qubit_circuit()).quantum(stabilizer()).shots(2) + with pytest.raises(ValueError, match=r"deprecated \.shots\(\) cannot be combined"): + builder.sampling(monte_carlo(5)).run() diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 47b0ca312..b69a6030d 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -14,6 +14,7 @@ depolarizing, fault_catalog, meas_sampling, + monte_carlo, sim_neo, stabilizer, statevec, @@ -179,7 +180,7 @@ def test_meas_sampling_runs_on_lowered_traced_qis_surface_code(): tc = build_lowered_traced_qis_surface_code() shots = 8 - result = sim_neo(tc).quantum(meas_sampling()).noise(traced_qis_noise()).shots(shots).seed(123).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(traced_qis_noise()).sampling(monte_carlo(shots)).seed(123).run() assert result.num_shots == shots assert result.num_measurements == int(tc.get_meta("num_measurements")) @@ -202,7 +203,7 @@ def test_lowered_traced_qis_pipeline_sampling_and_catalog_smoke(): tc = build_lowered_traced_qis_surface_code(rounds=2) noise = traced_qis_noise() - result = sim_neo(tc).quantum(meas_sampling()).noise(noise).shots(3).seed(321).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(3)).seed(321).run() catalog = fault_catalog(tc, noise) first_fault = next(catalog.fault_configurations(1)) @@ -217,7 +218,7 @@ def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) - result = sim_neo(tc).quantum(meas_sampling()).noise(noise).shots(3).seed(123).run() + result = sim_neo(tc).quantum(meas_sampling()).noise(noise).sampling(monte_carlo(3)).seed(123).run() assert result.num_shots == 3 assert result.num_measurements == 2 @@ -239,7 +240,7 @@ def test_sim_neo_native_backends_accept_face_gates(): tc.set_meta("observables", "[]") for backend in (stabilizer(), statevec()): - result = sim_neo(tc).quantum(backend).noise(zero_noise()).shots(2).seed(123).run() + result = sim_neo(tc).quantum(backend).noise(zero_noise()).sampling(monte_carlo(2)).seed(123).run() assert result.num_measurements == 1 assert all(result[shot][0] == 0 for shot in range(result.num_shots)) @@ -280,7 +281,7 @@ def test_random_mirrored_standard_clifford_circuits_match_across_backends(): backend_results = {} for name, backend in (("stabilizer", stabilizer()), ("statevec", statevec())): - result = sim_neo(tc).quantum(backend).noise(zero_noise()).shots(4).seed(seed).run() + result = sim_neo(tc).quantum(backend).noise(zero_noise()).sampling(monte_carlo(4)).seed(seed).run() backend_results[name] = [list(row) for row in result.to_list()] backend_results["StabMps"] = [run_direct_wrapper_mirrored_circuit(StabMps(3, seed=seed), sequence)] diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py index 3b194a7dd..c7f1787be 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py @@ -12,7 +12,7 @@ from pecos.qec.surface.circuit_builder import tick_circuit_to_stim from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model from pecos_rslib.qec import DemSampler -from pecos_rslib_exp import depolarizing, fault_catalog, meas_sampling, sim_neo +from pecos_rslib_exp import depolarizing, fault_catalog, meas_sampling, monte_carlo, sim_neo pymatching = pytest.importorskip("pymatching") stim = pytest.importorskip("stim") @@ -137,7 +137,7 @@ def test_traced_qis_meas_sampling_ler_tracks_native_dem_pymatching(distance, rou matching = _pymatching_decoder(circuit, noise_args) raw_result = ( - sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).shots(shots).seed(1234).run() + sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).sampling(monte_carlo(shots)).seed(1234).run() ) meas_errors = _decode_raw_measurements(raw_result, circuit, matching, shots) native_errors = _decode_native_dem_samples(circuit, noise_args, matching, shots, seed=5678) @@ -154,7 +154,7 @@ def test_d3_traced_qis_zero_noise_pymatching_pipeline_has_no_logical_errors(): shots = 64 raw_result = ( - sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).shots(shots).seed(2468).run() + sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).sampling(monte_carlo(shots)).seed(2468).run() ) meas_errors = _decode_raw_measurements(raw_result, circuit, matching, shots) native_errors = _decode_native_dem_samples(circuit, noise_args, matching, shots, seed=1357) From 3fe2c291536810685e84547ddc5741a226efd5a2 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 09:48:10 -0600 Subject: [PATCH 089/388] Expose subset simulation as a sim_neo sampler builder (sampling(subset_simulation(n).score(..).failure(..)) -> SimulationResults::subset) and fix pre-existing subset.rs bugs: early-exit double counting of threshold conditionals in all four variants and per-sample seeding missing the simulator measurement RNG --- exp/pecos-neo/docs/dev/design-patterns.md | 3 +- .../docs/user-guides/subset-simulation.md | 68 ++- exp/pecos-neo/src/sampling/subset.rs | 92 ++-- exp/pecos-neo/src/tool.rs | 7 +- exp/pecos-neo/src/tool/simulation.rs | 401 +++++++++++++++++- 5 files changed, 515 insertions(+), 56 deletions(-) diff --git a/exp/pecos-neo/docs/dev/design-patterns.md b/exp/pecos-neo/docs/dev/design-patterns.md index 1c54e597d..271a336a9 100644 --- a/exp/pecos-neo/docs/dev/design-patterns.md +++ b/exp/pecos-neo/docs/dev/design-patterns.md @@ -33,7 +33,8 @@ Need to run a quantum circuit simulation? │ │ └─► Use sim_neo() with importance_sampling(shots) │ │ │ └─► P ~ 10^-6 or smaller? -│ └─► Use SubsetSimulation or ProperSubsetSimulation +│ └─► Use sim_neo() with subset_simulation(samples).score(..).failure(..) +│ (or ProperSubsetSimulation directly for checkpoint continuation) │ └─► Need population-based simulation (splitting, cloning)? └─► Use World with ECS components diff --git a/exp/pecos-neo/docs/user-guides/subset-simulation.md b/exp/pecos-neo/docs/user-guides/subset-simulation.md index f694d1610..6a0a7e087 100644 --- a/exp/pecos-neo/docs/user-guides/subset-simulation.md +++ b/exp/pecos-neo/docs/user-guides/subset-simulation.md @@ -17,19 +17,54 @@ instead of 1/P total samples. ## Quick Start +Subset simulation is a sampling strategy on `sim_neo()`, like +`monte_carlo()` and `importance_sampling()`. It needs two functions: +a score (how close is this outcome to failure?) and a failure predicate. +Both are required; the result arrives in `results.subset` (per-shot +`outcomes` are empty for subset runs). + ```rust -use pecos_neo::sampling::subset::{SubsetSimulation, SubsetConfig}; +use pecos_neo::tool::{sim_neo, sparse_stab, subset_simulation}; + +let results = sim_neo(circuit) + .quantum(sparse_stab()) // currently the only supported backend + .noise(noise) + .sampling( + subset_simulation(1000) // samples per level + // Higher score = closer to failure + .score(|outcomes| count_syndrome_errors(outcomes) as f64) + // What counts as failure? + .failure(|outcomes| is_logical_error(outcomes)), + ) + .seed(42) + .run(); + +let subset = results.subset.expect("subset strategy returns an estimate"); +println!("P(failure) = {:.2e}", subset.probability()); +println!("95% CI: {:?}", subset.confidence_interval_95()); +``` + +Requires a static circuit on the `sparse_stab()` backend; checked at +`.build()`. + +## Configuration + +```rust +subset_simulation(1000) // Samples per level (more = slower but more precise) + .score(score_fn) // Required: distance-to-failure metric + .failure(failure_fn) // Required: rare event predicate + .threshold_fraction(0.1) // Top 10% advance each level + .max_levels(20) // Safety limit on levels + .min_conditional_prob(1e-6) // Give up below this conditional probability +``` -// Define: what does "closer to failure" mean? -let score_fn = |outcomes: &MeasurementOutcomes| -> f64 { - // Higher score = closer to failure - count_syndrome_errors(outcomes) as f64 -}; +## Direct library API -// Define: what counts as failure? -let is_failure = |outcomes: &MeasurementOutcomes| -> bool { - is_logical_error(outcomes) -}; +For lower-level control (custom noise factories, checkpoint-continuation +variants like `ProperSubsetSimulation`), use the sampling module directly: + +```rust +use pecos_neo::sampling::subset::{SubsetSimulation, SubsetConfig}; let config = SubsetConfig::new() .with_samples_per_level(1000) @@ -39,19 +74,6 @@ let result = SubsetSimulation::new(circuit, num_qubits, score_fn, is_failure) .with_noise_builder(|| Some(noise.clone())) .with_config(config) .run(); - -println!("P(failure) = {:.2e}", result.probability()); -println!("95% CI: [{:.2e}, {:.2e}]", result.confidence_interval_95()); -``` - -## Configuration - -```rust -SubsetConfig::new() - .with_samples_per_level(1000) // Samples per level (more = slower but more precise) - .with_threshold_fraction(0.1) // Top 10% advance each level - .with_max_levels(20) // Safety limit on levels - .with_seed(42) // For reproducibility ``` ## When to Use diff --git a/exp/pecos-neo/src/sampling/subset.rs b/exp/pecos-neo/src/sampling/subset.rs index fb1dcb531..3b41895e0 100644 --- a/exp/pecos-neo/src/sampling/subset.rs +++ b/exp/pecos-neo/src/sampling/subset.rs @@ -366,16 +366,21 @@ where num_failures, }); - cumulative_prob *= conditional_prob; - - // Check termination conditions + // Check termination conditions BEFORE committing this level's + // conditional probability: the final estimate is + // cumulative_prob x (failure fraction of the current population), + // so a level's conditional must only be multiplied in when the + // population is actually conditioned on its threshold (resampled + // from the survivors). Otherwise the threshold-crossing + // probability gets counted twice. if num_failures == samples.len() { - // All samples have failed - we're done + // All samples have failed - we're done (final fraction = 1). break; } if conditional_prob < self.config.min_conditional_prob { - // Probability too small to continue reliably + // Probability too small to continue reliably; estimate from + // the current (unconditioned) population. break; } @@ -385,11 +390,19 @@ where break; } - // Check if all survivors are failures - let survivors: Vec<_> = samples.iter().filter(|s| s.score >= threshold).collect(); + // Commit this level: condition on score >= threshold. + cumulative_prob *= conditional_prob; - if survivors.iter().all(|s| s.is_failure) { - // All survivors are failures - we're done + // Check if all survivors are failures + if samples + .iter() + .filter(|s| s.score >= threshold) + .all(|s| s.is_failure) + { + // All survivors are failures - we're done. Keep only the + // conditioned population so the final failure fraction is + // measured on it (= 1.0 here). + samples.retain(|s| s.score >= threshold); break; } @@ -484,15 +497,18 @@ where /// Run one sample and return (`outcomes`, `score`, `is_failure`). fn run_one_sample(&self, rng: &mut PecosRng) -> (MeasurementOutcomes, f64, bool) { let mut sim = SparseStab::new(self.num_qubits); - let mut runner = CircuitRunner::::new().with_rng(rng.clone()); + let mut runner = CircuitRunner::::new(); // Get fresh noise model from builder if let Some(noise) = (self.noise_builder)() { runner = runner.with_noise(noise); } - // Advance the RNG so next call gets different randomness - rng.random::(); + // Seed both the noise RNG and the simulator's internal measurement + // RNG from the master stream; seeding only the noise RNG leaves + // measurement randomness nondeterministic. + let sample_seed: u64 = rng.random(); + runner.set_full_seed(&mut sim, sample_seed); let outcomes = runner .apply_circuit(&mut sim, &self.circuit) @@ -1235,7 +1251,6 @@ impl ProperSubsetSimulation { // Conditional probability for this level let conditional_prob = num_above as f64 / n as f64; - cumulative_prob *= conditional_prob; // Record level statistics let num_failures = self.trajectories.iter().filter(|t| t.is_failure).count(); @@ -1248,16 +1263,27 @@ impl ProperSubsetSimulation { num_failures, }); - // If all trajectories have failed, we're done + // Terminal checks BEFORE committing this level's conditional: + // the final estimate is cumulative_prob x (failure fraction of + // the current population), so a level's conditional must only be + // multiplied in once the population is resampled (conditioned) + // on its threshold. Otherwise the threshold crossing is counted + // twice. + + // If all trajectories have failed, we're done (fraction = 1). if num_failures == n { break; } - // If threshold exceeds failure threshold, we're done + // If the threshold reaches the failure threshold, stop and let + // the final failure fraction condition directly on the failure + // event over the current population. if new_threshold >= self.failure_threshold { break; } + cumulative_prob *= conditional_prob; + current_threshold = new_threshold; // Step 3: Resample - replace trajectories below threshold @@ -1477,10 +1503,6 @@ impl ProperSubsetSimulation { // Conditional probability for this level let conditional_prob = num_above as f64 / n as f64; - if conditional_prob > 0.0 { - cumulative_prob *= conditional_prob; - } - // Record level statistics let num_failures = self.trajectories.iter().filter(|t| t.is_failure).count(); self.levels.push(LevelStats { @@ -1492,16 +1514,28 @@ impl ProperSubsetSimulation { num_failures, }); - // If all trajectories have failed, we're done + // Terminal checks BEFORE committing this level's conditional: + // the final estimate is cumulative_prob x (failure fraction of + // the current population), so a level's conditional must only be + // multiplied in once the population is resampled (conditioned) + // on its threshold. Otherwise the threshold crossing is counted + // twice. + + // If all trajectories have failed, we're done (fraction = 1). if num_failures == n { break; } - // If threshold reached failure level, we're done + // If the threshold reaches the failure level, stop and let the + // final failure fraction condition directly on the failure event. if actual_threshold >= self.failure_threshold { break; } + if conditional_prob > 0.0 { + cumulative_prob *= conditional_prob; + } + current_threshold = actual_threshold; // Step 5: Resample - replace trajectories below threshold @@ -2044,7 +2078,6 @@ impl QecSubsetSimulation { // Conditional probability for this level let conditional_prob = num_above as f64 / n as f64; - cumulative_prob *= conditional_prob; // Record level statistics let num_failures = histories.iter().filter(|h| h.is_failure).count(); @@ -2057,16 +2090,27 @@ impl QecSubsetSimulation { num_failures, }); - // If all trajectories have failed, we're done + // Terminal checks BEFORE committing this level's conditional: + // the final estimate is cumulative_prob x (failure fraction of + // the current population), so a level's conditional must only be + // multiplied in once the population is resampled (conditioned) + // on its threshold. Otherwise the threshold crossing is counted + // twice. + + // If all trajectories have failed, we're done (fraction = 1). if num_failures == n { break; } - // If threshold exceeds failure threshold, we're done + // If the threshold reaches the failure threshold, stop and let + // the final failure fraction condition directly on the failure + // event over the current population. if new_threshold >= self.config.failure_threshold { break; } + cumulative_prob *= conditional_prob; + current_threshold = new_threshold; // Step 3: Resample - replace trajectories below threshold diff --git a/exp/pecos-neo/src/tool.rs b/exp/pecos-neo/src/tool.rs index 1ce54cca3..50a03848b 100644 --- a/exp/pecos-neo/src/tool.rs +++ b/exp/pecos-neo/src/tool.rs @@ -89,6 +89,7 @@ mod system; // Re-export core types pub use self::core::Tool; +pub use crate::sampling::subset::{LevelStats, SubsetResult}; pub use importance::{ CurrentShotWeight, ImportanceSamplingConfig, ImportanceSamplingPlugin, ImportanceSamplingResults, @@ -99,9 +100,9 @@ pub use simulation::{ Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, MonteCarloBuilder, NoiseResource, QuantumBackend, Sampling, SimConfig, SimNeoBuilder, SimNeoInput, Simulation, SimulationResults, SimulatorFactory, SparseStabBuilder, StabilizerBuilder, StateVecBuilder, StoredOverrides, - custom_backend, custom_backend_from_factory, custom_backend_with_rotations, - importance_sampling, monte_carlo, sim_neo, sim_neo_builder, sparse_stab, stabilizer, - state_vector, + SubsetFailureFn, SubsetScoreFn, SubsetSimulationBuilder, custom_backend, + custom_backend_from_factory, custom_backend_with_rotations, importance_sampling, monte_carlo, + sim_neo, sim_neo_builder, sparse_stab, stabilizer, state_vector, subset_simulation, }; pub use simulation::{PendingEngineBuilder, TypedProgram}; pub use system::{IntoSystem, Schedule, System}; diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 287eeb787..5089b5322 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -119,6 +119,7 @@ use crate::outcome::{MeasurementOutcomes, RegisterMap}; use crate::program::{CommandSource, DynProgramRunner, ProgramRunner, StaticProgram}; use crate::runner::{EventHandlers, GateOverrides}; use crate::sampling::importance_runner::ImportanceSamplingRunner; +use crate::sampling::subset::{SubsetConfig, SubsetResult, SubsetSimulation}; use pecos_core::rng::RngManageable; use pecos_core::rng::rng_manageable::derive_seed; use pecos_random::PecosRng; @@ -127,6 +128,7 @@ use pecos_simulators::{ }; use rayon::prelude::*; use std::collections::BTreeMap; +use std::sync::Arc; use super::resource::Resources; use super::{Plugin, Stage, Tool}; @@ -1018,6 +1020,149 @@ pub fn monte_carlo(shots: usize) -> MonteCarloBuilder { MonteCarloBuilder { shots, workers: 1 } } +/// Score function for subset simulation: how "close" an outcome is to the +/// failure event (higher = closer). +pub type SubsetScoreFn = Arc f64 + Send + Sync>; + +/// Failure predicate for subset simulation: did this outcome reach the +/// rare event? +pub type SubsetFailureFn = Arc bool + Send + Sync>; + +/// Builder for the subset simulation sampling strategy. +/// +/// Created by [`subset_simulation()`]. `samples_per_level` is the defining +/// argument; `.score()` and `.failure()` are required (there is no sensible +/// default for either), checked at `.build()`. +#[derive(Clone)] +pub struct SubsetSimulationBuilder { + samples_per_level: usize, + threshold_fraction: f64, + max_levels: usize, + min_conditional_prob: f64, + score: Option, + failure: Option, +} + +impl std::fmt::Debug for SubsetSimulationBuilder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SubsetSimulationBuilder") + .field("samples_per_level", &self.samples_per_level) + .field("threshold_fraction", &self.threshold_fraction) + .field("max_levels", &self.max_levels) + .field("min_conditional_prob", &self.min_conditional_prob) + .field("score", &self.score.as_ref().map(|_| "Fn(..) -> f64")) + .field("failure", &self.failure.as_ref().map(|_| "Fn(..) -> bool")) + .finish() + } +} + +impl SubsetSimulationBuilder { + /// Set the score function: how "close" is this outcome to failure? + /// + /// Higher scores advance to the next level. The score must be + /// consistent with `.failure()`: failing outcomes should score at + /// least as high as any non-failing outcome. + #[must_use] + pub fn score(mut self, score: F) -> Self + where + F: Fn(&MeasurementOutcomes) -> f64 + Send + Sync + 'static, + { + self.score = Some(Arc::new(score)); + self + } + + /// Set the failure predicate: did this outcome reach the rare event? + #[must_use] + pub fn failure(mut self, failure: F) -> Self + where + F: Fn(&MeasurementOutcomes) -> bool + Send + Sync + 'static, + { + self.failure = Some(Arc::new(failure)); + self + } + + /// Set the fraction of samples that advances past each threshold + /// (typically 0.1-0.2; default 0.1). + #[must_use] + pub fn threshold_fraction(mut self, fraction: f64) -> Self { + self.threshold_fraction = fraction; + self + } + + /// Set the maximum number of levels before giving up (default 20). + #[must_use] + pub fn max_levels(mut self, levels: usize) -> Self { + self.max_levels = levels; + self + } + + /// Set the minimum conditional probability before declaring the + /// failure event unreachable (default 1e-6). + #[must_use] + pub fn min_conditional_prob(mut self, p: f64) -> Self { + self.min_conditional_prob = p; + self + } +} + +impl From for Sampling { + fn from(builder: SubsetSimulationBuilder) -> Self { + Sampling::SubsetSimulation { config: builder } + } +} + +/// Create a subset simulation strategy builder running `samples_per_level` +/// samples at each level. +/// +/// Subset simulation estimates rare event probabilities (1e-6 and below) +/// by decomposing them into a product of conditional probabilities across +/// adaptive levels. It needs a `.score()` function (how close an outcome is +/// to failure) and a `.failure()` predicate (did the rare event occur); +/// both are required. +/// +/// The result arrives in [`SimulationResults::subset`]; per-shot +/// `outcomes` are empty for subset runs. +/// +/// Currently supports static circuits on the `sparse_stab()` backend only. +/// +/// # Example +/// +/// ```no_run +/// use pecos_neo::tool::{sim_neo, sparse_stab, subset_simulation}; +/// use pecos_neo::prelude::*; +/// +/// let circuit = CommandBuilder::new() +/// .pz(&[0, 1, 2]) +/// .h(&[0, 1, 2]) +/// .mz(&[0, 1, 2]) +/// .build(); +/// +/// let results = sim_neo(circuit) +/// .quantum(sparse_stab()) +/// .sampling( +/// subset_simulation(1000) +/// .score(|o| o.iter().filter(|m| m.outcome).count() as f64) +/// .failure(|o| o.iter().all(|m| m.outcome)), +/// ) +/// .seed(42) +/// .run(); +/// +/// let subset = results.subset.expect("subset strategy produces an estimate"); +/// println!("P(failure) = {:.2e}", subset.probability()); +/// ``` +#[must_use] +pub fn subset_simulation(samples_per_level: usize) -> SubsetSimulationBuilder { + let defaults = SubsetConfig::default(); + SubsetSimulationBuilder { + samples_per_level, + threshold_fraction: defaults.threshold_fraction, + max_levels: defaults.max_levels, + min_conditional_prob: defaults.min_conditional_prob, + score: None, + failure: None, + } +} + /// Sampling strategy for simulation execution. /// /// This enum defines how shots are executed. Different strategies offer @@ -1029,7 +1174,7 @@ pub fn monte_carlo(shots: usize) -> MonteCarloBuilder { /// [`importance_sampling()`] and pass to /// [`SimNeoBuilder::sampling()`](SimNeoBuilder::sampling). There is no /// default: the shot count is part of the strategy and must be explicit. -#[derive(Debug, Clone)] +#[derive(Clone)] pub enum Sampling { /// Monte Carlo execution (sequential with 1 worker, parallel with >1). /// @@ -1048,22 +1193,58 @@ pub enum Sampling { /// Importance sampling for rare event estimation. /// /// Biases sampling toward rare events and reweights results. - /// Use when estimating probabilities of rare outcomes. + /// Use when estimating probabilities of rare outcomes (~1e-3 to 1e-6). /// /// Use the [`importance_sampling()`] builder function to create this variant. ImportanceSampling { /// Configuration for importance sampling. config: ImportanceSamplingBuilder, }, + + /// Subset simulation for very rare event estimation (~1e-6 and below). + /// + /// Decomposes the rare event probability into a product of conditional + /// probabilities across adaptive levels. Produces an estimate in + /// [`SimulationResults::subset`] instead of per-shot outcomes. + /// + /// Use the [`subset_simulation()`] builder function to create this variant. + SubsetSimulation { + /// Configuration for subset simulation. + config: SubsetSimulationBuilder, + }, +} + +impl std::fmt::Debug for Sampling { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MonteCarlo { shots, workers } => f + .debug_struct("MonteCarlo") + .field("shots", shots) + .field("workers", workers) + .finish(), + Self::ImportanceSampling { config } => f + .debug_struct("ImportanceSampling") + .field("config", config) + .finish(), + Self::SubsetSimulation { config } => f + .debug_struct("SubsetSimulation") + .field("config", config) + .finish(), + } + } } impl Sampling { /// Number of shots/trials this strategy will run. + /// + /// For subset simulation this is the samples per level; the total + /// sample count depends on how many levels the run needs. #[must_use] pub fn shots(&self) -> usize { match self { Self::MonteCarlo { shots, .. } => *shots, Self::ImportanceSampling { config } => config.shots(), + Self::SubsetSimulation { config } => config.samples_per_level, } } } @@ -1075,6 +1256,9 @@ pub struct SimulationResults { pub outcomes: Vec, /// Per-shot importance weights (only for importance sampling). pub weights: Option>, + /// Rare-event estimate with per-level statistics (only for subset + /// simulation; `outcomes` is empty for subset runs). + pub subset: Option, } impl SimulationResults { @@ -1102,6 +1286,7 @@ impl SimulationResults { if let Some(ref mut weights) = self.weights { weights.clear(); } + self.subset = None; } /// Check if this result has importance weights. @@ -2141,6 +2326,8 @@ impl SimNeoBuilder { /// - Parallel Monte Carlo (`workers > 1`) is requested for a /// configuration that cannot build per-worker state (pre-built dynamic /// command sources, custom backends) + /// - Subset simulation is missing `.score()`/`.failure()`, or is used + /// with a non-static source or a backend other than `sparse_stab()` #[must_use] pub fn build(self) -> Simulation { // Resolve the program source - configure pending builder with source if needed @@ -2259,6 +2446,40 @@ impl SimNeoBuilder { _ => None, }; + // Subset simulation runs outside the Tool schedule, driving + // CircuitRunner directly; validate its requirements here and capture + // what the run needs. + let subset_spec = match &sampling { + Sampling::SubsetSimulation { config: ss_config } => { + assert!( + ss_config.score.is_some() && ss_config.failure.is_some(), + "Subset simulation requires both .score(..) and .failure(..) on the \ + subset_simulation(..) builder; neither has a sensible default." + ); + let circuit = match &source { + ProgramSource::Static(circuit) => circuit.clone(), + _ => panic!( + "Subset simulation requires a static circuit. Classical engines \ + and dynamic command sources are not supported." + ), + }; + assert!( + matches!(quantum_backend, QuantumBackend::SparseStab), + "Subset simulation currently supports only the sparse_stab() backend \ + (or .auto())." + ); + let num_qubits = self + .explicit_num_qubits + .unwrap_or_else(|| infer_num_qubits_from_circuit(&circuit)); + Some(SubsetRunSpec { + circuit, + num_qubits, + noise: self.noise.clone(), + }) + } + _ => None, + }; + let mut tool = Tool::new() .insert_resource(ProgramSourceResource(source)) .insert_resource(config) @@ -2276,6 +2497,8 @@ impl SimNeoBuilder { explicit_num_qubits: self.explicit_num_qubits, }); } + // Subset simulation does not use the Tool schedule; no plugin. + Sampling::SubsetSimulation { .. } => {} } // Add noise if configured @@ -2307,6 +2530,7 @@ impl SimNeoBuilder { tool, sampling, parallel_plan, + subset_spec, } } @@ -2970,6 +3194,15 @@ pub struct Simulation { sampling: Sampling, /// Data-oriented plan for parallel execution (if applicable). parallel_plan: Option, + /// Captured inputs for subset simulation (if applicable). + subset_spec: Option, +} + +/// Inputs captured at build time for a subset simulation run. +struct SubsetRunSpec { + circuit: CommandQueue, + num_qubits: usize, + noise: Option, } /// Native backend used by the internal parallel runner factory. @@ -3263,12 +3496,14 @@ impl Simulation { /// - `MonteCarlo { workers: 1, .. }`: Runs shots via the Tool /// - `MonteCarlo { workers: n, .. }`: Parallelizes shots across n workers /// - `ImportanceSampling`: Runs via the Tool with `ImportanceSamplingSimPlugin` + /// - `SubsetSimulation`: Runs the level-adaptive subset algorithm + /// directly; the estimate lands in [`SimulationResults::subset`] /// /// # Panics /// - /// Panics if the parallel execution plan is missing for `workers > 1`; - /// `SimNeoBuilder::build()` validates this, so it cannot happen for - /// simulations constructed through the builder. + /// Panics if the parallel execution plan or subset spec is missing for + /// the corresponding strategy; `SimNeoBuilder::build()` validates both, + /// so it cannot happen for simulations constructed through the builder. pub fn run(&mut self) -> SimulationResults { let config = self.tool.resource::().clone(); @@ -3281,6 +3516,44 @@ impl Simulation { .expect("parallel plan validated at build time for workers > 1"); self.run_parallel(&config, plan, *workers) } + Sampling::SubsetSimulation { config: ss_config } => { + let spec = self + .subset_spec + .as_ref() + .expect("subset spec validated at build time"); + let score = ss_config + .score + .clone() + .expect("score fn validated at build time"); + let failure = ss_config + .failure + .clone() + .expect("failure fn validated at build time"); + // config.shots carries samples_per_level, so the rerun + // override Simulation::shots() applies to it naturally. + let subset_config = SubsetConfig { + samples_per_level: config.shots, + threshold_fraction: ss_config.threshold_fraction, + max_levels: ss_config.max_levels, + min_conditional_prob: ss_config.min_conditional_prob, + seed: config.seed, + }; + let noise = spec.noise.clone(); + let result = SubsetSimulation::new( + spec.circuit.clone(), + spec.num_qubits, + move |o: &MeasurementOutcomes| score(o), + move |o: &MeasurementOutcomes| failure(o), + ) + .with_noise_builder(move || noise.clone()) + .with_config(subset_config) + .run(); + SimulationResults { + outcomes: Vec::new(), + weights: None, + subset: Some(result), + } + } _ => { // Both MonteCarlo{workers:1} and ImportanceSampling run via the Tool. // IS uses ImportanceSamplingSimPlugin instead of UnifiedSimulationPlugin. @@ -3368,6 +3641,7 @@ impl Simulation { SimulationResults { outcomes, weights: None, + subset: None, } } @@ -4180,6 +4454,123 @@ mod tests { assert!(results.has_weights()); } + // --- Subset Simulation Strategy Tests --- + + fn three_qubit_h_circuit() -> CommandQueue { + CommandBuilder::new() + .pz(&[0, 1, 2]) + .h(&[0, 1, 2]) + .mz(&[0, 1, 2]) + .build() + } + + fn count_ones(outcomes: &MeasurementOutcomes) -> f64 { + outcomes.iter().filter(|m| m.outcome).count() as f64 + } + + fn all_ones(outcomes: &MeasurementOutcomes) -> bool { + outcomes.iter().count() > 0 && outcomes.iter().all(|m| m.outcome) + } + + #[test] + fn test_sim_neo_subset_simulation_estimates_known_probability() { + // Three H gates: P(all three measure 1) = 1/8. + let results = sim_neo(three_qubit_h_circuit()) + .quantum(sparse_stab()) + .sampling(subset_simulation(2000).score(count_ones).failure(all_ones)) + .seed(42) + .run(); + + assert!(results.outcomes.is_empty()); + let subset = results.subset.expect("subset strategy returns an estimate"); + let p = subset.probability(); + assert!( + (0.08..=0.20).contains(&p), + "Expected estimate near 1/8, got {p:.4}" + ); + assert!(subset.total_samples >= 2000); + } + + #[test] + fn test_sim_neo_subset_simulation_certain_event() { + // X gate makes the failure event certain. + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let results = sim_neo(circuit) + .auto() + .sampling(subset_simulation(200).score(count_ones).failure(all_ones)) + .seed(7) + .run(); + + let subset = results.subset.expect("subset estimate"); + assert!( + (subset.probability() - 1.0).abs() < 1e-9, + "Certain event should estimate ~1.0, got {}", + subset.probability() + ); + } + + #[test] + fn test_sim_neo_subset_simulation_with_noise() { + // Depolarizing(0.3) after Z: P(flip) = 2/3 * 0.3 = 0.2. + let circuit = CommandBuilder::new().pz(&[0]).z(&[0]).mz(&[0]).build(); + let noise = ComposableNoiseModel::new().add_channel(SingleQubitChannel::depolarizing(0.3)); + + let results = sim_neo(circuit) + .quantum(sparse_stab()) + .noise(noise) + .sampling(subset_simulation(2000).score(count_ones).failure(all_ones)) + .seed(11) + .run(); + + let p = results.subset.expect("subset estimate").probability(); + assert!( + (0.12..=0.28).contains(&p), + "Expected estimate near 0.2, got {p:.4}" + ); + } + + #[test] + fn test_sim_neo_subset_simulation_deterministic() { + let run = || { + sim_neo(three_qubit_h_circuit()) + .auto() + .sampling(subset_simulation(500).score(count_ones).failure(all_ones)) + .seed(99) + .run() + .subset + .expect("subset estimate") + .probability() + }; + assert!((run() - run()).abs() < 1e-15, "Same seed, same estimate"); + } + + #[test] + #[should_panic(expected = "requires both .score(..) and .failure(..)")] + fn test_sim_neo_subset_simulation_missing_fns_is_build_error() { + let _ = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling(subset_simulation(100)) + .build(); + } + + #[test] + #[should_panic(expected = "Subset simulation requires a static circuit")] + fn test_sim_neo_subset_simulation_rejects_dynamic_source() { + let _ = sim_neo(deterministic_conditional_program()) + .auto() + .sampling(subset_simulation(100).score(count_ones).failure(all_ones)) + .build(); + } + + #[test] + #[should_panic(expected = "supports only the sparse_stab() backend")] + fn test_sim_neo_subset_simulation_rejects_state_vector_backend() { + let _ = sim_neo(three_qubit_h_circuit()) + .quantum(state_vector()) + .sampling(subset_simulation(100).score(count_ones).failure(all_ones)) + .build(); + } + #[test] fn test_sim_neo_single_worker_matches_parallel() { // Critical test: 1 worker and multiple workers should produce identical From 17c1008b418dee97375b59f9d93b6f16289fec8b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 10:14:09 -0600 Subject: [PATCH 090/388] Add path_enumeration sampler and importance_sampling workers (worker-count invariant via global-shot-index seeds), move remaining config panics (override/backend mismatch, adapted/custom config, IS static source) to build time, and include exp/ and pecos-rslib-exp in rust-test CI path filters --- .github/workflows/rust-test.yml | 4 + exp/pecos-neo/README.md | 2 +- .../docs/user-guides/importance-sampling.md | 12 + exp/pecos-neo/src/tool.rs | 11 +- exp/pecos-neo/src/tool/simulation.rs | 587 +++++++++++++++++- 5 files changed, 584 insertions(+), 32 deletions(-) diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 003f43fac..b95aafb66 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -16,9 +16,11 @@ on: paths: - 'crates/**' - 'examples/**' + - 'exp/**' - 'julia/pecos-julia-ffi/**' - 'python/pecos-rslib-cuda/**' - 'python/pecos-rslib/**' + - 'python/pecos-rslib-exp/**' - 'python/pecos-rslib-llvm/**' - 'Cargo.toml' - 'Justfile' @@ -29,9 +31,11 @@ on: paths: - 'crates/**' - 'examples/**' + - 'exp/**' - 'julia/pecos-julia-ffi/**' - 'python/pecos-rslib-cuda/**' - 'python/pecos-rslib/**' + - 'python/pecos-rslib-exp/**' - 'python/pecos-rslib-llvm/**' - 'Cargo.toml' - 'Justfile' diff --git a/exp/pecos-neo/README.md b/exp/pecos-neo/README.md index 7768eadff..1c4b27f64 100644 --- a/exp/pecos-neo/README.md +++ b/exp/pecos-neo/README.md @@ -34,7 +34,7 @@ for outcome in &results.outcomes { - **Typed Commands**: `GateCommand` and `CommandQueue` with signal support for metadata alongside gates - **Plugin System**: ECS-inspired architecture for bundling simulation functionality - **Parallel Execution**: Monte Carlo across multiple workers with `.sampling(monte_carlo(shots).workers(n))` -- **Advanced Sampling**: Importance sampling and subset simulation for rare event estimation +- **Advanced Sampling**: Importance sampling (with parallel workers) and subset simulation for rare event estimation; exhaustive path enumeration for measurement branches - **Extensible Gates**: `GateId`-based system with runtime overrides and decomposition - **Program Support**: Classical control engines (QASM, HUGR) with mid-circuit measurement and feedback - **State Vector**: Non-Clifford gates (T, rotations) via `state_vector()` backend diff --git a/exp/pecos-neo/docs/user-guides/importance-sampling.md b/exp/pecos-neo/docs/user-guides/importance-sampling.md index 86e97a700..f764e1d77 100644 --- a/exp/pecos-neo/docs/user-guides/importance-sampling.md +++ b/exp/pecos-neo/docs/user-guides/importance-sampling.md @@ -27,6 +27,18 @@ sim_neo(circuit).auto() .run(); ``` +Trials parallelize across workers; seeding is per global shot index, so +results are identical for any worker count: + +```rust +sim_neo(circuit).auto() + .sampling(importance_sampling(10000) + .with_uniform_error(0.001) + .with_boost(10.0) + .workers(8)) + .run(); +``` + ## Reading Results Results include importance weights. Use `weighted_mean()` to get unbiased estimates: diff --git a/exp/pecos-neo/src/tool.rs b/exp/pecos-neo/src/tool.rs index 50a03848b..a73507a87 100644 --- a/exp/pecos-neo/src/tool.rs +++ b/exp/pecos-neo/src/tool.rs @@ -98,11 +98,12 @@ pub use plugin::{Plugin, PluginGroup}; pub use resource::{Resource, Resources}; pub use simulation::{ Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, MonteCarloBuilder, NoiseResource, - QuantumBackend, Sampling, SimConfig, SimNeoBuilder, SimNeoInput, Simulation, SimulationResults, - SimulatorFactory, SparseStabBuilder, StabilizerBuilder, StateVecBuilder, StoredOverrides, - SubsetFailureFn, SubsetScoreFn, SubsetSimulationBuilder, custom_backend, - custom_backend_from_factory, custom_backend_with_rotations, importance_sampling, monte_carlo, - sim_neo, sim_neo_builder, sparse_stab, stabilizer, state_vector, subset_simulation, + PathEnumerationBuilder, QuantumBackend, Sampling, SimConfig, SimNeoBuilder, SimNeoInput, + Simulation, SimulationResults, SimulatorFactory, SparseStabBuilder, StabilizerBuilder, + StateVecBuilder, StoredOverrides, SubsetFailureFn, SubsetScoreFn, SubsetSimulationBuilder, + custom_backend, custom_backend_from_factory, custom_backend_with_rotations, + importance_sampling, monte_carlo, path_enumeration, sim_neo, sim_neo_builder, sparse_stab, + stabilizer, state_vector, subset_simulation, }; pub use simulation::{PendingEngineBuilder, TypedProgram}; pub use system::{IntoSystem, Schedule, System}; diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 5089b5322..b56a624bf 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -119,6 +119,7 @@ use crate::outcome::{MeasurementOutcomes, RegisterMap}; use crate::program::{CommandSource, DynProgramRunner, ProgramRunner, StaticProgram}; use crate::runner::{EventHandlers, GateOverrides}; use crate::sampling::importance_runner::ImportanceSamplingRunner; +use crate::sampling::path::{PathEnumerator, PathExplorer}; use crate::sampling::subset::{SubsetConfig, SubsetResult, SubsetSimulation}; use pecos_core::rng::RngManageable; use pecos_core::rng::rng_manageable::derive_seed; @@ -802,6 +803,8 @@ impl Default for SimConfig { pub struct ImportanceSamplingBuilder { /// Number of (boosted) trials to run. shots: usize, + /// Number of parallel workers (1 = sequential). + workers: usize, /// Single-qubit gate error rate (true distribution). p1: f64, /// Two-qubit gate error rate (true distribution). @@ -820,6 +823,7 @@ impl ImportanceSamplingBuilder { pub fn new(shots: usize) -> Self { Self { shots, + workers: 1, p1: 0.001, p2: 0.01, p_meas: 0.001, @@ -827,6 +831,23 @@ impl ImportanceSamplingBuilder { } } + /// Set the number of parallel workers (1 = sequential). + /// + /// Trials are seeded per global shot index, so results are identical + /// for any worker count. + #[must_use] + pub fn workers(mut self, workers: usize) -> Self { + self.workers = workers; + self + } + + /// Set the worker count from available parallelism. + #[must_use] + pub fn auto_workers(mut self) -> Self { + self.workers = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); + self + } + /// Set the single-qubit gate error rate. #[must_use] pub fn with_p1(mut self, p: f64) -> Self { @@ -1020,6 +1041,73 @@ pub fn monte_carlo(shots: usize) -> MonteCarloBuilder { MonteCarloBuilder { shots, workers: 1 } } +/// Builder for the path enumeration sampling strategy. +/// +/// Created by [`path_enumeration()`]. +#[derive(Debug, Clone)] +pub struct PathEnumerationBuilder { + max_measurements: usize, +} + +impl From for Sampling { + fn from(builder: PathEnumerationBuilder) -> Self { + Sampling::PathEnumeration { config: builder } + } +} + +/// Create a path enumeration strategy covering up to `max_measurements` +/// random measurement branches. +/// +/// Instead of sampling, this systematically enumerates the measurement +/// branches of a (noiseless) Clifford circuit: every random measurement +/// splits the execution into two equal-probability paths. Each distinct +/// realized path becomes one entry in `SimulationResults::outcomes`, with +/// its exact probability in `SimulationResults::weights` +/// (`2^-{number of random measurements}` along that path; deterministic +/// measurements do not branch). +/// +/// If `max_measurements` covers every random measurement in the circuit, +/// the enumeration is complete and the weights sum to 1. If the circuit +/// has more random measurements than `max_measurements`, uncovered +/// branches default to outcome 0 and the weights sum to less than 1. +/// +/// Requires a static circuit on the `sparse_stab()` backend with no +/// `.noise()` (noise makes branching stochastic beyond measurements); +/// checked at `.build()`. The rerun override `Simulation::shots()` has no +/// effect on enumeration size. +/// +/// # Example +/// +/// ```no_run +/// use pecos_neo::tool::{path_enumeration, sim_neo, sparse_stab}; +/// use pecos_neo::prelude::*; +/// +/// let circuit = CommandBuilder::new() +/// .pz(&[0, 1]) +/// .h(&[0]) +/// .cx(&[(0, 1)]) +/// .mz(&[0, 1]) +/// .build(); +/// +/// let results = sim_neo(circuit) +/// .quantum(sparse_stab()) +/// .sampling(path_enumeration(1)) +/// .run(); +/// +/// // Two paths (00 and 11), each with probability 0.5. +/// for (outcome, weight) in results +/// .outcomes +/// .iter() +/// .zip(results.weights.as_ref().unwrap()) +/// { +/// println!("p = {:.3}: {:?}", weight.weight(), outcome); +/// } +/// ``` +#[must_use] +pub fn path_enumeration(max_measurements: usize) -> PathEnumerationBuilder { + PathEnumerationBuilder { max_measurements } +} + /// Score function for subset simulation: how "close" an outcome is to the /// failure event (higher = closer). pub type SubsetScoreFn = Arc f64 + Send + Sync>; @@ -1212,6 +1300,17 @@ pub enum Sampling { /// Configuration for subset simulation. config: SubsetSimulationBuilder, }, + + /// Exhaustive enumeration of measurement branches (noiseless circuits). + /// + /// Each distinct realized path becomes one outcome entry with its exact + /// probability as the weight. + /// + /// Use the [`path_enumeration()`] builder function to create this variant. + PathEnumeration { + /// Configuration for path enumeration. + config: PathEnumerationBuilder, + }, } impl std::fmt::Debug for Sampling { @@ -1230,6 +1329,10 @@ impl std::fmt::Debug for Sampling { .debug_struct("SubsetSimulation") .field("config", config) .finish(), + Self::PathEnumeration { config } => f + .debug_struct("PathEnumeration") + .field("config", config) + .finish(), } } } @@ -1238,13 +1341,16 @@ impl Sampling { /// Number of shots/trials this strategy will run. /// /// For subset simulation this is the samples per level; the total - /// sample count depends on how many levels the run needs. + /// sample count depends on how many levels the run needs. For path + /// enumeration this is the number of enumerated forced paths + /// (`2^max_measurements`); distinct realized paths may be fewer. #[must_use] pub fn shots(&self) -> usize { match self { Self::MonteCarlo { shots, .. } => *shots, Self::ImportanceSampling { config } => config.shots(), Self::SubsetSimulation { config } => config.samples_per_level, + Self::PathEnumeration { config } => 1usize << config.max_measurements, } } } @@ -2421,6 +2527,39 @@ impl SimNeoBuilder { QuantumBackend::SparseStab }); + // Configuration/backend mismatches are knowable now; fail at build + // instead of at startup. The startup-time checks remain as defensive + // duplicates for direct Tool users. + if let Some(overrides) = &self.overrides { + validate_overrides_backend(overrides, &quantum_backend); + } + match &quantum_backend { + QuantumBackend::AdaptedQuantumEngine(_) => { + reject_dynamic_runner_config( + "QuantumEngineBuilder backend", + self.definitions.as_ref(), + self.max_decomp_depth.as_ref(), + self.overrides.as_ref(), + self.event_handlers.as_ref(), + ); + assert!( + self.noise.is_none(), + "QuantumEngineBuilder backends do not support sim_neo noise modeling. \ + Use a noise-modeling runner/backend instead." + ); + } + QuantumBackend::Custom(factory) => { + reject_dynamic_runner_config( + factory.diagnostic_label(), + self.definitions.as_ref(), + self.max_decomp_depth.as_ref(), + self.overrides.as_ref(), + self.event_handlers.as_ref(), + ); + } + _ => {} + } + let parallel_plan = match &sampling { Sampling::MonteCarlo { workers, .. } if *workers > 1 => { let plan = build_parallel_execution_plan( @@ -2446,6 +2585,72 @@ impl SimNeoBuilder { _ => None, }; + // Importance sampling requires a static circuit; this is knowable + // now, so fail at build time. Parallel IS runs outside the Tool + // schedule and needs the circuit captured. + let is_parallel_spec = match &sampling { + Sampling::ImportanceSampling { config: is_config } => { + let ProgramSource::Static(circuit) = &source else { + panic!( + "Importance sampling requires a static circuit. \ + Classical engines are not supported." + ) + }; + if is_config.workers > 1 { + let circuit = circuit.clone(); + let num_qubits = self + .explicit_num_qubits + .unwrap_or_else(|| infer_num_qubits_from_circuit(&circuit)); + Some(StaticCircuitSpec { + circuit, + num_qubits, + }) + } else { + None + } + } + _ => None, + }; + + // Path enumeration runs outside the Tool schedule; validate its + // requirements here and capture what the run needs. + let path_spec = match &sampling { + Sampling::PathEnumeration { config: pe_config } => { + assert!( + pe_config.max_measurements <= 24, + "Path enumeration covers 2^max_measurements paths; \ + max_measurements = {} would enumerate more than 16M paths. \ + Use subset_simulation or importance_sampling for larger spaces.", + pe_config.max_measurements + ); + let circuit = match &source { + ProgramSource::Static(circuit) => circuit.clone(), + _ => panic!( + "Path enumeration requires a static circuit. Classical engines \ + and dynamic command sources are not supported." + ), + }; + assert!( + matches!(quantum_backend, QuantumBackend::SparseStab), + "Path enumeration currently supports only the sparse_stab() backend \ + (or .auto())." + ); + assert!( + self.noise.is_none(), + "Path enumeration enumerates measurement branches of the noiseless \ + circuit; remove .noise()." + ); + let num_qubits = self + .explicit_num_qubits + .unwrap_or_else(|| infer_num_qubits_from_circuit(&circuit)); + Some(StaticCircuitSpec { + circuit, + num_qubits, + }) + } + _ => None, + }; + // Subset simulation runs outside the Tool schedule, driving // CircuitRunner directly; validate its requirements here and capture // what the run needs. @@ -2497,8 +2702,9 @@ impl SimNeoBuilder { explicit_num_qubits: self.explicit_num_qubits, }); } - // Subset simulation does not use the Tool schedule; no plugin. - Sampling::SubsetSimulation { .. } => {} + // Subset simulation and path enumeration do not use the Tool + // schedule; no plugin. + Sampling::SubsetSimulation { .. } | Sampling::PathEnumeration { .. } => {} } // Add noise if configured @@ -2531,6 +2737,8 @@ impl SimNeoBuilder { sampling, parallel_plan, subset_spec, + is_parallel_spec, + path_spec, } } @@ -2641,12 +2849,36 @@ pub struct UnifiedShotState { pub shot_index: usize, } +/// Validate that stored gate overrides match the resolved backend. +/// +/// The mismatch is knowable at build time; messages mirror the startup-time +/// checks for each backend. +fn validate_overrides_backend(overrides: &StoredOverrides, backend: &QuantumBackend) { + let override_kind = match overrides { + StoredOverrides::SparseStab(_) => "SparseStab", + StoredOverrides::Stabilizer(_) => "Stabilizer", + StoredOverrides::StateVec(_) => "StateVec", + }; + let backend_kind = match backend { + QuantumBackend::SparseStab => "SparseStab", + QuantumBackend::Stabilizer => "Stabilizer", + QuantumBackend::StateVec => "StateVec", + // Adapted/custom backends reject overrides wholesale elsewhere. + QuantumBackend::AdaptedQuantumEngine(_) | QuantumBackend::Custom(_) => return, + }; + assert!( + override_kind == backend_kind, + "{override_kind} gate overrides used with {backend_kind} backend. \ + Use GateOverrides::<{backend_kind}> instead." + ); +} + fn reject_dynamic_runner_config( backend_name: &str, - definitions: Option<&GateDefinitionsResource>, - max_depth: Option<&MaxDecompDepthResource>, - overrides: Option<&GateOverridesResource>, - event_handlers: Option<&EventHandlersResource>, + definitions: Option<&GateDefinitions>, + max_depth: Option<&usize>, + overrides: Option<&StoredOverrides>, + event_handlers: Option<&EventHandlers>, ) { assert!( definitions.is_none(), @@ -2941,10 +3173,10 @@ fn unified_simulation_startup(resources: &mut Resources) { QuantumBackend::AdaptedQuantumEngine(factory) => { reject_dynamic_runner_config( "QuantumEngineBuilder backend", - definitions.as_ref(), - max_depth.as_ref(), - overrides.as_ref(), - event_handlers.as_ref(), + definitions.as_ref().map(|d| &d.0), + max_depth.as_ref().map(|d| &d.0), + overrides.as_ref().map(|o| &o.0), + event_handlers.as_ref().map(|h| &h.0), ); assert!( noise.is_none(), @@ -2957,10 +3189,10 @@ fn unified_simulation_startup(resources: &mut Resources) { QuantumBackend::Custom(factory) => { reject_dynamic_runner_config( factory.diagnostic_label(), - definitions.as_ref(), - max_depth.as_ref(), - overrides.as_ref(), - event_handlers.as_ref(), + definitions.as_ref().map(|d| &d.0), + max_depth.as_ref().map(|d| &d.0), + overrides.as_ref().map(|o| &o.0), + event_handlers.as_ref().map(|h| &h.0), ); // Custom backends create their own runner; gate definitions // should be captured in the factory closure if needed. @@ -3065,6 +3297,33 @@ struct ISCurrentResult { weight: crate::sampling::weight::SampleWeight, } +/// Build an importance sampling runner from builder config. +fn build_importance_runner( + is_config: &ImportanceSamplingBuilder, + num_qubits: usize, +) -> ImportanceSamplingRunner { + ImportanceSamplingRunner::new(SparseStab::new(num_qubits)) + .with_single_qubit_boost(is_config.p1(), is_config.boost()) + .with_two_qubit_boost(is_config.p2(), is_config.boost()) + .with_measurement_boost(is_config.p_meas(), is_config.boost()) +} + +/// Seed an importance sampling runner for a specific global shot index. +/// +/// Seeds derive from (`base_seed`, `shot_index`) only, so results are +/// identical whether shots run sequentially or partitioned across workers. +fn seed_importance_runner( + runner: &mut ImportanceSamplingRunner, + base_seed: u64, + shot_index: usize, +) { + let shot_seed = derive_seed(base_seed, &format!("shot_{shot_index}")); + runner.rng = PecosRng::seed_from_u64(derive_seed(shot_seed, "noise")); + runner + .simulator + .set_seed(derive_seed(shot_seed, "simulator")); +} + /// Startup system for importance sampling simulation. fn is_sim_startup(resources: &mut Resources) { let explicit_qubits = resources.get::().0; @@ -3110,11 +3369,7 @@ fn is_sim_startup(resources: &mut Resources) { // Also consume NoiseResource if present (IS uses its own boosted noise) let _ = resources.try_remove::(); - let sim = SparseStab::new(num_qubits); - let runner = ImportanceSamplingRunner::new(sim) - .with_single_qubit_boost(is_config.p1(), is_config.boost()) - .with_two_qubit_boost(is_config.p2(), is_config.boost()) - .with_measurement_boost(is_config.p_meas(), is_config.boost()); + let runner = build_importance_runner(&is_config, num_qubits); resources.insert(ISShotState { runner, @@ -3134,12 +3389,8 @@ fn is_sim_pre_shot(resources: &mut Resources) { let state = resources.get_mut::(); let base_seed = config.seed.unwrap_or(0); - let shot_seed = derive_seed(base_seed, &format!("shot_{}", state.shot_index)); - let sim_seed = derive_seed(shot_seed, "simulator"); - let noise_seed = derive_seed(shot_seed, "noise"); - - state.runner.rng = PecosRng::seed_from_u64(noise_seed); - state.runner.simulator.set_seed(sim_seed); + let shot_index = state.shot_index; + seed_importance_runner(&mut state.runner, base_seed, shot_index); } /// Execute system for importance sampling: run one shot with biased noise. @@ -3196,6 +3447,10 @@ pub struct Simulation { parallel_plan: Option, /// Captured inputs for subset simulation (if applicable). subset_spec: Option, + /// Captured inputs for parallel importance sampling (if applicable). + is_parallel_spec: Option, + /// Captured inputs for path enumeration (if applicable). + path_spec: Option, } /// Inputs captured at build time for a subset simulation run. @@ -3205,6 +3460,12 @@ struct SubsetRunSpec { noise: Option, } +/// Inputs captured at build time for a parallel importance sampling run. +struct StaticCircuitSpec { + circuit: CommandQueue, + num_qubits: usize, +} + /// Native backend used by the internal parallel runner factory. #[derive(Debug, Clone, Copy)] enum NativeParallelBackend { @@ -3516,6 +3777,41 @@ impl Simulation { .expect("parallel plan validated at build time for workers > 1"); self.run_parallel(&config, plan, *workers) } + Sampling::ImportanceSampling { config: is_config } if is_config.workers > 1 => { + let spec = self + .is_parallel_spec + .as_ref() + .expect("parallel IS spec captured at build time"); + Self::run_parallel_importance(&config, is_config, spec) + } + Sampling::PathEnumeration { config: pe_config } => { + let spec = self + .path_spec + .as_ref() + .expect("path spec validated at build time"); + let mut explorer = PathExplorer::new(SparseStab::new(spec.num_qubits)); + + let mut seen = std::collections::BTreeSet::new(); + let mut outcomes = Vec::new(); + let mut weights = Vec::new(); + for forced_path in PathEnumerator::new(pe_config.max_measurements) { + let result = explorer.run_with_path(&spec.circuit, &forced_path); + // Different forced paths can realize the same actual path + // (deterministic measurements ignore forced bits); keep + // each distinct realized path once with its exact + // probability. + if seen.insert(result.path.signature().to_binary_string()) { + weights.push(result.path.probability()); + outcomes.push(result.outcomes); + } + } + + SimulationResults { + outcomes, + weights: Some(weights), + subset: None, + } + } Sampling::SubsetSimulation { config: ss_config } => { let spec = self .subset_spec @@ -3568,6 +3864,67 @@ impl Simulation { } } + /// Run importance sampling trials in parallel using rayon. + /// + /// Each worker builds its own boosted runner and processes a contiguous + /// range of global shot indices. Seeds derive from (base seed, global + /// shot index) alone — the same scheme as the sequential IS systems — + /// so outcomes and weights are identical for any worker count. + fn run_parallel_importance( + config: &SimConfig, + is_config: &ImportanceSamplingBuilder, + spec: &StaticCircuitSpec, + ) -> SimulationResults { + let shots = config.shots; + let num_workers = is_config.workers; + let base_seed = config.seed.unwrap_or(0); + + let shots_per_worker = distribute_shots(shots, num_workers); + let mut start_indices = vec![0usize; num_workers]; + for i in 1..num_workers { + start_indices[i] = start_indices[i - 1] + shots_per_worker[i - 1]; + } + + let per_worker: Vec<( + Vec, + Vec, + )> = (0..num_workers) + .into_par_iter() + .map(|worker_id| { + let worker_shots = shots_per_worker[worker_id]; + let mut outcomes = Vec::with_capacity(worker_shots); + let mut weights = Vec::with_capacity(worker_shots); + if worker_shots == 0 { + return (outcomes, weights); + } + + let mut runner = build_importance_runner(is_config, spec.num_qubits); + let start = start_indices[worker_id]; + for shot_index in start..start + worker_shots { + seed_importance_runner(&mut runner, base_seed, shot_index); + let result = runner.run_shot_fresh(&spec.circuit); + outcomes.push(result.outcomes); + weights.push(result.weight); + } + (outcomes, weights) + }) + .collect(); + + // Flatten in worker order = global shot order. + let mut outcomes = Vec::with_capacity(shots); + let mut weights = Vec::with_capacity(shots); + for (o, w) in per_worker { + outcomes.extend(o); + weights.extend(w); + } + + SimulationResults { + outcomes, + weights: Some(weights), + subset: None, + } + } + /// Run shots in parallel using rayon (static circuits with built-in backends). /// /// Each worker gets its own `Resources` and runs the shared schedule, @@ -4454,6 +4811,184 @@ mod tests { assert!(results.has_weights()); } + #[test] + fn test_sim_neo_importance_sampling_parallel_matches_sequential() { + // Per-shot seeding from global indices: any worker count gives + // identical outcomes AND weights. + let circuit = CommandBuilder::new() + .pz(&[0, 1]) + .h(&[0]) + .cx(&[(0, 1)]) + .mz(&[0, 1]) + .build(); + let run = |workers: usize| { + sim_neo(circuit.clone()) + .auto() + .sampling( + importance_sampling(60) + .with_uniform_error(0.01) + .with_boost(10.0) + .workers(workers), + ) + .seed(42) + .run() + }; + + let sequential = run(1); + let parallel = run(4); + + assert_eq!(sequential.outcomes.len(), 60); + assert_eq!(sequential.outcomes.len(), parallel.outcomes.len()); + for (i, (s, p)) in sequential + .outcomes + .iter() + .zip(parallel.outcomes.iter()) + .enumerate() + { + assert_eq!( + s.get_bit(QubitId(0)), + p.get_bit(QubitId(0)), + "Shot {i} qubit 0 should match" + ); + assert_eq!( + s.get_bit(QubitId(1)), + p.get_bit(QubitId(1)), + "Shot {i} qubit 1 should match" + ); + } + + let sw = sequential.weights.as_ref().unwrap(); + let pw = parallel.weights.as_ref().unwrap(); + assert_eq!(sw.len(), pw.len()); + for (i, (a, b)) in sw.iter().zip(pw.iter()).enumerate() { + assert!( + (a.weight() - b.weight()).abs() < 1e-12, + "Weight at shot {i} should match" + ); + } + } + + #[test] + fn test_sim_neo_importance_sampling_parallel_deterministic() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let run = || { + sim_neo(circuit.clone()) + .auto() + .sampling(importance_sampling(30).with_uniform_error(0.01).workers(3)) + .seed(5) + .run() + }; + let r1 = run(); + let r2 = run(); + for (o1, o2) in r1.outcomes.iter().zip(r2.outcomes.iter()) { + assert_eq!(o1.get_bit(QubitId(0)), o2.get_bit(QubitId(0))); + } + } + + // --- Path Enumeration Strategy Tests --- + + #[test] + fn test_sim_neo_path_enumeration_bell_pair() { + // H + CX: first measurement is random (two branches), second is + // deterministic given the first. Exactly two paths, p = 0.5 each, + // with perfectly correlated outcomes. + let circuit = CommandBuilder::new() + .pz(&[0, 1]) + .h(&[0]) + .cx(&[(0, 1)]) + .mz(&[0, 1]) + .build(); + + let results = sim_neo(circuit) + .quantum(sparse_stab()) + .sampling(path_enumeration(1)) + .run(); + + assert_eq!(results.outcomes.len(), 2, "Two measurement branches"); + let weights = results.weights.as_ref().unwrap(); + let total: f64 = weights + .iter() + .map(crate::sampling::weight::SampleWeight::weight) + .sum(); + assert!( + (total - 1.0).abs() < 1e-12, + "Complete enumeration sums to 1" + ); + for (outcome, weight) in results.outcomes.iter().zip(weights) { + assert!((weight.weight() - 0.5).abs() < 1e-12); + assert_eq!( + outcome.get_bit(QubitId(0)), + outcome.get_bit(QubitId(1)), + "Bell pair outcomes must be correlated" + ); + } + } + + #[test] + fn test_sim_neo_path_enumeration_dedupes_deterministic_circuit() { + // X then measure: fully deterministic, one realized path with p = 1 + // even though 2^2 forced paths are enumerated. + let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let results = sim_neo(circuit).auto().sampling(path_enumeration(2)).run(); + + assert_eq!(results.outcomes.len(), 1, "One distinct path"); + let weights = results.weights.as_ref().unwrap(); + assert!((weights[0].weight() - 1.0).abs() < 1e-12); + assert_eq!(results.outcomes[0].get_bit(QubitId(0)), Some(true)); + } + + #[test] + fn test_sim_neo_path_enumeration_three_qubit_uniform() { + // Three independent H measurements: 8 paths, p = 1/8 each. + let results = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling(path_enumeration(3)) + .run(); + + assert_eq!(results.outcomes.len(), 8); + let weights = results.weights.as_ref().unwrap(); + let total: f64 = weights + .iter() + .map(crate::sampling::weight::SampleWeight::weight) + .sum(); + assert!((total - 1.0).abs() < 1e-12); + for weight in weights { + assert!((weight.weight() - 0.125).abs() < 1e-12); + } + } + + #[test] + #[should_panic(expected = "remove .noise()")] + fn test_sim_neo_path_enumeration_rejects_noise() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit) + .auto() + .noise(SingleQubitChannel::depolarizing(0.1)) + .sampling(path_enumeration(1)) + .build(); + } + + #[test] + #[should_panic(expected = "Path enumeration currently supports only the sparse_stab() backend")] + fn test_sim_neo_path_enumeration_rejects_state_vector_backend() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit) + .quantum(state_vector()) + .sampling(path_enumeration(1)) + .build(); + } + + #[test] + #[should_panic(expected = "more than 16M paths")] + fn test_sim_neo_path_enumeration_rejects_huge_enumeration() { + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let _ = sim_neo(circuit) + .auto() + .sampling(path_enumeration(25)) + .build(); + } + // --- Subset Simulation Strategy Tests --- fn three_qubit_h_circuit() -> CommandQueue { From c82bc7b342cb2598ac1b6eaa6edc35d69592ef79 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 11:45:40 -0600 Subject: [PATCH 091/388] Add path_enumeration to the sim_neo Python bindings with exact path probabilities exposed via an optional weights getter on RawMeasurementResult --- python/pecos-rslib-exp/src/lib.rs | 2 + .../pecos-rslib-exp/src/sim_neo_bindings.rs | 171 ++++++++++++++++-- .../qec/test_sim_neo_path_enumeration.py | 91 ++++++++++ 3 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs index 7babea7a9..08432b11c 100644 --- a/python/pecos-rslib-exp/src/lib.rs +++ b/python/pecos-rslib-exp/src/lib.rs @@ -72,6 +72,8 @@ fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(sim_neo_bindings::py_sim_neo, m)?)?; m.add_class::()?; m.add_function(wrap_pyfunction!(sim_neo_bindings::monte_carlo, m)?)?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::path_enumeration, m)?)?; m.add_function(wrap_pyfunction!(sim_neo_bindings::stab_mps, m)?)?; m.add_function(wrap_pyfunction!(sim_neo_bindings::depolarizing, m)?)?; m.add_class::()?; diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 78b355168..0c06c1dff 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -60,6 +60,10 @@ fn measurement_record_index(record: i32, num_measurements: usize) -> Option>, } enum RawMeasurementStorage { @@ -135,6 +139,7 @@ impl PyRawMeasurementResult { pub fn from_columnar(result: SampleResult) -> Self { Self { storage: RawMeasurementStorage::Columnar(result), + weights: None, } } @@ -146,8 +151,16 @@ impl PyRawMeasurementResult { rows, num_measurements, }, + weights: None, } } + + /// Construct from row-major data with per-row weights. + pub fn from_rows_weighted(rows: Vec>, weights: Vec) -> Self { + let mut result = Self::from_rows(rows); + result.weights = Some(weights); + result + } } #[pymethods] @@ -164,6 +177,15 @@ impl PyRawMeasurementResult { self.storage.num_measurements() } + /// Per-row weights, or None for plain Monte Carlo runs. + /// + /// Path enumeration: exact path probabilities (sum to 1 for complete + /// enumeration). + #[getter] + fn weights(&self) -> Option> { + self.weights.clone() + } + /// Get a single measurement bit (0 or 1). fn get(&self, shot: isize, measurement: isize) -> PyResult { let s = Self::check_index(shot, self.storage.num_shots(), "shot")?; @@ -493,6 +515,39 @@ pub fn monte_carlo(shots: usize) -> PyMonteCarloBuilder { PyMonteCarloBuilder { shots, workers: 1 } } +/// Path enumeration strategy builder. Mirrors Rust `path_enumeration(k)`. +/// +/// Exhaustively enumerates the measurement branches of a noiseless Clifford +/// circuit. Each distinct realized path becomes one result row; exact path +/// probabilities are exposed via `result.weights`. +/// +/// Example: +/// result = sim_neo(tc).quantum(stabilizer()).sampling(path_enumeration(2)).run() +/// for row, p in zip(result, result.weights): ... +#[pyclass( + name = "PathEnumerationBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PyPathEnumerationBuilder { + pub(crate) max_measurements: usize, +} + +/// Create a path enumeration strategy covering up to `max_measurements` +/// random measurement branches. +#[pyfunction] +pub fn path_enumeration(max_measurements: usize) -> PyPathEnumerationBuilder { + PyPathEnumerationBuilder { max_measurements } +} + +/// Sampling strategy selected on the Python builder. +#[derive(Clone)] +enum PySampling { + MonteCarlo { shots: usize, workers: usize }, + PathEnumeration { max_measurements: usize }, +} + /// Builder for sim_neo simulations. Mirrors the Rust-side `SimNeoBuilder`. #[pyclass( name = "SimNeoBuilder", @@ -505,8 +560,8 @@ pub struct PySimNeoBuilder { /// Original Rust TickCircuit for meas_sampling (avoids reconstruction). /// Wrapped in Arc for Clone compatibility with pyo3. tick_circuit: std::sync::Arc, - /// Sampling strategy as (shots, workers). None until `.sampling()`. - sampling: Option<(usize, usize)>, + /// Sampling strategy. None until `.sampling()`. + sampling: Option, /// Shot count from the deprecated top-level `.shots()` forwarder. legacy_shots: Option, /// Random seed. None = nondeterministic, mirroring the Rust builder. @@ -585,12 +640,30 @@ impl PySimNeoBuilder { /// Set the sampling strategy (shots and workers live on the sampler). /// + /// Accepts `monte_carlo(shots)` or `path_enumeration(max_measurements)`. + /// /// Example: /// sim_neo(tc).sampling(monte_carlo(1000).workers(4)).run() - fn sampling(&self, sampler: &PyMonteCarloBuilder) -> Self { + /// sim_neo(tc).sampling(path_enumeration(2)).run() + fn sampling(&self, sampler: &Bound<'_, PyAny>) -> PyResult { let mut c = self.clone(); - c.sampling = Some((sampler.shots, sampler.workers)); - c + if sampler.is_instance_of::() { + let s: PyRef<'_, PyMonteCarloBuilder> = sampler.extract()?; + c.sampling = Some(PySampling::MonteCarlo { + shots: s.shots, + workers: s.workers, + }); + } else if sampler.is_instance_of::() { + let s: PyRef<'_, PyPathEnumerationBuilder> = sampler.extract()?; + c.sampling = Some(PySampling::PathEnumeration { + max_measurements: s.max_measurements, + }); + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "sampling() expects monte_carlo(shots) or path_enumeration(max_measurements)", + )); + } + Ok(c) } /// Set number of shots. @@ -628,7 +701,11 @@ impl PySimNeoBuilder { if backend == "meas_sampling" { return self.run_meas_sampling(); } - let (shots, workers) = self.resolved_sampling()?; + + if let PySampling::PathEnumeration { max_measurements } = self.resolved_sampling()? { + return self.run_path_enumeration(&backend, max_measurements); + } + let (shots, workers) = self.resolved_monte_carlo("this backend")?; let noise = self .noise_config @@ -678,23 +755,89 @@ impl PySimNeoBuilder { } impl PySimNeoBuilder { + /// Path enumeration: exhaustively enumerate measurement branches. + /// + /// Pre-validates with ValueError mirroring the Rust builder's + /// build-time checks, then runs through the Rust sim_neo builder. + fn run_path_enumeration( + &self, + backend: &str, + max_measurements: usize, + ) -> PyResult { + if backend != "stabilizer" { + return Err(pyo3::exceptions::PyValueError::new_err( + "Path enumeration currently supports only the stabilizer() backend \ + (or .auto()).", + )); + } + if self.noise_config.is_some() { + return Err(pyo3::exceptions::PyValueError::new_err( + "Path enumeration enumerates measurement branches of the noiseless \ + circuit; remove .noise().", + )); + } + if max_measurements > 24 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Path enumeration covers 2^max_measurements paths; \ + max_measurements = {max_measurements} would enumerate more than 16M paths." + ))); + } + + let results = sim_neo(self.commands.clone()) + .quantum(pecos_neo::tool::sparse_stab()) + .sampling(pecos_neo::tool::path_enumeration(max_measurements)) + .build() + .run(); + + let rows: Vec> = results + .outcomes + .iter() + .map(|shot| shot.iter().map(|o| u8::from(o.outcome)).collect()) + .collect(); + let weights: Vec = results + .weights + .as_ref() + .map(|ws| { + ws.iter() + .map(pecos_neo::sampling::weight::SampleWeight::weight) + .collect() + }) + .unwrap_or_default(); + + Ok(PyRawMeasurementResult::from_rows_weighted(rows, weights)) + } + /// Resolve the sampling strategy, mirroring the Rust builder's rules /// and error messages. - fn resolved_sampling(&self) -> PyResult<(usize, usize)> { - match (self.sampling, self.legacy_shots) { - (Some(sampling), None) => Ok(sampling), + fn resolved_sampling(&self) -> PyResult { + match (&self.sampling, self.legacy_shots) { + (Some(sampling), None) => Ok(sampling.clone()), (Some(_), Some(_)) => Err(pyo3::exceptions::PyValueError::new_err( "Conflicting sampling configuration: deprecated .shots() cannot be combined \ with .sampling(). Set shots on the sampler builder, e.g. \ .sampling(monte_carlo(1000)).", )), - (None, Some(shots)) => Ok((shots, 1)), + (None, Some(shots)) => Ok(PySampling::MonteCarlo { shots, workers: 1 }), (None, None) => Err(pyo3::exceptions::PyValueError::new_err( "No sampling strategy set. Use .sampling(monte_carlo(shots)).", )), } } + /// Resolve to (shots, workers) for execution paths that only support + /// Monte Carlo sampling. + fn resolved_monte_carlo(&self, path_name: &str) -> PyResult<(usize, usize)> { + match self.resolved_sampling()? { + PySampling::MonteCarlo { shots, workers } => Ok((shots, workers)), + PySampling::PathEnumeration { .. } => { + Err(pyo3::exceptions::PyValueError::new_err(format!( + "{path_name} does not support path enumeration; use \ + .sampling(monte_carlo(shots)) instead." + ))) + } + } + } + /// Resolve the quantum backend, mirroring the Rust builder's rules: /// explicit `.quantum()` wins; `.auto()` opts into automatic selection; /// otherwise fail fast. Auto selects the stabilizer backend, except for @@ -755,7 +898,7 @@ impl PySimNeoBuilder { ))); } } - let (shots, workers) = self.resolved_sampling()?; + let (shots, workers) = self.resolved_monte_carlo("inline-channel execution")?; if workers > 1 { return Err(pyo3::exceptions::PyValueError::new_err( "inline-channel execution does not support parallel workers; use monte_carlo(shots) without .workers()", @@ -799,7 +942,7 @@ impl PySimNeoBuilder { /// DEM sampling backend: dispatches to stochastic or coherent path based on method. fn run_meas_sampling(&self) -> PyResult { - let (_, workers) = self.resolved_sampling()?; + let (_, workers) = self.resolved_monte_carlo("meas_sampling")?; if workers > 1 { return Err(pyo3::exceptions::PyValueError::new_err( "meas_sampling does its own batch sampling and does not support parallel workers; use monte_carlo(shots) without .workers()", @@ -864,7 +1007,7 @@ impl PySimNeoBuilder { .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; let plan = RawMeasurementPlan::new(&history, mechanisms); - let (shots, _) = self.resolved_sampling()?; + let (shots, _) = self.resolved_monte_carlo("meas_sampling")?; let result = plan.sample(shots, self.resolved_seed_u64()); Ok(PyRawMeasurementResult::from_columnar(result)) @@ -942,7 +1085,7 @@ impl PySimNeoBuilder { let gates = commands_to_gates(&self.commands); let generator = select_generator(method, noise_config.idle_rz_angle); - let (shots, _) = self.resolved_sampling()?; + let (shots, _) = self.resolved_monte_carlo("meas_sampling")?; let result = run_dem_simulation( &gates, &noise, diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py b/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py new file mode 100644 index 000000000..833374640 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py @@ -0,0 +1,91 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Path enumeration through the sim_neo Python bindings. + +Mirrors the Rust tool API: each distinct realized measurement-branch path is +one result row, with its exact probability in `result.weights`. +""" + +from __future__ import annotations + +import pytest + +pecos_rslib_exp = pytest.importorskip("pecos_rslib_exp") + +from pecos.quantum import TickCircuit # noqa: E402 +from pecos_rslib_exp import ( # noqa: E402 + depolarizing, + monte_carlo, + path_enumeration, + sim_neo, + stabilizer, +) + + +def bell_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().cx([(0, 1)]) + tc.tick().mz([0, 1]) + return tc + + +def deterministic_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().x([0]) + tc.tick().mz([0]) + return tc + + +def test_bell_pair_enumerates_two_correlated_paths() -> None: + result = sim_neo(bell_circuit()).quantum(stabilizer()).sampling(path_enumeration(1)).run() + + assert result.num_shots == 2 + weights = result.weights + assert weights is not None + assert abs(sum(weights) - 1.0) < 1e-12 + for row, weight in zip(result, weights, strict=True): + assert abs(weight - 0.5) < 1e-12 + assert row[0] == row[1], "Bell pair outcomes must be correlated" + + +def test_deterministic_circuit_dedupes_to_one_path() -> None: + result = sim_neo(deterministic_circuit()).auto().sampling(path_enumeration(2)).run() + + assert result.num_shots == 1 + assert result.weights == [1.0] + assert list(result[0]) == [1] + + +def test_monte_carlo_results_have_no_weights() -> None: + result = ( + sim_neo(deterministic_circuit()) + .quantum(stabilizer()) + .sampling(monte_carlo(3)) + .seed(1) + .run() + ) + assert result.weights is None + + +def test_path_enumeration_rejects_noise() -> None: + with pytest.raises(ValueError, match=r"remove \.noise\(\)"): + sim_neo(bell_circuit()).quantum(stabilizer()).noise(depolarizing().p1(0.01)).sampling( + path_enumeration(1), + ).run() + + +def test_path_enumeration_rejects_huge_enumeration() -> None: + with pytest.raises(ValueError, match="more than 16M paths"): + sim_neo(bell_circuit()).quantum(stabilizer()).sampling(path_enumeration(25)).run() From 2ecd00d1645064c1ce3d1130478fb6d7bb232e70 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 11:56:29 -0600 Subject: [PATCH 092/388] Add subset simulation to the sim_neo Python bindings with Python score/failure callables receiving measurement bits, the estimate exposed via result.subset, and callable exceptions propagated through the run --- python/pecos-rslib-exp/src/lib.rs | 3 + .../pecos-rslib-exp/src/sim_neo_bindings.rs | 318 +++++++++++++++++- .../qec/test_sim_neo_subset_simulation.py | 129 +++++++ 3 files changed, 446 insertions(+), 4 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs index 08432b11c..e3ea3b0e5 100644 --- a/python/pecos-rslib-exp/src/lib.rs +++ b/python/pecos-rslib-exp/src/lib.rs @@ -74,6 +74,9 @@ fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(sim_neo_bindings::monte_carlo, m)?)?; m.add_class::()?; m.add_function(wrap_pyfunction!(sim_neo_bindings::path_enumeration, m)?)?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::subset_simulation, m)?)?; m.add_function(wrap_pyfunction!(sim_neo_bindings::stab_mps, m)?)?; m.add_function(wrap_pyfunction!(sim_neo_bindings::depolarizing, m)?)?; m.add_class::()?; diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 0c06c1dff..9a05d97bb 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -64,6 +64,9 @@ pub struct PyRawMeasurementResult { /// importance weights for importance sampling). None for plain /// Monte Carlo runs. weights: Option>, + /// Rare-event estimate (subset simulation only; rows are empty for + /// subset runs). Mirrors Rust `SimulationResults::subset`. + subset: Option>, } enum RawMeasurementStorage { @@ -140,6 +143,7 @@ impl PyRawMeasurementResult { Self { storage: RawMeasurementStorage::Columnar(result), weights: None, + subset: None, } } @@ -152,6 +156,7 @@ impl PyRawMeasurementResult { num_measurements, }, weights: None, + subset: None, } } @@ -161,6 +166,13 @@ impl PyRawMeasurementResult { result.weights = Some(weights); result } + + /// Construct a subset-simulation result (no rows; estimate only). + pub fn from_subset(subset: Py) -> Self { + let mut result = Self::from_rows(Vec::new()); + result.subset = Some(subset); + result + } } #[pymethods] @@ -186,6 +198,12 @@ impl PyRawMeasurementResult { self.weights.clone() } + /// Rare-event estimate, or None unless run with subset simulation. + #[getter] + fn subset(&self, py: Python<'_>) -> Option> { + self.subset.as_ref().map(|s| s.clone_ref(py)) + } + /// Get a single measurement bit (0 or 1). fn get(&self, shot: isize, measurement: isize) -> PyResult { let s = Self::check_index(shot, self.storage.num_shots(), "shot")?; @@ -541,11 +559,168 @@ pub fn path_enumeration(max_measurements: usize) -> PyPathEnumerationBuilder { PyPathEnumerationBuilder { max_measurements } } +/// Subset simulation strategy builder. Mirrors Rust `subset_simulation(n)`. +/// +/// Estimates rare event probabilities by decomposing them into conditional +/// probabilities across adaptive levels. Requires a `.score(fn)` (how close +/// is this outcome to failure?) and a `.failure(fn)` predicate; both receive +/// the measurement bits of one sample as `list[int]` and are called once per +/// sample (Python-callable cost applies). +/// +/// Example: +/// result = (sim_neo(tc) +/// .quantum(stabilizer()) +/// .sampling(subset_simulation(1000) +/// .score(lambda bits: float(sum(bits))) +/// .failure(lambda bits: all(bits))) +/// .seed(42) +/// .run()) +/// print(result.subset.probability) +#[pyclass( + name = "SubsetSimulationBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +pub struct PySubsetSimulationBuilder { + samples_per_level: usize, + threshold_fraction: f64, + max_levels: usize, + min_conditional_prob: f64, + score: Option>, + failure: Option>, +} + +impl Clone for PySubsetSimulationBuilder { + fn clone(&self) -> Self { + Python::attach(|py| Self { + samples_per_level: self.samples_per_level, + threshold_fraction: self.threshold_fraction, + max_levels: self.max_levels, + min_conditional_prob: self.min_conditional_prob, + score: self.score.as_ref().map(|f| f.clone_ref(py)), + failure: self.failure.as_ref().map(|f| f.clone_ref(py)), + }) + } +} + +#[pymethods] +impl PySubsetSimulationBuilder { + /// Set the score function: bits (`list[int]`) -> float. + /// + /// Higher scores advance to the next level; failing outcomes should + /// score at least as high as any non-failing outcome. + fn score(&self, f: Py) -> Self { + let mut c = self.clone(); + c.score = Some(f); + c + } + + /// Set the failure predicate: bits (`list[int]`) -> bool. + fn failure(&self, f: Py) -> Self { + let mut c = self.clone(); + c.failure = Some(f); + c + } + + /// Fraction of samples that advances past each threshold (default 0.1). + fn threshold_fraction(&self, fraction: f64) -> Self { + let mut c = self.clone(); + c.threshold_fraction = fraction; + c + } + + /// Maximum number of levels before giving up (default 20). + fn max_levels(&self, levels: usize) -> Self { + let mut c = self.clone(); + c.max_levels = levels; + c + } + + /// Minimum conditional probability before declaring the failure event + /// unreachable (default 1e-6). + fn min_conditional_prob(&self, p: f64) -> Self { + let mut c = self.clone(); + c.min_conditional_prob = p; + c + } +} + +/// Create a subset simulation strategy running `samples_per_level` samples +/// at each level. `.score(..)` and `.failure(..)` are required. +#[pyfunction] +pub fn subset_simulation(samples_per_level: usize) -> PySubsetSimulationBuilder { + let defaults = pecos_neo::sampling::subset::SubsetConfig::default(); + PySubsetSimulationBuilder { + samples_per_level, + threshold_fraction: defaults.threshold_fraction, + max_levels: defaults.max_levels, + min_conditional_prob: defaults.min_conditional_prob, + score: None, + failure: None, + } +} + +/// Rare-event estimate from subset simulation. Mirrors Rust `SubsetResult`. +#[pyclass(name = "SubsetResult", module = "pecos_rslib_exp")] +pub struct PySubsetResult { + inner: pecos_neo::sampling::subset::SubsetResult, +} + +#[pymethods] +impl PySubsetResult { + /// Overall probability estimate. + #[getter] + fn probability(&self) -> f64 { + self.inner.probability() + } + + /// Coefficient of variation (standard error / estimate). + #[getter] + fn coefficient_of_variation(&self) -> f64 { + self.inner.coefficient_of_variation + } + + /// Total number of samples run across all levels. + #[getter] + fn total_samples(&self) -> usize { + self.inner.total_samples + } + + /// Number of failures observed directly. + #[getter] + fn direct_failures(&self) -> usize { + self.inner.direct_failures + } + + /// 95% confidence interval (assuming log-normal): (lower, upper). + fn confidence_interval_95(&self) -> (f64, f64) { + self.inner.confidence_interval_95() + } + + /// Per-level statistics as a list of dicts. + fn levels<'py>(&self, py: Python<'py>) -> PyResult> { + use pyo3::types::{PyDict, PyList}; + let list = PyList::empty(py); + for level in &self.inner.levels { + let d = PyDict::new(py); + d.set_item("level", level.level)?; + d.set_item("threshold", level.threshold)?; + d.set_item("num_samples", level.num_samples)?; + d.set_item("num_exceeded", level.num_exceeded)?; + d.set_item("conditional_prob", level.conditional_prob)?; + d.set_item("num_failures", level.num_failures)?; + list.append(d)?; + } + Ok(list) + } +} + /// Sampling strategy selected on the Python builder. #[derive(Clone)] enum PySampling { MonteCarlo { shots: usize, workers: usize }, PathEnumeration { max_measurements: usize }, + SubsetSimulation { config: PySubsetSimulationBuilder }, } /// Builder for sim_neo simulations. Mirrors the Rust-side `SimNeoBuilder`. @@ -658,9 +833,13 @@ impl PySimNeoBuilder { c.sampling = Some(PySampling::PathEnumeration { max_measurements: s.max_measurements, }); + } else if sampler.is_instance_of::() { + let s: PyRef<'_, PySubsetSimulationBuilder> = sampler.extract()?; + c.sampling = Some(PySampling::SubsetSimulation { config: s.clone() }); } else { return Err(pyo3::exceptions::PyTypeError::new_err( - "sampling() expects monte_carlo(shots) or path_enumeration(max_measurements)", + "sampling() expects monte_carlo(shots), path_enumeration(max_measurements), \ + or subset_simulation(samples_per_level)", )); } Ok(c) @@ -692,7 +871,7 @@ impl PySimNeoBuilder { /// /// All backends return `RawMeasurementResult` which supports: /// `result[shot]`, `result.get(shot, meas)`, `len(result)`, iteration. - fn run(&self) -> PyResult { + fn run(&self, py: Python<'_>) -> PyResult { if self.tick_circuit.has_channel_operations() { return self.run_inline_channel_circuit(); } @@ -702,8 +881,14 @@ impl PySimNeoBuilder { return self.run_meas_sampling(); } - if let PySampling::PathEnumeration { max_measurements } = self.resolved_sampling()? { - return self.run_path_enumeration(&backend, max_measurements); + match self.resolved_sampling()? { + PySampling::PathEnumeration { max_measurements } => { + return self.run_path_enumeration(&backend, max_measurements); + } + PySampling::SubsetSimulation { config } => { + return self.run_subset_simulation(py, &backend, &config); + } + PySampling::MonteCarlo { .. } => {} } let (shots, workers) = self.resolved_monte_carlo("this backend")?; @@ -807,6 +992,125 @@ impl PySimNeoBuilder { Ok(PyRawMeasurementResult::from_rows_weighted(rows, weights)) } + /// Subset simulation with Python score/failure callables. + /// + /// Each callable receives the sample's measurement bits as `list[int]` + /// and is invoked once per sample on the calling thread. The first + /// Python exception raised by a callable aborts the run and propagates. + fn run_subset_simulation( + &self, + py: Python<'_>, + backend: &str, + config: &PySubsetSimulationBuilder, + ) -> PyResult { + use pecos_neo::outcome::MeasurementOutcomes; + use pecos_neo::sampling::subset::{SubsetConfig, SubsetSimulation}; + use std::sync::{Arc, Mutex}; + + if backend != "stabilizer" { + return Err(pyo3::exceptions::PyValueError::new_err( + "Subset simulation currently supports only the stabilizer() backend \ + (or .auto()).", + )); + } + let (Some(score), Some(failure)) = (&config.score, &config.failure) else { + return Err(pyo3::exceptions::PyValueError::new_err( + "Subset simulation requires both .score(..) and .failure(..) on the \ + subset_simulation(..) builder; neither has a sensible default.", + )); + }; + + let noise = self + .noise_config + .as_ref() + .and_then(PyNoiseModelBuilder::build_noise); + + let num_qubits = self + .commands + .iter() + .flat_map(|cmd| cmd.qubits.iter()) + .map(|q| q.0) + .max() + .map_or(1, |max| max + 1); + + // Bridge Python callables into Fn closures. Errors cannot propagate + // through the closure signature, so capture the first one and check + // after the run. + let captured_err: Arc>> = Arc::new(Mutex::new(None)); + let bits_of = |outcomes: &MeasurementOutcomes| -> Vec { + outcomes.iter().map(|o| u8::from(o.outcome)).collect() + }; + + let score_fn = score.clone_ref(py); + let score_err = Arc::clone(&captured_err); + let score_closure = move |outcomes: &MeasurementOutcomes| -> f64 { + Python::attach(|py| { + match score_fn + .call1(py, (bits_of(outcomes),)) + .and_then(|v| v.extract::(py)) + { + Ok(v) => v, + Err(e) => { + score_err + .lock() + .expect("subset callable error slot poisoned") + .get_or_insert(e); + 0.0 + } + } + }) + }; + + let failure_fn = failure.clone_ref(py); + let failure_err = Arc::clone(&captured_err); + let failure_closure = move |outcomes: &MeasurementOutcomes| -> bool { + Python::attach(|py| { + match failure_fn + .call1(py, (bits_of(outcomes),)) + .and_then(|v| v.extract::(py)) + { + Ok(v) => v, + Err(e) => { + failure_err + .lock() + .expect("subset callable error slot poisoned") + .get_or_insert(e); + false + } + } + }) + }; + + let subset_config = SubsetConfig { + samples_per_level: config.samples_per_level, + threshold_fraction: config.threshold_fraction, + max_levels: config.max_levels, + min_conditional_prob: config.min_conditional_prob, + seed: self.seed, + }; + + let result = SubsetSimulation::new( + self.commands.clone(), + num_qubits, + score_closure, + failure_closure, + ) + .with_noise_builder(move || noise.clone()) + .with_config(subset_config) + .run(); + + if let Some(err) = captured_err + .lock() + .expect("subset callable error slot poisoned") + .take() + { + return Err(err); + } + + let subset = Py::new(py, PySubsetResult { inner: result })?; + Ok(PyRawMeasurementResult::from_subset(subset)) + } + /// Resolve the sampling strategy, mirroring the Rust builder's rules /// and error messages. fn resolved_sampling(&self) -> PyResult { @@ -835,6 +1139,12 @@ impl PySimNeoBuilder { .sampling(monte_carlo(shots)) instead." ))) } + PySampling::SubsetSimulation { .. } => { + Err(pyo3::exceptions::PyValueError::new_err(format!( + "{path_name} does not support subset simulation; use \ + .sampling(monte_carlo(shots)) instead." + ))) + } } } diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py b/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py new file mode 100644 index 000000000..329d5186c --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py @@ -0,0 +1,129 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use +# this file except in compliance with the License. You may obtain a copy of the +# License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed +# under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +# CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Subset simulation through the sim_neo Python bindings. + +Mirrors the Rust tool API: score/failure are Python callables receiving the +sample's measurement bits as list[int]; the estimate arrives in +result.subset (rows are empty for subset runs). +""" + +from __future__ import annotations + +import pytest + +pecos_rslib_exp = pytest.importorskip("pecos_rslib_exp") + +from pecos.quantum import TickCircuit # noqa: E402 +from pecos_rslib_exp import ( # noqa: E402 + sim_neo, + stabilizer, + statevec, + subset_simulation, +) + + +def three_h_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().h([0, 1, 2]) + tc.tick().mz([0, 1, 2]) + return tc + + +def x_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().x([0]) + tc.tick().mz([0]) + return tc + + +def test_subset_estimates_known_probability() -> None: + # P(all three H measurements give 1) = 1/8. + result = ( + sim_neo(three_h_circuit()) + .quantum(stabilizer()) + .sampling( + subset_simulation(2000) + .score(lambda bits: float(sum(bits))) + .failure(lambda bits: all(b == 1 for b in bits)), + ) + .seed(42) + .run() + ) + + assert result.num_shots == 0, "Subset runs produce an estimate, not rows" + subset = result.subset + assert subset is not None + assert 0.08 <= subset.probability <= 0.20 + assert subset.total_samples >= 2000 + lower, upper = subset.confidence_interval_95() + assert lower <= subset.probability <= upper + assert len(subset.levels()) >= 1 + + +def test_subset_certain_event() -> None: + result = ( + sim_neo(x_circuit()) + .auto() + .sampling( + subset_simulation(200) + .score(lambda bits: float(sum(bits))) + .failure(lambda bits: bits[0] == 1), + ) + .seed(7) + .run() + ) + assert abs(result.subset.probability - 1.0) < 1e-9 + + +def test_subset_deterministic_with_seed() -> None: + def run() -> float: + return ( + sim_neo(three_h_circuit()) + .auto() + .sampling( + subset_simulation(500) + .score(lambda bits: float(sum(bits))) + .failure(lambda bits: all(b == 1 for b in bits)), + ) + .seed(99) + .run() + .subset.probability + ) + + assert run() == run() + + +def test_subset_requires_score_and_failure() -> None: + with pytest.raises(ValueError, match=r"requires both \.score\(\.\.\) and \.failure\(\.\.\)"): + sim_neo(three_h_circuit()).auto().sampling(subset_simulation(100)).run() + + +def test_subset_rejects_statevec_backend() -> None: + with pytest.raises(ValueError, match="only the stabilizer"): + sim_neo(three_h_circuit()).quantum(statevec()).sampling( + subset_simulation(100) + .score(lambda bits: float(sum(bits))) + .failure(lambda bits: all(b == 1 for b in bits)), + ).run() + + +def test_subset_callable_exception_propagates() -> None: + def bad_score(_bits: list[int]) -> float: + msg = "score exploded" + raise RuntimeError(msg) + + with pytest.raises(RuntimeError, match="score exploded"): + sim_neo(three_h_circuit()).auto().sampling( + subset_simulation(50).score(bad_score).failure(lambda _bits: False), + ).run() From e23fbb2ccef3a1f1de7521cc4f34543ef40dbc75 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 12:25:13 -0600 Subject: [PATCH 093/388] Extract result types (Shot, ShotVec, ShotMap, Data, DataVec) from pecos-engines into a neutral pecos-results crate, leaving shot_results as a re-export shim with the ByteMessage conveniences as free functions --- Cargo.lock | 12 + Cargo.toml | 1 + crates/pecos-engines/Cargo.toml | 1 + crates/pecos-engines/src/shot_results.rs | 236 ++++-------------- crates/pecos-results/Cargo.toml | 22 ++ crates/pecos-results/README.md | 13 + .../src}/conversions.rs | 0 .../src}/data.rs | 0 .../src}/data_vec.rs | 4 +- crates/pecos-results/src/lib.rs | 202 +++++++++++++++ .../src}/shot.rs | 46 ---- .../src}/shot_map.rs | 26 +- .../src}/shot_map_formatter.rs | 2 +- .../src}/shot_tests.rs | 2 +- .../src}/shot_vec.rs | 41 +-- 15 files changed, 322 insertions(+), 286 deletions(-) create mode 100644 crates/pecos-results/Cargo.toml create mode 100644 crates/pecos-results/README.md rename crates/{pecos-engines/src/shot_results => pecos-results/src}/conversions.rs (100%) rename crates/{pecos-engines/src/shot_results => pecos-results/src}/data.rs (100%) rename crates/{pecos-engines/src/shot_results => pecos-results/src}/data_vec.rs (99%) create mode 100644 crates/pecos-results/src/lib.rs rename crates/{pecos-engines/src/shot_results => pecos-results/src}/shot.rs (75%) rename crates/{pecos-engines/src/shot_results => pecos-results/src}/shot_map.rs (97%) rename crates/{pecos-engines/src/shot_results => pecos-results/src}/shot_map_formatter.rs (99%) rename crates/{pecos-engines/src/shot_results => pecos-results/src}/shot_tests.rs (99%) rename crates/{pecos-engines/src/shot_results => pecos-results/src}/shot_vec.rs (86%) diff --git a/Cargo.lock b/Cargo.lock index 99311349c..3d0ab3ac4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3850,6 +3850,7 @@ dependencies = [ "num-bigint", "pecos-core", "pecos-random", + "pecos-results", "pecos-simulators", "rand 0.10.1", "rayon", @@ -4247,6 +4248,17 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "pecos-results" +version = "0.2.0-dev.0" +dependencies = [ + "bitvec", + "num-bigint", + "pecos-core", + "serde", + "serde_json", +] + [[package]] name = "pecos-rslib" version = "0.2.0-dev.0" diff --git a/Cargo.toml b/Cargo.toml index 40c106469..649f9d40b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -210,6 +210,7 @@ pecos-qis-ffi-types = { version = "0.2.0-dev.0", path = "crates/pecos-qis-ffi-ty pecos-quantum = { version = "0.2.0-dev.0", path = "crates/pecos-quantum" } pecos-random = { version = "0.2.0-dev.0", path = "crates/pecos-random" } pecos-relay-bp = { version = "0.2.0-dev.0", path = "crates/pecos-relay-bp" } +pecos-results = { version = "0.2.0-dev.0", path = "crates/pecos-results" } pecos-rslib = { version = "0.2.0-dev.0", path = "python/pecos-rslib" } pecos-rslib-llvm = { version = "0.2.0-dev.0", path = "python/pecos-rslib-llvm" } pecos-simulators = { version = "0.2.0-dev.0", path = "crates/pecos-simulators" } diff --git a/crates/pecos-engines/Cargo.toml b/crates/pecos-engines/Cargo.toml index 2793a7d11..0f217c7b3 100644 --- a/crates/pecos-engines/Cargo.toml +++ b/crates/pecos-engines/Cargo.toml @@ -27,6 +27,7 @@ num-bigint.workspace = true bitvec.workspace = true pecos-core.workspace = true +pecos-results.workspace = true pecos-simulators.workspace = true pecos-random.workspace = true diff --git a/crates/pecos-engines/src/shot_results.rs b/crates/pecos-engines/src/shot_results.rs index 1cd7951e1..12455d9b8 100644 --- a/crates/pecos-engines/src/shot_results.rs +++ b/crates/pecos-engines/src/shot_results.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at @@ -12,191 +12,61 @@ //! Shot results and data structures for quantum program execution. //! -//! This module provides comprehensive data structures for storing and manipulating -//! the results of quantum program executions. It includes: -//! -//! - **Data Types**: The `Data` enum for flexible value storage -//! - **Single Results**: The `Shot` struct for individual execution results -//! - **Collections**: The `ShotVec` struct for multiple executions -//! - **Columnar Analysis**: The `ShotMap` struct for efficient analysis -//! - **Formatting**: Display and export utilities -//! -//! # Design Philosophy -//! -//! The module is designed around the following principles: -//! - **Flexibility**: Support for diverse data types and quantum backends -//! - **Efficiency**: Optimized for common operations like analysis and export -//! - **Compatibility**: Easy conversion between row-based and columnar formats -//! - **Extensibility**: JSON support for custom and complex data -//! -//! # Main Types -//! -//! ## `Data` - Flexible Value Storage -//! ``` -//! use pecos_engines::shot_results::Data; -//! use bitvec::prelude::*; -//! -//! // Support for various numeric types -//! let measurement = Data::U32(42); -//! let phase = Data::F64(3.14159); -//! -//! // BitVec for quantum register results -//! let mut bits = BitVec::::new(); -//! bits.push(true); -//! bits.push(false); -//! let register = Data::BitVec(bits); -//! ``` -//! -//! ## `Shot` - Single Execution Results -//! ``` -//! use pecos_engines::shot_results::{Shot, Data}; -//! -//! let mut shot = Shot::default(); -//! shot.add_register("qubits", 5, 3); // 3-bit register with value 5 -//! shot.data.insert("error_rate".to_string(), Data::F64(0.001)); -//! ``` -//! -//! ## `ShotVec` - Multiple Executions -//! ``` -//! use pecos_engines::shot_results::{ShotVec, Shot}; -//! -//! let mut results = ShotVec::new(); -//! for i in 0..100 { -//! let mut shot = Shot::default(); -//! shot.add_register("measurement", i % 8, 3); -//! results.shots.push(shot); -//! } -//! -//! // Convert to JSON for export -//! let json = results.to_compact_json(); -//! ``` -//! -//! ## `ShotMap` - Columnar Analysis -//! ``` -//! # use pecos_engines::shot_results::{ShotVec, Shot}; -//! # let mut results = ShotVec::new(); -//! # for i in 0..100 { -//! # let mut shot = Shot::default(); -//! # shot.add_register("measurement", i % 8, 3); -//! # results.shots.push(shot); -//! # } -//! // Convert to columnar format for analysis -//! let shot_map = results.try_as_shot_map().unwrap(); -//! -//! // Efficient analysis of specific registers -//! let measurements = shot_map.try_bits_as_u64("measurement").unwrap(); -//! let average: f64 = measurements.iter().sum::() as f64 / measurements.len() as f64; -//! ``` - -#![allow(clippy::similar_names)] -// For percentage calculations below with large usize values converted to f64, -// we accept the potential precision loss since the values are used only for display -// with a single decimal place, and the precision loss would only be observable -// with extremely large shot counts (> 2^53). -#![allow(clippy::cast_precision_loss)] - -// Sub-modules -pub mod conversions; -pub mod data; -pub mod data_vec; -pub mod shot; -pub mod shot_map; -pub mod shot_map_formatter; -#[cfg(test)] -mod shot_tests; -pub mod shot_vec; - -// Re-export all public types for backward compatibility -pub use data::Data; -pub use data_vec::DataVec; -pub use shot::Shot; -pub use shot_map::ShotMap; -pub use shot_map_formatter::{ - BitVecDisplayFormat, ShotMapDisplay, ShotMapDisplayExt, ShotMapDisplayOptions, -}; -pub use shot_vec::ShotVec; - -// Re-export for tests and benchmarks that may reference the full module path -#[cfg(test)] -#[allow(clippy::similar_names)] -mod tests { - use super::*; - - #[test] - fn test_shot_results_display_64bit() { - // Create a shot with various data types - let mut shot1 = Shot::default(); - shot1.data.insert("reg_32".to_string(), Data::U32(42)); - - // Add a large 64-bit register (larger than u32::MAX) - let large_value = 1u64 << 34; // 2^34 = 17,179,869,184 (>4B) - shot1 - .data - .insert("reg_64".to_string(), Data::U64(large_value)); +//! The result types live in the neutral [`pecos_results`] crate so that any +//! simulation stack can produce them; this module re-exports them under the +//! historical `pecos_engines::shot_results` paths and adds the +//! `ByteMessage`-protocol conveniences that belong to this crate. + +pub use pecos_results::*; + +use crate::byte_message::ByteMessage; +use pecos_core::errors::PecosError; +use std::collections::BTreeMap; + +/// Create a [`Shot`] directly from a [`ByteMessage`] containing measurement +/// results, mapping result IDs to names via `result_id_to_name` (missing IDs +/// fall back to `result_{id}`). +/// +/// # Errors +/// +/// Returns an error if the `ByteMessage` cannot be parsed or doesn't contain +/// valid measurement results. +pub fn shot_from_byte_message( + message: &ByteMessage, + result_id_to_name: &BTreeMap, +) -> Result { + let outcomes = message.outcomes()?; + + let mut result = Shot::default(); + for (result_id, value) in outcomes.into_iter().enumerate() { + let name = result_id_to_name + .get(&result_id) + .cloned() + .unwrap_or_else(|| format!("result_{result_id}")); + result.data.insert(name, Data::U32(value)); + } - // Add a signed 64-bit register with negative value - shot1.data.insert("reg_signed".to_string(), Data::I64(-42)); + Ok(result) +} - // Add some floating point data - shot1 +/// Create a single-shot [`ShotVec`] directly from a [`ByteMessage`] +/// containing measurement results, naming each outcome `result_{id}`. +/// +/// # Errors +/// +/// Returns a `PecosError` if the measurements cannot be extracted from the +/// `ByteMessage`. +pub fn shot_vec_from_byte_message(message: &ByteMessage) -> Result { + let outcomes = message.outcomes()?; + + let mut shot_result = Shot::default(); + for (result_id, value) in outcomes.into_iter().enumerate() { + shot_result .data - .insert("float_val".to_string(), Data::F64(std::f64::consts::PI)); - - // Create ShotVec with one shot - let shot_results = ShotVec { shots: vec![shot1] }; - - // Convert to string - let json_string = shot_results.to_compact_json(); - let display_string = format!("{shot_results}"); - - // The display string should match the compact JSON string - assert_eq!(display_string, json_string); - - // Verify that both are valid JSON and contain the same data - let json_value1: serde_json::Value = serde_json::from_str(&display_string).unwrap(); - let json_value2: serde_json::Value = serde_json::from_str(&json_string).unwrap(); - - // Verify that both are arrays with the same length - assert_eq!( - json_value1.as_array().unwrap().len(), - json_value2.as_array().unwrap().len(), - "JSON arrays should have the same number of shots" - ); - - // Verify that all registers appear in the JSON - assert!(json_string.contains("\"reg_32\"")); - assert!(json_string.contains("42")); - assert!(json_string.contains("\"reg_64\"")); - assert!(json_string.contains("17179869184")); - assert!(json_string.contains("\"reg_signed\"")); - assert!(json_string.contains("-42")); - assert!(json_string.contains("\"float_val\"")); - assert!(json_string.contains("3.14159")); + .insert(format!("result_{result_id}"), Data::U32(value)); } - #[test] - fn test_module_integration() { - // Test that all modules work together correctly - let mut shot_vec = ShotVec::new(); - - for i in 0..5 { - let mut shot = Shot::default(); - shot.add_register("qubits", i, 3); - shot.data - .insert("phase".to_string(), Data::F64(f64::from(i) * 0.1)); - shot_vec.shots.push(shot); - } - - // Convert to ShotMap - let shot_map = shot_vec.try_as_shot_map().unwrap(); - - // Test data access - assert_eq!(shot_map.num_shots(), 5); - assert_eq!(shot_map.num_registers(), 2); // qubits + phase (width metadata filtered out) - - // Test formatting - let display_output = format!("{}", shot_map.display()); - assert!(display_output.contains("\"qubits\"")); - assert!(display_output.contains("\"phase\"")); - } + Ok(ShotVec { + shots: vec![shot_result], + }) } diff --git a/crates/pecos-results/Cargo.toml b/crates/pecos-results/Cargo.toml new file mode 100644 index 000000000..ce8cb266c --- /dev/null +++ b/crates/pecos-results/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "pecos-results" +version.workspace = true +edition.workspace = true +readme = "README.md" +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Result types (Shot, ShotVec, ShotMap, Data) for PECOS quantum program execution" + +[dependencies] +pecos-core.workspace = true +serde.workspace = true +serde_json.workspace = true +num-bigint.workspace = true +bitvec.workspace = true + +[lints] +workspace = true diff --git a/crates/pecos-results/README.md b/crates/pecos-results/README.md new file mode 100644 index 000000000..e6f8972ca --- /dev/null +++ b/crates/pecos-results/README.md @@ -0,0 +1,13 @@ +# pecos-results + +Result types for PECOS quantum program execution: `Shot`, `ShotVec`, +`ShotMap`, `Data`, and `DataVec`. + +These types are the shared result contract between PECOS simulation stacks +(`pecos-engines`, `pecos-neo`) and their language bindings. They carry named +registers with flexible values (integers, floats, bit vectors, JSON) in +row-based (`ShotVec`) or columnar (`ShotMap`) form, with conversions between +the two and display/export utilities. + +This crate is deliberately free of any execution-protocol or simulator +dependencies so that any producer can emit results in this format. diff --git a/crates/pecos-engines/src/shot_results/conversions.rs b/crates/pecos-results/src/conversions.rs similarity index 100% rename from crates/pecos-engines/src/shot_results/conversions.rs rename to crates/pecos-results/src/conversions.rs diff --git a/crates/pecos-engines/src/shot_results/data.rs b/crates/pecos-results/src/data.rs similarity index 100% rename from crates/pecos-engines/src/shot_results/data.rs rename to crates/pecos-results/src/data.rs diff --git a/crates/pecos-engines/src/shot_results/data_vec.rs b/crates/pecos-results/src/data_vec.rs similarity index 99% rename from crates/pecos-engines/src/shot_results/data_vec.rs rename to crates/pecos-results/src/data_vec.rs index ece957d8e..d6989b800 100644 --- a/crates/pecos-engines/src/shot_results/data_vec.rs +++ b/crates/pecos-results/src/data_vec.rs @@ -26,7 +26,7 @@ use serde_json::Value as JsonValue; /// /// # Example /// ``` -/// use pecos_engines::{DataVec, Data}; +/// use pecos_results::{Data, DataVec}; /// /// // Create a DataVec from a vector of Data values /// let data_values = vec![Data::U32(1), Data::U32(2), Data::U32(3)]; @@ -292,7 +292,7 @@ impl DataVec { /// /// # Example /// ``` - /// use pecos_engines::{DataVec, DataVecType}; + /// use pecos_results::{DataVec, DataVecType}; /// /// let vec = DataVec::new_empty(DataVecType::U32); /// assert!(vec.is_empty()); diff --git a/crates/pecos-results/src/lib.rs b/crates/pecos-results/src/lib.rs new file mode 100644 index 000000000..e568a5655 --- /dev/null +++ b/crates/pecos-results/src/lib.rs @@ -0,0 +1,202 @@ +// Copyright 2025 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License.You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Shot results and data structures for quantum program execution. +//! +//! This module provides comprehensive data structures for storing and manipulating +//! the results of quantum program executions. It includes: +//! +//! - **Data Types**: The `Data` enum for flexible value storage +//! - **Single Results**: The `Shot` struct for individual execution results +//! - **Collections**: The `ShotVec` struct for multiple executions +//! - **Columnar Analysis**: The `ShotMap` struct for efficient analysis +//! - **Formatting**: Display and export utilities +//! +//! # Design Philosophy +//! +//! The module is designed around the following principles: +//! - **Flexibility**: Support for diverse data types and quantum backends +//! - **Efficiency**: Optimized for common operations like analysis and export +//! - **Compatibility**: Easy conversion between row-based and columnar formats +//! - **Extensibility**: JSON support for custom and complex data +//! +//! # Main Types +//! +//! ## `Data` - Flexible Value Storage +//! ``` +//! use pecos_results::Data; +//! use bitvec::prelude::*; +//! +//! // Support for various numeric types +//! let measurement = Data::U32(42); +//! let phase = Data::F64(3.14159); +//! +//! // BitVec for quantum register results +//! let mut bits = BitVec::::new(); +//! bits.push(true); +//! bits.push(false); +//! let register = Data::BitVec(bits); +//! ``` +//! +//! ## `Shot` - Single Execution Results +//! ``` +//! use pecos_results::{Shot, Data}; +//! +//! let mut shot = Shot::default(); +//! shot.add_register("qubits", 5, 3); // 3-bit register with value 5 +//! shot.data.insert("error_rate".to_string(), Data::F64(0.001)); +//! ``` +//! +//! ## `ShotVec` - Multiple Executions +//! ``` +//! use pecos_results::{ShotVec, Shot}; +//! +//! let mut results = ShotVec::new(); +//! for i in 0..100 { +//! let mut shot = Shot::default(); +//! shot.add_register("measurement", i % 8, 3); +//! results.shots.push(shot); +//! } +//! +//! // Convert to JSON for export +//! let json = results.to_compact_json(); +//! ``` +//! +//! ## `ShotMap` - Columnar Analysis +//! ``` +//! # use pecos_results::{ShotVec, Shot}; +//! # let mut results = ShotVec::new(); +//! # for i in 0..100 { +//! # let mut shot = Shot::default(); +//! # shot.add_register("measurement", i % 8, 3); +//! # results.shots.push(shot); +//! # } +//! // Convert to columnar format for analysis +//! let shot_map = results.try_as_shot_map().unwrap(); +//! +//! // Efficient analysis of specific registers +//! let measurements = shot_map.try_bits_as_u64("measurement").unwrap(); +//! let average: f64 = measurements.iter().sum::() as f64 / measurements.len() as f64; +//! ``` + +#![allow(clippy::similar_names)] +// For percentage calculations below with large usize values converted to f64, +// we accept the potential precision loss since the values are used only for display +// with a single decimal place, and the precision loss would only be observable +// with extremely large shot counts (> 2^53). +#![allow(clippy::cast_precision_loss)] + +// Sub-modules +pub mod conversions; +pub mod data; +pub mod data_vec; +pub mod shot; +pub mod shot_map; +pub mod shot_map_formatter; +#[cfg(test)] +mod shot_tests; +pub mod shot_vec; + +// Re-export all public types for backward compatibility +pub use data::Data; +pub use data_vec::{DataVec, DataVecType}; +pub use shot::Shot; +pub use shot_map::ShotMap; +pub use shot_map_formatter::{ + BitVecDisplayFormat, ShotMapDisplay, ShotMapDisplayExt, ShotMapDisplayOptions, +}; +pub use shot_vec::ShotVec; + +// Re-export for tests and benchmarks that may reference the full module path +#[cfg(test)] +#[allow(clippy::similar_names)] +mod tests { + use super::*; + + #[test] + fn test_shot_results_display_64bit() { + // Create a shot with various data types + let mut shot1 = Shot::default(); + shot1.data.insert("reg_32".to_string(), Data::U32(42)); + + // Add a large 64-bit register (larger than u32::MAX) + let large_value = 1u64 << 34; // 2^34 = 17,179,869,184 (>4B) + shot1 + .data + .insert("reg_64".to_string(), Data::U64(large_value)); + + // Add a signed 64-bit register with negative value + shot1.data.insert("reg_signed".to_string(), Data::I64(-42)); + + // Add some floating point data + shot1 + .data + .insert("float_val".to_string(), Data::F64(std::f64::consts::PI)); + + // Create ShotVec with one shot + let shot_results = ShotVec { shots: vec![shot1] }; + + // Convert to string + let json_string = shot_results.to_compact_json(); + let display_string = format!("{shot_results}"); + + // The display string should match the compact JSON string + assert_eq!(display_string, json_string); + + // Verify that both are valid JSON and contain the same data + let json_value1: serde_json::Value = serde_json::from_str(&display_string).unwrap(); + let json_value2: serde_json::Value = serde_json::from_str(&json_string).unwrap(); + + // Verify that both are arrays with the same length + assert_eq!( + json_value1.as_array().unwrap().len(), + json_value2.as_array().unwrap().len(), + "JSON arrays should have the same number of shots" + ); + + // Verify that all registers appear in the JSON + assert!(json_string.contains("\"reg_32\"")); + assert!(json_string.contains("42")); + assert!(json_string.contains("\"reg_64\"")); + assert!(json_string.contains("17179869184")); + assert!(json_string.contains("\"reg_signed\"")); + assert!(json_string.contains("-42")); + assert!(json_string.contains("\"float_val\"")); + assert!(json_string.contains("3.14159")); + } + + #[test] + fn test_module_integration() { + // Test that all modules work together correctly + let mut shot_vec = ShotVec::new(); + + for i in 0..5 { + let mut shot = Shot::default(); + shot.add_register("qubits", i, 3); + shot.data + .insert("phase".to_string(), Data::F64(f64::from(i) * 0.1)); + shot_vec.shots.push(shot); + } + + // Convert to ShotMap + let shot_map = shot_vec.try_as_shot_map().unwrap(); + + // Test data access + assert_eq!(shot_map.num_shots(), 5); + assert_eq!(shot_map.num_registers(), 2); // qubits + phase (width metadata filtered out) + + // Test formatting + let display_output = format!("{}", shot_map.display()); + assert!(display_output.contains("\"qubits\"")); + assert!(display_output.contains("\"phase\"")); + } +} diff --git a/crates/pecos-engines/src/shot_results/shot.rs b/crates/pecos-results/src/shot.rs similarity index 75% rename from crates/pecos-engines/src/shot_results/shot.rs rename to crates/pecos-results/src/shot.rs index 8e1909293..83595a23b 100644 --- a/crates/pecos-engines/src/shot_results/shot.rs +++ b/crates/pecos-results/src/shot.rs @@ -11,9 +11,7 @@ // the License. use super::data::Data; -use crate::byte_message::ByteMessage; use bitvec::prelude::*; -use pecos_core::errors::PecosError; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -95,50 +93,6 @@ impl Shot { } } - /// Create a `Shot` directly from a `ByteMessage` containing measurement results. - /// - /// This method extracts measurement results from a `ByteMessage` and creates a `Shot` - /// with properly mapped result IDs to names. - /// - /// # Parameters - /// - /// * `message` - A `ByteMessage` containing measurement results - /// * `result_id_to_name` - A mapping from `result_id` to a human-readable name - /// - /// # Returns - /// - /// A new `Shot` instance containing the processed measurement results - /// - /// # Errors - /// - /// Returns an error if the `ByteMessage` cannot be parsed or doesn't contain valid measurement results - pub fn from_byte_message( - message: &ByteMessage, - result_id_to_name: &BTreeMap, - ) -> Result { - // Extract the raw measurement results from the ByteMessage - let outcomes = message.outcomes()?; - - // Convert raw outcomes to indexed results - let measurements: Vec<(usize, u32)> = outcomes.into_iter().enumerate().collect(); - - let mut result = Self::default(); - - // Process each measurement - for (result_id, value) in measurements { - // Get the name for this result_id, or use a default if not found - let name = result_id_to_name - .get(&result_id) - .cloned() - .unwrap_or_else(|| format!("result_{result_id}")); - - // Store as U32 data - result.data.insert(name, Data::U32(value)); - } - - Ok(result) - } - /// Creates a binary string representation of results. /// /// This is a convenience method that creates a binary string from register values. diff --git a/crates/pecos-engines/src/shot_results/shot_map.rs b/crates/pecos-results/src/shot_map.rs similarity index 97% rename from crates/pecos-engines/src/shot_results/shot_map.rs rename to crates/pecos-results/src/shot_map.rs index 683730443..f14ee6b0f 100644 --- a/crates/pecos-engines/src/shot_results/shot_map.rs +++ b/crates/pecos-results/src/shot_map.rs @@ -29,8 +29,8 @@ use std::fmt; /// /// # Example /// ``` -/// # use pecos_engines::shot_results::{ShotVec, Shot, Data}; -/// # use pecos_engines::{ShotMapDisplayExt, BitVecDisplayFormat}; +/// # use pecos_results::{ShotVec, Shot, Data}; +/// # use pecos_results::{BitVecDisplayFormat, ShotMapDisplayExt}; /// # use pecos_core::errors::PecosError; /// # fn main() -> Result<(), PecosError> { /// let mut shot_vec = ShotVec::new(); @@ -165,7 +165,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::{ShotVec, Shot, Data, ShotMap}; + /// # use pecos_results::{Data, Shot, ShotMap, ShotVec}; /// # use pecos_core::errors::PecosError; /// # fn main() -> Result<(), PecosError> { /// let mut shot_vec = ShotVec::new(); @@ -248,7 +248,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use bitvec::prelude::*; /// # fn main() -> Result<(), PecosError> { @@ -309,7 +309,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use bitvec::prelude::*; /// # fn main() -> Result<(), PecosError> { @@ -366,7 +366,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use num_bigint::BigUint; /// # fn main() -> Result<(), PecosError> { @@ -428,7 +428,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use bitvec::prelude::*; /// # fn main() -> Result<(), PecosError> { @@ -729,7 +729,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # use bitvec::prelude::*; /// # fn main() -> Result<(), PecosError> { @@ -798,7 +798,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot, Data}; + /// # use pecos_results::{ShotVec, Shot, Data}; /// # use pecos_core::errors::PecosError; /// # fn main() -> Result<(), PecosError> { /// let mut shot_vec = ShotVec::new(); @@ -832,7 +832,7 @@ impl ShotMap { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// # use pecos_core::errors::PecosError; /// # fn main() -> Result<(), PecosError> { /// let mut shot_vec = ShotVec::new(); @@ -886,7 +886,7 @@ impl ShotMap { impl fmt::Display for ShotMap { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Import the extension trait to get the display() method - use crate::shot_results::shot_map_formatter::ShotMapDisplayExt; + use crate::shot_map_formatter::ShotMapDisplayExt; // Delegate to the display formatter write!(f, "{}", self.display()) } @@ -915,7 +915,7 @@ impl<'a> IntoIterator for &'a ShotMap { #[cfg(test)] mod tests { use super::*; - use crate::shot_results::{Shot, ShotVec}; + use crate::{Shot, ShotVec}; #[test] fn test_shot_map_creation() { @@ -938,7 +938,7 @@ mod tests { #[test] fn test_display_impl() { - use crate::shot_results::shot_map_formatter::ShotMapDisplayExt; + use crate::shot_map_formatter::ShotMapDisplayExt; let mut shot_vec = ShotVec::new(); diff --git a/crates/pecos-engines/src/shot_results/shot_map_formatter.rs b/crates/pecos-results/src/shot_map_formatter.rs similarity index 99% rename from crates/pecos-engines/src/shot_results/shot_map_formatter.rs rename to crates/pecos-results/src/shot_map_formatter.rs index f4e65bae7..1020c11af 100644 --- a/crates/pecos-engines/src/shot_results/shot_map_formatter.rs +++ b/crates/pecos-results/src/shot_map_formatter.rs @@ -245,7 +245,7 @@ impl ShotMapDisplayExt for ShotMap { #[cfg(test)] mod tests { use super::*; - use crate::shot_results::{Data, Shot, ShotVec}; + use crate::{Data, Shot, ShotVec}; #[test] fn test_display_formatting() { diff --git a/crates/pecos-engines/src/shot_results/shot_tests.rs b/crates/pecos-results/src/shot_tests.rs similarity index 99% rename from crates/pecos-engines/src/shot_results/shot_tests.rs rename to crates/pecos-results/src/shot_tests.rs index 4e6ac2676..412f76f9d 100644 --- a/crates/pecos-engines/src/shot_results/shot_tests.rs +++ b/crates/pecos-results/src/shot_tests.rs @@ -16,7 +16,7 @@ #[cfg(test)] mod tests { - use crate::shot_results::{Data, Shot, ShotVec}; + use crate::{Data, Shot, ShotVec}; #[test] fn test_shot_results_display_64bit() { diff --git a/crates/pecos-engines/src/shot_results/shot_vec.rs b/crates/pecos-results/src/shot_vec.rs similarity index 86% rename from crates/pecos-engines/src/shot_results/shot_vec.rs rename to crates/pecos-results/src/shot_vec.rs index 4c793fd03..98b8fbc9c 100644 --- a/crates/pecos-engines/src/shot_results/shot_vec.rs +++ b/crates/pecos-results/src/shot_vec.rs @@ -11,7 +11,6 @@ // the License. use super::{data::Data, shot::Shot}; -use crate::byte_message::ByteMessage; use pecos_core::errors::PecosError; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -87,7 +86,7 @@ impl ShotVec { /// /// # Example /// ``` - /// # use pecos_engines::shot_results::{ShotVec, Shot}; + /// # use pecos_results::{ShotVec, Shot}; /// let mut shot_vec = ShotVec::new(); /// /// // Add shots with consistent structure @@ -282,44 +281,6 @@ impl ShotVec { } } - /// Create a `ShotVec` instance directly from a `ByteMessage` containing measurement results. - /// - /// This method extracts measurement results from a `ByteMessage` and creates a `ShotVec` - /// instance with properly formatted results. It's more efficient than going through - /// `Shot` instances and provides better context about the measurements. - /// - /// # Parameters - /// - /// * `message` - A `ByteMessage` containing measurement results - /// - /// # Errors - /// - /// Returns a `PecosError` if the measurements cannot be extracted from the `ByteMessage` - /// or if there are issues with creating the `ShotVec` instance. - pub fn from_byte_message(message: &ByteMessage) -> Result { - // Extract the measurement results from the ByteMessage - // Extract raw measurement outcomes - let outcomes = message.outcomes()?; - - // Convert to indexed measurements - let measurements: Vec<(usize, u32)> = outcomes.into_iter().enumerate().collect(); - - let mut shot_result = Shot::default(); - - // Process each measurement - for (result_id, value) in measurements { - // Get the name for this result_id, or use a default if not found - let name = format!("result_{result_id}"); - - // Add the measurement to the results - shot_result.data.insert(name, Data::U32(value)); - } - - Ok(Self { - shots: vec![shot_result], - }) - } - /// Prints the `ShotVec` to stdout. pub fn print(&self) { println!("{self}"); From e4ee57b681cf1e56f4490d3a5543af3b0a36eb85 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 12:39:09 -0600 Subject: [PATCH 094/388] Make sim_neo produce Shot/ShotVec: CommandSource::shot_results hook lets ClassicalEngineAdapter surface the wrapped engine's named registers into SimulationResults::shots (sequential and parallel), with to_shot_vec synthesis for static circuits --- Cargo.lock | 1 + exp/pecos-neo/Cargo.toml | 1 + exp/pecos-neo/src/adapter.rs | 8 ++ exp/pecos-neo/src/program.rs | 12 ++ exp/pecos-neo/src/tool.rs | 1 + exp/pecos-neo/src/tool/simulation.rs | 165 ++++++++++++++++++++++++++- 6 files changed, 186 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d0ab3ac4..b0bb87c22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4039,6 +4039,7 @@ dependencies = [ "pecos-qasm", "pecos-quantum", "pecos-random", + "pecos-results", "pecos-simulators", "proptest", "rand 0.10.1", diff --git a/exp/pecos-neo/Cargo.toml b/exp/pecos-neo/Cargo.toml index 0a4f57b8d..634c054e6 100644 --- a/exp/pecos-neo/Cargo.toml +++ b/exp/pecos-neo/Cargo.toml @@ -27,6 +27,7 @@ rayon.workspace = true num_cpus = "1.16" pecos-engines.workspace = true +pecos-results.workspace = true pecos-programs.workspace = true # Optional: for QASM support pecos-qasm = { workspace = true, optional = true } diff --git a/exp/pecos-neo/src/adapter.rs b/exp/pecos-neo/src/adapter.rs index 29e0ae177..86ba7933d 100644 --- a/exp/pecos-neo/src/adapter.rs +++ b/exp/pecos-neo/src/adapter.rs @@ -340,6 +340,14 @@ where fn num_qubits(&self) -> usize { self.num_qubits } + + fn shot_results(&self) -> Option { + Some( + self.engine + .get_results() + .expect("classical engine failed to produce results for a completed shot"), + ) + } } /// Runner adapter for executing pecos-neo command sources through a diff --git a/exp/pecos-neo/src/program.rs b/exp/pecos-neo/src/program.rs index 6e7108590..4580db75f 100644 --- a/exp/pecos-neo/src/program.rs +++ b/exp/pecos-neo/src/program.rs @@ -110,6 +110,18 @@ pub trait CommandSource { /// Get the number of qubits required. fn num_qubits(&self) -> usize; + + /// Rich results for the just-completed shot, if this source produces + /// them. + /// + /// Classical engines accumulate named register values (e.g. QASM cregs) + /// during a shot and return them here as a [`pecos_results::Shot`]. + /// Sources without register data (static circuits, plain command + /// queues) return `None`; their per-qubit bits live in + /// `MeasurementOutcomes` instead. + fn shot_results(&self) -> Option { + None + } } /// Result of a single program execution (shot). diff --git a/exp/pecos-neo/src/tool.rs b/exp/pecos-neo/src/tool.rs index a73507a87..fcce2ecc1 100644 --- a/exp/pecos-neo/src/tool.rs +++ b/exp/pecos-neo/src/tool.rs @@ -94,6 +94,7 @@ pub use importance::{ CurrentShotWeight, ImportanceSamplingConfig, ImportanceSamplingPlugin, ImportanceSamplingResults, }; +pub use pecos_results::{Data, Shot, ShotMap, ShotVec}; pub use plugin::{Plugin, PluginGroup}; pub use resource::{Resource, Resources}; pub use simulation::{ diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index b56a624bf..576f17591 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -1365,6 +1365,11 @@ pub struct SimulationResults { /// Rare-event estimate with per-level statistics (only for subset /// simulation; `outcomes` is empty for subset runs). pub subset: Option, + /// Per-shot named-register results, populated when the program source + /// produces them (classical engines: QASM cregs, PHIR variables). + /// None for sources without register data; see + /// [`to_shot_vec()`](Self::to_shot_vec) to synthesize from outcomes. + pub shots: Option, } impl SimulationResults { @@ -1393,6 +1398,48 @@ impl SimulationResults { weights.clear(); } self.subset = None; + self.shots = None; + } + + /// View the results as a [`pecos_results::ShotVec`]. + /// + /// Program sources with named-register data (classical engines) + /// populate [`shots`](Self::shots) directly; that is returned as-is. + /// Otherwise one [`pecos_results::Shot`] per outcome is synthesized: + /// with a `register_map`, one `Data::BitVec` register per named + /// register (bits in the register's qubit order); without one, a + /// single register named `"meas"` with bits in ascending qubit order. + #[must_use] + pub fn to_shot_vec(&self, register_map: Option<&RegisterMap>) -> pecos_results::ShotVec { + if let Some(shots) = &self.shots { + return shots.clone(); + } + + let mut shot_vec = pecos_results::ShotVec::new(); + for outcomes in &self.outcomes { + let mut shot = pecos_results::Shot::default(); + if let Some(map) = register_map { + for name in map.register_names() { + if let Some(bits) = outcomes.register_bitstring(map, name) { + let bitstring: String = + bits.iter().map(|&b| if b { '1' } else { '0' }).collect(); + if let Some(data) = pecos_results::Data::from_bitstring(&bitstring) { + shot.data.insert(name.to_string(), data); + } + } + } + } else { + let bitstring: String = outcomes + .iter() + .map(|o| if o.outcome { '1' } else { '0' }) + .collect(); + if let Some(data) = pecos_results::Data::from_bitstring(&bitstring) { + shot.data.insert("meas".to_string(), data); + } + } + shot_vec.shots.push(shot); + } + shot_vec } /// Check if this result has importance weights. @@ -3244,6 +3291,21 @@ fn unified_simulation_post_shot(resources: &mut Resources) { .outcomes .push(outcomes.0); + // Collect rich register results when the source produces them + // (classical engines: QASM cregs, PHIR variables). + let shot = resources + .get::() + .command_source + .shot_results(); + if let Some(shot) = shot { + resources + .get_mut::() + .shots + .get_or_insert_with(pecos_results::ShotVec::new) + .shots + .push(shot); + } + // Increment shot counter resources.get_mut::().shot_index += 1; } @@ -3810,6 +3872,7 @@ impl Simulation { outcomes, weights: Some(weights), subset: None, + shots: None, } } Sampling::SubsetSimulation { config: ss_config } => { @@ -3848,6 +3911,7 @@ impl Simulation { outcomes: Vec::new(), weights: None, subset: Some(result), + shots: None, } } _ => { @@ -3922,6 +3986,7 @@ impl Simulation { outcomes, weights: Some(weights), subset: None, + shots: None, } } @@ -3992,13 +4057,25 @@ impl Simulation { }) .collect(); - // Flatten in deterministic order - let outcomes = all_results.into_iter().flat_map(|r| r.outcomes).collect(); + // Flatten in deterministic order, merging per-worker register shots + // when the source produced them. + let mut outcomes = Vec::new(); + let mut shots: Option = None; + for worker_results in all_results { + outcomes.extend(worker_results.outcomes); + if let Some(worker_shots) = worker_results.shots { + shots + .get_or_insert_with(pecos_results::ShotVec::new) + .shots + .extend(worker_shots.shots); + } + } SimulationResults { outcomes, weights: None, subset: None, + shots, } } @@ -4885,6 +4962,90 @@ mod tests { } } + // --- Shot/ShotVec Production Tests --- + + #[test] + fn test_sim_neo_static_circuit_shot_vec_synthesis() { + let circuit = CommandBuilder::new() + .pz(&[0, 1]) + .x(&[0]) + .mz(&[0, 1]) + .build(); + let results = sim_neo(circuit) + .auto() + .sampling(monte_carlo(3)) + .seed(1) + .run(); + + assert!( + results.shots.is_none(), + "Static circuits have no register data" + ); + + // Without a map: single "meas" register, bits in ascending qubit order. + let synthesized = results.to_shot_vec(None); + assert_eq!(synthesized.shots.len(), 3); + for shot in &synthesized.shots { + assert_eq!(shot.data["meas"].to_bitstring().unwrap(), "10"); + } + + // With a map: one BitVec register per name. + let mut map = RegisterMap::new(); + map.add_register("a", &[QubitId(0)]); + map.add_register("b", &[QubitId(1)]); + let named = results.to_shot_vec(Some(&map)); + for shot in &named.shots { + assert_eq!(shot.data["a"].to_bitstring().unwrap(), "1"); + assert_eq!(shot.data["b"].to_bitstring().unwrap(), "0"); + } + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_produces_register_shots() { + // The classical engine's named cregs flow through the adapter into + // SimulationResults::shots — including the feedback-conditioned bit. + let program = pecos_programs::Qasm::from_string(deterministic_conditional_qasm()); + let results = sim_neo(program) + .auto() + .quantum(sparse_stab()) + .sampling(monte_carlo(5)) + .seed(42) + .run(); + + let shots = results + .shots + .as_ref() + .expect("classical engines produce register shots"); + assert_eq!(shots.shots.len(), 5); + for shot in &shots.shots { + assert_eq!(shot.data["c"].to_bitstring().unwrap(), "11"); + } + // to_shot_vec returns the engine-produced registers as-is. + assert_eq!(results.to_shot_vec(None).shots.len(), 5); + } + + #[cfg(feature = "qasm")] + #[test] + fn test_sim_neo_qasm_parallel_register_shots() { + let program = pecos_programs::Qasm::from_string(deterministic_conditional_qasm()); + let results = sim_neo(program) + .auto() + .quantum(sparse_stab()) + .sampling(monte_carlo(6).workers(2)) + .seed(42) + .run(); + + let shots = results + .shots + .as_ref() + .expect("parallel adapter runs merge register shots"); + assert_eq!(shots.shots.len(), 6); + for shot in &shots.shots { + assert_eq!(shot.data["c"].to_bitstring().unwrap(), "11"); + } + } + // --- Path Enumeration Strategy Tests --- #[test] From cdbc3505602e97e0ee68de07a6435e4d1624ee8f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 13:15:10 -0600 Subject: [PATCH 095/388] Add sim() routing to the pecos-neo stack via .stack(SimStack::Neo) behind a neo cargo feature, rejecting not-yet-mapped config with clear errors, with contract tests proving identical ShotVecs across stacks for deterministic QASM --- Cargo.lock | 1 + crates/pecos-cli/src/cli/rust_cmd.rs | 3 +- crates/pecos/Cargo.toml | 4 + crates/pecos/src/lib.rs | 2 +- crates/pecos/src/unified_sim.rs | 141 ++++++++++++++++++++++++- crates/pecos/tests/neo_routing_test.rs | 113 ++++++++++++++++++++ 6 files changed, 261 insertions(+), 3 deletions(-) create mode 100644 crates/pecos/tests/neo_routing_test.rs diff --git a/Cargo.lock b/Cargo.lock index b0bb87c22..e328efe8e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3670,6 +3670,7 @@ dependencies = [ "pecos-hugr", "pecos-hugr-qis", "pecos-llvm", + "pecos-neo", "pecos-num", "pecos-phir", "pecos-phir-json", diff --git a/crates/pecos-cli/src/cli/rust_cmd.rs b/crates/pecos-cli/src/cli/rust_cmd.rs index c833dcc6d..bfd53f9cb 100644 --- a/crates/pecos-cli/src/cli/rust_cmd.rs +++ b/crates/pecos-cli/src/cli/rust_cmd.rs @@ -524,9 +524,10 @@ fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { println!("Testing workspace packages..."); // runtime = sim + qasm + phir (format parsers) // hugr = qis (includes llvm) + hugr compilation + // neo = sim() routing to the pecos-neo stack (contract tests) // pecos-cli is excluded here and tested separately below with --features=runtime // to ensure the pecos binary has PHIR/QIS support for integration tests. - let mut args: Vec<&str> = vec!["test", "--workspace", "--features=runtime,hugr"]; + let mut args: Vec<&str> = vec!["test", "--workspace", "--features=runtime,hugr,neo"]; for crate_name in FFI_CRATES.iter().chain(PYO3_CDYLIB_TEST_EXCLUDES) { args.push("--exclude"); diff --git a/crates/pecos/Cargo.toml b/crates/pecos/Cargo.toml index 85c779ced..9f3232712 100644 --- a/crates/pecos/Cargo.toml +++ b/crates/pecos/Cargo.toml @@ -28,6 +28,7 @@ pecos-qis = { workspace = true, optional = true } pecos-llvm = { workspace = true, optional = true } pecos-hugr-qis = { workspace = true, optional = true } pecos-hugr = { workspace = true, optional = true } +pecos-neo = { workspace = true, optional = true } pecos-phir = { workspace = true, optional = true, features = ["hugr"] } pecos-random = { workspace = true, optional = true } pecos-num = { workspace = true, optional = true } @@ -70,6 +71,9 @@ sim = [ # Runtime: enables full simulation library with QASM and PHIR support runtime = ["sim", "qasm", "phir"] +# Experimental: route sim() to the pecos-neo stack via .stack(SimStack::Neo) +neo = ["runtime", "dep:pecos-neo", "pecos-neo/qasm", "pecos-neo/hugr"] + # Program formats (require sim) qasm = ["sim", "dep:pecos-qasm"] phir = ["sim", "dep:pecos-phir-json"] diff --git a/crates/pecos/src/lib.rs b/crates/pecos/src/lib.rs index 7e4ad9700..5792a8cd6 100644 --- a/crates/pecos/src/lib.rs +++ b/crates/pecos/src/lib.rs @@ -225,4 +225,4 @@ pub use pecos_qis::{QisEngineBuilder, qis_engine, setup_qis_engine_with_runtime} #[cfg(feature = "wasm")] pub use pecos_wasm::{ForeignObject, WasmForeignObject}; #[cfg(feature = "runtime")] -pub use unified_sim::{ProgrammedSimBuilder, SimBuilderExt, sim}; +pub use unified_sim::{ProgrammedSimBuilder, SimBuilderExt, SimStack, sim}; diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 06194e162..2e65f4fd7 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -25,6 +25,21 @@ fn build_qis_engine( .map_err(|e| PecosError::Generic(format!("Failed to load program: {e}"))) } +/// Which simulation stack executes the program. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SimStack { + /// The engine/`EngineSystem` stack in `pecos-engines` (current default). + #[default] + Engines, + /// The data-oriented `pecos-neo` stack (experimental). + /// + /// Requires building pecos with the `neo` cargo feature. Currently + /// routes QASM and HUGR programs with the default quantum backend; + /// explicit `.classical()`, `.noise()`, and `.quantum()` configuration + /// is not yet translated and is rejected with an error at `run()`. + Neo, +} + /// Extension trait for `SimBuilder` to add program-based methods pub trait SimBuilderExt { /// Set the program and automatically select an appropriate engine @@ -46,15 +61,34 @@ impl SimBuilderExt for SimBuilder { base_builder: self, program: program.into(), override_classical: false, + stack: SimStack::default(), + routed: RoutedConfig::default(), } } } +/// Config recorded at the facade for routing to the neo stack. +/// +/// The engines `SimBuilder` keeps its own copy via the delegating setters; +/// this records what the neo translation needs (values it can map, flags +/// for config it cannot yet map and must reject). +#[derive(Default)] +struct RoutedConfig { + seed: Option, + workers: Option, + auto_workers: bool, + qubits: Option, + noise_set: bool, + quantum_set: bool, +} + /// A simulation builder that has a program set and can auto-select engines pub struct ProgrammedSimBuilder { base_builder: SimBuilder, program: Program, override_classical: bool, + stack: SimStack, + routed: RoutedConfig, } impl ProgrammedSimBuilder { @@ -109,6 +143,18 @@ impl ProgrammedSimBuilder { } } + /// Select which simulation stack executes the program. + /// + /// Defaults to [`SimStack::Engines`]. [`SimStack::Neo`] is experimental + /// and requires the `neo` cargo feature; see [`SimStack`] for the + /// configuration it can route so far. The result type and contract are + /// identical on both stacks. + #[must_use] + pub fn stack(mut self, stack: SimStack) -> Self { + self.stack = stack; + self + } + /// Build the simulation with automatic engine selection /// /// # Errors @@ -116,7 +162,15 @@ impl ProgrammedSimBuilder { /// Returns an error if: /// - The program type is not yet supported (WASM, WAT, PHIR JSON, `SeleneInterface`) /// - Engine building fails + /// - The neo stack is selected (it has no `MonteCarloEngine`; use + /// [`run()`](Self::run) directly) pub fn build(self) -> Result { + if self.stack == SimStack::Neo { + return Err(PecosError::Input( + "The neo stack does not expose a MonteCarloEngine; call .run(shots) directly." + .to_string(), + )); + } self.configure_engine()?.build() } @@ -127,8 +181,85 @@ impl ProgrammedSimBuilder { /// Returns an error if: /// - The program type is not yet supported (WASM, WAT, PHIR JSON, `SeleneInterface`) /// - Engine building or running fails + /// - The neo stack is selected with configuration it cannot route yet pub fn run(self, shots: usize) -> Result { - self.configure_engine()?.run(shots) + match self.stack { + SimStack::Engines => self.configure_engine()?.run(shots), + SimStack::Neo => self.run_neo(shots), + } + } + + /// Run the program on the pecos-neo stack. + #[cfg(feature = "neo")] + fn run_neo(self, shots: usize) -> Result { + use pecos_neo::tool::{monte_carlo, sim_neo}; + + if self.override_classical { + return Err(PecosError::Input( + "Explicit .classical() engine builders are not yet routed to the neo stack; \ + remove .classical() or use .stack(SimStack::Engines)." + .to_string(), + )); + } + if self.routed.noise_set { + return Err(PecosError::Input( + "Noise models are not yet routed to the neo stack (the engines-to-neo noise \ + mapping is pending); remove .noise() or use .stack(SimStack::Engines)." + .to_string(), + )); + } + if self.routed.quantum_set { + return Err(PecosError::Input( + "Explicit quantum backends are not yet routed to the neo stack (it uses the \ + default sparse stabilizer); remove .quantum() or use .stack(SimStack::Engines)." + .to_string(), + )); + } + match &self.program { + Program::Qasm(_) | Program::Hugr(_) => {} + _ => { + return Err(PecosError::Input( + "Only QASM and HUGR programs are routed to the neo stack so far; \ + use .stack(SimStack::Engines) for other program types." + .to_string(), + )); + } + } + + let mut sampler = monte_carlo(shots); + if let Some(workers) = self.routed.workers { + sampler = sampler.workers(workers); + } + if self.routed.auto_workers { + sampler = sampler.auto_workers(); + } + + let mut builder = sim_neo(self.program).auto().sampling(sampler); + if let Some(seed) = self.routed.seed { + builder = builder.seed(seed); + } + if let Some(qubits) = self.routed.qubits { + builder = builder.qubits(qubits); + } + + let results = builder.run(); + results.shots.ok_or_else(|| { + PecosError::Generic( + "The neo stack produced no register results for a classical-engine program; \ + this is a bug in the neo routing." + .to_string(), + ) + }) + } + + /// Stub when pecos is built without the `neo` feature. + #[cfg(not(feature = "neo"))] + fn run_neo(self, _shots: usize) -> Result { + Err(PecosError::Input( + "pecos was built without the 'neo' cargo feature; rebuild with features = [\"neo\"] \ + to route sim() to the neo stack." + .to_string(), + )) } /// Override the classical engine selection @@ -150,6 +281,7 @@ impl ProgrammedSimBuilder { /// Set the random seed (delegates to base builder) #[must_use] pub fn seed(mut self, seed: u64) -> Self { + self.routed.seed = Some(seed); self.base_builder = self.base_builder.seed(seed); self } @@ -157,6 +289,7 @@ impl ProgrammedSimBuilder { /// Set the number of worker threads (delegates to base builder) #[must_use] pub fn workers(mut self, workers: usize) -> Self { + self.routed.workers = Some(workers); self.base_builder = self.base_builder.workers(workers); self } @@ -164,6 +297,7 @@ impl ProgrammedSimBuilder { /// Use automatic worker count (delegates to base builder) #[must_use] pub fn auto_workers(mut self) -> Self { + self.routed.auto_workers = true; self.base_builder = self.base_builder.auto_workers(); self } @@ -181,6 +315,7 @@ impl ProgrammedSimBuilder { where N: pecos_engines::noise::IntoNoiseModel + Send + 'static, { + self.routed.noise_set = true; self.base_builder = self.base_builder.noise(noise_builder); self } @@ -192,6 +327,7 @@ impl ProgrammedSimBuilder { Q: pecos_engines::quantum_engine_builder::IntoQuantumEngineBuilder + 'static, Q::Builder: Send + 'static, { + self.routed.quantum_set = true; self.base_builder = self.base_builder.quantum(quantum_builder); self } @@ -199,6 +335,7 @@ impl ProgrammedSimBuilder { /// Set the number of qubits (delegates to base builder) #[must_use] pub fn qubits(mut self, num_qubits: usize) -> Self { + self.routed.qubits = Some(num_qubits); self.base_builder = self.base_builder.qubits(num_qubits); self } @@ -237,5 +374,7 @@ pub fn sim>(program: P) -> ProgrammedSimBuilder { base_builder: sim_builder(), program: program.into(), override_classical: false, + stack: SimStack::default(), + routed: RoutedConfig::default(), } } diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs new file mode 100644 index 000000000..35d4e6ca8 --- /dev/null +++ b/crates/pecos/tests/neo_routing_test.rs @@ -0,0 +1,113 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Contract tests for routing `sim()` to the pecos-neo stack. +//! +//! The neo stack must return the same `ShotVec` contract as the engines +//! stack: for deterministic programs, results are compared for exact +//! equality across stacks. + +#![cfg(feature = "neo")] + +use pecos::{SimStack, sim}; +use pecos_programs::Qasm; + +/// Deterministic program exercising measurement feedback: c ends as "11". +fn deterministic_conditional_qasm() -> Qasm { + Qasm::from_string( + r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + x q[0]; + measure q[0] -> c[0]; + if (c[0] == 1) x q[1]; + measure q[1] -> c[1]; + "#, + ) +} + +#[test] +fn neo_stack_matches_engines_for_deterministic_qasm() { + let engines = sim(deterministic_conditional_qasm()) + .seed(42) + .run(5) + .expect("engines run"); + + let neo = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .seed(42) + .run(5) + .expect("neo run"); + + assert_eq!(engines.shots.len(), 5); + assert_eq!( + engines, neo, + "Deterministic program must produce identical ShotVecs on both stacks" + ); + for shot in &neo.shots { + assert_eq!(shot.data["c"].to_bitstring().unwrap(), "11"); + } +} + +#[test] +fn neo_stack_parallel_matches_engines() { + let engines = sim(deterministic_conditional_qasm()) + .seed(7) + .workers(2) + .run(6) + .expect("engines run"); + + let neo = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .seed(7) + .workers(2) + .run(6) + .expect("neo run"); + + assert_eq!(engines, neo); +} + +#[test] +fn neo_stack_rejects_unrouted_noise() { + let err = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .noise(pecos_engines::DepolarizingNoise { p: 0.01 }) + .run(5) + .expect_err("noise is not yet routed to the neo stack"); + assert!( + err.to_string().contains("not yet routed to the neo stack"), + "unexpected error: {err}" + ); +} + +#[test] +fn neo_stack_rejects_unrouted_quantum_backend() { + let err = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .quantum(pecos_engines::state_vector()) + .run(5) + .expect_err("explicit quantum backends are not yet routed"); + assert!(err.to_string().contains("not yet routed to the neo stack")); +} + +#[test] +fn neo_stack_rejects_build() { + let Err(err) = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .build() + else { + panic!("neo stack has no MonteCarloEngine; build() must error"); + }; + assert!(err.to_string().contains("MonteCarloEngine")); +} From c5cb83ad5a3db396a088a43c579fa115f47f24ae Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 13:26:35 -0600 Subject: [PATCH 096/388] Parallelize custom SimulatorFactory backends: QuantumBackend::Custom holds Arc and the parallel plan invokes the factory once per worker with cloned noise, keeping results worker-count invariant via global-shot-index seeding --- exp/pecos-neo/src/tool/simulation.rs | 125 +++++++++++++++++++++++---- 1 file changed, 106 insertions(+), 19 deletions(-) diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 576f17591..822c066ec 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -176,9 +176,10 @@ pub enum QuantumBackend { /// Allows any simulator implementing `CliffordGateable + RngManageable` /// to be used through the `sim_neo().auto()` API. Use [`custom_backend()`] to create. /// - /// Custom backends only support sequential execution. Parallel Monte - /// Carlo (workers > 1) is rejected at `.build()` time. - Custom(Box), + /// The factory is invoked once per worker, so parallel Monte Carlo + /// works like the built-in backends (per-shot seeding from global shot + /// indices keeps results identical for any worker count). + Custom(Arc), } impl std::fmt::Debug for QuantumBackend { @@ -445,7 +446,7 @@ where /// /// Created via [`custom_backend()`]. Converts into [`QuantumBackend::Custom`]. pub struct CustomBackendBuilder { - factory: Box, + factory: Arc, } impl From for QuantumBackend { @@ -460,9 +461,10 @@ impl From for QuantumBackend { /// to be used through `sim_neo().auto()`. The closure receives the number of qubits /// and should return a new simulator instance. /// -/// **Note:** Custom backends only support sequential execution. Using `.workers()`, -/// `.auto_workers()`, or importance sampling with a custom backend will panic -/// at `.run()` time. +/// The factory is invoked once per worker for parallel Monte Carlo, so +/// `.workers(n)` works like the built-in backends. (Importance sampling +/// always runs on its internal sparse stabilizer and ignores the backend +/// choice.) /// /// # Example /// @@ -488,7 +490,7 @@ where F: Fn(usize) -> S + Send + Sync + 'static, { CustomBackendBuilder { - factory: Box::new(factory), + factory: Arc::new(factory), } } @@ -502,7 +504,7 @@ pub fn custom_backend_from_factory( factory: impl SimulatorFactory + 'static, ) -> CustomBackendBuilder { CustomBackendBuilder { - factory: Box::new(factory), + factory: Arc::new(factory), } } @@ -540,7 +542,7 @@ where F: Fn(usize) -> S + Send + Sync + 'static, { CustomBackendBuilder { - factory: Box::new(RotationSimulatorFactory(factory)), + factory: Arc::new(RotationSimulatorFactory(factory)), } } @@ -984,9 +986,9 @@ impl MonteCarloBuilder { /// count. /// /// Requires a per-worker construction path: a static circuit or a - /// classical engine builder source, on a built-in or adapted backend. - /// Pre-built dynamic command sources and custom backends cannot build - /// per-worker state; `.build()` rejects those combinations. + /// classical engine builder source, on any backend (built-in, adapted, + /// or custom factory). Pre-built dynamic command sources cannot build + /// per-worker state; `.build()` rejects that combination. #[must_use] pub fn workers(mut self, workers: usize) -> Self { self.workers = workers; @@ -2478,7 +2480,7 @@ impl SimNeoBuilder { /// - Deprecated `.shots()`/`.workers()` are combined with `.sampling()` /// - Parallel Monte Carlo (`workers > 1`) is requested for a /// configuration that cannot build per-worker state (pre-built dynamic - /// command sources, custom backends) + /// command sources) /// - Subset simulation is missing `.score()`/`.failure()`, or is used /// with a non-static source or a backend other than `sparse_stab()` #[must_use] @@ -2622,10 +2624,9 @@ impl SimNeoBuilder { assert!( plan.is_some(), "Parallel Monte Carlo (workers > 1) requires per-worker construction: \ - a static circuit or classical engine builder source, on a built-in or \ - adapted backend. Pre-built dynamic command sources and custom backends \ - cannot build per-worker state; remove .workers(..) for sequential \ - execution." + a static circuit or classical engine builder source. Pre-built dynamic \ + command sources cannot build per-worker state; remove .workers(..) for \ + sequential execution." ); plan } @@ -3677,6 +3678,26 @@ impl ParallelQuantumRunnerFactory for NativeQuantumRunnerFactory { } } } +/// Per-worker runner factory for custom `SimulatorFactory` backends. +/// +/// The user's factory is invoked once per worker with a clone of the noise +/// model; per-shot seeding from global shot indices happens in the shared +/// schedule, exactly as for built-in backends. +struct CustomRunnerFactory { + factory: Arc, + num_qubits: usize, + noise: Option, +} + +impl ParallelQuantumRunnerFactory for CustomRunnerFactory { + fn create_runner(&self, seed: Option) -> QuantumRunner { + QuantumRunner::Custom( + self.factory + .create_runner(self.num_qubits, self.noise.clone(), seed), + ) + } +} + struct AdaptedQuantumEngineRunnerFactory where B: pecos_engines::QuantumEngineBuilder + Clone + 'static, @@ -3784,7 +3805,11 @@ fn build_parallel_execution_plan( ); factory.create_parallel_runner_factory(num_qubits) } - QuantumBackend::Custom(_) => return None, + QuantumBackend::Custom(factory) => Box::new(CustomRunnerFactory { + factory: Arc::clone(factory), + num_qubits, + noise, + }), }; Some(ParallelExecutionPlan { @@ -6249,6 +6274,68 @@ mod tests { } } + #[test] + fn test_custom_backend_parallel_matches_sequential() { + // The factory builds one runner per worker; per-shot seeding from + // global shot indices makes results identical for any worker count. + let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); + let run = |workers: usize| { + sim_neo(circuit.clone()) + .quantum(custom_backend(SparseStab::new)) + .sampling(monte_carlo(50).workers(workers)) + .seed(42) + .run() + }; + + let sequential = run(1); + let parallel = run(4); + + assert_eq!(sequential.outcomes.len(), 50); + assert_eq!(sequential.outcomes.len(), parallel.outcomes.len()); + for (i, (s, p)) in sequential + .outcomes + .iter() + .zip(parallel.outcomes.iter()) + .enumerate() + { + assert_eq!( + s.get_bit(QubitId(0)), + p.get_bit(QubitId(0)), + "Shot {i} should match across worker counts" + ); + } + } + + #[test] + fn test_custom_backend_parallel_with_noise() { + // Noise is cloned per worker; results stay worker-count invariant. + let circuit = CommandBuilder::new().pz(&[0]).z(&[0]).mz(&[0]).build(); + let run = |workers: usize| { + sim_neo(circuit.clone()) + .quantum(custom_backend(SparseStab::new)) + .noise(SingleQubitChannel::depolarizing(0.3)) + .sampling(monte_carlo(40).workers(workers)) + .seed(7) + .run() + }; + + let sequential = run(1); + let parallel = run(3); + + for (i, (s, p)) in sequential + .outcomes + .iter() + .zip(parallel.outcomes.iter()) + .enumerate() + { + assert_eq!( + s.get_bit(QubitId(0)), + p.get_bit(QubitId(0)), + "Noisy shot {i} should match across worker counts" + ); + } + } + #[test] fn test_custom_backend_state_vector() { // Verify StateVec also works via custom_backend From 3a3b20f3e6023b007394ce6f0d88c17e9ed44457 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 13:48:03 -0600 Subject: [PATCH 097/388] Route depolarizing-family noise to the neo stack with one-to-one probability mapping (verified identical sampling conventions; the 1.5x/1.25x scalings are GeneralNoiseModel's average convention only) and statistical cross-stack equivalence tests --- crates/pecos/src/unified_sim.rs | 89 ++++++++++++++++++++---- crates/pecos/tests/neo_routing_test.rs | 96 ++++++++++++++++++++++++-- 2 files changed, 168 insertions(+), 17 deletions(-) diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 2e65f4fd7..90b5f0f6e 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -34,9 +34,12 @@ pub enum SimStack { /// The data-oriented `pecos-neo` stack (experimental). /// /// Requires building pecos with the `neo` cargo feature. Currently - /// routes QASM and HUGR programs with the default quantum backend; - /// explicit `.classical()`, `.noise()`, and `.quantum()` configuration - /// is not yet translated and is rejected with an error at `run()`. + /// routes QASM and HUGR programs with the default quantum backend. + /// Depolarizing-family noise (`PassThroughNoise`, `DepolarizingNoise`, + /// `DepolarizingNoiseModel`) is translated with identical conventions; + /// other noise types, explicit `.classical()`, and explicit + /// `.quantum()` configuration are not yet translated and are rejected + /// with an error at `run()`. Neo, } @@ -78,7 +81,10 @@ struct RoutedConfig { workers: Option, auto_workers: bool, qubits: Option, - noise_set: bool, + /// The noise config as passed, for translation to the neo stack. + /// Type-erased because `.noise()` is generic; the neo route downcasts + /// against the known engines noise types. + noise: Option>, quantum_set: bool, } @@ -201,13 +207,10 @@ impl ProgrammedSimBuilder { .to_string(), )); } - if self.routed.noise_set { - return Err(PecosError::Input( - "Noise models are not yet routed to the neo stack (the engines-to-neo noise \ - mapping is pending); remove .noise() or use .stack(SimStack::Engines)." - .to_string(), - )); - } + let neo_noise = match &self.routed.noise { + None => None, + Some(noise) => map_noise_to_neo(noise.as_ref())?, + }; if self.routed.quantum_set { return Err(PecosError::Input( "Explicit quantum backends are not yet routed to the neo stack (it uses the \ @@ -241,6 +244,9 @@ impl ProgrammedSimBuilder { if let Some(qubits) = self.routed.qubits { builder = builder.qubits(qubits); } + if let Some(noise) = neo_noise { + builder = builder.noise(noise); + } let results = builder.run(); results.shots.ok_or_else(|| { @@ -261,7 +267,64 @@ impl ProgrammedSimBuilder { .to_string(), )) } +} + +/// Translate an engines noise config into the neo stack's noise model. +/// +/// The depolarizing family has identical sampling conventions on both +/// stacks (uniform X/Y/Z at p1, uniform 15 two-qubit Paulis at p2, X +/// before prep/measure for `p_prep`/`p_meas`), verified by +/// `exp/pecos-neo/tests/noise_comparison_test.rs`, so probabilities map +/// one-to-one. `GeneralNoiseModel` is NOT mapped: its full configuration +/// (leakage, idle, crosstalk, emission models) is not readable from the +/// built model and uses the "average" probability convention; configure +/// `sim_neo()` directly with neo's `GeneralNoiseModelBuilder` for those. +/// +/// Returns `Ok(None)` for pass-through (no noise). +#[cfg(feature = "neo")] +fn map_noise_to_neo( + noise: &(dyn std::any::Any + Send), +) -> Result, PecosError> { + use pecos_engines::noise::{DepolarizingNoiseModelBuilder, PassThroughNoiseModelBuilder}; + use pecos_engines::{DepolarizingNoise, PassThroughNoise}; + use pecos_neo::noise::GeneralNoiseModelBuilder; + + let uniform = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { + GeneralNoiseModelBuilder::new() + .with_p_prep(p_prep) + .with_p_meas_symmetric(p_meas) + .with_p1(p1) + .with_p2(p2) + }; + + if noise.downcast_ref::().is_some() + || noise + .downcast_ref::() + .is_some() + { + return Ok(None); + } + if let Some(depolarizing) = noise.downcast_ref::() { + let p = depolarizing.p; + return Ok(Some(uniform(p, p, p, p))); + } + if let Some(builder) = noise.downcast_ref::() { + // Resolve the configured probabilities via the built model; this + // enforces the same all-probabilities-set requirement the engines + // path would. + let (p_prep, p_meas, p1, p2) = builder.clone().build().probabilities(); + return Ok(Some(uniform(p_prep, p_meas, p1, p2))); + } + + Err(PecosError::Input( + "This noise type is not yet mapped to the neo stack (mapped so far: PassThroughNoise, \ + DepolarizingNoise, DepolarizingNoiseModelBuilder). Remove .noise(), use \ + .stack(SimStack::Engines), or configure sim_neo() directly with a neo noise model." + .to_string(), + )) +} +impl ProgrammedSimBuilder { /// Override the classical engine selection /// /// This allows you to specify a different engine than the auto-selected one. @@ -313,9 +376,9 @@ impl ProgrammedSimBuilder { #[must_use] pub fn noise(mut self, noise_builder: N) -> Self where - N: pecos_engines::noise::IntoNoiseModel + Send + 'static, + N: pecos_engines::noise::IntoNoiseModel + Clone + Send + 'static, { - self.routed.noise_set = true; + self.routed.noise = Some(Box::new(noise_builder.clone())); self.base_builder = self.base_builder.noise(noise_builder); self } diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index 35d4e6ca8..694e09e2b 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -78,15 +78,103 @@ fn neo_stack_parallel_matches_engines() { assert_eq!(engines, neo); } +/// One-qubit program whose only error source is what the noise model adds. +fn x_measure_qasm() -> Qasm { + Qasm::from_string( + r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + x q[0]; + measure q[0] -> c[0]; + "#, + ) +} + +/// Fraction of shots where register `c` reads the given bitstring. +#[allow(clippy::cast_precision_loss)] // shot counts are far below 2^52 +fn rate_of(results: &pecos_engines::shot_results::ShotVec, bits: &str) -> f64 { + let matching = results + .shots + .iter() + .filter(|shot| shot.data["c"].to_bitstring().as_deref() == Some(bits)) + .count(); + matching as f64 / results.shots.len() as f64 +} + +#[test] +fn neo_stack_measurement_noise_rate_matches_engines() { + // Measurement-only noise: P(c = 0) = p_meas exactly on both stacks. + let p_meas = 0.2; + let shots = 4000; + let noise = pecos_engines::noise::DepolarizingNoiseModel::builder() + .with_prep_probability(0.0) + .with_meas_probability(p_meas) + .with_p1_probability(0.0) + .with_p2_probability(0.0); + + let engines = sim(x_measure_qasm()) + .noise(noise.clone()) + .seed(42) + .run(shots) + .expect("engines run"); + let neo = sim(x_measure_qasm()) + .stack(SimStack::Neo) + .noise(noise) + .seed(42) + .run(shots) + .expect("neo run"); + + let engines_rate = rate_of(&engines, "0"); + let neo_rate = rate_of(&neo, "0"); + + // Bands: ~5 sigma for p=0.2 at 4000 shots is ~0.032. + assert!( + (engines_rate - p_meas).abs() < 0.035, + "engines rate {engines_rate} should be near {p_meas}" + ); + assert!( + (neo_rate - p_meas).abs() < 0.035, + "neo rate {neo_rate} should be near {p_meas}" + ); +} + +#[test] +fn neo_stack_uniform_depolarizing_rate_matches_engines() { + // Uniform depolarizing through the convenience struct: the compound + // error rate must agree across stacks (same conventions, different + // RNG streams). + let shots = 4000; + let run = |stack: SimStack| { + sim(x_measure_qasm()) + .stack(stack) + .noise(pecos_engines::DepolarizingNoise { p: 0.1 }) + .seed(7) + .run(shots) + .expect("run") + }; + + let engines_rate = rate_of(&run(SimStack::Engines), "0"); + let neo_rate = rate_of(&run(SimStack::Neo), "0"); + + assert!( + (engines_rate - neo_rate).abs() < 0.035, + "compound error rates should agree: engines={engines_rate}, neo={neo_rate}" + ); +} + #[test] -fn neo_stack_rejects_unrouted_noise() { +fn neo_stack_rejects_unmapped_noise() { + let general = + pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1_probability(0.01); let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) - .noise(pecos_engines::DepolarizingNoise { p: 0.01 }) + .noise(general) .run(5) - .expect_err("noise is not yet routed to the neo stack"); + .expect_err("GeneralNoiseModelBuilder is not yet mapped to the neo stack"); assert!( - err.to_string().contains("not yet routed to the neo stack"), + err.to_string().contains("not yet mapped to the neo stack"), "unexpected error: {err}" ); } From 3fc5a47cd6f093fb5780499bcc32b0306f8e13b7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 14:06:20 -0600 Subject: [PATCH 098/388] Map GeneralNoiseModel's simple Pauli subset to the neo stack via a simple_probabilities introspection getter, requiring explicit zeros for the realistic nonzero defaults (emission 0.5 replaces gates, prep leak 0.5, idle 0.001) that the old comparison test's loose tolerance had masked --- .../src/noise/general/builder.rs | 149 ++++++++++++++++++ crates/pecos/src/unified_sim.rs | 27 +++- crates/pecos/tests/neo_routing_test.rs | 55 ++++++- 3 files changed, 227 insertions(+), 4 deletions(-) diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index 4ea98e3f6..9a7b4b294 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -735,6 +735,93 @@ impl GeneralNoiseModelBuilder { } // ========================================================================================== // + /// The simple Pauli-probability subset of this configuration, if the + /// physics reduces to it. + /// + /// Returns `(p_prep, p_meas_0, p_meas_1, p1, p2)`. `p1`/`p2` are in the + /// standard depolarizing convention the builder stores internally (the + /// `with_average_*` setters convert on the way in). Unset probabilities + /// take their `GeneralNoiseModel::default()` values — this model's + /// philosophy is realistic defaults, NOT unset-means-off. + /// + /// Returns `Some` only when the noise shape is plain Pauli noise: + /// + /// - Knobs whose model defaults are non-neutral must be EXPLICITLY + /// zeroed: emission ratios (default 0.5 — half the errors replace the + /// gate instead of following it), prep leak ratio (default 0.5), and + /// the linear idle rate (default 0.001). + /// - Knobs with neutral defaults (crosstalk, quadratic idle, scales, + /// noiseless gates) may be unset or set to their neutral value. + /// - Custom Pauli/emission/crosstalk models and angle-dependent + /// two-qubit noise must be unset. + /// + /// A configured seed is ignored (it selects a random stream, not + /// physics). This exists so other simulation stacks can translate the + /// common configuration without re-deriving probability conventions. + #[must_use] + pub fn simple_probabilities(&self) -> Option<(f64, f64, f64, f64, f64)> { + let explicitly_zero = |v: Option| v == Some(0.0); + let zero_or_unset = |v: Option| v.is_none() || v == Some(0.0); + let one_or_unset = |v: Option| v.is_none() || v == Some(1.0); + + // Non-neutral model defaults: unset means the default applies, so + // these must be explicitly zeroed for the physics to be plain Pauli. + let defaulted_features_off = explicitly_zero(self.p1_emission_ratio) + && explicitly_zero(self.p2_emission_ratio) + && explicitly_zero(self.p_prep_leak_ratio) + && explicitly_zero(self.p_idle_linear_rate); + + // Neutral model defaults: unset is fine. + let optional_features_off = zero_or_unset(self.p_idle_quadratic_rate) + && zero_or_unset(self.p_prep_crosstalk) + && zero_or_unset(self.p2_idle) + && zero_or_unset(self.p_meas_crosstalk_global) + && zero_or_unset(self.p_meas_crosstalk_local); + + // Custom samplers/models could change the Pauli distribution; the + // model defaults are uniform, so unset is standard. + let models_off = self.p_idle_linear_model.is_none() + && self.p1_emission_model.is_none() + && self.p1_pauli_model.is_none() + && self.p2_emission_model.is_none() + && self.p2_pauli_model.is_none() + && self.p_meas_crosstalk_model.is_none() + && self.p2_angle_params.is_none() + && self.p2_angle_power.is_none(); + + let scales_neutral = one_or_unset(self.scale) + && one_or_unset(self.idle_scale) + && one_or_unset(self.prep_scale) + && one_or_unset(self.meas_scale) + && one_or_unset(self.p1_scale) + && one_or_unset(self.p2_scale) + && one_or_unset(self.p_prep_crosstalk_scale) + && one_or_unset(self.p_meas_crosstalk_scale); + + let gates_default = self.noiseless_gates.as_ref().is_none_or(BTreeSet::is_empty); + + if defaulted_features_off + && optional_features_off + && models_off + && scales_neutral + && gates_default + { + // Unset probabilities take the model defaults; read them from + // GeneralNoiseModel::default() so they cannot drift. + let (d_prep, d_meas_0, d_meas_1, d_p1, d_p2, _) = + GeneralNoiseModel::default().probabilities(); + Some(( + self.p_prep.unwrap_or(d_prep), + self.p_meas_0.unwrap_or(d_meas_0), + self.p_meas_1.unwrap_or(d_meas_1), + self.p1.unwrap_or(d_p1), + self.p2.unwrap_or(d_p2), + )) + } else { + None + } + } + // scaling // ========================================================================================== // @@ -815,3 +902,65 @@ impl crate::noise::IntoNoiseModel for GeneralNoiseModelBuilder { Box::new(self.build()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_probabilities_requires_explicit_zeros_for_defaulted_features() { + // Bare builder: model defaults include emission 0.5, prep leak 0.5, + // idle 0.001 — physics beyond the simple Pauli subset. + assert!( + GeneralNoiseModelBuilder::new() + .simple_probabilities() + .is_none() + ); + // Setting only a probability does not neutralize the defaults. + assert!( + GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .simple_probabilities() + .is_none() + ); + } + + #[test] + fn simple_probabilities_returns_stored_convention_values() { + let simple = GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .with_average_p2_probability(0.4) + .with_prep_probability(0.01) + .with_meas_0_probability(0.02) + .with_meas_1_probability(0.03) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .simple_probabilities() + .expect("fully zeroed config is simple"); + + let (p_prep, p_meas_0, p_meas_1, p1, p2) = simple; + assert!((p_prep - 0.01).abs() < 1e-12); + assert!((p_meas_0 - 0.02).abs() < 1e-12); + assert!((p_meas_1 - 0.03).abs() < 1e-12); + // Stored in standard depolarizing convention: average x 1.5 / x 1.25. + assert!((p1 - 0.3).abs() < 1e-12); + assert!((p2 - 0.5).abs() < 1e-12); + } + + #[test] + fn simple_probabilities_unset_probabilities_take_model_defaults() { + let simple = GeneralNoiseModelBuilder::new() + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .simple_probabilities() + .expect("zeroed features with default probabilities is simple"); + + let (d_prep, d_meas_0, d_meas_1, d_p1, d_p2, _) = + GeneralNoiseModel::default().probabilities(); + assert_eq!(simple, (d_prep, d_meas_0, d_meas_1, d_p1, d_p2)); + } +} diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 90b5f0f6e..a1c3d5f7d 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -315,11 +315,34 @@ fn map_noise_to_neo( let (p_prep, p_meas, p1, p2) = builder.clone().build().probabilities(); return Ok(Some(uniform(p_prep, p_meas, p1, p2))); } + if let Some(builder) = noise.downcast_ref::() { + // The stored p1/p2 are already in standard depolarizing convention + // (the with_average_* setters convert on the way in), so they map + // one-to-one onto neo's builder. + let Some((p_prep, p_meas_0, p_meas_1, p1, p2)) = builder.simple_probabilities() else { + return Err(PecosError::Input( + "This GeneralNoiseModel configuration uses features beyond the simple \ + probability subset (leakage, emission, seepage, idle, crosstalk, \ + angle-dependent noise, scales, or noiseless gates), which are not yet \ + mapped to the neo stack. Use .stack(SimStack::Engines) or configure \ + sim_neo() directly with a neo noise model." + .to_string(), + )); + }; + return Ok(Some( + GeneralNoiseModelBuilder::new() + .with_p_prep(p_prep) + .with_p_meas(p_meas_0, p_meas_1) + .with_p1(p1) + .with_p2(p2), + )); + } Err(PecosError::Input( "This noise type is not yet mapped to the neo stack (mapped so far: PassThroughNoise, \ - DepolarizingNoise, DepolarizingNoiseModelBuilder). Remove .noise(), use \ - .stack(SimStack::Engines), or configure sim_neo() directly with a neo noise model." + DepolarizingNoise, DepolarizingNoiseModelBuilder, GeneralNoiseModelBuilder's simple \ + probability subset). Remove .noise(), use .stack(SimStack::Engines), or configure \ + sim_neo() directly with a neo noise model." .to_string(), )) } diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index 694e09e2b..e82321b23 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -164,17 +164,68 @@ fn neo_stack_uniform_depolarizing_rate_matches_engines() { ); } +#[test] +fn neo_stack_general_noise_average_convention_matches() { + // The critical convention test: engines' with_average_p1_probability + // stores p1 = 1.5 x average internally (standard depolarizing + // convention), which the mapping carries one-to-one to neo. With + // average_p1 = 0.2 the effective depolarizing p1 is 0.3, so the + // outcome flip rate on a single 1q gate is 2/3 x 0.3 = 0.2 on BOTH + // stacks. A convention mismatch (double- or un-scaled) would shift + // one stack's rate to ~0.13 or ~0.3 and fail loudly. + let shots = 4000; + let expected_flip = 0.2; + let run = |stack: SimStack| { + // GeneralNoiseModel defaults are realistic (nonzero emission, prep + // leak, idle, and base probabilities); zero everything except the + // 1q Pauli channel so the physics is plain depolarizing. + let noise = pecos_engines::noise::GeneralNoiseModel::builder() + .with_average_p1_probability(0.2) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0) + .with_average_p2_probability(0.0); + sim(x_measure_qasm()) + .stack(stack) + .noise(noise) + .seed(11) + .run(shots) + .expect("run") + }; + + let engines_rate = rate_of(&run(SimStack::Engines), "0"); + let neo_rate = rate_of(&run(SimStack::Neo), "0"); + + assert!( + (engines_rate - expected_flip).abs() < 0.035, + "engines flip rate {engines_rate} should be near {expected_flip}" + ); + assert!( + (neo_rate - expected_flip).abs() < 0.035, + "neo flip rate {neo_rate} should be near {expected_flip}" + ); +} + #[test] fn neo_stack_rejects_unmapped_noise() { + // A bare GeneralNoiseModel keeps its realistic defaults (emission + // ratio 0.5, prep leak 0.5, idle 0.001) — physics beyond the simple + // Pauli subset, so the mapping must refuse rather than silently + // change the model. let general = pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1_probability(0.01); let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .noise(general) .run(5) - .expect_err("GeneralNoiseModelBuilder is not yet mapped to the neo stack"); + .expect_err("beyond-subset GeneralNoiseModel configs are not mapped"); assert!( - err.to_string().contains("not yet mapped to the neo stack"), + err.to_string() + .contains("beyond the simple probability subset"), "unexpected error: {err}" ); } From f6d6bbe95c7c21f5c1e9d48053e6cfa34d57034a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 14:53:22 -0600 Subject: [PATCH 099/388] Add engines-vs-neo stack baseline bench (neo adapter at -4% to +7% across bell/ghz/feedback/clifford cases) and fix the Clifford-angle rotation gap it exposed: neo's runner now executes rz/rx/ry/rzz/rxx/ryy at Clifford angles via the same CliffordRotation trait engines uses, in both dispatch chains --- Cargo.lock | 1 + crates/pecos/Cargo.toml | 6 ++ crates/pecos/benches/stack_comparison.rs | 113 +++++++++++++++++++++++ exp/pecos-neo/src/runner.rs | 40 +++++++- exp/pecos-neo/src/tool/simulation.rs | 32 +++++++ 5 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 crates/pecos/benches/stack_comparison.rs diff --git a/Cargo.lock b/Cargo.lock index e328efe8e..ebd5625c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3662,6 +3662,7 @@ name = "pecos" version = "0.2.0-dev.0" dependencies = [ "assert_cmd", + "criterion", "log", "pecos-core", "pecos-cppsparsestab", diff --git a/crates/pecos/Cargo.toml b/crates/pecos/Cargo.toml index 9f3232712..33d305f2c 100644 --- a/crates/pecos/Cargo.toml +++ b/crates/pecos/Cargo.toml @@ -109,6 +109,7 @@ qec = ["quantum", "dep:pecos-qec"] full = ["runtime", "all-simulators", "all-decoders", "hugr", "wasm", "qec"] [dev-dependencies] +criterion.workspace = true tempfile.workspace = true assert_cmd.workspace = true # Required for doctests @@ -148,3 +149,8 @@ required-features = ["runtime"] [lints] workspace = true + +[[bench]] +name = "stack_comparison" +harness = false +required-features = ["neo"] diff --git a/crates/pecos/benches/stack_comparison.rs b/crates/pecos/benches/stack_comparison.rs new file mode 100644 index 000000000..8c3bc30b8 --- /dev/null +++ b/crates/pecos/benches/stack_comparison.rs @@ -0,0 +1,113 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Engines-vs-neo stack baselines for the transition validation gate. +//! +//! Measures end-to-end `sim(qasm).run(shots)` (parse + build + execute) on +//! both stacks over a standard circuit set. Run with: +//! `cargo bench -p pecos --features neo --bench stack_comparison` + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use pecos::{SimStack, sim}; +use pecos_programs::Qasm; +use std::fmt::Write; + +const SHOTS: usize = 1000; + +fn bell() -> String { + r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + h q[0]; + cx q[0], q[1]; + measure q -> c; + "# + .to_string() +} + +fn ghz(n: usize) -> String { + let mut s = + format!("OPENQASM 2.0;\ninclude \"qelib1.inc\";\nqreg q[{n}];\ncreg c[{n}];\nh q[0];\n"); + for i in 1..n { + let _ = writeln!(s, "cx q[{}], q[{i}];", i - 1); + } + s.push_str("measure q -> c;\n"); + s +} + +/// Chain of measure-and-correct rounds: exercises the feedback path. +fn feedback_chain(rounds: usize) -> String { + let mut s = String::from("OPENQASM 2.0;\ninclude \"qelib1.inc\";\nqreg q[2];\n"); + for r in 0..rounds { + let _ = writeln!(s, "creg c{r}[1];"); + } + for r in 0..rounds { + let _ = writeln!( + s, + "h q[0];\nmeasure q[0] -> c{r}[0];\nif (c{r} == 1) x q[0];" + ); + } + s +} + +/// Layered Clifford circuit: H + S on every qubit, CX ladder, repeated. +fn clifford_layers(n: usize, depth: usize) -> String { + let mut s = format!("OPENQASM 2.0;\ninclude \"qelib1.inc\";\nqreg q[{n}];\ncreg c[{n}];\n"); + for _ in 0..depth { + for i in 0..n { + let _ = writeln!(s, "h q[{i}];\ns q[{i}];"); + } + for i in (0..n - 1).step_by(2) { + let _ = writeln!(s, "cx q[{}], q[{}];", i, i + 1); + } + } + s.push_str("measure q -> c;\n"); + s +} + +fn run_stack(qasm: &str, stack: SimStack, noisy: bool) { + let mut builder = sim(Qasm::from_string(qasm)).stack(stack).seed(42); + if noisy { + builder = builder.noise(pecos_engines::DepolarizingNoise { p: 0.001 }); + } + let results = builder.run(SHOTS).expect("run"); + assert_eq!(results.shots.len(), SHOTS); +} + +fn bench_stacks(c: &mut Criterion) { + let cases: Vec<(&str, String, bool)> = vec![ + ("bell", bell(), false), + ("bell_noisy", bell(), true), + ("ghz10", ghz(10), false), + ("feedback16", feedback_chain(16), false), + ("clifford_12q_x8", clifford_layers(12, 8), false), + ("clifford_12q_x8_noisy", clifford_layers(12, 8), true), + ]; + + let mut group = c.benchmark_group(format!("sim_run_{SHOTS}_shots")); + group.sample_size(10); + for (name, qasm, noisy) in &cases { + for (stack_name, stack) in [("engines", SimStack::Engines), ("neo", SimStack::Neo)] { + group.bench_with_input( + BenchmarkId::new(*name, stack_name), + &(qasm.as_str(), stack, *noisy), + |b, &(qasm, stack, noisy)| b.iter(|| run_stack(qasm, stack, noisy)), + ); + } + } + group.finish(); +} + +criterion_group!(benches, bench_stacks); +criterion_main!(benches); diff --git a/exp/pecos-neo/src/runner.rs b/exp/pecos-neo/src/runner.rs index 08094abfe..40fa9d0e7 100644 --- a/exp/pecos-neo/src/runner.rs +++ b/exp/pecos-neo/src/runner.rs @@ -1207,7 +1207,13 @@ impl CircuitRunner { || Self::try_execute_clifford(sim, gate_id, qubits) || self.rotation_executor.is_some_and(|executor| { executor(sim, gate_id, command.angles.as_slice(), qubits) - }); + }) + || Self::try_execute_clifford_rotation( + sim, + gate_id, + qubits, + command.angles.as_slice(), + ); if !executed { self.execute_via_decomposition( @@ -1393,7 +1399,8 @@ impl CircuitRunner { || Self::try_execute_clifford(sim, gate_id, qubits) || self .rotation_executor - .is_some_and(|executor| executor(sim, gate_id, angles, qubits)); + .is_some_and(|executor| executor(sim, gate_id, angles, qubits)) + || Self::try_execute_clifford_rotation(sim, gate_id, qubits, angles); if !executed { self.execute_via_decomposition(sim, gate_id, qubits, angles, depth)?; @@ -1529,6 +1536,35 @@ impl CircuitRunner { } } + /// Try to execute a rotation gate with a Clifford angle via the generic + /// `CliffordRotation` decomposition (the same path the pecos-engines + /// stack uses), so Clifford backends run e.g. QASM's `s`/`u1(pi/2)` + /// compiled to `rz(k*pi/2)` without a rotation-capable simulator. + /// Non-Clifford angles fall through to the decomposition registry. + fn try_execute_clifford_rotation( + sim: &mut S, + gate_id: GateId, + qubits: &[QubitId], + angles: &[Angle64], + ) -> bool { + use pecos_simulators::clifford_rotation::CliffordRotation; + let Some(gate_type) = gate_id.try_to_gate_type() else { + return false; + }; + let [angle] = angles else { + return false; + }; + match gate_type { + GateType::RZ => sim.try_rz(*angle, qubits).is_ok(), + GateType::RX => sim.try_rx(*angle, qubits).is_ok(), + GateType::RY => sim.try_ry(*angle, qubits).is_ok(), + GateType::RZZ => sim.try_rzz(*angle, &flat_to_pairs(qubits)).is_ok(), + GateType::RXX => sim.try_rxx(*angle, &flat_to_pairs(qubits)).is_ok(), + GateType::RYY => sim.try_ryy(*angle, &flat_to_pairs(qubits)).is_ok(), + _ => false, + } + } + /// Execute a gate via decomposition from `GateDefinitions`. fn execute_via_decomposition( &mut self, diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 822c066ec..fc682df8e 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -4987,6 +4987,38 @@ mod tests { } } + #[test] + fn test_sim_neo_clifford_angle_rotation_on_stabilizer_backend() { + // QASM compiles s -> u1(pi/2) -> rz(pi/2); Clifford backends must + // execute Clifford-angle rotations via the CliffordRotation + // decomposition instead of failing with NoDecomposition. + // h . rz(pi/2) . rz(pi/2) . h = h z h = x, so the outcome is + // deterministically 1. + use pecos_core::Angle64; + let circuit = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) + .rz(&[0], Angle64::QUARTER_TURN) + .rz(&[0], Angle64::QUARTER_TURN) + .h(&[0]) + .mz(&[0]) + .build(); + + let results = sim_neo(circuit) + .quantum(sparse_stab()) + .sampling(monte_carlo(10)) + .seed(1) + .run(); + + for outcome in &results.outcomes { + assert_eq!( + outcome.get_bit(QubitId(0)), + Some(true), + "h z h = x must deterministically flip" + ); + } + } + // --- Shot/ShotVec Production Tests --- #[test] From 684689f6a2815ddf208ebd61564b7fcf12978e73 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 15:25:13 -0600 Subject: [PATCH 100/388] Fix bug-audit rough edges: specific NonCliffordAngle error on Clifford backends, bounded subset Python-callable abort cost, sigma-based bands replacing flat tolerances in noise comparison tests --- exp/pecos-neo/src/runner.rs | 118 ++++++++++++++---- exp/pecos-neo/tests/noise_comparison_test.rs | 73 ++++++----- .../pecos-rslib-exp/src/sim_neo_bindings.rs | 19 ++- 3 files changed, 159 insertions(+), 51 deletions(-) diff --git a/exp/pecos-neo/src/runner.rs b/exp/pecos-neo/src/runner.rs index 40fa9d0e7..a68251a55 100644 --- a/exp/pecos-neo/src/runner.rs +++ b/exp/pecos-neo/src/runner.rs @@ -591,6 +591,9 @@ impl GateOverrides { pub enum ExecutionError { /// No decomposition found for a gate. NoDecomposition { gate_id: GateId }, + /// A rotation gate with a non-Clifford angle reached a Clifford-only + /// backend with no rotation support and no registered decomposition. + NonCliffordAngle { gate_id: GateId }, /// Maximum decomposition depth exceeded (possible infinite recursion). MaxDecompositionDepthExceeded, } @@ -601,6 +604,15 @@ impl std::fmt::Display for ExecutionError { Self::NoDecomposition { gate_id } => { write!(f, "No decomposition found for gate ID {}", gate_id.0) } + Self::NonCliffordAngle { gate_id } => { + write!( + f, + "Rotation gate ID {} has a non-Clifford angle, which this Clifford-only \ + backend cannot execute; use a rotation-capable backend such as \ + state_vector()", + gate_id.0 + ) + } Self::MaxDecompositionDepthExceeded => { write!(f, "Maximum decomposition depth exceeded") } @@ -610,6 +622,32 @@ impl std::fmt::Display for ExecutionError { impl std::error::Error for ExecutionError {} +/// Outcome of the Clifford-rotation execution attempt. +#[derive(Clone, Copy, PartialEq, Eq)] +enum CliffordRotationAttempt { + /// The gate executed via Clifford-angle decomposition. + Executed, + /// The gate is a rotation but the angle is not Clifford. + NonCliffordAngle, + /// The gate is not a rotation this path handles. + NotARotation, +} + +/// Upgrade a missing-decomposition error to the more specific +/// non-Clifford-angle error when that is the actual cause. +fn upgrade_rotation_error( + error: ExecutionError, + rotation_attempt: CliffordRotationAttempt, +) -> ExecutionError { + match (error, rotation_attempt) { + ( + ExecutionError::NoDecomposition { gate_id }, + CliffordRotationAttempt::NonCliffordAngle, + ) => ExecutionError::NonCliffordAngle { gate_id }, + (error, _) => error, + } +} + /// Stateless quantum simulation runner. /// /// Applies noise and circuits to a simulator state, producing measurement outcomes. @@ -1202,18 +1240,22 @@ impl CircuitRunner { } // Execute through unified precedence chain + let mut rotation_attempt = CliffordRotationAttempt::NotARotation; let executed = self.try_execute_override(sim, gate_id, qubits, command.angles.as_slice()) || Self::try_execute_clifford(sim, gate_id, qubits) || self.rotation_executor.is_some_and(|executor| { executor(sim, gate_id, command.angles.as_slice(), qubits) }) - || Self::try_execute_clifford_rotation( - sim, - gate_id, - qubits, - command.angles.as_slice(), - ); + || { + rotation_attempt = Self::try_execute_clifford_rotation( + sim, + gate_id, + qubits, + command.angles.as_slice(), + ); + rotation_attempt == CliffordRotationAttempt::Executed + }; if !executed { self.execute_via_decomposition( @@ -1222,7 +1264,8 @@ impl CircuitRunner { qubits, command.angles.as_slice(), 0, - )?; + ) + .map_err(|e| upgrade_rotation_error(e, rotation_attempt))?; } self.dispatch_after_gate_for_id( @@ -1395,15 +1438,21 @@ impl CircuitRunner { } // Try execution in order of precedence + let mut rotation_attempt = CliffordRotationAttempt::NotARotation; let executed = self.try_execute_override(sim, gate_id, qubits, angles) || Self::try_execute_clifford(sim, gate_id, qubits) || self .rotation_executor .is_some_and(|executor| executor(sim, gate_id, angles, qubits)) - || Self::try_execute_clifford_rotation(sim, gate_id, qubits, angles); + || { + rotation_attempt = + Self::try_execute_clifford_rotation(sim, gate_id, qubits, angles); + rotation_attempt == CliffordRotationAttempt::Executed + }; if !executed { - self.execute_via_decomposition(sim, gate_id, qubits, angles, depth)?; + self.execute_via_decomposition(sim, gate_id, qubits, angles, depth) + .map_err(|e| upgrade_rotation_error(e, rotation_attempt))?; } // Emit after-gate noise event @@ -1540,28 +1589,34 @@ impl CircuitRunner { /// `CliffordRotation` decomposition (the same path the pecos-engines /// stack uses), so Clifford backends run e.g. QASM's `s`/`u1(pi/2)` /// compiled to `rz(k*pi/2)` without a rotation-capable simulator. - /// Non-Clifford angles fall through to the decomposition registry. + /// Non-Clifford angles fall through to the decomposition registry; + /// the attempt outcome distinguishes them so the final error can point + /// at a rotation-capable backend instead of a missing decomposition. fn try_execute_clifford_rotation( sim: &mut S, gate_id: GateId, qubits: &[QubitId], angles: &[Angle64], - ) -> bool { + ) -> CliffordRotationAttempt { use pecos_simulators::clifford_rotation::CliffordRotation; let Some(gate_type) = gate_id.try_to_gate_type() else { - return false; + return CliffordRotationAttempt::NotARotation; }; let [angle] = angles else { - return false; + return CliffordRotationAttempt::NotARotation; }; - match gate_type { - GateType::RZ => sim.try_rz(*angle, qubits).is_ok(), - GateType::RX => sim.try_rx(*angle, qubits).is_ok(), - GateType::RY => sim.try_ry(*angle, qubits).is_ok(), - GateType::RZZ => sim.try_rzz(*angle, &flat_to_pairs(qubits)).is_ok(), - GateType::RXX => sim.try_rxx(*angle, &flat_to_pairs(qubits)).is_ok(), - GateType::RYY => sim.try_ryy(*angle, &flat_to_pairs(qubits)).is_ok(), - _ => false, + let result = match gate_type { + GateType::RZ => sim.try_rz(*angle, qubits).map(|_| ()), + GateType::RX => sim.try_rx(*angle, qubits).map(|_| ()), + GateType::RY => sim.try_ry(*angle, qubits).map(|_| ()), + GateType::RZZ => sim.try_rzz(*angle, &flat_to_pairs(qubits)).map(|_| ()), + GateType::RXX => sim.try_rxx(*angle, &flat_to_pairs(qubits)).map(|_| ()), + GateType::RYY => sim.try_ryy(*angle, &flat_to_pairs(qubits)).map(|_| ()), + _ => return CliffordRotationAttempt::NotARotation, + }; + match result { + Ok(()) => CliffordRotationAttempt::Executed, + Err(_) => CliffordRotationAttempt::NonCliffordAngle, } } @@ -3058,6 +3113,27 @@ mod tests { )); } + #[test] + fn test_non_clifford_angle_gets_specific_error() { + // rz with an arbitrary angle on a Clifford-only backend must point + // the user at a rotation-capable backend, not at a missing + // decomposition. + let circuit = crate::command::CommandBuilder::new() + .pz(&[0]) + .rz(&[0], Angle64::from_radians(0.123)) + .mz(&[0]) + .build(); + + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::new(); + + let err = runner + .apply_circuit(&mut state, &circuit) + .expect_err("non-Clifford angle must error"); + assert!(matches!(err, ExecutionError::NonCliffordAngle { .. })); + assert!(err.to_string().contains("state_vector()")); + } + // --- apply_gate (interpreter mode) tests --- #[test] diff --git a/exp/pecos-neo/tests/noise_comparison_test.rs b/exp/pecos-neo/tests/noise_comparison_test.rs index ae282640e..8e4d3c7d7 100644 --- a/exp/pecos-neo/tests/noise_comparison_test.rs +++ b/exp/pecos-neo/tests/noise_comparison_test.rs @@ -26,8 +26,12 @@ use pecos_neo::prelude::*; use pecos_simulators::SparseStab; use std::collections::BTreeMap; -const NUM_SHOTS: usize = 5000; -const TOLERANCE_PERCENT: f64 = 5.0; // Allow 5% difference in error rates +const NUM_SHOTS: usize = 20_000; +/// Comparison band in standard deviations. Flat percentage tolerances +/// previously masked a real physics difference (`GeneralNoiseModel`'s default +/// emission ratio shifting flip rates from 0.20 to 0.16 — inside a 5pp +/// band); sigma-based bands scale with the statistics instead. +const K_SIGMA: f64 = 5.0; /// Run a circuit with `GeneralNoiseModel` and count results. fn run_general_noise_model( @@ -119,9 +123,27 @@ fn outcome_percentage(counts: &BTreeMap, outcome: &str, total: us (count as f64 / total as f64) * 100.0 } -/// Compare two error rates and check if they're within tolerance. -fn rates_match(rate1: f64, rate2: f64, tolerance: f64) -> bool { - (rate1 - rate2).abs() <= tolerance +/// `K_SIGMA` band in percentage points for comparing two empirical rates +/// from independent `NUM_SHOTS`-shot binomial samples. A 0.1pp floor guards +/// degenerate zero-variance cases (rates at exactly 0% or 100%). +fn sigma_band_pct(rate1_pct: f64, rate2_pct: f64) -> f64 { + let p1 = rate1_pct / 100.0; + let p2 = rate2_pct / 100.0; + let var = (p1 * (1.0 - p1) + p2 * (1.0 - p2)) / NUM_SHOTS as f64; + (K_SIGMA * var.sqrt() * 100.0).max(0.1) +} + +/// Compare two empirical error rates within `K_SIGMA` of binomial noise. +fn rates_match(rate1: f64, rate2: f64) -> bool { + (rate1 - rate2).abs() <= sigma_band_pct(rate1, rate2) +} + +/// Compare an empirical rate against an analytic expectation within +/// `K_SIGMA` of binomial noise. +fn rate_matches_expected(rate_pct: f64, expected_pct: f64) -> bool { + let p = expected_pct / 100.0; + let band = (K_SIGMA * (p * (1.0 - p) / NUM_SHOTS as f64).sqrt() * 100.0).max(0.1); + (rate_pct - expected_pct).abs() <= band } #[test] @@ -185,8 +207,8 @@ fn test_single_qubit_depolarizing_comparison() { // So ~2/3 of errors result in |0⟩, ~1/3 result in |1⟩ assert!( - rates_match(general_zero, composable_zero, TOLERANCE_PERCENT), - "Error rates should match within {TOLERANCE_PERCENT}%: general={general_zero:.1}%, composable={composable_zero:.1}%" + rates_match(general_zero, composable_zero), + "Error rates should match within {K_SIGMA} sigma: general={general_zero:.1}%, composable={composable_zero:.1}%" ); } @@ -258,8 +280,8 @@ fn test_two_qubit_depolarizing_comparison() { println!(" ComposableNoiseModel: {composable_error:.1}% errors"); assert!( - rates_match(general_error, composable_error, TOLERANCE_PERCENT), - "Error rates should match within {TOLERANCE_PERCENT}%: general={general_error:.1}%, composable={composable_error:.1}%" + rates_match(general_error, composable_error), + "Error rates should match within {K_SIGMA} sigma: general={general_error:.1}%, composable={composable_error:.1}%" ); } @@ -311,18 +333,18 @@ fn test_measurement_error_comparison() { // Both should be close to 10% assert!( - rates_match(general_one, composable_one, TOLERANCE_PERCENT), - "Measurement error rates should match within {TOLERANCE_PERCENT}%: general={general_one:.1}%, composable={composable_one:.1}%" + rates_match(general_one, composable_one), + "Measurement error rates should match within {K_SIGMA} sigma: general={general_one:.1}%, composable={composable_one:.1}%" ); // Also verify they're close to expected value assert!( - (general_one - p_meas_0 * 100.0).abs() < TOLERANCE_PERCENT, + rate_matches_expected(general_one, p_meas_0 * 100.0), "GeneralNoiseModel measurement error rate should be close to {}: got {general_one:.1}%", p_meas_0 * 100.0 ); assert!( - (composable_one - p_meas_0 * 100.0).abs() < TOLERANCE_PERCENT, + rate_matches_expected(composable_one, p_meas_0 * 100.0), "ComposableNoiseModel measurement error rate should be close to {}: got {composable_one:.1}%", p_meas_0 * 100.0 ); @@ -377,8 +399,8 @@ fn test_preparation_error_comparison() { println!(" ComposableNoiseModel: {composable_one:.1}% |1⟩ (errors)"); assert!( - rates_match(general_one, composable_one, TOLERANCE_PERCENT), - "Preparation error rates should match within {TOLERANCE_PERCENT}%: general={general_one:.1}%, composable={composable_one:.1}%" + rates_match(general_one, composable_one), + "Preparation error rates should match within {K_SIGMA} sigma: general={general_one:.1}%, composable={composable_one:.1}%" ); } @@ -461,13 +483,8 @@ fn test_combined_noise_comparison() { // The correlated outcome rate should be similar assert!( - rates_match( - general_correlated, - composable_correlated, - TOLERANCE_PERCENT * 2.0 - ), - "Correlated rates should match within {}%: general={general_correlated:.1}%, composable={composable_correlated:.1}%", - TOLERANCE_PERCENT * 2.0 + rates_match(general_correlated, composable_correlated), + "Correlated rates should match within {K_SIGMA} sigma: general={general_correlated:.1}%, composable={composable_correlated:.1}%" ); } @@ -539,7 +556,7 @@ fn test_general_noise_model_builder_comparison() { println!(" pecos-neo GeneralNoiseModelBuilder: {composable_11:.1}% |11⟩"); assert!( - rates_match(general_11, composable_11, TOLERANCE_PERCENT * 2.0), + rates_match(general_11, composable_11), "Builder should produce equivalent results: general={general_11:.1}%, builder={composable_11:.1}%" ); } @@ -602,11 +619,11 @@ fn test_idle_noise_with_time_scale() { println!(" T1=10us, T2=5us, idle=1us"); println!(" Error rate: {error_rate:.1}% (expected ~10% from linear/T1 dephasing)"); - // With T1=10us and idle=1us, linear rate gives ~10% error probability - // (quadratic rate is negligible at this scale) - // Allow for statistical variation + // Analytic expectation: linear_rate = 1/T1 = 1e-4/ns, so 1000 ns idle + // gives p = 0.1 exactly (Z-only weights, detected via H basis). The + // quadratic T2 term contributes sin^2(4e-5) ~ 1.6e-9, negligible. assert!( - error_rate > 5.0 && error_rate < 20.0, - "Error rate {error_rate:.1}% should be in reasonable range for T1/T2 dephasing" + rate_matches_expected(error_rate, 10.0), + "Error rate {error_rate:.1}% should be within {K_SIGMA} sigma of the analytic 10% T1 dephasing rate" ); } diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 9a05d97bb..d9e7ff6b5 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -1035,15 +1035,26 @@ impl PySimNeoBuilder { // Bridge Python callables into Fn closures. Errors cannot propagate // through the closure signature, so capture the first one and check - // after the run. + // after the run. After an error, the closures stop calling Python + // and report every sample as a failure, which trips the algorithm's + // all-samples-failed termination at the end of the current level — + // bounding the wasted work without changing the library API. let captured_err: Arc>> = Arc::new(Mutex::new(None)); let bits_of = |outcomes: &MeasurementOutcomes| -> Vec { outcomes.iter().map(|o| u8::from(o.outcome)).collect() }; + let has_err = |slot: &Arc>>| { + slot.lock() + .expect("subset callable error slot poisoned") + .is_some() + }; let score_fn = score.clone_ref(py); let score_err = Arc::clone(&captured_err); let score_closure = move |outcomes: &MeasurementOutcomes| -> f64 { + if has_err(&score_err) { + return 0.0; + } Python::attach(|py| { match score_fn .call1(py, (bits_of(outcomes),)) @@ -1064,6 +1075,10 @@ impl PySimNeoBuilder { let failure_fn = failure.clone_ref(py); let failure_err = Arc::clone(&captured_err); let failure_closure = move |outcomes: &MeasurementOutcomes| -> bool { + if has_err(&failure_err) { + // Steer the run to its all-failed termination condition. + return true; + } Python::attach(|py| { match failure_fn .call1(py, (bits_of(outcomes),)) @@ -1075,7 +1090,7 @@ impl PySimNeoBuilder { .lock() .expect("subset callable error slot poisoned") .get_or_insert(e); - false + true } } }) From b58771a2ee3db44bf69e7e9a53d49dd0db0daff3 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 11 Jun 2026 21:20:30 -0600 Subject: [PATCH 101/388] Add biased single-qubit DEM noise support --- .../src/fault_tolerance_bindings.rs | 55 +++++++++++++++++-- python/quantum-pecos/src/pecos/qec/dem.py | 8 ++- .../src/pecos/qec/surface/circuit_builder.py | 7 ++- .../src/pecos/qec/surface/decode.py | 31 ++++++++++- .../tests/qec/test_from_guppy_dem.py | 30 ++++++++++ 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 021316745..7813d1d88 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -73,6 +73,37 @@ use std::collections::BTreeMap; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); +fn parse_p1_weights(weights: BTreeMap) -> PyResult { + use pecos_core::pauli::{X, Y, Z}; + + let mut entries = Vec::with_capacity(weights.len()); + let mut sum = 0.0; + for (label, weight) in weights { + let label = label.trim().to_ascii_uppercase(); + let pauli = match label.as_str() { + "X" => X(0), + "Y" => Y(0), + "Z" => Z(0), + _ => { + let msg = format!("p1_weights keys must be one of ['X', 'Y', 'Z'], got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + }; + if !weight.is_finite() || weight < 0.0 { + let msg = + format!("p1_weights[{label:?}] must be finite and non-negative, got {weight}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + sum += weight; + entries.push((pauli, weight)); + } + if (sum - 1.0).abs() >= 1.0e-6 { + let msg = format!("p1_weights relative probabilities must sum to 1.0, got {sum}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + Ok(PauliWeights::new(entries)) +} + fn parse_p2_weights(weights: BTreeMap) -> PyResult { use pecos_core::pauli::{X, Y, Z}; @@ -242,6 +273,7 @@ fn apply_noise_options( p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -280,6 +312,9 @@ fn apply_noise_options( if let Some(rate) = p_idle_z_quadratic_rate { noise.p_idle_quadratic_rate = rate.max(0.0); } + if let Some(weights) = p1_weights { + noise = noise.set_p1_weights(parse_p1_weights(weights)?); + } if let Some(weights) = p2_weights { noise = noise.set_p2_weights(parse_p2_weights(weights)?); } @@ -1201,7 +1236,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1221,6 +1256,7 @@ impl PyDetectorErrorModel { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -1244,6 +1280,7 @@ impl PyDetectorErrorModel { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, @@ -1601,7 +1638,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1621,6 +1658,7 @@ impl PyDemBuilder { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -1642,6 +1680,7 @@ impl PyDemBuilder { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, @@ -3510,7 +3549,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3530,6 +3569,7 @@ impl PyDemSampler { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -3551,6 +3591,7 @@ impl PyDemSampler { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, @@ -3667,7 +3708,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3689,6 +3730,7 @@ impl PyDemSampler { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -3710,6 +3752,7 @@ impl PyDemSampler { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, @@ -4169,7 +4212,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4189,6 +4232,7 @@ impl PyDemSamplerBuilder { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, @@ -4210,6 +4254,7 @@ impl PyDemSamplerBuilder { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p1_weights, p2_weights, p2_replacement_approximation, p_meas_crosstalk_local, diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index e4f1a9a46..002a33757 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -41,6 +41,7 @@ from pecos_rslib.qec import DetectorErrorModel as _RustDetectorErrorModel +P1Weights = Mapping[str, float] P2Weights = Mapping[str, float] @@ -59,6 +60,7 @@ def from_guppy( observables_json: str = "[]", num_measurements: int | None = None, p1: float = 0.001, + p1_weights: P1Weights | None = None, p2: float = 0.01, p2_weights: P2Weights | None = None, p2_replacement_approximation: str | None = None, @@ -142,7 +144,10 @@ def from_guppy( num_measurements: Total measurement count, used to resolve negative ``records`` offsets. If omitted, it is inferred from the traced circuit; if given, it must match the traced count. - p1: Single-qubit gate depolarizing rate. + p1: Single-qubit gate Pauli error rate. + p1_weights: Optional relative probabilities over single-qubit + Pauli error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must + sum to 1.0; ``p1`` remains the total single-qubit error rate. p2: Two-qubit gate depolarizing rate. p2_weights: Optional relative probabilities over two-qubit Pauli error labels. Plain labels such as ``"XX"`` are post-gate @@ -272,6 +277,7 @@ def from_guppy( return _RustDetectorErrorModel.from_circuit( tc, p1=p1, + p1_weights=p1_weights, p2=p2, p2_weights=p2_weights, p2_replacement_approximation=p2_replacement_approximation, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 1eb5245d9..433c6372a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2525,6 +2525,7 @@ def generate_dem_from_tick_circuit( tc: TickCircuit, *, p1: float = 0.01, + p1_weights: Mapping[str, float] | None = None, p2: float = 0.01, p2_weights: Mapping[str, float] | None = None, p_meas: float = 0.01, @@ -2563,7 +2564,10 @@ def generate_dem_from_tick_circuit( Args: tc: TickCircuit with detector/observable metadata (required) - p1: Single-qubit depolarizing error rate + p1: Single-qubit Pauli error rate + p1_weights: Optional relative probabilities over single-qubit Pauli + errors (``X``, ``Y``, ``Z``). Values must sum to 1.0; ``p1`` + remains the total single-qubit error rate. p2: Two-qubit depolarizing error rate p2_weights: Optional relative probabilities over the 15 non-identity two-qubit Pauli errors (``IX`` through ``ZZ``). Values must sum to @@ -2623,6 +2627,7 @@ def generate_dem_from_tick_circuit( p2, p_meas, p_prep, + p1_weights=p1_weights, p2_weights=p2_weights, p_idle=p_idle, t1=t1, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 927aa6913..55f5058b8 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -58,6 +58,7 @@ from pecos.qec.surface.patch import Stabilizer, SurfacePatch +P1Weights = Mapping[str, float] | Sequence[tuple[str, float]] P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] @@ -90,6 +91,9 @@ class NoiseModel: Attributes: p1: Single-qubit gate error rate. + p1_weights: Optional relative probabilities over single-qubit Pauli + error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must sum to + 1.0; ``p1`` remains the total single-qubit error rate. p2: Two-qubit gate error rate. p2_weights: Optional relative probabilities over two-qubit Pauli error labels. Plain labels such as ``"XX"`` are post-gate Pauli branches; @@ -122,6 +126,7 @@ class NoiseModel: """ p1: float = 0.0 + p1_weights: P1Weights | None = None p2: float = 0.0 p2_weights: P2Weights | None = None p2_replacement_approximation: str | None = None @@ -141,6 +146,7 @@ class NoiseModel: def __post_init__(self) -> None: """Normalize cache-sensitive inputs after dataclass initialization.""" + self.p1_weights = _normalize_p1_weights(self.p1_weights) self.p2_weights = _normalize_p2_weights(self.p2_weights) @property @@ -193,13 +199,26 @@ def physical_error_rate(self) -> float: return max(rates) -def _normalize_p2_weights(p2_weights: P2Weights | None) -> tuple[tuple[str, float], ...] | None: - if p2_weights is None: +def _normalize_pauli_weights(weights: P1Weights | P2Weights | None) -> tuple[tuple[str, float], ...] | None: + if weights is None: return None - items = p2_weights.items() if isinstance(p2_weights, Mapping) else p2_weights + items = weights.items() if isinstance(weights, Mapping) else weights return tuple(sorted((str(label).upper(), float(weight)) for label, weight in items)) +def _normalize_p1_weights(p1_weights: P1Weights | None) -> tuple[tuple[str, float], ...] | None: + return _normalize_pauli_weights(p1_weights) + + +def _p1_weights_dict(p1_weights: P1Weights | None) -> dict[str, float] | None: + normalized = _normalize_p1_weights(p1_weights) + return None if normalized is None else dict(normalized) + + +def _normalize_p2_weights(p2_weights: P2Weights | None) -> tuple[tuple[str, float], ...] | None: + return _normalize_pauli_weights(p2_weights) + + def _p2_weights_dict(p2_weights: P2Weights | None) -> dict[str, float] | None: normalized = _normalize_p2_weights(p2_weights) return None if normalized is None else dict(normalized) @@ -1554,6 +1573,7 @@ def _dem_string_from_cached_surface_topology( "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, + "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } if noise.p2_replacement_approximation is not None: @@ -1595,6 +1615,7 @@ def _cached_surface_native_dem_string( ancilla_budget: int | None, circuit_source: Literal["abstract", "traced_qis"], p1: float, + p1_weights: tuple[tuple[str, float], ...] | None, p2: float, p_meas: float, p_prep: float, @@ -1639,6 +1660,7 @@ def _cached_surface_native_dem_string( topology, NoiseModel( p1=p1, + p1_weights=p1_weights, p2=p2, p2_weights=p2_weights, p2_replacement_approximation=p2_replacement_approximation, @@ -1709,6 +1731,7 @@ def _build_native_sampler_from_cached_surface_topology( p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p1_weights=_p1_weights_dict(noise.p1_weights), p2_weights=_p2_weights_dict(noise.p2_weights), p2_replacement_approximation=noise.p2_replacement_approximation, ) @@ -1813,6 +1836,7 @@ def generate_circuit_level_dem_from_builder( ancilla_budget, circuit_source, noise.p1, + noise.p1_weights, noise.p2, noise.p_meas, noise.p_prep, @@ -3531,6 +3555,7 @@ def build_native_sampler( ancilla_budget, circuit_source, noise.p1, + noise.p1_weights, noise.p2, noise.p_meas, noise.p_prep, diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index ba6b1d623..4b76c3acd 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -358,6 +358,36 @@ def test_from_circuit_accepts_biased_p2_weights() -> None: assert dem.num_contributions > 0 +def test_from_circuit_accepts_biased_p1_weights() -> None: + from pecos.qec import DetectorErrorModel + + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 0]}}], + "lowered_quantum_ops": [ + {"gate_type": "PZ", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "H", "qubits": [0], "angles": [], "params": []}, + {"gate_type": "MZ", "qubits": [0], "angles": [], "params": [], "measurement_result_ids": [0]}, + ], + }, + ] + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + tc.set_meta("detectors", '[{"id": 0, "records": [-1]}]') + tc.set_meta("observables", "[]") + tc.set_meta("num_measurements", "1") + + dem = DetectorErrorModel.from_circuit( + tc, + p1=0.01, + p1_weights={"X": 1.0, "Y": 0.0, "Z": 0.0}, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + ) + + assert dem.num_contributions > 0 + + def test_reject_partially_lowered_trace_passes_on_uniformly_lowered() -> None: """A trace where every quantum-carrying chunk is also lowered is accepted (this is the real Selene shape; the byte-identical regressions exercise it From 4bf34e9e5036229275a0325544fbafca6a1be304 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 21:30:24 -0600 Subject: [PATCH 102/388] Add V5 surface-code d=3/d=5 LER equivalence test across engines/neo stacks with Jeffreys intervals (new pecos-num special functions: ln_gamma, betainc, inverse), plus dev-profile opt-level overrides for sim-hot crates --- Cargo.lock | 2 + Cargo.toml | 23 ++ crates/pecos-num/src/lib.rs | 4 +- crates/pecos-num/src/prelude.rs | 7 +- crates/pecos-num/src/special.rs | 394 +++++++++++++++++++ crates/pecos-num/src/stats.rs | 152 ++++++++ crates/pecos/Cargo.toml | 10 + crates/pecos/tests/neo_surface_ler_test.rs | 434 +++++++++++++++++++++ 8 files changed, 1023 insertions(+), 3 deletions(-) create mode 100644 crates/pecos-num/src/special.rs create mode 100644 crates/pecos/tests/neo_surface_ler_test.rs diff --git a/Cargo.lock b/Cargo.lock index ebd5625c7..d09cd2909 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3666,8 +3666,10 @@ dependencies = [ "log", "pecos-core", "pecos-cppsparsestab", + "pecos-decoder-core", "pecos-decoders", "pecos-engines", + "pecos-fusion-blossom", "pecos-hugr", "pecos-hugr-qis", "pecos-llvm", diff --git a/Cargo.toml b/Cargo.toml index 649f9d40b..77c58c845 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -264,3 +264,26 @@ multiple-crate-versions = "allow" similar-names = "allow" many-single-char-names = "allow" too-many-lines = "allow" # ~114 hits across 11 crates -- sim algorithms, tests, benchmarks + +# Simulation-heavy crates run at opt-level 2 even in dev/test builds: +# statistical validation tests (e.g. the surface-code LER equivalence +# gate) need tens of thousands of simulated shots and are unusably slow +# unoptimized (>10 min vs ~2 min). Debug assertions remain enabled. +[profile.dev.package.pecos-core] +opt-level = 2 +[profile.dev.package.pecos-random] +opt-level = 2 +[profile.dev.package.pecos-simulators] +opt-level = 2 +[profile.dev.package.pecos-engines] +opt-level = 2 +[profile.dev.package.pecos-neo] +opt-level = 2 +[profile.dev.package.pecos-qasm] +opt-level = 2 +[profile.dev.package.pecos-qec] +opt-level = 2 +[profile.dev.package.pecos-fusion-blossom] +opt-level = 2 +[profile.dev.package.fusion-blossom] +opt-level = 2 diff --git a/crates/pecos-num/src/lib.rs b/crates/pecos-num/src/lib.rs index 3976769be..538ef4e85 100644 --- a/crates/pecos-num/src/lib.rs +++ b/crates/pecos-num/src/lib.rs @@ -45,6 +45,7 @@ pub mod optimize; pub mod polynomial; pub mod prelude; pub mod random; +pub mod special; pub mod stats; pub mod z2_linalg; @@ -56,4 +57,5 @@ pub use graph::Graph; pub use linalg::{matrix_exp, matrix_log}; pub use optimize::{BrentqOptions, NewtonOptions, OptimizeError, brentq, newton}; pub use polynomial::{Poly1d, PolynomialError, polyfit}; -pub use stats::mean; +pub use special::{betainc_inv, betainc_reg, ln_gamma}; +pub use stats::{jeffreys_interval, mean}; diff --git a/crates/pecos-num/src/prelude.rs b/crates/pecos-num/src/prelude.rs index b8e0044bb..2dafd93d5 100644 --- a/crates/pecos-num/src/prelude.rs +++ b/crates/pecos-num/src/prelude.rs @@ -31,10 +31,13 @@ pub use crate::random; // Re-export statistical functions pub use crate::stats::{ - jackknife_resamples, jackknife_stats, jackknife_stats_axis, jackknife_weighted, mean, - mean_axis, std, std_axis, weighted_mean, + jackknife_resamples, jackknife_stats, jackknife_stats_axis, jackknife_weighted, + jeffreys_interval, mean, mean_axis, std, std_axis, weighted_mean, }; +// Re-export special functions +pub use crate::special::{betainc_inv, betainc_reg, ln_gamma}; + // Re-export mathematical traits (use these for polymorphism!) pub use crate::math::{ Abs, Acos, Acosh, Asin, Asinh, Atan, Atan2, Atanh, Ceil, Cos, Cosh, Exp, Floor, Ln, LogBase, diff --git a/crates/pecos-num/src/special.rs b/crates/pecos-num/src/special.rs new file mode 100644 index 000000000..dc3b4e9bc --- /dev/null +++ b/crates/pecos-num/src/special.rs @@ -0,0 +1,394 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Special functions: log-gamma and the regularized incomplete beta +//! function with its inverse. +//! +//! These are the primitives behind Beta-distribution quantiles, which in +//! turn back the Jeffreys binomial interval in [`crate::stats`]. +//! +//! Algorithms follow Numerical Recipes, 3rd edition, section 6.1 +//! (Lanczos log-gamma) and section 6.4 (Lentz continued fraction for the +//! incomplete beta; Halley iteration with the Abramowitz & Stegun 26.5.22 +//! initial guess for the inverse). Differential tests against `SciPy` +//! reference values live in the test module below. + +/// Convergence threshold for the inverse incomplete beta Halley iteration. +const BETAINC_INV_EPS: f64 = 1.0e-8; +/// Maximum iterations for the incomplete-beta continued fraction. +const BETACF_MAX_ITER: u32 = 10_000; +/// Convergence threshold for the continued fraction. +const BETACF_EPS: f64 = 3.0e-14; +/// Smallest representable ratio guard for Lentz's method. +const BETACF_FPMIN: f64 = f64::MIN_POSITIVE / BETACF_EPS; + +/// Natural logarithm of the gamma function for `x > 0`. +/// +/// Drop-in replacement for `scipy.special.gammaln` on the positive real +/// axis. Uses the 14-term Lanczos approximation (Numerical Recipes 3rd +/// ed., section 6.1), accurate to roughly machine precision. +/// +/// Returns NaN for `x <= 0`. +/// +/// # Examples +/// +/// ``` +/// use pecos_num::special::ln_gamma; +/// +/// // Gamma(1) = 1, Gamma(2) = 1 +/// assert!(ln_gamma(1.0).abs() < 1e-14); +/// assert!(ln_gamma(2.0).abs() < 1e-14); +/// // Gamma(0.5) = sqrt(pi) +/// let half = std::f64::consts::PI.sqrt().ln(); +/// assert!((ln_gamma(0.5) - half).abs() < 1e-14); +/// ``` +#[must_use] +pub fn ln_gamma(x: f64) -> f64 { + const COF: [f64; 14] = [ + 57.156_235_665_862_92, + -59.597_960_355_475_49, + 14.136_097_974_741_746, + -0.491_913_816_097_620_2, + 3.399_464_998_481_189e-5, + 4.652_362_892_704_858e-5, + -9.837_447_530_487_956e-5, + 1.580_887_032_249_125e-4, + -2.102_644_417_241_049e-4, + 2.174_396_181_152_126e-4, + -1.643_181_065_367_639e-4, + 8.441_822_398_385_274e-5, + -2.619_083_840_158_141e-5, + 3.689_918_265_953_162e-6, + ]; + const LANCZOS_G: f64 = 5.242_187_5; + const SQRT_2PI: f64 = 2.506_628_274_631_000_5; + + if x <= 0.0 { + return f64::NAN; + } + + let mut denom = x; + let tmp = x + LANCZOS_G; + let tmp = (x + 0.5) * tmp.ln() - tmp; + let mut series = 0.999_999_999_999_997_1; + for c in COF { + denom += 1.0; + series += c / denom; + } + tmp + (SQRT_2PI * series / x).ln() +} + +/// Continued-fraction evaluation for the incomplete beta function +/// (Numerical Recipes 3rd ed., section 6.4, via Lentz's method). +fn betacf(a: f64, b: f64, x: f64) -> f64 { + let qab = a + b; + let qap = a + 1.0; + let qam = a - 1.0; + let mut c = 1.0; + let mut d = 1.0 - qab * x / qap; + if d.abs() < BETACF_FPMIN { + d = BETACF_FPMIN; + } + d = 1.0 / d; + let mut h = d; + + for m in 1..=BETACF_MAX_ITER { + let m = f64::from(m); + let m2 = 2.0 * m; + + // Even step of the continued fraction. + let aa = m * (b - m) * x / ((qam + m2) * (a + m2)); + d = 1.0 + aa * d; + if d.abs() < BETACF_FPMIN { + d = BETACF_FPMIN; + } + c = 1.0 + aa / c; + if c.abs() < BETACF_FPMIN { + c = BETACF_FPMIN; + } + d = 1.0 / d; + h *= d * c; + + // Odd step. + let aa = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2)); + d = 1.0 + aa * d; + if d.abs() < BETACF_FPMIN { + d = BETACF_FPMIN; + } + c = 1.0 + aa / c; + if c.abs() < BETACF_FPMIN { + c = BETACF_FPMIN; + } + d = 1.0 / d; + let del = d * c; + h *= del; + + if (del - 1.0).abs() <= BETACF_EPS { + return h; + } + } + // The fraction converges for all valid inputs; reaching the iteration + // cap means the inputs were extreme enough that the result is unusable. + f64::NAN +} + +/// Regularized incomplete beta function `I_x(a, b)` for `a, b > 0` and +/// `x` in `[0, 1]`. +/// +/// Drop-in replacement for `scipy.special.betainc`. This is also the CDF +/// of the Beta(a, b) distribution evaluated at `x`. +/// +/// Returns NaN outside the valid domain. +/// +/// # Examples +/// +/// ``` +/// use pecos_num::special::betainc_reg; +/// +/// // Symmetric case: I_{0.5}(a, a) = 0.5 +/// assert!((betainc_reg(10.0, 10.0, 0.5) - 0.5).abs() < 1e-12); +/// // Boundaries +/// assert_eq!(betainc_reg(2.0, 3.0, 0.0), 0.0); +/// assert_eq!(betainc_reg(2.0, 3.0, 1.0), 1.0); +/// ``` +#[must_use] +pub fn betainc_reg(a: f64, b: f64, x: f64) -> f64 { + let domain_ok = a > 0.0 && b > 0.0 && (0.0..=1.0).contains(&x); + if !domain_ok { + return f64::NAN; + } + if x <= 0.0 { + return 0.0; + } + if x >= 1.0 { + return 1.0; + } + + // Prefactor x^a (1-x)^b / (a B(a,b)), computed in log space. + let ln_front = ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b) + a * x.ln() + b * (1.0 - x).ln(); + let front = ln_front.exp(); + + // Use the continued fraction directly where it converges fastest, + // and the symmetry I_x(a,b) = 1 - I_{1-x}(b,a) otherwise. + if x < (a + 1.0) / (a + b + 2.0) { + front * betacf(a, b, x) / a + } else { + 1.0 - front * betacf(b, a, 1.0 - x) / b + } +} + +/// Inverse of the regularized incomplete beta function: returns `x` such +/// that `betainc_reg(a, b, x) == p`. +/// +/// Drop-in replacement for `scipy.special.betaincinv`. This is also the +/// quantile (inverse CDF) of the Beta(a, b) distribution. +/// +/// Follows Numerical Recipes 3rd ed., section 6.4: an initial guess from +/// Abramowitz & Stegun 26.5.22 refined by Halley iterations. +/// +/// Returns NaN outside the valid domain (`a, b > 0`, `p` in `[0, 1]`). +/// +/// # Examples +/// +/// ``` +/// use pecos_num::special::{betainc_inv, betainc_reg}; +/// +/// let x = betainc_inv(2.0, 3.0, 0.6); +/// assert!((betainc_reg(2.0, 3.0, x) - 0.6).abs() < 1e-10); +/// ``` +#[must_use] +pub fn betainc_inv(a: f64, b: f64, p: f64) -> f64 { + let domain_ok = a > 0.0 && b > 0.0 && (0.0..=1.0).contains(&p); + if !domain_ok { + return f64::NAN; + } + if p <= 0.0 { + return 0.0; + } + if p >= 1.0 { + return 1.0; + } + + let a1 = a - 1.0; + let b1 = b - 1.0; + + let mut x: f64; + if a >= 1.0 && b >= 1.0 { + // Abramowitz & Stegun 26.5.22 via the normal quantile + // approximation 26.2.23. + let pp = if p < 0.5 { p } else { 1.0 - p }; + let t = (-2.0 * pp.ln()).sqrt(); + let mut gauss = (2.30753 + t * 0.27061) / (1.0 + t * (0.99229 + t * 0.04481)) - t; + if p < 0.5 { + gauss = -gauss; + } + let al = (gauss * gauss - 3.0) / 6.0; + let h = 2.0 / (1.0 / (2.0 * a - 1.0) + 1.0 / (2.0 * b - 1.0)); + let w = gauss * (al + h).sqrt() / h + - (1.0 / (2.0 * b - 1.0) - 1.0 / (2.0 * a - 1.0)) * (al + 5.0 / 6.0 - 2.0 / (3.0 * h)); + x = a / (a + b * (2.0 * w).exp()); + } else { + let lna = (a / (a + b)).ln(); + let lnb = (b / (a + b)).ln(); + let t = (a * lna).exp() / a; + let u = (b * lnb).exp() / b; + let w = t + u; + x = if p < t / w { + (a * w * p).powf(1.0 / a) + } else { + 1.0 - (b * w * (1.0 - p)).powf(1.0 / b) + }; + } + + let afac = ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b); + for iteration in 0..10 { + if x <= 0.0 { + return 0.0; + } + if x >= 1.0 { + return 1.0; + } + let err = betainc_reg(a, b, x) - p; + let t = (a1 * x.ln() + b1 * (1.0 - x).ln() + afac).exp(); + let u = err / t; + // Halley step. + let step = u / (1.0 - 0.5 * f64::min(1.0, u * (a1 / x - b1 / (1.0 - x)))); + x -= step; + if x <= 0.0 { + x = 0.5 * (x + step); + } + if x >= 1.0 { + x = 0.5 * (x + step + 1.0); + } + if step.abs() < BETAINC_INV_EPS * x && iteration > 0 { + break; + } + } + x +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Assert relative agreement with a `SciPy` reference value. + fn assert_close(actual: f64, expected: f64, rel_tol: f64) { + let scale = expected.abs().max(f64::MIN_POSITIVE); + assert!( + (actual - expected).abs() / scale <= rel_tol || (actual - expected).abs() <= rel_tol, + "expected {expected:.17e}, got {actual:.17e}" + ); + } + + // Reference values generated with: + // uv run python -c "from scipy import special; print(special.gammaln(x))" + // (scipy 1.x, double precision) + #[test] + fn ln_gamma_matches_scipy() { + let cases = [ + (0.5, 0.572_364_942_924_7), + (1.0, 0.0), + (1.5, -0.120_782_237_635_245_26), + (2.0, 0.0), + (3.7, 1.428_072_326_665_388), + (10.0, 12.801_827_480_081_469), + (100.5, 361.435_540_467_777_57), + (1000.0, 5_905.220_423_209_181), + ]; + for (x, expected) in cases { + let actual = ln_gamma(x); + if expected == 0.0 { + assert!(actual.abs() < 1e-13, "ln_gamma({x}) = {actual:.3e}, want 0"); + } else { + assert_close(actual, expected, 1e-12); + } + } + } + + #[test] + fn ln_gamma_invalid_domain_is_nan() { + assert!(ln_gamma(0.0).is_nan()); + assert!(ln_gamma(-1.5).is_nan()); + } + + // Reference values generated with: + // uv run python -c "from scipy import special; print(special.betainc(a, b, x))" + #[test] + fn betainc_reg_matches_scipy() { + let cases = [ + (0.5, 0.5, 0.3, 0.369_010_119_565_545_36), + (2.0, 3.0, 0.4, 0.524_799_999_999_999_9), + (5.5, 1.5, 0.7, 0.251_904_453_669_740_85), + (10.0, 10.0, 0.5, 0.5), + (0.5, 20.5, 0.01, 0.476_541_531_548_465_6), + (100.5, 900.5, 0.1, 0.494_385_987_853_672_66), + (3.5, 0.5, 0.99, 0.797_971_695_234_850_9), + ]; + for (a, b, x, expected) in cases { + assert_close(betainc_reg(a, b, x), expected, 1e-12); + } + } + + #[test] + fn betainc_reg_invalid_domain_is_nan() { + assert!(betainc_reg(0.0, 1.0, 0.5).is_nan()); + assert!(betainc_reg(1.0, -1.0, 0.5).is_nan()); + assert!(betainc_reg(1.0, 1.0, -0.1).is_nan()); + assert!(betainc_reg(1.0, 1.0, 1.1).is_nan()); + } + + // Reference values generated with: + // uv run python -c "from scipy import special; print(special.betaincinv(a, b, p))" + #[test] + fn betainc_inv_matches_scipy() { + let cases = [ + (0.5, 0.5, 0.25, 0.146_446_609_406_726_24), + (2.0, 3.0, 0.6, 0.444_500_002_083_767_4), + (5.5, 1.5, 0.05, 0.505_461_253_650_681_3), + (10.0, 10.0, 0.975, 0.711_356_752_083_001_1), + (0.5, 20.5, 0.995, 0.176_754_097_436_689_93), + (100.5, 900.5, 0.025, 0.082_562_652_843_060_04), + // Binomial-CI-scale parameters: n = 20000 trials, far tail. + (50.5, 19_950.5, 0.999_999, 0.004_581_655_467_494_118_5), + ]; + for (a, b, p, expected) in cases { + assert_close(betainc_inv(a, b, p), expected, 1e-8); + } + } + + #[test] + fn betainc_inv_round_trips_through_betainc_reg() { + for &(a, b) in &[(0.5, 0.5), (2.0, 3.0), (7.5, 19_993.5), (100.5, 900.5)] { + for &p in &[1e-6, 0.025, 0.5, 0.975, 1.0 - 1e-6] { + let x = betainc_inv(a, b, p); + let back = betainc_reg(a, b, x); + assert!( + (back - p).abs() < 1e-9, + "round trip failed for a={a}, b={b}, p={p}: x={x}, back={back}" + ); + } + } + } + + // Allow exact float comparisons: the edge cases return the sentinel + // values 0.0 and 1.0 verbatim. + #[allow(clippy::float_cmp)] + #[test] + fn betainc_inv_edges() { + assert_eq!(betainc_inv(2.0, 3.0, 0.0), 0.0); + assert_eq!(betainc_inv(2.0, 3.0, 1.0), 1.0); + assert!(betainc_inv(0.0, 3.0, 0.5).is_nan()); + assert!(betainc_inv(2.0, 3.0, -0.1).is_nan()); + } +} diff --git a/crates/pecos-num/src/stats.rs b/crates/pecos-num/src/stats.rs index 278b49108..0a0028386 100644 --- a/crates/pecos-num/src/stats.rs +++ b/crates/pecos-num/src/stats.rs @@ -33,11 +33,83 @@ //! - [`jackknife_weighted`] - Jackknife resampling for weighted/grouped data (full workflow) //! - [`weighted_mean`] - Calculate weighted mean from (value, weight) pairs //! +//! ## Binomial Proportions +//! - [`jeffreys_interval`] - Jeffreys credible interval for a binomial proportion +//! //! The slice functions are fast and simple for 1D data. The axis functions //! provide idiomatic Rust API for multi-dimensional arrays. +use crate::special::betainc_inv; use ndarray::{Array, ArrayView, Axis, Dimension, RemoveAxis}; +/// Jeffreys credible interval for a binomial proportion. +/// +/// Computes the equal-tailed interval of the Beta(k + 1/2, n - k + 1/2) +/// posterior arising from the Jeffreys prior Beta(1/2, 1/2), following +/// Brown, Cai & `DasGupta`, "Interval Estimation for a Binomial +/// Proportion", Statistical Science 16(2), 2001. Per that paper's +/// standard modification, the lower bound is 0 when `successes == 0` and +/// the upper bound is 1 when `successes == trials`. +/// +/// # Arguments +/// +/// * `successes` - Number of observed successes (k) +/// * `trials` - Number of trials (n), must be nonzero +/// * `confidence` - Interval mass, e.g. 0.95; must be in (0, 1) +/// +/// # Returns +/// +/// `(lower, upper)` bounds on the proportion. +/// +/// # Panics +/// +/// Panics if `trials == 0`, `successes > trials`, or `confidence` is not +/// in (0, 1) — these are contract violations, not numeric edge cases. +/// +/// # Examples +/// +/// ``` +/// use pecos_num::stats::jeffreys_interval; +/// +/// let (lo, hi) = jeffreys_interval(50, 200, 0.95); +/// assert!(lo < 0.25 && 0.25 < hi); +/// +/// // Zero successes: lower bound is exactly 0. +/// let (lo, hi) = jeffreys_interval(0, 100, 0.95); +/// assert_eq!(lo, 0.0); +/// assert!(hi < 0.05); +/// ``` +#[must_use] +#[allow(clippy::cast_precision_loss)] +// Cast is safe: trial counts in practice are much smaller than f64 mantissa precision +pub fn jeffreys_interval(successes: u64, trials: u64, confidence: f64) -> (f64, f64) { + assert!(trials > 0, "jeffreys_interval requires trials > 0"); + assert!( + successes <= trials, + "jeffreys_interval requires successes ({successes}) <= trials ({trials})" + ); + assert!( + confidence > 0.0 && confidence < 1.0, + "jeffreys_interval requires confidence in (0, 1), got {confidence}" + ); + + let alpha = 1.0 - confidence; + let a = successes as f64 + 0.5; + let b = (trials - successes) as f64 + 0.5; + + let lower = if successes == 0 { + 0.0 + } else { + betainc_inv(a, b, alpha / 2.0) + }; + let upper = if successes == trials { + 1.0 + } else { + betainc_inv(a, b, 1.0 - alpha / 2.0) + }; + (lower, upper) +} + /// Calculate the arithmetic mean of a slice of values. /// /// # Arguments @@ -602,6 +674,86 @@ mod tests { use super::*; use ndarray::Axis; + // Reference values generated with: + // uv run python -c "from scipy import stats; + // print(stats.beta.ppf(q, k + 0.5, n - k + 0.5))" + #[test] + fn jeffreys_interval_matches_scipy_beta_quantiles() { + let cases: [(u64, u64, f64, f64, f64); 6] = [ + (0, 100, 0.95, 0.0, 0.024_745_270_015_269_89), + (100, 100, 0.95, 0.975_254_729_984_730_1, 1.0), + ( + 3, + 1000, + 0.95, + 0.000_845_634_801_829_834_8, + 0.007_984_367_358_403_443, + ), + ( + 50, + 200, + 0.95, + 0.193_872_680_411_726_73, + 0.313_302_662_892_847_86, + ), + // High-confidence intervals at LER-study scales. + ( + 7, + 20_000, + 0.99999, + 3.838_996_822_347_517e-5, + 1.307_358_543_951_447_7e-3, + ), + ( + 1234, + 20_000, + 0.99999, + 0.054_475_933_954_188_78, + 0.069_508_522_504_658_04, + ), + ]; + for (k, n, conf, lo_expected, hi_expected) in cases { + let (lo, hi) = jeffreys_interval(k, n, conf); + let lo_scale = lo_expected.abs().max(1e-12); + let hi_scale = hi_expected.abs().max(1e-12); + assert!( + (lo - lo_expected).abs() / lo_scale < 1e-6, + "lower bound for k={k}, n={n}: expected {lo_expected:.12e}, got {lo:.12e}" + ); + assert!( + (hi - hi_expected).abs() / hi_scale < 1e-6, + "upper bound for k={k}, n={n}: expected {hi_expected:.12e}, got {hi:.12e}" + ); + } + } + + #[test] + fn jeffreys_interval_brackets_the_point_estimate() { + let (lo, hi) = jeffreys_interval(50, 200, 0.95); + assert!(lo < 0.25 && 0.25 < hi); + // Wider confidence gives a wider interval. + let (lo99, hi99) = jeffreys_interval(50, 200, 0.99); + assert!(lo99 < lo && hi < hi99); + } + + #[test] + #[should_panic(expected = "trials > 0")] + fn jeffreys_interval_rejects_zero_trials() { + let _ = jeffreys_interval(0, 0, 0.95); + } + + #[test] + #[should_panic(expected = "successes")] + fn jeffreys_interval_rejects_successes_above_trials() { + let _ = jeffreys_interval(11, 10, 0.95); + } + + #[test] + #[should_panic(expected = "confidence")] + fn jeffreys_interval_rejects_bad_confidence() { + let _ = jeffreys_interval(5, 10, 1.0); + } + // Allow exact float comparisons in tests - we're testing mathematically exact results // that are exactly representable in IEEE 754 (e.g., 3.0, 42.0, 0.4) #[allow(clippy::float_cmp)] diff --git a/crates/pecos/Cargo.toml b/crates/pecos/Cargo.toml index 33d305f2c..254db783b 100644 --- a/crates/pecos/Cargo.toml +++ b/crates/pecos/Cargo.toml @@ -122,11 +122,21 @@ pecos-phir-json.workspace = true pecos-random.workspace = true log.workspace = true serde_json.workspace = true +# Surface-code LER equivalence test (neo validation gate) +pecos-qec.workspace = true +pecos-quantum.workspace = true +pecos-decoder-core.workspace = true +pecos-fusion-blossom.workspace = true +pecos-num.workspace = true [[test]] name = "unified_sim_api_test" required-features = ["runtime", "qis"] +[[test]] +name = "neo_surface_ler_test" +required-features = ["neo"] + [[example]] name = "sim_api_final" required-features = ["runtime", "qis"] diff --git a/crates/pecos/tests/neo_surface_ler_test.rs b/crates/pecos/tests/neo_surface_ler_test.rs new file mode 100644 index 000000000..79a6baca1 --- /dev/null +++ b/crates/pecos/tests/neo_surface_ler_test.rs @@ -0,0 +1,434 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Surface-code memory LER equivalence between the engines and neo stacks +//! (validation-gate item V5). +//! +//! Builds a rotated surface-code Z-memory experiment once, emitting the +//! same circuit as both a QASM program (run through `sim()` on each stack) +//! and a `TickCircuit` (fed to the Rust DEM builder for decoding). Both +//! stacks' samples are decoded with the same MWPM decoder against the +//! same DEM, and the logical error rates are compared with Jeffreys +//! credible intervals. + +#![cfg(feature = "neo")] + +use pecos::{SimStack, sim}; +use pecos_decoder_core::ObservableDecoder; +use pecos_engines::shot_results::ShotVec; +use pecos_fusion_blossom::FusionBlossomDecoder; +use pecos_num::jeffreys_interval; +use pecos_programs::Qasm; +use pecos_qec::SurfaceCode; +use pecos_qec::fault_tolerance::dem_builder::DemBuilder; +use pecos_quantum::{Attribute, TickCircuit, TickMeasRef}; +use std::fmt::Write as _; + +/// A surface-code memory experiment in both program representations. +struct MemoryExperiment { + qasm: String, + tick: TickCircuit, + /// Detector definitions as relative measurement records (Stim style: + /// record -k is the k-th most recent measurement). + detectors: Vec>, + /// The logical-Z observable as relative measurement records. + observable: Vec, + num_measurements: usize, + /// Classical registers in declaration order: (name, width). + registers: Vec<(String, usize)>, + /// Global measurement record index -> (register index, bit index). + record_map: Vec<(usize, usize)>, +} + +/// Build a rotated surface-code Z-memory experiment of the given distance, +/// emitting the identical circuit as QASM and as a `TickCircuit`. +/// +/// Mirrors `examples/surface/d3_fault_catalog_lookup.rs`: data qubits are +/// reset to |0>, each round prepares ancillas, runs a sequential +/// CX-per-check schedule (X checks via H-conjugated ancilla controls, +/// Z checks via data controls), and measures the ancillas; the experiment +/// ends with a transversal Z-basis data measurement. +fn build_surface_memory(distance: usize, rounds: usize) -> MemoryExperiment { + let code = SurfaceCode::rotated(distance).expect("valid distance"); + let num_data = code.num_data_qubits(); + let nx = code.num_x_stabilizers(); + let nz = code.num_z_stabilizers(); + let num_qubits = num_data + nx + nz; + let x_anc = |idx: usize| num_data + idx; + let z_anc = |idx: usize| num_data + nx + idx; + + let mut tick = TickCircuit::new(); + let mut body = String::new(); + let mut registers: Vec<(String, usize)> = Vec::new(); + let mut record_map: Vec<(usize, usize)> = Vec::new(); + + let data_qubits: Vec = (0..num_data).collect(); + let x_ancillas: Vec = (0..nx).map(x_anc).collect(); + let z_ancillas: Vec = (0..nz).map(z_anc).collect(); + + tick.tick().pz(&data_qubits); + for q in &data_qubits { + writeln!(body, "reset q[{q}];").unwrap(); + } + + let mut x_round: Vec> = Vec::with_capacity(rounds); + let mut z_round: Vec> = Vec::with_capacity(rounds); + + for round in 0..rounds { + tick.tick().pz(&x_ancillas); + tick.tick().pz(&z_ancillas); + for q in x_ancillas.iter().chain(&z_ancillas) { + writeln!(body, "reset q[{q}];").unwrap(); + } + + tick.tick().h(&x_ancillas); + for q in &x_ancillas { + writeln!(body, "h q[{q}];").unwrap(); + } + + for check in code.x_stabilizers() { + let anc = x_anc(check.index); + for data in check.qubits() { + tick.tick().cx(&[(anc, data)]); + writeln!(body, "cx q[{anc}],q[{data}];").unwrap(); + } + } + for check in code.z_stabilizers() { + let anc = z_anc(check.index); + for data in check.qubits() { + tick.tick().cx(&[(data, anc)]); + writeln!(body, "cx q[{data}],q[{anc}];").unwrap(); + } + } + + tick.tick().h(&x_ancillas); + for q in &x_ancillas { + writeln!(body, "h q[{q}];").unwrap(); + } + + let reg_idx = registers.len(); + registers.push((format!("s{round}"), nx + nz)); + x_round.push(tick.tick().mz(&x_ancillas)); + for (bit, q) in x_ancillas.iter().enumerate() { + writeln!(body, "measure q[{q}] -> s{round}[{bit}];").unwrap(); + record_map.push((reg_idx, bit)); + } + z_round.push(tick.tick().mz(&z_ancillas)); + for (offset, q) in z_ancillas.iter().enumerate() { + let bit = nx + offset; + writeln!(body, "measure q[{q}] -> s{round}[{bit}];").unwrap(); + record_map.push((reg_idx, bit)); + } + } + + let reg_idx = registers.len(); + registers.push(("f".to_string(), num_data)); + let final_data = tick.tick().mz(&data_qubits); + for (bit, q) in data_qubits.iter().enumerate() { + writeln!(body, "measure q[{q}] -> f[{bit}];").unwrap(); + record_map.push((reg_idx, bit)); + } + + let num_measurements = tick.num_measurements(); + assert_eq!( + record_map.len(), + num_measurements, + "QASM measurement emission must track TickCircuit records one-to-one" + ); + + // Detector definitions, identical to the fault-catalog example: + // first-round Z checks are deterministic for |0...0> initialization, + // consecutive rounds compare like checks, and the final round compares + // each Z check against the data measurements in its support. + let mut detectors: Vec> = Vec::new(); + for &meas_ref in &z_round[0] { + detectors.push(relative_records(num_measurements, &[meas_ref])); + } + for round in 1..rounds { + for (¤t, &previous) in x_round[round].iter().zip(&x_round[round - 1]) { + detectors.push(relative_records(num_measurements, &[current, previous])); + } + for (¤t, &previous) in z_round[round].iter().zip(&z_round[round - 1]) { + detectors.push(relative_records(num_measurements, &[current, previous])); + } + } + for check in code.z_stabilizers() { + let mut refs = vec![z_round[rounds - 1][check.index]]; + refs.extend(check.qubits().into_iter().map(|q| final_data[q])); + detectors.push(relative_records(num_measurements, &refs)); + } + + let logical_refs: Vec = code + .logical_z() + .data_qubits + .iter() + .map(|&q| final_data[q]) + .collect(); + let observable = relative_records(num_measurements, &logical_refs); + + tick.set_meta( + "num_measurements", + Attribute::String(num_measurements.to_string()), + ); + tick.set_meta("detectors", Attribute::String(records_json(&detectors))); + tick.set_meta( + "observables", + Attribute::String(records_json(std::slice::from_ref(&observable))), + ); + + let mut qasm = String::new(); + writeln!(qasm, "OPENQASM 2.0;").unwrap(); + writeln!(qasm, "include \"qelib1.inc\";").unwrap(); + writeln!(qasm, "qreg q[{num_qubits}];").unwrap(); + for (name, width) in ®isters { + writeln!(qasm, "creg {name}[{width}];").unwrap(); + } + qasm.push_str(&body); + + MemoryExperiment { + qasm, + tick, + detectors, + observable, + num_measurements, + registers, + record_map, + } +} + +fn relative_records(num_measurements: usize, refs: &[TickMeasRef]) -> Vec { + let num_measurements = i32::try_from(num_measurements).expect("measurement count fits in i32"); + refs.iter() + .map(|m| i32::try_from(m.record_idx).expect("record index fits in i32") - num_measurements) + .collect() +} + +fn records_json(records: &[Vec]) -> String { + let entries: Vec = records + .iter() + .enumerate() + .map(|(id, rs)| { + let values = rs.iter().map(i32::to_string).collect::>().join(","); + format!(r#"{{"id":{id},"records":[{values}]}}"#) + }) + .collect(); + format!("[{}]", entries.join(",")) +} + +/// Extract the flat measurement-record bits of one shot. +fn shot_record_bits( + shot: &pecos_engines::shot_results::Shot, + experiment: &MemoryExperiment, +) -> Vec { + let register_bit = |reg: usize, bit: usize| -> u8 { + let (name, _) = &experiment.registers[reg]; + let data = &shot.data[name.as_str()]; + match data { + pecos_engines::shot_results::Data::BitVec(bv) => u8::from(bv[bit]), + pecos_engines::shot_results::Data::U8(v) => u8::from((v >> bit) & 1 == 1), + pecos_engines::shot_results::Data::U16(v) => u8::from((v >> bit) & 1 == 1), + pecos_engines::shot_results::Data::U32(v) => u8::from((v >> bit) & 1 == 1), + pecos_engines::shot_results::Data::U64(v) => u8::from((v >> bit) & 1 == 1), + other => panic!("unexpected register data type for {name}: {other:?}"), + } + }; + + experiment + .record_map + .iter() + .map(|&(reg, bit)| register_bit(reg, bit)) + .collect() +} + +/// XOR a relative-record definition over a shot's measurement bits. +fn xor_records(bits: &[u8], records: &[i32], num_measurements: usize) -> u8 { + records.iter().fold(0u8, |acc, &rec| { + let idx = i64::try_from(num_measurements).unwrap() + i64::from(rec); + let idx = usize::try_from(idx).expect("record index in range"); + acc ^ bits[idx] + }) +} + +/// Convert a `ShotVec` into per-shot detector syndromes and observable masks. +fn shots_to_syndromes( + results: &ShotVec, + experiment: &MemoryExperiment, +) -> (Vec>, Vec) { + let mut syndromes = Vec::with_capacity(results.shots.len()); + let mut masks = Vec::with_capacity(results.shots.len()); + for shot in &results.shots { + let bits = shot_record_bits(shot, experiment); + let syndrome: Vec = experiment + .detectors + .iter() + .map(|records| xor_records(&bits, records, experiment.num_measurements)) + .collect(); + let mask = u64::from(xor_records( + &bits, + &experiment.observable, + experiment.num_measurements, + )); + syndromes.push(syndrome); + masks.push(mask); + } + (syndromes, masks) +} + +/// Uniform circuit-level depolarizing noise for the engines/neo mapping. +fn depolarizing_noise(p: f64) -> pecos_engines::noise::DepolarizingNoiseModelBuilder { + pecos_engines::noise::DepolarizingNoiseModel::builder() + .with_prep_probability(p) + .with_meas_probability(p) + .with_p1_probability(p) + .with_p2_probability(p) +} + +/// Run the experiment on one stack and return its `ShotVec`. +fn run_stack( + experiment: &MemoryExperiment, + stack: SimStack, + p: f64, + shots: usize, + seed: u64, +) -> ShotVec { + sim(Qasm::from_string(&experiment.qasm)) + .stack(stack) + .noise(depolarizing_noise(p)) + .seed(seed) + .workers(4) + .run(shots) + .expect("simulation run") +} + +/// Decode both stacks' samples with one MWPM decoder over the same DEM, +/// returning (engines errors, neo errors). +fn decode_logical_errors( + experiment: &MemoryExperiment, + p: f64, + engines: &ShotVec, + neo: &ShotVec, +) -> (u64, u64) { + let dem = DemBuilder::try_from_tick_circuit(&experiment.tick, p, p, p, p) + .expect("DEM from tick circuit") + .to_string_decomposed(); + let mut decoder = FusionBlossomDecoder::from_dem(&dem).expect("decoder from DEM"); + + let mut count = |results: &ShotVec| -> u64 { + let (syndromes, masks) = shots_to_syndromes(results, experiment); + let num_detectors = experiment.detectors.len(); + let flat: Vec = syndromes.concat(); + let predicted = decoder + .decode_batch_to_observables(&flat, masks.len(), num_detectors) + .expect("batch decode"); + predicted + .iter() + .zip(&masks) + .filter(|(pred, actual)| pred != actual) + .count() as u64 + }; + + (count(engines), count(neo)) +} + +#[test] +fn noiseless_surface_memory_is_silent_on_both_stacks() { + // Validates the generator end-to-end on each stack independently: the + // X-ancilla outcomes are individually random, so every detector and + // the logical observable XOR to zero only if the QASM, the record + // bookkeeping, and the register bit mapping all line up. + let experiment = build_surface_memory(3, 3); + + for stack in [SimStack::Engines, SimStack::Neo] { + let results = sim(Qasm::from_string(&experiment.qasm)) + .stack(stack) + .seed(11) + .run(25) + .expect("noiseless run"); + let (syndromes, masks) = shots_to_syndromes(&results, &experiment); + for (shot_idx, syndrome) in syndromes.iter().enumerate() { + assert!( + syndrome.iter().all(|&bit| bit == 0), + "stack {stack:?} shot {shot_idx}: noiseless detectors must be silent, got {syndrome:?}" + ); + } + assert!( + masks.iter().all(|&m| m == 0), + "stack {stack:?}: noiseless logical observable must be trivial" + ); + } +} + +#[test] +fn surface_memory_ler_matches_across_stacks() { + // V5: d=3 and d=5 Z-memory under uniform depolarizing noise. Both + // stacks' LERs must have overlapping Jeffreys intervals, and each + // stack must show d=5 suppressing the LER below d=3. + // + // Calibration (20k shots, this circuit's sequential schedule): + // threshold sits near p = 0.004; at p = 0.003 the LERs are roughly + // 4.1e-3 (d=3) and 1.4e-3 (d=5) with ~3x suppression on both stacks. + // The sequential schedule's hook errors limit the suppression + // steepness; that affects both stacks identically. + // 20k shots: at 10k the per-stack error counts (~20-40) fluctuate too + // much for the suppression margin to be decisive. + let p = 0.003; + let shots = 20_000; + // High-confidence intervals so stack disagreement, not sampling + // noise, is what fails the equivalence check (~4.4 sigma per side). + let equivalence_confidence = 0.99999; + // The suppression margin is smaller than the equivalence margin, so + // it gets its own (still strict) confidence. + let suppression_confidence = 0.99; + + let mut pooled_intervals = Vec::new(); + for distance in [3, 5] { + let experiment = build_surface_memory(distance, distance); + let engines = run_stack(&experiment, SimStack::Engines, p, shots, 42); + let neo = run_stack(&experiment, SimStack::Neo, p, shots, 42); + let (engines_errors, neo_errors) = decode_logical_errors(&experiment, p, &engines, &neo); + + let engines_ci = jeffreys_interval(engines_errors, shots as u64, equivalence_confidence); + let neo_ci = jeffreys_interval(neo_errors, shots as u64, equivalence_confidence); + println!( + "d={distance}: engines {engines_errors}/{shots} LER CI [{:.5}, {:.5}], \ + neo {neo_errors}/{shots} LER CI [{:.5}, {:.5}]", + engines_ci.0, engines_ci.1, neo_ci.0, neo_ci.1 + ); + + assert!( + engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, + "d={distance}: stack LERs are statistically incompatible: \ + engines {engines_errors}/{shots} vs neo {neo_errors}/{shots}" + ); + // With per-stack equivalence established, pool the stacks for the + // suppression physics check (doubles the statistics). + pooled_intervals.push(jeffreys_interval( + engines_errors + neo_errors, + 2 * shots as u64, + suppression_confidence, + )); + } + + // Error suppression: the pooled d=5 interval must sit strictly below + // the pooled d=3 interval (p = 0.003 is below threshold). + let d3 = pooled_intervals[0]; + let d5 = pooled_intervals[1]; + assert!( + d5.1 < d3.0, + "d=5 LER must be suppressed below d=3: \ + d5 CI [{:.5}, {:.5}] vs d3 CI [{:.5}, {:.5}]", + d5.0, + d5.1, + d3.0, + d3.1 + ); +} From fea6ee32aa534066a45ad5dde9a7f1533e713f49 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 23:01:03 -0600 Subject: [PATCH 103/388] Add V1 statistical equivalence matrix: 7 noise cells through sim() on both stacks with Jeffreys-interval overlap and analytic-truth containment --- crates/pecos/Cargo.toml | 4 + .../tests/neo_equivalence_matrix_test.rs | 264 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 crates/pecos/tests/neo_equivalence_matrix_test.rs diff --git a/crates/pecos/Cargo.toml b/crates/pecos/Cargo.toml index 254db783b..a6836f2f5 100644 --- a/crates/pecos/Cargo.toml +++ b/crates/pecos/Cargo.toml @@ -137,6 +137,10 @@ required-features = ["runtime", "qis"] name = "neo_surface_ler_test" required-features = ["neo"] +[[test]] +name = "neo_equivalence_matrix_test" +required-features = ["neo"] + [[example]] name = "sim_api_final" required-features = ["runtime", "qis"] diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs new file mode 100644 index 000000000..be0bd8de4 --- /dev/null +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -0,0 +1,264 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Statistical equivalence matrix between the engines and neo stacks +//! (validation-gate item V1). +//! +//! Each cell runs the same QASM program with the same mapped noise +//! configuration through `sim()` on both stacks and compares the target +//! outcome rate with Jeffreys credible intervals: the two stacks' +//! intervals must overlap, and where the rate has an exact analytic +//! value, each stack's interval must contain it. +//! +//! Program-type coverage beyond QASM (HUGR) and exact worker-count +//! invariance are covered by `neo_routing_test.rs`; surface-code-scale +//! decoded equivalence is covered by `neo_surface_ler_test.rs`. + +#![cfg(feature = "neo")] + +use pecos::{SimStack, sim}; +use pecos_engines::shot_results::ShotVec; +use pecos_num::jeffreys_interval; +use pecos_programs::Qasm; + +const SHOTS: usize = 20_000; +/// ~4.4 sigma per side: stack disagreement, not sampling noise, is what +/// fails a cell. +const CONFIDENCE: f64 = 0.99999; +const SEED: u64 = 42; + +const X_MEASURE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + x q[0]; + measure q[0] -> c[0]; +"#; + +const RESET_MEASURE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + reset q[0]; + measure q[0] -> c[0]; +"#; + +const BELL: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + h q[0]; + cx q[0],q[1]; + measure q[0] -> c[0]; + measure q[1] -> c[1]; +"#; + +const FEEDBACK: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + x q[0]; + measure q[0] -> c[0]; + if (c == 1) x q[1]; + measure q[1] -> c[1]; +"#; + +/// One noise configuration of the matrix, applied identically to both +/// stacks (the facade maps it to neo's noise channels). +enum NoiseCell { + Meas(f64), + Prep(f64), + P1(f64), + P2(f64), + Uniform(f64), + GnmSimple { average_p1: f64, p_meas: f64 }, +} + +impl NoiseCell { + fn run(&self, qasm: &str, stack: SimStack) -> ShotVec { + let builder = sim(Qasm::from_string(qasm)).stack(stack).seed(SEED); + let depol = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { + pecos_engines::noise::DepolarizingNoiseModel::builder() + .with_prep_probability(p_prep) + .with_meas_probability(p_meas) + .with_p1_probability(p1) + .with_p2_probability(p2) + }; + let results = match *self { + Self::Meas(p) => builder.noise(depol(0.0, p, 0.0, 0.0)).run(SHOTS), + Self::Prep(p) => builder.noise(depol(p, 0.0, 0.0, 0.0)).run(SHOTS), + Self::P1(p) => builder.noise(depol(0.0, 0.0, p, 0.0)).run(SHOTS), + Self::P2(p) => builder.noise(depol(0.0, 0.0, 0.0, p)).run(SHOTS), + Self::Uniform(p) => builder + .noise(pecos_engines::DepolarizingNoise { p }) + .run(SHOTS), + Self::GnmSimple { average_p1, p_meas } => builder + .noise( + // GeneralNoiseModel has realistic non-zero defaults; + // zero everything outside the simple Pauli subset so + // the cell physics is exactly known. + pecos_engines::noise::GeneralNoiseModel::builder() + .with_average_p1_probability(average_p1) + .with_average_p2_probability(0.0) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(p_meas) + .with_meas_1_probability(p_meas), + ) + .run(SHOTS), + }; + results.expect("simulation run") + } +} + +/// Count shots whose register `c` reads any of the target bitstrings. +/// +/// Every target set used here is symmetric under bit reversal, so the +/// count is independent of register bit ordering. +fn count_targets(results: &ShotVec, targets: &[&str]) -> u64 { + results + .shots + .iter() + .filter(|shot| { + let bits = shot.data["c"].to_bitstring().expect("c register bits"); + targets.contains(&bits.as_str()) + }) + .count() as u64 +} + +/// Run one matrix cell on both stacks and apply the equivalence (and +/// optional analytic-truth) assertions. +fn check_cell(name: &str, qasm: &str, cell: &NoiseCell, targets: &[&str], analytic: Option) { + let engines = count_targets(&cell.run(qasm, SimStack::Engines), targets); + let neo = count_targets(&cell.run(qasm, SimStack::Neo), targets); + + let engines_ci = jeffreys_interval(engines, SHOTS as u64, CONFIDENCE); + let neo_ci = jeffreys_interval(neo, SHOTS as u64, CONFIDENCE); + println!( + "{name}: engines {engines}/{SHOTS} CI [{:.5}, {:.5}], \ + neo {neo}/{SHOTS} CI [{:.5}, {:.5}], analytic {analytic:?}", + engines_ci.0, engines_ci.1, neo_ci.0, neo_ci.1 + ); + + assert!( + engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, + "{name}: stack rates are statistically incompatible: \ + engines {engines}/{SHOTS} vs neo {neo}/{SHOTS}" + ); + + if let Some(truth) = analytic { + assert!( + engines_ci.0 <= truth && truth <= engines_ci.1, + "{name}: engines rate {engines}/{SHOTS} excludes the analytic value {truth}" + ); + assert!( + neo_ci.0 <= truth && truth <= neo_ci.1, + "{name}: neo rate {neo}/{SHOTS} excludes the analytic value {truth}" + ); + } +} + +#[test] +fn meas_only_rates_match() { + // Measurement flip only: P(c = 0 after X) = p_meas exactly. + check_cell( + "meas_only", + X_MEASURE, + &NoiseCell::Meas(0.2), + &["0"], + Some(0.2), + ); +} + +#[test] +fn prep_only_rates_match() { + // Preparation error only: P(c = 1 after reset) = p_prep exactly. + check_cell( + "prep_only", + RESET_MEASURE, + &NoiseCell::Prep(0.15), + &["1"], + Some(0.15), + ); +} + +#[test] +fn p1_only_rates_match() { + // Uniform 1q depolarizing after the X gate: X and Y flip the Z-basis + // outcome, Z does not, so P(c = 0) = 2p/3 exactly. + check_cell("p1_only", X_MEASURE, &NoiseCell::P1(0.3), &["0"], Some(0.2)); +} + +#[test] +fn p2_only_anticorrelation_matches() { + // Uniform 2q depolarizing after the Bell CX: of the 15 two-qubit + // Paulis, the 8 with exactly one X/Y factor anticommute with Z(x)Z + // and break the outcome correlation, so P(01 or 10) = 8p/15 exactly. + check_cell( + "p2_only", + BELL, + &NoiseCell::P2(0.3), + &["01", "10"], + Some(8.0 * 0.3 / 15.0), + ); +} + +#[test] +fn uniform_depolarizing_compound_matches() { + // All channels at p = 0.1 on the x-measure program: the compound + // error rate has no simple closed form; cross-stack agreement only. + check_cell( + "uniform_depol", + X_MEASURE, + &NoiseCell::Uniform(0.1), + &["0"], + None, + ); +} + +#[test] +fn gnm_simple_subset_matches() { + // GeneralNoiseModel's "average" convention stores p1 = 1.5 x average + // (0.3 here, flip 0.2) on both stacks, composed with a 5% measurement + // flip: P(c = 0) = 0.2 * 0.95 + 0.8 * 0.05 = 0.23 exactly. + check_cell( + "gnm_simple", + X_MEASURE, + &NoiseCell::GnmSimple { + average_p1: 0.2, + p_meas: 0.05, + }, + &["0"], + Some(0.23), + ); +} + +#[test] +fn feedback_under_measurement_noise_matches() { + // Conditional feedback with noisy measurement: the recorded c[0] + // drives the correction, so both stacks must apply the measurement + // flip with the same record-vs-state semantics to agree here. + check_cell( + "feedback_meas_noise", + FEEDBACK, + &NoiseCell::Meas(0.1), + &["11"], + None, + ); +} From 5dcad767f42d711bb28ef43d8c8d94a7d05ec568 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 23:10:30 -0600 Subject: [PATCH 104/388] Fix full-branch review findings 1-6: propagate decode_count_parallel worker/decode errors instead of unwrap_or(chunk.len()), count bare 'detector Dk' declarations in all DEM parsers, flip the global observable_idx not list position in LogicalSubgraphDecoder, enforce non-negative UF edge weights (hard assert + Err on every DEM-text constructor), and gate BpUf predecoding on config.predecoder --- crates/pecos-decoder-core/src/dem.rs | 127 ++++++++++++------ crates/pecos-decoder-core/src/lib.rs | 2 +- .../src/logical_algorithm.rs | 5 +- .../src/logical_subgraph.rs | 36 ++--- .../src/logical_subgraph/window_plan.rs | 11 +- crates/pecos-uf-decoder/src/bp_uf.rs | 15 ++- crates/pecos-uf-decoder/src/css_decoder.rs | 2 + crates/pecos-uf-decoder/src/decoder.rs | 56 +++++++- .../tests/integration_tests.rs | 35 +++++ .../src/fault_tolerance_bindings.rs | 28 ++-- 10 files changed, 241 insertions(+), 76 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index d7e351633..eafc3dbc2 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -131,10 +131,9 @@ pub mod utils { } // Normalize commands that carry parenthesized parameters: - // `error(0.01) ...` and `detector(x,y,t) Dk` (Stim always parenthesizes - // detector coordinates, so a literal `parts[0] == "detector"` check - // would miss every real declaration and undercount detectors that are - // declared but never referenced by an error mechanism). + // `error(0.01) ...` and `detector(x,y,t) Dk`. Stim emits bare + // `detector Dk` for declarations without coordinates; that form + // already matches via `parts[0]` below. let command = if parts[0].starts_with("error(") { "error" } else if parts[0].starts_with("detector(") { @@ -318,10 +317,7 @@ impl SparseDem { max_observable = Some(max_observable.map_or(l, |m| m.max(l))); } } - ( - det_set.into_iter().collect(), - obs_set.into_iter().collect(), - ) + (det_set.into_iter().collect(), obs_set.into_iter().collect()) } else { // Graphlike mechanism: keep DEM token order. let mut detectors = Vec::new(); @@ -342,17 +338,28 @@ impl SparseDem { }; mechanisms.push((probability, detectors, observables)); - } else if let Some(rest) = line.strip_prefix("detector(") { - let Some(close) = rest.find(')') else { - continue; + } else if let Some(rest) = line.strip_prefix("detector") { + // `detector(x,y,t) Dk` carries coordinates; Stim emits bare + // `detector Dk` for declarations without coordinates. Both + // declare the id, which counts toward `num_detectors` even if + // no error mechanism references it. + let (coords, targets) = if let Some(after) = rest.strip_prefix('(') { + let Some(close) = after.find(')') else { + continue; + }; + let coords: Vec = after[..close] + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + (Some(coords), &after[close + 1..]) + } else { + (None, rest) }; - let coords: Vec = rest[..close] - .split(',') - .filter_map(|s| s.trim().parse().ok()) - .collect(); - for token in rest[close + 1..].split_whitespace() { + for token in targets.split_whitespace() { if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { - detector_coords.insert(d as usize, coords.clone()); + if let Some(c) = &coords { + detector_coords.insert(d as usize, c.clone()); + } max_detector = Some(max_detector.map_or(d, |m| m.max(d))); } } @@ -453,16 +460,23 @@ impl DemCheckMatrix { .into(), )); } - if let Some(rest) = line.strip_prefix("detector(") { + if let Some(rest) = line.strip_prefix("detector") { // Count the declared detector id, which may not be referenced by - // any error mechanism. All parsers agree on + // any error mechanism. Stim emits `detector(x,y,t) Dk` when + // coordinates are attached and bare `detector Dk` when not; both + // declare the id. All parsers agree on // `max(declared, error-referenced) + 1`. - if let Some(close) = rest.find(')') { - for token in rest[close + 1..].split_whitespace() { - if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) - { - max_detector = Some(max_detector.map_or(d, |m| m.max(d))); - } + let targets = if let Some(after) = rest.strip_prefix('(') { + match after.find(')') { + Some(close) => &after[close + 1..], + None => continue, + } + } else { + rest + }; + for token in targets.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); } } continue; @@ -665,16 +679,22 @@ impl DemMatchingGraph { .into(), )); } - if let Some(rest) = line.strip_prefix("detector(") { + if let Some(rest) = line.strip_prefix("detector") { // Count the declared detector id (may not be error-referenced) so // `num_detectors` matches the other parsers and its coordinate is - // not later dropped from `detector_coords`. - if let Some(close) = rest.find(')') { - for token in rest[close + 1..].split_whitespace() { - if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) - { - max_detector = Some(max_detector.map_or(d, |m| m.max(d))); - } + // not later dropped from `detector_coords`. Stim emits bare + // `detector Dk` (no parentheses) for coordinate-less declarations. + let targets = if let Some(after) = rest.strip_prefix('(') { + match after.find(')') { + Some(close) => &after[close + 1..], + None => continue, + } + } else { + rest + }; + for token in targets.split_whitespace() { + if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) { + max_detector = Some(max_detector.map_or(d, |m| m.max(d))); } } continue; @@ -1132,9 +1152,31 @@ mod tests { let dem = "detector(0, 0, 0) D2\nlogical_observable L0\n"; assert_eq!(SparseDem::from_dem_str(dem).unwrap().num_detectors, 3); assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_detectors, 3); - assert_eq!(DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, 3); + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, + 3 + ); + let (dets, _obs) = utils::parse_dem_metadata(dem).unwrap(); + assert_eq!( + dets, 3, + "parse_dem_metadata must count declared detector D2" + ); + } + + #[test] + fn test_parsers_count_bare_detector_declarations() { + // Stim emits coordinate-less declarations as bare `detector Dk` (no + // parentheses). All four parsers must count it like the parenthesized + // form; three of them previously gated on `detector(` and dropped it. + let dem = "error(0.01) D0 L0\ndetector D7\n"; + assert_eq!(SparseDem::from_dem_str(dem).unwrap().num_detectors, 8); + assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_detectors, 8); + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_detectors, + 8 + ); let (dets, _obs) = utils::parse_dem_metadata(dem).unwrap(); - assert_eq!(dets, 3, "parse_dem_metadata must count declared detector D2"); + assert_eq!(dets, 8, "parse_dem_metadata must count bare detector D7"); } #[test] @@ -1142,10 +1184,19 @@ mod tests { // Only L2 present: index-addressed buffers need 3 slots, not 1. All // parsers must agree on `max + 1`, not distinct-id count. let dem = "error(0.01) D0 L2\ndetector(0,0,0) D0\n"; - assert_eq!(DemMatchingGraph::from_dem_str(dem).unwrap().num_observables, 3); - assert_eq!(DemCheckMatrix::from_dem_str(dem).unwrap().num_observables, 3); + assert_eq!( + DemMatchingGraph::from_dem_str(dem).unwrap().num_observables, + 3 + ); + assert_eq!( + DemCheckMatrix::from_dem_str(dem).unwrap().num_observables, + 3 + ); let (_d, obs) = utils::parse_dem_metadata(dem).unwrap(); - assert_eq!(obs, 3, "parse_dem_metadata must agree (max+1, not distinct count)"); + assert_eq!( + obs, 3, + "parse_dem_metadata must agree (max+1, not distinct count)" + ); } #[test] diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index acbb2c244..1189cd51b 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -37,9 +37,9 @@ pub mod errors; pub mod ghost_protocol; pub mod k_mwpm; pub mod logical_algorithm; +pub mod logical_subgraph; pub mod matrix; pub mod multi_decoder; -pub mod logical_subgraph; pub mod pauli_frame; pub mod perturbed; pub mod preprocessor; diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index f516d2275..dc5c283a5 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -764,7 +764,10 @@ mod tests { // bit 0; the strategy must flip the GLOBAL bit, not the list position. // (The pre-fix `1 << i` would have produced bits {0,1} = 0b0011.) let mut strat = WindowedLogicalSubgraphStrategy::new( - vec!["error(0.1) D0 L0".to_string(), "error(0.1) D0 L0".to_string()], + vec![ + "error(0.1) D0 L0".to_string(), + "error(0.1) D0 L0".to_string(), + ], vec![vec![0usize], vec![1usize]], vec![1usize, 3usize], |_dem| Ok(Box::new(FixedDecoder(1)) as Box), diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 52bbb728e..da8fb4c81 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -610,12 +610,7 @@ impl LogicalSubgraphDecoder { // Per-shot observable predictions, accumulated across subgraphs. let mut shot_obs: Vec = vec![0u64; num_shots]; - for (i, (sg, dec)) in self - .subgraphs - .iter() - .zip(self.decoders.iter_mut()) - .enumerate() - { + for (sg, dec) in self.subgraphs.iter().zip(self.decoders.iter_mut()) { let n = sg.detector_map.len(); if n == 0 { continue; @@ -639,7 +634,9 @@ impl LogicalSubgraphDecoder { for (shot_idx, &sub_obs) in sub_masks.iter().enumerate() { if sub_obs & 1 != 0 { - shot_obs[shot_idx] |= 1 << i; + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + shot_obs[shot_idx] |= 1u64 << sg.observable_idx; } } } @@ -682,7 +679,9 @@ impl ObservableDecoder for LogicalSubgraphDecoder { let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - obs_mask |= 1 << i; + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + obs_mask |= 1u64 << sg.observable_idx; } } @@ -757,9 +756,11 @@ impl ParallelLogicalSubgraphDecoder { .collect(); let mut obs_mask = 0u64; - for (i, result) in results.into_iter().enumerate() { + for (sg, result) in self.subgraphs.iter().zip(results) { if result? { - obs_mask |= 1 << i; + // Flip the subgraph's GLOBAL observable bit, not the list + // position: construction guarantees < 64 observables. + obs_mask |= 1u64 << sg.observable_idx; } } Ok(obs_mask) @@ -909,7 +910,7 @@ mod tests { // An out-of-range membership detector id must error, not panic // (the DEM has 2 detectors D0,D1; detector 5 is past `inverse_map`). assert!(matches!( - subgraphs_from_membership(&sdem, &vec![vec![5usize]]), + subgraphs_from_membership(&sdem, &[vec![5usize]]), Err(DecoderError::InvalidConfiguration(_)) )); } @@ -932,7 +933,11 @@ mod tests { assert_eq!(exact, vec![vec![0usize]], "None = exact boundary time only"); let widened = coordinate_membership_from_dem(&sdem, &sc, Some(1)).unwrap(); - assert_eq!(widened, vec![vec![0usize, 1usize]], "radius pulls in time 1"); + assert_eq!( + widened, + vec![vec![0usize, 1usize]], + "radius pulls in time 1" + ); } #[test] @@ -943,8 +948,8 @@ mod tests { let dem = concat!( "detector(1, 0, 0) D0\n", "detector(0, 1, 0) D1\n", - "error(0.01) D0 L0\n", // boundary → qubit 0 X - "error(0.02) D0 ^ D1 L0\n", // decomposed; XOR -> {D0, D1} + "error(0.01) D0 L0\n", // boundary → qubit 0 X + "error(0.02) D0 ^ D1 L0\n", // decomposed; XOR -> {D0, D1} ); let sc = simple_stab_coords(); let sdem = SparseDem::from_dem_str(dem).unwrap(); @@ -1044,9 +1049,10 @@ mod tests { #[test] fn test_too_many_observables_errors() { // 65 observables exceeds the u64 mask capacity. + use std::fmt::Write; let mut dem = String::from("detector(1, 0, 0) D0\n"); for l in 0..65 { - dem.push_str(&format!("error(0.01) D0 L{l}\n")); + writeln!(dem, "error(0.01) D0 L{l}").unwrap(); } let sc = simple_stab_coords(); let err = partition_dem_by_logical(&dem, &sc).unwrap_err(); diff --git a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs index 1159582fb..f32da5b5d 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/window_plan.rs @@ -141,7 +141,10 @@ impl LogicalSubgraphWindowPlan { /// Local->global detector maps (one per non-empty observable). #[must_use] pub fn detector_maps(&self) -> Vec> { - self.entries.iter().map(|e| e.detector_map.clone()).collect() + self.entries + .iter() + .map(|e| e.detector_map.clone()) + .collect() } /// Estimated number of time windows observable `i` would use at `step` @@ -200,7 +203,11 @@ fn window_count_for_times(times: &[f64], step: usize) -> usize { let mut t_start = 0.0f64; while t_start < total_t { let is_last = t_start + 2.0 * step > total_t; - let t_core_end = if is_last { total_t + 1.0 } else { t_start + step }; + let t_core_end = if is_last { + total_t + 1.0 + } else { + t_start + step + }; if times.iter().any(|&t| t >= t_start && t < t_core_end) { count += 1; } diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs index 0a293ce40..428069a55 100644 --- a/crates/pecos-uf-decoder/src/bp_uf.rs +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -151,6 +151,7 @@ impl BpUfDecoder { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| DecoderError::InvalidConfiguration(e.to_string()))?; let graph = DemMatchingGraph::from_dem_str(dem)?; + UfDecoder::check_non_negative_weights(&graph)?; let uf = UfDecoder::from_matching_graph(&graph, config.uf_config); // Build mechanism → edge mapping. @@ -252,6 +253,7 @@ impl BpUfDecoder { // Matching graph and UF from the decomposed DEM. let match_graph = DemMatchingGraph::from_dem_str(matching_dem)?; + UfDecoder::check_non_negative_weights(&match_graph)?; let uf = UfDecoder::from_matching_graph(&match_graph, config.uf_config); // Map BP mechanisms (non-decomposed) → matching graph edges (decomposed). @@ -453,6 +455,9 @@ impl pecos_decoder_core::bp_matching::BpWeightProvider for BpUfDecoder { } fn is_trivial(&self, syndrome: &[u8]) -> Option { + if !self.uf.config.predecoder { + return None; + } self.uf.predecode_clusters(syndrome) } } @@ -460,8 +465,14 @@ impl pecos_decoder_core::bp_matching::BpWeightProvider for BpUfDecoder { impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { // Fast path: cluster predecoder handles isolated cases without BP. - // This catches 0 defects, single defects, and isolated pairs. - if let Some(obs) = self.uf.predecode_clusters(syndrome) { + // This catches 0 defects, single defects, and isolated pairs. Gated on + // the UF config like the plain `UfDecoder` paths. It deliberately runs + // on construction-time weights, bypassing BP: the cases it accepts are + // provably min-weight under the prior weights, and BP reweighting is + // only consulted for the larger clusters that fall through. + if self.uf.config.predecoder + && let Some(obs) = self.uf.predecode_clusters(syndrome) + { return Ok(obs); } diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs index 69baaf239..051e970c1 100644 --- a/crates/pecos-uf-decoder/src/css_decoder.rs +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -113,6 +113,8 @@ impl CssUfDecoder { ) -> Result { let x_graph = DemMatchingGraph::from_dem_str(x_dem)?; let z_graph = DemMatchingGraph::from_dem_str(z_dem)?; + UfDecoder::check_non_negative_weights(&x_graph)?; + UfDecoder::check_non_negative_weights(&z_graph)?; // Auto-detect qubit-edge mapping from detector coordinates. let qubit_map = Self::build_qubit_mapping(&x_graph, &z_graph); diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 5a1c4e29a..974691b8a 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -144,8 +144,9 @@ pub struct UfDecoder { adj_offset: Vec, /// Number of detectors. num_detectors: usize, - /// Config. - config: UfDecoderConfig, + /// Config. Crate-visible so wrappers (e.g. `BpUfDecoder`) honour the same + /// flags (notably `predecoder`) instead of silently ignoring them. + pub(crate) config: UfDecoderConfig, // === Per-shot reusable buffers === /// Disjoint-set forest: parent[i] = parent of node i. @@ -193,7 +194,44 @@ impl UfDecoder { &self.adj_data[start..end] } + /// Check that every edge weight in `graph` is non-negative. + /// + /// The predecoder's shortcut proofs ("lightest edge is the min-weight + /// correction", "direct pair <= boundary split") require non-negative + /// weights. `ln((1-p)/p)` guarantees that for priors p <= 0.5, but a raw + /// `error(p)` DEM line with p > 0.5 produces a negative weight. Call this + /// at any boundary where DEM text enters, so bad input is rejected as an + /// error instead of panicking in `from_matching_graph`. + /// + /// # Errors + /// + /// Returns `DecoderError::InvalidConfiguration` naming the first offending + /// edge (negative or NaN weight). + pub fn check_non_negative_weights(graph: &DemMatchingGraph) -> Result<(), DecoderError> { + if let Some((idx, e)) = graph + .edges + .iter() + .enumerate() + .find(|(_, e)| e.weight < 0.0 || e.weight.is_nan()) + { + return Err(DecoderError::InvalidConfiguration(format!( + "UfDecoder requires non-negative edge weights (error priors p <= 0.5), \ + but edge {idx} (node {} -- {:?}) has weight {}", + e.node1, e.node2, e.weight, + ))); + } + Ok(()) + } + /// Build from a `DemMatchingGraph`. + /// + /// # Panics + /// + /// Panics if any edge weight is negative or NaN: the predecoder's + /// optimality proofs depend on non-negative weights, so violating that + /// premise must fail loudly rather than silently mis-decode. Callers + /// holding untrusted DEM input should pre-validate with + /// [`Self::check_non_negative_weights`] to get an error instead. #[must_use] pub fn from_matching_graph(graph: &DemMatchingGraph, config: UfDecoderConfig) -> Self { let num_detectors = graph.num_detectors; @@ -225,13 +263,14 @@ impl UfDecoder { } // Construction-time invariant: edge weights are non-negative (true for - // `ln((1-p)/p)` when p < 0.5, i.e. real sub-threshold priors). The + // `ln((1-p)/p)` when p <= 0.5, i.e. real sub-threshold priors). The // predecoder's shortcut proofs ("lightest edge is the min-weight // correction", "direct pair <= boundary split") depend on it; a negative - // weight would silently break them. Checked once here, not per shot. - debug_assert!( + // weight would silently break them, so this is a hard assert (a raw + // `error(p > 0.5)` DEM line violates it). Checked once here, not per shot. + assert!( edges.iter().all(|e| e.weight >= 0.0), - "UfDecoder requires non-negative edge weights (error priors p < 0.5)" + "UfDecoder requires non-negative edge weights (error priors p <= 0.5)" ); // Sort each node's adjacency by weight (lightest first). @@ -283,9 +322,12 @@ impl UfDecoder { /// /// # Errors /// - /// Returns `DecoderError` if the DEM is malformed. + /// Returns `DecoderError` if the DEM is malformed or contains an error + /// prior p > 0.5 (negative edge weight, which the predecoder's optimality + /// proofs do not admit). pub fn from_dem(dem: &str, config: UfDecoderConfig) -> Result { let graph = DemMatchingGraph::from_dem_str(dem)?; + Self::check_non_negative_weights(&graph)?; Ok(Self::from_matching_graph(&graph, config)) } diff --git a/crates/pecos-uf-decoder/tests/integration_tests.rs b/crates/pecos-uf-decoder/tests/integration_tests.rs index 985a179b9..fc75d7ae1 100644 --- a/crates/pecos-uf-decoder/tests/integration_tests.rs +++ b/crates/pecos-uf-decoder/tests/integration_tests.rs @@ -171,3 +171,38 @@ fn test_buffer_reuse_correctness() { let _ = dec.decode_syndrome(&defect_syndrome); } } + +/// A DEM line with an error prior p > 0.5 produces a negative edge weight +/// (`ln((1-p)/p) < 0`), which the predecoder's optimality proofs do not admit. +/// The DEM-text constructors must reject it as an error, not mis-decode. +#[test] +fn test_from_dem_rejects_negative_weight_priors() { + let dem = "error(0.6) D0 L0\nerror(0.01) D0 D1\n"; + assert!(UfDecoder::from_dem(dem, UfDecoderConfig::balanced()).is_err()); + assert!( + pecos_uf_decoder::BpUfDecoder::from_dem(dem, pecos_uf_decoder::BpUfConfig::balanced()) + .is_err() + ); + assert!( + pecos_uf_decoder::CssUfDecoder::from_dems(dem, dem, UfDecoderConfig::balanced()).is_err() + ); +} + +/// The graph-level constructor asserts the same premise loudly for callers +/// that build graphs directly (contract violation, not user input). +#[test] +#[should_panic(expected = "non-negative edge weights")] +fn test_from_matching_graph_asserts_non_negative_weights() { + let dem = "error(0.6) D0 L0\n"; + let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); + let _ = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()); +} + +/// Weight-zero edges (p = 0.5) are benign: the shortcut proofs hold as ties. +#[test] +fn test_from_dem_accepts_weight_zero_edges() { + let dem = "error(0.5) D0 L0\nerror(0.01) D0 D1\n"; + let mut dec = UfDecoder::from_dem(dem, UfDecoderConfig::balanced()).unwrap(); + let syndrome = vec![0u8; dec.num_detectors()]; + assert_eq!(dec.decode_syndrome(&syndrome), 0); +} diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index caa04793f..a468c9bbd 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2024,6 +2024,10 @@ fn create_observable_decoder( |e| PyErr::new::(e.to_string()), )?; + // Reject negative-weight edges (error priors p > 0.5) as a Python + // error rather than panicking in `from_matching_graph`. + pecos_decoders::UfDecoder::check_non_negative_weights(&graph) + .map_err(|e| PyErr::new::(e.to_string()))?; let uf = pecos_decoders::UfDecoder::from_matching_graph( &graph, pecos_decoders::UfDecoderConfig::balanced(), @@ -4684,7 +4688,9 @@ impl PyLogicalSubgraphDecoder { let dem_str = dem.to_string(); // Reuse the backend chosen at construction unless the caller overrides, // so parallel workers decode identically to the serial path. - let inner_str = inner_decoder.unwrap_or(self.inner_decoder.as_str()).to_string(); + let inner_str = inner_decoder + .unwrap_or(self.inner_decoder.as_str()) + .to_string(); let n = batch.num_shots; // Materialize row-major data for parallel decode. @@ -4702,7 +4708,10 @@ impl PyLogicalSubgraphDecoder { .build() .map_err(|e| PyErr::new::(e.to_string()))?; - let errors: usize = pool.install(|| { + // Propagate worker construction and decode errors instead of panicking + // across the FFI boundary or silently scoring a failed chunk as + // all-failures (which would inflate the reported logical error rate). + let errors: Result = pool.install(|| { // Split into chunks, each chunk gets its own decoder + batch decode let chunk_size = n.div_ceil(rayon::current_num_threads()); (0..n) @@ -4723,20 +4732,18 @@ impl PyLogicalSubgraphDecoder { Ok(Box::new(SendWrapper(d)) as Box) }, - ) - .unwrap(); + )?; // Collect chunk syndromes and masks for batch decode let chunk_syns: Vec> = chunk.iter().map(|&i| events[i].clone()).collect(); let chunk_masks: Vec = chunk.iter().map(|&i| masks[i]).collect(); dec.decode_count_batched(&chunk_syns, &chunk_masks) - .unwrap_or(chunk.len()) }) - .sum() + .try_reduce(|| 0, |a, b| Ok(a + b)) }); - Ok(errors) + errors.map_err(|e| PyErr::new::(e.to_string())) } /// Number of detectors in each subgraph. @@ -5449,9 +5456,10 @@ impl PyLogicalCircuitDecoder { det_maps, obs_indices, |dem_str| { - let dec = create_observable_decoder(dem_str, &fallback_inner).map_err( - |e| pecos_decoders::DecoderError::InternalError(e.to_string()), - )?; + let dec = + create_observable_decoder(dem_str, &fallback_inner).map_err(|e| { + pecos_decoders::DecoderError::InternalError(e.to_string()) + })?; Ok(Box::new(SendWrapper(dec)) as Box) }, From e276e0d9a3c604a2cfe9bea4fe5e1acc6029a62b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 23:39:14 -0600 Subject: [PATCH 105/388] Route Python sim().stack("neo") through the unified pecos::sim() facade with fail-fast rejection of unrouted configs --- Cargo.lock | 1 + .../pecos-qasm/src/unified_engine_builder.rs | 13 ++ python/pecos-rslib/Cargo.toml | 2 + python/pecos-rslib/src/engine_builders.rs | 4 + python/pecos-rslib/src/sim.rs | 194 +++++++++++++++++- .../tests/pecos/test_sim_stack_routing.py | 108 ++++++++++ 6 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 python/quantum-pecos/tests/pecos/test_sim_stack_routing.py diff --git a/Cargo.lock b/Cargo.lock index d09cd2909..07565ca62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4275,6 +4275,7 @@ dependencies = [ "ndarray 0.17.2", "num-complex 0.4.6", "parking_lot", + "pecos", "pecos-build", "pecos-core", "pecos-cppsparsestab", diff --git a/crates/pecos-qasm/src/unified_engine_builder.rs b/crates/pecos-qasm/src/unified_engine_builder.rs index 0c44f6652..110b8c84a 100644 --- a/crates/pecos-qasm/src/unified_engine_builder.rs +++ b/crates/pecos-qasm/src/unified_engine_builder.rs @@ -162,6 +162,19 @@ impl QasmEngineBuilder { self.source.is_some() } + /// Check if this builder has a WASM foreign-function program configured + #[must_use] + pub fn has_wasm(&self) -> bool { + #[cfg(feature = "wasm")] + { + self.wasm_program.is_some() + } + #[cfg(not(feature = "wasm"))] + { + false + } + } + /// Get the `Qasm` from this builder (if any) #[must_use] pub fn get_program(&self) -> Option { diff --git a/python/pecos-rslib/Cargo.toml b/python/pecos-rslib/Cargo.toml index d0db547a2..c31890e3b 100644 --- a/python/pecos-rslib/Cargo.toml +++ b/python/pecos-rslib/Cargo.toml @@ -51,6 +51,8 @@ pecos-num.workspace = true pecos-random.workspace = true pecos-simulators.workspace = true pecos-engines.workspace = true +# Unified sim() facade with neo-stack routing (strangler-fig: Python .stack("neo")) +pecos = { workspace = true, default-features = false, features = ["neo", "hugr"] } pecos-programs.workspace = true # Program formats diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index f18ef6271..350275686 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -83,6 +83,7 @@ impl PyQasmEngineBuilder { noise_builder: None, explicit_num_qubits: None, foreign_object: None, + stack: None, }), }) } @@ -258,6 +259,7 @@ pub struct PyQasmSimBuilder { pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, pub(crate) foreign_object: Option>, + pub(crate) stack: Option, } /// Python wrapper for built QASM simulation @@ -573,6 +575,7 @@ impl PyHugrEngineBuilder { foreign_object: None, keep_intermediate_files: false, hugr_bytes: None, + stack: None, }), }) } @@ -589,6 +592,7 @@ pub struct PyHugrSimBuilder { pub(crate) foreign_object: Option>, pub(crate) keep_intermediate_files: bool, pub(crate) hugr_bytes: Option>, + pub(crate) stack: Option, } /// Python wrapper for built HUGR simulation diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 2228fa4be..8c420e645 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -10,7 +10,7 @@ use crate::prelude::*; // Import QASM WASM support use pecos_qasm::QasmEngineWasm; -use pyo3::exceptions::{PyRuntimeError, PyTypeError}; +use pyo3::exceptions::{PyRuntimeError, PyTypeError, PyValueError}; use pyo3::prelude::*; use std::sync::{Arc, Mutex}; @@ -106,6 +106,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { noise_builder: None, explicit_num_qubits: None, foreign_object: None, + stack: None, }), }) } else if let Ok(qis_prog) = program.extract::(py) { @@ -177,6 +178,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { foreign_object: None, keep_intermediate_files: false, hugr_bytes: Some(hugr_bytes), + stack: None, }), }) } else if let Ok(phir_prog) = program.extract::(py) { @@ -210,6 +212,14 @@ pub fn sim_builder() -> PySimBuilder { } } +/// Which simulation stack `run()` uses, mirroring the Rust facade's +/// `pecos::SimStack`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum PySimStack { + Engines, + Neo, +} + /// Python simulation builder /// /// This builder follows the same fluent API as the Rust `SimBuilder`, @@ -380,6 +390,43 @@ impl PySimBuilder { }) } + /// Select the simulation stack: "engines" (the default) or "neo" + /// (experimental), mirroring the Rust facade's `.stack(SimStack)`. + fn stack(&mut self, stack: &str) -> PyResult { + let parsed = match stack { + "engines" => PySimStack::Engines, + "neo" => PySimStack::Neo, + other => { + return Err(PyValueError::new_err(format!( + "Unknown simulation stack '{other}'; expected \"engines\" or \"neo\"" + ))); + } + }; + match &mut self.inner { + SimBuilderInner::Qasm(builder) => builder.stack = Some(parsed), + SimBuilderInner::Hugr(builder) => builder.stack = Some(parsed), + SimBuilderInner::QisControl(_) + | SimBuilderInner::PhirJson(_) + | SimBuilderInner::Phir(_) => { + if parsed == PySimStack::Neo { + return Err(PyValueError::new_err( + "Only QASM and HUGR programs are routed to the neo stack so far; \ + this program type runs on the engines stack", + )); + } + // "engines" is already the default for every program type. + } + SimBuilderInner::Empty => { + return Err(PyTypeError::new_err( + "Cannot select a stack on an empty builder - create with a program first", + )); + } + } + Ok(PySimBuilder { + inner: self.inner.clone(), + }) + } + /// Set number of worker threads fn workers(&mut self, workers: usize) -> PyResult { match &mut self.inner { @@ -708,6 +755,9 @@ impl PySimBuilder { match &self.inner { SimBuilderInner::Qasm(builder) => { + if builder.stack == Some(PySimStack::Neo) { + return run_qasm_neo(builder, shots); + } let mut builder_lock = builder.engine_builder.lock().expect("lock poisoned"); let engine_builder = builder_lock .take() @@ -1002,6 +1052,9 @@ impl PySimBuilder { } } SimBuilderInner::Hugr(builder) => { + if builder.stack == Some(PySimStack::Neo) { + return run_hugr_neo(builder, shots); + } // Direct HUGR interpreter let mut builder_lock = builder.engine_builder.lock().expect("lock poisoned"); let engine_builder = builder_lock @@ -1139,6 +1192,18 @@ impl PySimBuilder { use crate::engine_builders::{PyPhirJsonSimulation, PyPhirSimulation, PyQasmSimulation}; use pyo3::exceptions::PyRuntimeError; + let neo_selected = match &self.inner { + SimBuilderInner::Qasm(builder) => builder.stack == Some(PySimStack::Neo), + SimBuilderInner::Hugr(builder) => builder.stack == Some(PySimStack::Neo), + _ => false, + }; + if neo_selected { + return Err(PyRuntimeError::new_err( + "build() is not available on the neo stack (it has no reusable \ + MonteCarloEngine); call run(shots) directly or use the engines stack", + )); + } + Python::attach(|py| { match &self.inner { SimBuilderInner::Qasm(builder) => { @@ -1697,6 +1762,131 @@ impl PySimBuilder { } } +/// Route a QASM program through the unified `pecos::sim()` facade onto the +/// neo stack. Noise mapping stays centralized in the facade +/// (`map_noise_to_neo`); nothing is translated here. +fn run_qasm_neo( + builder: &PyQasmSimBuilder, + shots: usize, +) -> PyResult { + if builder.foreign_object.is_some() { + return Err(PyRuntimeError::new_err( + "WASM foreign objects are not routed to the neo stack; \ + remove .foreign_object() or use the engines stack", + )); + } + let program = { + let lock = builder.engine_builder.lock().expect("lock poisoned"); + let engine = lock + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("Builder already consumed"))?; + if engine.has_wasm() { + return Err(PyRuntimeError::new_err( + "WASM foreign objects are not routed to the neo stack; \ + remove .wasm() or use the engines stack", + )); + } + engine + .get_program() + .ok_or_else(|| PyRuntimeError::new_err("No QASM program configured"))? + }; + run_program_neo( + pecos::sim(program), + builder.seed, + builder.workers, + builder.explicit_num_qubits, + builder.quantum_engine_builder.as_ref(), + builder.noise_builder.as_ref(), + shots, + ) +} + +/// Route a HUGR program through the unified `pecos::sim()` facade onto the +/// neo stack (direct HUGR interpretation, no LLVM). +fn run_hugr_neo( + builder: &crate::engine_builders::PyHugrSimBuilder, + shots: usize, +) -> PyResult { + if builder.foreign_object.is_some() { + return Err(PyRuntimeError::new_err( + "WASM foreign objects are not routed to the neo stack; \ + remove .foreign_object() or use the engines stack", + )); + } + let bytes = builder.hugr_bytes.clone().ok_or_else(|| { + PyRuntimeError::new_err("HUGR program bytes are not available for the neo stack route") + })?; + let program = pecos_programs::Hugr::from_bytes(bytes); + run_program_neo( + pecos::sim(program), + builder.seed, + builder.workers, + builder.explicit_num_qubits, + builder.quantum_engine_builder.as_ref(), + builder.noise_builder.as_ref(), + shots, + ) +} + +/// Apply the shared configuration to a facade builder pointed at the neo +/// stack and run it. +fn run_program_neo( + facade: pecos::ProgrammedSimBuilder, + seed: Option, + workers: Option, + qubits: Option, + quantum: Option<&Py>, + noise: Option<&Py>, + shots: usize, +) -> PyResult { + use crate::engine_builders::{ + PyBiasedDepolarizingNoiseModelBuilder, PyDepolarizingNoiseModelBuilder, + PyGeneralNoiseModelBuilder, + }; + + if quantum.is_some() { + return Err(PyRuntimeError::new_err( + "Explicit quantum backends are not yet routed to the neo stack (it uses the \ + default sparse stabilizer); remove .quantum() or use the engines stack", + )); + } + + let mut facade = facade.stack(pecos::SimStack::Neo); + if let Some(seed) = seed { + facade = facade.seed(seed); + } + if let Some(workers) = workers { + facade = facade.workers(workers); + } + if let Some(n) = qubits { + facade = facade.qubits(n); + } + if let Some(noise_py) = noise { + facade = Python::attach(|py| -> PyResult<_> { + if let Ok(general) = noise_py.extract::(py) { + Ok(facade.noise(general.inner.clone())) + } else if let Ok(depolarizing) = noise_py.extract::(py) + { + Ok(facade.noise(depolarizing.inner.clone())) + } else if let Ok(biased) = noise_py.extract::(py) + { + Ok(facade.noise(biased.inner.clone())) + } else { + // The engines path silently ignores unknown noise objects; + // the neo route refuses instead so nothing runs noiseless + // by accident. + Err(PyRuntimeError::new_err( + "Unrecognized noise builder type for the neo stack route", + )) + } + })?; + } + match facade.run(shots) { + Ok(shot_vec) => Ok(crate::shot_results_bindings::PyShotVec::new(shot_vec)), + Err(e) => Err(PyRuntimeError::new_err(format!("Simulation failed: {e}"))), + } +} + // Clone implementations for the inner types impl Clone for SimBuilderInner { fn clone(&self) -> Self { @@ -1712,6 +1902,7 @@ impl Clone for SimBuilderInner { noise_builder: builder.noise_builder.as_ref().map(|obj| obj.clone_ref(py)), explicit_num_qubits: builder.explicit_num_qubits, foreign_object: builder.foreign_object.as_ref().map(|obj| obj.clone_ref(py)), + stack: builder.stack, }), SimBuilderInner::QisControl(builder) => { SimBuilderInner::QisControl(PyQisControlSimBuilder { @@ -1753,6 +1944,7 @@ impl Clone for SimBuilderInner { foreign_object: builder.foreign_object.as_ref().map(|obj| obj.clone_ref(py)), keep_intermediate_files: builder.keep_intermediate_files, hugr_bytes: builder.hugr_bytes.clone(), + stack: builder.stack, }), SimBuilderInner::Phir(builder) => SimBuilderInner::Phir(PyPhirSimBuilder { engine_builder: builder.engine_builder.clone(), diff --git a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py new file mode 100644 index 000000000..ba56a0a7f --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py @@ -0,0 +1,108 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Contract tests for routing Python sim() to the pecos-neo stack. + +Mirrors the Rust contract tests in crates/pecos/tests/neo_routing_test.rs: +the neo stack must return the same results contract as the engines stack, +with exact equality for deterministic programs and statistical agreement +under noise. +""" + +from __future__ import annotations + +import pytest +from pecos_rslib import Qasm, depolarizing_noise, sim, state_vector + +DETERMINISTIC_CONDITIONAL = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[2]; +creg c[2]; +x q[0]; +measure q[0] -> c[0]; +if (c == 1) x q[1]; +measure q[1] -> c[1]; +""" + +X_MEASURE = """ +OPENQASM 2.0; +include "qelib1.inc"; +qreg q[1]; +creg c[1]; +x q[0]; +measure q[0] -> c[0]; +""" + + +def test_neo_stack_matches_engines_for_deterministic_qasm() -> None: + engines = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).seed(42).run(5) + neo = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack("neo").seed(42).run(5) + + assert list(engines["c"]) == list(neo["c"]) + assert all(value == 3 for value in neo["c"]) # c0 = c1 = 1 + + +def test_neo_stack_parallel_matches_engines() -> None: + engines = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).seed(7).workers(2).run(6) + neo = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack("neo").seed(7).workers(2).run(6) + + assert list(engines["c"]) == list(neo["c"]) + + +def test_neo_stack_measurement_noise_rate_matches_engines() -> None: + """Measurement-only noise: P(c = 0) = p_meas on both stacks.""" + p_meas = 0.2 + shots = 4000 + + def rate_of_zero(stack: str) -> float: + noise = ( + depolarizing_noise() + .with_prep_probability(0.0) + .with_meas_probability(p_meas) + .with_p1_probability(0.0) + .with_p2_probability(0.0) + ) + builder = sim(Qasm.from_string(X_MEASURE)).noise(noise).seed(42) + if stack == "neo": + builder = builder.stack("neo") + results = builder.run(shots) + zeros = sum(1 for value in results["c"] if value == 0) + return zeros / shots + + engines_rate = rate_of_zero("engines") + neo_rate = rate_of_zero("neo") + + # ~5 sigma for p = 0.2 at 4000 shots is ~0.032. + assert abs(engines_rate - p_meas) < 0.035 + assert abs(neo_rate - p_meas) < 0.035 + + +def test_explicit_engines_stack_is_the_default_path() -> None: + default = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).seed(3).run(4) + explicit = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack("engines").seed(3).run(4) + + assert list(default["c"]) == list(explicit["c"]) + + +def test_unknown_stack_is_rejected() -> None: + with pytest.raises(ValueError, match="Unknown simulation stack"): + sim(Qasm.from_string(X_MEASURE)).stack("warp-drive") + + +def test_neo_stack_rejects_explicit_quantum_backend() -> None: + with pytest.raises(RuntimeError, match="not yet routed to the neo stack"): + sim(Qasm.from_string(X_MEASURE)).stack("neo").quantum(state_vector()).run(5) + + +def test_neo_stack_rejects_build() -> None: + with pytest.raises(RuntimeError, match="build"): + sim(Qasm.from_string(X_MEASURE)).stack("neo").build() From 47c5f3df2cbb50d63da5084004ff0dd7f1478bb8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 11 Jun 2026 23:42:04 -0600 Subject: [PATCH 106/388] Add stim_spotcheck phase confirming the fusion-vs-bp ranking transfers to the production stim-generated DEM (memory p=0.005: d=5 1.63x disjoint Jeffreys, d=3 tied), closing the study's last DEM-generator gap --- examples/surface/inner_decoder_study.py | 85 +++++++++++++++++-- .../inner_decoder_study_stim_spotcheck.jsonl | 18 ++++ 2 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py index bbe281f00..e51a69123 100644 --- a/examples/surface/inner_decoder_study.py +++ b/examples/surface/inner_decoder_study.py @@ -138,10 +138,32 @@ def intervals_disjoint(a: Cell, b: Cell) -> bool: # --------------------------------------------------------------------------- # -def measure_cell(family: str, d: int, rounds: int, p: float, seed: int, inners: list[str], n: int) -> list[Cell]: - """Sample ONE batch and decode it with every inner (paired comparison).""" +def measure_cell( + family: str, + d: int, + rounds: int, + p: float, + seed: int, + inners: list[str], + n: int, + dem_source: str = "native", +) -> list[Cell]: + """Sample ONE batch and decode it with every inner (paired comparison). + + ``dem_source="native"`` uses the PECOS-native ``build_dem`` pipeline (the + main study). ``dem_source="stim"`` uses the exact DEM the production + default decode path consumes (``LogicalCircuitBuilder.build_decoder`` with + ``use_stim_dem=True``): the stim circuit's non-decomposed detector error + model. + """ builder = FAMILIES[family](d, rounds) - dem = builder.build_dem(p1=p, p2=p, p_meas=p) + if dem_source == "stim": + import stim # analysis-only here; the production path already requires it + + stim_str = builder.to_stim(p1=p, p2=p, p_meas=p) + dem = str(stim.Circuit(stim_str).detector_error_model(ignore_decomposition_failures=True)) + else: + dem = builder.build_dem(p1=p, p2=p, p_meas=p) sc = builder.stab_coords() batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(n, seed=seed) @@ -271,6 +293,49 @@ def run_hyperedge(path: Path) -> None: ) +def run_stim_spotcheck(path: Path) -> None: + """Confirm the ranking transfers to the DEM generator the shipped default decodes. + + The production default path (``LogicalCircuitBuilder.build_decoder``, + ``use_stim_dem=True``) consumes a STIM-generated DEM, while every main study + cell used the PECOS-native ``build_dem``. This cell repeats the memory + fusion-vs-bp contrast on the stim DEM (2026-06-11 review follow-up).""" + done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} + inners = ["fusion_blossom_serial", "pecos_uf:bp", "pymatching"] + p = 0.005 + for d in [3, 5]: + for seed in [1, 2, 3]: + todo = [i for i in inners if ("memory", d, p, seed, i) not in done] + if not todo: + continue + t = time.perf_counter() + cells = measure_cell("memory", d, d, p, seed, todo, 100_000, dem_source="stim") + _append(path, cells) + print( + f"[stim_spotcheck] memory d={d} p={p:.3f} seed={seed}: " + + " ".join(f"{c.inner.split(':')[0][:6]}={c.num_errors}" for c in cells) + + f" ({time.perf_counter() - t:.1f}s)", + flush=True, + ) + # Pooled verdict for the d=5 contrast (the cell where bp is dominated on + # the native DEM): report Jeffreys intervals and disjointness. + agg = _pool(_load(path)) + for d in [3, 5]: + if ("memory", d, p, "fusion_blossom_serial") not in agg or ("memory", d, p, "pecos_uf:bp") not in agg: + continue + fk, fn = agg[("memory", d, p, "fusion_blossom_serial")] + rk, rn = agg[("memory", d, p, "pecos_uf:bp")] + flo, fhi = jeffreys_ci(fk, fn) + rlo, rhi = jeffreys_ci(rk, rn) + sep = "DISJOINT" if fhi < rlo or rhi < flo else "overlap" + ratio = (rk / rn) / (fk / fn) if fk else float("inf") + print( + f"[stim_spotcheck] pooled d={d}: fusion {fk}/{fn} [{flo:.2e},{fhi:.2e}] vs " + f"bp {rk}/{rn} [{rlo:.2e},{rhi:.2e}] -- {ratio:.2f}x, {sep}", + flush=True, + ) + + def run_speed(path: Path) -> None: """Per-shot decode throughput (build vs decode) at the costly d=7 point.""" done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} @@ -444,7 +509,11 @@ def w(s: str = "") -> None: def main() -> None: ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("--phase", required=True, choices=["suppress", "threshold", "hyperedge", "speed", "analyze", "smoke"]) + ap.add_argument( + "--phase", + required=True, + choices=["suppress", "threshold", "hyperedge", "speed", "stim_spotcheck", "analyze", "smoke"], + ) ap.add_argument("--out", type=Path, default=RESULTS_DIR) args = ap.parse_args() @@ -466,7 +535,13 @@ def main() -> None: return path = args.out / f"inner_decoder_study_{args.phase}.jsonl" - {"suppress": run_suppress, "threshold": run_threshold, "hyperedge": run_hyperedge, "speed": run_speed}[args.phase](path) + { + "suppress": run_suppress, + "threshold": run_threshold, + "hyperedge": run_hyperedge, + "speed": run_speed, + "stim_spotcheck": run_stim_spotcheck, + }[args.phase](path) print(f"[done] {args.phase} -> {path}", flush=True) diff --git a/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl b/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl new file mode 100644 index 000000000..a16f704c3 --- /dev/null +++ b/examples/surface/results/inner_decoder_study_stim_spotcheck.jsonl @@ -0,0 +1,18 @@ +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 814, "ler": 0.00814, "build_seconds": 0.0005815229378640652, "decode_seconds": 1.0498094509821385} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 841, "ler": 0.00841, "build_seconds": 0.001328750979155302, "decode_seconds": 0.8888753189239651} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 815, "ler": 0.00815, "build_seconds": 0.0007096950430423021, "decode_seconds": 0.4390910370275378} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 805, "ler": 0.00805, "build_seconds": 0.0005728199612349272, "decode_seconds": 1.0615145689807832} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 826, "ler": 0.00826, "build_seconds": 0.0008161291480064392, "decode_seconds": 0.8780001488048583} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 807, "ler": 0.00807, "build_seconds": 0.000710461987182498, "decode_seconds": 0.4365270962007344} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 836, "ler": 0.00836, "build_seconds": 0.000561530003324151, "decode_seconds": 1.042770282132551} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 854, "ler": 0.00854, "build_seconds": 0.0008523988071829081, "decode_seconds": 0.8763707531616092} +{"family": "memory", "distance": 3, "rounds": 3, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 833, "ler": 0.00833, "build_seconds": 0.0006987580563873053, "decode_seconds": 0.43262582598254085} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 534, "ler": 0.00534, "build_seconds": 0.003445373848080635, "decode_seconds": 8.91389643913135} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 897, "ler": 0.00897, "build_seconds": 0.005997291067615151, "decode_seconds": 22.61978363688104} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 1, "inner": "pymatching", "num_shots": 100000, "num_errors": 533, "ler": 0.00533, "build_seconds": 0.0036377678625285625, "decode_seconds": 1.7914626270066947} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 566, "ler": 0.00566, "build_seconds": 0.003552536014467478, "decode_seconds": 8.98982253507711} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 901, "ler": 0.00901, "build_seconds": 0.006013470934703946, "decode_seconds": 22.358651204034686} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 2, "inner": "pymatching", "num_shots": 100000, "num_errors": 568, "ler": 0.00568, "build_seconds": 0.0036590120289474726, "decode_seconds": 1.8368506969418377} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "fusion_blossom_serial", "num_shots": 100000, "num_errors": 573, "ler": 0.00573, "build_seconds": 0.003564184997230768, "decode_seconds": 9.03868287592195} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pecos_uf:bp", "num_shots": 100000, "num_errors": 931, "ler": 0.00931, "build_seconds": 0.006468330975621939, "decode_seconds": 22.687049677129835} +{"family": "memory", "distance": 5, "rounds": 5, "p": 0.005, "seed": 3, "inner": "pymatching", "num_shots": 100000, "num_errors": 572, "ler": 0.00572, "build_seconds": 0.0038262868765741587, "decode_seconds": 1.8458779270295054} From ddbf9f98276ebed5df13de5ae30e781f53660511 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 11 Jun 2026 23:42:59 -0600 Subject: [PATCH 107/388] Add sine-law idle noise support for DEM construction --- .../src/fault_tolerance/dem_builder/types.rs | 66 ++++++++++++++++- crates/pecos-qec/tests/idle_noise_tests.rs | 17 +++++ .../src/fault_tolerance_bindings.rs | 66 +++++++++++++++-- python/quantum-pecos/src/pecos/qec/dem.py | 13 ++++ .../src/pecos/qec/surface/circuit_builder.py | 30 +++++--- .../src/pecos/qec/surface/decode.py | 71 ++++++++++++++++++- 6 files changed, 244 insertions(+), 19 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index d637d19ed..51025ff67 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2491,6 +2491,15 @@ pub struct NoiseConfig { /// /// This is the legacy Z-axis alias for `p_idle_z_quadratic_rate`. pub p_idle_quadratic_rate: f64, + /// Stochastic Z-memory sine-law rate for the quadratic idle term. + /// + /// Each explicit `Idle(duration, q)` contributes a Z-fault probability + /// term `sin(p_idle_quadratic_sine_rate * duration)^2`. This preserves + /// the small-duration quadratic behavior of coherent dephasing models + /// without changing the coefficient-style `p_idle_quadratic_rate` API. + /// + /// This is the legacy Z-axis alias for `p_idle_z_quadratic_sine_rate`. + pub p_idle_quadratic_sine_rate: f64, /// Stochastic X-memory error rate linear in idle duration. pub p_idle_x_linear_rate: f64, /// Stochastic Y-memory error rate linear in idle duration. @@ -2499,6 +2508,10 @@ pub struct NoiseConfig { pub p_idle_x_quadratic_rate: f64, /// Stochastic Y-memory error rate quadratic in idle duration. pub p_idle_y_quadratic_rate: f64, + /// Stochastic X-memory sine-law rate for the quadratic idle term. + pub p_idle_x_quadratic_sine_rate: f64, + /// Stochastic Y-memory sine-law rate for the quadratic idle term. + pub p_idle_y_quadratic_sine_rate: f64, /// Per-payload local measurement-crosstalk event rate. /// /// This rate is multiplied by the selected hidden-measurement transition @@ -2658,10 +2671,13 @@ impl Default for NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, + p_idle_quadratic_sine_rate: 0.0, p_idle_x_linear_rate: 0.0, p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_idle_x_quadratic_sine_rate: 0.0, + p_idle_y_quadratic_sine_rate: 0.0, p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2688,10 +2704,13 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, + p_idle_quadratic_sine_rate: 0.0, p_idle_x_linear_rate: 0.0, p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_idle_x_quadratic_sine_rate: 0.0, + p_idle_y_quadratic_sine_rate: 0.0, p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2716,10 +2735,13 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, + p_idle_quadratic_sine_rate: 0.0, p_idle_x_linear_rate: 0.0, p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_idle_x_quadratic_sine_rate: 0.0, + p_idle_y_quadratic_sine_rate: 0.0, p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2744,10 +2766,13 @@ impl NoiseConfig { idle_rz: 0.0, p_idle_linear_rate: 0.0, p_idle_quadratic_rate: 0.0, + p_idle_quadratic_sine_rate: 0.0, p_idle_x_linear_rate: 0.0, p_idle_y_linear_rate: 0.0, p_idle_x_quadratic_rate: 0.0, p_idle_y_quadratic_rate: 0.0, + p_idle_x_quadratic_sine_rate: 0.0, + p_idle_y_quadratic_sine_rate: 0.0, p_meas_crosstalk_local: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_model: MeasurementCrosstalkTransitionModel::default(), @@ -2776,6 +2801,13 @@ impl NoiseConfig { self } + /// Sets the sine-law quadratic stochastic Z-memory rate for explicit idle gates. + #[must_use] + pub fn set_idle_quadratic_sine_rate(mut self, rate: f64) -> Self { + self.p_idle_quadratic_sine_rate = rate.max(0.0); + self + } + /// Sets the linear stochastic Pauli-memory rates for explicit idle gates. #[must_use] pub fn set_idle_pauli_linear_rates(mut self, px_rate: f64, py_rate: f64, pz_rate: f64) -> Self { @@ -2799,6 +2831,20 @@ impl NoiseConfig { self } + /// Sets the sine-law quadratic stochastic Pauli-memory rates for explicit idle gates. + #[must_use] + pub fn set_idle_pauli_quadratic_sine_rates( + mut self, + px_rate: f64, + py_rate: f64, + pz_rate: f64, + ) -> Self { + self.p_idle_x_quadratic_sine_rate = px_rate.max(0.0); + self.p_idle_y_quadratic_sine_rate = py_rate.max(0.0); + self.p_idle_quadratic_sine_rate = pz_rate.max(0.0); + self + } + /// Sets T1/T2 relaxation times for idle noise. /// /// When set, idle gates use the Pauli-twirled T1/T2 model instead of @@ -2918,10 +2964,18 @@ impl NoiseConfig { self } - fn idle_memory_probability(linear_rate: f64, quadratic_rate: f64, duration: f64) -> f64 { + fn idle_memory_probability( + linear_rate: f64, + quadratic_rate: f64, + quadratic_sine_rate: f64, + duration: f64, + ) -> f64 { let duration = duration.max(0.0); - (linear_rate.max(0.0) * duration + quadratic_rate.max(0.0) * duration * duration) - .clamp(0.0, 1.0) + let sine_angle = quadratic_sine_rate.max(0.0) * duration; + (linear_rate.max(0.0) * duration + + quadratic_rate.max(0.0) * duration * duration + + sine_angle.sin().powi(2)) + .clamp(0.0, 1.0) } /// Dedicated idle-memory Pauli probabilities for `Idle(duration, q)`. @@ -2931,16 +2985,19 @@ impl NoiseConfig { px: Self::idle_memory_probability( self.p_idle_x_linear_rate, self.p_idle_x_quadratic_rate, + self.p_idle_x_quadratic_sine_rate, duration, ), py: Self::idle_memory_probability( self.p_idle_y_linear_rate, self.p_idle_y_quadratic_rate, + self.p_idle_y_quadratic_sine_rate, duration, ), pz: Self::idle_memory_probability( self.p_idle_linear_rate, self.p_idle_quadratic_rate, + self.p_idle_quadratic_sine_rate, duration, ), }; @@ -2999,10 +3056,13 @@ impl NoiseConfig { || matches!((self.t1, self.t2), (Some(_), Some(_))) || self.p_idle_linear_rate > 0.0 || self.p_idle_quadratic_rate.abs() > f64::EPSILON + || self.p_idle_quadratic_sine_rate > 0.0 || self.p_idle_x_linear_rate > 0.0 || self.p_idle_y_linear_rate > 0.0 || self.p_idle_x_quadratic_rate > 0.0 || self.p_idle_y_quadratic_rate > 0.0 + || self.p_idle_x_quadratic_sine_rate > 0.0 + || self.p_idle_y_quadratic_sine_rate > 0.0 } } diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 4f195d58d..52fa340cb 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -246,6 +246,23 @@ fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { assert!((pauli.pz - 0.06).abs() < 1e-15); } +#[test] +fn idle_memory_pauli_probabilities_support_quadratic_sine_model() { + let z_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_quadratic_sine_rate(0.2) + .idle_memory_pauli_probs(3.0); + assert_eq!(z_sine.px, 0.0); + assert_eq!(z_sine.py, 0.0); + assert!((z_sine.pz - 0.6_f64.sin().powi(2)).abs() < 1e-15); + + let pauli_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_idle_pauli_quadratic_sine_rates(0.1, 0.2, 0.3) + .idle_memory_pauli_probs(2.0); + assert!((pauli_sine.px - 0.2_f64.sin().powi(2)).abs() < 1e-15); + assert!((pauli_sine.py - 0.4_f64.sin().powi(2)).abs() < 1e-15); + assert!((pauli_sine.pz - 0.6_f64.sin().powi(2)).abs() < 1e-15); +} + #[test] fn dem_builder_scalar_p1_does_not_attach_to_idle() { let dag = build_idle_then_measure(1); diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 7813d1d88..9b1e1452c 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -273,6 +273,10 @@ fn apply_noise_options( p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -312,6 +316,18 @@ fn apply_noise_options( if let Some(rate) = p_idle_z_quadratic_rate { noise.p_idle_quadratic_rate = rate.max(0.0); } + if let Some(rate) = p_idle_quadratic_sine_rate { + noise = noise.set_idle_quadratic_sine_rate(rate); + } + if let Some(rate) = p_idle_x_quadratic_sine_rate { + noise.p_idle_x_quadratic_sine_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_y_quadratic_sine_rate { + noise.p_idle_y_quadratic_sine_rate = rate.max(0.0); + } + if let Some(rate) = p_idle_z_quadratic_sine_rate { + noise.p_idle_quadratic_sine_rate = rate.max(0.0); + } if let Some(weights) = p1_weights { noise = noise.set_p1_weights(parse_p1_weights(weights)?); } @@ -1236,7 +1252,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1256,6 +1272,10 @@ impl PyDetectorErrorModel { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -1280,6 +1300,10 @@ impl PyDetectorErrorModel { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, p2_replacement_approximation, @@ -1638,7 +1662,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1658,6 +1682,10 @@ impl PyDemBuilder { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -1680,6 +1708,10 @@ impl PyDemBuilder { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, p2_replacement_approximation, @@ -3549,7 +3581,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3569,6 +3601,10 @@ impl PyDemSampler { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -3591,6 +3627,10 @@ impl PyDemSampler { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, p2_replacement_approximation, @@ -3708,7 +3748,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3730,6 +3770,10 @@ impl PyDemSampler { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -3752,6 +3796,10 @@ impl PyDemSampler { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, p2_replacement_approximation, @@ -4212,7 +4260,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4232,6 +4280,10 @@ impl PyDemSamplerBuilder { p_idle_x_quadratic_rate: Option, p_idle_y_quadratic_rate: Option, p_idle_z_quadratic_rate: Option, + p_idle_quadratic_sine_rate: Option, + p_idle_x_quadratic_sine_rate: Option, + p_idle_y_quadratic_sine_rate: Option, + p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, p2_replacement_approximation: Option, @@ -4254,6 +4306,10 @@ impl PyDemSamplerBuilder { p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, p2_replacement_approximation, diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 002a33757..e7c836664 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -77,6 +77,10 @@ def from_guppy( p_idle_x_quadratic_rate: float | None = None, p_idle_y_quadratic_rate: float | None = None, p_idle_z_quadratic_rate: float | None = None, + p_idle_quadratic_sine_rate: float | None = None, + p_idle_x_quadratic_sine_rate: float | None = None, + p_idle_y_quadratic_sine_rate: float | None = None, + p_idle_z_quadratic_sine_rate: float | None = None, runtime: object | None = None, seed: int = 0, ) -> _RustDetectorErrorModel: @@ -178,6 +182,11 @@ def from_guppy( p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. + p_idle_quadratic_sine_rate: Optional legacy alias for stochastic Z-memory + rate with probability ``sin(rate * duration)^2``. + p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. + p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. + p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. runtime: Optional Selene runtime selector/plugin. ``None`` selects the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. @@ -294,6 +303,10 @@ def from_guppy( p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 433c6372a..e8e85b2bc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -1716,17 +1716,18 @@ def _gate_type_name(gate: object) -> str: return str(getattr(gate_type, "name", str(gate_type).rsplit(".", maxsplit=1)[-1])) +def _format_gate_angle(angle: object) -> str: + try: + return repr(float(angle)) + except (TypeError, ValueError): + return repr(angle) + + def _gate_angles_for_message(gate: object) -> list[str]: angles = getattr(gate, "angles", None) if angles is None: angles = getattr(gate, "params", []) - formatted = [] - for angle in angles: - try: - formatted.append(repr(float(angle))) - except (TypeError, ValueError): - formatted.append(repr(angle)) - return formatted + return [_format_gate_angle(angle) for angle in angles] def get_detector_descriptors_from_tick_circuit( @@ -2489,7 +2490,7 @@ def _maximally_decompose_graphlike_dem(dem_text: str) -> str: return "\n".join(rewritten_lines) -def _build_canonical_dem_influence_map(dag: DagCircuit): +def _build_canonical_dem_influence_map(dag: DagCircuit) -> object: """Build the influence map used by the canonical Rust DEM builder. `DagFaultAnalyzer` supplies the detector influence map. Observable and @@ -2541,6 +2542,10 @@ def generate_dem_from_tick_circuit( p_idle_x_quadratic_rate: float | None = None, p_idle_y_quadratic_rate: float | None = None, p_idle_z_quadratic_rate: float | None = None, + p_idle_quadratic_sine_rate: float | None = None, + p_idle_x_quadratic_sine_rate: float | None = None, + p_idle_y_quadratic_sine_rate: float | None = None, + p_idle_z_quadratic_sine_rate: float | None = None, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -2588,6 +2593,11 @@ def generate_dem_from_tick_circuit( p_idle_x_quadratic_rate: Optional stochastic X-memory rate quadratic in idle duration. p_idle_y_quadratic_rate: Optional stochastic Y-memory rate quadratic in idle duration. p_idle_z_quadratic_rate: Optional stochastic Z-memory rate quadratic in idle duration. + p_idle_quadratic_sine_rate: Optional legacy alias for stochastic Z-memory + rate with probability ``sin(rate * duration)^2``. + p_idle_x_quadratic_sine_rate: Optional stochastic X-memory sine-law rate. + p_idle_y_quadratic_sine_rate: Optional stochastic Y-memory sine-law rate. + p_idle_z_quadratic_sine_rate: Optional stochastic Z-memory sine-law rate. decompose_errors: If True (default), decompose hyperedge errors into graphlike components using the `^` separator. Set to False to output raw hyperedges. Ignored if maximal_decomposition=True. @@ -2640,6 +2650,10 @@ def generate_dem_from_tick_circuit( p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) builder.with_num_measurements(num_measurements) if metadata_uses_records: diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 55f5058b8..5f2880453 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -123,6 +123,11 @@ class NoiseModel: p_idle_x_quadratic_rate: Stochastic X-memory rate quadratic in idle duration. p_idle_y_quadratic_rate: Stochastic Y-memory rate quadratic in idle duration. p_idle_z_quadratic_rate: Stochastic Z-memory rate quadratic in idle duration. + p_idle_quadratic_sine_rate: Legacy alias for stochastic Z-memory rate + with probability ``sin(rate * duration)^2``. + p_idle_x_quadratic_sine_rate: Stochastic X-memory sine-law rate. + p_idle_y_quadratic_sine_rate: Stochastic Y-memory sine-law rate. + p_idle_z_quadratic_sine_rate: Stochastic Z-memory sine-law rate. """ p1: float = 0.0 @@ -143,6 +148,10 @@ class NoiseModel: p_idle_x_quadratic_rate: float | None = None p_idle_y_quadratic_rate: float | None = None p_idle_z_quadratic_rate: float | None = None + p_idle_quadratic_sine_rate: float | None = None + p_idle_x_quadratic_sine_rate: float | None = None + p_idle_y_quadratic_sine_rate: float | None = None + p_idle_z_quadratic_sine_rate: float | None = None def __post_init__(self) -> None: """Normalize cache-sensitive inputs after dataclass initialization.""" @@ -159,6 +168,13 @@ def effective_p_idle_z_quadratic_rate(self) -> float | None: """Z-axis quadratic idle rate, accepting the legacy alias.""" return self.p_idle_z_quadratic_rate if self.p_idle_z_quadratic_rate is not None else self.p_idle_quadratic_rate + @property + def effective_p_idle_z_quadratic_sine_rate(self) -> float | None: + """Z-axis sine-law quadratic idle rate, accepting the legacy alias.""" + if self.p_idle_z_quadratic_sine_rate is not None: + return self.p_idle_z_quadratic_sine_rate + return self.p_idle_quadratic_sine_rate + @property def idle_memory_rates(self) -> tuple[float | None, ...]: """All dedicated Pauli idle-memory rates that require explicit idles.""" @@ -169,6 +185,9 @@ def idle_memory_rates(self) -> tuple[float | None, ...]: self.p_idle_x_quadratic_rate, self.p_idle_y_quadratic_rate, self.effective_p_idle_z_quadratic_rate, + self.p_idle_x_quadratic_sine_rate, + self.p_idle_y_quadratic_sine_rate, + self.effective_p_idle_z_quadratic_sine_rate, ) @staticmethod @@ -1432,6 +1451,10 @@ def _uses_dedicated_idle_noise( p_idle_x_quadratic_rate: float | None = None, p_idle_y_quadratic_rate: float | None = None, p_idle_z_quadratic_rate: float | None = None, + p_idle_quadratic_sine_rate: float | None = None, + p_idle_x_quadratic_sine_rate: float | None = None, + p_idle_y_quadratic_sine_rate: float | None = None, + p_idle_z_quadratic_sine_rate: float | None = None, ) -> bool: """Return True when noise parameters require explicit idle locations.""" return ( @@ -1448,6 +1471,10 @@ def _uses_dedicated_idle_noise( p_idle_x_quadratic_rate, p_idle_y_quadratic_rate, p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate, ) ) ) @@ -1467,6 +1494,10 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, ) @@ -1573,6 +1604,10 @@ def _dem_string_from_cached_surface_topology( "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } @@ -1633,6 +1668,10 @@ def _cached_surface_native_dem_string( p_idle_x_quadratic_rate: float | None = None, p_idle_y_quadratic_rate: float | None = None, p_idle_z_quadratic_rate: float | None = None, + p_idle_quadratic_sine_rate: float | None = None, + p_idle_x_quadratic_sine_rate: float | None = None, + p_idle_y_quadratic_sine_rate: float | None = None, + p_idle_z_quadratic_sine_rate: float | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" include_idle_gates = _uses_dedicated_idle_noise( @@ -1647,6 +1686,10 @@ def _cached_surface_native_dem_string( p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) topology = _cached_surface_native_topology( patch_key, @@ -1677,6 +1720,10 @@ def _cached_surface_native_dem_string( p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ), decompose_errors=decompose_errors, ) @@ -1701,7 +1748,7 @@ def _build_native_sampler_from_cached_surface_topology( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", ) -> NativeSampler: """Construct a native sampler from cached topology-only analysis.""" - from pecos.qec import DemSampler, ParsedDem + from pecos.qec import ParsedDem if sampling_model == "dem": dem_str = _dem_string_from_cached_surface_topology( @@ -1731,6 +1778,10 @@ def _build_native_sampler_from_cached_surface_topology( p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, p1_weights=_p1_weights_dict(noise.p1_weights), p2_weights=_p2_weights_dict(noise.p2_weights), p2_replacement_approximation=noise.p2_replacement_approximation, @@ -1854,6 +1905,10 @@ def generate_circuit_level_dem_from_builder( p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, ) @@ -2781,6 +2836,7 @@ def _compute_dem_detection_events_z( synx_list: X syndrome arrays, one per round synz_list: Z syndrome arrays, one per round final: Final data qubit measurements + init_synx: Initial X-syndrome baseline measured during logical prep Returns: Detection events array matching the DEM detector ordering @@ -2798,7 +2854,9 @@ def _compute_dem_detection_events_z( events: list[int] = [] if self.num_rounds > 0: - assert init_synx is not None + if init_synx is None: + msg = "init_synx is required for Z-basis circuit-level DEM decoding" + raise ValueError(msg) init_synx_array = np.array(init_synx, dtype=np.uint8) if init_synx_array.shape != synx[0].shape: msg = f"init_synx has shape {init_synx_array.shape}, expected {synx[0].shape}" @@ -2844,6 +2902,7 @@ def _compute_dem_detection_events_x( synx_list: X syndrome arrays, one per round synz_list: Z syndrome arrays, one per round final: Final data qubit measurements + init_synz: Initial Z-syndrome baseline measured during logical prep Returns: Detection events array matching the DEM detector ordering @@ -2861,7 +2920,9 @@ def _compute_dem_detection_events_x( events: list[int] = [] if self.num_rounds > 0: - assert init_synz is not None + if init_synz is None: + msg = "init_synz is required for X-basis circuit-level DEM decoding" + raise ValueError(msg) init_synz_array = np.array(init_synz, dtype=np.uint8) if init_synz_array.shape != synz[0].shape: msg = f"init_synz has shape {init_synz_array.shape}, expected {synz[0].shape}" @@ -3573,6 +3634,10 @@ def build_native_sampler( p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( From e023b9f580820bf9a7ff03b0076490617eec3e75 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 00:09:02 -0600 Subject: [PATCH 108/388] Add Guppy surface Pauli twirl handoff --- crates/pecos-qec/src/fault_tolerance.rs | 2 + .../src/fault_tolerance/pauli_frame.rs | 554 ++++++++++++++++++ crates/pecos-quantum/src/dag_circuit.rs | 6 + crates/pecos-quantum/src/tick_circuit.rs | 17 +- .../src/fault_tolerance_bindings.rs | 232 ++++++++ .../quantum-pecos/src/pecos/guppy/surface.py | 475 +++++++++++++-- .../quantum-pecos/src/pecos/qec/__init__.py | 2 + python/quantum-pecos/src/pecos/qec/dem.py | 55 +- .../src/pecos/qec/surface/__init__.py | 14 + .../pecos/qec/surface/_detection_events.py | 66 +++ .../src/pecos/qec/surface/_twirl_config.py | 131 +++++ .../src/pecos/qec/surface/_twirl_sites.py | 119 ++++ .../src/pecos/qec/surface/circuit_builder.py | 78 ++- .../src/pecos/qec/surface/decode.py | 507 ++++++++++++++-- .../tests/guppy/test_surface_twirl_render.py | 145 +++++ .../qec/surface/test_pauli_mask_harvest.py | 393 +++++++++++++ .../qec/surface/test_pauli_twirl_handoff.py | 192 ++++++ 17 files changed, 2858 insertions(+), 130 deletions(-) create mode 100644 crates/pecos-qec/src/fault_tolerance/pauli_frame.rs create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_detection_events.py create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py create mode 100644 python/quantum-pecos/tests/guppy/test_surface_twirl_render.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index 8aa05b7a7..7997b32eb 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -25,6 +25,7 @@ pub mod fault_sampler; pub mod gadget_checker; pub mod influence_builder; pub mod lookup_decoder; +pub mod pauli_frame; pub mod pauli_prop_checker; pub mod propagator; pub mod stabilizer_flip_checker; @@ -47,6 +48,7 @@ pub use gadget_checker::{ GadgetSyndromeAnalysis, }; pub use influence_builder::InfluenceBuilder; +pub use pauli_frame::{PauliFrameLookup, PauliFrameLookupError}; pub use pauli_prop_checker::{ DecoderAnalysis, FaultClass, FaultToleranceAnalysis, FaultToleranceFailure, FollowUpConfig, MeasurementRound, PauliPropChecker, PropagationResult, SyndromeAnalysis, SyndromeClass, diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs new file mode 100644 index 000000000..f2d7f5213 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs @@ -0,0 +1,554 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Pauli-frame lookup support for sampling Pauli-twirl masks. +//! +//! Twirl sites are emitted as three positional tracked-Pauli annotations per +//! site: X, Y, and Z. The DEM sampler samples decoder-facing detector and +//! observable bits. This lookup adds the deterministic frame update induced by a +//! user-supplied Pauli mask by XOR-ing precomputed detector/observable rows into +//! sampled shots. + +use super::dem_builder::record_offset_to_absolute_index; +use super::propagator::{Direction, apply_gate}; +use pecos_core::gate_type::GateType; +use pecos_core::{Pauli, PauliString}; +use pecos_quantum::{AnnotationKind, DagCircuit}; +use pecos_simulators::PauliProp; +use std::collections::{BTreeMap, BTreeSet}; +use thiserror::Error; + +type MeasurementRecordMap = BTreeMap>; + +/// Errors returned while building or applying a Pauli-frame lookup. +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum PauliFrameLookupError { + /// A tracked-Pauli annotation has no `meta_node` set. + #[error( + "tracked-Pauli annotation is missing its meta_node; cannot determine spacetime position" + )] + MissingMetaNode, + + /// A tracked-Pauli annotation's `meta_node` does not point at a + /// `TrackedPauliMeta` gate in the DAG. + #[error( + "tracked-Pauli annotation references DAG node {meta_node}, which is missing or not a TrackedPauliMeta gate" + )] + MetaNodeNotTrackedPauliMeta { meta_node: usize }, + + /// A measurement gate has malformed measurement IDs. + #[error("measurement node {node} has {meas_ids} measurement id(s) for {qubits} qubit(s)")] + MalformedMeasurementIds { + node: usize, + meas_ids: usize, + qubits: usize, + }, + + /// Detector/observable metadata references a measurement record outside the + /// circuit's measurement range. + #[error( + "{kind} {output} references measurement record offset {record}, but the circuit has {num_measurements} measurement(s)" + )] + InvalidRecordOffset { + kind: &'static str, + output: usize, + record: i32, + num_measurements: usize, + }, + + /// Twirl mask composition requires X/Y/Z triples per site. + #[error("tracked-Pauli count {num_tracked_paulis} is not divisible by 3")] + NonTripletTrackedPaulis { num_tracked_paulis: usize }, + + /// The flat mask buffer length does not match the supplied shape. + #[error("pauli mask buffer has length {len}, expected {expected} for shape ({rows}, {cols})")] + MaskLengthMismatch { + len: usize, + expected: usize, + rows: usize, + cols: usize, + }, + + /// The number of mask rows must match the number of sampled shots. + #[error("pauli mask row count {mask_rows} does not match num_shots {num_shots}")] + MaskShotMismatch { mask_rows: usize, num_shots: usize }, + + /// The number of mask columns must match the number of Pauli-twirl sites. + #[error("pauli mask column count {mask_cols} does not match num_pauli_sites {num_pauli_sites}")] + MaskSiteMismatch { + mask_cols: usize, + num_pauli_sites: usize, + }, + + /// Mask values must use 0=I, 1=X, 2=Y, 3=Z. + #[error("pauli mask value {value} at row {row}, column {col} is outside 0..=3")] + InvalidMaskValue { row: usize, col: usize, value: u8 }, + + /// A sampled output row does not match the lookup dimensions. + #[error("{kind} row {row} has length {actual}, expected {expected}")] + OutputWidthMismatch { + kind: &'static str, + row: usize, + actual: usize, + expected: usize, + }, +} + +/// Deterministic lookup from tracked-Pauli mask values to detector/observable flips. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PauliFrameLookup { + num_pauli_sites: usize, + num_detectors: usize, + num_observables: usize, + detector_rows: Vec>, + observable_rows: Vec>, +} + +impl PauliFrameLookup { + /// Build a Pauli-frame lookup from a DAG circuit and record-based detector + /// and observable definitions. + /// + /// The circuit must carry positional tracked-Pauli annotations. The tracked + /// Paulis are interpreted in groups of three per site, ordered X, Y, Z by + /// the surface-code emitter. + /// + /// # Errors + /// + /// Returns an error when tracked-Pauli metadata is malformed, when tracked + /// annotations are not X/Y/Z triples, or when detector/observable record + /// offsets reference missing measurements. + pub fn from_circuit( + dag: &DagCircuit, + detector_records: &[Vec], + observable_records: &[Vec], + ) -> Result { + let tracked_annotations: Vec<&pecos_quantum::PauliAnnotation> = dag + .annotations() + .iter() + .filter(|ann| matches!(ann.kind, AnnotationKind::TrackedPauli)) + .collect(); + let mut meta_nodes: Vec = dag + .nodes() + .into_iter() + .filter(|&node| { + dag.gate(node) + .is_some_and(|gate| gate.gate_type == GateType::TrackedPauliMeta) + }) + .collect(); + meta_nodes.sort_unstable(); + + if tracked_annotations.len() != meta_nodes.len() { + return Err(PauliFrameLookupError::MissingMetaNode); + } + let tracked: Vec<(&pecos_quantum::PauliAnnotation, usize)> = + tracked_annotations.into_iter().zip(meta_nodes).collect(); + if tracked.len() % 3 != 0 { + return Err(PauliFrameLookupError::NonTripletTrackedPaulis { + num_tracked_paulis: tracked.len(), + }); + } + + let topo_order = dag.topological_order(); + let topo_positions: BTreeMap = topo_order + .iter() + .enumerate() + .map(|(pos, &node)| (node, pos)) + .collect(); + let (measurement_records, num_measurements) = measurement_records_by_node(dag)?; + let detectors_by_measurement = + outputs_by_measurement(num_measurements, detector_records, "detector")?; + let observables_by_measurement = + outputs_by_measurement(num_measurements, observable_records, "observable")?; + + let mut detector_rows = Vec::with_capacity(tracked.len()); + let mut observable_rows = Vec::with_capacity(tracked.len()); + + for (ann, meta_node) in &tracked { + if !dag + .gate(*meta_node) + .is_some_and(|gate| gate.gate_type == GateType::TrackedPauliMeta) + { + return Err(PauliFrameLookupError::MetaNodeNotTrackedPauliMeta { + meta_node: *meta_node, + }); + } + let start_pos = *topo_positions.get(meta_node).ok_or( + PauliFrameLookupError::MetaNodeNotTrackedPauliMeta { + meta_node: *meta_node, + }, + )?; + let affected_measurements = propagate_tracked_pauli_forward( + dag, + &topo_order, + &measurement_records, + start_pos, + &ann.pauli, + ); + detector_rows.push(measurements_to_output_row( + &affected_measurements, + &detectors_by_measurement, + )); + observable_rows.push(measurements_to_output_row( + &affected_measurements, + &observables_by_measurement, + )); + } + + Ok(Self { + num_pauli_sites: tracked.len() / 3, + num_detectors: detector_records.len(), + num_observables: observable_records.len(), + detector_rows, + observable_rows, + }) + } + + /// Number of mask sites. Each site has three tracked rows: X, Y, Z. + #[must_use] + pub fn num_pauli_sites(&self) -> usize { + self.num_pauli_sites + } + + /// Number of tracked-Pauli rows in the lookup. + #[must_use] + pub fn num_tracked_paulis(&self) -> usize { + self.detector_rows.len() + } + + /// Number of detector output columns. + #[must_use] + pub fn num_detectors(&self) -> usize { + self.num_detectors + } + + /// Number of observable output columns. + #[must_use] + pub fn num_observables(&self) -> usize { + self.num_observables + } + + /// Return the detector and observable row for one tracked-Pauli index. + #[must_use] + pub fn row_effects(&self, tracked_idx: usize) -> Option<(&[u32], &[u32])> { + self.detector_rows + .get(tracked_idx) + .zip(self.observable_rows.get(tracked_idx)) + .map(|(det, obs)| (det.as_slice(), obs.as_slice())) + } + + /// Convert a flat `(num_shots, num_pauli_sites)` mask buffer to tracked-row firings. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the lookup or when + /// any mask value is outside `0..=3`. + pub fn mask_firings( + &self, + masks: &[u8], + rows: usize, + cols: usize, + ) -> Result>, PauliFrameLookupError> { + self.validate_mask_shape(masks, rows, cols, rows)?; + let mut firings = vec![vec![false; self.num_tracked_paulis()]; rows]; + for row in 0..rows { + for col in 0..cols { + let value = masks[row * cols + col]; + if value != 0 { + firings[row][mask_value_to_tracked_idx(col, value)] = true; + } + } + } + Ok(firings) + } + + /// Compute the mask-induced XOR pattern for detectors and observables. + /// + /// Returns `(det_xor, obs_xor)` where `det_xor[i]` is the detector XOR + /// pattern for shot `i` and `obs_xor[i]` is the observable XOR pattern. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the lookup or when + /// any mask value is outside `0..=3`. + pub fn compute_mask_xor( + &self, + masks: &[u8], + rows: usize, + cols: usize, + ) -> Result<(Vec>, Vec>), PauliFrameLookupError> { + let mut det_xor = vec![vec![false; self.num_detectors]; rows]; + let mut obs_xor = vec![vec![false; self.num_observables]; rows]; + self.apply_mask_values(masks, rows, cols, &mut det_xor, &mut obs_xor)?; + Ok((det_xor, obs_xor)) + } + + /// XOR mask-induced frame flips into sampled detector and observable rows. + /// + /// # Errors + /// + /// Returns an error when the mask shape does not match the sampled batch, + /// when any mask value is outside `0..=3`, or when sampled output row widths + /// do not match the lookup dimensions. + pub fn apply_mask_values( + &self, + masks: &[u8], + rows: usize, + cols: usize, + det_events: &mut [Vec], + obs_flips: &mut [Vec], + ) -> Result<(), PauliFrameLookupError> { + self.validate_mask_shape(masks, rows, cols, det_events.len())?; + if obs_flips.len() != rows { + return Err(PauliFrameLookupError::MaskShotMismatch { + mask_rows: rows, + num_shots: obs_flips.len(), + }); + } + + for row in 0..rows { + if det_events[row].len() != self.num_detectors { + return Err(PauliFrameLookupError::OutputWidthMismatch { + kind: "detector", + row, + actual: det_events[row].len(), + expected: self.num_detectors, + }); + } + if obs_flips[row].len() != self.num_observables { + return Err(PauliFrameLookupError::OutputWidthMismatch { + kind: "observable", + row, + actual: obs_flips[row].len(), + expected: self.num_observables, + }); + } + + for col in 0..cols { + let value = masks[row * cols + col]; + if value == 0 { + continue; + } + let tracked_idx = mask_value_to_tracked_idx(col, value); + xor_row(&mut det_events[row], &self.detector_rows[tracked_idx]); + xor_row(&mut obs_flips[row], &self.observable_rows[tracked_idx]); + } + } + + Ok(()) + } + + fn validate_mask_shape( + &self, + masks: &[u8], + rows: usize, + cols: usize, + num_shots: usize, + ) -> Result<(), PauliFrameLookupError> { + let expected = rows.saturating_mul(cols); + if masks.len() != expected { + return Err(PauliFrameLookupError::MaskLengthMismatch { + len: masks.len(), + expected, + rows, + cols, + }); + } + if rows != num_shots { + return Err(PauliFrameLookupError::MaskShotMismatch { + mask_rows: rows, + num_shots, + }); + } + if cols != self.num_pauli_sites { + return Err(PauliFrameLookupError::MaskSiteMismatch { + mask_cols: cols, + num_pauli_sites: self.num_pauli_sites, + }); + } + for row in 0..rows { + for col in 0..cols { + let value = masks[row * cols + col]; + if value > 3 { + return Err(PauliFrameLookupError::InvalidMaskValue { row, col, value }); + } + } + } + Ok(()) + } +} + +fn mask_value_to_tracked_idx(site_idx: usize, value: u8) -> usize { + site_idx * 3 + usize::from(value - 1) +} + +fn xor_row(row: &mut [bool], indices: &[u32]) { + for &idx in indices { + if let Some(bit) = row.get_mut(idx as usize) { + *bit = !*bit; + } + } +} + +fn measurement_records_by_node( + dag: &DagCircuit, +) -> Result<(MeasurementRecordMap, usize), PauliFrameLookupError> { + let mut by_node = BTreeMap::new(); + let mut next_record = 0usize; + let mut num_measurements = 0usize; + + for node in dag.topological_order() { + let Some(gate) = dag.gate(node) else { + continue; + }; + if !matches!( + gate.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) { + continue; + } + if !gate.meas_ids.is_empty() && gate.meas_ids.len() != gate.qubits.len() { + return Err(PauliFrameLookupError::MalformedMeasurementIds { + node, + meas_ids: gate.meas_ids.len(), + qubits: gate.qubits.len(), + }); + } + + let mut entries = Vec::with_capacity(gate.qubits.len()); + for (idx, qubit) in gate.qubits.iter().enumerate() { + let record = if gate.meas_ids.is_empty() { + let record = next_record; + next_record += 1; + record + } else { + gate.meas_ids[idx].index() + }; + num_measurements = num_measurements.max(record + 1); + entries.push((qubit.index(), record)); + } + by_node.insert(node, entries); + } + + Ok((by_node, num_measurements.max(next_record))) +} + +fn outputs_by_measurement( + num_measurements: usize, + records_by_output: &[Vec], + kind: &'static str, +) -> Result>, PauliFrameLookupError> { + let mut outputs = vec![Vec::new(); num_measurements]; + for (output, records) in records_by_output.iter().enumerate() { + for &record in records { + let Some(measurement) = record_offset_to_absolute_index(num_measurements, record) + else { + return Err(PauliFrameLookupError::InvalidRecordOffset { + kind, + output, + record, + num_measurements, + }); + }; + if measurement >= num_measurements { + return Err(PauliFrameLookupError::InvalidRecordOffset { + kind, + output, + record, + num_measurements, + }); + } + outputs[measurement].push(output); + } + } + Ok(outputs) +} + +fn propagate_tracked_pauli_forward( + dag: &DagCircuit, + topo_order: &[usize], + measurement_records: &BTreeMap>, + start_pos: usize, + pauli: &PauliString, +) -> BTreeSet { + let mut prop = pauli_prop_from_string(pauli); + let mut affected_measurements = BTreeSet::new(); + + for &node in topo_order.iter().skip(start_pos + 1) { + let Some(gate) = dag.gate(node) else { + continue; + }; + match gate.gate_type { + GateType::TrackedPauliMeta => {} + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { + if let Some(entries) = measurement_records.get(&node) { + for &(qubit, record) in entries { + if prop.contains_x(qubit) { + affected_measurements.insert(record); + } + clear_qubit(&mut prop, qubit); + } + } + } + GateType::PZ | GateType::QAlloc => { + for qubit in &gate.qubits { + clear_qubit(&mut prop, qubit.index()); + } + } + _ => apply_gate(&mut prop, gate, Direction::Forward), + } + } + + affected_measurements +} + +fn measurements_to_output_row( + measurements: &BTreeSet, + outputs_by_measurement: &[Vec], +) -> Vec { + let mut outputs = BTreeSet::new(); + for &measurement in measurements { + if let Some(row) = outputs_by_measurement.get(measurement) { + for &output in row { + if !outputs.remove(&output) { + outputs.insert(output); + } + } + } + } + outputs + .into_iter() + .map(|idx| u32::try_from(idx).expect("detector/observable index must fit into u32")) + .collect() +} + +fn pauli_prop_from_string(pauli: &PauliString) -> PauliProp { + let mut prop = PauliProp::new(); + for (pauli, qubit) in pauli.iter_pairs() { + let qubit = qubit.index(); + match pauli { + Pauli::I => {} + Pauli::X => prop.track_x(&[qubit]), + Pauli::Z => prop.track_z(&[qubit]), + Pauli::Y => prop.track_y(&[qubit]), + } + } + prop +} + +fn clear_qubit(prop: &mut PauliProp, qubit: usize) { + if prop.contains_x(qubit) { + prop.track_x(&[qubit]); + } + if prop.contains_z(qubit) { + prop.track_z(&[qubit]); + } +} diff --git a/crates/pecos-quantum/src/dag_circuit.rs b/crates/pecos-quantum/src/dag_circuit.rs index 7086a6dcf..77de172ff 100644 --- a/crates/pecos-quantum/src/dag_circuit.rs +++ b/crates/pecos-quantum/src/dag_circuit.rs @@ -1832,6 +1832,12 @@ impl DagCircuit { self.annotations.push(ann); } + /// Add a pre-built annotation when the corresponding tracked-Pauli meta + /// gate has already been inserted into the DAG. + pub(crate) fn add_annotation_without_meta_gate(&mut self, ann: PauliAnnotation) { + self.annotations.push(ann); + } + /// Get detector annotations. pub fn detectors(&self) -> impl Iterator { self.annotations diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index dbfc3ed8b..8f334300f 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -2229,6 +2229,7 @@ impl TickCircuit { pub fn tracked_pauli(&mut self, mut pauli: pecos_core::PauliString) -> usize { pauli.set_phase(pecos_core::QuarterPhase::PlusOne); let idx = self.annotations.len(); + self.insert_pauli_meta_tick(&pauli); self.annotations.push(PauliAnnotation { pauli, kind: AnnotationKind::TrackedPauli, @@ -2244,6 +2245,13 @@ impl TickCircuit { idx } + /// Insert a `TrackedPauliMeta` batch in its own tick at the current point. + fn insert_pauli_meta_tick(&mut self, pauli: &pecos_core::PauliString) { + let qubits: Vec = pauli.qubits().into_iter().map(QubitId::from).collect(); + let gate = Gate::simple(GateType::TrackedPauliMeta, qubits); + self.tick().add_gate(gate); + } + /// Get all annotations. #[must_use] pub fn annotations(&self) -> &[PauliAnnotation] { @@ -3395,11 +3403,16 @@ impl From<&TickCircuit> for DagCircuit { } AnnotationKind::TrackedPauli => AnnotationKind::TrackedPauli, }; - dag.add_annotation(PauliAnnotation { + let remapped_annotation = PauliAnnotation { pauli: ann.pauli.clone(), kind: remapped_kind, label: ann.label.clone(), - }); + }; + if matches!(remapped_annotation.kind, AnnotationKind::TrackedPauli) { + dag.add_annotation_without_meta_gate(remapped_annotation); + } else { + dag.add_annotation(remapped_annotation); + } } dag diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 9b1e1452c..80b0831e8 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -42,6 +42,8 @@ //! has_syndrome, causes_logical = influence_map.classify_fault(0, 1) # loc 0, X fault //! ``` +use crate::pecos_array::{Array, ArrayData}; +use pecos_qec::fault_tolerance::PauliFrameLookup as RustPauliFrameLookup; use pecos_qec::fault_tolerance::dem_builder::{ ComparisonMethod as RustComparisonMethod, ContributionEffectSummary as RustContributionEffectSummary, @@ -994,6 +996,178 @@ impl PyInfluenceBuilder { } } +// ============================================================================= +// Pauli Frame Lookup +// ============================================================================= + +#[pyclass(name = "PauliFrameLookup", module = "pecos_rslib.qec")] +pub struct PyPauliFrameLookup { + inner: RustPauliFrameLookup, +} + +#[pymethods] +impl PyPauliFrameLookup { + /// Build a Pauli-frame lookup from positional tracked-Pauli annotations. + /// + /// Args: + /// dag: A `DagCircuit` carrying tracked-Pauli meta-gates. + /// detectors: Detector definitions as measurement-record offsets. + /// observables: Observable definitions as measurement-record offsets. + #[staticmethod] + #[pyo3(signature = (dag, detectors, observables))] + fn from_circuit( + dag: &crate::dag_circuit_bindings::PyDagCircuit, + detectors: Vec>, + observables: Vec>, + ) -> PyResult { + let inner = RustPauliFrameLookup::from_circuit(&dag.inner, &detectors, &observables) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) + } + + /// Number of Pauli-twirl mask sites. + #[getter] + fn num_pauli_sites(&self) -> usize { + self.inner.num_pauli_sites() + } + + /// Number of tracked-Pauli rows. + #[getter] + fn num_tracked_paulis(&self) -> usize { + self.inner.num_tracked_paulis() + } + + /// Number of detector columns. + #[getter] + fn num_detectors(&self) -> usize { + self.inner.num_detectors() + } + + /// Number of observable columns. + #[getter] + fn num_observables(&self) -> usize { + self.inner.num_observables() + } + + /// Return one tracked-Pauli row as `(detectors, observables)`. + fn row(&self, tracked_idx: usize) -> PyResult<(Vec, Vec)> { + let Some((detectors, observables)) = self.inner.row_effects(tracked_idx) else { + return Err(pyo3::exceptions::PyIndexError::new_err(format!( + "tracked_idx {tracked_idx} is out of range" + ))); + }; + Ok((detectors.to_vec(), observables.to_vec())) + } + + /// Decode a Pauli mask array into tracked-row firings. + fn mask_firings(&self, pauli_masks: &Bound<'_, pyo3::PyAny>) -> PyResult>> { + let (values, rows, cols) = extract_pauli_mask_values(pauli_masks)?; + self.inner + .mask_firings(&values, rows, cols) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + /// Compute per-shot detector/observable XOR patterns for the given masks. + fn compute_mask_xor( + &self, + pauli_masks: &Bound<'_, pyo3::PyAny>, + ) -> PyResult<(Vec>, Vec>)> { + let (values, rows, cols) = extract_pauli_mask_values(pauli_masks)?; + self.inner + .compute_mask_xor(&values, rows, cols) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn __repr__(&self) -> String { + format!( + "PauliFrameLookup(num_pauli_sites={}, num_tracked_paulis={}, num_detectors={}, num_observables={})", + self.num_pauli_sites(), + self.num_tracked_paulis(), + self.num_detectors(), + self.num_observables(), + ) + } +} + +fn extract_pauli_mask_values( + pauli_masks: &Bound<'_, pyo3::PyAny>, +) -> PyResult<(Vec, usize, usize)> { + let array = Array::from_python_value(pauli_masks, None)?; + match &array.data { + ArrayData::I8(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I16(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I32(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::I64(arr) => collect_signed_pauli_mask_values(arr), + ArrayData::U8(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U16(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U32(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::U64(arr) => collect_unsigned_pauli_mask_values(arr), + ArrayData::Bool(_) + | ArrayData::F32(_) + | ArrayData::F64(_) + | ArrayData::Complex64(_) + | ArrayData::Complex128(_) + | ArrayData::Pauli(_) + | ArrayData::PauliString(_) => Err(pyo3::exceptions::PyTypeError::new_err( + "pauli_masks must be an integer Array with values 0=I, 1=X, 2=Y, 3=Z", + )), + } +} + +fn pauli_mask_shape(arr: &ndarray::ArrayD) -> PyResult<(usize, usize)> { + let shape = arr.shape(); + if shape.len() != 2 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks must be 2-D with shape (num_shots, num_pauli_sites), got shape {shape:?}" + ))); + } + Ok((shape[0], shape[1])) +} + +fn collect_signed_pauli_mask_values( + arr: &ndarray::ArrayD, +) -> PyResult<(Vec, usize, usize)> +where + T: Copy + Into, +{ + let (rows, cols) = pauli_mask_shape(arr)?; + let mut values = Vec::with_capacity(arr.len()); + for (idx, value) in arr.iter().copied().enumerate() { + let value = value.into(); + if !(0..=3).contains(&value) { + let row = idx / cols; + let col = idx % cols; + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks[{row}, {col}]={value} is outside 0..=3" + ))); + } + values.push(u8::try_from(value).expect("validated pauli mask value fits in u8")); + } + Ok((values, rows, cols)) +} + +fn collect_unsigned_pauli_mask_values( + arr: &ndarray::ArrayD, +) -> PyResult<(Vec, usize, usize)> +where + T: Copy + Into, +{ + let (rows, cols) = pauli_mask_shape(arr)?; + let mut values = Vec::with_capacity(arr.len()); + for (idx, value) in arr.iter().copied().enumerate() { + let value = value.into(); + if value > 3 { + let row = idx / cols; + let col = idx % cols; + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli_masks[{row}, {col}]={value} is outside 0..=3" + ))); + } + values.push(u8::try_from(value).expect("validated pauli mask value fits in u8")); + } + Ok((values, rows, cols)) +} + // ============================================================================= // Detector Error Model // ============================================================================= @@ -3929,6 +4103,63 @@ impl PyDemSampler { self.inner.sample_batch(num_shots, &mut rng) } + /// Sample multiple shots and XOR a known Pauli-frame mask into the outputs. + /// + /// Args: + /// `num_shots`: Number of shots to sample. + /// lookup: Pauli-frame lookup built from the same circuit metadata. + /// `pauli_masks`: Integer array with shape `(num_shots, num_pauli_sites)`. + /// Values are 0=I, 1=X, 2=Y, 3=Z. + /// seed: Optional random seed for reproducibility. + /// + /// Returns: + /// Tuple of (`all_detection_events`, `all_dem_output_flips`). + #[pyo3(signature = (num_shots, lookup, pauli_masks, seed=None))] + fn sample_batch_with_pauli_masks( + &self, + num_shots: usize, + lookup: &PyPauliFrameLookup, + pauli_masks: &Bound<'_, pyo3::PyAny>, + seed: Option, + ) -> PyResult<(Vec>, Vec>)> { + use pecos_random::PecosRng; + use rand::RngExt; + + if lookup.inner.num_detectors() != self.inner.num_outputs() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli frame lookup has {} detector(s), sampler has {}", + lookup.inner.num_detectors(), + self.inner.num_outputs() + ))); + } + if lookup.inner.num_observables() != self.inner.num_dem_outputs() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "pauli frame lookup has {} observable(s), sampler has {}", + lookup.inner.num_observables(), + self.inner.num_dem_outputs() + ))); + } + + let (mask_values, mask_rows, mask_cols) = extract_pauli_mask_values(pauli_masks)?; + let mut rng = match seed { + Some(s) => PecosRng::seed_from_u64(s), + None => PecosRng::seed_from_u64(rand::rng().random()), + }; + + let (mut det_events, mut obs_flips) = self.inner.sample_batch(num_shots, &mut rng); + lookup + .inner + .apply_mask_values( + &mask_values, + mask_rows, + mask_cols, + &mut det_events, + &mut obs_flips, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok((det_events, obs_flips)) + } + /// Sample direct tracked-Pauli flips. /// /// Raises: @@ -6123,6 +6354,7 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; + qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 9bd615176..97aa6fabf 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -20,7 +20,7 @@ from pecos.qec.surface.schedule import compute_cnot_schedule if TYPE_CHECKING: - from pecos.qec.surface import SurfacePatch + from pecos.qec.surface import GuppyRngMaskConfig, SurfacePatch, TwirlConfig # Module state container (avoids global statement) @@ -45,10 +45,73 @@ def _get_temp_dir() -> Path: return _state.temp_dir +def _render_inline_pcg32() -> list[str]: + """Render Guppy-local PCG32 helpers for runtime twirl masks. + + The user seed is a stream separator. Per-shot entropy comes from + H/measure side-band qubits so the runtime twirl is not a fixed mask. + """ + return [ + "@guppy", + "@no_type_check", + "def _pcg32_mask32(value: nat) -> nat:", + " uint32_mask: nat = 4294967295", + " return value & uint32_mask", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_advance(state: nat, inc: nat) -> nat:", + " pcg32_mult: nat = 6364136223846793005", + " return nat(state * pcg32_mult + inc)", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:", + " old_state = state", + " new_state = _pcg32_advance(state, inc)", + " xorshifted = _pcg32_mask32(((old_state >> nat(18)) ^ old_state) >> nat(27))", + " rot = _pcg32_mask32(old_state >> nat(59))", + " rot_inv = _pcg32_mask32((~rot + nat(1)) & nat(31))", + " output = _pcg32_mask32((xorshifted >> rot) | (xorshifted << rot_inv))", + " return new_state, int(output & nat(3))", + "", + "", + "@guppy", + "@no_type_check", + "def seeded_pcg32_from_sequence(seed: int, sequence: nat) -> tuple[nat, nat]:", + " initstate = nat(42)", + " initseq = nat(seed) ^ sequence", + " inc = nat((initseq << nat(1)) | nat(1))", + " state = _pcg32_advance(nat(0), inc)", + " state += initstate", + " state = _pcg32_advance(state, inc)", + " return state, inc", + "", + "", + "@guppy", + "@no_type_check", + "def seeded_pcg32_with_quantum_entropy(seed: int) -> tuple[nat, nat]:", + " entropy = nat(0)", + " for i in range(32):", + " entropy_q = qubit()", + " h(entropy_q)", + " if measure(entropy_q):", + " entropy = entropy | (nat(1) << nat(i))", + " return seeded_pcg32_from_sequence(seed, entropy)", + "", + "", + ] + + def generate_guppy_source( patch: "SurfacePatch", *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, ) -> str: """Generate Guppy source code for a surface code patch. @@ -78,12 +141,42 @@ def generate_guppy_source( ancilla_budget: Optional cap on simultaneously live ancillas. ``None`` or a value ``>= total_ancilla`` emits the unconstrained shape; ``< total_ancilla`` emits batched. + twirl: When provided, emit Pauli-twirl-site mask draws between + consecutive syndrome rounds and apply the sampled physical + Pauli to each data qubit at runtime. Both ``twirl`` and + ``rng`` must be supplied together. The encoding is + ``"bool_array_v1"``: one + ``result("pauli_mask:round:R", array(lo_q0, hi_q0, ...))`` + call per twirl site, with the per-round body Python-time + unrolled at source-generation time so each tag fires exactly + once per shot. ``twirl.frame_output="canonical"`` additionally + emits measurement records in the canonical untwirled DEM frame. + rng: Runtime mask source: a stream-separator seed mixed with + per-shot quantum entropy when ``twirl`` is enabled. Returns: Python/Guppy source code as a string. + + Raises: + ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget + if (twirl is None) != (rng is None): + msg = "twirl and rng must be supplied together; got twirl={!r} rng={!r}".format( + twirl, rng + ) + raise ValueError(msg) + if twirl is not None: + twirl._validate_runtime_supported() + if num_rounds is None: + msg = "num_rounds is required when twirl is supplied" + raise ValueError(msg) + if num_rounds < 1: + msg = f"num_rounds must be >= 1, got {num_rounds}" + raise ValueError(msg) + canonical_frame_output = twirl is not None and twirl.frame_output == "canonical" + geom = patch.geometry num_data = geom.num_data num_x_stab = len(geom.x_stabilizers) @@ -91,8 +184,37 @@ def generate_guppy_source( total_ancilla = num_x_stab + num_z_stab effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) constrained = effective_budget < total_ancilla + if twirl is not None and constrained: + msg = ( + f"twirl + constrained ancilla budget is not supported on " + f"the Guppy runtime path " + f"(ancilla_budget={ancilla_budget} < total_ancilla={total_ancilla}); " + "the between_rounds twirl-site schedule assumes the " + "unconstrained syndrome shape. Pass ancilla_budget=None or " + ">= total_ancilla, or omit twirl." + ) + raise ValueError(msg) dx, dz = geom.dx, geom.dz + if twirl is not None: + imports = [ + "from __future__ import annotations", + "from typing import no_type_check", + "", + "from guppylang import guppy", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.num import nat", + "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x, y, z", + ] + else: + imports = [ + "from __future__ import annotations", + "", + "from guppylang import guppy", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x", + ] + lines = [ f'"""Surface code patch (dx={dx}, dz={dz}) implementation in Guppy.', "", @@ -104,13 +226,14 @@ def generate_guppy_source( f"Ancilla qubits: {num_x_stab + num_z_stab} (one per stabilizer)", '"""', "", - "from guppylang import guppy", - "from guppylang.std.builtins import array, owned, result", - "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x", + *imports, "", "", ] + if twirl is not None: + lines.extend(_render_inline_pcg32()) + # Generate struct definitions lines.extend( [ @@ -164,9 +287,16 @@ def generate_guppy_source( "# === Syndrome Extraction ===", "", "@guppy", - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", ], ) + if canonical_frame_output: + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}, frame_x: array[bool, {num_data}], frame_z: array[bool, {num_data}]) -> Syndrome_{dx}x{dz}:" + ) + else: + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:" + ) if not constrained: # Unconstrained: one ancilla per stabilizer, X-stabs first then @@ -203,11 +333,29 @@ def generate_guppy_source( lines.append(" # Measure ancillas") idx = 0 for stab in geom.x_stabilizers: - lines.append(f" sx{stab.index} = measure(ax{stab.index})") + if canonical_frame_output: + raw_var = f"sx{stab.index}_raw" + flip_var = f"sx{stab.index}_flip" + flip_expr = _xor_expr(f"frame_z[{q}]" for q in stab.data_qubits) + lines.append(f" {raw_var} = measure(ax{stab.index})") + lines.append(f" {flip_var} = {flip_expr}") + lines.append(f" sx{stab.index} = {raw_var} != {flip_var}") + lines.append(f' result("raw:sx{stab.index}:bit:{idx}", {raw_var})') + else: + lines.append(f" sx{stab.index} = measure(ax{stab.index})") lines.append(f' result("sx{stab.index}:meas:{idx}", sx{stab.index})') idx += 1 for stab in geom.z_stabilizers: - lines.append(f" sz{stab.index} = measure(az{stab.index})") + if canonical_frame_output: + raw_var = f"sz{stab.index}_raw" + flip_var = f"sz{stab.index}_flip" + flip_expr = _xor_expr(f"frame_x[{q}]" for q in stab.data_qubits) + lines.append(f" {raw_var} = measure(az{stab.index})") + lines.append(f" {flip_var} = {flip_expr}") + lines.append(f" sz{stab.index} = {raw_var} != {flip_var}") + lines.append(f' result("raw:sz{stab.index}:bit:{idx}", {raw_var})') + else: + lines.append(f" sz{stab.index} = measure(az{stab.index})") lines.append(f' result("sz{stab.index}:meas:{idx}", sz{stab.index})') idx += 1 else: @@ -470,59 +618,211 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) # Generate memory experiment factories - lines.extend( - [ - "# === Memory Experiments ===", - "", - "def make_memory_z(num_rounds: int):", - ' """Create Z-basis memory experiment."""', - " from guppylang.std.builtins import comptime", - "", - " @guppy", - " def memory_z() -> None:", - f' """Z-basis memory experiment for dx={dx}, dz={dz}."""', - " surf = prep_z_basis()", - " init_syn = init_z_basis(surf)", - ' result("init_synx", init_syn)', - "", - " for _t in range(comptime(num_rounds)):", - " syn = syndrome_extraction(surf)", - ' result("synx", syn.synx)', - ' result("synz", syn.synz)', - "", - " final = measure_z_basis(surf)", - ' result("final", final)', - "", - " return memory_z", - "", - "", - "def make_memory_x(num_rounds: int):", - ' """Create X-basis memory experiment."""', - " from guppylang.std.builtins import comptime", - "", - " @guppy", - " def memory_x() -> None:", - f' """X-basis memory experiment for dx={dx}, dz={dz}."""', - " surf = prep_x_basis()", - " init_syn = init_x_basis(surf)", - ' result("init_synz", init_syn)', - "", - " for _t in range(comptime(num_rounds)):", - " syn = syndrome_extraction(surf)", - ' result("synx", syn.synx)', - ' result("synz", syn.synz)', - "", - " final = measure_x_basis(surf)", - ' result("final", final)', - "", - " return memory_x", - "", - ], - ) + lines.extend(_render_memory_experiments(dx, dz, num_data, twirl, rng, num_rounds)) return "\n".join(lines) +def _xor_expr(terms: object) -> str: + """Return a Guppy bool XOR expression for the given source terms.""" + parts = list(terms) + if not parts: + return "False" + expr = parts[0] + for part in parts[1:]: + expr = f"({expr} != {part})" + return str(expr) + + +def _render_memory_experiments( + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig | None", + rng: "GuppyRngMaskConfig | None", + num_rounds: int | None, +) -> list[str]: + """Render both memory factory functions.""" + lines = [ + "# === Memory Experiments ===", + "", + ] + for basis, basis_upper in (("z", "Z"), ("x", "X")): + if twirl is None: + lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz)) + else: + assert rng is not None + assert num_rounds is not None + lines.extend( + _render_twirled_memory_block( + basis, + basis_upper, + dx, + dz, + num_data, + twirl, + rng, + num_rounds, + ) + ) + return lines + + +def _render_plain_memory_block( + basis: str, + basis_upper: str, + dx: int, + dz: int, +) -> list[str]: + """Render the vanilla handoff memory factory for one basis.""" + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis memory experiment."""', + " from guppylang.std.builtins import comptime", + "", + " @guppy", + f" def memory_{basis}() -> None:", + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}."""', + f" surf = prep_{basis}_basis()", + f" init_syn = {init_func}(surf)", + f' result("{init_tag}", init_syn)', + "", + " for _t in range(comptime(num_rounds)):", + " syn = syndrome_extraction(surf)", + ' result("synx", syn.synx)', + ' result("synz", syn.synz)', + "", + f" final = measure_{basis}_basis(surf)", + ' result("final", final)', + "", + f" return memory_{basis}", + "", + "", + ] + + +def _render_twirled_memory_block( + basis: str, + basis_upper: str, + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig", + rng: "GuppyRngMaskConfig", + num_rounds: int, +) -> list[str]: + """Render a Python-time unrolled twirled memory factory.""" + from pecos.qec.surface._twirl_sites import num_twirl_sites, pauli_mask_round_tag + + seed = int(rng.seed) + canonical_frame_output = twirl.frame_output == "canonical" + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + body: list[str] = [ + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}, num_rounds={num_rounds} (twirled)."""', + f" surf = prep_{basis}_basis()", + " # RNG seed is structural -- changing it does not invalidate the", + " # abstract DEM / topology cache, only the per-shot mask buffer.", + f" rng_state, rng_inc = seeded_pcg32_with_quantum_entropy({seed})", + f' result("frame_mode:{twirl.frame_output}", True)', + f" init_syn = {init_func}(surf)", + f' result("{init_tag}", init_syn)', + "", + ] + if canonical_frame_output: + for q in range(num_data): + body.append(f" fx_{q} = False") + body.append(f" fz_{q} = False") + body.append("") + + # Emit num_rounds - 1 twirled rounds, then one final untwirled round. + n_twirl = num_twirl_sites(num_rounds) + for r in range(n_twirl): + body.append(f" # === Round {r} (twirled) ===") + if canonical_frame_output: + frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) + frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) + body.append( + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))" + ) + else: + body.append(" syn = syndrome_extraction(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append(" # Pauli twirl site between this round and the next.") + for q in range(num_data): + body.append(f" rng_state, m_{r}_{q} = _pcg32_next4(rng_state, rng_inc)") + body.append(f" if m_{r}_{q} == 1:") + body.append(f" x(surf.data[{q}])") + body.append(f" if m_{r}_{q} == 2:") + body.append(f" y(surf.data[{q}])") + body.append(f" if m_{r}_{q} == 3:") + body.append(f" z(surf.data[{q}])") + body.append(f" lo_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 3)") + body.append(f" hi_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)") + if canonical_frame_output: + body.append( + f" twx_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 2)" + ) + body.append( + f" twz_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)" + ) + body.append(f" fx_{q} = fx_{q} != twx_{r}_{q}") + body.append(f" fz_{q} = fz_{q} != twz_{r}_{q}") + elements = ", ".join(f"lo_{r}_{q}, hi_{r}_{q}" for q in range(num_data)) + tag = pauli_mask_round_tag(r) + body.append(f' result("{tag}", array({elements}))') + body.append("") + + if num_rounds > 0: + body.append(f" # === Round {num_rounds - 1} (final, no twirl after) ===") + if canonical_frame_output: + frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) + frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) + body.append( + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))" + ) + else: + body.append(" syn = syndrome_extraction(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append("") + + if canonical_frame_output: + body.append(f" final_raw = measure_{basis}_basis(surf)") + body.append(' result("raw:final", final_raw)') + for q in range(num_data): + flip_var = f"fx_{q}" if basis == "z" else f"fz_{q}" + body.append(f" final_{q} = final_raw[{q}] != {flip_var}") + final_elements = ", ".join(f"final_{q}" for q in range(num_data)) + body.append(f' result("final", array({final_elements}))') + else: + body.append(f" final = measure_{basis}_basis(surf)") + body.append(' result("final", final)') + + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis twirled memory experiment.', + "", + f" num_rounds must equal {num_rounds} -- the body was unrolled at", + " source-generation time. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {num_rounds}:", + f' msg = f"this generated module was unrolled for num_rounds={num_rounds}, got {{num_rounds!r}}"', + " raise ValueError(msg)", + "", + " @guppy", + f" def memory_{basis}() -> None:", + *body, + "", + f" return memory_{basis}", + "", + "", + ] + + def _validate_surface_memory_distance(d: int) -> None: """Enforce the surface-memory Guppy entry-point distance contract. @@ -538,23 +838,46 @@ def _validate_surface_memory_distance(d: int) -> None: raise ValueError(msg) -def _guppy_module_cache_key(patch: "SurfacePatch", effective_budget: int) -> str: - """Filesystem-safe cache key spanning full patch identity + budget. +def _guppy_module_cache_key( + patch: "SurfacePatch", + effective_budget: int, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, +) -> str: + """Filesystem-safe cache key spanning full patch identity + budget + twirl. Mirrors the topology identity used by the native cache (``decode._surface_patch_cache_key``): dx, dz, orientation, and the rotated flag. Keying on distance/dx-dz alone would collide a rotated and a non-rotated patch of the same shape onto one generated module. + + Twirled source is Python-time unrolled, so the cache key includes + ``num_rounds`` in addition to the structural twirl fields, runtime + frame-output mode, and RNG seed. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" - return f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" + base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" + if twirl is None: + return base + assert rng is not None + assert num_rounds is not None + twirl_part = ( + f"t-{twirl.scheme}-{twirl.site_schedule}-{twirl.result_encoding}" + f"-frame-{twirl.frame_output}" + f"-s{int(rng.seed)}-r{int(num_rounds)}" + ) + return f"{base}_{twirl_part}" def _load_guppy_module( patch: "SurfacePatch", *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, + num_rounds: int | None = None, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -562,11 +885,14 @@ def _load_guppy_module( rotated) and the **effective** budget (after clamping via ``normalize_ancilla_budget``), so ``ancilla_budget=None`` and ``ancilla_budget >= total_ancilla`` resolve to the same cache entry - while distinct patch geometries never collide. + while distinct patch geometries never collide. Twirled source also + keys on twirl fields, frame-output mode, RNG seed, and round count. Args: patch: SurfacePatch with geometry ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Pauli-twirl-site declaration (structural) + rng: Runtime mask RNG seed (must be supplied with ``twirl``) Returns: Module dictionary with generated functions @@ -576,13 +902,18 @@ def _load_guppy_module( geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = _guppy_module_cache_key(patch, effective_budget) + cache_key = _guppy_module_cache_key(patch, effective_budget, twirl, rng, num_rounds) if cache_key in _state.module_cache: return _state.module_cache[cache_key] - # Generate source for this (patch, effective_budget) combination. - source = generate_guppy_source(patch, ancilla_budget=ancilla_budget) + source = generate_guppy_source( + patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + rng=rng, + num_rounds=num_rounds, + ) # Write to temp file (required for Guppy introspection). temp_dir = _get_temp_dir() @@ -610,6 +941,8 @@ def generate_memory_experiment( basis: str, *, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, + rng: "GuppyRngMaskConfig | None" = None, ) -> object: """Generate a memory experiment for a patch. @@ -618,11 +951,19 @@ def generate_memory_experiment( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Pauli-twirl-site declaration; must be supplied with ``rng``. + rng: Runtime mask RNG seed; must be supplied with ``twirl``. Returns: Guppy function for the experiment """ - module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) + module = _load_guppy_module( + patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + rng=rng, + num_rounds=num_rounds if twirl is not None else None, + ) if basis.upper() == "Z": factory = module["make_memory_z"] @@ -640,6 +981,7 @@ def get_num_qubits( *, patch: "SurfacePatch | None" = None, ancilla_budget: int | None = None, + twirl: "TwirlConfig | None" = None, ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -657,6 +999,8 @@ def get_num_qubits( ``num_data + min(ancilla_budget, total_ancilla)`` slots are live at once. Clamping matches ``normalize_ancilla_budget``, so the unconstrained-via-``None`` and unconstrained-via-large-int cases collapse. + Twirled Guppy programs allocate one additional side-band entropy qubit at + a time for per-shot mask seeding. Returns: Total qubits the traced program will simultaneously use. @@ -676,7 +1020,8 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 - return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) + twirl_entropy_qubits = 1 if twirl is not None else 0 + return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) + twirl_entropy_qubits def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> str: diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 9b959d6a5..023a60792 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -37,6 +37,7 @@ EquivalenceResult, FaultLocation, InfluenceBuilder, + PauliFrameLookup, ParsedDem, assert_dems_equivalent, compare_dems_exact, @@ -123,6 +124,7 @@ "EquivalenceResult", "FaultLocation", "InfluenceBuilder", + "PauliFrameLookup", "ParsedDem", "assert_dems_equivalent", "compare_dems_exact", diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index e7c836664..53302e7d7 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -45,6 +45,59 @@ P2Weights = Mapping[str, float] +def _from_circuit_with_noise( + tc: Any, + *, + p1: float, + p1_weights: P1Weights | None, + p2: float, + p2_weights: P2Weights | None, + p2_replacement_approximation: str | None, + p_meas: float, + p_prep: float, + p_idle: float | None, + t1: float | None, + t2: float | None, + p_idle_linear_rate: float | None, + p_idle_quadratic_rate: float | None, + p_idle_x_linear_rate: float | None, + p_idle_y_linear_rate: float | None, + p_idle_z_linear_rate: float | None, + p_idle_x_quadratic_rate: float | None, + p_idle_y_quadratic_rate: float | None, + p_idle_z_quadratic_rate: float | None, + p_idle_quadratic_sine_rate: float | None, + p_idle_x_quadratic_sine_rate: float | None, + p_idle_y_quadratic_sine_rate: float | None, + p_idle_z_quadratic_sine_rate: float | None, +) -> _RustDetectorErrorModel: + return _RustDetectorErrorModel.from_circuit( + tc, + p1=p1, + p1_weights=p1_weights, + p2=p2, + p2_weights=p2_weights, + p2_replacement_approximation=p2_replacement_approximation, + p_meas=p_meas, + p_prep=p_prep, + p_idle=p_idle, + t1=t1, + t2=t2, + p_idle_linear_rate=p_idle_linear_rate, + p_idle_quadratic_rate=p_idle_quadratic_rate, + p_idle_x_linear_rate=p_idle_x_linear_rate, + p_idle_y_linear_rate=p_idle_y_linear_rate, + p_idle_z_linear_rate=p_idle_z_linear_rate, + p_idle_x_quadratic_rate=p_idle_x_quadratic_rate, + p_idle_y_quadratic_rate=p_idle_y_quadratic_rate, + p_idle_z_quadratic_rate=p_idle_z_quadratic_rate, + p_idle_quadratic_sine_rate=p_idle_quadratic_sine_rate, + p_idle_x_quadratic_sine_rate=p_idle_x_quadratic_sine_rate, + p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, + p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, + ) + + class _DetectorErrorModelMixin: """Namespace for the Python Guppy/QIS-trace convenience constructor.""" @@ -283,7 +336,7 @@ def from_guppy( if num_measurements is not None: tc.set_meta("num_measurements", str(num_measurements)) - return _RustDetectorErrorModel.from_circuit( + return _from_circuit_with_noise( tc, p1=p1, p1_weights=p1_weights, diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index 5aee25b2e..f4d4c3963 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -54,12 +54,16 @@ SurfaceDecoder, build_memory_circuit, build_native_sampler, + build_native_sampler_from_dem, build_stim_circuit_from_patch, + decode_native_samples, + demask_pauli_frame_records, generate_circuit_level_dem, generate_dem_from_patch, generate_repetition_code_dem, generate_surface_code_dem, run_noisy_memory_experiment, + sample_pauli_masks_from_guppy, surface_code_memory, syndromes_to_detection_events, ) @@ -101,12 +105,17 @@ get_stabilizer_touch_label, ) from pecos.qec.surface.plot import plot_patch, plot_surface_code +from pecos.qec.surface._detection_events import extract_detection_events_and_observables from pecos.qec.surface.schedule import ( compute_cnot_schedule, get_stab_schedule, ) +from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig __all__ = [ + # Twirling config (Pauli-frame randomization) + "GuppyRngMaskConfig", + "TwirlConfig", # Rotated lattice (most common, default) "compute_rotated_x_stabilizers", "compute_rotated_z_stabilizers", @@ -144,12 +153,17 @@ "SurfaceDecoder", "build_memory_circuit", "build_native_sampler", + "build_native_sampler_from_dem", "build_stim_circuit_from_patch", + "decode_native_samples", + "demask_pauli_frame_records", + "extract_detection_events_and_observables", "generate_circuit_level_dem", "generate_dem_from_patch", "generate_repetition_code_dem", "generate_surface_code_dem", "run_noisy_memory_experiment", + "sample_pauli_masks_from_guppy", "surface_code_memory", "syndromes_to_detection_events", # Visualization diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py new file mode 100644 index 000000000..14a250631 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -0,0 +1,66 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Metadata-driven detection-event extraction for surface memory circuits.""" + +from __future__ import annotations + +import json +from collections.abc import Iterable, Sequence +from typing import Any + + +def extract_detection_events_and_observables( + tick_circuit: Any, + results: Iterable[Sequence[int]], +) -> tuple[list[list[int]], list[list[int]]]: + """Extract fired detectors and observables from flat measurement rows.""" + detectors_json = tick_circuit.get_meta("detectors") + detectors = json.loads(detectors_json) if detectors_json else [] + + observables_json = tick_circuit.get_meta("observables") + observables = json.loads(observables_json) if observables_json else [] + + num_meas_meta = tick_circuit.get_meta("num_measurements") + if num_meas_meta is None or num_meas_meta == "": + msg = ( + "extract_detection_events_and_observables requires " + "tick_circuit.get_meta('num_measurements') to be set" + ) + raise ValueError(msg) + num_meas = int(num_meas_meta) + + detection_events_per_shot: list[list[int]] = [] + observable_flips_per_shot: list[list[int]] = [] + + for row in results: + if len(row) != num_meas: + msg = ( + f"result row has length {len(row)} but tick_circuit metadata " + f"declares num_measurements={num_meas}" + ) + raise ValueError(msg) + + fired_detectors: list[int] = [] + for det_idx, det in enumerate(detectors): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < num_meas: + val ^= int(row[idx]) + if val: + fired_detectors.append(det_idx) + detection_events_per_shot.append(fired_detectors) + + flipped_observables: list[int] = [] + for obs_idx, obs in enumerate(observables): + val = 0 + for rec in obs["records"]: + idx = num_meas + rec + if 0 <= idx < num_meas: + val ^= int(row[idx]) + if val: + flipped_observables.append(obs_idx) + observable_flips_per_shot.append(flipped_observables) + + return detection_events_per_shot, observable_flips_per_shot diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py new file mode 100644 index 000000000..2263583e7 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -0,0 +1,131 @@ +"""Configuration objects for Pauli-frame twirling. + +`TwirlConfig` carries the twirl-site declaration: scheme, where in the +circuit twirling sites are emitted, how the per-shot mask is encoded into +the runtime result bundle after the corresponding physical Pauli gates +are applied, and how generated Guppy measurement records are framed. The +first three fields are structural for abstract DEM / topology caches. +`frame_output` is runtime-only: raw and canonical Guppy records share the +same abstract DEM and `PauliFrameLookup`. + +`GuppyRngMaskConfig` carries the **runtime** mask source: a stream-separator +seed mixed with 32 bits of per-shot quantum entropy when the mask is drawn, +applied to data qubits, and recorded via `result()`. Two abstract circuits +identical except for `seed` or `frame_output` reuse the same DEM but produce +different shot-level runtime records, so those values belong in the Guppy- +module / compiled-shot cache layer but NOT in the abstract DEM cache. + +The split mirrors the two-tracks-per-twirl-setting architecture from the +design doc: the abstract circuit (consumer: DEM builder, +`PauliFrameLookup`, decoder structure) consumes `TwirlConfig`; the Guppy +module (consumer: Selene runtime) consumes `TwirlConfig` AND +`GuppyRngMaskConfig`. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Literal + + +_SUPPORTED_SCHEMES = ("pauli",) +_SUPPORTED_SITE_SCHEDULES = ("between_rounds",) +_SUPPORTED_RESULT_ENCODINGS = ("bool_array_v1",) +_SUPPORTED_FRAME_OUTPUTS = ("raw", "canonical") + + +@dataclass(frozen=True) +class TwirlConfig: + """Structural Pauli-twirl-site declaration. + + All fields are constrained to the values Phase 0a currently supports. + Future Phase 2 (Clifford twirling) work will extend the `scheme` enum. + + Attributes: + scheme: Twirling family. Phase 0a supports `"pauli"`; the + `"clifford"` value is reserved for Phase 2 ({I, H} + Clifford-frame randomization) and not yet implemented. + site_schedule: Where twirling sites are emitted in the circuit. + `"between_rounds"` (the only supported value) emits one site + between each pair of consecutive syndrome rounds. + result_encoding: How the per-shot mask is recorded in the + runtime result bundle. `"bool_array_v1"` packs the + `2 * num_data` bool bits per round into one tagged array per + twirl site (the only supported encoding -- the earlier + shared-tag scalar-bool variant is unimplementable because + `ShotVec.to_dict()` collapses repeated same-tag calls to the + last value). + frame_output: Runtime Guppy measurement-frame convention. + `"raw"` preserves the landed behavior: measurement tags are + emitted in the physical/twirled frame and callers can + canonicalize with `PauliFrameLookup`. `"canonical"` makes the + generated Guppy program track the Pauli frame classically and + flip emitted measurement bits into the canonical untwirled DEM + frame. This does not change the abstract circuit or DEM + topology; it only changes generated runtime records and must + therefore be part of the Guppy module cache key. + """ + + scheme: Literal["pauli"] = "pauli" + site_schedule: Literal["between_rounds"] = "between_rounds" + result_encoding: Literal["bool_array_v1"] = "bool_array_v1" + frame_output: Literal["raw", "canonical"] = "raw" + + def _validate_runtime_supported(self) -> None: + """Raise ``ValueError`` if any field is outside the supported runtime set. + + The ``Literal`` annotations are static-only hints; this method + enforces them at runtime so e.g. + ``TwirlConfig(result_encoding="bool_scalar_v1")`` (constructed via + ``object.__setattr__`` or a stale call path) fails loudly at the + Guppy / harvest boundary rather than silently producing + encoding-incompatible behavior. + """ + if self.scheme not in _SUPPORTED_SCHEMES: + msg = ( + f"TwirlConfig.scheme={self.scheme!r} is not supported; " + f"expected one of {_SUPPORTED_SCHEMES!r}" + ) + raise ValueError(msg) + if self.site_schedule not in _SUPPORTED_SITE_SCHEDULES: + msg = ( + f"TwirlConfig.site_schedule={self.site_schedule!r} is not " + f"supported; expected one of {_SUPPORTED_SITE_SCHEDULES!r}" + ) + raise ValueError(msg) + if self.result_encoding not in _SUPPORTED_RESULT_ENCODINGS: + msg = ( + f"TwirlConfig.result_encoding={self.result_encoding!r} is " + f"not supported; expected one of {_SUPPORTED_RESULT_ENCODINGS!r}" + ) + raise ValueError(msg) + if self.frame_output not in _SUPPORTED_FRAME_OUTPUTS: + msg = ( + f"TwirlConfig.frame_output={self.frame_output!r} is not " + f"supported; expected one of {_SUPPORTED_FRAME_OUTPUTS!r}" + ) + raise ValueError(msg) + + +@dataclass(frozen=True) +class GuppyRngMaskConfig: + """Runtime Guppy-side mask source. + + The seed separates mask streams. Generated Guppy programs mix it with + 32 measured H-basis entropy bits per shot before drawing the per-shot + mask at quantum runtime. Excluded from the abstract DEM / topology + cache key by construction: changing the seed must NOT invalidate the + abstract circuit's DEM, only the compiled Guppy module + per-shot mask + buffer. + + The Selene harvest helpers also pass this seed to the Stim simulator + so mask draws are reproducible in tests. Studies that need mask-stream + and syndrome-noise randomness to vary independently should expose a + separate simulator seed. + + Attributes: + seed: 64-bit unsigned stream-separator seed. Must be representable + as a 64-bit unsigned integer. + """ + + seed: int diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py new file mode 100644 index 000000000..068185102 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -0,0 +1,119 @@ +"""Canonical `(round, qubit) -> site_idx` mapping shared by the abstract +circuit (`circuit_builder.py`) and the Guppy runtime renderer +(`pecos.guppy.surface`). + +Both tracks must agree byte-for-byte on the ordering of twirl-site +metadata: the abstract circuit's `tracked_pauli` annotations populate +rows of the `PauliFrameLookup` matrix `M`, and the Guppy program's +runtime Pauli applications plus per-shot `result()` recordings populate +the matching mask columns. If the two paths disagree about which +`(round, qubit)` maps to which row / column, the per-shot XOR +application silently runs the mask against the wrong tracked-Pauli +annotations and the decoder sees an incoherent syndrome. + +The mapping is currently a simple `site_idx == round_idx` for the +`between_rounds` schedule (one site between each pair of consecutive +syndrome rounds, for a total of `num_rounds - 1` sites). The helpers +below abstract that so a future schedule only needs to update this module. + +Encoding contract for the runtime mask (``"bool_array_v1"``): + +- One result tag PER twirl site, named via :func:`pauli_mask_round_tag` + (`f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}"`). Each tag is emitted + exactly once per shot, so ``ShotVec.to_dict()`` cannot collapse it. + (An earlier "shared-tag scalar bool" variant was attempted but + unimplementable -- ``ShotVec.to_dict()`` collapses repeated same-tag + ``result()`` calls in one shot to the last value, dropping every + earlier bit on the floor.) +- Each per-round tag carries one bool array of length + ``2 * num_data`` laid out as + ``(qubit_0_lo, qubit_0_hi, qubit_1_lo, qubit_1_hi, ...)``, where + ``lo = (m == 1) | (m == 3)`` and ``hi = (m == 2) | (m == 3)`` for a + Pauli value ``m in {0=I, 1=X, 2=Y, 3=Z}`` drawn at runtime by the + generated Guppy program's inline functional PCG helper and applied as + the matching physical Pauli gate at that twirl site. This is the binary encoding + of the enumeration code, not the Pauli's symplectic X/Z components. +- The decoder + (:func:`pecos.qec.surface.decode._extract_pauli_masks_from_results`) + reads each ``pauli_mask:round:{r}`` tag, packs each ``(lo, hi)`` pair + back into the integer Pauli code via ``m = lo + 2 * hi``, and lays + the result at column :func:`mask_col_for` in row-major + ``(site, qubit)`` order so the output columns match the abstract + ``PauliFrameLookup`` byte-for-byte. +- Side-band result tags emitted by twirl support (`pauli_mask:*`, + `frame_mode:*`, `raw:*`) are not detector-bearing measurement tags. + They must never contain ``":meas:"`` and must never be exactly + ``"final"``; handoff result-provenance code treats only the surface + measurement tag grammar and exact ``"final"`` as measurement records. +""" + +from __future__ import annotations + +# Base prefix for per-round mask result tags. The Guppy renderer emits one +# `result(f"{PAULI_MASK_TAG_PREFIX}:round:{r}", array(lo_q0, hi_q0, ...))` +# call per twirl site so each tag fires exactly once per shot (avoids the +# same-tag-multi-call overwrite trap in `ShotVec.to_dict()`). +PAULI_MASK_TAG_PREFIX = "pauli_mask" + +# Backwards-compatible alias for callers that constructed the bare tag. +PAULI_MASK_TAG = PAULI_MASK_TAG_PREFIX + + +def pauli_mask_round_tag(round_idx: int) -> str: + """Canonical `result()` tag for the twirl mask emitted between syndrome + rounds ``round_idx`` and ``round_idx + 1``. + """ + return f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}" + + +def num_twirl_sites(num_rounds: int) -> int: + """Number of twirl sites for the `between_rounds` schedule. + + One site between each pair of consecutive syndrome rounds: + `max(0, num_rounds - 1)`. + """ + return max(0, num_rounds - 1) + + +def site_idx_for_round(round_idx: int) -> int: + """Map the round-loop index of the round PRECEDING a twirl site to its + canonical `site_idx`. + + For the `between_rounds` schedule, the twirl site that sits between + syndrome round `r` and round `r + 1` is canonical `site_idx == r`. + Identity by construction; abstracted so a future schedule can override. + """ + return round_idx + + +def num_mask_bits_per_round(num_data: int) -> int: + """Number of bool bits the Guppy program emits per twirl site. + + The bool-bits encoding uses two bits per data qubit (`lo, hi`) so the + per-site contribution is `2 * num_data` bools. + """ + return 2 * num_data + + +def num_mask_bits_per_shot(num_rounds: int, num_data: int) -> int: + """Total bool-bit count one shot's `pauli_mask` result accumulates.""" + return num_twirl_sites(num_rounds) * num_mask_bits_per_round(num_data) + + +def num_pauli_sites(num_rounds: int, num_data: int) -> int: + """Number of mask columns (one per (site, data qubit) cell). + + Matches the column count of the abstract `PauliFrameLookup` matrix `M` + when twirling is enabled. + """ + return num_twirl_sites(num_rounds) * num_data + + +def mask_col_for(site_idx: int, qubit_idx: int, num_data: int) -> int: + """Canonical flat (site, qubit) -> mask column index. + + Row-major over (site, qubit). Decoder-side `sample_pauli_masks_from_guppy` + uses this to assemble the `(num_shots, num_pauli_sites)` u8 array from a + flat bool stream. + """ + return site_idx * num_data + qubit_idx diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index e8e85b2bc..0f7700c1d 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -45,6 +45,7 @@ ) if TYPE_CHECKING: + from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import ( LogicalDescriptor, StabilizerDescriptor, @@ -127,6 +128,13 @@ class OpType(Enum): TICK = auto() # Layer separator COMMENT = auto() # Comment/annotation + # Annotation: declares a candidate tracked Pauli at this circuit position. + # The propagator records its forward propagation to detectors and + # observables; per-shot "did this Pauli fire?" is consumed at sampling + # time. ``qubits`` is the single data qubit the Pauli acts on; ``label`` + # carries the Pauli kind and site as ``"{X|Y|Z}@s"``. + TRACKED_PAULI = auto() + @dataclass class SurfaceCircuitStep: @@ -156,6 +164,8 @@ def build_surface_code_circuit( num_rounds: int, basis: str = "Z", ancilla_budget: int | None = None, + *, + twirl: TwirlConfig | None = None, ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. @@ -172,6 +182,11 @@ def build_surface_code_circuit( ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, ancillas are reused across stabilizer batches following the public Guppy order. + twirl: When provided, emit three ``OpType.TRACKED_PAULI`` annotations + (``X``, ``Y``, ``Z``) per data qubit at each between-round twirl + site. The sites are between counted syndrome rounds only: no site + is emitted before the first counted round, after the last counted + round, or inside the prep-boundary init syndrome block. Returns: Tuple of (operations list, qubit allocation info) @@ -222,6 +237,14 @@ def x_anc_q(stab_idx: int) -> int: def z_anc_q(stab_idx: int) -> int: return allocation.z_ancilla_qubits[stab_idx] + def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) -> None: + """Append 3 * num_data candidate tracked-Pauli annotations.""" + target_ops.extend( + SurfaceCircuitStep(OpType.TRACKED_PAULI, [data_q(i)], f"{kind}@s{site_idx}") + for i in range(num_data) + for kind in ("X", "Y", "Z") + ) + # Get CNOT schedule cnot_rounds = compute_cnot_schedule(patch) @@ -508,6 +531,9 @@ def z_anc_q(stab_idx: int) -> int: ops.append(SurfaceCircuitStep(OpType.TICK)) + if twirl is not None and rnd < num_rounds - 1: + emit_pauli_twirl_site(ops, rnd) + # ========================================================================= # measure_z_basis / measure_x_basis # ========================================================================= @@ -711,6 +737,13 @@ def render( elif op.op_type == OpType.TICK: lines.append("TICK") + elif op.op_type == OpType.TRACKED_PAULI: + msg = ( + "StimRenderer does not yet handle OpType.TRACKED_PAULI; " + "use TickCircuit / PauliFrameLookup path for twirled DEMs" + ) + raise NotImplementedError(msg) + # Add detector annotations if requested if self.add_detectors: lines.append("") @@ -896,6 +929,13 @@ def render( elif op.op_type == OpType.TICK: pass # DagCircuit doesn't have explicit ticks + elif op.op_type == OpType.TRACKED_PAULI: + msg = ( + "DagCircuitRenderer does not yet handle OpType.TRACKED_PAULI; " + "use TickCircuit / PauliFrameLookup path for twirled DEMs" + ) + raise NotImplementedError(msg) + return circuit @@ -1256,6 +1296,27 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non current_tick_handle = None qubits_in_current_tick = set() + elif op.op_type == OpType.TRACKED_PAULI: + from pecos_rslib import PauliString + + q = op.qubits[0] + kind, sep, site_suffix = op.label.partition("@") + pauli_ctor = { + "X": PauliString.X, + "Y": PauliString.Y, + "Z": PauliString.Z, + }.get(kind) + if pauli_ctor is None or sep != "@" or not site_suffix.startswith("s"): + msg = ( + "OpType.TRACKED_PAULI requires label of the form " + f"'{{X|Y|Z}}@s', got {op.label!r}" + ) + raise ValueError(msg) + circuit.tracked_pauli( + pauli_ctor(q), + label=f"twirl_{site_suffix}_q{q}_{kind}", + ) + # Apply tick-level metadata in place. Gate metadata is attached as each # gate is emitted so batching decisions can account for it immediately. for tick_idx, tick_meta in all_tick_metadata.items(): @@ -1599,6 +1660,7 @@ def generate_tick_circuit_from_patch( add_detectors: bool = True, add_typed_annotations: bool = True, ancilla_budget: int | None = None, + twirl: TwirlConfig | None = None, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -1622,11 +1684,21 @@ def generate_tick_circuit_from_patch( add_detectors: Whether to add detector/observable metadata. add_typed_annotations: Whether to also add typed Pauli annotations. ancilla_budget: Optional cap on simultaneously live ancillas + twirl: Optional Pauli-frame randomization layout. When supplied, + tracked-Pauli annotations are emitted even if + ``add_typed_annotations`` is false; that flag controls detector + and observable typed annotations, not the twirl lookup channel. Returns: PECOS TickCircuit instance """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + twirl=twirl, + ) renderer = TickCircuitRenderer( add_detectors=add_detectors, add_typed_annotations=add_typed_annotations, @@ -2505,7 +2577,9 @@ def _build_canonical_dem_influence_map(dag: DagCircuit) -> object: annotation_builder = InfluenceBuilder(dag) annotation_builder.with_circuit_annotations() annotation_map = annotation_builder.build() - influence_map.merge_dem_outputs_from(annotation_map) + merge_dem_outputs = getattr(influence_map, "merge_dem_outputs_from", None) + if merge_dem_outputs is not None: + merge_dem_outputs(annotation_map) return influence_map diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 5f2880453..ac954498c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -44,7 +44,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from enum import Enum from functools import cache from typing import TYPE_CHECKING, Any, Literal @@ -55,6 +55,7 @@ import stim from numpy.typing import NDArray + from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import Stabilizer, SurfacePatch @@ -260,12 +261,14 @@ class _CachedNativeSurfaceTopology: dag_circuit: Any influence_map: Any + pauli_frame_lookup: Any | None detectors_json: str observables_json: str measurement_order: tuple[int, ...] num_measurements: int num_detectors: int num_observables: int + num_pauli_sites: int def _surface_patch_cache_key(patch: SurfacePatch) -> tuple[int, int, str, bool]: @@ -292,6 +295,24 @@ def _cached_surface_patch(patch_key: tuple[int, int, str, bool]) -> SurfacePatch ) +def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: + """Drop runtime-only Guppy record-framing fields before DEM caching.""" + if twirl is None: + return None + return replace(twirl, frame_output="raw") + + +def _twirl_traced_qis_rejection_message() -> str: + return ( + "twirl=TwirlConfig() is not supported with circuit_source='traced_qis': " + "tracing runtime RNG twirl would bake one concrete mask realization " + "into the circuit/DEM/lookup, and canonical frame_output may break " + "runtime measurement result-id provenance because measurement tags can " + "be XOR-derived expressions. Use circuit_source='abstract' for twirl " + "for now." + ) + + def syndromes_to_detection_events( syndromes: NDArray[np.uint8], num_rounds: int, @@ -689,6 +710,8 @@ def _index_surface_result_trace_ids( result_ids = trace.get("result_ids") if not isinstance(name, str) or not isinstance(values, list) or not isinstance(result_ids, list): continue + if _is_surface_sideband_result_tag(name): + continue if len(values) != len(result_ids): msg = ( f"runtime result tag {name!r} has {len(values)} value(s) but " @@ -707,6 +730,11 @@ def _index_surface_result_trace_ids( return scalar_trace_ids, array_trace_ids +def _is_surface_sideband_result_tag(name: str) -> bool: + """Return true for non-detector-bearing surface result tags.""" + return name.startswith(("pauli_mask:", "frame_mode:", "raw:")) + + def _surface_abstract_measurement_result_refs(abstract_tc: Any) -> list[tuple[str, str] | tuple[str, str, int]]: """Return the result-tag reference for each abstract surface measurement.""" refs: list[tuple[str, str] | tuple[str, str, int]] = [] @@ -1153,7 +1181,17 @@ def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" if any(chunk.get("lowered_quantum_ops") for chunk in chunks): _reject_partially_lowered_trace(chunks) - return _replay_lowered_qis_trace_into_tick_circuit(chunks) + try: + return _replay_lowered_qis_trace_into_tick_circuit(chunks) + except ValueError as exc: + if "missing measurement_result_ids" not in str(exc): + raise + # Older local Selene/qis-compiler builds can emit lowered gates + # without measurement_result_ids while still carrying the raw QIS + # operations, whose Measure payloads include the stable result ids. + # Replay the raw operations in that compatibility case instead of + # losing provenance. + pass operations: list[dict[str, Any]] = [] for chunk in chunks: @@ -1282,7 +1320,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" from pecos.guppy import get_num_qubits - from pecos.guppy.surface import generate_memory_experiment + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits program = generate_memory_experiment( patch, @@ -1306,6 +1344,7 @@ def _build_surface_tick_circuit_for_native_model( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, + twirl: TwirlConfig | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch @@ -1316,6 +1355,7 @@ def _build_surface_tick_circuit_for_native_model( basis, ancilla_budget=ancilla_budget, add_typed_annotations=False, + twirl=twirl, ) if circuit_source == "abstract": @@ -1325,6 +1365,9 @@ def _build_surface_tick_circuit_for_native_model( msg = f"Unknown circuit_source {circuit_source!r}" raise ValueError(msg) + if twirl is not None: + raise ValueError(_twirl_traced_qis_rejection_message()) + traced_tc, result_traces = _generate_traced_surface_tick_circuit_with_result_traces( patch, num_rounds, @@ -1359,6 +1402,7 @@ def build_memory_circuit( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, + twirl: TwirlConfig | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1377,6 +1421,10 @@ def build_memory_circuit( ``"traced_qis"`` for the lowered traced QIS gate stream. runtime: Optional Selene runtime selector/plugin used when ``circuit_source="traced_qis"``. + twirl: Optional Pauli-frame randomization layout. Currently supported + only with ``circuit_source="abstract"``; traced-QIS twirl is + rejected because a runtime trace would bake one sampled mask into + the circuit and can lose canonical result-id provenance. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1408,6 +1456,7 @@ def build_memory_circuit( ancilla_budget=ancilla_budget, circuit_source=circuit_source, runtime=runtime, + twirl=twirl, ) @@ -1501,6 +1550,61 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: ) +def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: + """Call Rust ``with_noise`` using the richest signature this binding supports.""" + noise_kwargs = { + "p_idle": noise.p_idle, + "t1": noise.t1, + "t2": noise.t2, + "p_idle_linear_rate": noise.p_idle_linear_rate, + "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, + "p1_weights": _p1_weights_dict(noise.p1_weights), + "p2_weights": _p2_weights_dict(noise.p2_weights), + } + if noise.p2_replacement_approximation is not None: + noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation + + try: + return builder.with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + **noise_kwargs, + ) + except TypeError as exc: + unsupported = { + key: value + for key, value in noise_kwargs.items() + if key not in {"p_idle", "t1", "t2"} and value is not None + } + if unsupported: + msg = ( + "This pecos_rslib build does not support the requested advanced " + f"surface noise options: {sorted(unsupported)}" + ) + raise TypeError(msg) from exc + return builder.with_noise( + noise.p1, + noise.p2, + noise.p_meas, + noise.p_prep, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, + ) + + def _surface_native_topology( patch_key: tuple[int, int, str, bool], num_rounds: int, @@ -1510,6 +1614,7 @@ def _surface_native_topology( include_idle_gates: bool, *, runtime: object | None = None, + twirl: TwirlConfig | None = None, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1529,6 +1634,7 @@ def _surface_native_topology( ancilla_budget=ancilla_budget, circuit_source=circuit_source, runtime=runtime, + twirl=twirl, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -1546,20 +1652,32 @@ def _surface_native_topology( detectors_json = tc.get_meta("detectors") or "[]" observables_json = tc.get_meta("observables") or "[]" + det_records = [d["records"] for d in json.loads(detectors_json)] if detectors_json else [] + obs_records = [o["records"] for o in json.loads(observables_json)] if observables_json else [] measurement_order = ( tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) + pauli_frame_lookup = None + num_pauli_sites = 0 + if twirl is not None: + from pecos_rslib.qec import PauliFrameLookup + + pauli_frame_lookup = PauliFrameLookup.from_circuit(dag, det_records, obs_records) + num_pauli_sites = pauli_frame_lookup.num_pauli_sites + return _CachedNativeSurfaceTopology( dag_circuit=dag, influence_map=influence_map, + pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, observables_json=observables_json, measurement_order=measurement_order, num_measurements=num_measurements, - num_detectors=len(json.loads(detectors_json)) if detectors_json else 0, - num_observables=len(json.loads(observables_json)) if observables_json else 0, + num_detectors=len(det_records), + num_observables=len(obs_records), + num_pauli_sites=num_pauli_sites, ) @@ -1571,6 +1689,8 @@ def _cached_surface_native_topology( ancilla_budget: int | None, circuit_source: Literal["abstract", "traced_qis"], include_idle_gates: bool, + *, + twirl: TwirlConfig | None = None, ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" return _surface_native_topology( @@ -1580,6 +1700,7 @@ def _cached_surface_native_topology( ancilla_budget, circuit_source, include_idle_gates, + twirl=twirl, ) @@ -1592,35 +1713,7 @@ def _dem_string_from_cached_surface_topology( """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder - noise_kwargs = { - "p_idle": noise.p_idle, - "t1": noise.t1, - "t2": noise.t2, - "p_idle_linear_rate": noise.p_idle_linear_rate, - "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, - "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, - "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, - "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, - "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, - "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, - "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, - "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, - "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, - "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, - "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, - "p1_weights": _p1_weights_dict(noise.p1_weights), - "p2_weights": _p2_weights_dict(noise.p2_weights), - } - if noise.p2_replacement_approximation is not None: - noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation - - builder = DemBuilder(topology.influence_map).with_noise( - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - **noise_kwargs, - ) + builder = _with_noise_compat(DemBuilder(topology.influence_map), noise) if hasattr(builder, "with_exact_branch_replay_circuit"): builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) @@ -1672,6 +1765,7 @@ def _cached_surface_native_dem_string( p_idle_x_quadratic_sine_rate: float | None = None, p_idle_y_quadratic_sine_rate: float | None = None, p_idle_z_quadratic_sine_rate: float | None = None, + twirl: TwirlConfig | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" include_idle_gates = _uses_dedicated_idle_noise( @@ -1698,6 +1792,7 @@ def _cached_surface_native_dem_string( ancilla_budget, circuit_source, include_idle_gates, + twirl=twirl, ) return _dem_string_from_cached_surface_topology( topology, @@ -1761,31 +1856,7 @@ def _build_native_sampler_from_cached_surface_topology( from pecos.qec import DemSamplerBuilder sampler_builder = ( - DemSamplerBuilder(topology.influence_map) - .with_noise( - noise.p1, - noise.p2, - noise.p_meas, - noise.p_prep, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - p_idle_linear_rate=noise.p_idle_linear_rate, - p_idle_quadratic_rate=noise.p_idle_quadratic_rate, - p_idle_x_linear_rate=noise.p_idle_x_linear_rate, - p_idle_y_linear_rate=noise.p_idle_y_linear_rate, - p_idle_z_linear_rate=noise.p_idle_z_linear_rate, - p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, - p1_weights=_p1_weights_dict(noise.p1_weights), - p2_weights=_p2_weights_dict(noise.p2_weights), - p2_replacement_approximation=noise.p2_replacement_approximation, - ) + _with_noise_compat(DemSamplerBuilder(topology.influence_map), noise) .with_detectors_json(topology.detectors_json) .with_observables_json(topology.observables_json) ) @@ -1804,6 +1875,8 @@ def _build_native_sampler_from_cached_surface_topology( observables_json=topology.observables_json, num_detectors=topology.num_detectors, num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, ) @@ -1818,6 +1891,7 @@ def generate_circuit_level_dem_from_builder( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, + twirl: TwirlConfig | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -1850,6 +1924,9 @@ def generate_circuit_level_dem_from_builder( ``circuit_source="traced_qis"``. Custom runtime topologies are not kept in PECOS's in-process topology cache because plugin objects can carry private mutable state. + twirl: Optional Pauli-frame randomization layout. Canonical Guppy + frame-output mode is normalized to the same abstract raw lookup + and DEM topology. Returns: DEM string in standard format @@ -1862,6 +1939,7 @@ def generate_circuit_level_dem_from_builder( >>> dem = generate_circuit_level_dem_from_builder(patch, num_rounds=3, noise=noise) """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) if runtime is not None: @@ -1873,6 +1951,7 @@ def generate_circuit_level_dem_from_builder( circuit_source, include_idle_gates, runtime=runtime, + twirl=twirl, ) return _dem_string_from_cached_surface_topology( topology, @@ -1909,6 +1988,7 @@ def generate_circuit_level_dem_from_builder( p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, + twirl=twirl, ) @@ -3511,7 +3591,11 @@ class NativeSampler: observables_json: JSON string with observable definitions num_detectors: Number of detectors num_observables: Number of observables + pauli_frame_lookup: Optional PECOS lookup for Pauli-twirl mask composition + num_pauli_sites: Number of Pauli-twirl mask sites sampling_model: Which native sampling backend is active + dem_string: Optional graphlike-decomposed DEM string used to build the + sampler. Populated when the ``"dem"`` sampling model is selected. """ sampler: Any @@ -3519,14 +3603,19 @@ class NativeSampler: observables_json: str num_detectors: int num_observables: int + pauli_frame_lookup: Any | None = None + num_pauli_sites: int = 0 sampling_model: Literal["dem", "influence_dem", "mnm"] = ( "dem" # "mnm" accepted for compat, mapped to "influence_dem" ) + dem_string: str | None = None def sample( self, num_shots: int, seed: int | None = None, + *, + pauli_masks: Any | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Sample detection events and observable flips. @@ -3535,13 +3624,28 @@ def sample( Args: num_shots: Number of shots to sample seed: Optional random seed for reproducibility + pauli_masks: Optional integer array of shape + ``(num_shots, num_pauli_sites)`` with values 0=I, 1=X, 2=Y, + 3=Z. Requires ``build_native_sampler(..., + twirl=TwirlConfig())``. Returns: Tuple of (detection_events, observable_flips) as numpy arrays. - detection_events: shape (num_shots, num_detectors) - observable_flips: shape (num_shots, num_observables) """ - det_events, obs_flips = self.sampler.sample_batch(num_shots, seed) + if pauli_masks is None: + det_events, obs_flips = self.sampler.sample_batch(num_shots, seed) + else: + if self.pauli_frame_lookup is None: + msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" + raise ValueError(msg) + det_events, obs_flips = self.sampler.sample_batch_with_pauli_masks( + num_shots, + self.pauli_frame_lookup, + pauli_masks, + seed, + ) return np.array(det_events, dtype=bool), np.array(obs_flips, dtype=bool) @@ -3552,6 +3656,7 @@ def build_native_sampler( basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", + twirl: TwirlConfig | None = None, sampling_model: Literal[ "dem", "influence_dem", @@ -3581,6 +3686,8 @@ def build_native_sampler( TickCircuit. ``"traced_qis"`` traces the lowered ideal Selene/QIS gate stream and replays that exact gate list into a TickCircuit before native PECOS fault analysis. + twirl: Optional Pauli-frame randomization layout. Canonical runtime + frame-output mode is normalized to the same abstract raw lookup. sampling_model: Which native sampling backend to use. ``"dem"`` samples the generated decomposed DEM and is the default. ``"influence_dem"`` uses the influence-map-based DemSampler with @@ -3598,6 +3705,7 @@ def build_native_sampler( >>> detection_events, observable_flips = sampler.sample(num_shots=10000) """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( @@ -3607,6 +3715,7 @@ def build_native_sampler( ancilla_budget, circuit_source, _noise_uses_dedicated_idle_noise(noise), + twirl=twirl, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -3638,6 +3747,7 @@ def build_native_sampler( p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, + twirl=twirl, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -3646,10 +3756,287 @@ def build_native_sampler( observables_json=topology.observables_json, num_detectors=topology.num_detectors, num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, + dem_string=dem_str, ) return _build_native_sampler_from_cached_surface_topology( topology, noise, sampling_model=sampling_model, ) + + +def build_native_sampler_from_dem( + decomposed_dem: str, + patch: SurfacePatch, + num_rounds: int, + basis: str = "Z", + *, + ancilla_budget: int | None = None, + circuit_source: Literal["abstract", "traced_qis"] = "abstract", + twirl: TwirlConfig | None = None, +) -> NativeSampler: + """Build a native sampler from a caller-supplied decomposed DEM string. + + The supplied DEM is parsed directly and stored verbatim on the returned + sampler. Surface topology metadata and optional Pauli-frame lookup are + taken from the same abstract/traced surface circuit family as + :func:`build_native_sampler`. + """ + ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) + twirl = _abstract_twirl_config(twirl) + basis = basis.upper() + patch_key = _surface_patch_cache_key(patch) + topology = _cached_surface_native_topology( + patch_key, + num_rounds, + basis, + ancilla_budget, + circuit_source, + False, + twirl=twirl, + ) + sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() + return NativeSampler( + sampler=sampler, + detectors_json=topology.detectors_json, + observables_json=topology.observables_json, + num_detectors=topology.num_detectors, + num_observables=topology.num_observables, + pauli_frame_lookup=topology.pauli_frame_lookup, + num_pauli_sites=topology.num_pauli_sites, + sampling_model="dem", + dem_string=decomposed_dem, + ) + + +def decode_native_samples( + sampler: NativeSampler, + num_shots: int, + *, + dem: str | None = None, + decoder_type: str = "pymatching", + seed: int | None = None, + pauli_masks: Any | None = None, +) -> int: + """Sample, optionally apply a known Pauli-frame mask, de-mask, and decode.""" + from pecos_rslib.qec import SampleBatch + + dem_str = dem if dem is not None else sampler.dem_string + if dem_str is None: + msg = ( + "decode_native_samples requires a DEM string; " + "pass dem= or build the sampler with sampling_model='dem'" + ) + raise ValueError(msg) + + det_events, obs_flips = sampler.sample(num_shots, seed=seed, pauli_masks=pauli_masks) + + if pauli_masks is not None: + if sampler.pauli_frame_lookup is None: + msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" + raise ValueError(msg) + det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(pauli_masks) + det_events = np.asarray(det_events, dtype=bool) ^ np.asarray(det_xor, dtype=bool) + obs_flips = np.asarray(obs_flips, dtype=bool) ^ np.asarray(obs_xor, dtype=bool) + + det_list = np.asarray(det_events, dtype=np.uint8).tolist() + obs_arr = np.asarray(obs_flips, dtype=np.uint64) + if obs_arr.ndim != 2: + msg = f"expected obs_flips to be 2-D, got shape {obs_arr.shape}" + raise ValueError(msg) + weights = (1 << np.arange(obs_arr.shape[1], dtype=np.uint64)).astype(np.uint64) + obs_masks = (obs_arr * weights).sum(axis=1).astype(np.uint64).tolist() + batch = SampleBatch(det_list, obs_masks) + return batch.decode_count(dem_str, decoder_type) + + +def demask_pauli_frame_records( + pauli_frame_lookup: Any, + raw_events: Any, + raw_obs: Any, + pauli_masks: Any, +) -> tuple[NDArray[np.bool_], NDArray[np.bool_]]: + """Cancel known Pauli-frame mask flips from detector/observable records.""" + events_arr = np.asarray(raw_events, dtype=bool) + obs_arr = np.asarray(raw_obs, dtype=bool) + masks_arr = np.asarray(pauli_masks, dtype=np.int64) + + if events_arr.ndim != 2: + msg = ( + f"raw_events must be 2-D of shape (num_shots, num_detectors); " + f"got ndim={events_arr.ndim}, shape={events_arr.shape}" + ) + raise ValueError(msg) + if obs_arr.ndim != 2: + msg = ( + f"raw_obs must be 2-D of shape (num_shots, num_observables); " + f"got ndim={obs_arr.ndim}, shape={obs_arr.shape}" + ) + raise ValueError(msg) + if masks_arr.ndim != 2: + msg = ( + f"pauli_masks must be 2-D of shape (num_shots, num_pauli_sites); " + f"got ndim={masks_arr.ndim}, shape={masks_arr.shape}" + ) + raise ValueError(msg) + if events_arr.shape[0] != obs_arr.shape[0] or events_arr.shape[0] != masks_arr.shape[0]: + msg = ( + "raw_events, raw_obs, and pauli_masks must have the same " + f"num_shots; got {events_arr.shape[0]}, {obs_arr.shape[0]}, " + f"{masks_arr.shape[0]}" + ) + raise ValueError(msg) + + expected_det = pauli_frame_lookup.num_detectors + expected_obs = pauli_frame_lookup.num_observables + expected_sites = pauli_frame_lookup.num_pauli_sites + if events_arr.shape[1] != expected_det: + msg = ( + f"raw_events width {events_arr.shape[1]} != " + f"pauli_frame_lookup.num_detectors {expected_det}" + ) + raise ValueError(msg) + if obs_arr.shape[1] != expected_obs: + msg = ( + f"raw_obs width {obs_arr.shape[1]} != " + f"pauli_frame_lookup.num_observables {expected_obs}" + ) + raise ValueError(msg) + if masks_arr.shape[1] != expected_sites: + msg = ( + f"pauli_masks width {masks_arr.shape[1]} != " + f"pauli_frame_lookup.num_pauli_sites {expected_sites}" + ) + raise ValueError(msg) + + det_xor, obs_xor = pauli_frame_lookup.compute_mask_xor(masks_arr) + return ( + events_arr ^ np.asarray(det_xor, dtype=bool), + obs_arr ^ np.asarray(obs_xor, dtype=bool), + ) + + +def _extract_pauli_masks_from_results( + results: dict[str, Any], + *, + num_rounds: int, + num_data: int, + num_shots: int, +) -> NDArray[np.uint8]: + """Reconstruct per-shot Pauli-mask codes from Guppy result tags.""" + from pecos.qec.surface._twirl_sites import ( + mask_col_for, + num_pauli_sites, + pauli_mask_round_tag, + site_idx_for_round, + ) + + n_twirl = max(0, num_rounds - 1) + bits_per_round = 2 * num_data + out = np.zeros((num_shots, num_pauli_sites(num_rounds, num_data)), dtype=np.uint8) + + for r in range(n_twirl): + tag = pauli_mask_round_tag(r) + if tag not in results: + msg = ( + f"missing Pauli-mask result tag {tag!r} (expected {n_twirl} round " + f"tags for num_rounds={num_rounds}); did the program run with twirl enabled?" + ) + raise ValueError(msg) + per_round = results[tag] + if len(per_round) != num_shots: + msg = ( + f"Pauli-mask tag {tag!r}: got {len(per_round)} shots, " + f"expected {num_shots} shots" + ) + raise ValueError(msg) + + bits = np.asarray(per_round, dtype=np.uint8) + if bits.ndim != 2 or bits.shape[1] != bits_per_round: + msg = ( + f"Pauli-mask tag {tag!r} array has shape {bits.shape}, expected " + f"({num_shots}, {bits_per_round}) = (num_shots, 2*num_data)" + ) + raise ValueError(msg) + + lo = bits[:, 0::2] + hi = bits[:, 1::2] + packed = (lo + (hi << 1)).astype(np.uint8) + + site = site_idx_for_round(r) + for q in range(num_data): + out[:, mask_col_for(site, q, num_data)] = packed[:, q] + + return out + + +def sample_pauli_masks_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> NDArray[np.uint8]: + """Run the Guppy memory program with twirling and harvest mask columns.""" + from selene_sim import SimpleRuntime, Stim, build + + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + + if twirl is None or rng is None: + msg = "sample_pauli_masks_from_guppy requires both twirl and rng to be set" + raise ValueError(msg) + twirl._validate_runtime_supported() + if num_rounds < 1: + msg = f"num_rounds must be >= 1, got {num_rounds}" + raise ValueError(msg) + + num_data = patch.geometry.num_data + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) + + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_mask_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + num_qubits = get_num_qubits( + patch=patch, + ancilla_budget=ancilla_budget, + twirl=twirl, + ) + + results: dict[str, list[list[Any]]] = {} + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=num_qubits, + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + for name, values in shot_results: + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + results.setdefault(name, []).append(shot_value) + + return _extract_pauli_masks_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + ) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py new file mode 100644 index 000000000..94f3a6983 --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -0,0 +1,145 @@ +from __future__ import annotations + +import pytest + +pytest.importorskip("guppylang") + +from pecos.guppy.surface import ( # noqa: E402 + _guppy_module_cache_key, + generate_guppy_source, + generate_memory_experiment, +) +from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig # noqa: E402 +from pecos.qec.surface._twirl_sites import pauli_mask_round_tag # noqa: E402 +from pecos.qec.surface.patch import SurfacePatch # noqa: E402 + + +@pytest.fixture +def patch() -> SurfacePatch: + return SurfacePatch.create(distance=3) + + +def test_no_twirl_source_has_no_rng_or_mask_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch) + + assert "RNG(" not in src + assert "random_int_bounded" not in src + assert "seeded_pcg32_with_quantum_entropy(" not in src + assert 'result("pauli_mask' not in src + assert "for _t in range(comptime(num_rounds)):" in src + assert 'result("final"' in src + + +def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=3, + ) + + assert "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:" in src + assert "def seeded_pcg32_with_quantum_entropy(seed: int) -> tuple[nat, nat]:" in src + assert "entropy_q = qubit()" in src + assert "if measure(entropy_q):" in src + assert src.count("rng_state, rng_inc = seeded_pcg32_with_quantum_entropy(42)") == 2 + assert src.count('result("frame_mode:raw", True)') == 2 + assert "rng_state, m_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "if m_0_0 == 1:" in src + assert " x(surf.data[0])" in src + assert "if m_0_0 == 2:" in src + assert " y(surf.data[0])" in src + assert "if m_0_0 == 3:" in src + assert " z(surf.data[0])" in src + + for r in range(2): + assert src.count(f'result("{pauli_mask_round_tag(r)}"') == 2 + assert src.count("# === Round 2 (final, no twirl after) ===") == 2 + + +def test_canonical_frame_output_emits_raw_sibling_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=2, + ) + + assert 'result("frame_mode:canonical", True)' in src + assert "fx_0 = False" in src + assert "fz_0 = False" in src + assert "sx0_raw = measure(ax0)" in src + assert "sx0 = sx0_raw != sx0_flip" in src + assert 'result("raw:sx0:bit:0", sx0_raw)' in src + assert 'result("raw:final", final_raw)' in src + assert 'result("final", array(final_0' in src + + +def test_twirl_validation_requires_rng_and_num_rounds(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="twirl and rng must be supplied together"): + generate_guppy_source(patch, twirl=TwirlConfig(), num_rounds=2) + with pytest.raises(ValueError, match="twirl and rng must be supplied together"): + generate_guppy_source(patch, rng=GuppyRngMaskConfig(seed=42)) + with pytest.raises(ValueError, match="num_rounds is required when twirl is supplied"): + generate_guppy_source( + patch, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + ) + + +def test_twirled_cache_key_includes_seed_rounds_and_frame_mode(patch: SurfacePatch) -> None: + raw = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) + raw_seed2 = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=2), + num_rounds=2, + ) + canonical = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) + round3 = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=3, + ) + + assert raw != _guppy_module_cache_key(patch, effective_budget=10) + assert raw != raw_seed2 + assert raw != canonical + assert raw != round3 + assert "s1" in raw + assert "s2" in raw_seed2 + assert "frame-raw" in raw + assert "frame-canonical" in canonical + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +@pytest.mark.parametrize("frame_output", ["raw", "canonical"]) +def test_twirled_memory_experiment_compiles( + patch: SurfacePatch, + basis: str, + frame_output: str, +) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=2, + basis=basis, + twirl=TwirlConfig(frame_output=frame_output), + rng=GuppyRngMaskConfig(seed=7), + ) + assert fn is not None diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py new file mode 100644 index 000000000..e49b9785a --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -0,0 +1,393 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from pecos.qec.surface import ( + GuppyRngMaskConfig, + NoiseModel, + SurfacePatch, + TwirlConfig, + build_memory_circuit, + build_native_sampler, + demask_pauli_frame_records, + extract_detection_events_and_observables, + sample_pauli_masks_from_guppy, +) +from pecos.qec.surface._twirl_sites import num_pauli_sites +from pecos.qec.surface.decode import _extract_pauli_masks_from_results + +pytest.importorskip("guppylang") +pytest.importorskip("selene_sim") + + +@pytest.fixture +def patch_d3() -> SurfacePatch: + return SurfacePatch.create(distance=3) + + +def _bool_array_from_indices(rows: list[list[int]], width: int) -> np.ndarray: + out = np.zeros((len(rows), width), dtype=bool) + for shot, indices in enumerate(rows): + for idx in indices: + out[shot, idx] = True + return out + + +def _canonicalize_raw_rows_from_masks( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + raw_rows: list[list[int]], + masks: np.ndarray, +) -> list[list[int]]: + """Apply the same Pauli-frame rule as the generated canonical tracker.""" + num_data = patch.geometry.num_data + init_count = ( + len(patch.geometry.x_stabilizers) + if basis.upper() == "Z" + else len(patch.geometry.z_stabilizers) + ) + expected_width = init_count + num_rounds * patch.geometry.num_ancilla + num_data + canonical_rows: list[list[int]] = [] + + for shot, raw_row in enumerate(raw_rows): + assert len(raw_row) == expected_width + fx = [False] * num_data + fz = [False] * num_data + row: list[int] = [] + offset = 0 + + for _ in range(init_count): + row.append(int(raw_row[offset])) + offset += 1 + + for r in range(num_rounds): + for stab in patch.geometry.x_stabilizers: + flip = False + for q in stab.data_qubits: + flip ^= fz[q] + row.append(int(bool(raw_row[offset]) ^ flip)) + offset += 1 + for stab in patch.geometry.z_stabilizers: + flip = False + for q in stab.data_qubits: + flip ^= fx[q] + row.append(int(bool(raw_row[offset]) ^ flip)) + offset += 1 + if r < num_rounds - 1: + site_offset = r * num_data + for q in range(num_data): + code = int(masks[shot, site_offset + q]) + fx[q] ^= code in (1, 2) + fz[q] ^= code in (2, 3) + + final_flip = fx if basis.upper() == "Z" else fz + for q in range(num_data): + row.append(int(bool(raw_row[offset]) ^ final_flip[q])) + offset += 1 + + assert offset == expected_width + canonical_rows.append(row) + + return canonical_rows + + +def _run_twirled_guppy_rows_masks_and_raw( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, + twirl: TwirlConfig, +) -> tuple[list[list[int]], np.ndarray, list[list[int]], list[str]]: + from selene_sim import SimpleRuntime, Stim, build + + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ) + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_twirl_null_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + + mask_results: dict[str, list[list[int]]] = {} + measurement_rows: list[list[int]] = [] + raw_rows: list[list[int]] = [] + frame_modes: list[str] = [] + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=get_num_qubits(patch=patch, twirl=twirl), + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + row: list[int] = [] + raw_row: list[int] = [] + saw_final = False + saw_raw_final = False + frame_mode: str | None = None + + for name, values in shot_results: + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + + if name.startswith("pauli_mask:round:"): + mask_results.setdefault(name, []).append([int(v) for v in shot_value]) + elif name.startswith("frame_mode:"): + assert len(shot_value) == 1 + assert bool(shot_value[0]) + frame_mode = name.removeprefix("frame_mode:") + elif name.startswith("raw:") and ":bit:" in name: + assert len(shot_value) == 1 + raw_row.append(int(shot_value[0])) + elif name == "raw:final": + raw_row.extend(int(v) for v in shot_value) + saw_raw_final = True + elif ":meas:" in name: + assert len(shot_value) == 1 + bit = int(shot_value[0]) + row.append(bit) + if twirl.frame_output == "canonical" and ":init:meas:" in name: + raw_row.append(bit) + elif name == "final": + row.extend(int(v) for v in shot_value) + saw_final = True + + assert saw_final + assert frame_mode == twirl.frame_output + if twirl.frame_output == "canonical": + assert saw_raw_final + measurement_rows.append(row) + raw_rows.append(raw_row) + frame_modes.append(frame_mode) + + masks = _extract_pauli_masks_from_results( + mask_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + ) + return measurement_rows, masks, raw_rows, frame_modes + + +def _run_twirled_guppy_measurement_rows_and_masks( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, +) -> tuple[list[list[int]], np.ndarray]: + rows, masks, _, _ = _run_twirled_guppy_rows_masks_and_raw( + patch, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=rng, + twirl=TwirlConfig(), + ) + return rows, masks + + +def test_runtime_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=3, + num_shots=6, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=11), + ) + + unique_rows = np.unique(masks, axis=0) + assert unique_rows.shape[0] >= 2 + + +def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: + kwargs = dict( + num_rounds=3, + num_shots=6, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + ) + + masks_a = sample_pauli_masks_from_guppy(patch_d3, **kwargs) + masks_b = sample_pauli_masks_from_guppy(patch_d3, **kwargs) + + np.testing.assert_array_equal(masks_a, masks_b) + + +def test_different_seeds_differ(patch_d3: SurfacePatch) -> None: + common = dict( + num_rounds=3, + num_shots=8, + basis="Z", + twirl=TwirlConfig(), + ) + + masks_seed1 = sample_pauli_masks_from_guppy( + patch_d3, + rng=GuppyRngMaskConfig(seed=1), + **common, + ) + masks_seed2 = sample_pauli_masks_from_guppy( + patch_d3, + rng=GuppyRngMaskConfig(seed=2), + **common, + ) + + assert not np.array_equal(masks_seed1, masks_seed2) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_twirled_theta0_demask_null( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 6 + measurement_rows, masks = _run_twirled_guppy_measurement_rows_and_masks( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=12345), + ) + assert masks.any() + + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=TwirlConfig(), + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + raw_events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + assert not events.any() + assert not observables.any() + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 6 + twirl = TwirlConfig(frame_output="canonical") + measurement_rows, masks, raw_rows, frame_modes = _run_twirled_guppy_rows_masks_and_raw( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=12345), + twirl=twirl, + ) + + assert frame_modes == ["canonical"] * num_shots + assert masks.any() + assert raw_rows + + expected_rows = _canonicalize_raw_rows_from_masks( + patch_d3, + basis=basis, + num_rounds=num_rounds, + raw_rows=raw_rows, + masks=masks, + ) + assert measurement_rows == expected_rows + + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=TwirlConfig(), + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + raw_events_per_shot, raw_obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + raw_rows, + ) + raw_events = _bool_array_from_indices(raw_events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(raw_obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + demasked_events, demasked_obs = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + observables = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + np.testing.assert_array_equal(events, demasked_events) + np.testing.assert_array_equal(observables, demasked_obs) + assert not events.any() + assert not observables.any() + + +def test_d5_compile_smoke(patch_d3: SurfacePatch) -> None: + del patch_d3 + patch_d5 = SurfacePatch.create(distance=5) + masks = sample_pauli_masks_from_guppy( + patch_d5, + num_rounds=3, + num_shots=2, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=11), + ) + + num_data = patch_d5.geometry.num_data + assert masks.shape == (2, num_pauli_sites(3, num_data)) + assert masks.dtype == np.uint8 + assert masks.min() >= 0 and masks.max() <= 3 diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py new file mode 100644 index 000000000..9bfa05f28 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -0,0 +1,192 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from pecos.qec.surface import ( + GuppyRngMaskConfig, + NoiseModel, + SurfacePatch, + TwirlConfig, + build_memory_circuit, + build_native_sampler, + demask_pauli_frame_records, +) +from pecos.qec.surface._twirl_sites import ( + mask_col_for, + num_pauli_sites, + pauli_mask_round_tag, +) +from pecos.qec.surface.decode import _extract_pauli_masks_from_results + + +def test_extract_pauli_masks_packs_bits_in_row_major_site_qubit_order() -> None: + results = { + pauli_mask_round_tag(0): [[1, 0, 1, 1]], + pauli_mask_round_tag(1): [[0, 1, 0, 0]], + } + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=3, + num_data=2, + num_shots=1, + ) + + assert mask.dtype == np.uint8 + assert mask.tolist() == [[1, 3, 2, 0]] + assert mask[0, mask_col_for(0, 0, 2)] == 1 + assert mask[0, mask_col_for(0, 1, 2)] == 3 + assert mask[0, mask_col_for(1, 0, 2)] == 2 + assert mask[0, mask_col_for(1, 1, 2)] == 0 + + +def test_extract_pauli_masks_rejects_missing_or_misshaped_tags() -> None: + with pytest.raises(ValueError, match="missing Pauli-mask result tag"): + _extract_pauli_masks_from_results({}, num_rounds=2, num_data=1, num_shots=1) + + with pytest.raises(ValueError, match=r"expected \(1, 4\)"): + _extract_pauli_masks_from_results( + {pauli_mask_round_tag(0): [[1, 0, 1]]}, + num_rounds=2, + num_data=2, + num_shots=1, + ) + + +def test_demask_helper_cancels_known_pauli_frame_xor() -> None: + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + assert sampler.pauli_frame_lookup is not None + + masks = np.zeros((2, sampler.num_pauli_sites), dtype=np.uint8) + masks[0, 0] = 1 + masks[1, 0] = 3 + + det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(masks.astype(np.int64)) + physical_events = np.zeros((2, sampler.num_detectors), dtype=bool) + physical_obs = np.zeros((2, sampler.num_observables), dtype=bool) + physical_events[0, 0] = True + physical_obs[1, 0] = True + + raw_events = physical_events ^ np.asarray(det_xor, dtype=bool) + raw_obs = physical_obs ^ np.asarray(obs_xor, dtype=bool) + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + np.testing.assert_array_equal(events, physical_events) + np.testing.assert_array_equal(observables, physical_obs) + + +def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: + patch = SurfacePatch.create(distance=3) + raw = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + canonical = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(frame_output="canonical"), + ) + + assert canonical.num_detectors == raw.num_detectors + assert canonical.num_observables == raw.num_observables + assert canonical.num_pauli_sites == raw.num_pauli_sites + assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup + + +def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError) as exc_info: + build_memory_circuit( + patch=patch, + rounds=2, + basis="Z", + circuit_source="traced_qis", + twirl=TwirlConfig(), + ) + + msg = str(exc_info.value) + assert "one concrete mask realization" in msg + assert "result-id provenance" in msg + assert "circuit_source='abstract'" in msg + + +@pytest.mark.parametrize("distance", [3, 5]) +@pytest.mark.parametrize("num_rounds", [2, 3]) +def test_tracked_pauli_label_order_matches_mask_col_for( + distance: int, + num_rounds: int, +) -> None: + patch = SurfacePatch.create(distance=distance) + num_data = patch.geometry.num_data + tc = build_memory_circuit( + patch=patch, + rounds=num_rounds, + basis="Z", + twirl=TwirlConfig(), + ) + tracked = [a for a in tc.annotations() if a["kind"] == "tracked_pauli"] + + assert len(tracked) == 3 * num_pauli_sites(num_rounds, num_data) + for site in range(num_rounds - 1): + for q in range(num_data): + col = mask_col_for(site, q, num_data) + base = 3 * col + for offset, kind in enumerate(("X", "Y", "Z")): + assert tracked[base + offset]["label"] == f"twirl_s{site}_q{q}_{kind}" + + +def test_raw_twirled_guppy_trace_result_provenance_ignores_sideband_tags() -> None: + pytest.importorskip("guppylang") + pytest.importorskip("selene_sim") + + from pecos.guppy import get_num_qubits + from pecos.guppy.surface import generate_memory_experiment + from pecos.qec.surface.decode import ( + _index_surface_result_trace_ids, + trace_guppy_into_tick_circuit_with_result_traces, + ) + + patch = SurfacePatch.create(distance=3) + num_rounds = 2 + program = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis="Z", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=0), + ) + _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( + program, + get_num_qubits(patch=patch, twirl=TwirlConfig()), + seed=0, + ) + + sideband_names = {trace.get("name") for trace in result_traces if isinstance(trace.get("name"), str)} + assert any(str(name).startswith("pauli_mask:") for name in sideband_names) + assert "frame_mode:raw" in sideband_names + + scalar_trace_ids, array_trace_ids = _index_surface_result_trace_ids(result_traces) + indexed_names = set(scalar_trace_ids) | set(array_trace_ids) + assert any(name.startswith("sx") and ":meas:" in name for name in indexed_names) + assert "final" in indexed_names + assert not any(name.startswith(("pauli_mask:", "frame_mode:", "raw:")) for name in indexed_names) From b8c37766cf370c0ccfa200e6ada1bd91465e5eee Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 00:30:45 -0600 Subject: [PATCH 109/388] Address Pauli twirl review findings --- .../src/pecos/qec/surface/decode.py | 28 ++++- .../qec/surface/test_pauli_twirl_handoff.py | 100 +++++++++++++++++- 2 files changed, 122 insertions(+), 6 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ac954498c..2ea7e21ed 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -299,6 +299,7 @@ def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: """Drop runtime-only Guppy record-framing fields before DEM caching.""" if twirl is None: return None + twirl._validate_runtime_supported() return replace(twirl, frame_output="raw") @@ -1349,6 +1350,9 @@ def _build_surface_tick_circuit_for_native_model( """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch + if twirl is not None: + twirl._validate_runtime_supported() + abstract_tc = generate_tick_circuit_from_patch( patch, num_rounds, @@ -1393,6 +1397,18 @@ def _build_surface_tick_circuit_for_native_model( return traced_tc +def _pauli_masks_as_int64(pauli_masks: Any) -> NDArray[np.int64]: + """Return Pauli-mask input in the integer dtype accepted by Rust bindings.""" + masks_arr = np.asarray(pauli_masks) + if not np.issubdtype(masks_arr.dtype, np.integer): + msg = ( + "pauli_masks must be an integer array with values " + "0=I, 1=X, 2=Y, 3=Z" + ) + raise TypeError(msg) + return np.asarray(masks_arr, dtype=np.int64) + + def build_memory_circuit( *, rounds: int, @@ -3640,10 +3656,11 @@ def sample( if self.pauli_frame_lookup is None: msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" raise ValueError(msg) + masks_arr = _pauli_masks_as_int64(pauli_masks) det_events, obs_flips = self.sampler.sample_batch_with_pauli_masks( num_shots, self.pauli_frame_lookup, - pauli_masks, + masks_arr, seed, ) return np.array(det_events, dtype=bool), np.array(obs_flips, dtype=bool) @@ -3832,13 +3849,14 @@ def decode_native_samples( ) raise ValueError(msg) - det_events, obs_flips = sampler.sample(num_shots, seed=seed, pauli_masks=pauli_masks) + masks_arr = _pauli_masks_as_int64(pauli_masks) if pauli_masks is not None else None + det_events, obs_flips = sampler.sample(num_shots, seed=seed, pauli_masks=masks_arr) - if pauli_masks is not None: + if masks_arr is not None: if sampler.pauli_frame_lookup is None: msg = "pauli_masks require build_native_sampler(..., twirl=TwirlConfig())" raise ValueError(msg) - det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(pauli_masks) + det_xor, obs_xor = sampler.pauli_frame_lookup.compute_mask_xor(masks_arr) det_events = np.asarray(det_events, dtype=bool) ^ np.asarray(det_xor, dtype=bool) obs_flips = np.asarray(obs_flips, dtype=bool) ^ np.asarray(obs_xor, dtype=bool) @@ -3862,7 +3880,7 @@ def demask_pauli_frame_records( """Cancel known Pauli-frame mask flips from detector/observable records.""" events_arr = np.asarray(raw_events, dtype=bool) obs_arr = np.asarray(raw_obs, dtype=bool) - masks_arr = np.asarray(pauli_masks, dtype=np.int64) + masks_arr = _pauli_masks_as_int64(pauli_masks) if events_arr.ndim != 2: msg = ( diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index 9bfa05f28..d7ee9560e 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -10,6 +10,7 @@ TwirlConfig, build_memory_circuit, build_native_sampler, + decode_native_samples, demask_pauli_frame_records, ) from pecos.qec.surface._twirl_sites import ( @@ -17,7 +18,10 @@ num_pauli_sites, pauli_mask_round_tag, ) -from pecos.qec.surface.decode import _extract_pauli_masks_from_results +from pecos.qec.surface.decode import ( + _extract_pauli_masks_from_results, + generate_circuit_level_dem_from_builder, +) def test_extract_pauli_masks_packs_bits_in_row_major_site_qubit_order() -> None: @@ -89,6 +93,27 @@ def test_demask_helper_cancels_known_pauli_frame_xor() -> None: np.testing.assert_array_equal(observables, physical_obs) +def test_native_sampler_accepts_harvested_uint8_pauli_masks() -> None: + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(), + ) + assert sampler.num_pauli_sites > 0 + + masks = np.zeros((4, sampler.num_pauli_sites), dtype=np.uint8) + masks[:, 0] = [0, 1, 2, 3] + + det_events, obs_flips = sampler.sample(4, seed=123, pauli_masks=masks) + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + + assert decode_native_samples(sampler, 4, seed=123, pauli_masks=masks) == 0 + + def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: patch = SurfacePatch.create(distance=3) raw = build_native_sampler( @@ -112,6 +137,79 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup +@pytest.mark.parametrize( + ("field", "value"), + [ + ("scheme", "clifford"), + ("site_schedule", "per_two_qubit_gate"), + ("result_encoding", "bogus"), + ("frame_output", "physical"), + ], +) +def test_abstract_twirl_builders_reject_unsupported_config( + field: str, + value: str, +) -> None: + patch = SurfacePatch.create(distance=3) + kwargs = {field: value} + twirl = TwirlConfig(**kwargs) # type: ignore[arg-type] + + with pytest.raises(ValueError, match=field): + build_memory_circuit( + patch=patch, + rounds=2, + basis="Z", + twirl=twirl, + ) + + with pytest.raises(ValueError, match=field): + build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + + with pytest.raises(ValueError, match=field): + generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + + +def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p_idle_x_quadratic_sine_rate=0.03) + twirl = TwirlConfig() + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=twirl, + ) + assert "error(" in dem + + sampler = build_native_sampler( + patch, + num_rounds=2, + noise=noise, + basis="Z", + twirl=twirl, + ) + assert sampler.num_pauli_sites == num_pauli_sites(2, patch.geometry.num_data) + + det_events, obs_flips = sampler.sample(2, seed=7) + assert det_events.shape == (2, sampler.num_detectors) + assert obs_flips.shape == (2, sampler.num_observables) + + def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: patch = SurfacePatch.create(distance=3) From 888dad2c8838e85955a5bd930adfc8b6438bbb36 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 08:11:14 -0600 Subject: [PATCH 110/388] Treat Pauli meta ticks as zero-duration --- crates/pecos-quantum/src/tick_circuit.rs | 36 ++++++++++++++++++ .../qec/surface/test_pauli_twirl_handoff.py | 37 +++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 8f334300f..1be17d783 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -2297,6 +2297,17 @@ impl TickCircuit { } for tick in &mut self.ticks { + // Metadata-only ticks are zero-duration bookkeeping markers. + // They preserve annotation order but must not create physical idle + // periods on qubits outside the metadata payload. + if !tick.is_empty() + && tick + .gate_batches() + .iter() + .all(|gate| gate.gate_type.is_meta()) + { + continue; + } let active = tick.active_qubits(); for &q in &all_qubits { if !active.contains(&q) { @@ -6139,6 +6150,31 @@ mod tests { assert!(count_after > count_before, "Should have added idle gates"); } + #[test] + fn test_fill_idle_gates_skips_tracked_pauli_meta_ticks() { + use pecos_core::pauli::X; + + let mut tc = TickCircuit::new(); + tc.tick().h(&[0, 1]); + tc.tracked_pauli_labeled("frame_marker", X(0)); + tc.tick().h(&[0, 1]); + + tc.fill_idle_gates(); + + let meta_tick = tc + .ticks() + .iter() + .find(|tick| { + tick.gate_batches() + .iter() + .any(|gate| gate.gate_type == GateType::TrackedPauliMeta) + }) + .expect("tracked-Pauli meta tick"); + + assert_eq!(meta_tick.len(), 1); + assert!(meta_tick.gate_batches()[0].gate_type.is_meta()); + } + #[test] fn test_channel_gate_is_first_class_tick_operation() { let mut tc = TickCircuit::new(); diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index d7ee9560e..8f98cb082 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -210,6 +210,43 @@ def test_twirl_sine_law_idle_noise_builds_dem_and_sampler() -> None: assert obs_flips.shape == (2, sampler.num_observables) +@pytest.mark.parametrize( + ("label", "noise"), + [ + ("depolarizing", NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001)), + ("uniform_idle", NoiseModel(p_idle=0.002)), + ("t1_t2", NoiseModel(t1=1000.0, t2=800.0)), + ("linear_idle", NoiseModel(p_idle_linear_rate=0.001)), + ("quadratic_idle", NoiseModel(p_idle_quadratic_rate=0.01)), + ("sine_law_idle", NoiseModel(p_idle_x_quadratic_sine_rate=0.03)), + ], +) +def test_twirling_does_not_change_canonical_dem( + label: str, + noise: NoiseModel, +) -> None: + del label + patch = SurfacePatch.create(distance=3) + + untwirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + ) + twirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=TwirlConfig(), + ) + + assert twirled == untwirled + + def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: patch = SurfacePatch.create(distance=3) From 0adfae6d427e2557efe701489c0116fa1e0ce2d7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 09:37:35 -0600 Subject: [PATCH 111/388] Harden meta tick idle accounting --- crates/pecos-core/src/gate_type.rs | 6 +++ crates/pecos-quantum/src/tick_circuit.rs | 66 +++++++++++++++++++----- 2 files changed, 58 insertions(+), 14 deletions(-) diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index 66ab8046d..9e0b6845f 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -193,6 +193,12 @@ impl GateType { /// /// Meta-gates have a position in the DAG but do not affect quantum state /// and should not create fault locations or receive noise. + /// + /// Idle-duration accounting depends on this predicate: + /// `TickCircuit::fill_idle_gates` treats ticks whose batches are all + /// meta as zero physical duration. Any new meta gate type MUST be added + /// here, or it will manufacture phantom idle periods in + /// idle-duration-driven noise models. #[must_use] pub const fn is_meta(self) -> bool { matches!(self, GateType::TrackedPauliMeta) diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 1be17d783..25c18752a 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -2260,6 +2260,14 @@ impl TickCircuit { // ==================== Idle ==================== + /// Insert Idle gates after each two-qubit gate on both of its qubits. + /// + /// Delegates to `InsertIdleAfterTwoQubitGates` pass. See [`crate::pass`]. + pub fn insert_idle_after_two_qubit_gates(&mut self, duration: f64) { + use crate::pass::{CircuitPass, InsertIdleAfterTwoQubitGates}; + InsertIdleAfterTwoQubitGates(duration).apply_tick(self); + } + /// Insert identity gates for qubits not operated on during each tick. /// /// For each tick, finds qubits that are in the circuit's qubit set but @@ -2270,6 +2278,17 @@ impl TickCircuit { /// This is separate from `GateType::Idle` which represents explicit /// wait operations with duration-dependent `p_idle` noise. /// + /// Metadata-only ticks (all batches `GateType::is_meta`) are zero + /// physical duration and receive no idle gates. + /// + /// # Panics + /// + /// Panics if a tick mixes meta and physical gate batches: such a tick has + /// physical duration, but per-tick qubit exclusivity makes idle insertion + /// on meta-occupied qubits impossible, so the accounting would be + /// silently wrong. Emit meta batches in their own tick (as + /// `tracked_pauli` does). + /// /// # Example /// /// ``` @@ -2282,14 +2301,6 @@ impl TickCircuit { /// /// circuit.fill_idle_gates(); /// ``` - /// Insert Idle gates after each two-qubit gate on both of its qubits. - /// - /// Delegates to `InsertIdleAfterTwoQubitGates` pass. See [`crate::pass`]. - pub fn insert_idle_after_two_qubit_gates(&mut self, duration: f64) { - use crate::pass::{CircuitPass, InsertIdleAfterTwoQubitGates}; - InsertIdleAfterTwoQubitGates(duration).apply_tick(self); - } - pub fn fill_idle_gates(&mut self) { let all_qubits = self.all_qubits(); if all_qubits.is_empty() { @@ -2300,14 +2311,25 @@ impl TickCircuit { // Metadata-only ticks are zero-duration bookkeeping markers. // They preserve annotation order but must not create physical idle // periods on qubits outside the metadata payload. - if !tick.is_empty() - && tick - .gate_batches() - .iter() - .all(|gate| gate.gate_type.is_meta()) - { + let meta_batches = tick + .gate_batches() + .iter() + .filter(|gate| gate.gate_type.is_meta()) + .count(); + if !tick.is_empty() && meta_batches == tick.gate_batches().len() { continue; } + // A tick that mixes meta and physical gates has ambiguous idle + // accounting: the tick has physical duration, but per-tick qubit + // exclusivity makes it impossible to insert an Idle on a qubit a + // meta gate already occupies. Meta batches belong in their own + // tick (as `tracked_pauli` emits them). + assert!( + meta_batches == 0, + "fill_idle_gates: tick mixes meta and physical gates; emit meta \ + batches in their own tick so idle-duration accounting stays \ + unambiguous" + ); let active = tick.active_qubits(); for &q in &all_qubits { if !active.contains(&q) { @@ -6175,6 +6197,22 @@ mod tests { assert!(meta_tick.gate_batches()[0].gate_type.is_meta()); } + #[test] + #[should_panic(expected = "tick mixes meta and physical gates")] + fn test_fill_idle_gates_rejects_mixed_meta_and_physical_tick() { + let mut tc = TickCircuit::new(); + let mut tick = tc.tick(); + tick.h(&[0]); + tick.try_add_gate(Gate::simple( + GateType::TrackedPauliMeta, + vec![QubitId::from(1)], + )) + .map(|_| ()) + .expect("meta gate on a free qubit must be addable"); + + tc.fill_idle_gates(); + } + #[test] fn test_channel_gate_is_first_class_tick_operation() { let mut tc = TickCircuit::new(); From 930ad80b6bb5b384af993bd1f883ac5104085419 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 12 Jun 2026 10:05:37 -0600 Subject: [PATCH 112/388] Polish Pauli twirl handoff lint checks --- .../quantum-pecos/src/pecos/guppy/surface.py | 37 +++++++++++-------- .../src/pecos/qec/surface/__init__.py | 4 +- .../pecos/qec/surface/_detection_events.py | 14 +++++-- .../src/pecos/qec/surface/_twirl_config.py | 7 +++- .../src/pecos/qec/surface/_twirl_sites.py | 16 ++++---- .../src/pecos/qec/surface/decode.py | 10 ++--- .../tests/guppy/test_surface_twirl_render.py | 8 ++-- .../qec/surface/test_pauli_mask_harvest.py | 33 ++++++++--------- .../qec/surface/test_pauli_twirl_handoff.py | 3 +- 9 files changed, 74 insertions(+), 58 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 97aa6fabf..fcdbfe2be 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -153,6 +153,8 @@ def generate_guppy_source( emits measurement records in the canonical untwirled DEM frame. rng: Runtime mask source: a stream-separator seed mixed with per-shot quantum entropy when ``twirl`` is enabled. + num_rounds: Number of syndrome rounds to render. Required when + ``twirl`` is enabled because twirled source is unrolled per round. Returns: Python/Guppy source code as a string. @@ -163,12 +165,10 @@ def generate_guppy_source( from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget if (twirl is None) != (rng is None): - msg = "twirl and rng must be supplied together; got twirl={!r} rng={!r}".format( - twirl, rng - ) + msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" raise ValueError(msg) if twirl is not None: - twirl._validate_runtime_supported() + twirl.validate_runtime_supported() if num_rounds is None: msg = "num_rounds is required when twirl is supplied" raise ValueError(msg) @@ -291,11 +291,15 @@ def generate_guppy_source( ) if canonical_frame_output: lines.append( - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}, frame_x: array[bool, {num_data}], frame_z: array[bool, {num_data}]) -> Syndrome_{dx}x{dz}:" + "def syndrome_extraction(" + f"surf: SurfaceCode_{dx}x{dz}, " + f"frame_x: array[bool, {num_data}], " + f"frame_z: array[bool, {num_data}]" + f") -> Syndrome_{dx}x{dz}:", ) else: lines.append( - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:" + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", ) if not constrained: @@ -651,8 +655,9 @@ def _render_memory_experiments( if twirl is None: lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz)) else: - assert rng is not None - assert num_rounds is not None + if rng is None or num_rounds is None: + msg = "twirled memory rendering requires both rng and num_rounds" + raise ValueError(msg) lines.extend( _render_twirled_memory_block( basis, @@ -663,7 +668,7 @@ def _render_memory_experiments( twirl, rng, num_rounds, - ) + ), ) return lines @@ -745,7 +750,7 @@ def _render_twirled_memory_block( frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) body.append( - f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))" + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))", ) else: body.append(" syn = syndrome_extraction(surf)") @@ -764,10 +769,10 @@ def _render_twirled_memory_block( body.append(f" hi_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)") if canonical_frame_output: body.append( - f" twx_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 2)" + f" twx_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 2)", ) body.append( - f" twz_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)" + f" twz_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)", ) body.append(f" fx_{q} = fx_{q} != twx_{r}_{q}") body.append(f" fz_{q} = fz_{q} != twz_{r}_{q}") @@ -782,7 +787,7 @@ def _render_twirled_memory_block( frame_x = ", ".join(f"fx_{q}" for q in range(num_data)) frame_z = ", ".join(f"fz_{q}" for q in range(num_data)) body.append( - f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))" + f" syn = syndrome_extraction(surf, array({frame_x}), array({frame_z}))", ) else: body.append(" syn = syndrome_extraction(surf)") @@ -861,8 +866,9 @@ def _guppy_module_cache_key( base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" if twirl is None: return base - assert rng is not None - assert num_rounds is not None + if rng is None or num_rounds is None: + msg = "twirled Guppy module cache keys require both rng and num_rounds" + raise ValueError(msg) twirl_part = ( f"t-{twirl.scheme}-{twirl.site_schedule}-{twirl.result_encoding}" f"-frame-{twirl.frame_output}" @@ -893,6 +899,7 @@ def _load_guppy_module( ancilla_budget: Optional cap on simultaneously live ancillas twirl: Pauli-twirl-site declaration (structural) rng: Runtime mask RNG seed (must be supplied with ``twirl``) + num_rounds: Syndrome-round count for unrolled twirled source. Returns: Module dictionary with generated functions diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index f4d4c3963..27130f05c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -18,6 +18,8 @@ """ # Circuit generation from geometry (unified abstraction) +from pecos.qec.surface._detection_events import extract_detection_events_and_observables +from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig from pecos.qec.surface.circuit_builder import ( DagCircuitRenderer, GuppyRenderer, @@ -105,12 +107,10 @@ get_stabilizer_touch_label, ) from pecos.qec.surface.plot import plot_patch, plot_surface_code -from pecos.qec.surface._detection_events import extract_detection_events_and_observables from pecos.qec.surface.schedule import ( compute_cnot_schedule, get_stab_schedule, ) -from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig __all__ = [ # Twirling config (Pauli-frame randomization) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py index 14a250631..95d4c7c58 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -6,12 +6,20 @@ from __future__ import annotations import json -from collections.abc import Iterable, Sequence -from typing import Any +from typing import TYPE_CHECKING, Protocol + +if TYPE_CHECKING: + from collections.abc import Iterable, Sequence + + +class _TickCircuitLike(Protocol): + def get_meta(self, key: str) -> str | None: + """Return metadata stored under ``key`` when available.""" + ... def extract_detection_events_and_observables( - tick_circuit: Any, + tick_circuit: _TickCircuitLike, results: Iterable[Sequence[int]], ) -> tuple[list[list[int]], list[list[int]]]: """Extract fired detectors and observables from flat measurement rows.""" diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py index 2263583e7..d35e3a1dc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -27,7 +27,6 @@ from dataclasses import dataclass from typing import Literal - _SUPPORTED_SCHEMES = ("pauli",) _SUPPORTED_SITE_SCHEDULES = ("between_rounds",) _SUPPORTED_RESULT_ENCODINGS = ("bool_array_v1",) @@ -71,7 +70,7 @@ class TwirlConfig: result_encoding: Literal["bool_array_v1"] = "bool_array_v1" frame_output: Literal["raw", "canonical"] = "raw" - def _validate_runtime_supported(self) -> None: + def validate_runtime_supported(self) -> None: """Raise ``ValueError`` if any field is outside the supported runtime set. The ``Literal`` annotations are static-only hints; this method @@ -106,6 +105,10 @@ def _validate_runtime_supported(self) -> None: ) raise ValueError(msg) + def _validate_runtime_supported(self) -> None: + """Compatibility alias for the public runtime validator.""" + self.validate_runtime_supported() + @dataclass(frozen=True) class GuppyRngMaskConfig: diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py index 068185102..72fd05d69 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -1,6 +1,7 @@ -"""Canonical `(round, qubit) -> site_idx` mapping shared by the abstract -circuit (`circuit_builder.py`) and the Guppy runtime renderer -(`pecos.guppy.surface`). +"""Canonical Pauli-twirl site mapping. + +This maps `(round, qubit) -> site_idx` for both the abstract circuit +(`circuit_builder.py`) and the Guppy runtime renderer (`pecos.guppy.surface`). Both tracks must agree byte-for-byte on the ordering of twirl-site metadata: the abstract circuit's `tracked_pauli` annotations populate @@ -60,8 +61,10 @@ def pauli_mask_round_tag(round_idx: int) -> str: - """Canonical `result()` tag for the twirl mask emitted between syndrome - rounds ``round_idx`` and ``round_idx + 1``. + """Return the canonical per-round twirl-mask result tag. + + The tag is emitted between syndrome rounds ``round_idx`` and + ``round_idx + 1``. """ return f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}" @@ -76,8 +79,7 @@ def num_twirl_sites(num_rounds: int) -> int: def site_idx_for_round(round_idx: int) -> int: - """Map the round-loop index of the round PRECEDING a twirl site to its - canonical `site_idx`. + """Map the preceding round-loop index to a twirl site index. For the `between_rounds` schedule, the twirl site that sits between syndrome round `r` and round `r + 1` is canonical `site_idx == r`. diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 2ea7e21ed..e4b360901 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -299,7 +299,7 @@ def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: """Drop runtime-only Guppy record-framing fields before DEM caching.""" if twirl is None: return None - twirl._validate_runtime_supported() + twirl.validate_runtime_supported() return replace(twirl, frame_output="raw") @@ -1192,7 +1192,6 @@ def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> # operations, whose Measure payloads include the stable result ids. # Replay the raw operations in that compatibility case instead of # losing provenance. - pass operations: list[dict[str, Any]] = [] for chunk in chunks: @@ -1320,7 +1319,6 @@ def _generate_traced_surface_tick_circuit_with_result_traces( runtime: object | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" - from pecos.guppy import get_num_qubits from pecos.guppy.surface import generate_memory_experiment, get_num_qubits program = generate_memory_experiment( @@ -1351,7 +1349,7 @@ def _build_surface_tick_circuit_for_native_model( from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch if twirl is not None: - twirl._validate_runtime_supported() + twirl.validate_runtime_supported() abstract_tc = generate_tick_circuit_from_patch( patch, @@ -3812,7 +3810,7 @@ def build_native_sampler_from_dem( basis, ancilla_budget, circuit_source, - False, + include_idle_gates=False, twirl=twirl, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() @@ -4010,7 +4008,7 @@ def sample_pauli_masks_from_guppy( if twirl is None or rng is None: msg = "sample_pauli_masks_from_guppy requires both twirl and rng to be set" raise ValueError(msg) - twirl._validate_runtime_supported() + twirl.validate_runtime_supported() if num_rounds < 1: msg = f"num_rounds must be >= 1, got {num_rounds}" raise ValueError(msg) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 94f3a6983..d0266340a 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -4,14 +4,14 @@ pytest.importorskip("guppylang") -from pecos.guppy.surface import ( # noqa: E402 +from pecos.guppy.surface import ( _guppy_module_cache_key, generate_guppy_source, generate_memory_experiment, ) -from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig # noqa: E402 -from pecos.qec.surface._twirl_sites import pauli_mask_round_tag # noqa: E402 -from pecos.qec.surface.patch import SurfacePatch # noqa: E402 +from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig +from pecos.qec.surface._twirl_sites import pauli_mask_round_tag +from pecos.qec.surface.patch import SurfacePatch @pytest.fixture diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py index e49b9785a..5ceaff57a 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -2,7 +2,6 @@ import numpy as np import pytest - from pecos.qec.surface import ( GuppyRngMaskConfig, NoiseModel, @@ -103,10 +102,9 @@ def _run_twirled_guppy_rows_masks_and_raw( rng: GuppyRngMaskConfig, twirl: TwirlConfig, ) -> tuple[list[list[int]], np.ndarray, list[list[int]], list[str]]: - from selene_sim import SimpleRuntime, Stim, build - from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + from selene_sim import SimpleRuntime, Stim, build fn = generate_memory_experiment( patch, @@ -217,13 +215,13 @@ def test_runtime_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: - kwargs = dict( - num_rounds=3, - num_shots=6, - basis="Z", - twirl=TwirlConfig(), - rng=GuppyRngMaskConfig(seed=42), - ) + kwargs = { + "num_rounds": 3, + "num_shots": 6, + "basis": "Z", + "twirl": TwirlConfig(), + "rng": GuppyRngMaskConfig(seed=42), + } masks_a = sample_pauli_masks_from_guppy(patch_d3, **kwargs) masks_b = sample_pauli_masks_from_guppy(patch_d3, **kwargs) @@ -232,12 +230,12 @@ def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: def test_different_seeds_differ(patch_d3: SurfacePatch) -> None: - common = dict( - num_rounds=3, - num_shots=8, - basis="Z", - twirl=TwirlConfig(), - ) + common = { + "num_rounds": 3, + "num_shots": 8, + "basis": "Z", + "twirl": TwirlConfig(), + } masks_seed1 = sample_pauli_masks_from_guppy( patch_d3, @@ -390,4 +388,5 @@ def test_d5_compile_smoke(patch_d3: SurfacePatch) -> None: num_data = patch_d5.geometry.num_data assert masks.shape == (2, num_pauli_sites(3, num_data)) assert masks.dtype == np.uint8 - assert masks.min() >= 0 and masks.max() <= 3 + assert masks.min() >= 0 + assert masks.max() <= 3 diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index 8f98cb082..971d502e0 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -2,7 +2,6 @@ import numpy as np import pytest - from pecos.qec.surface import ( GuppyRngMaskConfig, NoiseModel, @@ -250,7 +249,7 @@ def test_twirling_does_not_change_canonical_dem( def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="one concrete mask realization") as exc_info: build_memory_circuit( patch=patch, rounds=2, From 1a651bf581549af8b238fb53cf4ac8ca5d9c158b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 10:27:00 -0600 Subject: [PATCH 113/388] Tighten Pauli twirl canonicalization tests --- .../tests/qec/surface/test_pauli_twirl_handoff.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index 971d502e0..e74c1dbda 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -134,6 +134,7 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: assert canonical.num_observables == raw.num_observables assert canonical.num_pauli_sites == raw.num_pauli_sites assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup + assert canonical.dem_string == raw.dem_string @pytest.mark.parametrize( @@ -153,6 +154,9 @@ def test_abstract_twirl_builders_reject_unsupported_config( kwargs = {field: value} twirl = TwirlConfig(**kwargs) # type: ignore[arg-type] + with pytest.raises(ValueError, match=field): + twirl.validate_runtime_supported() + with pytest.raises(ValueError, match=field): build_memory_circuit( patch=patch, From 17276d28797b421e401ab308ff4a03021e5df4ff Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 10:55:39 -0600 Subject: [PATCH 114/388] Strengthen Guppy Pauli twirl validation --- .../qec/surface/test_pauli_mask_harvest.py | 98 +++++++++++++++++-- 1 file changed, 88 insertions(+), 10 deletions(-) diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py index 5ceaff57a..98ebf88c6 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -9,6 +9,7 @@ TwirlConfig, build_memory_circuit, build_native_sampler, + decode_native_samples, demask_pauli_frame_records, extract_detection_events_and_observables, sample_pauli_masks_from_guppy, @@ -300,20 +301,21 @@ def test_runtime_twirled_theta0_demask_null( assert not observables.any() -@pytest.mark.parametrize("basis", ["Z", "X"]) -def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( - patch_d3: SurfacePatch, +def _assert_canonical_frame_output_matches_lookup( + patch: SurfacePatch, + *, basis: str, + num_rounds: int, + num_shots: int, + seed: int, ) -> None: - num_rounds = 2 - num_shots = 6 twirl = TwirlConfig(frame_output="canonical") measurement_rows, masks, raw_rows, frame_modes = _run_twirled_guppy_rows_masks_and_raw( - patch_d3, + patch, basis=basis, num_rounds=num_rounds, num_shots=num_shots, - rng=GuppyRngMaskConfig(seed=12345), + rng=GuppyRngMaskConfig(seed=seed), twirl=twirl, ) @@ -322,7 +324,7 @@ def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( assert raw_rows expected_rows = _canonicalize_raw_rows_from_masks( - patch_d3, + patch, basis=basis, num_rounds=num_rounds, raw_rows=raw_rows, @@ -331,13 +333,13 @@ def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( assert measurement_rows == expected_rows tick_circuit = build_memory_circuit( - patch=patch_d3, + patch=patch, rounds=num_rounds, basis=basis, twirl=TwirlConfig(), ) sampler = build_native_sampler( - patch_d3, + patch, num_rounds=num_rounds, noise=NoiseModel(), basis=basis, @@ -373,6 +375,82 @@ def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( assert not observables.any() +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( + patch_d3: SurfacePatch, + basis: str, +) -> None: + _assert_canonical_frame_output_matches_lookup( + patch_d3, + basis=basis, + num_rounds=2, + num_shots=6, + seed=12345, + ) + + +@pytest.mark.parametrize( + ("distance", "num_rounds", "basis", "num_shots", "seed"), + [ + (3, 3, "Z", 4, 2024), + (3, 3, "X", 4, 2025), + (5, 3, "Z", 2, 2026), + ], +) +def test_runtime_canonical_frame_output_matches_lookup_matrix( + distance: int, + num_rounds: int, + basis: str, + num_shots: int, + seed: int, +) -> None: + patch = SurfacePatch.create(distance=distance) + _assert_canonical_frame_output_matches_lookup( + patch, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + seed=seed, + ) + + +def test_harvested_runtime_masks_drive_fixed_dem_sampler_null(patch_d3: SurfacePatch) -> None: + num_rounds = 3 + num_shots = 8 + twirl = TwirlConfig() + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=5150), + ) + assert masks.any() + + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis="Z", + twirl=twirl, + ) + assert sampler.pauli_frame_lookup is not None + + raw_events, raw_obs = sampler.sample(num_shots, seed=99, pauli_masks=masks) + assert raw_events.any() or raw_obs.any() + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + assert not events.any() + assert not observables.any() + assert decode_native_samples(sampler, num_shots, seed=99, pauli_masks=masks) == 0 + + def test_d5_compile_smoke(patch_d3: SurfacePatch) -> None: del patch_d3 patch_d5 = SurfacePatch.create(distance=5) From 7749ad4cb79032484769a3a7747500f19ce4de83 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 10:57:19 -0600 Subject: [PATCH 115/388] Route the Python QASM run path through the unified facade for both stacks and replace silent-ignore of unknown noise/quantum builders with typed errors --- python/pecos-rslib/src/sim.rs | 331 +++++++++++++++++----------------- 1 file changed, 167 insertions(+), 164 deletions(-) diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 8c420e645..f1af4e4ef 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -754,132 +754,7 @@ impl PySimBuilder { log::debug!("PySimBuilder::run() called with {shots} shots"); match &self.inner { - SimBuilderInner::Qasm(builder) => { - if builder.stack == Some(PySimStack::Neo) { - return run_qasm_neo(builder, shots); - } - let mut builder_lock = builder.engine_builder.lock().expect("lock poisoned"); - let engine_builder = builder_lock - .take() - .ok_or_else(|| PyRuntimeError::new_err("Builder already consumed"))?; - - // Apply foreign object if present - let engine_builder = if let Some(ref fo_py) = builder.foreign_object { - Python::attach(|py| -> PyResult<_> { - let fo_bound = fo_py.bind(py); - let wasm_obj: PyRef<'_, PyWasmForeignObject> = - fo_bound.cast::()?.borrow(); - // Get WASM bytes and create QasmEngineWasm - let wasm_bytes = wasm_obj.inner.wasm_bytes().to_vec(); - let qasm_wasm = QasmEngineWasm::from_bytes(wasm_bytes); - Ok(engine_builder.wasm(qasm_wasm)) - })? - } else { - engine_builder - }; - - // Create the Rust SimBuilder - let mut sim_builder = engine_builder.to_sim(); - - // Apply configuration - if let Some(seed) = builder.seed { - sim_builder = sim_builder.seed(seed); - } - if let Some(workers) = builder.workers { - sim_builder = sim_builder.workers(workers); - } - if let Some(n) = builder.explicit_num_qubits { - sim_builder = sim_builder.qubits(n); - } - - // Apply quantum engine builder if present - if let Some(ref qe_py) = builder.quantum_engine_builder { - sim_builder = Python::attach(|py| -> PyResult<_> { - if let Ok(mut state_vec) = qe_py.extract::(py) { - if let Some(inner) = state_vec.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut sparse_stab) = - qe_py.extract::(py) - { - if let Some(inner) = sparse_stab.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut stab_vec) = qe_py.extract::(py) - { - if let Some(inner) = stab_vec.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut density_mat) = - qe_py.extract::(py) - { - if let Some(inner) = density_mat.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut stab) = qe_py.extract::(py) - { - if let Some(inner) = stab.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else if let Ok(mut ct) = qe_py.extract::(py) { - if let Some(inner) = ct.inner.take() { - Ok(sim_builder.quantum(inner)) - } else { - Err(PyErr::new::( - "Quantum engine builder has already been consumed", - )) - } - } else { - Ok(sim_builder) - } - })?; - } - - // Apply noise builder if present - if let Some(ref noise_py) = builder.noise_builder { - sim_builder = Python::attach(|py| -> PyResult<_> { - if let Ok(general) = noise_py.extract::(py) { - Ok(sim_builder.noise(general.inner.clone())) - } else if let Ok(depolarizing) = - noise_py.extract::(py) - { - Ok(sim_builder.noise(depolarizing.inner.clone())) - } else if let Ok(biased) = - noise_py.extract::(py) - { - Ok(sim_builder.noise(biased.inner.clone())) - } else { - Ok(sim_builder) - } - })?; - } - - // Run directly - match sim_builder.run(shots) { - Ok(shot_vec) => Ok(PyShotVec::new(shot_vec)), - Err(e) => Err(PyRuntimeError::new_err(format!("Simulation failed: {e}"))), - } - } + SimBuilderInner::Qasm(builder) => run_qasm_via_facade(builder, shots), SimBuilderInner::QisControl(builder) => { // Implementation for QIS Engine let mut builder_lock = builder.engine_builder.lock().expect("lock poisoned"); @@ -973,7 +848,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \\ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -992,7 +870,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \\ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1143,7 +1024,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \\ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -1162,7 +1046,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \\ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1302,7 +1189,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \\ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -1322,7 +1212,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \\ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1496,7 +1389,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \\ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -1516,7 +1412,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \\ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1673,7 +1572,10 @@ impl PySimBuilder { )) } } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \\ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) } })?; } @@ -1693,7 +1595,10 @@ impl PySimBuilder { { Ok(sim_builder.noise(biased.inner.clone())) } else { - Ok(sim_builder) + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \\ + biased_depolarizing_noise(), or general_noise()", + )) } })?; } @@ -1762,43 +1667,141 @@ impl PySimBuilder { } } -/// Route a QASM program through the unified `pecos::sim()` facade onto the -/// neo stack. Noise mapping stays centralized in the facade -/// (`map_noise_to_neo`); nothing is translated here. -fn run_qasm_neo( +/// Run a QASM program through the unified `pecos::sim()` facade. +/// +/// Both stacks flow through this one entry: when no stack was selected the +/// facade default governs, so a future default flip in crates/pecos carries +/// the Python surface automatically. Noise mapping for the neo stack stays +/// centralized in the facade (`map_noise_to_neo`); nothing is translated +/// here. +fn run_qasm_via_facade( builder: &PyQasmSimBuilder, shots: usize, ) -> PyResult { - if builder.foreign_object.is_some() { + let engine_builder = builder + .engine_builder + .lock() + .expect("lock poisoned") + .take() + .ok_or_else(|| PyRuntimeError::new_err("Builder already consumed"))?; + + // Apply a foreign object (WASM) if present, as the direct path did. + let engine_builder = if let Some(ref fo_py) = builder.foreign_object { + Python::attach(|py| -> PyResult<_> { + let fo_bound = fo_py.bind(py); + let wasm_obj: PyRef<'_, PyWasmForeignObject> = + fo_bound.cast::()?.borrow(); + let wasm_bytes = wasm_obj.inner.wasm_bytes().to_vec(); + let qasm_wasm = QasmEngineWasm::from_bytes(wasm_bytes); + Ok(engine_builder.wasm(qasm_wasm)) + })? + } else { + engine_builder + }; + + if builder.stack == Some(PySimStack::Neo) && engine_builder.has_wasm() { return Err(PyRuntimeError::new_err( "WASM foreign objects are not routed to the neo stack; \ - remove .foreign_object() or use the engines stack", + remove .wasm()/.foreign_object() or use the engines stack", )); } - let program = { - let lock = builder.engine_builder.lock().expect("lock poisoned"); - let engine = lock - .as_ref() - .ok_or_else(|| PyRuntimeError::new_err("Builder already consumed"))?; - if engine.has_wasm() { - return Err(PyRuntimeError::new_err( - "WASM foreign objects are not routed to the neo stack; \ - remove .wasm() or use the engines stack", - )); + + // The Python QasmEngineBuilder can only carry a program and a WASM + // module. A plain program re-enters through the facade's auto + // selection (identical construction); a WASM-configured engine is + // kept verbatim via the classical override, where the facade never + // reads the program field. + let mut facade = match engine_builder.get_program() { + Some(program) if !engine_builder.has_wasm() => pecos::sim(program), + program => { + pecos::sim(program.unwrap_or_else(|| pecos_programs::Qasm::from_string(String::new()))) + .classical(engine_builder) } - engine - .get_program() - .ok_or_else(|| PyRuntimeError::new_err("No QASM program configured"))? }; - run_program_neo( - pecos::sim(program), - builder.seed, - builder.workers, - builder.explicit_num_qubits, - builder.quantum_engine_builder.as_ref(), - builder.noise_builder.as_ref(), - shots, - ) + + match builder.stack { + None => {} // the facade default stack governs + Some(PySimStack::Engines) => facade = facade.stack(pecos::SimStack::Engines), + Some(PySimStack::Neo) => facade = facade.stack(pecos::SimStack::Neo), + } + if let Some(seed) = builder.seed { + facade = facade.seed(seed); + } + if let Some(workers) = builder.workers { + facade = facade.workers(workers); + } + if let Some(n) = builder.explicit_num_qubits { + facade = facade.qubits(n); + } + if let Some(ref qe_py) = builder.quantum_engine_builder { + facade = apply_quantum_to_facade(facade, qe_py)?; + } + if let Some(ref noise_py) = builder.noise_builder { + facade = apply_noise_to_facade(facade, noise_py)?; + } + match facade.run(shots) { + Ok(shot_vec) => Ok(crate::shot_results_bindings::PyShotVec::new(shot_vec)), + Err(e) => Err(PyRuntimeError::new_err(format!("Simulation failed: {e}"))), + } +} + +/// Extract a Python quantum-engine builder and apply it to the facade. +fn apply_quantum_to_facade( + facade: pecos::ProgrammedSimBuilder, + qe_py: &Py, +) -> PyResult { + use crate::engine_builders::{ + PyCoinTossEngineBuilder, PyDensityMatrixEngineBuilder, PySparseStabEngineBuilder, + PyStabVecEngineBuilder, PyStabilizerEngineBuilder, PyStateVectorEngineBuilder, + }; + + let consumed = || PyRuntimeError::new_err("Quantum engine builder has already been consumed"); + Python::attach(|py| -> PyResult<_> { + if let Ok(mut state_vec) = qe_py.extract::(py) { + Ok(facade.quantum(state_vec.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut sparse_stab) = qe_py.extract::(py) { + Ok(facade.quantum(sparse_stab.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut stab_vec) = qe_py.extract::(py) { + Ok(facade.quantum(stab_vec.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut density_mat) = qe_py.extract::(py) { + Ok(facade.quantum(density_mat.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut stab) = qe_py.extract::(py) { + Ok(facade.quantum(stab.inner.take().ok_or_else(consumed)?)) + } else if let Ok(mut ct) = qe_py.extract::(py) { + Ok(facade.quantum(ct.inner.take().ok_or_else(consumed)?)) + } else { + Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )) + } + }) +} + +/// Extract a Python noise builder and apply it to the facade. +fn apply_noise_to_facade( + facade: pecos::ProgrammedSimBuilder, + noise_py: &Py, +) -> PyResult { + use crate::engine_builders::{ + PyBiasedDepolarizingNoiseModelBuilder, PyDepolarizingNoiseModelBuilder, + PyGeneralNoiseModelBuilder, + }; + + Python::attach(|py| -> PyResult<_> { + if let Ok(general) = noise_py.extract::(py) { + Ok(facade.noise(general.inner.clone())) + } else if let Ok(depolarizing) = noise_py.extract::(py) { + Ok(facade.noise(depolarizing.inner.clone())) + } else if let Ok(biased) = noise_py.extract::(py) { + Ok(facade.noise(biased.inner.clone())) + } else { + Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )) + } + }) } /// Route a HUGR program through the unified `pecos::sim()` facade onto the From 9eccee9220d699ebabd732daf5859b078e788bc0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 11:29:04 -0600 Subject: [PATCH 116/388] Add gate-local Guppy Pauli twirl schedule --- .../quantum-pecos/src/pecos/guppy/surface.py | 374 +++++++++++++++++- .../src/pecos/qec/surface/_twirl_config.py | 10 +- .../src/pecos/qec/surface/_twirl_sites.py | 76 +++- .../src/pecos/qec/surface/circuit_builder.py | 138 ++++--- .../src/pecos/qec/surface/decode.py | 63 +++ .../tests/guppy/test_surface_twirl_render.py | 36 +- .../qec/surface/test_pauli_mask_harvest.py | 140 ++++++- .../qec/surface/test_pauli_twirl_handoff.py | 94 +++++ 8 files changed, 838 insertions(+), 93 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index fcdbfe2be..7eed802d4 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -189,7 +189,7 @@ def generate_guppy_source( f"twirl + constrained ancilla budget is not supported on " f"the Guppy runtime path " f"(ancilla_budget={ancilla_budget} < total_ancilla={total_ancilla}); " - "the between_rounds twirl-site schedule assumes the " + "the runtime twirl-site schedules assume the " "unconstrained syndrome shape. Pass ancilla_budget=None or " ">= total_ancilla, or omit twirl." ) @@ -622,7 +622,7 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) # Generate memory experiment factories - lines.extend(_render_memory_experiments(dx, dz, num_data, twirl, rng, num_rounds)) + lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds)) return "\n".join(lines) @@ -639,6 +639,7 @@ def _xor_expr(terms: object) -> str: def _render_memory_experiments( + patch: "SurfacePatch", dx: int, dz: int, num_data: int, @@ -658,18 +659,33 @@ def _render_memory_experiments( if rng is None or num_rounds is None: msg = "twirled memory rendering requires both rng and num_rounds" raise ValueError(msg) - lines.extend( - _render_twirled_memory_block( - basis, - basis_upper, - dx, - dz, - num_data, - twirl, - rng, - num_rounds, - ), - ) + if twirl.site_schedule == "before_two_qubit_gate": + lines.extend( + _render_gate_local_twirled_memory_block( + patch, + basis, + basis_upper, + dx, + dz, + num_data, + twirl, + rng, + num_rounds, + ), + ) + else: + lines.extend( + _render_twirled_memory_block( + basis, + basis_upper, + dx, + dz, + num_data, + twirl, + rng, + num_rounds, + ), + ) return lines @@ -828,6 +844,336 @@ def _render_twirled_memory_block( ] +def _frame_vars(prefix: str, idx: int) -> tuple[str, str]: + return f"frame_x_{prefix}{idx}", f"frame_z_{prefix}{idx}" + + +def _append_frame_swap(lines: list[str], indent: str, x_var: str, z_var: str, tmp_var: str) -> None: + lines.append(f"{indent}{tmp_var} = {x_var}") + lines.append(f"{indent}{x_var} = {z_var}") + lines.append(f"{indent}{z_var} = {tmp_var}") + + +def _append_gate_local_draw( + lines: list[str], + indent: str, + *, + site_idx: int, + operand_idx: int, + qubit_expr: str, + frame_vars: tuple[str, str] | None, +) -> tuple[str, str]: + m_var = f"m_g{site_idx}_o{operand_idx}" + lo_var = f"lo_g{site_idx}_o{operand_idx}" + hi_var = f"hi_g{site_idx}_o{operand_idx}" + lines.append(f"{indent}rng_state, {m_var} = _pcg32_next4(rng_state, rng_inc)") + lines.append(f"{indent}if {m_var} == 1:") + lines.append(f"{indent} x({qubit_expr})") + lines.append(f"{indent}if {m_var} == 2:") + lines.append(f"{indent} y({qubit_expr})") + lines.append(f"{indent}if {m_var} == 3:") + lines.append(f"{indent} z({qubit_expr})") + lines.append(f"{indent}{lo_var} = ({m_var} == 1) | ({m_var} == 3)") + lines.append(f"{indent}{hi_var} = ({m_var} == 2) | ({m_var} == 3)") + if frame_vars is not None: + x_frame, z_frame = frame_vars + twx_var = f"twx_g{site_idx}_o{operand_idx}" + twz_var = f"twz_g{site_idx}_o{operand_idx}" + lines.append(f"{indent}{twx_var} = ({m_var} == 1) | ({m_var} == 2)") + lines.append(f"{indent}{twz_var} = ({m_var} == 2) | ({m_var} == 3)") + lines.append(f"{indent}{x_frame} = {x_frame} != {twx_var}") + lines.append(f"{indent}{z_frame} = {z_frame} != {twz_var}") + return lo_var, hi_var + + +def _append_gate_local_layer( + lines: list[str], + indent: str, + *, + site_idx: int, + cx_ops: list[tuple[str, str, tuple[str, str] | None, tuple[str, str] | None]], +) -> int: + """Emit all twirl draws before a parallel CX layer, then the CX layer.""" + from pecos.qec.surface._twirl_sites import pauli_mask_gate_tag + + for control_expr, target_expr, control_frame, target_frame in cx_ops: + lo0, hi0 = _append_gate_local_draw( + lines, + indent, + site_idx=site_idx, + operand_idx=0, + qubit_expr=control_expr, + frame_vars=control_frame, + ) + lo1, hi1 = _append_gate_local_draw( + lines, + indent, + site_idx=site_idx, + operand_idx=1, + qubit_expr=target_expr, + frame_vars=target_frame, + ) + tag = pauli_mask_gate_tag(site_idx) + lines.append(f'{indent}result("{tag}", array({lo0}, {hi0}, {lo1}, {hi1}))') + site_idx += 1 + + for control_expr, target_expr, control_frame, target_frame in cx_ops: + lines.append(f"{indent}cx({control_expr}, {target_expr})") + if control_frame is not None and target_frame is not None: + control_x, control_z = control_frame + target_x, target_z = target_frame + lines.append(f"{indent}{target_x} = {target_x} != {control_x}") + lines.append(f"{indent}{control_z} = {control_z} != {target_z}") + return site_idx + + +def _append_gate_local_measure( + lines: list[str], + indent: str, + *, + bit_var: str, + qubit_expr: str, + result_tag: str, + raw_tag: str, + frame_vars: tuple[str, str] | None, +) -> None: + if frame_vars is None: + lines.append(f"{indent}{bit_var} = measure({qubit_expr})") + else: + raw_var = f"{bit_var}_raw" + frame_x, _frame_z = frame_vars + lines.append(f"{indent}{raw_var} = measure({qubit_expr})") + lines.append(f"{indent}{bit_var} = {raw_var} != {frame_x}") + lines.append(f'{indent}result("{raw_tag}", {raw_var})') + lines.append(f'{indent}result("{result_tag}", {bit_var})') + + +def _render_gate_local_twirled_memory_block( + patch: "SurfacePatch", + basis: str, + basis_upper: str, + dx: int, + dz: int, + num_data: int, + twirl: "TwirlConfig", + rng: "GuppyRngMaskConfig", + num_rounds: int, +) -> list[str]: + """Render a gate-local twirled memory factory.""" + seed = int(rng.seed) + canonical_frame_output = twirl.frame_output == "canonical" + geom = patch.geometry + rounds = compute_cnot_schedule(patch) + init_stab_type = "X" if basis == "z" else "Z" + init_tag = "init_synx" if basis == "z" else "init_synz" + indent = " " + site_idx = 0 + + data_frames = [_frame_vars("d", q) for q in range(num_data)] + x_anc_frames = [_frame_vars("ax", stab.index) for stab in geom.x_stabilizers] + z_anc_frames = [_frame_vars("az", stab.index) for stab in geom.z_stabilizers] + + def data_expr(q: int) -> str: + return f"surf.data[{q}]" + + def anc_expr(stab_type: str, stab_idx: int) -> str: + return f"ax{stab_idx}" if stab_type == "X" else f"az{stab_idx}" + + def anc_frame(stab_type: str, stab_idx: int) -> tuple[str, str] | None: + if not canonical_frame_output: + return None + return x_anc_frames[stab_idx] if stab_type == "X" else z_anc_frames[stab_idx] + + def data_frame(q: int) -> tuple[str, str] | None: + return data_frames[q] if canonical_frame_output else None + + def cx_tuple( + stab_type: str, + stab_idx: int, + data_q: int, + ) -> tuple[str, str, tuple[str, str] | None, tuple[str, str] | None]: + if stab_type == "X": + return ( + anc_expr(stab_type, stab_idx), + data_expr(data_q), + anc_frame(stab_type, stab_idx), + data_frame(data_q), + ) + return ( + data_expr(data_q), + anc_expr(stab_type, stab_idx), + data_frame(data_q), + anc_frame(stab_type, stab_idx), + ) + + body: list[str] = [ + f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}, ' + f'num_rounds={num_rounds} (gate-local twirled)."""', + f" surf = prep_{basis}_basis()", + " # RNG seed is structural -- changing it does not invalidate the", + " # abstract DEM / topology cache, only the per-shot mask buffer.", + f" rng_state, rng_inc = seeded_pcg32_with_quantum_entropy({seed})", + f' result("frame_mode:{twirl.frame_output}", True)', + ] + if canonical_frame_output: + for q in range(num_data): + frame_x, frame_z = data_frames[q] + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + body.append("") + + # Initial syndrome establishment for the complementary stabilizer family. + init_stabs = list(geom.x_stabilizers if init_stab_type == "X" else geom.z_stabilizers) + for stab in init_stabs: + body.append(f"{indent}{anc_expr(init_stab_type, stab.index)} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame(init_stab_type, stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + if init_stab_type == "X": + body.append("") + for stab in init_stabs: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_init_ax{stab.index}") + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == init_stab_type] + if not filtered: + continue + body.append("") + body.append(f"{indent}# Init CX round {rnd_idx + 1}") + cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in filtered] + site_idx = _append_gate_local_layer(body, indent, site_idx=site_idx, cx_ops=cx_ops) + + if init_stab_type == "X": + body.append("") + for stab in init_stabs: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_init2_ax{stab.index}") + + body.append("") + init_bits: list[str] = [] + for idx, stab in enumerate(init_stabs): + bit_var = f"s{init_stab_type.lower()}{stab.index}_init" + init_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=anc_expr(init_stab_type, stab.index), + result_tag=f"s{init_stab_type.lower()}{stab.index}:init:meas:{idx}", + raw_tag=f"raw:s{init_stab_type.lower()}{stab.index}:init:bit:{idx}", + frame_vars=anc_frame(init_stab_type, stab.index), + ) + body.append(f'{indent}result("{init_tag}", array({", ".join(init_bits)}))') + body.append("") + + for round_idx in range(num_rounds): + body.append(f"{indent}# === Round {round_idx} (gate-local twirled) ===") + for stab in geom.x_stabilizers: + body.append(f"{indent}ax{stab.index} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + for stab in geom.z_stabilizers: + body.append(f"{indent}az{stab.index} = qubit()") + if canonical_frame_output: + frame_x, frame_z = anc_frame("Z", stab.index) or ("", "") + body.append(f"{indent}{frame_x} = False") + body.append(f"{indent}{frame_z} = False") + for stab in geom.x_stabilizers: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_r{round_idx}_ax{stab.index}") + + for rnd_idx, rnd_gates in enumerate(rounds): + body.append("") + body.append(f"{indent}# Round {round_idx} CX layer {rnd_idx + 1}") + cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in rnd_gates] + site_idx = _append_gate_local_layer(body, indent, site_idx=site_idx, cx_ops=cx_ops) + + for stab in geom.x_stabilizers: + body.append(f"{indent}h(ax{stab.index})") + if canonical_frame_output: + frame_x, frame_z = anc_frame("X", stab.index) or ("", "") + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h2_r{round_idx}_ax{stab.index}") + + sx_bits: list[str] = [] + sz_bits: list[str] = [] + meas_idx = 0 + for stab in geom.x_stabilizers: + bit_var = f"sx{stab.index}_r{round_idx}" + sx_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=f"ax{stab.index}", + result_tag=f"sx{stab.index}:meas:{meas_idx}", + raw_tag=f"raw:sx{stab.index}:bit:{meas_idx}", + frame_vars=anc_frame("X", stab.index), + ) + meas_idx += 1 + for stab in geom.z_stabilizers: + bit_var = f"sz{stab.index}_r{round_idx}" + sz_bits.append(bit_var) + _append_gate_local_measure( + body, + indent, + bit_var=bit_var, + qubit_expr=f"az{stab.index}", + result_tag=f"sz{stab.index}:meas:{meas_idx}", + raw_tag=f"raw:sz{stab.index}:bit:{meas_idx}", + frame_vars=anc_frame("Z", stab.index), + ) + meas_idx += 1 + body.append(f'{indent}result("synx", array({", ".join(sx_bits)}))') + body.append(f'{indent}result("synz", array({", ".join(sz_bits)}))') + body.append("") + + if canonical_frame_output: + if basis == "x": + for q in range(num_data): + body.append(f"{indent}h(surf.data[{q}])") + frame_x, frame_z = data_frames[q] + _append_frame_swap(body, indent, frame_x, frame_z, f"tmp_h_final_d{q}") + body.append(f"{indent}final_raw = measure_array(surf.data)") + body.append(f'{indent}result("raw:final", final_raw)') + for q in range(num_data): + frame_x, _frame_z = data_frames[q] + body.append(f"{indent}final_{q} = final_raw[{q}] != {frame_x}") + body.append(f'{indent}result("final", array({", ".join(f"final_{q}" for q in range(num_data))}))') + else: + body.append(f"{indent}final = measure_{basis}_basis(surf)") + body.append(f'{indent}result("final", final)') + + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis gate-local twirled memory experiment.', + "", + f" num_rounds must equal {num_rounds} -- the body was unrolled at", + " source-generation time. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {num_rounds}:", + f' msg = f"this generated module was unrolled for num_rounds={num_rounds}, got {{num_rounds!r}}"', + " raise ValueError(msg)", + "", + " @guppy", + f" def memory_{basis}() -> None:", + *body, + "", + f" return memory_{basis}", + "", + "", + ] + + def _validate_surface_memory_distance(d: int) -> None: """Enforce the surface-memory Guppy entry-point distance contract. diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py index d35e3a1dc..3bfab8fa4 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -28,7 +28,7 @@ from typing import Literal _SUPPORTED_SCHEMES = ("pauli",) -_SUPPORTED_SITE_SCHEDULES = ("between_rounds",) +_SUPPORTED_SITE_SCHEDULES = ("between_rounds", "before_two_qubit_gate") _SUPPORTED_RESULT_ENCODINGS = ("bool_array_v1",) _SUPPORTED_FRAME_OUTPUTS = ("raw", "canonical") @@ -45,8 +45,10 @@ class TwirlConfig: `"clifford"` value is reserved for Phase 2 ({I, H} Clifford-frame randomization) and not yet implemented. site_schedule: Where twirling sites are emitted in the circuit. - `"between_rounds"` (the only supported value) emits one site - between each pair of consecutive syndrome rounds. + `"between_rounds"` emits one site between each pair of + consecutive syndrome rounds. `"before_two_qubit_gate"` emits + one site per operand immediately before every surface-memory + two-qubit gate in the supported Guppy runtime path. result_encoding: How the per-shot mask is recorded in the runtime result bundle. `"bool_array_v1"` packs the `2 * num_data` bool bits per round into one tagged array per @@ -66,7 +68,7 @@ class TwirlConfig: """ scheme: Literal["pauli"] = "pauli" - site_schedule: Literal["between_rounds"] = "between_rounds" + site_schedule: Literal["between_rounds", "before_two_qubit_gate"] = "between_rounds" result_encoding: Literal["bool_array_v1"] = "bool_array_v1" frame_output: Literal["raw", "canonical"] = "raw" diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py index 72fd05d69..e60692578 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -1,6 +1,6 @@ """Canonical Pauli-twirl site mapping. -This maps `(round, qubit) -> site_idx` for both the abstract circuit +This maps twirl-site declarations for both the abstract circuit (`circuit_builder.py`) and the Guppy runtime renderer (`pecos.guppy.surface`). Both tracks must agree byte-for-byte on the ordering of twirl-site @@ -12,10 +12,12 @@ application silently runs the mask against the wrong tracked-Pauli annotations and the decoder sees an incoherent syndrome. -The mapping is currently a simple `site_idx == round_idx` for the -`between_rounds` schedule (one site between each pair of consecutive -syndrome rounds, for a total of `num_rounds - 1` sites). The helpers -below abstract that so a future schedule only needs to update this module. +The backwards-compatible helpers below describe the `between_rounds` +schedule: `site_idx == round_idx`, one site between each pair of +consecutive syndrome rounds, and one Pauli-mask column per data qubit at +that site. The `before_two_qubit_gate` helpers describe the gate-local +schedule: one tag per two-qubit gate occurrence and two Pauli-mask +columns per tag (control operand then target operand). Encoding contract for the runtime mask (``"bool_array_v1"``): @@ -50,6 +52,13 @@ from __future__ import annotations +from typing import TYPE_CHECKING, Literal + +from pecos.qec.surface.schedule import compute_cnot_schedule + +if TYPE_CHECKING: + from pecos.qec.surface.patch import SurfacePatch + # Base prefix for per-round mask result tags. The Guppy renderer emits one # `result(f"{PAULI_MASK_TAG_PREFIX}:round:{r}", array(lo_q0, hi_q0, ...))` # call per twirl site so each tag fires exactly once per shot (avoids the @@ -69,6 +78,11 @@ def pauli_mask_round_tag(round_idx: int) -> str: return f"{PAULI_MASK_TAG_PREFIX}:round:{round_idx}" +def pauli_mask_gate_tag(site_idx: int) -> str: + """Return the canonical per-two-qubit-gate twirl-mask result tag.""" + return f"{PAULI_MASK_TAG_PREFIX}:gate:{site_idx}" + + def num_twirl_sites(num_rounds: int) -> int: """Number of twirl sites for the `between_rounds` schedule. @@ -119,3 +133,55 @@ def mask_col_for(site_idx: int, qubit_idx: int, num_data: int) -> int: flat bool stream. """ return site_idx * num_data + qubit_idx + + +def mask_col_for_gate_operand(site_idx: int, operand_idx: int) -> int: + """Canonical flat (gate site, operand) -> mask column index. + + Operand order is the physical two-qubit gate order: control first, + target second. Each gate-local twirl site therefore contributes two + integer Pauli-code columns. + """ + if operand_idx not in (0, 1): + msg = f"operand_idx must be 0 or 1, got {operand_idx}" + raise ValueError(msg) + return site_idx * 2 + operand_idx + + +def num_two_qubit_gate_twirl_sites( + patch: SurfacePatch, + *, + num_rounds: int, + basis: str, +) -> int: + """Number of two-qubit gate occurrences twirled by the gate-local schedule.""" + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + cnot_rounds = compute_cnot_schedule(patch) + init_gates = sum( + 1 + for cx_round in cnot_rounds + for stab_type, _stab_idx, _data_idx in cx_round + if stab_type == init_stabilizer_type + ) + counted_round_gates = sum(len(cx_round) for cx_round in cnot_rounds) * num_rounds + return init_gates + counted_round_gates + + +def num_pauli_sites_for_schedule( + patch: SurfacePatch, + *, + num_rounds: int, + basis: str, + site_schedule: Literal["between_rounds", "before_two_qubit_gate"] = "between_rounds", +) -> int: + """Number of integer Pauli-code columns for a schedule.""" + if site_schedule == "between_rounds": + return num_pauli_sites(num_rounds, patch.geometry.num_data) + if site_schedule == "before_two_qubit_gate": + return 2 * num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + msg = f"unsupported Pauli-twirl site_schedule={site_schedule!r}" + raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 0f7700c1d..327424015 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -183,10 +183,11 @@ def build_surface_code_circuit( provided below the total stabilizer count, ancillas are reused across stabilizer batches following the public Guppy order. twirl: When provided, emit three ``OpType.TRACKED_PAULI`` annotations - (``X``, ``Y``, ``Z``) per data qubit at each between-round twirl - site. The sites are between counted syndrome rounds only: no site - is emitted before the first counted round, after the last counted - round, or inside the prep-boundary init syndrome block. + (``X``, ``Y``, ``Z``) per Pauli-mask column. The + ``"between_rounds"`` schedule emits one column per data qubit at + each site between counted syndrome rounds. The + ``"before_two_qubit_gate"`` schedule emits one column per operand + immediately before each surface-memory two-qubit gate. Returns: Tuple of (operations list, qubit allocation info) @@ -199,6 +200,9 @@ def build_surface_code_circuit( num_z_anc = len(geom.z_stabilizers) total_ancilla = num_x_anc + num_z_anc effective_ancilla_budget = _normalize_ancilla_budget(total_ancilla, ancilla_budget) + if twirl is not None: + twirl.validate_runtime_supported() + twirl_site_schedule = None if twirl is None else twirl.site_schedule # Qubit allocation layout. Under ancilla reuse, stabilizers map onto a # shared ancilla pool and different stabilizers can intentionally share the @@ -237,7 +241,7 @@ def x_anc_q(stab_idx: int) -> int: def z_anc_q(stab_idx: int) -> int: return allocation.z_ancilla_qubits[stab_idx] - def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) -> None: + def emit_between_round_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) -> None: """Append 3 * num_data candidate tracked-Pauli annotations.""" target_ops.extend( SurfaceCircuitStep(OpType.TRACKED_PAULI, [data_q(i)], f"{kind}@s{site_idx}") @@ -245,6 +249,42 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for kind in ("X", "Y", "Z") ) + gate_twirl_site_idx = 0 + + def emit_gate_local_twirl_site( + target_ops: list[SurfaceCircuitStep], + site_idx: int, + control_q: int, + target_q: int, + ) -> None: + """Append tracked-Pauli annotations for one two-qubit-gate site.""" + for operand_idx, q in enumerate((control_q, target_q)): + target_ops.extend( + SurfaceCircuitStep( + OpType.TRACKED_PAULI, + [q], + f"{kind}@g{site_idx}o{operand_idx}", + ) + for kind in ("X", "Y", "Z") + ) + + def emit_gate_local_twirl_layer( + target_ops: list[SurfaceCircuitStep], + cx_ops: list[tuple[int, int, str]], + ) -> None: + """Append all gate-local twirl annotations before a parallel CX layer.""" + nonlocal gate_twirl_site_idx + if twirl_site_schedule != "before_two_qubit_gate": + return + for control_q, target_q, _label in cx_ops: + emit_gate_local_twirl_site( + target_ops, + gate_twirl_site_idx, + control_q, + target_q, + ) + gate_twirl_site_idx += 1 + # Get CNOT schedule cnot_rounds = compute_cnot_schedule(patch) @@ -299,25 +339,16 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: if stab_type != init_stabilizer_type: continue if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [x_anc_q(stab_idx), data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((x_anc_q(stab_idx), data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), z_anc_q(stab_idx)], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), z_anc_q(stab_idx), f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) ops.append(SurfaceCircuitStep(OpType.TICK)) if init_stabilizer_type == "X": @@ -368,26 +399,17 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: ancilla_q = batch_ancillas.get((stab_type, stab_idx)) if ancilla_q is None: continue if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [ancilla_q, data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((ancilla_q, data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), ancilla_q], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), ancilla_q, f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) ops.append(SurfaceCircuitStep(OpType.TICK)) if init_stabilizer_type == "X": @@ -428,23 +450,14 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [x_anc_q(stab_idx), data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((x_anc_q(stab_idx), data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), z_anc_q(stab_idx)], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), z_anc_q(stab_idx), f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend(SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops) ops.append(SurfaceCircuitStep(OpType.TICK)) ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) @@ -489,26 +502,20 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - for rnd_idx, cx_round in enumerate(cnot_rounds): ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + cx_ops: list[tuple[int, int, str]] = [] for stab_type, stab_idx, data_idx in cx_round: ancilla_q = batch_ancillas.get((stab_type, stab_idx)) if ancilla_q is None: continue if stab_type == "X": - ops.append( - SurfaceCircuitStep( - OpType.CX, - [ancilla_q, data_q(data_idx)], - f"X{stab_idx}", - ), - ) + cx_ops.append((ancilla_q, data_q(data_idx), f"X{stab_idx}")) else: - ops.append( - SurfaceCircuitStep( - OpType.CX, - [data_q(data_idx), ancilla_q], - f"Z{stab_idx}", - ), - ) + cx_ops.append((data_q(data_idx), ancilla_q, f"Z{stab_idx}")) + emit_gate_local_twirl_layer(ops, cx_ops) + ops.extend( + SurfaceCircuitStep(OpType.CX, [control, target], label) + for control, target, label in cx_ops + ) ops.append(SurfaceCircuitStep(OpType.TICK)) if x_stabilizers_in_batch: @@ -531,8 +538,8 @@ def emit_pauli_twirl_site(target_ops: list[SurfaceCircuitStep], site_idx: int) - ops.append(SurfaceCircuitStep(OpType.TICK)) - if twirl is not None and rnd < num_rounds - 1: - emit_pauli_twirl_site(ops, rnd) + if twirl_site_schedule == "between_rounds" and rnd < num_rounds - 1: + emit_between_round_twirl_site(ops, rnd) # ========================================================================= # measure_z_basis / measure_x_basis @@ -1306,10 +1313,11 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non "Y": PauliString.Y, "Z": PauliString.Z, }.get(kind) - if pauli_ctor is None or sep != "@" or not site_suffix.startswith("s"): + if pauli_ctor is None or sep != "@" or not site_suffix.startswith(("s", "g")): msg = ( "OpType.TRACKED_PAULI requires label of the form " - f"'{{X|Y|Z}}@s', got {op.label!r}" + f"'{{X|Y|Z}}@s' or " + f"'{{X|Y|Z}}@go', got {op.label!r}" ) raise ValueError(msg) circuit.tracked_pauli( diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e4b360901..8294ecbb7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -3941,15 +3941,75 @@ def _extract_pauli_masks_from_results( num_rounds: int, num_data: int, num_shots: int, + patch: SurfacePatch | None = None, + basis: str = "Z", + twirl: TwirlConfig | None = None, ) -> NDArray[np.uint8]: """Reconstruct per-shot Pauli-mask codes from Guppy result tags.""" from pecos.qec.surface._twirl_sites import ( mask_col_for, + mask_col_for_gate_operand, num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_mask_gate_tag, pauli_mask_round_tag, site_idx_for_round, ) + site_schedule = "between_rounds" if twirl is None else twirl.site_schedule + if site_schedule == "before_two_qubit_gate": + if patch is None: + msg = "patch is required to extract before_two_qubit_gate Pauli masks" + raise ValueError(msg) + n_twirl = num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + out = np.zeros( + ( + num_shots, + num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ), + dtype=np.uint8, + ) + for site in range(n_twirl): + tag = pauli_mask_gate_tag(site) + if tag not in results: + msg = ( + f"missing Pauli-mask result tag {tag!r} (expected {n_twirl} gate " + f"tags for num_rounds={num_rounds}, basis={basis!r}); " + "did the program run with gate-local twirl enabled?" + ) + raise ValueError(msg) + per_gate = results[tag] + if len(per_gate) != num_shots: + msg = ( + f"Pauli-mask tag {tag!r}: got {len(per_gate)} shots, " + f"expected {num_shots} shots" + ) + raise ValueError(msg) + + bits = np.asarray(per_gate, dtype=np.uint8) + if bits.ndim != 2 or bits.shape[1] != 4: + msg = ( + f"Pauli-mask tag {tag!r} array has shape {bits.shape}, expected " + f"({num_shots}, 4) = (num_shots, 2*gate_operands)" + ) + raise ValueError(msg) + lo = bits[:, 0::2] + hi = bits[:, 1::2] + packed = (lo + (hi << 1)).astype(np.uint8) + for operand in range(2): + out[:, mask_col_for_gate_operand(site, operand)] = packed[:, operand] + return out + n_twirl = max(0, num_rounds - 1) bits_per_round = 2 * num_data out = np.zeros((num_shots, num_pauli_sites(num_rounds, num_data)), dtype=np.uint8) @@ -4055,4 +4115,7 @@ def sample_pauli_masks_from_guppy( num_rounds=num_rounds, num_data=num_data, num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, ) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index d0266340a..910ea4336 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -10,7 +10,7 @@ generate_memory_experiment, ) from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig -from pecos.qec.surface._twirl_sites import pauli_mask_round_tag +from pecos.qec.surface._twirl_sites import pauli_mask_gate_tag, pauli_mask_round_tag from pecos.qec.surface.patch import SurfacePatch @@ -75,6 +75,27 @@ def test_canonical_frame_output_emits_raw_sibling_tags(patch: SurfacePatch) -> N assert 'result("final", array(final_0' in src +def test_gate_local_twirl_source_emits_gate_tags_and_ancilla_frame_tracking( + patch: SurfacePatch, +) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate", frame_output="canonical"), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=2, + ) + + assert 'result("frame_mode:canonical", True)' in src + assert f'result("{pauli_mask_gate_tag(0)}", array(' in src + assert "rng_state, m_g0_o0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "x(ax" in src + assert "x(surf.data[" in src + assert "frame_x_ax" in src + assert "frame_z_az" in src + assert "raw:sx" in src + assert 'result("pauli_mask:round:' not in src + + def test_twirl_validation_requires_rng_and_num_rounds(patch: SurfacePatch) -> None: with pytest.raises(ValueError, match="twirl and rng must be supplied together"): generate_guppy_source(patch, twirl=TwirlConfig(), num_rounds=2) @@ -117,29 +138,40 @@ def test_twirled_cache_key_includes_seed_rounds_and_frame_mode(patch: SurfacePat rng=GuppyRngMaskConfig(seed=1), num_rounds=3, ) + gate_local = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) assert raw != _guppy_module_cache_key(patch, effective_budget=10) assert raw != raw_seed2 assert raw != canonical assert raw != round3 + assert raw != gate_local assert "s1" in raw assert "s2" in raw_seed2 assert "frame-raw" in raw assert "frame-canonical" in canonical + assert "before_two_qubit_gate" in gate_local @pytest.mark.parametrize("basis", ["Z", "X"]) @pytest.mark.parametrize("frame_output", ["raw", "canonical"]) +@pytest.mark.parametrize("site_schedule", ["between_rounds", "before_two_qubit_gate"]) def test_twirled_memory_experiment_compiles( patch: SurfacePatch, basis: str, frame_output: str, + site_schedule: str, ) -> None: fn = generate_memory_experiment( patch, num_rounds=2, basis=basis, - twirl=TwirlConfig(frame_output=frame_output), + twirl=TwirlConfig(site_schedule=site_schedule, frame_output=frame_output), rng=GuppyRngMaskConfig(seed=7), ) assert fn is not None diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py index 98ebf88c6..828ecb43c 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -14,7 +14,7 @@ extract_detection_events_and_observables, sample_pauli_masks_from_guppy, ) -from pecos.qec.surface._twirl_sites import num_pauli_sites +from pecos.qec.surface._twirl_sites import num_pauli_sites, num_pauli_sites_for_schedule from pecos.qec.surface.decode import _extract_pauli_masks_from_results pytest.importorskip("guppylang") @@ -143,7 +143,7 @@ def _run_twirled_guppy_rows_masks_and_raw( except TypeError: shot_value = [values] - if name.startswith("pauli_mask:round:"): + if name.startswith("pauli_mask:"): mask_results.setdefault(name, []).append([int(v) for v in shot_value]) elif name.startswith("frame_mode:"): assert len(shot_value) == 1 @@ -159,7 +159,11 @@ def _run_twirled_guppy_rows_masks_and_raw( assert len(shot_value) == 1 bit = int(shot_value[0]) row.append(bit) - if twirl.frame_output == "canonical" and ":init:meas:" in name: + if ( + twirl.frame_output == "canonical" + and twirl.site_schedule == "between_rounds" + and ":init:meas:" in name + ): raw_row.append(bit) elif name == "final": row.extend(int(v) for v in shot_value) @@ -178,6 +182,9 @@ def _run_twirled_guppy_rows_masks_and_raw( num_rounds=num_rounds, num_data=patch.geometry.num_data, num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, ) return measurement_rows, masks, raw_rows, frame_modes @@ -301,6 +308,68 @@ def test_runtime_twirled_theta0_demask_null( assert not observables.any() +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_gate_local_twirled_theta0_demask_null( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 4 + twirl = TwirlConfig(site_schedule="before_two_qubit_gate") + measurement_rows, masks, _, _ = _run_twirled_guppy_rows_masks_and_raw( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=23456), + twirl=twirl, + ) + assert masks.any() + assert masks.shape == ( + num_shots, + num_pauli_sites_for_schedule( + patch_d3, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ) + + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=twirl, + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=twirl, + ) + assert sampler.pauli_frame_lookup is not None + assert sampler.num_pauli_sites == masks.shape[1] + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + raw_events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + events, observables = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + assert not events.any() + assert not observables.any() + + def _assert_canonical_frame_output_matches_lookup( patch: SurfacePatch, *, @@ -375,6 +444,71 @@ def _assert_canonical_frame_output_matches_lookup( assert not observables.any() +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_runtime_gate_local_canonical_frame_output_matches_lookup( + patch_d3: SurfacePatch, + basis: str, +) -> None: + num_rounds = 2 + num_shots = 4 + twirl = TwirlConfig(site_schedule="before_two_qubit_gate", frame_output="canonical") + measurement_rows, masks, raw_rows, frame_modes = _run_twirled_guppy_rows_masks_and_raw( + patch_d3, + basis=basis, + num_rounds=num_rounds, + num_shots=num_shots, + rng=GuppyRngMaskConfig(seed=34567), + twirl=twirl, + ) + + assert frame_modes == ["canonical"] * num_shots + assert masks.any() + assert raw_rows + + abstract_twirl = TwirlConfig(site_schedule="before_two_qubit_gate") + tick_circuit = build_memory_circuit( + patch=patch_d3, + rounds=num_rounds, + basis=basis, + twirl=abstract_twirl, + ) + sampler = build_native_sampler( + patch_d3, + num_rounds=num_rounds, + noise=NoiseModel(), + basis=basis, + twirl=abstract_twirl, + ) + assert sampler.pauli_frame_lookup is not None + + raw_events_per_shot, raw_obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + raw_rows, + ) + raw_events = _bool_array_from_indices(raw_events_per_shot, sampler.num_detectors) + raw_obs = _bool_array_from_indices(raw_obs_per_shot, sampler.num_observables) + assert raw_events.any() or raw_obs.any() + + demasked_events, demasked_obs = demask_pauli_frame_records( + sampler.pauli_frame_lookup, + raw_events, + raw_obs, + masks, + ) + + events_per_shot, obs_per_shot = extract_detection_events_and_observables( + tick_circuit, + measurement_rows, + ) + events = _bool_array_from_indices(events_per_shot, sampler.num_detectors) + observables = _bool_array_from_indices(obs_per_shot, sampler.num_observables) + + np.testing.assert_array_equal(events, demasked_events) + np.testing.assert_array_equal(observables, demasked_obs) + assert not events.any() + assert not observables.any() + + @pytest.mark.parametrize("basis", ["Z", "X"]) def test_runtime_canonical_frame_output_theta0_null_and_raw_equivalence( patch_d3: SurfacePatch, diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index e74c1dbda..356c93875 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -14,7 +14,11 @@ ) from pecos.qec.surface._twirl_sites import ( mask_col_for, + mask_col_for_gate_operand, num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_mask_gate_tag, pauli_mask_round_tag, ) from pecos.qec.surface.decode import ( @@ -57,6 +61,38 @@ def test_extract_pauli_masks_rejects_missing_or_misshaped_tags() -> None: ) +def test_extract_gate_local_pauli_masks_packs_operand_order() -> None: + patch = SurfacePatch.create(distance=3) + results = { + pauli_mask_gate_tag(site): [[0, 0, 0, 0]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + } + results[pauli_mask_gate_tag(0)] = [[1, 0, 0, 1]] + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=1, + num_data=patch.geometry.num_data, + num_shots=1, + patch=patch, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + + assert mask.dtype == np.uint8 + assert mask.shape == ( + 1, + num_pauli_sites_for_schedule( + patch, + num_rounds=1, + basis="Z", + site_schedule="before_two_qubit_gate", + ), + ) + assert mask[0, mask_col_for_gate_operand(0, 0)] == 1 + assert mask[0, mask_col_for_gate_operand(0, 1)] == 2 + + def test_demask_helper_cancels_known_pauli_frame_xor() -> None: patch = SurfacePatch.create(distance=3) sampler = build_native_sampler( @@ -250,6 +286,29 @@ def test_twirling_does_not_change_canonical_dem( assert twirled == untwirled +def test_gate_local_twirling_does_not_change_canonical_dem() -> None: + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) + + untwirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + ) + twirled = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + basis="Z", + decompose_errors=True, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + + assert twirled == untwirled + + def test_surface_traced_qis_rejects_twirl_with_semantic_message() -> None: patch = SurfacePatch.create(distance=3) @@ -293,6 +352,41 @@ def test_tracked_pauli_label_order_matches_mask_col_for( assert tracked[base + offset]["label"] == f"twirl_s{site}_q{q}_{kind}" +def test_gate_local_tracked_pauli_label_order_matches_gate_operand_cols() -> None: + from pecos.qec.surface.schedule import compute_cnot_schedule + + patch = SurfacePatch.create(distance=3) + num_rounds = 2 + tc = build_memory_circuit( + patch=patch, + rounds=num_rounds, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + ) + tracked = [a for a in tc.annotations() if a["kind"] == "tracked_pauli"] + expected_cols = num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis="Z", + site_schedule="before_two_qubit_gate", + ) + assert len(tracked) == 3 * expected_cols + + first_init_x_gate = next( + (stab_idx, data_idx) + for cx_round in compute_cnot_schedule(patch) + for stab_type, stab_idx, data_idx in cx_round + if stab_type == "X" + ) + stab_idx, data_idx = first_init_x_gate + first_control = patch.geometry.num_data + stab_idx + expected_first_labels = [ + *(f"twirl_g0o0_q{first_control}_{kind}" for kind in ("X", "Y", "Z")), + *(f"twirl_g0o1_q{data_idx}_{kind}" for kind in ("X", "Y", "Z")), + ] + assert [tracked[i]["label"] for i in range(6)] == expected_first_labels + + def test_raw_twirled_guppy_trace_result_provenance_ignores_sideband_tags() -> None: pytest.importorskip("guppylang") pytest.importorskip("selene_sim") From 96e4313d43ba28718caaa8422b4efa6fd8e2c523 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 12:07:43 -0600 Subject: [PATCH 117/388] Add gate-local twirl mask variation guard --- .../tests/qec/surface/test_pauli_mask_harvest.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py index 828ecb43c..26043fa3a 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -222,6 +222,20 @@ def test_runtime_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: assert unique_rows.shape[0] >= 2 +def test_runtime_gate_local_twirl_masks_vary_across_shots(patch_d3: SurfacePatch) -> None: + masks = sample_pauli_masks_from_guppy( + patch_d3, + num_rounds=2, + num_shots=6, + basis="Z", + twirl=TwirlConfig(site_schedule="before_two_qubit_gate"), + rng=GuppyRngMaskConfig(seed=12), + ) + + unique_rows = np.unique(masks, axis=0) + assert unique_rows.shape[0] >= 2 + + def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: kwargs = { "num_rounds": 3, From b8414f4a17d78109ee17cbd8cf22d5cddf11d15d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 14:04:24 -0600 Subject: [PATCH 118/388] Add scaled Guppy Pauli twirl activation Add TwirlConfig.twirl_probability for runtime twirl activation, with fixed per-site RNG consumption so same-seed runs remain pairable across activation probabilities. Scaled runs emit explicit pauli_active side-band tags while legacy f=1.0 bundles keep the existing pauli_mask schema. Normalize twirl_probability out of abstract DEM and lookup caching, include its threshold in the Guppy module cache key, and validate inactive sites cannot record non-identity Pauli codes. Note: fixed RNG consumption changes exact pre-change same-seed mask streams; same-seed reproducibility within a build is unaffected. --- .../quantum-pecos/src/pecos/guppy/surface.py | 134 +++++++++--- .../src/pecos/qec/surface/__init__.py | 2 + .../src/pecos/qec/surface/_twirl_config.py | 37 +++- .../src/pecos/qec/surface/_twirl_sites.py | 12 + .../src/pecos/qec/surface/decode.py | 207 +++++++++++++++++- .../tests/guppy/test_surface_twirl_render.py | 53 ++++- .../qec/surface/test_pauli_mask_harvest.py | 193 +++++++++++++++- .../qec/surface/test_pauli_twirl_handoff.py | 103 ++++++++- 8 files changed, 689 insertions(+), 52 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 7eed802d4..6f54c22e0 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -68,13 +68,20 @@ def _render_inline_pcg32() -> list[str]: "", "@guppy", "@no_type_check", - "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:", + "def _pcg32_next32(state: nat, inc: nat) -> tuple[nat, nat]:", " old_state = state", " new_state = _pcg32_advance(state, inc)", " xorshifted = _pcg32_mask32(((old_state >> nat(18)) ^ old_state) >> nat(27))", " rot = _pcg32_mask32(old_state >> nat(59))", " rot_inv = _pcg32_mask32((~rot + nat(1)) & nat(31))", " output = _pcg32_mask32((xorshifted >> rot) | (xorshifted << rot_inv))", + " return new_state, output", + "", + "", + "@guppy", + "@no_type_check", + "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:", + " new_state, output = _pcg32_next32(state, inc)", " return new_state, int(output & nat(3))", "", "", @@ -724,6 +731,45 @@ def _render_plain_memory_block( ] +def _twirl_activation_threshold(twirl: "TwirlConfig") -> int: + """Return the full-width PCG threshold for a twirl activation probability.""" + probability = float(twirl.twirl_probability) + # Validation lives on TwirlConfig; clamp the mathematically exact endpoint + # after float multiplication so f=1.0 always activates every 32-bit draw. + threshold = int(probability * (1 << 32)) + return min(max(threshold, 0), 1 << 32) + + +def _emit_scaled_activation_tags(twirl: "TwirlConfig") -> bool: + """Whether generated source needs explicit activation side-band tags.""" + return float(twirl.twirl_probability) != 1.0 + + +def _append_pauli_draw( + lines: list[str], + indent: str, + *, + active_var: str, + draw_var: str, + m_var: str, + qubit_expr: str, + threshold: int, +) -> None: + """Emit fixed-consumption activation + Pauli draw code for one twirl operand.""" + lines.append(f"{indent}rng_state, active_draw_{m_var} = _pcg32_next32(rng_state, rng_inc)") + lines.append(f"{indent}{active_var} = active_draw_{m_var} < nat({threshold})") + lines.append(f"{indent}rng_state, {draw_var} = _pcg32_next4(rng_state, rng_inc)") + lines.append(f"{indent}{m_var} = 0") + lines.append(f"{indent}if {active_var}:") + lines.append(f"{indent} {m_var} = {draw_var}") + lines.append(f"{indent}if {m_var} == 1:") + lines.append(f"{indent} x({qubit_expr})") + lines.append(f"{indent}if {m_var} == 2:") + lines.append(f"{indent} y({qubit_expr})") + lines.append(f"{indent}if {m_var} == 3:") + lines.append(f"{indent} z({qubit_expr})") + + def _render_twirled_memory_block( basis: str, basis_upper: str, @@ -735,10 +781,12 @@ def _render_twirled_memory_block( num_rounds: int, ) -> list[str]: """Render a Python-time unrolled twirled memory factory.""" - from pecos.qec.surface._twirl_sites import num_twirl_sites, pauli_mask_round_tag + from pecos.qec.surface._twirl_sites import num_twirl_sites, pauli_active_round_tag, pauli_mask_round_tag seed = int(rng.seed) canonical_frame_output = twirl.frame_output == "canonical" + activation_threshold = _twirl_activation_threshold(twirl) + emit_activation_tags = _emit_scaled_activation_tags(twirl) init_func = "init_z_basis" if basis == "z" else "init_x_basis" init_tag = "init_synx" if basis == "z" else "init_synz" body: list[str] = [ @@ -774,13 +822,15 @@ def _render_twirled_memory_block( body.append(' result("synz", syn.synz)') body.append(" # Pauli twirl site between this round and the next.") for q in range(num_data): - body.append(f" rng_state, m_{r}_{q} = _pcg32_next4(rng_state, rng_inc)") - body.append(f" if m_{r}_{q} == 1:") - body.append(f" x(surf.data[{q}])") - body.append(f" if m_{r}_{q} == 2:") - body.append(f" y(surf.data[{q}])") - body.append(f" if m_{r}_{q} == 3:") - body.append(f" z(surf.data[{q}])") + _append_pauli_draw( + body, + " ", + active_var=f"active_{r}_{q}", + draw_var=f"m_draw_{r}_{q}", + m_var=f"m_{r}_{q}", + qubit_expr=f"surf.data[{q}]", + threshold=activation_threshold, + ) body.append(f" lo_{r}_{q} = (m_{r}_{q} == 1) | (m_{r}_{q} == 3)") body.append(f" hi_{r}_{q} = (m_{r}_{q} == 2) | (m_{r}_{q} == 3)") if canonical_frame_output: @@ -795,6 +845,10 @@ def _render_twirled_memory_block( elements = ", ".join(f"lo_{r}_{q}, hi_{r}_{q}" for q in range(num_data)) tag = pauli_mask_round_tag(r) body.append(f' result("{tag}", array({elements}))') + if emit_activation_tags: + active_elements = ", ".join(f"active_{r}_{q}" for q in range(num_data)) + active_tag = pauli_active_round_tag(r) + body.append(f' result("{active_tag}", array({active_elements}))') body.append("") if num_rounds > 0: @@ -862,17 +916,22 @@ def _append_gate_local_draw( operand_idx: int, qubit_expr: str, frame_vars: tuple[str, str] | None, -) -> tuple[str, str]: + threshold: int, +) -> tuple[str, str, str]: m_var = f"m_g{site_idx}_o{operand_idx}" + active_var = f"active_g{site_idx}_o{operand_idx}" + draw_var = f"m_draw_g{site_idx}_o{operand_idx}" lo_var = f"lo_g{site_idx}_o{operand_idx}" hi_var = f"hi_g{site_idx}_o{operand_idx}" - lines.append(f"{indent}rng_state, {m_var} = _pcg32_next4(rng_state, rng_inc)") - lines.append(f"{indent}if {m_var} == 1:") - lines.append(f"{indent} x({qubit_expr})") - lines.append(f"{indent}if {m_var} == 2:") - lines.append(f"{indent} y({qubit_expr})") - lines.append(f"{indent}if {m_var} == 3:") - lines.append(f"{indent} z({qubit_expr})") + _append_pauli_draw( + lines, + indent, + active_var=active_var, + draw_var=draw_var, + m_var=m_var, + qubit_expr=qubit_expr, + threshold=threshold, + ) lines.append(f"{indent}{lo_var} = ({m_var} == 1) | ({m_var} == 3)") lines.append(f"{indent}{hi_var} = ({m_var} == 2) | ({m_var} == 3)") if frame_vars is not None: @@ -883,7 +942,7 @@ def _append_gate_local_draw( lines.append(f"{indent}{twz_var} = ({m_var} == 2) | ({m_var} == 3)") lines.append(f"{indent}{x_frame} = {x_frame} != {twx_var}") lines.append(f"{indent}{z_frame} = {z_frame} != {twz_var}") - return lo_var, hi_var + return lo_var, hi_var, active_var def _append_gate_local_layer( @@ -892,29 +951,36 @@ def _append_gate_local_layer( *, site_idx: int, cx_ops: list[tuple[str, str, tuple[str, str] | None, tuple[str, str] | None]], + threshold: int, + emit_activation_tags: bool, ) -> int: """Emit all twirl draws before a parallel CX layer, then the CX layer.""" - from pecos.qec.surface._twirl_sites import pauli_mask_gate_tag + from pecos.qec.surface._twirl_sites import pauli_active_gate_tag, pauli_mask_gate_tag for control_expr, target_expr, control_frame, target_frame in cx_ops: - lo0, hi0 = _append_gate_local_draw( + lo0, hi0, active0 = _append_gate_local_draw( lines, indent, site_idx=site_idx, operand_idx=0, qubit_expr=control_expr, frame_vars=control_frame, + threshold=threshold, ) - lo1, hi1 = _append_gate_local_draw( + lo1, hi1, active1 = _append_gate_local_draw( lines, indent, site_idx=site_idx, operand_idx=1, qubit_expr=target_expr, frame_vars=target_frame, + threshold=threshold, ) tag = pauli_mask_gate_tag(site_idx) lines.append(f'{indent}result("{tag}", array({lo0}, {hi0}, {lo1}, {hi1}))') + if emit_activation_tags: + active_tag = pauli_active_gate_tag(site_idx) + lines.append(f'{indent}result("{active_tag}", array({active0}, {active1}))') site_idx += 1 for control_expr, target_expr, control_frame, target_frame in cx_ops: @@ -962,6 +1028,8 @@ def _render_gate_local_twirled_memory_block( """Render a gate-local twirled memory factory.""" seed = int(rng.seed) canonical_frame_output = twirl.frame_output == "canonical" + activation_threshold = _twirl_activation_threshold(twirl) + emit_activation_tags = _emit_scaled_activation_tags(twirl) geom = patch.geometry rounds = compute_cnot_schedule(patch) init_stab_type = "X" if basis == "z" else "Z" @@ -1045,7 +1113,14 @@ def cx_tuple( body.append("") body.append(f"{indent}# Init CX round {rnd_idx + 1}") cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in filtered] - site_idx = _append_gate_local_layer(body, indent, site_idx=site_idx, cx_ops=cx_ops) + site_idx = _append_gate_local_layer( + body, + indent, + site_idx=site_idx, + cx_ops=cx_ops, + threshold=activation_threshold, + emit_activation_tags=emit_activation_tags, + ) if init_stab_type == "X": body.append("") @@ -1096,7 +1171,14 @@ def cx_tuple( body.append("") body.append(f"{indent}# Round {round_idx} CX layer {rnd_idx + 1}") cx_ops = [cx_tuple(stab_type, stab_idx, data_q) for stab_type, stab_idx, data_q in rnd_gates] - site_idx = _append_gate_local_layer(body, indent, site_idx=site_idx, cx_ops=cx_ops) + site_idx = _append_gate_local_layer( + body, + indent, + site_idx=site_idx, + cx_ops=cx_ops, + threshold=activation_threshold, + emit_activation_tags=emit_activation_tags, + ) for stab in geom.x_stabilizers: body.append(f"{indent}h(ax{stab.index})") @@ -1205,7 +1287,7 @@ def _guppy_module_cache_key( Twirled source is Python-time unrolled, so the cache key includes ``num_rounds`` in addition to the structural twirl fields, runtime - frame-output mode, and RNG seed. + frame-output mode, RNG seed, and activation-probability threshold. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -1218,6 +1300,7 @@ def _guppy_module_cache_key( twirl_part = ( f"t-{twirl.scheme}-{twirl.site_schedule}-{twirl.result_encoding}" f"-frame-{twirl.frame_output}" + f"-p{_twirl_activation_threshold(twirl)}" f"-s{int(rng.seed)}-r{int(num_rounds)}" ) return f"{base}_{twirl_part}" @@ -1238,7 +1321,8 @@ def _load_guppy_module( ``normalize_ancilla_budget``), so ``ancilla_budget=None`` and ``ancilla_budget >= total_ancilla`` resolve to the same cache entry while distinct patch geometries never collide. Twirled source also - keys on twirl fields, frame-output mode, RNG seed, and round count. + keys on twirl fields, frame-output mode, activation-probability threshold, + RNG seed, and round count. Args: patch: SurfacePatch with geometry diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index 27130f05c..925d93684 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -65,6 +65,7 @@ generate_repetition_code_dem, generate_surface_code_dem, run_noisy_memory_experiment, + sample_pauli_activations_from_guppy, sample_pauli_masks_from_guppy, surface_code_memory, syndromes_to_detection_events, @@ -163,6 +164,7 @@ "generate_repetition_code_dem", "generate_surface_code_dem", "run_noisy_memory_experiment", + "sample_pauli_activations_from_guppy", "sample_pauli_masks_from_guppy", "surface_code_memory", "syndromes_to_detection_events", diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py index 3bfab8fa4..c24fdf78e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -3,17 +3,27 @@ `TwirlConfig` carries the twirl-site declaration: scheme, where in the circuit twirling sites are emitted, how the per-shot mask is encoded into the runtime result bundle after the corresponding physical Pauli gates -are applied, and how generated Guppy measurement records are framed. The -first three fields are structural for abstract DEM / topology caches. -`frame_output` is runtime-only: raw and canonical Guppy records share the -same abstract DEM and `PauliFrameLookup`. +are applied, how generated Guppy measurement records are framed, and the +runtime activation probability. `scheme`, `site_schedule`, and +`result_encoding` are structural for abstract DEM / topology caches. +`frame_output` and `twirl_probability` are runtime-only: raw/canonical and +scaled/unscaled Guppy records share the same abstract DEM and +`PauliFrameLookup`. + +Scaled twirl uses fixed RNG consumption: generated Guppy draws both an +activation decision and a Pauli code at every site, even when inactive. This +intentionally changed the exact seed-to-mask stream from the pre-scaled +implementation, but preserves same-seed reproducibility within one build and +enables common-random-number comparisons across different activation +probabilities. `GuppyRngMaskConfig` carries the **runtime** mask source: a stream-separator seed mixed with 32 bits of per-shot quantum entropy when the mask is drawn, applied to data qubits, and recorded via `result()`. Two abstract circuits -identical except for `seed` or `frame_output` reuse the same DEM but produce -different shot-level runtime records, so those values belong in the Guppy- -module / compiled-shot cache layer but NOT in the abstract DEM cache. +identical except for `seed`, `frame_output`, or `twirl_probability` reuse the +same DEM but produce different shot-level runtime records, so those values +belong in the Guppy-module / compiled-shot cache layer but NOT in the abstract +DEM cache. The split mirrors the two-tracks-per-twirl-setting architecture from the design doc: the abstract circuit (consumer: DEM builder, @@ -65,12 +75,18 @@ class TwirlConfig: frame. This does not change the abstract circuit or DEM topology; it only changes generated runtime records and must therefore be part of the Guppy module cache key. + twirl_probability: Per-site activation probability. Runtime Guppy + source draws an activation bit and a Pauli code at every twirl + site. If inactive, the recorded Pauli code is identity. This + changes runtime records and generated source, but not the + abstract DEM / `PauliFrameLookup` structure. """ scheme: Literal["pauli"] = "pauli" site_schedule: Literal["between_rounds", "before_two_qubit_gate"] = "between_rounds" result_encoding: Literal["bool_array_v1"] = "bool_array_v1" frame_output: Literal["raw", "canonical"] = "raw" + twirl_probability: float = 1.0 def validate_runtime_supported(self) -> None: """Raise ``ValueError`` if any field is outside the supported runtime set. @@ -106,6 +122,13 @@ def validate_runtime_supported(self) -> None: f"supported; expected one of {_SUPPORTED_FRAME_OUTPUTS!r}" ) raise ValueError(msg) + probability = float(self.twirl_probability) + if not 0.0 <= probability <= 1.0: + msg = ( + f"TwirlConfig.twirl_probability={self.twirl_probability!r} " + "is not supported; expected a finite probability in [0, 1]" + ) + raise ValueError(msg) def _validate_runtime_supported(self) -> None: """Compatibility alias for the public runtime validator.""" diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py index e60692578..02b3663e4 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_sites.py @@ -44,6 +44,7 @@ ``(site, qubit)`` order so the output columns match the abstract ``PauliFrameLookup`` byte-for-byte. - Side-band result tags emitted by twirl support (`pauli_mask:*`, + `pauli_active:*`, `frame_mode:*`, `raw:*`) are not detector-bearing measurement tags. They must never contain ``":meas:"`` and must never be exactly ``"final"``; handoff result-provenance code treats only the surface @@ -64,6 +65,7 @@ # call per twirl site so each tag fires exactly once per shot (avoids the # same-tag-multi-call overwrite trap in `ShotVec.to_dict()`). PAULI_MASK_TAG_PREFIX = "pauli_mask" +PAULI_ACTIVE_TAG_PREFIX = "pauli_active" # Backwards-compatible alias for callers that constructed the bare tag. PAULI_MASK_TAG = PAULI_MASK_TAG_PREFIX @@ -83,6 +85,16 @@ def pauli_mask_gate_tag(site_idx: int) -> str: return f"{PAULI_MASK_TAG_PREFIX}:gate:{site_idx}" +def pauli_active_round_tag(round_idx: int) -> str: + """Return the canonical per-round twirl-activation result tag.""" + return f"{PAULI_ACTIVE_TAG_PREFIX}:round:{round_idx}" + + +def pauli_active_gate_tag(site_idx: int) -> str: + """Return the canonical per-two-qubit-gate activation result tag.""" + return f"{PAULI_ACTIVE_TAG_PREFIX}:gate:{site_idx}" + + def num_twirl_sites(num_rounds: int) -> int: """Number of twirl sites for the `between_rounds` schedule. diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8294ecbb7..06f418bff 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -300,7 +300,7 @@ def _abstract_twirl_config(twirl: TwirlConfig | None) -> TwirlConfig | None: if twirl is None: return None twirl.validate_runtime_supported() - return replace(twirl, frame_output="raw") + return replace(twirl, frame_output="raw", twirl_probability=1.0) def _twirl_traced_qis_rejection_message() -> str: @@ -733,7 +733,7 @@ def _index_surface_result_trace_ids( def _is_surface_sideband_result_tag(name: str) -> bool: """Return true for non-detector-bearing surface result tags.""" - return name.startswith(("pauli_mask:", "frame_mode:", "raw:")) + return name.startswith(("pauli_mask:", "pauli_active:", "frame_mode:", "raw:")) def _surface_abstract_measurement_result_refs(abstract_tc: Any) -> list[tuple[str, str] | tuple[str, str, int]]: @@ -4008,6 +4008,18 @@ def _extract_pauli_masks_from_results( packed = (lo + (hi << 1)).astype(np.uint8) for operand in range(2): out[:, mask_col_for_gate_operand(site, operand)] = packed[:, operand] + active = _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + if np.any((~active) & (out != 0)): + msg = "malformed Pauli twirl bundle: inactive gate-local site recorded a non-identity Pauli" + raise ValueError(msg) return out n_twirl = max(0, num_rounds - 1) @@ -4046,10 +4058,131 @@ def _extract_pauli_masks_from_results( for q in range(num_data): out[:, mask_col_for(site, q, num_data)] = packed[:, q] + active = _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + if np.any((~active) & (out != 0)): + msg = "malformed Pauli twirl bundle: inactive round site recorded a non-identity Pauli" + raise ValueError(msg) return out -def sample_pauli_masks_from_guppy( +def _extract_pauli_activations_from_results( + results: dict[str, Any], + *, + num_rounds: int, + num_data: int, + num_shots: int, + patch: SurfacePatch | None = None, + basis: str = "Z", + twirl: TwirlConfig | None = None, +) -> NDArray[np.bool_]: + """Reconstruct per-shot twirl activation bits from Guppy result tags. + + Legacy `twirl_probability=1.0` bundles have no activation tags; they are + interpreted as active at every site. Scaled-twirl bundles must carry explicit + activation tags so skipped sites and active identity draws remain auditable. + """ + from pecos.qec.surface._twirl_sites import ( + mask_col_for, + mask_col_for_gate_operand, + num_pauli_sites, + num_pauli_sites_for_schedule, + num_two_qubit_gate_twirl_sites, + pauli_active_gate_tag, + pauli_active_round_tag, + site_idx_for_round, + ) + + site_schedule = "between_rounds" if twirl is None else twirl.site_schedule + probability = 1.0 if twirl is None else float(twirl.twirl_probability) + has_active_tags = any(str(name).startswith("pauli_active:") for name in results) + require_active_tags = has_active_tags or probability != 1.0 + + if site_schedule == "before_two_qubit_gate": + if patch is None: + msg = "patch is required to extract before_two_qubit_gate Pauli activations" + raise ValueError(msg) + n_twirl = num_two_qubit_gate_twirl_sites( + patch, + num_rounds=num_rounds, + basis=basis, + ) + out = np.ones( + ( + num_shots, + num_pauli_sites_for_schedule( + patch, + num_rounds=num_rounds, + basis=basis, + site_schedule="before_two_qubit_gate", + ), + ), + dtype=bool, + ) + if not require_active_tags: + return out + out[...] = False + for site in range(n_twirl): + tag = pauli_active_gate_tag(site) + if tag not in results: + msg = f"missing Pauli-activation result tag {tag!r}" + raise ValueError(msg) + per_gate = results[tag] + if len(per_gate) != num_shots: + msg = ( + f"Pauli-activation tag {tag!r}: got {len(per_gate)} shots, " + f"expected {num_shots} shots" + ) + raise ValueError(msg) + bits = np.asarray(per_gate, dtype=bool) + if bits.ndim != 2 or bits.shape[1] != 2: + msg = ( + f"Pauli-activation tag {tag!r} array has shape {bits.shape}, " + f"expected ({num_shots}, 2) = (num_shots, gate_operands)" + ) + raise ValueError(msg) + for operand in range(2): + out[:, mask_col_for_gate_operand(site, operand)] = bits[:, operand] + return out + + n_twirl = max(0, num_rounds - 1) + out = np.ones((num_shots, num_pauli_sites(num_rounds, num_data)), dtype=bool) + if not require_active_tags: + return out + out[...] = False + for r in range(n_twirl): + tag = pauli_active_round_tag(r) + if tag not in results: + msg = f"missing Pauli-activation result tag {tag!r}" + raise ValueError(msg) + per_round = results[tag] + if len(per_round) != num_shots: + msg = ( + f"Pauli-activation tag {tag!r}: got {len(per_round)} shots, " + f"expected {num_shots} shots" + ) + raise ValueError(msg) + bits = np.asarray(per_round, dtype=bool) + if bits.ndim != 2 or bits.shape[1] != num_data: + msg = ( + f"Pauli-activation tag {tag!r} array has shape {bits.shape}, " + f"expected ({num_shots}, {num_data}) = (num_shots, num_data)" + ) + raise ValueError(msg) + site = site_idx_for_round(r) + for q in range(num_data): + out[:, mask_col_for(site, q, num_data)] = bits[:, q] + return out + + +def _sample_pauli_sideband_results_from_guppy( patch: SurfacePatch, *, num_rounds: int, @@ -4058,8 +4191,8 @@ def sample_pauli_masks_from_guppy( twirl: TwirlConfig, rng: Any, ancilla_budget: int | None = None, -) -> NDArray[np.uint8]: - """Run the Guppy memory program with twirling and harvest mask columns.""" +) -> dict[str, list[list[Any]]]: + """Run the Guppy memory program with twirling and harvest side-band tags.""" from selene_sim import SimpleRuntime, Stim, build from pecos.compilation_pipeline import compile_guppy_to_hugr @@ -4073,8 +4206,6 @@ def sample_pauli_masks_from_guppy( msg = f"num_rounds must be >= 1, got {num_rounds}" raise ValueError(msg) - num_data = patch.geometry.num_data - fn = generate_memory_experiment( patch, num_rounds=num_rounds, @@ -4108,7 +4239,33 @@ def sample_pauli_masks_from_guppy( shot_value = list(values) except TypeError: shot_value = [values] - results.setdefault(name, []).append(shot_value) + if name.startswith(("pauli_mask:", "pauli_active:")): + results.setdefault(name, []).append(shot_value) + return results + + +def sample_pauli_masks_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> NDArray[np.uint8]: + """Run the Guppy memory program with twirling and harvest mask columns.""" + twirl.validate_runtime_supported() + num_data = patch.geometry.num_data + results = _sample_pauli_sideband_results_from_guppy( + patch, + num_rounds=num_rounds, + num_shots=num_shots, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) return _extract_pauli_masks_from_results( results, @@ -4119,3 +4276,37 @@ def sample_pauli_masks_from_guppy( basis=basis, twirl=twirl, ) + + +def sample_pauli_activations_from_guppy( + patch: SurfacePatch, + *, + num_rounds: int, + num_shots: int, + basis: str, + twirl: TwirlConfig, + rng: Any, + ancilla_budget: int | None = None, +) -> NDArray[np.bool_]: + """Run the Guppy memory program with twirling and harvest activation bits.""" + twirl.validate_runtime_supported() + num_data = patch.geometry.num_data + results = _sample_pauli_sideband_results_from_guppy( + patch, + num_rounds=num_rounds, + num_shots=num_shots, + basis=basis, + twirl=twirl, + rng=rng, + ancilla_budget=ancilla_budget, + ) + + return _extract_pauli_activations_from_results( + results, + num_rounds=num_rounds, + num_data=num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 910ea4336..4799ef520 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -10,7 +10,12 @@ generate_memory_experiment, ) from pecos.qec.surface import GuppyRngMaskConfig, TwirlConfig -from pecos.qec.surface._twirl_sites import pauli_mask_gate_tag, pauli_mask_round_tag +from pecos.qec.surface._twirl_sites import ( + pauli_active_gate_tag, + pauli_active_round_tag, + pauli_mask_gate_tag, + pauli_mask_round_tag, +) from pecos.qec.surface.patch import SurfacePatch @@ -38,13 +43,18 @@ def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) num_rounds=3, ) + assert "def _pcg32_next32(state: nat, inc: nat) -> tuple[nat, nat]:" in src assert "def _pcg32_next4(state: nat, inc: nat) -> tuple[nat, int]:" in src assert "def seeded_pcg32_with_quantum_entropy(seed: int) -> tuple[nat, nat]:" in src assert "entropy_q = qubit()" in src assert "if measure(entropy_q):" in src assert src.count("rng_state, rng_inc = seeded_pcg32_with_quantum_entropy(42)") == 2 assert src.count('result("frame_mode:raw", True)') == 2 - assert "rng_state, m_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "rng_state, active_draw_m_0_0 = _pcg32_next32(rng_state, rng_inc)" in src + assert "active_0_0 = active_draw_m_0_0 < nat(4294967296)" in src + assert "rng_state, m_draw_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "if active_0_0:" in src + assert " m_0_0 = m_draw_0_0" in src assert "if m_0_0 == 1:" in src assert " x(surf.data[0])" in src assert "if m_0_0 == 2:" in src @@ -54,9 +64,23 @@ def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) for r in range(2): assert src.count(f'result("{pauli_mask_round_tag(r)}"') == 2 + assert f'result("{pauli_active_round_tag(r)}"' not in src assert src.count("# === Round 2 (final, no twirl after) ===") == 2 +def test_scaled_twirl_source_emits_activation_tags_and_threshold(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(twirl_probability=0.5), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=2, + ) + + assert "active_0_0 = active_draw_m_0_0 < nat(2147483648)" in src + assert "rng_state, m_draw_0_0 = _pcg32_next4(rng_state, rng_inc)" in src + assert f'result("{pauli_active_round_tag(0)}", array(active_0_0' in src + + def test_canonical_frame_output_emits_raw_sibling_tags(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, @@ -87,7 +111,8 @@ def test_gate_local_twirl_source_emits_gate_tags_and_ancilla_frame_tracking( assert 'result("frame_mode:canonical", True)' in src assert f'result("{pauli_mask_gate_tag(0)}", array(' in src - assert "rng_state, m_g0_o0 = _pcg32_next4(rng_state, rng_inc)" in src + assert "rng_state, active_draw_m_g0_o0 = _pcg32_next32(rng_state, rng_inc)" in src + assert "rng_state, m_draw_g0_o0 = _pcg32_next4(rng_state, rng_inc)" in src assert "x(ax" in src assert "x(surf.data[" in src assert "frame_x_ax" in src @@ -96,6 +121,18 @@ def test_gate_local_twirl_source_emits_gate_tags_and_ancilla_frame_tracking( assert 'result("pauli_mask:round:' not in src +def test_scaled_gate_local_source_emits_activation_tags(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + twirl=TwirlConfig(site_schedule="before_two_qubit_gate", twirl_probability=0.25), + rng=GuppyRngMaskConfig(seed=7), + num_rounds=1, + ) + + assert "active_g0_o0 = active_draw_m_g0_o0 < nat(1073741824)" in src + assert f'result("{pauli_active_gate_tag(0)}", array(active_g0_o0, active_g0_o1))' in src + + def test_twirl_validation_requires_rng_and_num_rounds(patch: SurfacePatch) -> None: with pytest.raises(ValueError, match="twirl and rng must be supplied together"): generate_guppy_source(patch, twirl=TwirlConfig(), num_rounds=2) @@ -145,14 +182,24 @@ def test_twirled_cache_key_includes_seed_rounds_and_frame_mode(patch: SurfacePat rng=GuppyRngMaskConfig(seed=1), num_rounds=2, ) + scaled = _guppy_module_cache_key( + patch, + effective_budget=10, + twirl=TwirlConfig(twirl_probability=0.5), + rng=GuppyRngMaskConfig(seed=1), + num_rounds=2, + ) assert raw != _guppy_module_cache_key(patch, effective_budget=10) assert raw != raw_seed2 assert raw != canonical assert raw != round3 assert raw != gate_local + assert raw != scaled assert "s1" in raw assert "s2" in raw_seed2 + assert "p4294967296" in raw + assert "p2147483648" in scaled assert "frame-raw" in raw assert "frame-canonical" in canonical assert "before_two_qubit_gate" in gate_local diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py index 26043fa3a..eb1ac06d1 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -12,10 +12,11 @@ decode_native_samples, demask_pauli_frame_records, extract_detection_events_and_observables, + sample_pauli_activations_from_guppy, sample_pauli_masks_from_guppy, ) from pecos.qec.surface._twirl_sites import num_pauli_sites, num_pauli_sites_for_schedule -from pecos.qec.surface.decode import _extract_pauli_masks_from_results +from pecos.qec.surface.decode import _extract_pauli_activations_from_results, _extract_pauli_masks_from_results pytest.importorskip("guppylang") pytest.importorskip("selene_sim") @@ -120,7 +121,7 @@ def _run_twirled_guppy_rows_masks_and_raw( name=f"pauli_twirl_null_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", ) - mask_results: dict[str, list[list[int]]] = {} + sideband_results: dict[str, list[list[int]]] = {} measurement_rows: list[list[int]] = [] raw_rows: list[list[int]] = [] frame_modes: list[str] = [] @@ -143,8 +144,8 @@ def _run_twirled_guppy_rows_masks_and_raw( except TypeError: shot_value = [values] - if name.startswith("pauli_mask:"): - mask_results.setdefault(name, []).append([int(v) for v in shot_value]) + if name.startswith(("pauli_mask:", "pauli_active:")): + sideband_results.setdefault(name, []).append([int(v) for v in shot_value]) elif name.startswith("frame_mode:"): assert len(shot_value) == 1 assert bool(shot_value[0]) @@ -178,7 +179,7 @@ def _run_twirled_guppy_rows_masks_and_raw( frame_modes.append(frame_mode) masks = _extract_pauli_masks_from_results( - mask_results, + sideband_results, num_rounds=num_rounds, num_data=patch.geometry.num_data, num_shots=num_shots, @@ -189,6 +190,70 @@ def _run_twirled_guppy_rows_masks_and_raw( return measurement_rows, masks, raw_rows, frame_modes +def _sample_twirled_guppy_masks_and_activations( + patch: SurfacePatch, + *, + basis: str, + num_rounds: int, + num_shots: int, + rng: GuppyRngMaskConfig, + twirl: TwirlConfig, +) -> tuple[np.ndarray, np.ndarray]: + from pecos.compilation_pipeline import compile_guppy_to_hugr + from pecos.guppy.surface import generate_memory_experiment, get_num_qubits + from selene_sim import SimpleRuntime, Stim, build + + fn = generate_memory_experiment( + patch, + num_rounds=num_rounds, + basis=basis, + twirl=twirl, + rng=rng, + ) + hugr_bytes = compile_guppy_to_hugr(fn) + instance = build( + hugr_bytes, + name=f"pauli_twirl_active_d{patch.geometry.dx}_r{num_rounds}_{basis.lower()}", + ) + + sideband_results: dict[str, list[list[int]]] = {} + for shot_results in instance.run_shots( + simulator=Stim(random_seed=int(rng.seed)), + n_qubits=get_num_qubits(patch=patch, twirl=twirl), + n_shots=num_shots, + runtime=SimpleRuntime(), + n_processes=1, + ): + for name, values in shot_results: + if not name.startswith(("pauli_mask:", "pauli_active:")): + continue + try: + shot_value = list(values) + except TypeError: + shot_value = [values] + sideband_results.setdefault(name, []).append([int(v) for v in shot_value]) + + masks = _extract_pauli_masks_from_results( + sideband_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + activations = _extract_pauli_activations_from_results( + sideband_results, + num_rounds=num_rounds, + num_data=patch.geometry.num_data, + num_shots=num_shots, + patch=patch, + basis=basis, + twirl=twirl, + ) + return masks, activations + + def _run_twirled_guppy_measurement_rows_and_masks( patch: SurfacePatch, *, @@ -236,6 +301,124 @@ def test_runtime_gate_local_twirl_masks_vary_across_shots(patch_d3: SurfacePatch assert unique_rows.shape[0] >= 2 +def test_scaled_twirl_probability_zero_records_inactive_identity( + patch_d3: SurfacePatch, +) -> None: + twirl = TwirlConfig(twirl_probability=0.0) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=2, + num_shots=3, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=19), + ) + + assert masks.shape == active.shape + assert not masks.any() + assert not active.any() + + +def test_scaled_twirl_records_active_identity_distinct_from_inactive_identity( + patch_d3: SurfacePatch, +) -> None: + twirl = TwirlConfig(twirl_probability=0.5) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=2, + num_shots=16, + basis="Z", + twirl=twirl, + rng=GuppyRngMaskConfig(seed=20), + ) + + assert active.any() + assert (~active).any() + assert np.any(active & (masks == 0)) + assert np.any((~active) & (masks == 0)) + assert not np.any((~active) & (masks != 0)) + + +def test_scaled_twirl_fixed_rng_consumption_aligns_same_seed_codes( + patch_d3: SurfacePatch, +) -> None: + common = { + "num_rounds": 2, + "num_shots": 8, + "basis": "Z", + "rng": GuppyRngMaskConfig(seed=21), + } + full_masks, full_active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + twirl=TwirlConfig(twirl_probability=1.0), + **common, + ) + half_masks, half_active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + twirl=TwirlConfig(twirl_probability=0.5), + **common, + ) + + assert full_active.all() + assert half_active.any() + np.testing.assert_array_equal(half_masks[half_active], full_masks[half_active]) + + +def _assert_jeffreys_rate_near( + successes: int, + total: int, + expected: float, + *, + sigma: float = 6.0, +) -> None: + assert total > 0 + alpha = successes + 0.5 + beta = total - successes + 0.5 + mean = alpha / (alpha + beta) + variance = (alpha * beta) / ((alpha + beta) ** 2 * (alpha + beta + 1)) + assert abs(mean - expected) <= sigma * float(np.sqrt(variance)) + + +def test_scaled_twirl_empirical_activation_and_code_rates( + patch_d3: SurfacePatch, +) -> None: + num_rounds = 2 + num_shots = 64 + twirl = TwirlConfig(twirl_probability=0.5) + rng = GuppyRngMaskConfig(seed=22) + masks, active = _sample_twirled_guppy_masks_and_activations( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=rng, + ) + + public_active = sample_pauli_activations_from_guppy( + patch_d3, + num_rounds=num_rounds, + num_shots=num_shots, + basis="Z", + twirl=twirl, + rng=rng, + ) + np.testing.assert_array_equal(public_active, active) + + active_count = int(active.sum()) + total_sites = int(active.size) + _assert_jeffreys_rate_near(active_count, total_sites, 0.5) + + active_codes = masks[active] + assert active_codes.size == active_count + for code in range(4): + _assert_jeffreys_rate_near( + int(np.count_nonzero(active_codes == code)), + active_count, + 0.25, + ) + + def test_same_seed_is_reproducible(patch_d3: SurfacePatch) -> None: kwargs = { "num_rounds": 3, diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py index 356c93875..4fbecf65a 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_twirl_handoff.py @@ -18,10 +18,13 @@ num_pauli_sites, num_pauli_sites_for_schedule, num_two_qubit_gate_twirl_sites, + pauli_active_gate_tag, + pauli_active_round_tag, pauli_mask_gate_tag, pauli_mask_round_tag, ) from pecos.qec.surface.decode import ( + _extract_pauli_activations_from_results, _extract_pauli_masks_from_results, generate_circuit_level_dem_from_builder, ) @@ -48,6 +51,54 @@ def test_extract_pauli_masks_packs_bits_in_row_major_site_qubit_order() -> None: assert mask[0, mask_col_for(1, 1, 2)] == 0 +def test_extract_scaled_round_twirl_requires_and_validates_activation_tags() -> None: + scaled = TwirlConfig(twirl_probability=0.5) + results = { + pauli_mask_round_tag(0): [[1, 0, 0, 0]], + pauli_active_round_tag(0): [[True, False]], + } + + mask = _extract_pauli_masks_from_results( + results, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + active = _extract_pauli_activations_from_results( + results, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + assert mask.tolist() == [[1, 0]] + assert active.tolist() == [[True, False]] + + with pytest.raises(ValueError, match="missing Pauli-activation result tag"): + _extract_pauli_masks_from_results( + {pauli_mask_round_tag(0): [[0, 0, 0, 0]]}, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + malformed = { + pauli_mask_round_tag(0): [[1, 0, 0, 0]], + pauli_active_round_tag(0): [[False, True]], + } + with pytest.raises(ValueError, match="inactive round site recorded a non-identity"): + _extract_pauli_masks_from_results( + malformed, + num_rounds=2, + num_data=2, + num_shots=1, + twirl=scaled, + ) + + def test_extract_pauli_masks_rejects_missing_or_misshaped_tags() -> None: with pytest.raises(ValueError, match="missing Pauli-mask result tag"): _extract_pauli_masks_from_results({}, num_rounds=2, num_data=1, num_shots=1) @@ -93,6 +144,34 @@ def test_extract_gate_local_pauli_masks_packs_operand_order() -> None: assert mask[0, mask_col_for_gate_operand(0, 1)] == 2 +def test_extract_scaled_gate_local_twirl_validates_activation_tags() -> None: + patch = SurfacePatch.create(distance=3) + twirl = TwirlConfig(site_schedule="before_two_qubit_gate", twirl_probability=0.5) + results = { + pauli_mask_gate_tag(site): [[0, 0, 0, 0]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + } + results.update( + { + pauli_active_gate_tag(site): [[True, True]] + for site in range(num_two_qubit_gate_twirl_sites(patch, num_rounds=1, basis="Z")) + }, + ) + results[pauli_mask_gate_tag(0)] = [[1, 0, 0, 0]] + results[pauli_active_gate_tag(0)] = [[False, True]] + + with pytest.raises(ValueError, match="inactive gate-local site recorded a non-identity"): + _extract_pauli_masks_from_results( + results, + num_rounds=1, + num_data=patch.geometry.num_data, + num_shots=1, + patch=patch, + basis="Z", + twirl=twirl, + ) + + def test_demask_helper_cancels_known_pauli_frame_xor() -> None: patch = SurfacePatch.create(distance=3) sampler = build_native_sampler( @@ -165,12 +244,24 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: basis="Z", twirl=TwirlConfig(frame_output="canonical"), ) + scaled = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(), + basis="Z", + twirl=TwirlConfig(twirl_probability=0.5), + ) assert canonical.num_detectors == raw.num_detectors assert canonical.num_observables == raw.num_observables assert canonical.num_pauli_sites == raw.num_pauli_sites assert canonical.pauli_frame_lookup is raw.pauli_frame_lookup assert canonical.dem_string == raw.dem_string + assert scaled.num_detectors == raw.num_detectors + assert scaled.num_observables == raw.num_observables + assert scaled.num_pauli_sites == raw.num_pauli_sites + assert scaled.pauli_frame_lookup is raw.pauli_frame_lookup + assert scaled.dem_string == raw.dem_string @pytest.mark.parametrize( @@ -180,11 +271,13 @@ def test_canonical_frame_output_reuses_raw_abstract_sampler_topology() -> None: ("site_schedule", "per_two_qubit_gate"), ("result_encoding", "bogus"), ("frame_output", "physical"), + ("twirl_probability", -0.1), + ("twirl_probability", 1.1), ], ) def test_abstract_twirl_builders_reject_unsupported_config( field: str, - value: str, + value: object, ) -> None: patch = SurfacePatch.create(distance=3) kwargs = {field: value} @@ -400,25 +493,27 @@ def test_raw_twirled_guppy_trace_result_provenance_ignores_sideband_tags() -> No patch = SurfacePatch.create(distance=3) num_rounds = 2 + twirl = TwirlConfig(twirl_probability=0.5) program = generate_memory_experiment( patch, num_rounds=num_rounds, basis="Z", - twirl=TwirlConfig(), + twirl=twirl, rng=GuppyRngMaskConfig(seed=0), ) _, result_traces = trace_guppy_into_tick_circuit_with_result_traces( program, - get_num_qubits(patch=patch, twirl=TwirlConfig()), + get_num_qubits(patch=patch, twirl=twirl), seed=0, ) sideband_names = {trace.get("name") for trace in result_traces if isinstance(trace.get("name"), str)} assert any(str(name).startswith("pauli_mask:") for name in sideband_names) + assert any(str(name).startswith("pauli_active:") for name in sideband_names) assert "frame_mode:raw" in sideband_names scalar_trace_ids, array_trace_ids = _index_surface_result_trace_ids(result_traces) indexed_names = set(scalar_trace_ids) | set(array_trace_ids) assert any(name.startswith("sx") and ":meas:" in name for name in indexed_names) assert "final" in indexed_names - assert not any(name.startswith(("pauli_mask:", "frame_mode:", "raw:")) for name in indexed_names) + assert not any(name.startswith(("pauli_mask:", "pauli_active:", "frame_mode:", "raw:")) for name in indexed_names) From faf8ece8a4e95edcf1f6d96cf76cbfbe8d671495 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 16:15:51 -0600 Subject: [PATCH 119/388] Add SZZ surface interaction basis skeleton --- .../src/pecos/qec/surface/circuit_builder.py | 614 +++++++++++++++++- .../src/pecos/qec/surface/decode.py | 47 +- .../qec/surface/test_szz_interaction_basis.py | 269 ++++++++ 3 files changed, 917 insertions(+), 13 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 327424015..ac7f8345c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -115,11 +115,17 @@ class OpType(Enum): # Single-qubit gates H = auto() # Hadamard + SX = auto() # sqrt X + SXDG = auto() # sqrt X dagger + SZ = auto() # sqrt Z / phase + SZDG = auto() # sqrt Z dagger X = auto() # Pauli X Z = auto() # Pauli Z # Two-qubit gates CX = auto() # CNOT + SZZ = auto() # sqrt ZZ + SZZDG = auto() # sqrt ZZ dagger # Measurement MEASURE = auto() # Destructive measurement @@ -159,6 +165,216 @@ def total(self) -> int: return len(set(self.data_qubits) | set(self.x_ancilla_qubits) | set(self.z_ancilla_qubits)) +@dataclass(frozen=True, order=True) +class SzzTouchSign: + """Signed SZZ touch in the v1 surface-memory sign convention.""" + + stabilizer_type: str + stabilizer_index: int + data_qubit: int + sign: int + + +@dataclass(frozen=True, order=True) +class SzzBoundaryCompensation: + """Single-qubit Clifford compensation for an odd boundary residual.""" + + stabilizer_type: str + stabilizer_index: int + data_qubit: int + gate: str + + +@dataclass(frozen=True, order=True) +class SzzClass2Stream: + """Standing deterministic Pauli stream induced by a class-2 residual.""" + + stabilizer_type: str + data_qubit: int + pauli: str + + +@dataclass(frozen=True) +class SzzResidualPlan: + """Validated SZZ sign convention and residual bookkeeping.""" + + signs: tuple[SzzTouchSign, ...] + boundary_compensations: tuple[SzzBoundaryCompensation, ...] + class2_streams: tuple[SzzClass2Stream, ...] + + +def _normalize_interaction_basis(interaction_basis: str) -> str: + """Validate and normalize the surface two-qubit interaction basis.""" + normalized = interaction_basis.lower() + if normalized not in {"cx", "szz"}: + msg = f"interaction_basis must be 'cx' or 'szz', got {interaction_basis!r}" + raise ValueError(msg) + return normalized + + +def _szz_residual_class(sum_signs: int) -> str: + """Classify a signed residual sum modulo four.""" + residue = sum_signs % 4 + if residue == 0: + return "identity" + if residue == 2: + return "pauli" + return "odd" + + +def _iter_surface_stabilizer_touches(patch: SurfacePatch) -> list[tuple[str, int, tuple[int, ...], bool]]: + """Return stabilizer touch rows in deterministic X-then-Z order.""" + geom = patch.geometry + rows: list[tuple[str, int, tuple[int, ...], bool]] = [] + rows.extend(("X", stab.index, tuple(stab.data_qubits), bool(stab.is_boundary)) for stab in geom.x_stabilizers) + rows.extend(("Z", stab.index, tuple(stab.data_qubits), bool(stab.is_boundary)) for stab in geom.z_stabilizers) + return rows + + +def _default_szz_sign_vector(patch: SurfacePatch) -> tuple[SzzTouchSign, ...]: + """Return the v1 hard-coded SZZ sign vector. + + Bulk checks use all ``SZZ``. Boundary checks use one ``SZZdg`` on the + second data operand, giving ancilla class 0 while preserving a stable, + geometry-derived convention. + """ + signs: list[SzzTouchSign] = [] + for stabilizer_type, stabilizer_index, data_qubits, is_boundary in _iter_surface_stabilizer_touches(patch): + if is_boundary and len(data_qubits) != 2: + msg = ( + "SZZ v1 expects boundary stabilizers to have weight 2; " + f"{stabilizer_type}{stabilizer_index} has weight {len(data_qubits)}" + ) + raise ValueError(msg) + for touch_index, data_qubit in enumerate(data_qubits): + sign = -1 if is_boundary and touch_index == 1 else 1 + signs.append( + SzzTouchSign( + stabilizer_type=stabilizer_type, + stabilizer_index=stabilizer_index, + data_qubit=data_qubit, + sign=sign, + ), + ) + return tuple(sorted(signs)) + + +def _validate_szz_sign_vector( + patch: SurfacePatch, + signs: tuple[SzzTouchSign, ...], +) -> SzzResidualPlan: + """Validate an SZZ sign vector and derive fixed residual bookkeeping.""" + expected_keys = { + (stabilizer_type, stabilizer_index, data_qubit) + for stabilizer_type, stabilizer_index, data_qubits, _is_boundary in _iter_surface_stabilizer_touches(patch) + for data_qubit in data_qubits + } + seen_keys: set[tuple[str, int, int]] = set() + check_sums: dict[tuple[str, int], int] = {} + data_sums: dict[tuple[str, int], int] = {} + data_touch_counts: dict[tuple[str, int], int] = {} + + for entry in signs: + if entry.sign not in {-1, 1}: + msg = f"SZZ sign entries must be +/-1, got {entry.sign!r} for {entry}" + raise ValueError(msg) + key = (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit) + if key in seen_keys: + msg = f"duplicate SZZ sign entry for touch {key}" + raise ValueError(msg) + seen_keys.add(key) + check_key = (entry.stabilizer_type, entry.stabilizer_index) + data_key = (entry.stabilizer_type, entry.data_qubit) + check_sums[check_key] = check_sums.get(check_key, 0) + entry.sign + data_sums[data_key] = data_sums.get(data_key, 0) + entry.sign + data_touch_counts[data_key] = data_touch_counts.get(data_key, 0) + 1 + + missing = sorted(expected_keys - seen_keys) + extra = sorted(seen_keys - expected_keys) + if missing or extra: + msg = f"SZZ sign vector must cover exactly the surface touches; missing={missing}, extra={extra}" + raise ValueError(msg) + + for (stabilizer_type, stabilizer_index), sum_signs in sorted(check_sums.items()): + residual_class = _szz_residual_class(sum_signs) + if residual_class != "identity": + msg = ( + "SZZ sign vector rejected: " + f"{stabilizer_type}{stabilizer_index} ancilla residual is {residual_class} " + f"(sum={sum_signs}, mod4={sum_signs % 4}); v1 has no record-flip checks" + ) + raise ValueError(msg) + + compensations: list[SzzBoundaryCompensation] = [] + streams: list[SzzClass2Stream] = [] + for (stabilizer_type, data_qubit), sum_signs in sorted(data_sums.items()): + residual_class = _szz_residual_class(sum_signs) + if residual_class == "identity": + continue + if residual_class == "pauli": + streams.append( + SzzClass2Stream( + stabilizer_type=stabilizer_type, + data_qubit=data_qubit, + pauli="X" if stabilizer_type == "X" else "Z", + ), + ) + continue + if data_touch_counts[(stabilizer_type, data_qubit)] != 1: + msg = ( + "SZZ sign vector rejected: odd residual on a non-boundary data class " + f"{stabilizer_type}, data={data_qubit}, sum={sum_signs}" + ) + raise ValueError(msg) + touch = next( + entry + for entry in signs + if entry.stabilizer_type == stabilizer_type and entry.data_qubit == data_qubit + ) + gate = { + ("X", 1): "SXDG", + ("X", -1): "SX", + ("Z", 1): "SZDG", + ("Z", -1): "SZ", + }[(stabilizer_type, touch.sign)] + compensations.append( + SzzBoundaryCompensation( + stabilizer_type=touch.stabilizer_type, + stabilizer_index=touch.stabilizer_index, + data_qubit=touch.data_qubit, + gate=gate, + ), + ) + + return SzzResidualPlan( + signs=tuple(sorted(signs)), + boundary_compensations=tuple(sorted(compensations)), + class2_streams=tuple(sorted(streams)), + ) + + +def _default_szz_residual_plan(patch: SurfacePatch) -> SzzResidualPlan: + """Return the validated v1 SZZ residual plan for a patch.""" + return _validate_szz_sign_vector(patch, _default_szz_sign_vector(patch)) + + +def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through uncompensated SZZ/SZZdg.""" + common = x_a ^ x_b + return x_a, z_a ^ common, x_b, z_b ^ common + + +def _propagate_compensated_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through the compensated CZ-equivalent interaction.""" + return x_a, z_a ^ x_b, x_b, z_b ^ x_a + + +def _propagate_sxx_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: + """Propagate local Pauli-frame bits through the SXX/SXXdg mirror.""" + common = z_a ^ z_b + return x_a ^ common, z_a, x_b ^ common, z_b + + def build_surface_code_circuit( patch: SurfacePatch, num_rounds: int, @@ -166,6 +382,7 @@ def build_surface_code_circuit( ancilla_budget: int | None = None, *, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. @@ -188,6 +405,10 @@ def build_surface_code_circuit( each site between counted syndrome rounds. The ``"before_two_qubit_gate"`` schedule emits one column per operand immediately before each surface-memory two-qubit gate. + interaction_basis: Surface-memory two-qubit interaction basis. + ``"cx"`` preserves the existing CNOT extraction circuit. ``"szz"`` + emits the direct-renderer SZZ/SZZdg abstract template; v1 rejects + ancilla reuse and twirl integration until the later stage gates. Returns: Tuple of (operations list, qubit allocation info) @@ -200,6 +421,7 @@ def build_surface_code_circuit( num_z_anc = len(geom.z_stabilizers) total_ancilla = num_x_anc + num_z_anc effective_ancilla_budget = _normalize_ancilla_budget(total_ancilla, ancilla_budget) + interaction_basis = _normalize_interaction_basis(interaction_basis) if twirl is not None: twirl.validate_runtime_supported() twirl_site_schedule = None if twirl is None else twirl.site_schedule @@ -288,6 +510,28 @@ def emit_gate_local_twirl_layer( # Get CNOT schedule cnot_rounds = compute_cnot_schedule(patch) + if interaction_basis == "szz": + if effective_ancilla_budget != total_ancilla: + msg = ( + "interaction_basis='szz' does not yet support constrained " + "ancilla budgets; pass ancilla_budget=None or a budget >= total_ancilla" + ) + raise ValueError(msg) + if twirl is not None: + msg = "interaction_basis='szz' twirl integration is staged later; omit twirl for Stage 1" + raise ValueError(msg) + return ( + _build_surface_code_circuit_szz( + patch, + num_rounds, + basis, + allocation, + cnot_rounds, + _default_szz_residual_plan(patch), + ), + allocation, + ) + ops: list[SurfaceCircuitStep] = [] # ========================================================================= @@ -556,6 +800,193 @@ def emit_gate_local_twirl_layer( return ops, allocation +def _build_surface_code_circuit_szz( + patch: SurfacePatch, + num_rounds: int, + basis: str, + allocation: QubitAllocation, + cnot_rounds: list[list[tuple[str, int, int]]], + residual_plan: SzzResidualPlan, +) -> list[SurfaceCircuitStep]: + """Build the Stage-1 abstract SZZ/SZZdg surface-memory template.""" + geom = patch.geometry + num_data = geom.num_data + sign_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs + } + compensation_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.gate + for entry in residual_plan.boundary_compensations + } + gate_by_name = { + "SX": OpType.SX, + "SXDG": OpType.SXDG, + "SZ": OpType.SZ, + "SZDG": OpType.SZDG, + } + + def data_q(i: int) -> int: + return allocation.data_qubits[i] + + def x_anc_q(stab_idx: int) -> int: + return allocation.x_ancilla_qubits[stab_idx] + + def z_anc_q(stab_idx: int) -> int: + return allocation.z_ancilla_qubits[stab_idx] + + def anc_q(stabilizer_type: str, stab_idx: int) -> int: + return x_anc_q(stab_idx) if stabilizer_type == "X" else z_anc_q(stab_idx) + + def stabilizers_for(stabilizer_type: str) -> list: + return geom.x_stabilizers if stabilizer_type == "X" else geom.z_stabilizers + + def append_szz_layer( + target_ops: list[SurfaceCircuitStep], + rnd_idx: int, + layer_gates: list[tuple[str, int, int]], + ) -> None: + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"SZZ round {rnd_idx + 1}")) + x_touches = [(stab_idx, data_idx) for stab_type, stab_idx, data_idx in layer_gates if stab_type == "X"] + for _stab_idx, data_idx in x_touches: + target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_pre_d{data_idx}")) + + for stab_type, stab_idx, data_idx in layer_gates: + sign = sign_by_touch[(stab_type, stab_idx, data_idx)] + op_type = OpType.SZZ if sign > 0 else OpType.SZZDG + target_ops.append( + SurfaceCircuitStep( + op_type, + [anc_q(stab_type, stab_idx), data_q(data_idx)], + f"{stab_type}{stab_idx}", + ), + ) + + for _stab_idx, data_idx in x_touches: + target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_post_d{data_idx}")) + + for stab_type, stab_idx, data_idx in layer_gates: + compensation = compensation_by_touch.get((stab_type, stab_idx, data_idx)) + if compensation is None: + continue + target_ops.append( + SurfaceCircuitStep( + gate_by_name[compensation], + [data_q(data_idx)], + f"szz_boundary_comp:{compensation}:{stab_type}{stab_idx}:d{data_idx}", + ), + ) + + ops: list[SurfaceCircuitStep] = [] + + # ========================================================================= + # prep_z_basis / prep_x_basis + # ========================================================================= + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) + ops.extend(SurfaceCircuitStep(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) + if basis.upper() == "X": + ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # init_{basis}_basis syndrome establishment + # ========================================================================= + init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" + init_stabilizers = list(stabilizers_for(init_stabilizer_type)) + ops.append( + SurfaceCircuitStep( + OpType.COMMENT, + label=f"init_{init_stabilizer_type.lower()}_syndrome", + ), + ) + ops.extend( + SurfaceCircuitStep( + OpType.ALLOC, + [anc_q(init_stabilizer_type, s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(init_stabilizer_type, s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cnot_round in enumerate(cnot_rounds): + layer_gates = [ + (stab_type, stab_idx, data_idx) + for stab_type, stab_idx, data_idx in cnot_round + if stab_type == init_stabilizer_type + ] + append_szz_layer(ops, rnd_idx, layer_gates) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(init_stabilizer_type, s.index)], + f"a{init_stabilizer_type.lower()}{s.index}", + ) + for s in init_stabilizers + ) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + init_label_prefix = "sx" if init_stabilizer_type == "X" else "sz" + ops.extend( + SurfaceCircuitStep( + OpType.MEASURE, + [anc_q(init_stabilizer_type, s.index)], + f"{init_label_prefix}{s.index}", + ) + for s in init_stabilizers + ) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # syndrome_extraction + # ========================================================================= + for rnd in range(num_rounds): + ops.append( + SurfaceCircuitStep(OpType.COMMENT, label=f"syndrome_extraction round {rnd + 1}"), + ) + ops.extend(SurfaceCircuitStep(OpType.ALLOC, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) + ops.extend(SurfaceCircuitStep(OpType.ALLOC, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) + ops.extend(SurfaceCircuitStep(OpType.H, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + for rnd_idx, cnot_round in enumerate(cnot_rounds): + append_szz_layer(ops, rnd_idx, list(cnot_round)) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) + ops.extend(SurfaceCircuitStep(OpType.H, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + ops.extend(SurfaceCircuitStep(OpType.MEASURE, [x_anc_q(s.index)], f"sx{s.index}") for s in geom.x_stabilizers) + ops.extend(SurfaceCircuitStep(OpType.MEASURE, [z_anc_q(s.index)], f"sz{s.index}") for s in geom.z_stabilizers) + ops.append(SurfaceCircuitStep(OpType.TICK)) + + # ========================================================================= + # measure_z_basis / measure_x_basis + # ========================================================================= + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) + if basis.upper() == "X": + ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) + ops.extend(SurfaceCircuitStep(OpType.MEASURE, [data_q(i)], f"final[{i}]") for i in range(num_data)) + + return ops + + def classify_stabilizer_boundary(stab_type: str, data_qubits: tuple[int, ...], d: int, dz: int | None = None) -> str: """Public wrapper for classifying a boundary stabilizer.""" from pecos.qec.surface.schedule import _classify_boundary @@ -717,12 +1148,44 @@ def render( if self.p1 > 0: lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + elif op.op_type == OpType.SX: + lines.append(f"SQRT_X {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SXDG: + lines.append(f"SQRT_X_DAG {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SZ: + lines.append(f"S {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + + elif op.op_type == OpType.SZDG: + lines.append(f"S_DAG {op.qubits[0]}") + if self.p1 > 0: + lines.append(f"DEPOLARIZE1({self.p1}) {op.qubits[0]}") + elif op.op_type == OpType.CX: c, t = op.qubits lines.append(f"CX {c} {t}") if self.p2 > 0: lines.append(f"DEPOLARIZE2({self.p2}) {c} {t}") + elif op.op_type == OpType.SZZ: + a, b = op.qubits + lines.append(f"SQRT_ZZ {a} {b}") + if self.p2 > 0: + lines.append(f"DEPOLARIZE2({self.p2}) {a} {b}") + + elif op.op_type == OpType.SZZDG: + a, b = op.qubits + lines.append(f"SQRT_ZZ_DAG {a} {b}") + if self.p2 > 0: + lines.append(f"DEPOLARIZE2({self.p2}) {a} {b}") + elif op.op_type == OpType.MEASURE: q = op.qubits[0] if self.p_meas > 0: @@ -892,7 +1355,7 @@ def render( _basis: str, ) -> DagCircuit: """Render to PECOS DagCircuit.""" - from pecos_rslib import DagCircuit + from pecos_rslib import DagCircuit, Gate, GateType circuit = DagCircuit() allocated: set[int] = set() @@ -916,6 +1379,18 @@ def render( elif op.op_type == OpType.H: circuit.h([op.qubits[0]]) + elif op.op_type == OpType.SX: + circuit.add_gate(Gate(GateType.SX, qubits=[op.qubits[0]])) + + elif op.op_type == OpType.SXDG: + circuit.add_gate(Gate(GateType.SXdg, qubits=[op.qubits[0]])) + + elif op.op_type == OpType.SZ: + circuit.sz([op.qubits[0]]) + + elif op.op_type == OpType.SZDG: + circuit.szdg([op.qubits[0]]) + elif op.op_type == OpType.X: circuit.x([op.qubits[0]]) @@ -925,6 +1400,12 @@ def render( elif op.op_type == OpType.CX: circuit.cx([(op.qubits[0], op.qubits[1])]) + elif op.op_type == OpType.SZZ: + circuit.szz([(op.qubits[0], op.qubits[1])]) + + elif op.op_type == OpType.SZZDG: + circuit.szzdg([(op.qubits[0], op.qubits[1])]) + elif op.op_type == OpType.MEASURE: q = op.qubits[0] if op.label.startswith(("sx", "sz")): @@ -1046,9 +1527,9 @@ def get_stabilizer_from_label(label: str) -> str: return f"Z{int(label[2:])}" return "" - # Helper to get stabilizer name for a CX gate - def get_cx_stabilizer(control: int, target: int, label: str = "") -> str: - """Get stabilizer name for a CX gate (e.g., 'X0', 'Z2').""" + # Helper to get stabilizer name for a two-qubit check interaction. + def get_check_stabilizer(control: int, target: int, label: str = "") -> str: + """Get stabilizer name for a two-qubit check gate (e.g., 'X0', 'Z2').""" from_label = get_stabilizer_from_label(label) if from_label: return from_label @@ -1089,11 +1570,18 @@ def get_ancilla_gate_metadata(qubit: int, label: str = "") -> dict[str, object]: metadata["ancilla_qubit"] = qubit return metadata - def get_cx_gate_metadata(control: int, target: int, label: str = "") -> dict[str, object]: - stab_label = get_cx_stabilizer(control, target, label) + def get_two_qubit_check_metadata( + control: int, + target: int, + label: str = "", + *, + gate_kind: str, + ) -> dict[str, object]: + stab_label = get_check_stabilizer(control, target, label) if not stab_label: return {} metadata = get_stabilizer_metadata(stab_label) + metadata["interaction_gate"] = gate_kind ancilla_qubit = next( (q for q in (control, target) if q in stabilizer_by_ancilla_qubit), None, @@ -1147,7 +1635,8 @@ def is_syndrome_context(phase: str, round_index: int) -> bool: if round_index >= 0 or phase.startswith("init_syndrome"): return True return round_index == -1 and ( - phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} or phase.startswith("cx_round_") + phase in {"syndrome_h_pre", "syndrome_h_post", "measure_ancilla"} + or phase.startswith(("cx_round_", "szz_round_")) ) def gate_metadata(meta: dict | None = None) -> dict: @@ -1193,7 +1682,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non elif "Prepare ancillas" in op.label: current_phase = "init_syndrome_prep" if current_round < 0 else "syndrome_prep" current_cx_round = 0 - elif "Hadamard on X ancillas" in op.label: + elif "Hadamard on X ancillas" in op.label or "Hadamard on SZZ ancillas" in op.label: current_phase = ( "syndrome_h_pre" if current_phase in {"syndrome_prep", "init_syndrome_prep"} @@ -1202,6 +1691,9 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non elif "CX round" in op.label: current_cx_round = int(op.label.split()[-1]) current_phase = f"cx_round_{current_cx_round}" + elif "SZZ round" in op.label: + current_cx_round = int(op.label.split()[-1]) + current_phase = f"szz_round_{current_cx_round}" elif "Measure ancillas" in op.label: current_phase = "measure_ancilla" elif "prep_z_basis" in op.label or "prep_x_basis" in op.label: @@ -1242,6 +1734,42 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta["label"] = op.label apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.SX: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sx([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SXDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sxdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZ: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sz([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).szdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.X: q = op.qubits[0] tick = get_tick_for_qubits([q]).x([q]) @@ -1264,7 +1792,25 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non qubits = op.qubits tick = get_tick_for_qubits(qubits).cx([(qubits[0], qubits[1])]) mark_qubits_used(qubits) - meta = get_cx_gate_metadata(qubits[0], qubits[1], op.label) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="CX") + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZZ: + qubits = op.qubits + tick = get_tick_for_qubits(qubits).szz([(qubits[0], qubits[1])]) + mark_qubits_used(qubits) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="SZZ") + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SZZDG: + qubits = op.qubits + tick = get_tick_for_qubits(qubits).szzdg([(qubits[0], qubits[1])]) + mark_qubits_used(qubits) + meta = get_two_qubit_check_metadata(qubits[0], qubits[1], op.label, gate_kind="SZZdg") if op.label: meta["label"] = op.label apply_gate_metadata(tick, meta or None) @@ -1585,10 +2131,12 @@ def generate_stim_from_patch( basis: str = "Z", *, ancilla_budget: int | None = None, + interaction_basis: str = "cx", p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, p_prep: float = 0.0, + add_detectors: bool = True, ) -> str: """Generate Stim circuit from SurfacePatch. @@ -1597,16 +2145,32 @@ def generate_stim_from_patch( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Surface-memory two-qubit interaction basis. p1: Single-qubit error rate p2: Two-qubit error rate p_meas: Measurement error rate p_prep: Initialization error rate + add_detectors: Whether to add detector/observable annotations. Returns: Stim circuit string """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) - renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + interaction_basis = _normalize_interaction_basis(interaction_basis) + if interaction_basis == "szz" and add_detectors: + msg = ( + "interaction_basis='szz' detector annotations require Stage 2 " + "class-2 stream correction; pass add_detectors=False for Stage 1 " + "structural gate rendering" + ) + raise ValueError(msg) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + interaction_basis=interaction_basis, + ) + renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, add_detectors=add_detectors) return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -1643,6 +2207,8 @@ def generate_dag_circuit_from_patch( num_rounds: int, basis: str = "Z", ancilla_budget: int | None = None, + *, + interaction_basis: str = "cx", ) -> DagCircuit: """Generate PECOS DagCircuit from SurfacePatch. @@ -1651,11 +2217,18 @@ def generate_dag_circuit_from_patch( num_rounds: Number of syndrome rounds basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Surface-memory two-qubit interaction basis. Returns: PECOS DagCircuit instance """ - ops, allocation = build_surface_code_circuit(patch, num_rounds, basis, ancilla_budget) + ops, allocation = build_surface_code_circuit( + patch, + num_rounds, + basis, + ancilla_budget, + interaction_basis=interaction_basis, + ) renderer = DagCircuitRenderer() return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -1669,6 +2242,7 @@ def generate_tick_circuit_from_patch( add_typed_annotations: bool = True, ancilla_budget: int | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -1696,16 +2270,26 @@ def generate_tick_circuit_from_patch( tracked-Pauli annotations are emitted even if ``add_typed_annotations`` is false; that flag controls detector and observable typed annotations, not the twirl lookup channel. + interaction_basis: Surface-memory two-qubit interaction basis. Returns: PECOS TickCircuit instance """ + interaction_basis = _normalize_interaction_basis(interaction_basis) + if interaction_basis == "szz" and add_detectors: + msg = ( + "interaction_basis='szz' detector annotations require Stage 2 " + "class-2 stream correction; pass add_detectors=False for Stage 1 " + "structural gate rendering" + ) + raise ValueError(msg) ops, allocation = build_surface_code_circuit( patch, num_rounds, basis, ancilla_budget, twirl=twirl, + interaction_basis=interaction_basis, ) renderer = TickCircuitRenderer( add_detectors=add_detectors, @@ -1945,12 +2529,18 @@ def tick_circuit_to_stim( simple_gate_map = { "H": ("H", "single"), + "SX": ("SQRT_X", "single"), + "SXdg": ("SQRT_X_DAG", "single"), + "SZ": ("S", "single"), + "SZdg": ("S_DAG", "single"), "X": ("X", "single"), "Y": ("Y", "single"), "Z": ("Z", "single"), "CX": ("CX", "two"), "CY": ("CY", "two"), "CZ": ("CZ", "two"), + "SZZ": ("SQRT_ZZ", "two"), + "SZZdg": ("SQRT_ZZ_DAG", "two"), "MZ": ("M", "measure"), "PZ": ("R", "prep"), "QAlloc": ("R", "prep"), diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 06f418bff..52d4d36e2 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1344,12 +1344,28 @@ def _build_surface_tick_circuit_for_native_model( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" - from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch if twirl is not None: twirl.validate_runtime_supported() + interaction_basis = _normalize_interaction_basis(interaction_basis) + if interaction_basis != "cx": + if circuit_source == "traced_qis": + msg = ( + "interaction_basis='szz' is not supported with circuit_source='traced_qis' " + "until the Guppy emitter stage" + ) + raise ValueError(msg) + msg = ( + "interaction_basis='szz' native detector/DEM/sampler support requires " + "Stage 2 class-2 stream-corrected detector validation; use " + "generate_tick_circuit_from_patch(..., add_detectors=False) for " + "Stage 1 structural rendering" + ) + raise ValueError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1358,6 +1374,7 @@ def _build_surface_tick_circuit_for_native_model( ancilla_budget=ancilla_budget, add_typed_annotations=False, twirl=twirl, + interaction_basis=interaction_basis, ) if circuit_source == "abstract": @@ -1417,6 +1434,7 @@ def build_memory_circuit( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1439,6 +1457,7 @@ def build_memory_circuit( only with ``circuit_source="abstract"``; traced-QIS twirl is rejected because a runtime trace would bake one sampled mask into the circuit and can lose canonical result-id provenance. + interaction_basis: Surface-memory two-qubit interaction basis. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1471,6 +1490,7 @@ def build_memory_circuit( circuit_source=circuit_source, runtime=runtime, twirl=twirl, + interaction_basis=interaction_basis, ) @@ -1629,6 +1649,7 @@ def _surface_native_topology( *, runtime: object | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1649,6 +1670,7 @@ def _surface_native_topology( circuit_source=circuit_source, runtime=runtime, twirl=twirl, + interaction_basis=interaction_basis, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -1705,6 +1727,7 @@ def _cached_surface_native_topology( include_idle_gates: bool, *, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" return _surface_native_topology( @@ -1715,6 +1738,7 @@ def _cached_surface_native_topology( circuit_source, include_idle_gates, twirl=twirl, + interaction_basis=interaction_basis, ) @@ -1780,6 +1804,7 @@ def _cached_surface_native_dem_string( p_idle_y_quadratic_sine_rate: float | None = None, p_idle_z_quadratic_sine_rate: float | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" include_idle_gates = _uses_dedicated_idle_noise( @@ -1807,6 +1832,7 @@ def _cached_surface_native_dem_string( circuit_source, include_idle_gates, twirl=twirl, + interaction_basis=interaction_basis, ) return _dem_string_from_cached_surface_topology( topology, @@ -1906,6 +1932,7 @@ def generate_circuit_level_dem_from_builder( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -1941,6 +1968,7 @@ def generate_circuit_level_dem_from_builder( twirl: Optional Pauli-frame randomization layout. Canonical Guppy frame-output mode is normalized to the same abstract raw lookup and DEM topology. + interaction_basis: Surface-memory two-qubit interaction basis. Returns: DEM string in standard format @@ -1954,6 +1982,9 @@ def generate_circuit_level_dem_from_builder( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + interaction_basis = _normalize_interaction_basis(interaction_basis) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) if runtime is not None: @@ -1966,6 +1997,7 @@ def generate_circuit_level_dem_from_builder( include_idle_gates, runtime=runtime, twirl=twirl, + interaction_basis=interaction_basis, ) return _dem_string_from_cached_surface_topology( topology, @@ -2003,6 +2035,7 @@ def generate_circuit_level_dem_from_builder( p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, twirl=twirl, + interaction_basis=interaction_basis, ) @@ -3672,6 +3705,7 @@ def build_native_sampler( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", sampling_model: Literal[ "dem", "influence_dem", @@ -3703,6 +3737,7 @@ def build_native_sampler( before native PECOS fault analysis. twirl: Optional Pauli-frame randomization layout. Canonical runtime frame-output mode is normalized to the same abstract raw lookup. + interaction_basis: Surface-memory two-qubit interaction basis. sampling_model: Which native sampling backend to use. ``"dem"`` samples the generated decomposed DEM and is the default. ``"influence_dem"`` uses the influence-map-based DemSampler with @@ -3721,6 +3756,9 @@ def build_native_sampler( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + interaction_basis = _normalize_interaction_basis(interaction_basis) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( @@ -3731,6 +3769,7 @@ def build_native_sampler( circuit_source, _noise_uses_dedicated_idle_noise(noise), twirl=twirl, + interaction_basis=interaction_basis, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -3763,6 +3802,7 @@ def build_native_sampler( p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, twirl=twirl, + interaction_basis=interaction_basis, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -3792,6 +3832,7 @@ def build_native_sampler_from_dem( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", twirl: TwirlConfig | None = None, + interaction_basis: str = "cx", ) -> NativeSampler: """Build a native sampler from a caller-supplied decomposed DEM string. @@ -3802,6 +3843,9 @@ def build_native_sampler_from_dem( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + interaction_basis = _normalize_interaction_basis(interaction_basis) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( @@ -3812,6 +3856,7 @@ def build_native_sampler_from_dem( circuit_source, include_idle_gates=False, twirl=twirl, + interaction_basis=interaction_basis, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py new file mode 100644 index 000000000..663e89601 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -0,0 +1,269 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +from __future__ import annotations + +import numpy as np +import pecos as pc +import pytest +from pecos.qec.surface import SurfacePatch, TwirlConfig +from pecos.qec.surface.circuit_builder import ( + OpType, + SzzTouchSign, + _default_szz_residual_plan, + _default_szz_sign_vector, + _propagate_compensated_szz_frame_bits, + _propagate_sxx_frame_bits, + _propagate_szz_frame_bits, + _szz_residual_class, + _validate_szz_sign_vector, + build_surface_code_circuit, + generate_dag_circuit_from_patch, + generate_stim_from_patch, + generate_tick_circuit_from_patch, +) +from pecos.qec.surface.decode import build_memory_circuit + + +def _to_numpy_complex(matrix: object) -> np.ndarray: + arr = np.asarray(matrix) + if arr.ndim >= 1 and arr.shape[-1:] == (2,) and not np.issubdtype(arr.dtype, np.complexfloating): + return arr[..., 0].astype(float) + 1j * arr[..., 1].astype(float) + return np.asarray(matrix, dtype=complex) + + +def _pauli_matrix(pauli: object) -> np.ndarray: + return _to_numpy_complex(pauli.to_matrix()) + + +def _kron(left: np.ndarray, right: np.ndarray) -> np.ndarray: + return _to_numpy_complex(pc.kron(pc.array(left), pc.array(right))) + + +I2 = _pauli_matrix(pc.PauliString.I()) +PAULI_X = _pauli_matrix(pc.X(0)) +PAULI_Z = _pauli_matrix(pc.Z(0)) +H = (PAULI_X + PAULI_Z) / np.sqrt(2.0) +SZ = (I2 + PAULI_Z) / 2 + 1j * (I2 - PAULI_Z) / 2 +SZDG = SZ.conj().T +SX = np.cos(np.pi / 4) * I2 - 1j * np.sin(np.pi / 4) * PAULI_X +I4 = _kron(I2, I2) +ZI = _kron(PAULI_Z, I2) +IZ = _kron(I2, PAULI_Z) +ZZ = _pauli_matrix(pc.Z(0) & pc.Z(1)) +CZ = (I4 + ZI + IZ - ZZ) / 2 +SZZ = np.cos(np.pi / 4) * I4 - 1j * np.sin(np.pi / 4) * ZZ +SZZDG = SZZ.conj().T +CX = _kron(I2, H) @ CZ @ _kron(I2, H) +SXX = _kron(H, H) @ SZZ @ _kron(H, H) + + +def _equiv_up_to_global_phase(left: np.ndarray, right: np.ndarray, *, atol: float = 1e-10) -> bool: + flat_right = right.ravel() + flat_left = left.ravel() + nonzero = np.flatnonzero(np.abs(flat_right) > atol) + if nonzero.size == 0: + return bool(np.allclose(left, right, atol=atol)) + ratio = flat_left[nonzero[0]] / flat_right[nonzero[0]] + return bool(np.allclose(flat_left, ratio * flat_right, atol=atol)) + + +def _pauli_from_bits(x_bit: bool, z_bit: bool) -> np.ndarray: + op = I2 + if x_bit: + op = PAULI_X @ op + if z_bit: + op = PAULI_Z @ op + return op + + +def _bits_from_pauli(op: np.ndarray) -> tuple[bool, bool]: + for x_bit in (False, True): + for z_bit in (False, True): + if _equiv_up_to_global_phase(op, _pauli_from_bits(x_bit, z_bit)): + return x_bit, z_bit + msg = f"not a Pauli up to phase:\n{op}" + raise AssertionError(msg) + + +def _matrix_frame_update( + unitary: np.ndarray, + x_a: bool, + z_a: bool, + x_b: bool, + z_b: bool, +) -> tuple[bool, bool, bool, bool]: + before = _kron(_pauli_from_bits(x_a, z_a), _pauli_from_bits(x_b, z_b)) + after = unitary @ before @ unitary.conj().T + basis = (I2, PAULI_X, PAULI_Z, PAULI_X @ PAULI_Z) + for left in basis: + for right in basis: + candidate = _kron(left, right) + if _equiv_up_to_global_phase(after, candidate): + ax, az = _bits_from_pauli(left) + bx, bz = _bits_from_pauli(right) + return ax, az, bx, bz + msg = f"not a tensor-product Pauli up to phase:\n{after}" + raise AssertionError(msg) + + +def test_szz_unitary_identities() -> None: + assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) + assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) + assert _equiv_up_to_global_phase( + _kron(I2, H) @ SZZ @ _kron(I2, H), + CX @ _kron(SZ, SX), + ) + + +@pytest.mark.parametrize("x_a", [False, True]) +@pytest.mark.parametrize("z_a", [False, True]) +@pytest.mark.parametrize("x_b", [False, True]) +@pytest.mark.parametrize("z_b", [False, True]) +def test_szz_frame_rules_match_matrix_oracle( + x_a: bool, + z_a: bool, + x_b: bool, + z_b: bool, +) -> None: + assert _propagate_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SZZ, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SZZDG, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_compensated_szz_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + CZ, + x_a, + z_a, + x_b, + z_b, + ) + assert _propagate_sxx_frame_bits(x_a, z_a, x_b, z_b) == _matrix_frame_update( + SXX, + x_a, + z_a, + x_b, + z_b, + ) + + +def test_szz_residual_classifier_and_default_plan() -> None: + patch = SurfacePatch.create(distance=3) + plan = _default_szz_residual_plan(patch) + + assert _szz_residual_class(0) == "identity" + assert _szz_residual_class(4) == "identity" + assert _szz_residual_class(2) == "pauli" + assert _szz_residual_class(-2) == "pauli" + assert _szz_residual_class(1) == "odd" + assert _szz_residual_class(-1) == "odd" + + assert {entry.sign for entry in plan.signs} == {-1, 1} + assert {entry.gate for entry in plan.boundary_compensations} == { + "SX", + "SXDG", + "SZ", + "SZDG", + } + assert {entry.pauli for entry in plan.class2_streams} == {"X", "Z"} + + +def test_szz_bad_sign_vector_rejected_loudly() -> None: + patch = SurfacePatch.create(distance=3) + signs = list(_default_szz_sign_vector(patch)) + bad_index = next(i for i, entry in enumerate(signs) if entry.sign == -1) + bad_entry = signs[bad_index] + signs[bad_index] = SzzTouchSign( + bad_entry.stabilizer_type, + bad_entry.stabilizer_index, + bad_entry.data_qubit, + 1, + ) + + with pytest.raises(ValueError, match="ancilla residual is pauli"): + _validate_szz_sign_vector(patch, tuple(signs)) + + +def test_szz_builder_emits_szz_template_and_rejects_stage_later_features() -> None: + patch = SurfacePatch.create(distance=3) + + cx_ops, _ = build_surface_code_circuit(patch, num_rounds=1, interaction_basis="cx") + assert any(op.op_type == OpType.CX for op in cx_ops) + assert not any(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in cx_ops) + + szz_ops, _ = build_surface_code_circuit(patch, num_rounds=1, interaction_basis="szz") + op_types = {op.op_type for op in szz_ops} + assert OpType.CX not in op_types + assert {OpType.SZZ, OpType.SZZDG, OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} <= op_types + + with pytest.raises(ValueError, match="does not yet support constrained ancilla budgets"): + build_surface_code_circuit(patch, num_rounds=1, ancilla_budget=1, interaction_basis="szz") + + with pytest.raises(ValueError, match="twirl integration is staged later"): + build_surface_code_circuit( + patch, + num_rounds=1, + twirl=TwirlConfig(), + interaction_basis="szz", + ) + + +def test_szz_tick_circuit_uses_named_szz_gates() -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + add_detectors=False, + interaction_basis="szz", + ) + + gate_names = { + gate.gate_type.name + for tick_index in range(tick_circuit.num_ticks()) + for gate in tick_circuit.get_tick(tick_index).gate_batches() + } + assert "CX" not in gate_names + assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names + + +def test_szz_direct_renderers_accept_interaction_basis() -> None: + patch = SurfacePatch.create(distance=3) + + stim_text = generate_stim_from_patch( + patch, + num_rounds=1, + interaction_basis="szz", + add_detectors=False, + ) + assert "CX" not in stim_text + assert "SQRT_X" in stim_text + assert "S_DAG" in stim_text + assert "SQRT_ZZ" in stim_text + assert "SQRT_ZZ_DAG" in stim_text + + dag_circuit = generate_dag_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") + gate_names = {dag_circuit.gate(node).gate_type.name for node in dag_circuit.nodes()} + assert "CX" not in gate_names + assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names + + +def test_szz_detector_paths_reject_until_stage2() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match="detector annotations require Stage 2"): + generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") + + with pytest.raises(ValueError, match="detector annotations require Stage 2"): + generate_tick_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") + + with pytest.raises(ValueError, match="native detector/DEM/sampler support requires Stage 2"): + build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") From ea0dde38bd1a9fb22d9cba83864d58abcb2358bd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 17:03:59 -0600 Subject: [PATCH 120/388] Add SZZ noiseless detector equivalence --- .../pecos/qec/surface/_detection_events.py | 14 ++- .../src/pecos/qec/surface/circuit_builder.py | 106 +++++++++-------- .../src/pecos/qec/surface/decode.py | 23 ++-- .../qec/surface/test_szz_interaction_basis.py | 109 ++++++++++++++++-- 4 files changed, 179 insertions(+), 73 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py index 95d4c7c58..7589408ad 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -18,6 +18,16 @@ def get_meta(self, key: str) -> str | None: ... +def _record_offsets(entry: dict[str, object], num_measurements: int) -> list[int]: + records = entry.get("records") + if records is not None: + return [int(record) for record in records] # type: ignore[union-attr] + meas_ids = entry.get("meas_ids") + if meas_ids is not None: + return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] + return [] + + def extract_detection_events_and_observables( tick_circuit: _TickCircuitLike, results: Iterable[Sequence[int]], @@ -52,7 +62,7 @@ def extract_detection_events_and_observables( fired_detectors: list[int] = [] for det_idx, det in enumerate(detectors): val = 0 - for rec in det["records"]: + for rec in _record_offsets(det, num_meas): idx = num_meas + rec if 0 <= idx < num_meas: val ^= int(row[idx]) @@ -63,7 +73,7 @@ def extract_detection_events_and_observables( flipped_observables: list[int] = [] for obs_idx, obs in enumerate(observables): val = 0 - for rec in obs["records"]: + for rec in _record_offsets(obs, num_meas): idx = num_meas + rec if 0 <= idx < num_meas: val ^= int(row[idx]) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index ac7f8345c..b62f0adea 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -407,8 +407,8 @@ def build_surface_code_circuit( immediately before each surface-memory two-qubit gate. interaction_basis: Surface-memory two-qubit interaction basis. ``"cx"`` preserves the existing CNOT extraction circuit. ``"szz"`` - emits the direct-renderer SZZ/SZZdg abstract template; v1 rejects - ancilla reuse and twirl integration until the later stage gates. + emits the direct-renderer SZZ/SZZdg abstract template with local + data-qubit compensation. Returns: Tuple of (operations list, qubit allocation info) @@ -808,21 +808,26 @@ def _build_surface_code_circuit_szz( cnot_rounds: list[list[tuple[str, int, int]]], residual_plan: SzzResidualPlan, ) -> list[SurfaceCircuitStep]: - """Build the Stage-1 abstract SZZ/SZZdg surface-memory template.""" + """Build the abstract SZZ/SZZdg surface-memory template.""" geom = patch.geometry num_data = geom.num_data sign_by_touch = { (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs } - compensation_by_touch = { - (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.gate - for entry in residual_plan.boundary_compensations + data_compensation_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): { + ("X", 1): OpType.SXDG, + ("X", -1): OpType.SX, + ("Z", 1): OpType.SZDG, + ("Z", -1): OpType.SZ, + }[(entry.stabilizer_type, entry.sign)] + for entry in residual_plan.signs } - gate_by_name = { - "SX": OpType.SX, - "SXDG": OpType.SXDG, - "SZ": OpType.SZ, - "SZDG": OpType.SZDG, + gate_name_by_type = { + OpType.SX: "SX", + OpType.SXDG: "SXDG", + OpType.SZ: "SZ", + OpType.SZDG: "SZDG", } def data_q(i: int) -> int: @@ -865,14 +870,12 @@ def append_szz_layer( target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_post_d{data_idx}")) for stab_type, stab_idx, data_idx in layer_gates: - compensation = compensation_by_touch.get((stab_type, stab_idx, data_idx)) - if compensation is None: - continue + compensation = data_compensation_by_touch[(stab_type, stab_idx, data_idx)] target_ops.append( SurfaceCircuitStep( - gate_by_name[compensation], + compensation, [data_q(data_idx)], - f"szz_boundary_comp:{compensation}:{stab_type}{stab_idx}:d{data_idx}", + f"szz_touch_comp:{gate_name_by_type[compensation]}:{stab_type}{stab_idx}:d{data_idx}", ), ) @@ -2156,13 +2159,6 @@ def generate_stim_from_patch( Stim circuit string """ interaction_basis = _normalize_interaction_basis(interaction_basis) - if interaction_basis == "szz" and add_detectors: - msg = ( - "interaction_basis='szz' detector annotations require Stage 2 " - "class-2 stream correction; pass add_detectors=False for Stage 1 " - "structural gate rendering" - ) - raise ValueError(msg) ops, allocation = build_surface_code_circuit( patch, num_rounds, @@ -2276,13 +2272,6 @@ def generate_tick_circuit_from_patch( PECOS TickCircuit instance """ interaction_basis = _normalize_interaction_basis(interaction_basis) - if interaction_basis == "szz" and add_detectors: - msg = ( - "interaction_basis='szz' detector annotations require Stage 2 " - "class-2 stream correction; pass add_detectors=False for Stage 1 " - "structural gate rendering" - ) - raise ValueError(msg) ops, allocation = build_surface_code_circuit( patch, num_rounds, @@ -2542,6 +2531,7 @@ def tick_circuit_to_stim( "SZZ": ("SQRT_ZZ", "two"), "SZZdg": ("SQRT_ZZ_DAG", "two"), "MZ": ("M", "measure"), + "MeasureFree": ("M", "measure"), "PZ": ("R", "prep"), "QAlloc": ("R", "prep"), } @@ -2661,9 +2651,10 @@ def _gate_to_stim( detectors_json = tc.get_meta("detectors") if detectors_json: detectors = json.loads(detectors_json) + num_measurements = int(tc.get_meta("num_measurements") or "0") for det in detectors: coords = det["coords"] - records = det["records"] + records = _metadata_record_offsets(det, num_measurements) coord_str = ", ".join(str(c) for c in coords) record_str = " ".join(f"rec[{r}]" for r in records) lines.append(f"DETECTOR({coord_str}) {record_str}") @@ -2672,9 +2663,10 @@ def _gate_to_stim( observables_json = tc.get_meta("observables") if observables_json: observables = json.loads(observables_json) + num_measurements = int(tc.get_meta("num_measurements") or "0") for obs in observables: obs_id = obs["id"] - records = obs["records"] + records = _metadata_record_offsets(obs, num_measurements) record_str = " ".join(f"rec[{r}]" for r in records) lines.append(f"OBSERVABLE_INCLUDE({obs_id}) {record_str}") @@ -2772,14 +2764,14 @@ def generate_dem_from_tick_circuit_via_pauli_frame( meas_to_detectors: dict[int, list[int]] = defaultdict(list) for det in detectors: det_id = det["id"] - for rec in det["records"]: + for rec in _metadata_record_offsets(det, num_measurements): abs_meas = num_measurements + rec # rec is negative meas_to_detectors[abs_meas].append(det_id) meas_to_observables: dict[int, list[int]] = defaultdict(list) for obs in observables: obs_id = obs["id"] - for rec in obs["records"]: + for rec in _metadata_record_offsets(obs, num_measurements): abs_meas = num_measurements + rec meas_to_observables[abs_meas].append(obs_id) @@ -3160,24 +3152,25 @@ def _maximally_decompose_graphlike_dem(dem_text: str) -> str: return "\n".join(rewritten_lines) -def _build_canonical_dem_influence_map(dag: DagCircuit) -> object: - """Build the influence map used by the canonical Rust DEM builder. - - `DagFaultAnalyzer` supplies the detector influence map. Observable and - tracked-Pauli annotations need positional propagation from - `InfluenceBuilder`; merging those non-detector outputs keeps this legacy - surface helper aligned with `DetectorErrorModel.from_circuit`. - """ - from pecos.qec import DagFaultAnalyzer, InfluenceBuilder +def _build_canonical_dem_influence_map( + dag: DagCircuit, + *, + include_circuit_annotations: bool = False, +) -> object: + """Build the influence map used by the metadata-driven Rust DEM builder.""" + from pecos.qec import DagFaultAnalyzer analyzer = DagFaultAnalyzer(dag) influence_map = analyzer.build_influence_map() - annotation_builder = InfluenceBuilder(dag) - annotation_builder.with_circuit_annotations() - annotation_map = annotation_builder.build() - merge_dem_outputs = getattr(influence_map, "merge_dem_outputs_from", None) - if merge_dem_outputs is not None: - merge_dem_outputs(annotation_map) + if include_circuit_annotations: + from pecos.qec import InfluenceBuilder + + annotation_builder = InfluenceBuilder(dag) + annotation_builder.with_circuit_annotations() + annotation_map = annotation_builder.build() + merge_dem_outputs = getattr(influence_map, "merge_dem_outputs_from", None) + if merge_dem_outputs is not None: + merge_dem_outputs(annotation_map) return influence_map @@ -3194,6 +3187,19 @@ def _metadata_uses_record_offsets(*metadata_jsons: str | None) -> bool: return False +def _metadata_record_offsets(entry: dict[str, object], num_measurements: int) -> list[int]: + """Return Stim-style negative record offsets for a metadata entry.""" + records = entry.get("records") + if records is not None: + return [int(record) for record in records] # type: ignore[union-attr] + + meas_ids = entry.get("meas_ids") + if meas_ids is not None: + return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] + + return [] + + def generate_dem_from_tick_circuit( tc: TickCircuit, *, @@ -3327,6 +3333,8 @@ def generate_dem_from_tick_circuit( p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) + if hasattr(builder, "with_exact_branch_replay_circuit"): + builder = builder.with_exact_branch_replay_circuit(dag) builder.with_num_measurements(num_measurements) if metadata_uses_records: builder.with_measurement_order(measurement_order) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 52d4d36e2..9a46ac76f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1352,18 +1352,10 @@ def _build_surface_tick_circuit_for_native_model( if twirl is not None: twirl.validate_runtime_supported() interaction_basis = _normalize_interaction_basis(interaction_basis) - if interaction_basis != "cx": - if circuit_source == "traced_qis": - msg = ( - "interaction_basis='szz' is not supported with circuit_source='traced_qis' " - "until the Guppy emitter stage" - ) - raise ValueError(msg) + if interaction_basis != "cx" and circuit_source == "traced_qis": msg = ( - "interaction_basis='szz' native detector/DEM/sampler support requires " - "Stage 2 class-2 stream-corrected detector validation; use " - "generate_tick_circuit_from_patch(..., add_detectors=False) for " - "Stage 1 structural rendering" + "interaction_basis='szz' is not supported with circuit_source='traced_qis' " + "until the Guppy emitter supports that basis" ) raise ValueError(msg) @@ -1657,6 +1649,7 @@ def _surface_native_topology( from pecos.qec.surface.circuit_builder import ( _build_canonical_dem_influence_map, _extract_measurement_order, + _metadata_record_offsets, _metadata_uses_record_offsets, normalize_traced_qis_tick_circuit, ) @@ -1688,12 +1681,16 @@ def _surface_native_topology( detectors_json = tc.get_meta("detectors") or "[]" observables_json = tc.get_meta("observables") or "[]" - det_records = [d["records"] for d in json.loads(detectors_json)] if detectors_json else [] - obs_records = [o["records"] for o in json.loads(observables_json)] if observables_json else [] measurement_order = ( tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) + det_records = [ + _metadata_record_offsets(detector, num_measurements) for detector in json.loads(detectors_json) + ] if detectors_json else [] + obs_records = [ + _metadata_record_offsets(observable, num_measurements) for observable in json.loads(observables_json) + ] if observables_json else [] pauli_frame_lookup = None num_pauli_sites = 0 diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 663e89601..82d4da150 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -3,10 +3,13 @@ from __future__ import annotations +import json + import numpy as np import pecos as pc import pytest -from pecos.qec.surface import SurfacePatch, TwirlConfig +import stim +from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig from pecos.qec.surface.circuit_builder import ( OpType, SzzTouchSign, @@ -22,7 +25,7 @@ generate_stim_from_patch, generate_tick_circuit_from_patch, ) -from pecos.qec.surface.decode import build_memory_circuit +from pecos.qec.surface.decode import build_memory_circuit, generate_circuit_level_dem_from_builder def _to_numpy_complex(matrix: object) -> np.ndarray: @@ -107,6 +110,37 @@ def _matrix_frame_update( raise AssertionError(msg) +def _record_parity(raw_records: np.ndarray, records: list[int]) -> np.ndarray: + num_measurements = raw_records.shape[1] + if not records: + return np.zeros(raw_records.shape[0], dtype=bool) + indices = [num_measurements + int(record) for record in records] + return np.bitwise_xor.reduce(raw_records[:, indices], axis=1) + + +def _assert_noiseless_record_metadata_is_zero(stim_text: str, tick_circuit: object) -> None: + circuit = stim.Circuit(stim_text) + circuit.detector_error_model() + + raw_records = circuit.compile_sampler(seed=20260612).sample(shots=32) + detectors = json.loads(tick_circuit.get_meta("detectors") or "[]") + observables = json.loads(tick_circuit.get_meta("observables") or "[]") + + for detector in detectors: + assert not np.any(_record_parity(raw_records, detector["records"])) + for observable in observables: + assert not np.any(_record_parity(raw_records, observable["records"])) + + det_samples, obs_samples = circuit.compile_detector_sampler(seed=20260612).sample( + shots=32, + separate_observables=True, + ) + assert det_samples.shape == (32, circuit.num_detectors) + assert obs_samples.shape == (32, circuit.num_observables) + assert not np.any(det_samples) + assert not np.any(obs_samples) + + def test_szz_unitary_identities() -> None: assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) @@ -204,6 +238,12 @@ def test_szz_builder_emits_szz_template_and_rejects_stage_later_features() -> No op_types = {op.op_type for op in szz_ops} assert OpType.CX not in op_types assert {OpType.SZZ, OpType.SZZDG, OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} <= op_types + szz_gate_count = sum(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in szz_ops) + data_compensation_count = sum( + op.label.startswith("szz_touch_comp:") and op.op_type in {OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} + for op in szz_ops + ) + assert data_compensation_count == szz_gate_count with pytest.raises(ValueError, match="does not yet support constrained ancilla budgets"): build_surface_code_circuit(patch, num_rounds=1, ancilla_budget=1, interaction_basis="szz") @@ -256,14 +296,65 @@ def test_szz_direct_renderers_accept_interaction_basis() -> None: assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names -def test_szz_detector_paths_reject_until_stage2() -> None: +def test_szz_detector_paths_accept_abstract_basis_and_reject_traced_qis() -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError, match="detector annotations require Stage 2"): - generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") + stim_text = generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") + assert "DETECTOR" in stim_text + + tick_circuit = generate_tick_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + memory_circuit = build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") + assert int(memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) + + with pytest.raises(ValueError, match="circuit_source='traced_qis'"): + build_memory_circuit( + patch=patch, + rounds=1, + circuit_source="traced_qis", + interaction_basis="szz", + ) + + +@pytest.mark.parametrize("distance", [3, 5]) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> None: + patch = SurfacePatch.create(distance=distance) + + cx_text = generate_stim_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="cx") + szz_text = generate_stim_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="szz") + cx_tick = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="cx") + szz_tick = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=basis, interaction_basis="szz") + + cx_circuit = stim.Circuit(cx_text) + szz_circuit = stim.Circuit(szz_text) + assert cx_circuit.num_measurements == szz_circuit.num_measurements + assert cx_circuit.num_detectors == szz_circuit.num_detectors + assert cx_circuit.num_observables == szz_circuit.num_observables + assert cx_tick.get_meta("detectors") == szz_tick.get_meta("detectors") + assert cx_tick.get_meta("observables") == szz_tick.get_meta("observables") + + _assert_noiseless_record_metadata_is_zero(cx_text, cx_tick) + _assert_noiseless_record_metadata_is_zero(szz_text, szz_tick) + - with pytest.raises(ValueError, match="detector annotations require Stage 2"): - generate_tick_circuit_from_patch(patch, num_rounds=1, interaction_basis="szz") +def test_szz_native_dem_path_uses_interaction_basis() -> None: + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) + + cx_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + interaction_basis="cx", + ) + szz_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=noise, + interaction_basis="szz", + ) - with pytest.raises(ValueError, match="native detector/DEM/sampler support requires Stage 2"): - build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") + assert cx_dem != szz_dem + assert stim.DetectorErrorModel(szz_dem).num_detectors > 0 From 8ebc302baaaf1efbc1418cf80441d853b612ed7c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 18:13:42 -0600 Subject: [PATCH 121/388] Tighten SZZ residual metadata --- .../pecos/qec/surface/_detection_events.py | 3 +- .../src/pecos/qec/surface/circuit_builder.py | 25 +++++++++------- .../qec/surface/test_surface_metadata.py | 29 +++++++++++++++++++ .../qec/surface/test_szz_interaction_basis.py | 2 +- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py index 7589408ad..06da3d14e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -25,7 +25,8 @@ def _record_offsets(entry: dict[str, object], num_measurements: int) -> list[int meas_ids = entry.get("meas_ids") if meas_ids is not None: return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] - return [] + msg = "detector/observable metadata entry must define either 'records' or 'meas_ids'" + raise ValueError(msg) def extract_detection_events_and_observables( diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b62f0adea..69893a3ef 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -177,7 +177,7 @@ class SzzTouchSign: @dataclass(frozen=True, order=True) class SzzBoundaryCompensation: - """Single-qubit Clifford compensation for an odd boundary residual.""" + """Analysis-only compensation for an odd uncompensated boundary residual.""" stabilizer_type: str stabilizer_index: int @@ -186,8 +186,8 @@ class SzzBoundaryCompensation: @dataclass(frozen=True, order=True) -class SzzClass2Stream: - """Standing deterministic Pauli stream induced by a class-2 residual.""" +class SzzClass2Residual: + """Analysis-only class-2 data residual of the uncompensated sign vector.""" stabilizer_type: str data_qubit: int @@ -196,11 +196,15 @@ class SzzClass2Stream: @dataclass(frozen=True) class SzzResidualPlan: - """Validated SZZ sign convention and residual bookkeeping.""" + """Validated SZZ sign convention and uncompensated residual bookkeeping. + + The active SZZ template cancels data residuals with immediate per-touch + compensation; no class-2 residual stream is emitted by the circuit. + """ signs: tuple[SzzTouchSign, ...] boundary_compensations: tuple[SzzBoundaryCompensation, ...] - class2_streams: tuple[SzzClass2Stream, ...] + class2_residuals: tuple[SzzClass2Residual, ...] def _normalize_interaction_basis(interaction_basis: str) -> str: @@ -306,14 +310,14 @@ def _validate_szz_sign_vector( raise ValueError(msg) compensations: list[SzzBoundaryCompensation] = [] - streams: list[SzzClass2Stream] = [] + class2_residuals: list[SzzClass2Residual] = [] for (stabilizer_type, data_qubit), sum_signs in sorted(data_sums.items()): residual_class = _szz_residual_class(sum_signs) if residual_class == "identity": continue if residual_class == "pauli": - streams.append( - SzzClass2Stream( + class2_residuals.append( + SzzClass2Residual( stabilizer_type=stabilizer_type, data_qubit=data_qubit, pauli="X" if stabilizer_type == "X" else "Z", @@ -349,7 +353,7 @@ def _validate_szz_sign_vector( return SzzResidualPlan( signs=tuple(sorted(signs)), boundary_compensations=tuple(sorted(compensations)), - class2_streams=tuple(sorted(streams)), + class2_residuals=tuple(sorted(class2_residuals)), ) @@ -3197,7 +3201,8 @@ def _metadata_record_offsets(entry: dict[str, object], num_measurements: int) -> if meas_ids is not None: return [int(meas_id) - num_measurements for meas_id in meas_ids] # type: ignore[union-attr] - return [] + msg = "detector/observable metadata entry must define either 'records' or 'meas_ids'" + raise ValueError(msg) def generate_dem_from_tick_circuit( diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index e52803d90..1eed66782 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -15,6 +15,7 @@ SurfacePatch, classify_stabilizer_boundary, describe_surface_memory_experiment, + extract_detection_events_and_observables, generate_tick_circuit_from_patch, get_detector_descriptors_from_tick_circuit, get_measurement_order_from_tick_circuit, @@ -25,11 +26,39 @@ get_stabilizer_schedule_metadata, get_stabilizer_touch_label, ) +from pecos.qec.surface.circuit_builder import _metadata_record_offsets if TYPE_CHECKING: from pecos.qec.surface import SurfacePatchDescriptor +class _MetadataOnlyTickCircuit: + def __init__(self, metadata: dict[str, str]) -> None: + self._metadata = metadata + + def get_meta(self, key: str) -> str | None: + return self._metadata.get(key) + + +def test_metadata_record_offsets_require_records_or_meas_ids() -> None: + """Local metadata consumers should not silently ignore malformed entries.""" + assert _metadata_record_offsets({"records": []}, 3) == [] + assert _metadata_record_offsets({"meas_ids": [1, 2]}, 5) == [-4, -3] + + with pytest.raises(ValueError, match=r"records.*meas_ids"): + _metadata_record_offsets({"id": 0}, 3) + + tick_circuit = _MetadataOnlyTickCircuit( + { + "detectors": json.dumps([{"id": 0, "coords": [0, 0, 0]}]), + "observables": "[]", + "num_measurements": "1", + }, + ) + with pytest.raises(ValueError, match=r"records.*meas_ids"): + extract_detection_events_and_observables(tick_circuit, [[0]]) + + def test_surface_schedule_helpers_expose_region_and_touch_labels() -> None: """Surface metadata helpers should expose stable boundary and touch labels.""" patch = SurfacePatch.create(distance=3) diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 82d4da150..b530e911f 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -208,7 +208,7 @@ def test_szz_residual_classifier_and_default_plan() -> None: "SZ", "SZDG", } - assert {entry.pauli for entry in plan.class2_streams} == {"X", "Z"} + assert {entry.pauli for entry in plan.class2_residuals} == {"X", "Z"} def test_szz_bad_sign_vector_rejected_loudly() -> None: From 38297bbee01b9c88092c35d317d1f21f9eefaf8d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 19:30:31 -0600 Subject: [PATCH 122/388] Add SZZ Guppy surface basis --- .../quantum-pecos/src/pecos/guppy/surface.py | 431 ++++++++++++++---- .../src/pecos/qec/surface/circuit_builder.py | 9 +- .../src/pecos/qec/surface/decode.py | 17 +- .../tests/guppy/test_surface_twirl_render.py | 47 ++ .../qec/surface/test_szz_interaction_basis.py | 17 +- .../tests/qec/test_from_guppy_dem.py | 36 +- 6 files changed, 439 insertions(+), 118 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 6f54c22e0..ce397954a 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -14,6 +14,7 @@ import importlib.util import sys import tempfile +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -32,12 +33,18 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str], dict]] = {} _state = _ModuleState() +def _normalize_surface_interaction_basis(interaction_basis: str) -> str: + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + return _normalize_interaction_basis(interaction_basis) + + def _get_temp_dir() -> Path: """Get or create temporary directory for generated code.""" if _state.temp_dir is None: @@ -119,10 +126,13 @@ def generate_guppy_source( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, + interaction_basis: str = "cx", ) -> str: """Generate Guppy source code for a surface code patch. - Uses a 4-round parallel CNOT schedule for syndrome extraction. + Uses a 4-round parallel schedule for syndrome extraction. The default + ``interaction_basis="cx"`` emits the CNOT template; ``"szz"`` emits the + signed SZZ/SZZdg template with immediate data compensation. ``ancilla_budget=None`` (default) emits the unconstrained shape: one ancilla per stabilizer, all measured in parallel at the end of @@ -162,6 +172,9 @@ def generate_guppy_source( per-shot quantum entropy when ``twirl`` is enabled. num_rounds: Number of syndrome rounds to render. Required when ``twirl`` is enabled because twirled source is unrolled per round. + interaction_basis: Surface-memory interaction basis: ``"cx"`` or + ``"szz"``. SZZ Guppy generation currently supports only the + unconstrained, non-twirled memory shape. Returns: Python/Guppy source code as a string. @@ -170,7 +183,9 @@ def generate_guppy_source( ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget + from pecos.qec.surface.circuit_builder import _default_szz_residual_plan + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) if (twirl is None) != (rng is None): msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" raise ValueError(msg) @@ -191,6 +206,16 @@ def generate_guppy_source( total_ancilla = num_x_stab + num_z_stab effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) constrained = effective_budget < total_ancilla + if interaction_basis == "szz" and constrained: + msg = ( + "interaction_basis='szz' does not yet support constrained ancilla " + f"budgets on the Guppy runtime path (ancilla_budget={ancilla_budget} " + f"< total_ancilla={total_ancilla})" + ) + raise ValueError(msg) + if interaction_basis == "szz" and twirl is not None: + msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" + raise ValueError(msg) if twirl is not None and constrained: msg = ( f"twirl + constrained ancilla budget is not supported on " @@ -213,6 +238,16 @@ def generate_guppy_source( "from guppylang.std.num import nat", "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x, y, z", ] + elif interaction_basis == "szz": + imports = [ + "from __future__ import annotations", + "", + "from guppylang import guppy", + "from guppylang.std.angles import angle", + "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.qsystem.functional import zz_phase", + "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x", + ] else: imports = [ "from __future__ import annotations", @@ -231,6 +266,7 @@ def generate_guppy_source( f"X stabilizers: {num_x_stab}", f"Z stabilizers: {num_z_stab}", f"Ancilla qubits: {num_x_stab + num_z_stab} (one per stabilizer)", + f"Interaction basis: {interaction_basis}", '"""', "", *imports, @@ -241,14 +277,21 @@ def generate_guppy_source( if twirl is not None: lines.extend(_render_inline_pcg32()) - # Generate struct definitions + # Generate struct definitions. lines.extend( [ "@guppy.struct", f"class SurfaceCode_{dx}x{dz}:", f' """Surface code patch with dx={dx}, dz={dz} ({num_data} data qubits)."""', "", - f" data: array[qubit, {num_data}]", + ], + ) + if interaction_basis == "szz": + lines.extend(f" d{i}: qubit" for i in range(num_data)) + else: + lines.append(f" data: array[qubit, {num_data}]") + lines.extend( + [ "", "", "@guppy.struct", @@ -262,32 +305,83 @@ def generate_guppy_source( ], ) + szz_data_args = ", ".join(f"d{i}" for i in range(num_data)) + + def _append_szz_data_unpack(target: list[str], indent: str) -> None: + target.extend(f"{indent}d{i} = surf.d{i}" for i in range(num_data)) + + def _szz_data_expr(data_q: int) -> str: + return f"d{data_q}" + # Generate state preparation functions - lines.extend( - [ - "# === State Preparation ===", - "", - "@guppy", - f"def prep_z_basis() -> SurfaceCode_{dx}x{dz}:", - ' """Prepare logical |0_L> state."""', - f" data = array(qubit() for _ in range({num_data}))", - f" return SurfaceCode_{dx}x{dz}(data)", - "", - "", - "@guppy", - f"def prep_x_basis() -> SurfaceCode_{dx}x{dz}:", - ' """Prepare logical |+_L> state."""', - f" data = array(qubit() for _ in range({num_data}))", - f" for i in range({num_data}):", - " h(data[i])", - f" return SurfaceCode_{dx}x{dz}(data)", - "", - "", - ], - ) + lines.extend(["# === State Preparation ===", "", "@guppy", f"def prep_z_basis() -> SurfaceCode_{dx}x{dz}:"]) + lines.append(' """Prepare logical |0_L> state."""') + if interaction_basis == "szz": + lines.extend(f" d{i} = qubit()" for i in range(num_data)) + lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") + else: + lines.append(f" data = array(qubit() for _ in range({num_data}))") + lines.append(f" return SurfaceCode_{dx}x{dz}(data)") + lines.extend(["", "", "@guppy", f"def prep_x_basis() -> SurfaceCode_{dx}x{dz}:"]) + lines.append(' """Prepare logical |+_L> state."""') + if interaction_basis == "szz": + lines.extend(f" d{i} = qubit()" for i in range(num_data)) + lines.extend(f" h(d{i})" for i in range(num_data)) + lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") + else: + lines.append(f" data = array(qubit() for _ in range({num_data}))") + lines.append(f" for i in range({num_data}):") + lines.append(" h(data[i])") + lines.append(f" return SurfaceCode_{dx}x{dz}(data)") + lines.extend(["", ""]) - # Generate syndrome extraction with parallel CNOT schedule. + # Generate syndrome extraction with the selected parallel interaction schedule. rounds = compute_cnot_schedule(patch) + szz_sign_by_touch: dict[tuple[str, int, int], int] = {} + if interaction_basis == "szz": + residual_plan = _default_szz_residual_plan(patch) + szz_sign_by_touch = { + (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign + for entry in residual_plan.signs + } + + def _szz_ancilla_expr(stab_type: str, stab_idx: int) -> str: + return f"ax{stab_idx}" if stab_type == "X" else f"az{stab_idx}" + + def _append_szz_layer( + target: list[str], + indent: str, + rnd_idx: int, + layer_gates: list[tuple[str, int, int]], + ancilla_expr: Callable[[str, int], str], + data_expr: Callable[[int], str], + ) -> None: + target.append("") + target.append(f"{indent}# SZZ round {rnd_idx + 1}") + x_touches = [(stab_idx, data_q) for stab_type, stab_idx, data_q in layer_gates if stab_type == "X"] + for _stab_idx, data_q in x_touches: + target.append(f"{indent}h({data_expr(data_q)})") + + for stab_type, stab_idx, data_q in layer_gates: + sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + half_turns = "0.5" if sign > 0 else "-0.5" + target.append( + f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " + f"zz_phase({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)}, angle({half_turns}))", + ) + + for _stab_idx, data_q in x_touches: + target.append(f"{indent}h({data_expr(data_q)})") + + for stab_type, stab_idx, data_q in layer_gates: + sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + compensation = { + ("X", 1): "vdg", + ("X", -1): "v", + ("Z", 1): "sdg", + ("Z", -1): "s", + }[(stab_type, sign)] + target.append(f"{indent}{compensation}({data_expr(data_q)})") lines.extend( [ @@ -305,9 +399,15 @@ def generate_guppy_source( f") -> Syndrome_{dx}x{dz}:", ) else: - lines.append( - f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", - ) + if interaction_basis == "szz": + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, Syndrome_{dx}x{dz}]:", + ) + else: + lines.append( + f"def syndrome_extraction(surf: SurfaceCode_{dx}x{dz}) -> Syndrome_{dx}x{dz}:", + ) if not constrained: # Unconstrained: one ancilla per stabilizer, X-stabs first then @@ -319,26 +419,42 @@ def generate_guppy_source( " # Allocate ancilla qubits (one per stabilizer)", ], ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + if interaction_basis == "cx": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + + for rnd_idx, rnd_gates in enumerate(rounds): + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for stab_type, stab_idx, data_q in rnd_gates: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") - for rnd_idx, rnd_gates in enumerate(rounds): lines.append("") - lines.append(f" # Round {rnd_idx + 1}") - for stab_type, stab_idx, data_q in rnd_gates: - if stab_type == "X": - lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + else: + lines.append("") + lines.append(" # Hadamard on SZZ ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + lines.extend(f" h(az{stab.index})" for stab in geom.z_stabilizers) - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + for rnd_idx, rnd_gates in enumerate(rounds): + _append_szz_layer(lines, " ", rnd_idx, list(rnd_gates), _szz_ancilla_expr, _szz_data_expr) + + lines.append("") + lines.append(" # Hadamard on SZZ ancillas") + lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) + lines.extend(f" h(az{stab.index})" for stab in geom.z_stabilizers) lines.append("") lines.append(" # Measure ancillas") @@ -437,17 +553,13 @@ def generate_guppy_source( x_calls = ", ".join(f"sx{s.index}" for s in geom.x_stabilizers) z_calls = ", ".join(f"sz{s.index}" for s in geom.z_stabilizers) - lines.extend( - [ - "", - f" synx = array({x_calls})", - f" synz = array({z_calls})", - "", - f" return Syndrome_{dx}x{dz}(synx, synz)", - "", - "", - ], - ) + lines.extend(["", f" synx = array({x_calls})", f" synz = array({z_calls})", ""]) + if interaction_basis == "szz": + lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + lines.append(f" return surf, Syndrome_{dx}x{dz}(synx, synz)") + else: + lines.append(f" return Syndrome_{dx}x{dz}(synx, synz)") + lines.extend(["", ""]) def append_init_syndrome_function(function_name: str, stab_type: str) -> None: """Append a basis-prep syndrome-establishment helper.""" @@ -467,36 +579,66 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: "", "", "@guppy", - f"def {function_name}(surf: SurfaceCode_{dx}x{dz}) -> {return_type}:", + ( + f"def {function_name}(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, {return_type}]:" + if interaction_basis == "szz" + else f"def {function_name}(surf: SurfaceCode_{dx}x{dz}) -> {return_type}:" + ), f' """{doc}"""', ], ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") if not constrained: if stab_type == "X": lines.extend(f" ax{stab.index} = qubit()" for stab in stabs) - lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in stabs) else: lines.extend(f" az{stab.index} = qubit()" for stab in stabs) - for rnd_idx, rnd_gates in enumerate(rounds): - filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] - if not filtered: - continue + if interaction_basis == "cx": + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] + if not filtered: + continue + lines.append("") + lines.append(f" # Round {rnd_idx + 1}") + for _stab_type, stab_idx, data_q in filtered: + if stab_type == "X": + lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas") + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: lines.append("") - lines.append(f" # Round {rnd_idx + 1}") - for _stab_type, stab_idx, data_q in filtered: - if stab_type == "X": - lines.append(f" cx(ax{stab_idx}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], az{stab_idx})") + lines.append(" # Hadamard on SZZ ancillas") + if stab_type == "X": + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.extend(f" h(az{stab.index})" for stab in stabs) + + for rnd_idx, rnd_gates in enumerate(rounds): + filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] + if not filtered: + continue + _append_szz_layer(lines, " ", rnd_idx, filtered, _szz_ancilla_expr, _szz_data_expr) - if stab_type == "X": lines.append("") - lines.append(" # Hadamard on X ancillas") - lines.extend(f" h(ax{stab.index})" for stab in stabs) + lines.append(" # Hadamard on SZZ ancillas") + if stab_type == "X": + lines.extend(f" h(ax{stab.index})" for stab in stabs) + else: + lines.extend(f" h(az{stab.index})" for stab in stabs) lines.append("") lines.append(" # Measure init ancillas") @@ -561,12 +703,12 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: lines.append(f' result("{syn_var}:init:meas:{idx}", {syn_var})') idx += 1 - lines.extend( - [ - "", - f" return array({return_calls})", - ], - ) + lines.append("") + if interaction_basis == "szz": + lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + lines.append(f" return surf, array({return_calls})") + else: + lines.append(f" return array({return_calls})") append_init_syndrome_function("init_z_basis", "X") append_init_syndrome_function("init_x_basis", "Z") @@ -579,19 +721,33 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: "@guppy", f"def measure_z_basis(surf: SurfaceCode_{dx}x{dz} @ owned) -> array[bool, {num_data}]:", ' """Destructively measure in Z basis."""', - " return measure_array(surf.data)", + ], + ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") + z_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) + lines.append(f" return array({z_meas})") + else: + lines.append(" return measure_array(surf.data)") + lines.extend( + [ "", "", "@guppy", f"def measure_x_basis(surf: SurfaceCode_{dx}x{dz} @ owned) -> array[bool, {num_data}]:", ' """Destructively measure in X basis."""', - f" for i in range({num_data}):", - " h(surf.data[i])", - " return measure_array(surf.data)", - "", - "", ], ) + if interaction_basis == "szz": + _append_szz_data_unpack(lines, " ") + lines.extend(f" h(d{i})" for i in range(num_data)) + x_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) + lines.append(f" return array({x_meas})") + else: + lines.append(f" for i in range({num_data}):") + lines.append(" h(surf.data[i])") + lines.append(" return measure_array(surf.data)") + lines.extend(["", ""]) # Generate logical operators logical_x_qubits = list(geom.logical_x.data_qubits) if geom.logical_x else [] @@ -606,7 +762,10 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ' """Apply logical X (string along left edge)."""', ], ) - lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) + if interaction_basis == "szz": + lines.extend(f" x(surf.d{q})" for q in logical_x_qubits) + else: + lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) lines.extend( [ @@ -619,7 +778,10 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: "", ], ) - lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) + if interaction_basis == "szz": + lines.extend(f" z(surf.d{q})" for q in logical_z_qubits) + else: + lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) lines.extend( [ @@ -629,7 +791,7 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) # Generate memory experiment factories - lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds)) + lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds, interaction_basis)) return "\n".join(lines) @@ -653,6 +815,7 @@ def _render_memory_experiments( twirl: "TwirlConfig | None", rng: "GuppyRngMaskConfig | None", num_rounds: int | None, + interaction_basis: str, ) -> list[str]: """Render both memory factory functions.""" lines = [ @@ -661,7 +824,7 @@ def _render_memory_experiments( ] for basis, basis_upper in (("z", "Z"), ("x", "X")): if twirl is None: - lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz)) + lines.extend(_render_plain_memory_block(basis, basis_upper, dx, dz, interaction_basis)) else: if rng is None or num_rounds is None: msg = "twirled memory rendering requires both rng and num_rounds" @@ -701,10 +864,17 @@ def _render_plain_memory_block( basis_upper: str, dx: int, dz: int, + interaction_basis: str, ) -> list[str]: """Render the vanilla handoff memory factory for one basis.""" init_func = "init_z_basis" if basis == "z" else "init_x_basis" init_tag = "init_synx" if basis == "z" else "init_synz" + if interaction_basis == "szz": + init_line = f" surf, init_syn = {init_func}(surf)" + syndrome_line = " surf, syn = syndrome_extraction(surf)" + else: + init_line = f" init_syn = {init_func}(surf)" + syndrome_line = " syn = syndrome_extraction(surf)" return [ f"def make_memory_{basis}(num_rounds: int):", f' """Create {basis_upper}-basis memory experiment."""', @@ -714,11 +884,11 @@ def _render_plain_memory_block( f" def memory_{basis}() -> None:", f' """{basis_upper}-basis memory experiment for dx={dx}, dz={dz}."""', f" surf = prep_{basis}_basis()", - f" init_syn = {init_func}(surf)", + init_line, f' result("{init_tag}", init_syn)', "", " for _t in range(comptime(num_rounds)):", - " syn = syndrome_extraction(surf)", + syndrome_line, ' result("synx", syn.synx)', ' result("synz", syn.synz)', "", @@ -1277,6 +1447,7 @@ def _guppy_module_cache_key( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, + interaction_basis: str = "cx", ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1291,7 +1462,9 @@ def _guppy_module_cache_key( """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" - base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}" + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" + base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}{interaction_part}" if twirl is None: return base if rng is None or num_rounds is None: @@ -1313,6 +1486,7 @@ def _load_guppy_module( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, + interaction_basis: str = "cx", ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1330,16 +1504,25 @@ def _load_guppy_module( twirl: Pauli-twirl-site declaration (structural) rng: Runtime mask RNG seed (must be supplied with ``twirl``) num_rounds: Syndrome-round count for unrolled twirled source. + interaction_basis: Surface-memory interaction basis. Returns: Module dictionary with generated functions """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = _guppy_module_cache_key(patch, effective_budget, twirl, rng, num_rounds) + cache_key = _guppy_module_cache_key( + patch, + effective_budget, + twirl, + rng, + num_rounds, + interaction_basis, + ) if cache_key in _state.module_cache: return _state.module_cache[cache_key] @@ -1350,6 +1533,7 @@ def _load_guppy_module( twirl=twirl, rng=rng, num_rounds=num_rounds, + interaction_basis=interaction_basis, ) # Write to temp file (required for Guppy introspection). @@ -1380,6 +1564,7 @@ def generate_memory_experiment( ancilla_budget: int | None = None, twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, + interaction_basis: str = "cx", ) -> object: """Generate a memory experiment for a patch. @@ -1390,6 +1575,7 @@ def generate_memory_experiment( ancilla_budget: Optional cap on simultaneously live ancillas twirl: Pauli-twirl-site declaration; must be supplied with ``rng``. rng: Runtime mask RNG seed; must be supplied with ``twirl``. + interaction_basis: Surface-memory interaction basis. Returns: Guppy function for the experiment @@ -1400,6 +1586,7 @@ def generate_memory_experiment( twirl=twirl, rng=rng, num_rounds=num_rounds if twirl is not None else None, + interaction_basis=interaction_basis, ) if basis.upper() == "Z": @@ -1419,6 +1606,7 @@ def get_num_qubits( patch: "SurfacePatch | None" = None, ancilla_budget: int | None = None, twirl: "TwirlConfig | None" = None, + interaction_basis: str = "cx", ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -1444,6 +1632,7 @@ def get_num_qubits( """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) if (d is None) == (patch is None): msg = "get_num_qubits requires exactly one of d=... or patch=..." raise ValueError(msg) @@ -1457,17 +1646,30 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 + if interaction_basis == "szz" and normalize_ancilla_budget(total_ancilla, ancilla_budget) < total_ancilla: + msg = "interaction_basis='szz' does not yet support constrained ancilla budgets on the Guppy runtime path" + raise ValueError(msg) + if interaction_basis == "szz" and twirl is not None: + msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" + raise ValueError(msg) + twirl_entropy_qubits = 1 if twirl is not None else 0 return num_data + normalize_ancilla_budget(total_ancilla, ancilla_budget) + twirl_entropy_qubits -def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> str: +def generate_surface_code_module( + d: int, + *, + ancilla_budget: int | None = None, + interaction_basis: str = "cx", +) -> str: """Generate source code for a distance-d surface code module. Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas; forwarded to ``generate_guppy_source``. + interaction_basis: Surface-memory interaction basis. Returns: Python/Guppy source code as a string @@ -1477,10 +1679,19 @@ def generate_surface_code_module(d: int, *, ancilla_budget: int | None = None) - from pecos.qec.surface import SurfacePatch patch = SurfacePatch.create(distance=d) - return generate_guppy_source(patch, ancilla_budget=ancilla_budget) + return generate_guppy_source( + patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ) -def _surface_code_module_for_patch(patch: "SurfacePatch", *, ancilla_budget: int | None = None) -> dict: +def _surface_code_module_for_patch( + patch: "SurfacePatch", + *, + ancilla_budget: int | None = None, + interaction_basis: str = "cx", +) -> dict: """Load + cache a surface-code module for an arbitrary patch. Cache key spans full patch identity (dx, dz, orientation, rotated) plus @@ -1491,32 +1702,44 @@ def _surface_code_module_for_patch(patch: "SurfacePatch", *, ancilla_budget: int """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + interaction_basis = _normalize_surface_interaction_basis(interaction_basis) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = (patch.dx, patch.dz, geom.orientation.name, geom.rotated, effective_budget) + cache_key = (patch.dx, patch.dz, geom.orientation.name, geom.rotated, effective_budget, interaction_basis) if cache_key in _state.distance_module_cache: return _state.distance_module_cache[cache_key] - module = _load_guppy_module(patch, ancilla_budget=ancilla_budget) + module = _load_guppy_module( + patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ) # Metadata derived from the actual patch geometry. module["distance"] = patch.distance module["num_data"] = geom.num_data module["num_stab"] = total_ancilla module["ancilla_budget"] = effective_budget + module["interaction_basis"] = interaction_basis _state.distance_module_cache[cache_key] = module return module -def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dict: +def get_surface_code_module( + d: int, + *, + ancilla_budget: int | None = None, + interaction_basis: str = "cx", +) -> dict: """Get a loaded surface code module for distance d. Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas + interaction_basis: Surface-memory interaction basis. Returns: Dictionary with module contents and metadata @@ -1525,7 +1748,11 @@ def get_surface_code_module(d: int, *, ancilla_budget: int | None = None) -> dic _validate_surface_memory_distance(d) patch = SurfacePatch.create(distance=d) - return _surface_code_module_for_patch(patch, ancilla_budget=ancilla_budget) + return _surface_code_module_for_patch( + patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ) def make_surface_code( @@ -1534,6 +1761,7 @@ def make_surface_code( basis: str, *, ancilla_budget: int | None = None, + interaction_basis: str = "cx", ) -> object: """Create a surface code memory experiment. @@ -1546,6 +1774,7 @@ def make_surface_code( a finite budget emits a stabilizer-batched program that matches the abstract circuit's ``batched_stabilizers(patch, effective_budget)`` schedule. + interaction_basis: Surface-memory interaction basis. Returns: Compiled Guppy program @@ -1554,7 +1783,11 @@ def make_surface_code( msg = f"basis must be 'Z' or 'X', got {basis!r}" raise ValueError(msg) - module = get_surface_code_module(distance, ancilla_budget=ancilla_budget) + module = get_surface_code_module( + distance, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 69893a3ef..549bcb2f9 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -1333,6 +1333,8 @@ def render( patch: SurfacePatch, _num_rounds: int, _basis: str, + *, + interaction_basis: str = "cx", ) -> str: """Render to Guppy source code. @@ -1347,7 +1349,7 @@ def render( from pecos.guppy.surface import generate_guppy_source # Use the canonical Guppy generator to ensure identical output - return generate_guppy_source(patch) + return generate_guppy_source(patch, interaction_basis=interaction_basis) class DagCircuitRenderer(CircuitRenderer): @@ -2178,6 +2180,8 @@ def generate_guppy_from_patch( patch: SurfacePatch, _num_rounds: int = 1, _basis: str = "Z", + *, + interaction_basis: str = "cx", ) -> str: """Generate Guppy code from SurfacePatch. @@ -2193,13 +2197,14 @@ def generate_guppy_from_patch( patch: Surface code patch _num_rounds: Unused (factory functions accept this at runtime) _basis: Unused (module includes both Z and X basis functions) + interaction_basis: Surface-memory two-qubit interaction basis. Returns: Guppy source code string (full module) """ from pecos.guppy.surface import generate_guppy_source - return generate_guppy_source(patch) + return generate_guppy_source(patch, interaction_basis=interaction_basis) def generate_dag_circuit_from_patch( diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 9a46ac76f..ac003292c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1284,6 +1284,7 @@ def _generate_traced_surface_tick_circuit( basis: str, *, ancilla_budget: int | None = None, + interaction_basis: str = "cx", runtime: object | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -1305,6 +1306,7 @@ def _generate_traced_surface_tick_circuit( num_rounds, basis, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, runtime=runtime, ) return tc @@ -1316,6 +1318,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( basis: str, *, ancilla_budget: int | None = None, + interaction_basis: str = "cx", runtime: object | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" @@ -1326,10 +1329,15 @@ def _generate_traced_surface_tick_circuit_with_result_traces( num_rounds, basis, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) return trace_guppy_into_tick_circuit_with_result_traces( program, - get_num_qubits(patch=patch, ancilla_budget=ancilla_budget), + get_num_qubits( + patch=patch, + ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, + ), seed=0, runtime=runtime, ) @@ -1352,12 +1360,6 @@ def _build_surface_tick_circuit_for_native_model( if twirl is not None: twirl.validate_runtime_supported() interaction_basis = _normalize_interaction_basis(interaction_basis) - if interaction_basis != "cx" and circuit_source == "traced_qis": - msg = ( - "interaction_basis='szz' is not supported with circuit_source='traced_qis' " - "until the Guppy emitter supports that basis" - ) - raise ValueError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1384,6 +1386,7 @@ def _build_surface_tick_circuit_for_native_model( num_rounds, basis, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, runtime=runtime, ) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 4799ef520..dfe3036a0 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -35,6 +35,53 @@ def test_no_twirl_source_has_no_rng_or_mask_tags(patch: SurfacePatch) -> None: assert 'result("final"' in src +def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz") + + assert "from guppylang.std.angles import angle" in src + assert "from guppylang.std.qsystem.functional import zz_phase" in src + assert "zz_phase(" in src + assert "angle(0.5)" in src + assert "angle(-0.5)" in src + assert "cx(" not in src + assert "h(az" in src + assert "vdg(d" in src + assert "sdg(d" in src + + +def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="constrained ancilla budgets"): + generate_guppy_source(patch, interaction_basis="szz", ancilla_budget=1) + + with pytest.raises(ValueError, match="twirl integration is staged later"): + generate_guppy_source( + patch, + interaction_basis="szz", + twirl=TwirlConfig(), + rng=GuppyRngMaskConfig(seed=42), + num_rounds=2, + ) + + +def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: + cx_key = _guppy_module_cache_key(patch, effective_budget=8) + szz_key = _guppy_module_cache_key(patch, effective_budget=8, interaction_basis="szz") + + assert cx_key != szz_key + assert "_ibcx" not in cx_key + assert "_ibszz" in szz_key + + +def test_szz_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=2, + basis="Z", + interaction_basis="szz", + ) + assert fn is not None + + def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index b530e911f..6ffece8cf 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -296,7 +296,7 @@ def test_szz_direct_renderers_accept_interaction_basis() -> None: assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names -def test_szz_detector_paths_accept_abstract_basis_and_reject_traced_qis() -> None: +def test_szz_detector_paths_accept_abstract_and_traced_qis_basis() -> None: patch = SurfacePatch.create(distance=3) stim_text = generate_stim_from_patch(patch, num_rounds=1, interaction_basis="szz") @@ -308,13 +308,14 @@ def test_szz_detector_paths_accept_abstract_basis_and_reject_traced_qis() -> Non memory_circuit = build_memory_circuit(patch=patch, rounds=1, interaction_basis="szz") assert int(memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) - with pytest.raises(ValueError, match="circuit_source='traced_qis'"): - build_memory_circuit( - patch=patch, - rounds=1, - circuit_source="traced_qis", - interaction_basis="szz", - ) + traced_memory_circuit = build_memory_circuit( + patch=patch, + rounds=1, + circuit_source="traced_qis", + interaction_basis="szz", + ) + assert traced_memory_circuit.get_meta("circuit_source") == "traced_qis" + assert int(traced_memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) @pytest.mark.parametrize("distance", [3, 5]) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 4b76c3acd..e2200c31a 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -4,6 +4,7 @@ """Regression tests for the Guppy-to-DEM convenience path.""" import json +from typing import ClassVar import pytest from guppylang import guppy @@ -495,6 +496,37 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: assert got == ref_dem, f"surface from_guppy not byte-identical ({basis})" +def test_from_guppy_szz_surface_code_is_byte_identical_to_reference() -> None: + """SZZ-basis surface Guppy generation must match the traced-QIS reference DEM.""" + p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + for basis in ("Z", "X"): + patch = SurfacePatch.create(distance=3) + ref = _build_surface_tick_circuit_for_native_model( + patch, + 3, + basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + ref.lower_clifford_rotations() + ref.assign_missing_meas_ids() + ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() + got = DetectorErrorModel.from_guppy( + make_surface_code( + distance=3, + num_rounds=3, + basis=basis, + interaction_basis="szz", + ), + num_qubits=get_num_qubits(3, interaction_basis="szz"), + detectors_json=ref.get_meta("detectors"), + observables_json=ref.get_meta("observables"), + num_measurements=int(ref.get_meta("num_measurements")), + **p, + ).to_string() + assert got == ref_dem, f"SZZ surface from_guppy not byte-identical ({basis})" + + def test_from_guppy_out_of_range_record_fails_loud() -> None: with pytest.raises(ValueError, match=r"out of range|record offset"): _dem_text(detectors_json='[{"id":0,"records":[-2]}]') # only 1 measurement @@ -1084,8 +1116,8 @@ def test_result_tag_remap_validation_rejects_unbound_traced_meas_ids() -> None: def test_result_tag_remap_validation_rejects_unstamped_measurements() -> None: class FakeGate: gate_type = "MZ" - qubits = [0] - meas_ids: list[int] = [] + qubits: ClassVar[list[int]] = [0] + meas_ids: ClassVar[list[int]] = [] class FakeTick: def gate_batches(self): From 4e09ba87799a72f2d81678f9c3c57176fb40a713 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 20:45:06 -0600 Subject: [PATCH 123/388] Tighten SZZ Guppy review coverage --- python/quantum-pecos/src/pecos/guppy/surface.py | 15 +++++++++------ .../tests/guppy/test_surface_twirl_render.py | 2 ++ .../tests/qec/test_from_guppy_dem.py | 11 ++++++----- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index ce397954a..9a33a75cf 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -246,7 +246,7 @@ def generate_guppy_source( "from guppylang.std.angles import angle", "from guppylang.std.builtins import array, owned, result", "from guppylang.std.qsystem.functional import zz_phase", - "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x", + "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, z", ] else: imports = [ @@ -254,7 +254,7 @@ def generate_guppy_source( "", "from guppylang import guppy", "from guppylang.std.builtins import array, owned, result", - "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x", + "from guppylang.std.quantum import cx, discard, h, measure, measure_array, qubit, x, z", ] lines = [ @@ -415,13 +415,18 @@ def _append_szz_layer( # abstract circuit's unconstrained-path measurement order. lines.extend( [ - ' """Extract full syndrome using 4-round parallel CNOT schedule."""', - " # Allocate ancilla qubits (one per stabilizer)", + ( + ' """Extract full syndrome using 4-round parallel SZZ/SZZdg schedule."""' + if interaction_basis == "szz" + else ' """Extract full syndrome using 4-round parallel CNOT schedule."""' + ), ], ) if interaction_basis == "szz": + lines.append(" # Unpack data qubits") _append_szz_data_unpack(lines, " ") + lines.append(" # Allocate ancilla qubits (one per stabilizer)") lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) @@ -774,8 +779,6 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: "@guppy", f"def apply_logical_z(surf: SurfaceCode_{dx}x{dz}) -> None:", ' """Apply logical Z (string along top edge)."""', - " from guppylang.std.quantum import z", - "", ], ) if interaction_basis == "szz": diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index dfe3036a0..f219264b5 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -46,7 +46,9 @@ def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: assert "cx(" not in src assert "h(az" in src assert "vdg(d" in src + assert "v(d" in src assert "sdg(d" in src + assert "s(d" in src def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> None: diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index e2200c31a..b461b650c 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -496,11 +496,12 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: assert got == ref_dem, f"surface from_guppy not byte-identical ({basis})" -def test_from_guppy_szz_surface_code_is_byte_identical_to_reference() -> None: +@pytest.mark.parametrize("distance", [3, 5]) +def test_from_guppy_szz_surface_code_is_byte_identical_to_reference(distance: int) -> None: """SZZ-basis surface Guppy generation must match the traced-QIS reference DEM.""" p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} for basis in ("Z", "X"): - patch = SurfacePatch.create(distance=3) + patch = SurfacePatch.create(distance=distance) ref = _build_surface_tick_circuit_for_native_model( patch, 3, @@ -513,18 +514,18 @@ def test_from_guppy_szz_surface_code_is_byte_identical_to_reference() -> None: ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() got = DetectorErrorModel.from_guppy( make_surface_code( - distance=3, + distance=distance, num_rounds=3, basis=basis, interaction_basis="szz", ), - num_qubits=get_num_qubits(3, interaction_basis="szz"), + num_qubits=get_num_qubits(distance, interaction_basis="szz"), detectors_json=ref.get_meta("detectors"), observables_json=ref.get_meta("observables"), num_measurements=int(ref.get_meta("num_measurements")), **p, ).to_string() - assert got == ref_dem, f"SZZ surface from_guppy not byte-identical ({basis})" + assert got == ref_dem, f"SZZ surface from_guppy not byte-identical (d={distance}, {basis})" def test_from_guppy_out_of_range_record_fails_loud() -> None: From 8ccad982fd904024d34f6caf780df856ffe26e9c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 20:55:09 -0600 Subject: [PATCH 124/388] Map pecos-qec PerGateTypeNoise to a neo PerGatePauliChannel via to_neo_channel, with a derived 2-qubit Pauli permutation and a loud guard rejecting idle config the DEM would include --- Cargo.lock | 1 + crates/pecos-qec/Cargo.toml | 11 + .../src/fault_tolerance/dem_builder/types.rs | 103 ++++ .../tests/per_gate_neo_mapping_tests.rs | 236 ++++++++ exp/pecos-neo/src/noise.rs | 2 + exp/pecos-neo/src/noise/per_gate_pauli.rs | 523 ++++++++++++++++++ 6 files changed, 876 insertions(+) create mode 100644 crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs create mode 100644 exp/pecos-neo/src/noise/per_gate_pauli.rs diff --git a/Cargo.lock b/Cargo.lock index 07565ca62..3e7c4d397 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4155,6 +4155,7 @@ dependencies = [ "ndarray 0.17.2", "pecos-core", "pecos-decoder-core", + "pecos-neo", "pecos-num", "pecos-quantum", "pecos-random", diff --git a/crates/pecos-qec/Cargo.toml b/crates/pecos-qec/Cargo.toml index 30a043360..e11bfaf2c 100644 --- a/crates/pecos-qec/Cargo.toml +++ b/crates/pecos-qec/Cargo.toml @@ -19,6 +19,8 @@ pecos-num.workspace = true pecos-quantum.workspace = true pecos-simulators.workspace = true pecos-random.workspace = true +# Optional: PerGateTypeNoise -> neo PerGatePauliChannel conversion +pecos-neo = { workspace = true, optional = true } rand.workspace = true rand_core.workspace = true rayon.workspace = true @@ -27,6 +29,15 @@ smallvec.workspace = true thiserror.workspace = true wide.workspace = true +[features] +# Convert PerGateTypeNoise to the pecos-neo noise channel for +# circuit-level Monte Carlo with the same noise that drives DEM export. +neo = ["dep:pecos-neo"] + +[[test]] +name = "per_gate_neo_mapping_tests" +required-features = ["neo"] + [[example]] name = "surface_d3_fault_catalog_lookup" path = "../../examples/surface/d3_fault_catalog_lookup.rs" diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 3deda7745..36ec6de8a 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2235,6 +2235,109 @@ impl PerGateTypeNoise { } } +/// Permutation from [`PAULI_2Q_ORDER`] indices to pecos-neo's +/// `TWO_QUBIT_PAULIS` ordering, derived from the two canonical constants +/// so it cannot silently drift. +#[cfg(feature = "neo")] +fn qec_to_neo_2q_rates(qec_rates: &[f64; 15]) -> [f64; 15] { + fn pauli_label(gate: pecos_neo::command::GateType) -> &'static str { + match gate { + pecos_neo::command::GateType::I => "I", + pecos_neo::command::GateType::X => "X", + pecos_neo::command::GateType::Y => "Y", + pecos_neo::command::GateType::Z => "Z", + other => unreachable!("TWO_QUBIT_PAULIS contains only Paulis, got {other:?}"), + } + } + + let mut neo_rates = [0.0; 15]; + for (neo_idx, &(first, second)) in pecos_neo::noise::TWO_QUBIT_PAULIS.iter().enumerate() { + let label = format!("{}{}", pauli_label(first), pauli_label(second)); + let qec_idx = PAULI_2Q_ORDER + .iter() + .position(|&entry| entry == label) + .expect("every non-identity Pauli pair appears in PAULI_2Q_ORDER"); + neo_rates[neo_idx] = qec_rates[qec_idx]; + } + neo_rates +} + +#[cfg(feature = "neo")] +impl PerGateTypeNoise { + /// Convert to a pecos-neo [`PerGatePauliChannel`] so circuit-level + /// Monte Carlo can run the same noise that drives DEM generation. + /// + /// Gate types translate via the canonical `From` impl between the two + /// `GateType` enums; two-qubit rate arrays are permuted from + /// [`PAULI_2Q_ORDER`] into neo's `TWO_QUBIT_PAULIS` ordering. + /// + /// The neo stack models idle noise through its `IdleChannel`/time + /// events, not gate events, so idle noise cannot be carried by this + /// conversion. Rather than silently dropping noise that the DEM built + /// from the same configuration WOULD include, any idle configuration + /// is rejected: compose a neo `IdleChannel` explicitly and zero the + /// idle settings here. + /// + /// # Panics + /// + /// Panics if the configuration carries idle noise in any form: + /// `Idle` entries in the per-gate or per-qubit rate maps, or a base + /// `NoiseConfig` with nonzero `p_idle`/`idle_rz` or `t1`/`t2` set. + /// + /// [`PerGatePauliChannel`]: pecos_neo::noise::PerGatePauliChannel + #[must_use] + pub fn to_neo_channel(&self) -> pecos_neo::noise::PerGatePauliChannel { + use pecos_neo::command::GateType as NeoGateType; + + let has_idle_entries = self.rates_1q.contains_key(&GateType::Idle) + || self + .rates_1q_per_qubit + .keys() + .any(|&(gate, _)| gate == GateType::Idle); + assert!( + !has_idle_entries + && self.base.p_idle == 0.0 + && self.base.idle_rz == 0.0 + && self.base.t1.is_none() + && self.base.t2.is_none(), + "PerGateTypeNoise::to_neo_channel cannot carry idle noise (the neo stack models \ + idle through IdleChannel/time events, not gate events); the DEM built from this \ + configuration would include idle contributions, so converting would silently \ + change the physics. Zero p_idle/idle_rz/t1/t2 and remove Idle rate entries, then \ + compose a neo IdleChannel explicitly if idle noise is needed." + ); + + let mut channel = pecos_neo::noise::PerGatePauliChannel::new() + .with_base(self.base.p1, self.base.p2) + .with_meas_init(self.p_meas, self.p_init); + + for (&gate, &rates) in &self.rates_1q { + channel = channel.with_1q_rates(NeoGateType::from(gate), rates); + } + for (&gate, &rates) in &self.rates_2q { + channel = channel.with_2q_rates(NeoGateType::from(gate), qec_to_neo_2q_rates(&rates)); + } + for (&(gate, qubit), &rates) in &self.rates_1q_per_qubit { + channel = channel.with_1q_rates_for_qubit(NeoGateType::from(gate), qubit, rates); + } + for (&(gate, q_control, q_target), &rates) in &self.rates_2q_per_qubits { + channel = channel.with_2q_rates_for_qubits( + NeoGateType::from(gate), + q_control, + q_target, + qec_to_neo_2q_rates(&rates), + ); + } + for (&qubit, &p) in &self.measurement_rates { + channel = channel.with_meas_rate_for_qubit(qubit, p); + } + for (&qubit, &p) in &self.init_rates { + channel = channel.with_init_rate_for_qubit(qubit, p); + } + channel + } +} + // ============================================================================ // Measurement Noise Model (MNM) // ============================================================================ diff --git a/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs b/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs new file mode 100644 index 000000000..1dd617271 --- /dev/null +++ b/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs @@ -0,0 +1,236 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Differential tests for `PerGateTypeNoise::to_neo_channel`. +//! +//! Each test configures noise through the pecos-qec type (qec orderings +//! and conventions), converts, runs circuit-level Monte Carlo on the neo +//! stack, and checks the measured rates against the analytic values the +//! qec configuration implies. The two-qubit cells are chosen to fail +//! loudly if the `PAULI_2Q_ORDER` -> `TWO_QUBIT_PAULIS` permutation or +//! the qubit-pair orientation ever drifts. + +#![cfg(feature = "neo")] + +use pecos_core::QubitId; +use pecos_core::gate_type::GateType; +use pecos_neo::prelude::*; +use pecos_qec::fault_tolerance::dem_builder::{NoiseConfig, PerGateTypeNoise}; +use pecos_simulators::SparseStab; + +const SHOTS: usize = 20_000; + +/// Rate of outcome-1 on one qubit over `SHOTS` runs of a circuit. +#[allow(clippy::cast_precision_loss)] +fn one_rate(noise: &PerGateTypeNoise, commands: &CommandQueue, qubit: usize) -> f64 { + let model = ComposableNoiseModel::new().add_channel(noise.to_neo_channel()); + let mut state = SparseStab::new(2); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let qubits = [QubitId(qubit)]; + let mut ones = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, commands).unwrap(); + if let Some(bits) = outcomes.bitstring(&qubits) + && bits[0] + { + ones += 1; + } + } + ones as f64 / SHOTS as f64 +} + +#[allow(clippy::cast_precision_loss)] +fn five_sigma(p: f64) -> f64 { + 5.0 * (p * (1.0 - p) / SHOTS as f64).sqrt() +} + +/// Index of a Pauli-pair label in qec's `PAULI_2Q_ORDER`. +fn qec_2q_index(label: &str) -> usize { + pecos_qec::fault_tolerance::dem_builder::PAULI_2Q_ORDER + .iter() + .position(|&entry| entry == label) + .expect("valid Pauli pair label") +} + +#[test] +fn per_gate_1q_rates_map_with_analytic_flip_rate() { + // 30% X-error on X gates (qec [X, Y, Z] ordering): the injected X + // cancels the gate, so P(outcome = 0) = 0.3. + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_1q_rates(GateType::X, [0.3, 0.0, 0.0]); + + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let rate = 1.0 - one_rate(&noise, &commands, 0); + assert!( + (rate - 0.3).abs() < five_sigma(0.3), + "1q gate rate: got flip rate {rate}, expected 0.3" + ); +} + +#[test] +fn per_qubit_1q_rates_override_per_gate() { + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_1q_rates(GateType::X, [0.3, 0.0, 0.0]) + .with_1q_rates_for_qubit(GateType::X, QubitId(0), [0.0; 3]); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .x(&[0]) + .x(&[1]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let flip0 = 1.0 - one_rate(&noise, &commands, 0); + let flip1 = 1.0 - one_rate(&noise, &commands, 1); + assert!( + flip0.abs() < f64::EPSILON, + "qubit 0 override must be noiseless, got {flip0}" + ); + assert!( + (flip1 - 0.3).abs() < five_sigma(0.3), + "qubit 1 keeps the per-gate rate: got {flip1}, expected 0.3" + ); +} + +#[test] +fn qec_2q_ordering_is_permuted_correctly() { + // Configure, IN QEC ORDERING, a 25% "IX" error on CX: identity on the + // first (control) qubit, X on the second (target). If the permutation + // into neo's ordering drifted, the error would land on the wrong + // Pauli pair and the wrong qubit would flip. + let mut rates = [0.0; 15]; + rates[qec_2q_index("IX")] = 0.25; + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, rates); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .cx(&[(0, 1)]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = one_rate(&noise, &commands, 0); + let rate1 = one_rate(&noise, &commands, 1); + assert!( + rate0.abs() < f64::EPSILON, + "control qubit must be untouched by IX, got {rate0}" + ); + assert!( + (rate1 - 0.25).abs() < five_sigma(0.25), + "target qubit must flip at the IX rate, got {rate1}" + ); +} + +#[test] +fn qec_2q_per_pair_rates_override_per_gate() { + // Per-gate: 20% "XI" (control flips). Per-pair override for (0, 1): + // 20% "IX" (target flips). The override must win for that pair. + let mut gate_rates = [0.0; 15]; + gate_rates[qec_2q_index("XI")] = 0.2; + let mut pair_rates = [0.0; 15]; + pair_rates[qec_2q_index("IX")] = 0.2; + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, gate_rates) + .with_2q_rates_for_qubits(GateType::CX, QubitId(0), QubitId(1), pair_rates); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .cx(&[(0, 1)]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = one_rate(&noise, &commands, 0); + let rate1 = one_rate(&noise, &commands, 1); + assert!( + rate0.abs() < f64::EPSILON, + "pair override replaces the per-gate XI error, got control rate {rate0}" + ); + assert!( + (rate1 - 0.2).abs() < five_sigma(0.2), + "pair override applies IX to the target: got {rate1}, expected 0.2" + ); +} + +#[test] +fn measurement_and_init_rates_map_with_per_qubit_overrides() { + // p_meas/p_init are seeded from the base config's p_meas/p_prep. + let base = NoiseConfig { + p_meas: 0.1, + ..NoiseConfig::uniform(0.0) + }; + let noise = PerGateTypeNoise::from_base_noise(base).with_measurement_rate(QubitId(1), 0.4); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = one_rate(&noise, &commands, 0); + let rate1 = one_rate(&noise, &commands, 1); + assert!( + (rate0 - 0.1).abs() < five_sigma(0.1), + "default meas rate: got {rate0}, expected 0.1" + ); + assert!( + (rate1 - 0.4).abs() < five_sigma(0.4), + "per-qubit meas rate: got {rate1}, expected 0.4" + ); +} + +#[test] +fn base_noise_back_fills_unlisted_gates() { + // base p1 = 0.3 -> uniform per-Pauli 0.1; X and Y flip the outcome + // after an X gate: P(outcome = 1) = 0.8. + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig { + p1: 0.3, + ..NoiseConfig::uniform(0.0) + }); + + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let rate = one_rate(&noise, &commands, 0); + assert!( + (rate - 0.8).abs() < five_sigma(0.8), + "base fallback: got outcome-1 rate {rate}, expected 0.8" + ); +} + +#[test] +#[should_panic(expected = "cannot carry idle noise")] +fn idle_configuration_is_rejected_not_dropped() { + // NoiseConfig::uniform sets p_idle = p, and the DEM built from this + // config includes idle contributions; silently dropping them in the + // conversion would change the physics. + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig { + p_idle: 0.001, + ..NoiseConfig::uniform(0.0) + }); + let _ = noise.to_neo_channel(); +} + +#[test] +#[should_panic(expected = "cannot carry idle noise")] +fn idle_gate_entries_are_rejected_not_dropped() { + let noise = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_1q_rates(GateType::Idle, [0.001, 0.0, 0.0]); + let _ = noise.to_neo_channel(); +} diff --git a/exp/pecos-neo/src/noise.rs b/exp/pecos-neo/src/noise.rs index 7bf280ffb..63f4f0e38 100644 --- a/exp/pecos-neo/src/noise.rs +++ b/exp/pecos-neo/src/noise.rs @@ -111,6 +111,7 @@ pub mod introspection; pub mod leakage; pub mod measurement; pub mod patterns; +pub mod per_gate_pauli; pub mod plugin; pub mod plugins; pub mod prelude; @@ -132,6 +133,7 @@ pub use general_builder::{GeneralNoiseModelBuilder, general_noise}; pub use idle::IdleChannel; pub use leakage::LeakageChannel; pub use measurement::MeasurementChannel; +pub use per_gate_pauli::PerGatePauliChannel; pub use plugin::{ContextObserver, EventHandler, NoiseModelConfig, NoisePlugin}; pub use preparation::PreparationChannel; pub use single_qubit::SingleQubitChannel; diff --git a/exp/pecos-neo/src/noise/per_gate_pauli.rs b/exp/pecos-neo/src/noise/per_gate_pauli.rs new file mode 100644 index 000000000..cc0bdb888 --- /dev/null +++ b/exp/pecos-neo/src/noise/per_gate_pauli.rs @@ -0,0 +1,523 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Per-gate-type, optionally per-qubit Pauli noise. +//! +//! Mirrors pecos-qec's `PerGateTypeNoise` layered lookup so circuit-level +//! Monte Carlo on this stack can run the same noise that drives DEM +//! generation: +//! +//! ```text +//! 1. per-(gate, qubit) rates // most specific +//! 2. per-gate-type rates +//! 3. base p1/3 (or p2/15) uniform // fallback +//! ``` +//! +//! Rates are absolute per-Pauli probabilities (NOT normalized weights): +//! a `[f64; 3]` entry is `[P(X), P(Y), P(Z)]` and the gate's total error +//! probability is the sum. Two-qubit arrays follow [`TWO_QUBIT_PAULIS`] +//! ordering. Idle noise is out of scope here — compose with +//! [`super::idle::IdleChannel`] for T1/T2 effects. + +use super::{NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse, TWO_QUBIT_PAULIS}; +use crate::command::{GateCommand, GateType}; +use pecos_core::QubitId; +use pecos_random::PecosRng; +use rand::RngExt; +use smallvec::SmallVec; +use std::collections::BTreeMap; + +/// Per-gate-type Pauli channel with per-qubit overrides. +/// +/// See the module docs for the lookup order and rate conventions. +#[derive(Debug, Clone, Default)] +pub struct PerGatePauliChannel { + rates_1q: BTreeMap, + rates_2q: BTreeMap, + rates_1q_per_qubit: BTreeMap<(GateType, QubitId), [f64; 3]>, + rates_2q_per_qubits: BTreeMap<(GateType, QubitId, QubitId), [f64; 15]>, + measurement_rates: BTreeMap, + init_rates: BTreeMap, + p_meas: f64, + p_init: f64, + base_p1: f64, + base_p2: f64, +} + +fn assert_probability_vector(rates: &[f64], what: &str) { + let total: f64 = rates.iter().sum(); + assert!( + rates.iter().all(|&r| r >= 0.0) && total <= 1.0 + 1e-9, + "{what} must be non-negative probabilities summing to at most 1.0, got total {total}" + ); +} + +impl PerGatePauliChannel { + /// Create an empty channel: no per-gate entries, zero base rates. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set the uniform base rates used for gates with no explicit entry. + /// + /// `p1`/`p2` are total error probabilities, split uniformly over the + /// 3 (15) Paulis, matching pecos-qec's `NoiseConfig` fallback. + #[must_use] + pub fn with_base(mut self, p1: f64, p2: f64) -> Self { + assert_probability_vector(&[p1], "base p1"); + assert_probability_vector(&[p2], "base p2"); + self.base_p1 = p1; + self.base_p2 = p2; + self + } + + /// Set the default measurement X-flip and preparation X-error rates. + #[must_use] + pub fn with_meas_init(mut self, p_meas: f64, p_init: f64) -> Self { + assert_probability_vector(&[p_meas], "p_meas"); + assert_probability_vector(&[p_init], "p_init"); + self.p_meas = p_meas; + self.p_init = p_init; + self + } + + /// Attach `[P(X), P(Y), P(Z)]` rates for a 1q gate type on any qubit. + #[must_use] + pub fn with_1q_rates(mut self, gate: GateType, rates: [f64; 3]) -> Self { + assert_probability_vector(&rates, "1q rates"); + self.rates_1q.insert(gate, rates); + self + } + + /// Attach rates for a 2q gate type on any qubit pair, ordered by + /// [`TWO_QUBIT_PAULIS`]. + #[must_use] + pub fn with_2q_rates(mut self, gate: GateType, rates: [f64; 15]) -> Self { + assert_probability_vector(&rates, "2q rates"); + self.rates_2q.insert(gate, rates); + self + } + + /// Attach `[P(X), P(Y), P(Z)]` rates for a 1q gate type on one qubit. + /// Takes precedence over [`Self::with_1q_rates`] for that pair. + #[must_use] + pub fn with_1q_rates_for_qubit( + mut self, + gate: GateType, + qubit: QubitId, + rates: [f64; 3], + ) -> Self { + assert_probability_vector(&rates, "1q per-qubit rates"); + self.rates_1q_per_qubit.insert((gate, qubit), rates); + self + } + + /// Attach rates for a 2q gate type on one ordered qubit pair, ordered + /// by [`TWO_QUBIT_PAULIS`]. Takes precedence over + /// [`Self::with_2q_rates`] for that combination. + #[must_use] + pub fn with_2q_rates_for_qubits( + mut self, + gate: GateType, + first: QubitId, + second: QubitId, + rates: [f64; 15], + ) -> Self { + assert_probability_vector(&rates, "2q per-qubit rates"); + self.rates_2q_per_qubits + .insert((gate, first, second), rates); + self + } + + /// Set a per-qubit measurement X-flip probability (overrides the + /// default from [`Self::with_meas_init`] for that qubit). + #[must_use] + pub fn with_meas_rate_for_qubit(mut self, qubit: QubitId, p: f64) -> Self { + assert_probability_vector(&[p], "per-qubit meas rate"); + self.measurement_rates.insert(qubit, p); + self + } + + /// Set a per-qubit preparation X-error probability (overrides the + /// default from [`Self::with_meas_init`] for that qubit). + #[must_use] + pub fn with_init_rate_for_qubit(mut self, qubit: QubitId, p: f64) -> Self { + assert_probability_vector(&[p], "per-qubit init rate"); + self.init_rates.insert(qubit, p); + self + } + + /// Resolve the 1q rates for a gate on a qubit via the layered lookup. + fn rates_1q_for(&self, gate: GateType, qubit: QubitId) -> [f64; 3] { + if let Some(rates) = self.rates_1q_per_qubit.get(&(gate, qubit)) { + return *rates; + } + if let Some(rates) = self.rates_1q.get(&gate) { + return *rates; + } + [self.base_p1 / 3.0; 3] + } + + /// Resolve the 2q rates for a gate on an ordered pair. + fn rates_2q_for(&self, gate: GateType, first: QubitId, second: QubitId) -> [f64; 15] { + if let Some(rates) = self.rates_2q_per_qubits.get(&(gate, first, second)) { + return *rates; + } + if let Some(rates) = self.rates_2q.get(&gate) { + return *rates; + } + [self.base_p2 / 15.0; 15] + } + + fn has_any_gate_noise(&self) -> bool { + self.base_p1 > 0.0 + || self.base_p2 > 0.0 + || !self.rates_1q.is_empty() + || !self.rates_2q.is_empty() + || !self.rates_1q_per_qubit.is_empty() + || !self.rates_2q_per_qubits.is_empty() + } + + fn apply_after_gate( + &self, + gate_type: GateType, + qubits: &[QubitId], + ctx: &NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + if ctx.is_noiseless(gate_type) { + return NoiseResponse::None; + } + + let mut gates: SmallVec<[GateCommand; 4]> = SmallVec::new(); + if gate_type.is_single_qubit() { + for &qubit in qubits { + if ctx.is_leaked(qubit) { + continue; + } + let [px, py, pz] = self.rates_1q_for(gate_type, qubit); + let r = rng.random::(); + let pauli = if r < px { + GateType::X + } else if r < px + py { + GateType::Y + } else if r < px + py + pz { + GateType::Z + } else { + continue; + }; + gates.push(GateCommand::new(pauli, smallvec::smallvec![qubit])); + } + } else if qubits.len() == 2 { + let (first, second) = (qubits[0], qubits[1]); + if !ctx.is_leaked(first) && !ctx.is_leaked(second) { + let rates = self.rates_2q_for(gate_type, first, second); + let r = rng.random::(); + let mut cumulative = 0.0; + for (idx, &p) in rates.iter().enumerate() { + cumulative += p; + if r < cumulative { + let (pauli0, pauli1) = TWO_QUBIT_PAULIS[idx]; + if pauli0 != GateType::I { + gates.push(GateCommand::new(pauli0, smallvec::smallvec![first])); + } + if pauli1 != GateType::I { + gates.push(GateCommand::new(pauli1, smallvec::smallvec![second])); + } + break; + } + } + } + } + + if gates.is_empty() { + NoiseResponse::None + } else { + NoiseResponse::inject_gates(gates) + } + } + + fn apply_after_measurement( + &self, + qubits: &[QubitId], + ctx: &NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let mut flips: SmallVec<[QubitId; 4]> = SmallVec::new(); + for &qubit in qubits { + if ctx.is_leaked(qubit) { + continue; + } + let p = *self.measurement_rates.get(&qubit).unwrap_or(&self.p_meas); + if p > 0.0 && rng.random::() < p { + flips.push(qubit); + } + } + if flips.is_empty() { + NoiseResponse::None + } else { + NoiseResponse::FlipOutcomes(flips) + } + } + + fn apply_after_preparation( + &self, + qubits: &[QubitId], + ctx: &NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let mut gates: SmallVec<[GateCommand; 4]> = SmallVec::new(); + for &qubit in qubits { + if ctx.is_leaked(qubit) { + continue; + } + let p = *self.init_rates.get(&qubit).unwrap_or(&self.p_init); + if p > 0.0 && rng.random::() < p { + gates.push(GateCommand::new(GateType::X, smallvec::smallvec![qubit])); + } + } + if gates.is_empty() { + NoiseResponse::None + } else { + NoiseResponse::inject_gates(gates) + } + } +} + +impl NoiseChannel for PerGatePauliChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + match event { + NoiseEvent::AfterGate { gate_type, .. } => { + gate_type.is_unitary_gate() + && (gate_type.is_single_qubit() || gate_type.is_two_qubit()) + && self.has_any_gate_noise() + } + NoiseEvent::AfterMeasurement { .. } => { + self.p_meas > 0.0 || !self.measurement_rates.is_empty() + } + NoiseEvent::AfterPreparation { .. } => self.p_init > 0.0 || !self.init_rates.is_empty(), + _ => false, + } + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + ctx: &mut NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + match event { + NoiseEvent::AfterGate { + gate_type, qubits, .. + } => self.apply_after_gate(*gate_type, qubits, ctx, rng), + NoiseEvent::AfterMeasurement { qubits, .. } => { + self.apply_after_measurement(qubits, ctx, rng) + } + NoiseEvent::AfterPreparation { qubits } => { + self.apply_after_preparation(qubits, ctx, rng) + } + _ => NoiseResponse::None, + } + } + + fn name(&self) -> &'static str { + "PerGatePauliChannel" + } + + fn priority(&self) -> i32 { + 10 + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + +#[cfg(test)] +// statistical tests use count as f64 +#[allow(clippy::cast_precision_loss)] +mod tests { + use super::*; + use crate::noise::ComposableNoiseModel; + use crate::prelude::*; + use pecos_simulators::SparseStab; + + const SHOTS: usize = 20_000; + + fn flip_rate(model: ComposableNoiseModel, commands: &CommandQueue, qubit: usize) -> f64 { + let mut state = SparseStab::new(2); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let qubits = [QubitId(qubit)]; + let mut flips = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, commands).unwrap(); + if let Some(bits) = outcomes.bitstring(&qubits) + && bits[0] + { + flips += 1; + } + } + flips as f64 / SHOTS as f64 + } + + fn five_sigma(p: f64) -> f64 { + 5.0 * (p * (1.0 - p) / SHOTS as f64).sqrt() + } + + #[test] + fn per_gate_rates_apply_to_that_gate_only() { + // X-only error on H; the X gate stays noiseless. + let channel = PerGatePauliChannel::new().with_1q_rates(GateType::H, [0.2, 0.0, 0.0]); + let model = ComposableNoiseModel::new().add_channel(channel); + + // H twice returns to |0>; an injected X after either H flips the + // outcome unless both Hs get one (prob 0.2*0.2 cancels via parity: + // P(flip) = 2*0.2*0.8 = 0.32). + let commands = CommandBuilder::new() + .pz(&[0]) + .h(&[0]) + .h(&[0]) + .mz(&[0]) + .build(); + let rate = flip_rate(model.clone(), &commands, 0); + // X after the second H flips Z-outcome directly; X after the first H + // becomes Z through the second H... careful: track exactly. + // |0> -H-> |+> -X-> |+> (X|+> = |+>): an X after the FIRST H does + // nothing to the final outcome. After the second H the state is |0>, + // X flips it. So P(flip) = 0.2 exactly. + assert!( + (rate - 0.2).abs() < five_sigma(0.2), + "H-specific X rate: got {rate}, expected 0.2" + ); + + // The X gate has no entry and base is zero: noiseless. + let channel = PerGatePauliChannel::new().with_1q_rates(GateType::H, [0.2, 0.0, 0.0]); + let model = ComposableNoiseModel::new().add_channel(channel); + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let rate = flip_rate(model, &commands, 0); + assert!( + (rate - 1.0).abs() < f64::EPSILON, + "X gate must stay noiseless, got outcome-1 rate {rate}" + ); + } + + #[test] + fn per_qubit_rates_override_per_gate_rates() { + // Gate-level: 30% X error on X gates; qubit 0 override: 0%. + let channel = PerGatePauliChannel::new() + .with_1q_rates(GateType::X, [0.3, 0.0, 0.0]) + .with_1q_rates_for_qubit(GateType::X, QubitId(0), [0.0; 3]); + let model = ComposableNoiseModel::new().add_channel(channel); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .x(&[0]) + .x(&[1]) + .mz(&[0]) + .mz(&[1]) + .build(); + + // Qubit 0: override says noiseless -> outcome always 1. + let rate0 = 1.0 - flip_rate(model.clone(), &commands, 0); + assert!( + rate0.abs() < f64::EPSILON, + "qubit 0 override must be noiseless, got flip rate {rate0}" + ); + // Qubit 1: per-gate 30% X error cancels the X gate -> outcome 0. + let rate1 = 1.0 - flip_rate(model, &commands, 1); + assert!( + (rate1 - 0.3).abs() < five_sigma(0.3), + "qubit 1 per-gate rate: got {rate1}, expected 0.3" + ); + } + + #[test] + fn two_qubit_orientation_first_pauli_hits_first_qubit() { + // XI-only error on CX(0, 1): qubit 0 flips at 25%, qubit 1 never + // (after CX from |00>, X on control would propagate only if it + // happened BEFORE the gate; injection is after, so it stays local). + let mut rates = [0.0; 15]; + rates[0] = 0.25; // (X, I) in TWO_QUBIT_PAULIS order + let channel = PerGatePauliChannel::new().with_2q_rates(GateType::CX, rates); + let model = ComposableNoiseModel::new().add_channel(channel); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .cx(&[(0, 1)]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = flip_rate(model.clone(), &commands, 0); + let rate1 = flip_rate(model, &commands, 1); + assert!( + (rate0 - 0.25).abs() < five_sigma(0.25), + "first qubit must flip at the XI rate, got {rate0}" + ); + assert!( + rate1.abs() < f64::EPSILON, + "second qubit must be untouched by XI, got {rate1}" + ); + } + + #[test] + fn measurement_and_init_rates_with_per_qubit_overrides() { + let channel = PerGatePauliChannel::new() + .with_meas_init(0.1, 0.0) + .with_meas_rate_for_qubit(QubitId(1), 0.4); + let model = ComposableNoiseModel::new().add_channel(channel); + + let commands = CommandBuilder::new() + .pz(&[0]) + .pz(&[1]) + .mz(&[0]) + .mz(&[1]) + .build(); + + let rate0 = flip_rate(model.clone(), &commands, 0); + let rate1 = flip_rate(model, &commands, 1); + assert!( + (rate0 - 0.1).abs() < five_sigma(0.1), + "default meas rate: got {rate0}, expected 0.1" + ); + assert!( + (rate1 - 0.4).abs() < five_sigma(0.4), + "per-qubit meas rate: got {rate1}, expected 0.4" + ); + } + + #[test] + fn base_rates_back_fill_unlisted_gates() { + // base p1 = 0.3 -> uniform X/Y/Z at 0.1 each; X and Y flip the + // Z-basis outcome after an X gate, so P(outcome = 1) = 0.8. + let channel = PerGatePauliChannel::new().with_base(0.3, 0.0); + let model = ComposableNoiseModel::new().add_channel(channel); + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + let rate = flip_rate(model, &commands, 0); + assert!( + (rate - 0.8).abs() < five_sigma(0.8), + "base fallback: got outcome-1 rate {rate}, expected 0.8" + ); + } + + #[test] + #[should_panic(expected = "must be non-negative probabilities")] + fn rejects_rates_summing_above_one() { + let _ = PerGatePauliChannel::new().with_1q_rates(GateType::H, [0.5, 0.4, 0.3]); + } +} From 1bfcbc3c76df9d94d3edaf0b2ed576d90f49df8e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 20:55:56 -0600 Subject: [PATCH 125/388] Address Fable review: map depolarizing p_meas to a state-flipping neo channel (engines injects X pre-measure, propagates as 2p(1-p) on re-measure), fix 10 mangled error strings and the lying empty-source error, narrow subset rare-event claims, route betainc_inv upper tail through symmetry with a jeffreys trials guard, document the V5 criterion sensitivity --- crates/pecos-num/src/special.rs | 70 ++++++++- crates/pecos-num/src/stats.rs | 32 ++++- crates/pecos/src/unified_sim.rs | 28 ++-- .../tests/neo_equivalence_matrix_test.rs | 28 ++++ crates/pecos/tests/neo_surface_ler_test.rs | 15 +- exp/pecos-neo/src/noise.rs | 2 +- exp/pecos-neo/src/noise/general_builder.rs | 18 ++- exp/pecos-neo/src/noise/measurement.rs | 136 ++++++++++++++++++ exp/pecos-neo/src/sampling/subset.rs | 14 +- exp/pecos-neo/src/tool/simulation.rs | 13 +- python/pecos-rslib/src/sim.rs | 31 ++-- .../tests/pecos/test_sim_stack_routing.py | 10 ++ 12 files changed, 361 insertions(+), 36 deletions(-) diff --git a/crates/pecos-num/src/special.rs b/crates/pecos-num/src/special.rs index dc3b4e9bc..5e35b43c5 100644 --- a/crates/pecos-num/src/special.rs +++ b/crates/pecos-num/src/special.rs @@ -191,8 +191,15 @@ pub fn betainc_reg(a: f64, b: f64, x: f64) -> f64 { /// Inverse of the regularized incomplete beta function: returns `x` such /// that `betainc_reg(a, b, x) == p`. /// -/// Drop-in replacement for `scipy.special.betaincinv`. This is also the -/// quantile (inverse CDF) of the Beta(a, b) distribution. +/// Matches `scipy.special.betaincinv` (this is the quantile / inverse CDF +/// of the Beta(a, b) distribution) over the parameter scales validated by +/// the test module: shape parameters up to roughly binomial-trial counts +/// of 1e12 and `p` away from the extreme tails by more than ~1e-15. The +/// upper tail is computed through the symmetry +/// `betainc_inv(a, b, p) = 1 - betainc_inv(b, a, 1 - p)` so both tails +/// share the well-conditioned lower-tail path; beyond those scales the +/// continued fraction can converge spuriously, so callers with extreme +/// parameters must validate independently. /// /// Follows Numerical Recipes 3rd ed., section 6.4: an initial guess from /// Abramowitz & Stegun 26.5.22 refined by Halley iterations. @@ -219,6 +226,13 @@ pub fn betainc_inv(a: f64, b: f64, p: f64) -> f64 { if p >= 1.0 { return 1.0; } + // Compute upper-tail quantiles through the lower tail of the mirrored + // distribution: `err = betainc_reg(...) - p` loses all precision when + // p is within ~1e-10 of 1 (the Halley correction then stalls on a + // cancelled residual), while 1 - p is exact in the mirrored call. + if p > 0.5 { + return 1.0 - betainc_inv(b, a, 1.0 - p); + } let a1 = a - 1.0; let b1 = b - 1.0; @@ -367,6 +381,58 @@ mod tests { } } + // Reference values generated with: + // uv run python -c "from scipy import special; print(special.betaincinv(a, b, p))" + // Upper-tail quantiles exercise the symmetry path (the direct Halley + // iteration loses the residual to cancellation beyond p ~ 1 - 1e-10). + #[test] + fn betainc_inv_upper_tail_matches_scipy() { + let cases: [(f64, f64, f64, f64); 4] = [ + (2.0, 3.0, 0.999_999_9, 0.997_073_840_091_498_9), + (100.5, 900.5, 1.0 - 1e-12, 0.179_649_794_238_11), + (7.5, 19_993.5, 1.0 - 2.3e-16, 0.002_728_615_291_135_757_6), + (0.5, 0.5, 0.999_999, 0.999_999_999_997_532_6), + ]; + for (a, b, p, expected) in cases { + let actual = betainc_inv(a, b, p); + let scale: f64 = expected.abs(); + assert!( + ((actual - expected).abs() / scale) < 1e-8, + "betainc_inv({a}, {b}, {p}): expected {expected:.12e}, got {actual:.12e}" + ); + } + } + + #[test] + fn betainc_inv_tails_are_symmetric() { + // Relative comparison, with cases chosen so neither side hits + // f64 representation limits: p stays >= 1e-6 (forming `1 - p` + // closer to 1 destroys p's precision before the function is even + // called) and the quantiles stay far enough from 0 and 1 that + // `1 - upper` keeps its significant digits. Outside those limits + // a mirrored comparison measures representation error, not + // implementation error. + let cases: [(f64, f64, &[f64]); 3] = [ + (2.0, 3.0, &[1e-6, 0.01, 0.3]), + (50.5, 19_950.5, &[1e-6, 0.01, 0.3]), + // Quantiles for this shape at small p sit below 1e-13, where + // the mirrored side cannot represent them; compare only at + // moderate p. + (0.5, 20.5, &[0.01, 0.3]), + ]; + for (a, b, ps) in cases { + for &p in ps { + let lower = betainc_inv(a, b, p); + let upper = betainc_inv(b, a, 1.0 - p); + let mirrored = 1.0 - upper; + assert!( + ((lower - mirrored) / lower).abs() < 1e-8, + "tail symmetry failed for a={a}, b={b}, p={p}: {lower} vs {mirrored}" + ); + } + } + } + #[test] fn betainc_inv_round_trips_through_betainc_reg() { for &(a, b) in &[(0.5, 0.5), (2.0, 3.0), (7.5, 19_993.5), (100.5, 900.5)] { diff --git a/crates/pecos-num/src/stats.rs b/crates/pecos-num/src/stats.rs index 0a0028386..533b691e3 100644 --- a/crates/pecos-num/src/stats.rs +++ b/crates/pecos-num/src/stats.rs @@ -63,8 +63,14 @@ use ndarray::{Array, ArrayView, Axis, Dimension, RemoveAxis}; /// /// # Panics /// -/// Panics if `trials == 0`, `successes > trials`, or `confidence` is not -/// in (0, 1) — these are contract violations, not numeric edge cases. +/// Panics if `trials == 0`, `trials > 10^12`, `successes > trials`, or +/// `confidence` is not in (0, 1). The first three are contract +/// violations; the trials cap marks the scale beyond which the +/// underlying incomplete-beta continued fraction has not been validated +/// (it can converge spuriously for extreme shape parameters, returning a +/// nonsense interval without warning). Also panics if the computed +/// bounds come back inverted — a numeric-breakdown trip-wire that should +/// be unreachable within the supported scales. /// /// # Examples /// @@ -81,9 +87,16 @@ use ndarray::{Array, ArrayView, Axis, Dimension, RemoveAxis}; /// ``` #[must_use] #[allow(clippy::cast_precision_loss)] -// Cast is safe: trial counts in practice are much smaller than f64 mantissa precision +// Cast is safe: the trials cap keeps counts far below f64 mantissa precision pub fn jeffreys_interval(successes: u64, trials: u64, confidence: f64) -> (f64, f64) { + const MAX_TRIALS: u64 = 1_000_000_000_000; + assert!(trials > 0, "jeffreys_interval requires trials > 0"); + assert!( + trials <= MAX_TRIALS, + "jeffreys_interval supports at most {MAX_TRIALS} trials (got {trials}); the \ + incomplete-beta evaluation is not validated beyond that scale" + ); assert!( successes <= trials, "jeffreys_interval requires successes ({successes}) <= trials ({trials})" @@ -107,6 +120,11 @@ pub fn jeffreys_interval(successes: u64, trials: u64, confidence: f64) -> (f64, } else { betainc_inv(a, b, 1.0 - alpha / 2.0) }; + assert!( + lower <= upper, + "jeffreys_interval produced inverted bounds ({lower} > {upper}) for k={successes}, \ + n={trials}; this indicates incomplete-beta breakdown and is a bug" + ); (lower, upper) } @@ -742,6 +760,14 @@ mod tests { let _ = jeffreys_interval(0, 0, 0.95); } + #[test] + #[should_panic(expected = "at most")] + fn jeffreys_interval_rejects_unvalidated_trial_scales() { + // Beyond ~1e12 trials the incomplete-beta continued fraction can + // converge spuriously (observed at 2e15: inverted bounds). + let _ = jeffreys_interval(1_000_000_000_000_000, 2_000_000_000_000_000, 0.95); + } + #[test] #[should_panic(expected = "successes")] fn jeffreys_interval_rejects_successes_above_trials() { diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index a1c3d5f7d..fd0da6d5f 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -271,14 +271,24 @@ impl ProgrammedSimBuilder { /// Translate an engines noise config into the neo stack's noise model. /// -/// The depolarizing family has identical sampling conventions on both -/// stacks (uniform X/Y/Z at p1, uniform 15 two-qubit Paulis at p2, X -/// before prep/measure for `p_prep`/`p_meas`), verified by -/// `exp/pecos-neo/tests/noise_comparison_test.rs`, so probabilities map -/// one-to-one. `GeneralNoiseModel` is NOT mapped: its full configuration -/// (leakage, idle, crosstalk, emission models) is not readable from the -/// built model and uses the "average" probability convention; configure -/// `sim_neo()` directly with neo's `GeneralNoiseModelBuilder` for those. +/// Gate and prep conventions are identical on both stacks (uniform X/Y/Z +/// at p1, uniform 15 two-qubit Paulis at p2, X after prep for `p_prep`) +/// and probabilities map one-to-one. Measurement noise differs BY MODEL +/// on the engines side and the mapping preserves each model's physics: +/// +/// - The depolarizing family injects a physical X into the state before +/// each measurement (the error persists and propagates — a qubit +/// measured twice without a reset flips at `2p(1-p)` the second time), +/// mapped to neo's `MeasurementStateFlipChannel` via +/// `with_p_meas_state_flip`. +/// - `GeneralNoiseModel` flips only the classical record (the +/// post-measurement state is untouched), mapped to neo's +/// record-flipping `MeasurementChannel` via `with_p_meas`. +/// +/// `GeneralNoiseModel` beyond the simple probability subset is NOT +/// mapped: its full configuration (leakage, idle, crosstalk, emission +/// models) is not readable from the built model; configure `sim_neo()` +/// directly with neo's `GeneralNoiseModelBuilder` for those. /// /// Returns `Ok(None)` for pass-through (no noise). #[cfg(feature = "neo")] @@ -292,7 +302,7 @@ fn map_noise_to_neo( let uniform = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { GeneralNoiseModelBuilder::new() .with_p_prep(p_prep) - .with_p_meas_symmetric(p_meas) + .with_p_meas_state_flip(p_meas) .with_p1(p1) .with_p2(p2) }; diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index be0bd8de4..737bf596d 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -54,6 +54,16 @@ const RESET_MEASURE: &str = r#" measure q[0] -> c[0]; "#; +const MEASURE_TWICE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + reset q[0]; + measure q[0] -> c[0]; + measure q[0] -> c[0]; +"#; + const BELL: &str = r#" OPENQASM 2.0; include "qelib1.inc"; @@ -262,3 +272,21 @@ fn feedback_under_measurement_noise_matches() { None, ); } + +#[test] +fn meas_twice_without_reset_matches() { + // Measurement noise in the depolarizing family is a physical X + // injected before readout, so the error persists in the state: the + // SECOND measurement of an un-reset qubit flips at 2p(1-p), not p. + // The creg bit is overwritten, so c reads the second outcome. A + // record-flip mapping (the bug this cell guards against) would + // produce p here. + let p = 0.25; + check_cell( + "meas_twice", + MEASURE_TWICE, + &NoiseCell::Meas(p), + &["1"], + Some(2.0 * p * (1.0 - p)), + ); +} diff --git a/crates/pecos/tests/neo_surface_ler_test.rs b/crates/pecos/tests/neo_surface_ler_test.rs index 79a6baca1..ef042eb9a 100644 --- a/crates/pecos/tests/neo_surface_ler_test.rs +++ b/crates/pecos/tests/neo_surface_ler_test.rs @@ -384,9 +384,22 @@ fn surface_memory_ler_matches_across_stacks() { let shots = 20_000; // High-confidence intervals so stack disagreement, not sampling // noise, is what fails the equivalence check (~4.4 sigma per side). + // + // Sensitivity, stated honestly: at these counts the overlap criterion + // only fails for LER ratios beyond roughly 2.4x at d=3, so this test + // is a coarse end-to-end guard — its value is exercising a real QEC + // circuit with decoding through both stacks. Fine-grained discrepancy + // detection belongs to the V1 matrix's analytic-anchor cells (power + // ~1 against convention bugs like the 2/3 and 8/15 factors or the + // measurement state-flip/record-flip distinction). The seed-42 d=3 + // draw (engines 85 vs neo 60, ~2.1 sigma) was settled as sampling + // noise by an independent 6-seed 120k-shot-per-stack run (engines + // 517 vs neo 482, z = 1.11). let equivalence_confidence = 0.99999; // The suppression margin is smaller than the equivalence margin, so - // it gets its own (still strict) confidence. + // it gets its own (still strict) confidence. Pooling the two stacks + // for suppression is justified by that 120k-shot equivalence run, + // not by this test's own (weaker) overlap check. let suppression_confidence = 0.99; let mut pooled_intervals = Vec::new(); diff --git a/exp/pecos-neo/src/noise.rs b/exp/pecos-neo/src/noise.rs index 63f4f0e38..d59838004 100644 --- a/exp/pecos-neo/src/noise.rs +++ b/exp/pecos-neo/src/noise.rs @@ -132,7 +132,7 @@ pub use gate_id_dependent::{GateIdDependentChannel, GateIdNoiseConfig}; pub use general_builder::{GeneralNoiseModelBuilder, general_noise}; pub use idle::IdleChannel; pub use leakage::LeakageChannel; -pub use measurement::MeasurementChannel; +pub use measurement::{MeasurementChannel, MeasurementStateFlipChannel}; pub use per_gate_pauli::PerGatePauliChannel; pub use plugin::{ContextObserver, EventHandler, NoiseModelConfig, NoisePlugin}; pub use preparation::PreparationChannel; diff --git a/exp/pecos-neo/src/noise/general_builder.rs b/exp/pecos-neo/src/noise/general_builder.rs index d55c2ef10..6033c727f 100644 --- a/exp/pecos-neo/src/noise/general_builder.rs +++ b/exp/pecos-neo/src/noise/general_builder.rs @@ -38,7 +38,7 @@ use super::crosstalk::CrosstalkChannel; use super::idle::IdleChannel; use super::leakage::LeakageChannel; -use super::measurement::MeasurementChannel; +use super::measurement::{MeasurementChannel, MeasurementStateFlipChannel}; use super::plugins::CorePlugin; use super::preparation::PreparationChannel; use super::single_qubit::SingleQubitChannel; @@ -101,6 +101,7 @@ pub struct GeneralNoiseModelBuilder { // Measurement p_meas_0: f64, p_meas_1: f64, + p_meas_state_flip: f64, p_meas_crosstalk_global: f64, p_meas_crosstalk_local: f64, p_meas_crosstalk_transitions: Option, @@ -160,6 +161,7 @@ impl GeneralNoiseModelBuilder { // Measurement p_meas_0: 0.0, p_meas_1: 0.0, + p_meas_state_flip: 0.0, p_meas_crosstalk_global: 0.0, p_meas_crosstalk_local: 0.0, p_meas_crosstalk_transitions: None, @@ -353,6 +355,17 @@ impl GeneralNoiseModelBuilder { self } + /// Set a measurement error realized as a physical X flip of the + /// qubit state just before readout (engines depolarizing / DEM + /// convention). Unlike `with_p_meas_symmetric`, the error persists + /// in the post-measurement state: measuring the same qubit twice + /// without a reset sees the second outcome flipped at `2p(1-p)`. + #[must_use] + pub fn with_p_meas_state_flip(mut self, p: f64) -> Self { + self.p_meas_state_flip = p; + self + } + /// Set measurement crosstalk probabilities (global and local). #[must_use] pub fn with_p_meas_crosstalk(mut self, global: f64, local: f64) -> Self { @@ -557,6 +570,9 @@ impl GeneralNoiseModelBuilder { } // Measurement channel + if self.p_meas_state_flip > 0.0 { + model = model.add_channel(MeasurementStateFlipChannel::new(self.p_meas_state_flip)); + } if self.p_meas_0 > 0.0 || self.p_meas_1 > 0.0 { model = model.add_channel(MeasurementChannel::asymmetric(self.p_meas_0, self.p_meas_1)); } diff --git a/exp/pecos-neo/src/noise/measurement.rs b/exp/pecos-neo/src/noise/measurement.rs index 9001d8cbb..28b2a93f6 100644 --- a/exp/pecos-neo/src/noise/measurement.rs +++ b/exp/pecos-neo/src/noise/measurement.rs @@ -209,11 +209,147 @@ impl NoiseChannel for MeasurementChannel { } } +/// Measurement error realized as a physical X flip of the qubit state +/// just before readout. +/// +/// Unlike [`MeasurementChannel`], which flips only the classical record +/// (the post-measurement state is untouched), this channel injects an X +/// gate before the measurement executes, so the error persists in the +/// state and propagates to later operations on the same qubit. This is +/// the convention of the engines stack's depolarizing noise family and +/// of the DEM builder's measurement mechanisms: measuring the same qubit +/// twice without a reset sees the second outcome flipped at rate +/// `2p(1-p)`, not `p`. +/// +/// State flips are inherently symmetric (X swaps |0> and |1>), so there +/// is no asymmetric variant; use [`MeasurementChannel`] for asymmetric +/// readout-record errors. +#[derive(Debug, Clone)] +pub struct MeasurementStateFlipChannel { + /// Probability of an X flip before each measurement. + pub p: f64, + /// Precomputed probability threshold for fast sampling. + threshold: u64, +} + +impl MeasurementStateFlipChannel { + /// Create a state-flip measurement error channel. + #[must_use] + pub fn new(p: f64) -> Self { + Self { + p, + threshold: PecosRng::probability_threshold(p), + } + } +} + +impl NoiseChannel for MeasurementStateFlipChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + if self.p <= 0.0 { + return false; + } + matches!(event, NoiseEvent::BeforeMeasurement { .. }) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + ctx: &mut NoiseContext, + rng: &mut PecosRng, + ) -> NoiseResponse { + let NoiseEvent::BeforeMeasurement { qubits } = event else { + return NoiseResponse::None; + }; + + let mut gates: SmallVec<[crate::command::GateCommand; 4]> = SmallVec::new(); + let has_any_leakage = ctx.leaked_count() > 0; + + for &qubit in *qubits { + if has_any_leakage && ctx.is_leaked(qubit) { + continue; + } + if rng.check_probability(self.threshold) { + gates.push(crate::command::GateCommand::new( + crate::command::GateType::X, + smallvec::smallvec![qubit], + )); + } + } + + if gates.is_empty() { + NoiseResponse::None + } else { + NoiseResponse::inject_gates(gates) + } + } + + fn name(&self) -> &'static str { + "MeasurementStateFlipChannel" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} + #[cfg(test)] mod tests { use super::*; use pecos_core::QubitId; + /// The state-flip vs record-flip distinction: measuring the same + /// qubit twice without a reset. A record flip leaves the state + /// untouched (second outcome flips at p); a state flip persists + /// (second outcome flips at 2p(1-p)). + #[test] + #[allow(clippy::cast_precision_loss)] + fn state_flip_propagates_to_second_measurement_record_flip_does_not() { + use crate::prelude::*; + use pecos_simulators::SparseStab; + + const SHOTS: usize = 20_000; + let p = 0.25; + + let commands = CommandBuilder::new().pz(&[0]).mz(&[0]).mz(&[0]).build(); + + let second_meas_rate = |model: ComposableNoiseModel| -> f64 { + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let mut ones = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); + let bits: Vec = outcomes.iter().map(|o| o.outcome).collect(); + assert_eq!(bits.len(), 2); + if bits[1] { + ones += 1; + } + } + ones as f64 / SHOTS as f64 + }; + + let five_sigma = |q: f64| 5.0 * (q * (1.0 - q) / SHOTS as f64).sqrt(); + + let state_flip = second_meas_rate( + ComposableNoiseModel::new().add_channel(MeasurementStateFlipChannel::new(p)), + ); + let expected_state = 2.0 * p * (1.0 - p); + assert!( + (state_flip - expected_state).abs() < five_sigma(expected_state), + "state flip second-measure rate: got {state_flip}, expected {expected_state}" + ); + + let record_flip = second_meas_rate( + ComposableNoiseModel::new().add_channel(MeasurementChannel::symmetric(p)), + ); + assert!( + (record_flip - p).abs() < five_sigma(p), + "record flip second-measure rate: got {record_flip}, expected {p}" + ); + } + #[test] fn test_symmetric_measurement_error() { let channel = MeasurementChannel::symmetric(1.0); // Always flip diff --git a/exp/pecos-neo/src/sampling/subset.rs b/exp/pecos-neo/src/sampling/subset.rs index 3b41895e0..c0c32a53f 100644 --- a/exp/pecos-neo/src/sampling/subset.rs +++ b/exp/pecos-neo/src/sampling/subset.rs @@ -13,8 +13,18 @@ //! Subset simulation for estimating very rare event probabilities. //! //! Subset simulation is a multilevel Monte Carlo method that decomposes a rare event -//! into a sequence of more frequent intermediate events. This allows efficient estimation -//! of probabilities as low as 1e-10 or smaller. +//! into a sequence of more frequent intermediate events, reducing the sample count +//! needed compared to direct Monte Carlo. +//! +//! ## Accuracy caveat +//! +//! The multi-level resampling in this implementation does not yet condition the +//! resampled population correctly across levels, which biases estimates upward and +//! degrades confidence-interval coverage once more than one level engages (measured: +//! ~+21% bias and ~15% CI coverage at event probabilities near 1e-3). Treat results +//! that engaged multiple levels as approximate until the estimator overhaul lands; +//! single-level runs (event probability within reach of `samples_per_level`) behave +//! like direct Monte Carlo and are unbiased. //! //! ## Algorithm //! diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index fc682df8e..d06b679af 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -1204,12 +1204,17 @@ impl From for Sampling { /// Create a subset simulation strategy builder running `samples_per_level` /// samples at each level. /// -/// Subset simulation estimates rare event probabilities (1e-6 and below) -/// by decomposing them into a product of conditional probabilities across -/// adaptive levels. It needs a `.score()` function (how close an outcome is -/// to failure) and a `.failure()` predicate (did the rare event occur); +/// Subset simulation estimates rare event probabilities by decomposing +/// them into a product of conditional probabilities across adaptive +/// levels. It needs a `.score()` function (how close an outcome is to +/// failure) and a `.failure()` predicate (did the rare event occur); /// both are required. /// +/// Accuracy caveat: the current multi-level estimator biases upward once +/// more than one level engages (see `sampling::subset` module docs); +/// treat deep-rare-event estimates as approximate until the estimator +/// overhaul lands. +/// /// The result arrives in [`SimulationResults::subset`]; per-shot /// `outcomes` are empty for subset runs. /// diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index f1af4e4ef..0d09e9e3c 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -849,7 +849,7 @@ impl PySimBuilder { } } else { Err(PyTypeError::new_err( - "Unrecognized quantum engine builder type; expected state_vector(), \\ + "Unrecognized quantum engine builder type; expected state_vector(), \ sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", )) } @@ -871,7 +871,7 @@ impl PySimBuilder { Ok(sim_builder.noise(biased.inner.clone())) } else { Err(PyTypeError::new_err( - "Unrecognized noise builder type; expected depolarizing_noise(), \\ + "Unrecognized noise builder type; expected depolarizing_noise(), \ biased_depolarizing_noise(), or general_noise()", )) } @@ -1025,7 +1025,7 @@ impl PySimBuilder { } } else { Err(PyTypeError::new_err( - "Unrecognized quantum engine builder type; expected state_vector(), \\ + "Unrecognized quantum engine builder type; expected state_vector(), \ sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", )) } @@ -1047,7 +1047,7 @@ impl PySimBuilder { Ok(sim_builder.noise(biased.inner.clone())) } else { Err(PyTypeError::new_err( - "Unrecognized noise builder type; expected depolarizing_noise(), \\ + "Unrecognized noise builder type; expected depolarizing_noise(), \ biased_depolarizing_noise(), or general_noise()", )) } @@ -1190,7 +1190,7 @@ impl PySimBuilder { } } else { Err(PyTypeError::new_err( - "Unrecognized quantum engine builder type; expected state_vector(), \\ + "Unrecognized quantum engine builder type; expected state_vector(), \ sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", )) } @@ -1213,7 +1213,7 @@ impl PySimBuilder { Ok(sim_builder.noise(biased.inner.clone())) } else { Err(PyTypeError::new_err( - "Unrecognized noise builder type; expected depolarizing_noise(), \\ + "Unrecognized noise builder type; expected depolarizing_noise(), \ biased_depolarizing_noise(), or general_noise()", )) } @@ -1390,7 +1390,7 @@ impl PySimBuilder { } } else { Err(PyTypeError::new_err( - "Unrecognized quantum engine builder type; expected state_vector(), \\ + "Unrecognized quantum engine builder type; expected state_vector(), \ sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", )) } @@ -1413,7 +1413,7 @@ impl PySimBuilder { Ok(sim_builder.noise(biased.inner.clone())) } else { Err(PyTypeError::new_err( - "Unrecognized noise builder type; expected depolarizing_noise(), \\ + "Unrecognized noise builder type; expected depolarizing_noise(), \ biased_depolarizing_noise(), or general_noise()", )) } @@ -1573,7 +1573,7 @@ impl PySimBuilder { } } else { Err(PyTypeError::new_err( - "Unrecognized quantum engine builder type; expected state_vector(), \\ + "Unrecognized quantum engine builder type; expected state_vector(), \ sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", )) } @@ -1596,7 +1596,7 @@ impl PySimBuilder { Ok(sim_builder.noise(biased.inner.clone())) } else { Err(PyTypeError::new_err( - "Unrecognized noise builder type; expected depolarizing_noise(), \\ + "Unrecognized noise builder type; expected depolarizing_noise(), \ biased_depolarizing_noise(), or general_noise()", )) } @@ -1713,9 +1713,14 @@ fn run_qasm_via_facade( // reads the program field. let mut facade = match engine_builder.get_program() { Some(program) if !engine_builder.has_wasm() => pecos::sim(program), - program => { - pecos::sim(program.unwrap_or_else(|| pecos_programs::Qasm::from_string(String::new()))) - .classical(engine_builder) + Some(program) => pecos::sim(program).classical(engine_builder), + None => { + // Error here rather than letting the classical-override + // placeholder reach the facade, which would misreport the + // problem as an unrouted .classical() configuration. + return Err(PyRuntimeError::new_err( + "No QASM source specified. Use .qasm() or .qasm_file()", + )); } }; diff --git a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py index ba56a0a7f..9415540ac 100644 --- a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py +++ b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py @@ -106,3 +106,13 @@ def test_neo_stack_rejects_explicit_quantum_backend() -> None: def test_neo_stack_rejects_build() -> None: with pytest.raises(RuntimeError, match="build"): sim(Qasm.from_string(X_MEASURE)).stack("neo").build() + + +def test_missing_qasm_source_reports_the_real_problem() -> None: + """A builder with no program must say so, not misreport an unrouted + .classical() configuration (regression: review finding S2).""" + from pecos_rslib import qasm_engine + + for stack in ["engines", "neo"]: + with pytest.raises(RuntimeError, match="No QASM source specified"): + qasm_engine().to_sim().stack(stack).run(2) From 71d6aad5bc264d1be99b6bf7d0f244823fbbea5a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 20:57:35 -0600 Subject: [PATCH 126/388] Add SZZ forward-flow pulse guard --- .../src/pecos/qec/surface/circuit_builder.py | 239 ++++++++++++++++++ .../qec/surface/test_szz_interaction_basis.py | 106 ++++++++ 2 files changed, 345 insertions(+) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 549bcb2f9..b1de6681c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -165,6 +165,35 @@ def total(self) -> int: return len(set(self.data_qubits) | set(self.x_ancilla_qubits) | set(self.z_ancilla_qubits)) +@dataclass(frozen=True) +class SzzForwardFlowPulse: + """One pending-Clifford discharge site in the SZZ device-flow model.""" + + host_index: int + host_op_type: str + host_label: str + qubit: int + kind: str + pending_clifford: str + + +@dataclass(frozen=True) +class SzzForwardFlowSummary: + """Pulse accounting for the SZZ pending-Clifford forward-flow model.""" + + abstract_single_qubit_ops: int + physical_prefix_pulses: int + two_qubit_prefix_pulses: int + measurement_prefix_pulses: int + virtual_z_two_qubit_carries: int + virtual_z_measure_discards: int + two_qubit_gates: int + measurements: int + prep_events: int + free_standing_single_qubit_ops: int + pulses: tuple[SzzForwardFlowPulse, ...] + + @dataclass(frozen=True, order=True) class SzzTouchSign: """Signed SZZ touch in the v1 surface-memory sign convention.""" @@ -379,6 +408,215 @@ def _propagate_sxx_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tup return x_a ^ common, z_a, x_b ^ common, z_b +_SIGNED_PAULI_NAMES = { + 1: "X", + -1: "-X", + 2: "Y", + -2: "-Y", + 3: "Z", + -3: "-Z", +} +_SZZ_FLOW_IDENTITY: tuple[int, int] = (1, 3) +_SZZ_FLOW_SINGLE_QUBIT_GATES = { + OpType.H, + OpType.SX, + OpType.SXDG, + OpType.SZ, + OpType.SZDG, + OpType.X, + OpType.Z, +} +_SZZ_FLOW_GATE_ACTIONS: dict[OpType, dict[int, int]] = { + OpType.H: {1: 3, 2: -2, 3: 1}, + OpType.SX: {1: 1, 2: 3, 3: -2}, + OpType.SXDG: {1: 1, 2: -3, 3: 2}, + OpType.SZ: {1: 2, 2: -1, 3: 3}, + OpType.SZDG: {1: -2, 2: 1, 3: 3}, + OpType.X: {1: 1, 2: -2, 3: -3}, + OpType.Z: {1: -1, 2: -2, 3: 3}, +} + + +def _szz_flow_apply_gate_to_pauli(op_type: OpType, pauli: int) -> int: + sign = -1 if pauli < 0 else 1 + return sign * _SZZ_FLOW_GATE_ACTIONS[op_type][abs(pauli)] + + +def _szz_flow_compose_pending_gate(pending: tuple[int, int], op_type: OpType) -> tuple[int, int]: + """Append one 1q Clifford to a pending signed-Pauli-image register.""" + return ( + _szz_flow_apply_gate_to_pauli(op_type, pending[0]), + _szz_flow_apply_gate_to_pauli(op_type, pending[1]), + ) + + +def _szz_flow_is_virtual_z(pending: tuple[int, int]) -> bool: + """Return whether a pending Clifford is a zero-pulse virtual-Z update.""" + return pending[1] == 3 + + +def _szz_flow_clifford_name(pending: tuple[int, int]) -> str: + return f"X->{_SIGNED_PAULI_NAMES[pending[0]]},Z->{_SIGNED_PAULI_NAMES[pending[1]]}" + + +def _analyze_szz_forward_flow(ops: list[SurfaceCircuitStep]) -> SzzForwardFlowSummary: + """Analyze SZZ pending-Clifford forward-flow pulse accounting. + + The abstract SZZ template intentionally contains free-standing 1q + Cliffords. The SZZ device model executes those Cliffords only as prefixes + of the next SZZ/SZZdg or MZ on that qubit. Virtual-Z pending Cliffords carry + through SZZ/SZZdg and are discarded at MZ. + """ + pending_by_qubit: dict[int, tuple[int, int]] = {} + pulses: list[SzzForwardFlowPulse] = [] + abstract_single_qubit_ops = 0 + physical_prefix_pulses = 0 + two_qubit_prefix_pulses = 0 + measurement_prefix_pulses = 0 + virtual_z_two_qubit_carries = 0 + virtual_z_measure_discards = 0 + two_qubit_gates = 0 + measurements = 0 + prep_events = 0 + + def pending_for(q: int) -> tuple[int, int]: + return pending_by_qubit.setdefault(q, _SZZ_FLOW_IDENTITY) + + def pulse_event( + *, + host_index: int, + op: SurfaceCircuitStep, + qubit: int, + kind: str, + pending: tuple[int, int], + ) -> SzzForwardFlowPulse: + return SzzForwardFlowPulse( + host_index=host_index, + host_op_type=op.op_type.name, + host_label=op.label, + qubit=qubit, + kind=kind, + pending_clifford=_szz_flow_clifford_name(pending), + ) + + def reset_for_prep(q: int, op: SurfaceCircuitStep) -> None: + current = pending_for(q) + if current != _SZZ_FLOW_IDENTITY: + msg = ( + "SZZ forward-flow cannot reset a qubit with a pending " + f"Clifford: q={q}, pending={_szz_flow_clifford_name(current)}, " + f"op={op.op_type.name} {op.label!r}" + ) + raise ValueError(msg) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge_for_two_qubit(q: int, host_index: int, op: SurfaceCircuitStep) -> None: + nonlocal physical_prefix_pulses, two_qubit_prefix_pulses, virtual_z_two_qubit_carries + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return + if _szz_flow_is_virtual_z(current): + virtual_z_two_qubit_carries += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="virtual_z_two_qubit_carry", + pending=current, + ), + ) + return + physical_prefix_pulses += 1 + two_qubit_prefix_pulses += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="physical_two_qubit_prefix", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge_for_measurement(q: int, host_index: int, op: SurfaceCircuitStep) -> None: + nonlocal physical_prefix_pulses, measurement_prefix_pulses, virtual_z_measure_discards + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return + if _szz_flow_is_virtual_z(current): + virtual_z_measure_discards += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="virtual_z_measure_discard", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + return + physical_prefix_pulses += 1 + measurement_prefix_pulses += 1 + pulses.append( + pulse_event( + host_index=host_index, + op=op, + qubit=q, + kind="physical_measurement_prefix", + pending=current, + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + for host_index, op in enumerate(ops): + if op.op_type in {OpType.COMMENT, OpType.TICK, OpType.TRACKED_PAULI}: + continue + if op.op_type in {OpType.ALLOC, OpType.PREP}: + prep_events += 1 + reset_for_prep(op.qubits[0], op) + continue + if op.op_type in _SZZ_FLOW_SINGLE_QUBIT_GATES: + q = op.qubits[0] + abstract_single_qubit_ops += 1 + pending_by_qubit[q] = _szz_flow_compose_pending_gate(pending_for(q), op.op_type) + continue + if op.op_type in {OpType.SZZ, OpType.SZZDG}: + two_qubit_gates += 1 + for q in op.qubits: + discharge_for_two_qubit(q, host_index, op) + continue + if op.op_type == OpType.CX: + msg = "SZZ forward-flow analysis only supports SZZ/SZZdg two-qubit gates" + raise ValueError(msg) + if op.op_type == OpType.MEASURE: + measurements += 1 + discharge_for_measurement(op.qubits[0], host_index, op) + continue + + remaining = {q: pending for q, pending in pending_by_qubit.items() if pending != _SZZ_FLOW_IDENTITY} + if remaining: + formatted = {q: _szz_flow_clifford_name(pending) for q, pending in sorted(remaining.items())} + msg = f"SZZ forward-flow ended with pending Cliffords: {formatted}" + raise ValueError(msg) + + return SzzForwardFlowSummary( + abstract_single_qubit_ops=abstract_single_qubit_ops, + physical_prefix_pulses=physical_prefix_pulses, + two_qubit_prefix_pulses=two_qubit_prefix_pulses, + measurement_prefix_pulses=measurement_prefix_pulses, + virtual_z_two_qubit_carries=virtual_z_two_qubit_carries, + virtual_z_measure_discards=virtual_z_measure_discards, + two_qubit_gates=two_qubit_gates, + measurements=measurements, + prep_events=prep_events, + free_standing_single_qubit_ops=0, + pulses=tuple(pulses), + ) + + def build_surface_code_circuit( patch: SurfacePatch, num_rounds: int, @@ -991,6 +1229,7 @@ def append_szz_layer( ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) ops.extend(SurfaceCircuitStep(OpType.MEASURE, [data_q(i)], f"final[{i}]") for i in range(num_data)) + _analyze_szz_forward_flow(ops) return ops diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 6ffece8cf..c6a029b3b 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -12,7 +12,9 @@ from pecos.qec.surface import NoiseModel, SurfacePatch, TwirlConfig from pecos.qec.surface.circuit_builder import ( OpType, + SurfaceCircuitStep, SzzTouchSign, + _analyze_szz_forward_flow, _default_szz_residual_plan, _default_szz_sign_vector, _propagate_compensated_szz_frame_bits, @@ -275,6 +277,110 @@ def test_szz_tick_circuit_uses_named_szz_gates() -> None: assert {"SZZ", "SZZdg", "SX", "SXdg", "SZ", "SZdg"} <= gate_names +def test_szz_forward_flow_merges_h_sandwich_before_host() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.abstract_single_qubit_ops == 2 + assert summary.physical_prefix_pulses == 0 + assert summary.free_standing_single_qubit_ops == 0 + assert summary.pulses == () + + +def test_szz_forward_flow_carries_virtual_z_to_measurement() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.SZ, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.physical_prefix_pulses == 0 + assert summary.virtual_z_two_qubit_carries == 1 + assert summary.virtual_z_measure_discards == 1 + assert [event.kind for event in summary.pulses] == [ + "virtual_z_two_qubit_carry", + "virtual_z_measure_discard", + ] + + +def test_szz_forward_flow_counts_physical_prefixes_at_hosts() -> None: + ops = [ + SurfaceCircuitStep(OpType.ALLOC, [0]), + SurfaceCircuitStep(OpType.ALLOC, [1]), + SurfaceCircuitStep(OpType.H, [0]), + SurfaceCircuitStep(OpType.SZZ, [0, 1], "g"), + SurfaceCircuitStep(OpType.H, [1]), + SurfaceCircuitStep(OpType.MEASURE, [0], "m0"), + SurfaceCircuitStep(OpType.MEASURE, [1], "m1"), + ] + + summary = _analyze_szz_forward_flow(ops) + + assert summary.physical_prefix_pulses == 2 + assert summary.two_qubit_prefix_pulses == 1 + assert summary.measurement_prefix_pulses == 1 + assert [event.kind for event in summary.pulses] == [ + "physical_two_qubit_prefix", + "physical_measurement_prefix", + ] + + +@pytest.mark.parametrize( + ("basis", "expected"), + [ + ( + "Z", + { + "abstract_single_qubit_ops": 108, + "physical_prefix_pulses": 54, + "two_qubit_prefix_pulses": 38, + "measurement_prefix_pulses": 16, + "virtual_z_two_qubit_carries": 10, + "virtual_z_measure_discards": 5, + }, + ), + ( + "X", + { + "abstract_single_qubit_ops": 102, + "physical_prefix_pulses": 54, + "two_qubit_prefix_pulses": 37, + "measurement_prefix_pulses": 17, + "virtual_z_two_qubit_carries": 10, + "virtual_z_measure_discards": 3, + }, + ), + ], +) +def test_szz_forward_flow_surface_pulse_count_snapshot(basis: str, expected: dict[str, int]) -> None: + patch = SurfacePatch.create(distance=3) + ops, _ = build_surface_code_circuit(patch, num_rounds=1, basis=basis, interaction_basis="szz") + + summary = _analyze_szz_forward_flow(ops) + + assert summary.two_qubit_gates == 36 + assert summary.measurements == 21 + assert summary.prep_events == 21 + assert summary.free_standing_single_qubit_ops == 0 + for field, value in expected.items(): + assert getattr(summary, field) == value + assert summary.physical_prefix_pulses < summary.abstract_single_qubit_ops + + def test_szz_direct_renderers_accept_interaction_basis() -> None: patch = SurfacePatch.create(distance=3) From 63f19035dd5b23dfa0ca8bd1dcb72a2c77d70327 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 21:07:02 -0600 Subject: [PATCH 127/388] Guard SZZ surface DEM pulse noise --- .../src/pecos/qec/surface/decode.py | 23 ++++++++++++ .../qec/surface/test_szz_interaction_basis.py | 35 +++++++++++++++++-- .../tests/qec/test_from_guppy_dem.py | 2 +- 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ac003292c..0772bc9b1 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1579,6 +1579,27 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: ) +def _reject_szz_unlowered_physical_noise(noise: NoiseModel, interaction_basis: str) -> None: + """Reject SZZ surface DEM noise that still needs post-flow pulse locations.""" + if interaction_basis != "szz": + return + reasons: list[str] = [] + if noise.p1 > 0.0: + reasons.append("p1") + if _noise_uses_dedicated_idle_noise(noise): + reasons.append("dedicated idle noise") + if not reasons: + return + joined = ", ".join(reasons) + msg = ( + "interaction_basis='szz' surface DEM generation does not yet support " + f"{joined} because the DEM must use post-flow prefix pulse locations " + "rather than the abstract H/SX/SZ scaffold; set p1=0 and omit " + "dedicated idle noise until SZZ pulse-location DEM lowering is enabled" + ) + raise ValueError(msg) + + def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: """Call Rust ``with_noise`` using the richest signature this binding supports.""" noise_kwargs = { @@ -1985,6 +2006,7 @@ def generate_circuit_level_dem_from_builder( from pecos.qec.surface.circuit_builder import _normalize_interaction_basis interaction_basis = _normalize_interaction_basis(interaction_basis) + _reject_szz_unlowered_physical_noise(noise, interaction_basis) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) if runtime is not None: @@ -3759,6 +3781,7 @@ def build_native_sampler( from pecos.qec.surface.circuit_builder import _normalize_interaction_basis interaction_basis = _normalize_interaction_basis(interaction_basis) + _reject_szz_unlowered_physical_noise(noise, interaction_basis) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index c6a029b3b..087d8d98e 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -27,7 +27,7 @@ generate_stim_from_patch, generate_tick_circuit_from_patch, ) -from pecos.qec.surface.decode import build_memory_circuit, generate_circuit_level_dem_from_builder +from pecos.qec.surface.decode import build_memory_circuit, build_native_sampler, generate_circuit_level_dem_from_builder def _to_numpy_complex(matrix: object) -> np.ndarray: @@ -448,7 +448,7 @@ def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> def test_szz_native_dem_path_uses_interaction_basis() -> None: patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001) + noise = NoiseModel(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}, p_meas=0.001, p_prep=0.001) cx_dem = generate_circuit_level_dem_from_builder( patch, @@ -465,3 +465,34 @@ def test_szz_native_dem_path_uses_interaction_basis() -> None: assert cx_dem != szz_dem assert stim.DetectorErrorModel(szz_dem).num_detectors > 0 + + +@pytest.mark.parametrize( + ("noise", "match"), + [ + (NoiseModel(p1=0.001), r"p1.*post-flow prefix pulse locations"), + (NoiseModel(p_idle=0.001), r"dedicated idle noise.*post-flow prefix pulse locations"), + ], +) +def test_szz_native_dem_rejects_unlowered_physical_noise(noise: NoiseModel, match: str) -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match=match): + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + interaction_basis="szz", + ) + + +def test_szz_native_sampler_rejects_unlowered_physical_noise() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match=r"p1.*post-flow prefix pulse locations"): + build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + ) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index b461b650c..d3dcdf116 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -499,7 +499,7 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: @pytest.mark.parametrize("distance", [3, 5]) def test_from_guppy_szz_surface_code_is_byte_identical_to_reference(distance: int) -> None: """SZZ-basis surface Guppy generation must match the traced-QIS reference DEM.""" - p = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + p = {"p1": 0.0, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} for basis in ("Z", "X"): patch = SurfacePatch.create(distance=distance) ref = _build_surface_tick_circuit_for_native_model( From 67d90e79822f0cdaae97d46bcaa32e581c85d5f8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 21:18:04 -0600 Subject: [PATCH 128/388] Add SZZ gate-specific noise rates --- .../fault_tolerance/dem_builder/builder.rs | 5 +- .../dem_builder/dem_sampler.rs | 13 +++- .../fault_tolerance/dem_builder/sampler.rs | 2 +- .../src/fault_tolerance/dem_builder/types.rs | 46 ++++++++++++-- .../src/fault_tolerance_bindings.rs | 49 +++++++++++++-- .../src/pecos/qec/surface/decode.py | 37 +++++++++++ .../qec/surface/test_szz_interaction_basis.py | 61 +++++++++++++++++++ 7 files changed, 198 insertions(+), 15 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index ab0b8fa5a..eae98d6f4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -434,6 +434,7 @@ impl<'a> DemBuilder<'a> { let p1 = flat / 4; let p2 = flat % 4; let pauli = pauli_pair_for_weight(p1, p2); + let p2_total = self.noise.p2_rate_for_gate(loc1.gate_type); let weight = if self.noise.p2_replacement_approximation == ReplacementBranchApproximation::BranchImpact || self.noise.p2_replacement_approximation @@ -447,10 +448,10 @@ impl<'a> DemBuilder<'a> { self.noise.p2_replacement_approximation, ) }; - self.noise.p2 * weight + p2_total * weight }); } - [per_channel_probability(self.noise.p2, 15); 15] + [per_channel_probability(self.noise.p2_rate_for_gate(loc1.gate_type), 15); 15] } /// Sets the number of measurements (used for record offset calculation). diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index 4fe4b1de7..d788c6508 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -1848,6 +1848,7 @@ pub(crate) struct SamplingEngineBuilder<'a> { influence_map: &'a DagFaultInfluenceMap, p1: f64, p2: f64, + p2_gate_rates: BTreeMap, p_meas: f64, p_prep: f64, p1_weights: Option, @@ -1874,6 +1875,7 @@ impl<'a> SamplingEngineBuilder<'a> { influence_map, p1: 0.01, p2: 0.01, + p2_gate_rates: BTreeMap::new(), p_meas: 0.01, p_prep: 0.01, p1_weights: None, @@ -1893,6 +1895,7 @@ impl<'a> SamplingEngineBuilder<'a> { pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { self.p1 = p1; self.p2 = p2; + self.p2_gate_rates.clear(); self.p_meas = p_meas; self.p_prep = p_prep; self.p1_weights = None; @@ -1907,6 +1910,7 @@ impl<'a> SamplingEngineBuilder<'a> { pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { self.p1 = noise.p1; self.p2 = noise.p2; + self.p2_gate_rates = noise.p2_gate_rates.clone(); self.p_meas = noise.p_meas; self.p_prep = noise.p_prep; self.p1_weights = noise.p1_weights.clone(); @@ -2134,7 +2138,9 @@ impl<'a> SamplingEngineBuilder<'a> { } // Process two-qubit gates as pairs - let has_any_2q_noise = self.per_gate.is_some() || self.p2 > 0.0; + let has_any_2q_noise = self.per_gate.is_some() + || self.p2 > 0.0 + || self.p2_gate_rates.values().any(|rate| *rate > 0.0); if has_any_2q_noise { for loc_indices in cx_groups.values() { for pair in loc_indices.chunks(2) { @@ -2380,12 +2386,13 @@ impl<'a> SamplingEngineBuilder<'a> { std::array::from_fn(|i| pg.rate_2q(gate, i)) } } else { + let p2_total = self.p2_gate_rates.get(&gate).copied().unwrap_or(self.p2); if let Some(weights) = &self.p2_weights { return std::array::from_fn(|idx| { let flat = idx + 1; let p1 = flat / 4; let p2 = flat % 4; - self.p2 + p2_total * weights.two_qubit_weight_for( gate, &pauli_pair_for_weight(p1, p2), @@ -2393,7 +2400,7 @@ impl<'a> SamplingEngineBuilder<'a> { ) }); } - [self.p2 / 15.0; 15] + [p2_total / 15.0; 15] } } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 6bd954490..6018d82f3 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1441,7 +1441,7 @@ pub(crate) fn compute_location_probs_from_noise( | GateType::SWAP | GateType::RXX | GateType::RYY - | GateType::RZZ => noise.p2, + | GateType::RZZ => noise.p2_rate_for_gate(loc.gate_type), GateType::Idle => { if noise.uses_dedicated_idle_noise() { let duration = loc.idle_duration.max(0.0); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 51025ff67..66ca3ef38 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2437,6 +2437,12 @@ pub struct NoiseConfig { pub p1: f64, /// Two-qubit gate error rate. pub p2: f64, + /// Optional per-gate total error-rate overrides for two-qubit gates. + /// + /// When a two-qubit gate type appears here, this total rate replaces + /// `p2` while still using `p2_weights` to distribute probability across + /// Pauli-pair channels. + pub p2_gate_rates: BTreeMap, /// Measurement error rate. pub p_meas: f64, /// Initialization (prep) error rate. @@ -2660,6 +2666,7 @@ impl Default for NoiseConfig { Self { p1: 0.01, p2: 0.01, + p2_gate_rates: BTreeMap::new(), p_meas: 0.01, p_prep: 0.01, p_idle: 0.0, @@ -2693,6 +2700,7 @@ impl NoiseConfig { Self { p1, p2, + p2_gate_rates: BTreeMap::new(), p_meas, p_prep, p_idle: 0.0, @@ -2724,6 +2732,7 @@ impl NoiseConfig { Self { p1, p2, + p2_gate_rates: BTreeMap::new(), p_meas, p_prep, p_idle, @@ -2755,6 +2764,7 @@ impl NoiseConfig { Self { p1: p, p2: p, + p2_gate_rates: BTreeMap::new(), p_meas: p, p_prep: p, p_idle: p, @@ -2894,6 +2904,32 @@ impl NoiseConfig { self } + /// Sets a total two-qubit error-rate override for one gate type. + /// + /// The override changes only the total rate. If `p2_weights` is configured, + /// those weights still determine the relative Pauli-pair distribution for + /// this gate. + #[must_use] + pub fn set_p2_gate_rate(mut self, gate_type: GateType, rate: f64) -> Self { + self.p2_gate_rates.insert(gate_type, rate.max(0.0)); + self + } + + /// Returns the total two-qubit error rate for `gate_type`. + #[must_use] + pub fn p2_rate_for_gate(&self, gate_type: GateType) -> f64 { + self.p2_gate_rates + .get(&gate_type) + .copied() + .unwrap_or(self.p2) + } + + /// Returns true when any scalar or per-gate two-qubit rate is positive. + #[must_use] + pub fn has_any_p2_noise(&self) -> bool { + self.p2 > 0.0 || self.p2_gate_rates.values().any(|rate| *rate > 0.0) + } + /// Sets how replacement entries in `p2_weights` are approximated. #[must_use] pub fn set_p2_replacement_approximation( @@ -3542,18 +3578,20 @@ impl PerGateTypeNoise { self.rate_1q(gate, pauli_idx) } - /// Lookup 2Q Pauli pair rate for a gate. Returns `base.p2 / 15.0` - /// if the gate type is not in the map. `pair_idx` follows [`PAULI_2Q_ORDER`]. + /// Lookup 2Q Pauli pair rate for a gate. Returns the base two-qubit gate + /// rate divided over the 15 Pauli pairs if the gate type is not in the + /// map. `pair_idx` follows [`PAULI_2Q_ORDER`]. #[must_use] pub fn rate_2q(&self, gate: GateType, pair_idx: usize) -> f64 { self.rates_2q .get(&gate) - .map_or(self.base.p2 / 15.0, |r| r[pair_idx]) + .map_or(self.base.p2_rate_for_gate(gate) / 15.0, |r| r[pair_idx]) } /// Lookup 2Q Pauli pair rate for a gate on a specific ordered /// qubit pair. Tries `(gate, q_control, q_target)` in the per-qubits - /// map first, then the per-gate-type map, then `base.p2 / 15.0`. + /// map first, then the per-gate-type map, then the base two-qubit gate + /// rate divided over the 15 Pauli pairs. #[must_use] pub fn rate_2q_on( &self, diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 80b0831e8..5a12e518e 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -43,6 +43,7 @@ //! ``` use crate::pecos_array::{Array, ArrayData}; +use pecos_core::gate_type::GateType; use pecos_qec::fault_tolerance::PauliFrameLookup as RustPauliFrameLookup; use pecos_qec::fault_tolerance::dem_builder::{ ComparisonMethod as RustComparisonMethod, @@ -71,6 +72,7 @@ use pecos_quantum::QubitId; use pyo3::Py; use pyo3::prelude::*; use std::collections::BTreeMap; +use std::str::FromStr; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); @@ -203,6 +205,27 @@ fn parse_replacement_approximation( } } +fn parse_p2_gate_rates(rates: BTreeMap) -> PyResult> { + let mut parsed = BTreeMap::new(); + for (label, rate) in rates { + if !rate.is_finite() || rate < 0.0 { + let msg = + format!("p2_gate_rates[{label:?}] must be finite and non-negative, got {rate}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let gate_type = GateType::from_str(label.trim()).map_err(|err| { + let msg = format!("unsupported p2_gate_rates gate label {label:?}: {err}"); + pyo3::exceptions::PyValueError::new_err(msg) + })?; + if !gate_type.is_two_qubit() { + let msg = format!("p2_gate_rates keys must name two-qubit gates, got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + parsed.insert(gate_type, rate); + } + Ok(parsed) +} + fn parse_measurement_crosstalk_dem_mode( value: Option, ) -> PyResult { @@ -281,6 +304,7 @@ fn apply_noise_options( p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -336,6 +360,11 @@ fn apply_noise_options( if let Some(weights) = p2_weights { noise = noise.set_p2_weights(parse_p2_weights(weights)?); } + if let Some(rates) = p2_gate_rates { + for (gate_type, rate) in parse_p2_gate_rates(rates)? { + noise = noise.set_p2_gate_rate(gate_type, rate); + } + } noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( p2_replacement_approximation, )?); @@ -1426,7 +1455,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1452,6 +1481,7 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -1480,6 +1510,7 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, @@ -1836,7 +1867,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1862,6 +1893,7 @@ impl PyDemBuilder { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -1888,6 +1920,7 @@ impl PyDemBuilder { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, @@ -3755,7 +3788,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3781,6 +3814,7 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -3807,6 +3841,7 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, @@ -3922,7 +3957,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3950,6 +3985,7 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -3976,6 +4012,7 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, @@ -4491,7 +4528,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4517,6 +4554,7 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, + p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, @@ -4543,6 +4581,7 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, + p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 0772bc9b1..244f07837 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -96,6 +96,10 @@ class NoiseModel: error labels ``"X"``, ``"Y"``, and ``"Z"``. Values must sum to 1.0; ``p1`` remains the total single-qubit error rate. p2: Two-qubit gate error rate. + p2_szz: Optional total error-rate override for ``SZZ`` gates. When + unset, ``SZZ`` uses ``p2``. + p2_szzdg: Optional total error-rate override for ``SZZdg`` gates. When + unset, ``SZZdg`` uses ``p2``. p2_weights: Optional relative probabilities over two-qubit Pauli error labels. Plain labels such as ``"XX"`` are post-gate Pauli branches; labels prefixed by ``"*"`` such as ``"*XX"`` are replacement @@ -134,6 +138,8 @@ class NoiseModel: p1: float = 0.0 p1_weights: P1Weights | None = None p2: float = 0.0 + p2_szz: float | None = None + p2_szzdg: float | None = None p2_weights: P2Weights | None = None p2_replacement_approximation: str | None = None p_meas: float = 0.0 @@ -158,6 +164,10 @@ def __post_init__(self) -> None: """Normalize cache-sensitive inputs after dataclass initialization.""" self.p1_weights = _normalize_p1_weights(self.p1_weights) self.p2_weights = _normalize_p2_weights(self.p2_weights) + if self.p2_szz is not None: + self.p2_szz = _validate_probability("p2_szz", self.p2_szz) + if self.p2_szzdg is not None: + self.p2_szzdg = _validate_probability("p2_szzdg", self.p2_szzdg) @property def effective_p_idle_z_linear_rate(self) -> float | None: @@ -191,6 +201,11 @@ def idle_memory_rates(self) -> tuple[float | None, ...]: self.effective_p_idle_z_quadratic_sine_rate, ) + @property + def p2_gate_rates(self) -> tuple[float | None, ...]: + """Explicit two-qubit gate-rate overrides.""" + return (self.p2_szz, self.p2_szzdg) + @staticmethod def uniform(physical_error_rate: float) -> NoiseModel: """Create a uniform circuit-level noise model from one physical error rate.""" @@ -203,6 +218,7 @@ def is_noiseless(self) -> bool: return ( self.p1 == 0.0 and self.p2 == 0.0 + and all(rate is None or rate == 0.0 for rate in self.p2_gate_rates) and self.p_meas == 0.0 and self.p_prep == 0.0 and (self.p_idle is None or self.p_idle == 0.0) @@ -213,6 +229,7 @@ def is_noiseless(self) -> bool: def physical_error_rate(self) -> float: """Approximate combined physical error rate.""" rates = [self.p1, self.p2, self.p_meas, self.p_prep] + rates.extend(rate for rate in self.p2_gate_rates if rate is not None) if self.p_idle is not None: rates.append(self.p_idle) rates.extend(rate for rate in self.idle_memory_rates if rate is not None) @@ -244,6 +261,15 @@ def _p2_weights_dict(p2_weights: P2Weights | None) -> dict[str, float] | None: return None if normalized is None else dict(normalized) +def _p2_gate_rates_dict(noise: NoiseModel) -> dict[str, float] | None: + rates: dict[str, float] = {} + if noise.p2_szz is not None: + rates["SZZ"] = noise.p2_szz + if noise.p2_szzdg is not None: + rates["SZZdg"] = noise.p2_szzdg + return rates or None + + @dataclass class DecodingResult: """Result from decoding a single shot.""" @@ -1621,6 +1647,9 @@ def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } + p2_gate_rates = _p2_gate_rates_dict(noise) + if p2_gate_rates is not None: + noise_kwargs["p2_gate_rates"] = p2_gate_rates if noise.p2_replacement_approximation is not None: noise_kwargs["p2_replacement_approximation"] = noise.p2_replacement_approximation @@ -1804,6 +1833,8 @@ def _cached_surface_native_dem_string( p1: float, p1_weights: tuple[tuple[str, float], ...] | None, p2: float, + p2_szz: float | None, + p2_szzdg: float | None, p_meas: float, p_prep: float, decompose_errors: bool, @@ -1861,6 +1892,8 @@ def _cached_surface_native_dem_string( p1=p1, p1_weights=p1_weights, p2=p2, + p2_szz=p2_szz, + p2_szzdg=p2_szzdg, p2_weights=p2_weights, p2_replacement_approximation=p2_replacement_approximation, p_meas=p_meas, @@ -2036,6 +2069,8 @@ def generate_circuit_level_dem_from_builder( noise.p1, noise.p1_weights, noise.p2, + noise.p2_szz, + noise.p2_szzdg, noise.p_meas, noise.p_prep, decompose_errors=decompose_errors, @@ -3804,6 +3839,8 @@ def build_native_sampler( noise.p1, noise.p1_weights, noise.p2, + noise.p2_szz, + noise.p2_szzdg, noise.p_meas, noise.p_prep, decompose_errors=True, diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 087d8d98e..80287125c 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -467,6 +467,67 @@ def test_szz_native_dem_path_uses_interaction_basis() -> None: assert stim.DetectorErrorModel(szz_dem).num_detectors > 0 +def test_szz_native_dem_respects_gate_specific_p2_overrides() -> None: + patch = SurfacePatch.create(distance=3) + + inherited_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.01, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + ) + no_szz_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.01, p2_szz=0.0, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + ) + no_szzdg_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.01, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + ) + override_only_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=NoiseModel( + p1=0.0, + p2=0.0, + p2_szz=0.01, + p2_szzdg=0.01, + p2_weights={"ZI": 1.0}, + ), + interaction_basis="szz", + ) + + assert no_szz_dem != inherited_dem + assert no_szzdg_dem != inherited_dem + assert "error(" in override_only_dem + + +def test_szz_native_influence_sampler_respects_override_only_p2() -> None: + patch = SurfacePatch.create(distance=3) + + zero_sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.0, p2_szz=0.0, p2_szzdg=0.0, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + sampling_model="influence_dem", + ) + active_sampler = build_native_sampler( + patch, + num_rounds=2, + noise=NoiseModel(p1=0.0, p2=0.0, p2_szz=0.01, p2_szzdg=0.01, p2_weights={"ZI": 1.0}), + interaction_basis="szz", + sampling_model="influence_dem", + ) + + assert "mechanisms=0" in repr(zero_sampler.sampler) + assert "mechanisms=0" not in repr(active_sampler.sampler) + + @pytest.mark.parametrize( ("noise", "match"), [ From e7e303043864f1adc19d4061521a95ff7cdf69b7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 21:33:30 -0600 Subject: [PATCH 129/388] Add SZZ prefix p1 lowering --- .../fault_tolerance/dem_builder/builder.rs | 9 +- .../dem_builder/dem_sampler.rs | 13 +- .../fault_tolerance/dem_builder/sampler.rs | 2 +- .../src/fault_tolerance/dem_builder/types.rs | 40 ++++- .../src/fault_tolerance_bindings.rs | 47 +++++- .../src/pecos/qec/surface/circuit_builder.py | 153 ++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 71 ++++++-- .../qec/surface/test_szz_interaction_basis.py | 52 +++++- 8 files changed, 355 insertions(+), 32 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index eae98d6f4..c4d736f90 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -370,15 +370,16 @@ impl<'a> DemBuilder<'a> { pg.rate_1q(loc.gate_type, 2), ]; } + let p1_total = self.noise.p1_rate_for_gate(loc.gate_type); if let Some(weights) = &self.noise.p1_weights { use pecos_core::pauli::{X, Y, Z}; return [ - self.noise.p1 * weights.weight_for(&X(0)), - self.noise.p1 * weights.weight_for(&Y(0)), - self.noise.p1 * weights.weight_for(&Z(0)), + p1_total * weights.weight_for(&X(0)), + p1_total * weights.weight_for(&Y(0)), + p1_total * weights.weight_for(&Z(0)), ]; } - let per = per_channel_probability(self.noise.p1, 3); + let per = per_channel_probability(p1_total, 3); [per, per, per] } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index d788c6508..637f60cf4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -1847,6 +1847,7 @@ pub(crate) struct SamplingEngineBuilder<'a> { per_gate: Option, influence_map: &'a DagFaultInfluenceMap, p1: f64, + p1_gate_rates: BTreeMap, p2: f64, p2_gate_rates: BTreeMap, p_meas: f64, @@ -1874,6 +1875,7 @@ impl<'a> SamplingEngineBuilder<'a> { Self { influence_map, p1: 0.01, + p1_gate_rates: BTreeMap::new(), p2: 0.01, p2_gate_rates: BTreeMap::new(), p_meas: 0.01, @@ -1894,6 +1896,7 @@ impl<'a> SamplingEngineBuilder<'a> { #[must_use] pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { self.p1 = p1; + self.p1_gate_rates.clear(); self.p2 = p2; self.p2_gate_rates.clear(); self.p_meas = p_meas; @@ -1909,6 +1912,7 @@ impl<'a> SamplingEngineBuilder<'a> { #[must_use] pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { self.p1 = noise.p1; + self.p1_gate_rates = noise.p1_gate_rates.clone(); self.p2 = noise.p2; self.p2_gate_rates = noise.p2_gate_rates.clone(); self.p_meas = noise.p_meas; @@ -2331,15 +2335,16 @@ impl<'a> SamplingEngineBuilder<'a> { ] } } else { + let p1_total = self.p1_gate_rates.get(&gate).copied().unwrap_or(self.p1); if let Some(weights) = &self.p1_weights { use pecos_core::pauli::{X, Y, Z}; return [ - self.p1 * weights.weight_for(&X(0)), - self.p1 * weights.weight_for(&Y(0)), - self.p1 * weights.weight_for(&Z(0)), + p1_total * weights.weight_for(&X(0)), + p1_total * weights.weight_for(&Y(0)), + p1_total * weights.weight_for(&Z(0)), ]; } - [self.p1 / 3.0; 3] + [p1_total / 3.0; 3] } } diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 6018d82f3..3da43b5b8 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1450,7 +1450,7 @@ pub(crate) fn compute_location_probs_from_noise( 0.0 } } - _ => noise.p1, + _ => noise.p1_rate_for_gate(loc.gate_type), } }) .collect() diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 66ca3ef38..84b824810 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2435,6 +2435,12 @@ pub fn omitted_two_qubit_gate_pauli_twirl( pub struct NoiseConfig { /// Single-qubit gate error rate. pub p1: f64, + /// Optional per-gate total error-rate overrides for single-qubit gates. + /// + /// When a single-qubit gate type appears here, this total rate replaces + /// `p1` while still using `p1_weights` to distribute probability across + /// Pauli channels. + pub p1_gate_rates: BTreeMap, /// Two-qubit gate error rate. pub p2: f64, /// Optional per-gate total error-rate overrides for two-qubit gates. @@ -2665,6 +2671,7 @@ impl Default for NoiseConfig { fn default() -> Self { Self { p1: 0.01, + p1_gate_rates: BTreeMap::new(), p2: 0.01, p2_gate_rates: BTreeMap::new(), p_meas: 0.01, @@ -2699,6 +2706,7 @@ impl NoiseConfig { pub fn new(p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { Self { p1, + p1_gate_rates: BTreeMap::new(), p2, p2_gate_rates: BTreeMap::new(), p_meas, @@ -2731,6 +2739,7 @@ impl NoiseConfig { pub fn with_idle(p1: f64, p2: f64, p_meas: f64, p_prep: f64, p_idle: f64) -> Self { Self { p1, + p1_gate_rates: BTreeMap::new(), p2, p2_gate_rates: BTreeMap::new(), p_meas, @@ -2763,6 +2772,7 @@ impl NoiseConfig { pub fn uniform(p: f64) -> Self { Self { p1: p, + p1_gate_rates: BTreeMap::new(), p2: p, p2_gate_rates: BTreeMap::new(), p_meas: p, @@ -2897,6 +2907,26 @@ impl NoiseConfig { self } + /// Sets a total single-qubit error-rate override for one gate type. + /// + /// The override changes only the total rate. If `p1_weights` is configured, + /// those weights still determine the relative Pauli distribution for this + /// gate. + #[must_use] + pub fn set_p1_gate_rate(mut self, gate_type: GateType, rate: f64) -> Self { + self.p1_gate_rates.insert(gate_type, rate.max(0.0)); + self + } + + /// Returns the total single-qubit error rate for `gate_type`. + #[must_use] + pub fn p1_rate_for_gate(&self, gate_type: GateType) -> f64 { + self.p1_gate_rates + .get(&gate_type) + .copied() + .unwrap_or(self.p1) + } + /// Sets custom per-Pauli weights for two-qubit gates. #[must_use] pub fn set_p2_weights(mut self, weights: PauliWeights) -> Self { @@ -3542,8 +3572,9 @@ impl PerGateTypeNoise { self } - /// Lookup 1Q Pauli rate for a gate. Returns `base.p1 / 3.0` if the - /// gate type is not in the map. `pauli_idx` is 0=X, 1=Y, 2=Z. + /// Lookup 1Q Pauli rate for a gate. Returns the base single-qubit gate + /// rate divided over the 3 Pauli channels if the gate type is not in the + /// map. `pauli_idx` is 0=X, 1=Y, 2=Z. /// /// `Idle` is a no-op by default. It receives noise only from explicitly /// attached idle rates or from the base idle-noise model. @@ -3564,11 +3595,12 @@ impl PerGateTypeNoise { } return 0.0; } - self.base.p1 / 3.0 + self.base.p1_rate_for_gate(gate) / 3.0 } /// Lookup 1Q Pauli rate for a gate on a specific qubit. Tries the - /// per-qubit map first, then the per-gate-type map, then `base.p1 / 3.0`. + /// per-qubit map first, then the per-gate-type map, then the base + /// single-qubit gate rate divided over the 3 Pauli channels. /// `pauli_idx` is 0=X, 1=Y, 2=Z. #[must_use] pub fn rate_1q_on(&self, gate: GateType, qubit: QubitId, pauli_idx: usize) -> f64 { diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 5a12e518e..402e594b4 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -226,6 +226,27 @@ fn parse_p2_gate_rates(rates: BTreeMap) -> PyResult) -> PyResult> { + let mut parsed = BTreeMap::new(); + for (label, rate) in rates { + if !rate.is_finite() || rate < 0.0 { + let msg = + format!("p1_gate_rates[{label:?}] must be finite and non-negative, got {rate}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + let gate_type = GateType::from_str(label.trim()).map_err(|err| { + let msg = format!("unsupported p1_gate_rates gate label {label:?}: {err}"); + pyo3::exceptions::PyValueError::new_err(msg) + })?; + if !gate_type.is_single_qubit() { + let msg = format!("p1_gate_rates keys must name single-qubit gates, got {label:?}"); + return Err(pyo3::exceptions::PyValueError::new_err(msg)); + } + parsed.insert(gate_type, rate); + } + Ok(parsed) +} + fn parse_measurement_crosstalk_dem_mode( value: Option, ) -> PyResult { @@ -310,6 +331,7 @@ fn apply_noise_options( p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); if let (Some(t1_val), Some(t2_val)) = (t1, t2) { @@ -365,6 +387,11 @@ fn apply_noise_options( noise = noise.set_p2_gate_rate(gate_type, rate); } } + if let Some(rates) = p1_gate_rates { + for (gate_type, rate) in parse_p1_gate_rates(rates)? { + noise = noise.set_p1_gate_rate(gate_type, rate); + } + } noise = noise.set_p2_replacement_approximation(parse_replacement_approximation( p2_replacement_approximation, )?); @@ -1455,7 +1482,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1487,6 +1514,7 @@ impl PyDetectorErrorModel { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; @@ -1516,6 +1544,7 @@ impl PyDetectorErrorModel { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; if let Ok(dag) = circuit.extract::>() @@ -1867,7 +1896,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1899,6 +1928,7 @@ impl PyDemBuilder { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -1926,6 +1956,7 @@ impl PyDemBuilder { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; Ok(slf) } @@ -3788,7 +3819,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3820,6 +3851,7 @@ impl PyDemSampler { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -3847,6 +3879,7 @@ impl PyDemSampler { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; // Accept both DagCircuit and TickCircuit @@ -3957,7 +3990,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -3991,6 +4024,7 @@ impl PyDemSampler { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult { let noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -4018,6 +4052,7 @@ impl PyDemSampler { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) .with_noise_config(noise) @@ -4528,7 +4563,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4560,6 +4595,7 @@ impl PyDemSamplerBuilder { p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p1_gate_rates: Option>, ) -> PyResult> { slf.noise = apply_noise_options( NoiseConfig::new(p1, p2, p_meas, p_prep), @@ -4587,6 +4623,7 @@ impl PyDemSamplerBuilder { p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p1_gate_rates, )?; Ok(slf) } diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b1de6681c..f4adb7d0f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -115,8 +115,12 @@ class OpType(Enum): # Single-qubit gates H = auto() # Hadamard + F = auto() # face Clifford + FDG = auto() # face Clifford dagger SX = auto() # sqrt X SXDG = auto() # sqrt X dagger + SY = auto() # sqrt Y + SYDG = auto() # sqrt Y dagger SZ = auto() # sqrt Z / phase SZDG = auto() # sqrt Z dagger X = auto() # Pauli X @@ -428,8 +432,12 @@ def _propagate_sxx_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tup } _SZZ_FLOW_GATE_ACTIONS: dict[OpType, dict[int, int]] = { OpType.H: {1: 3, 2: -2, 3: 1}, + OpType.F: {1: 2, 2: 3, 3: 1}, + OpType.FDG: {1: 3, 2: 1, 3: 2}, OpType.SX: {1: 1, 2: 3, 3: -2}, OpType.SXDG: {1: 1, 2: -3, 3: 2}, + OpType.SY: {1: -3, 2: 2, 3: 1}, + OpType.SYDG: {1: 3, 2: 2, 3: -1}, OpType.SZ: {1: 2, 2: -1, 3: 3}, OpType.SZDG: {1: -2, 2: 1, 3: 3}, OpType.X: {1: 1, 2: -2, 3: -3}, @@ -617,6 +625,107 @@ def discharge_for_measurement(q: int, host_index: int, op: SurfaceCircuitStep) - ) +_SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING: dict[tuple[int, int], tuple[OpType | None, OpType]] = { + (3, 1): (None, OpType.H), + (-3, 1): (None, OpType.SY), + (2, 1): (None, OpType.F), + (-2, 1): (OpType.Z, OpType.F), +} + + +def _lower_szz_forward_flow_ops(ops: list[SurfaceCircuitStep]) -> list[SurfaceCircuitStep]: + """Return an SZZ physical-prefix TickCircuit op stream. + + Free-standing single-qubit Cliffords in the abstract SZZ template are + accumulated into a pending local Clifford and discharged as a physical + prefix pulse on the next SZZ/SZZdg or MZ host. Zero-noise Z-frame gates are + emitted only when needed to preserve the exact signed Clifford before a + physical prefix pulse. + """ + pending_by_qubit: dict[int, tuple[int, int]] = {} + lowered: list[SurfaceCircuitStep] = [] + + def pending_for(q: int) -> tuple[int, int]: + return pending_by_qubit.setdefault(q, _SZZ_FLOW_IDENTITY) + + def reset_for_prep(q: int, op: SurfaceCircuitStep) -> None: + current = pending_for(q) + if current != _SZZ_FLOW_IDENTITY: + msg = ( + "SZZ forward-flow cannot reset a qubit with a pending " + f"Clifford: q={q}, pending={_szz_flow_clifford_name(current)}, " + f"op={op.op_type.name} {op.label!r}" + ) + raise ValueError(msg) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + def discharge(q: int, host: SurfaceCircuitStep) -> None: + current = pending_for(q) + if current == _SZZ_FLOW_IDENTITY: + return + if _szz_flow_is_virtual_z(current): + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY if host.op_type == OpType.MEASURE else current + return + try: + virtual_gate, physical_gate = _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[current] + except KeyError as exc: + msg = ( + "SZZ forward-flow cannot lower pending Clifford " + f"{_szz_flow_clifford_name(current)} on q={q} before " + f"{host.op_type.name} {host.label!r}" + ) + raise ValueError(msg) from exc + if virtual_gate is not None: + lowered.append( + SurfaceCircuitStep( + virtual_gate, + [q], + f"szz_virtual_prefix:{virtual_gate.name}:{host.label}:q{q}", + ), + ) + lowered.append( + SurfaceCircuitStep( + physical_gate, + [q], + f"szz_physical_prefix:{physical_gate.name}:{host.label}:q{q}", + ), + ) + pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + + for op in ops: + if op.op_type in {OpType.COMMENT, OpType.TICK, OpType.TRACKED_PAULI}: + lowered.append(op) + continue + if op.op_type in {OpType.ALLOC, OpType.PREP}: + reset_for_prep(op.qubits[0], op) + lowered.append(op) + continue + if op.op_type in _SZZ_FLOW_SINGLE_QUBIT_GATES: + q = op.qubits[0] + pending_by_qubit[q] = _szz_flow_compose_pending_gate(pending_for(q), op.op_type) + continue + if op.op_type in {OpType.SZZ, OpType.SZZDG}: + for q in op.qubits: + discharge(q, op) + lowered.append(op) + continue + if op.op_type == OpType.CX: + msg = "SZZ forward-flow lowering only supports SZZ/SZZdg two-qubit gates" + raise ValueError(msg) + if op.op_type == OpType.MEASURE: + discharge(op.qubits[0], op) + lowered.append(op) + continue + lowered.append(op) + + remaining = {q: pending for q, pending in pending_by_qubit.items() if pending != _SZZ_FLOW_IDENTITY} + if remaining: + formatted = {q: _szz_flow_clifford_name(pending) for q, pending in sorted(remaining.items())} + msg = f"SZZ forward-flow lowering ended with pending Cliffords: {formatted}" + raise ValueError(msg) + return lowered + + def build_surface_code_circuit( patch: SurfacePatch, num_rounds: int, @@ -1982,6 +2091,24 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta["label"] = op.label apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.F: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).f([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.FDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).fdg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.SX: q = op.qubits[0] tick = get_tick_for_qubits([q]).sx([q]) @@ -2000,6 +2127,24 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta["label"] = op.label apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.SY: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sy([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + + elif op.op_type == OpType.SYDG: + q = op.qubits[0] + tick = get_tick_for_qubits([q]).sydg([q]) + mark_qubits_used([q]) + meta = get_ancilla_gate_metadata(q, op.label) + if op.label: + meta["label"] = op.label + apply_gate_metadata(tick, meta or None) + elif op.op_type == OpType.SZ: q = op.qubits[0] tick = get_tick_for_qubits([q]).sz([q]) @@ -2487,6 +2632,7 @@ def generate_tick_circuit_from_patch( ancilla_budget: int | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + szz_physical_prefixes: bool = False, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -2515,11 +2661,16 @@ def generate_tick_circuit_from_patch( ``add_typed_annotations`` is false; that flag controls detector and observable typed annotations, not the twirl lookup channel. interaction_basis: Surface-memory two-qubit interaction basis. + szz_physical_prefixes: If true, lower the abstract SZZ single-qubit + scaffold into physical prefix pulses for native DEM analysis. Returns: PECOS TickCircuit instance """ interaction_basis = _normalize_interaction_basis(interaction_basis) + if szz_physical_prefixes and interaction_basis != "szz": + msg = "szz_physical_prefixes=True requires interaction_basis='szz'" + raise ValueError(msg) ops, allocation = build_surface_code_circuit( patch, num_rounds, @@ -2528,6 +2679,8 @@ def generate_tick_circuit_from_patch( twirl=twirl, interaction_basis=interaction_basis, ) + if szz_physical_prefixes: + ops = _lower_szz_forward_flow_ops(ops) renderer = TickCircuitRenderer( add_detectors=add_detectors, add_typed_annotations=add_typed_annotations, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 244f07837..73287f300 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -287,6 +287,7 @@ class _CachedNativeSurfaceTopology: dag_circuit: Any influence_map: Any + szz_physical_prefixes: bool pauli_frame_lookup: Any | None detectors_json: str observables_json: str @@ -1379,6 +1380,7 @@ def _build_surface_tick_circuit_for_native_model( runtime: object | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + szz_physical_prefixes: bool = False, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch @@ -1386,6 +1388,9 @@ def _build_surface_tick_circuit_for_native_model( if twirl is not None: twirl.validate_runtime_supported() interaction_basis = _normalize_interaction_basis(interaction_basis) + if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): + msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" + raise ValueError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1395,6 +1400,7 @@ def _build_surface_tick_circuit_for_native_model( add_typed_annotations=False, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) if circuit_source == "abstract": @@ -1605,13 +1611,17 @@ def _noise_uses_dedicated_idle_noise(noise: NoiseModel) -> bool: ) -def _reject_szz_unlowered_physical_noise(noise: NoiseModel, interaction_basis: str) -> None: +def _reject_szz_unlowered_physical_noise( + noise: NoiseModel, + interaction_basis: str, + circuit_source: Literal["abstract", "traced_qis"], +) -> None: """Reject SZZ surface DEM noise that still needs post-flow pulse locations.""" if interaction_basis != "szz": return reasons: list[str] = [] - if noise.p1 > 0.0: - reasons.append("p1") + if noise.p1 > 0.0 and circuit_source != "abstract": + reasons.append("p1 with circuit_source='traced_qis'") if _noise_uses_dedicated_idle_noise(noise): reasons.append("dedicated idle noise") if not reasons: @@ -1620,13 +1630,33 @@ def _reject_szz_unlowered_physical_noise(noise: NoiseModel, interaction_basis: s msg = ( "interaction_basis='szz' surface DEM generation does not yet support " f"{joined} because the DEM must use post-flow prefix pulse locations " - "rather than the abstract H/SX/SZ scaffold; set p1=0 and omit " - "dedicated idle noise until SZZ pulse-location DEM lowering is enabled" + "rather than the abstract H/SX/SZ scaffold; use circuit_source='abstract' " + "for p1 and omit dedicated idle noise until SZZ idle pulse-location DEM " + "lowering is enabled" ) raise ValueError(msg) -def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: +def _use_szz_physical_prefixes( + noise: NoiseModel, + interaction_basis: str, + circuit_source: Literal["abstract", "traced_qis"], +) -> bool: + return interaction_basis == "szz" and circuit_source == "abstract" and noise.p1 > 0.0 + + +def _szz_prefix_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: + if not topology.szz_physical_prefixes: + return None + return {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} + + +def _with_noise_compat( + builder: Any, + noise: NoiseModel, + *, + p1_gate_rates: Mapping[str, float] | None = None, +) -> Any: """Call Rust ``with_noise`` using the richest signature this binding supports.""" noise_kwargs = { "p_idle": noise.p_idle, @@ -1647,6 +1677,8 @@ def _with_noise_compat(builder: Any, noise: NoiseModel) -> Any: "p1_weights": _p1_weights_dict(noise.p1_weights), "p2_weights": _p2_weights_dict(noise.p2_weights), } + if p1_gate_rates is not None: + noise_kwargs["p1_gate_rates"] = {str(gate): float(rate) for gate, rate in p1_gate_rates.items()} p2_gate_rates = _p2_gate_rates_dict(noise) if p2_gate_rates is not None: noise_kwargs["p2_gate_rates"] = p2_gate_rates @@ -1695,6 +1727,7 @@ def _surface_native_topology( runtime: object | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + szz_physical_prefixes: bool = False, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1717,6 +1750,7 @@ def _surface_native_topology( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -1756,6 +1790,7 @@ def _surface_native_topology( return _CachedNativeSurfaceTopology( dag_circuit=dag, influence_map=influence_map, + szz_physical_prefixes=szz_physical_prefixes, pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, observables_json=observables_json, @@ -1778,6 +1813,7 @@ def _cached_surface_native_topology( *, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + szz_physical_prefixes: bool = False, ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" return _surface_native_topology( @@ -1789,6 +1825,7 @@ def _cached_surface_native_topology( include_idle_gates, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) @@ -1801,7 +1838,11 @@ def _dem_string_from_cached_surface_topology( """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder - builder = _with_noise_compat(DemBuilder(topology.influence_map), noise) + builder = _with_noise_compat( + DemBuilder(topology.influence_map), + noise, + p1_gate_rates=_szz_prefix_p1_gate_rates(topology), + ) if hasattr(builder, "with_exact_branch_replay_circuit"): builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) @@ -1876,6 +1917,7 @@ def _cached_surface_native_dem_string( p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) + szz_physical_prefixes = interaction_basis == "szz" and circuit_source == "abstract" and p1 > 0.0 topology = _cached_surface_native_topology( patch_key, num_rounds, @@ -1885,6 +1927,7 @@ def _cached_surface_native_dem_string( include_idle_gates, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) return _dem_string_from_cached_surface_topology( topology, @@ -1950,7 +1993,11 @@ def _build_native_sampler_from_cached_surface_topology( from pecos.qec import DemSamplerBuilder sampler_builder = ( - _with_noise_compat(DemSamplerBuilder(topology.influence_map), noise) + _with_noise_compat( + DemSamplerBuilder(topology.influence_map), + noise, + p1_gate_rates=_szz_prefix_p1_gate_rates(topology), + ) .with_detectors_json(topology.detectors_json) .with_observables_json(topology.observables_json) ) @@ -2039,9 +2086,10 @@ def generate_circuit_level_dem_from_builder( from pecos.qec.surface.circuit_builder import _normalize_interaction_basis interaction_basis = _normalize_interaction_basis(interaction_basis) - _reject_szz_unlowered_physical_noise(noise, interaction_basis) + _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) + szz_physical_prefixes = _use_szz_physical_prefixes(noise, interaction_basis, circuit_source) if runtime is not None: topology = _surface_native_topology( patch_key, @@ -2053,6 +2101,7 @@ def generate_circuit_level_dem_from_builder( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) return _dem_string_from_cached_surface_topology( topology, @@ -3816,9 +3865,10 @@ def build_native_sampler( from pecos.qec.surface.circuit_builder import _normalize_interaction_basis interaction_basis = _normalize_interaction_basis(interaction_basis) - _reject_szz_unlowered_physical_noise(noise, interaction_basis) + _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) + szz_physical_prefixes = _use_szz_physical_prefixes(noise, interaction_basis, circuit_source) topology = _cached_surface_native_topology( patch_key, num_rounds, @@ -3828,6 +3878,7 @@ def build_native_sampler( _noise_uses_dedicated_idle_noise(noise), twirl=twirl, interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 80287125c..d5df2a1b8 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -528,10 +528,28 @@ def test_szz_native_influence_sampler_respects_override_only_p2() -> None: assert "mechanisms=0" not in repr(active_sampler.sampler) +@pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) +def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_model: str) -> None: + patch = SurfacePatch.create(distance=3) + + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + sampling_model=sampling_model, + ) + det_events, obs_flips = sampler.sample(4, seed=20260612) + + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + if sampling_model == "influence_dem": + assert "mechanisms=0" not in repr(sampler.sampler) + + @pytest.mark.parametrize( ("noise", "match"), [ - (NoiseModel(p1=0.001), r"p1.*post-flow prefix pulse locations"), (NoiseModel(p_idle=0.001), r"dedicated idle noise.*post-flow prefix pulse locations"), ], ) @@ -547,13 +565,39 @@ def test_szz_native_dem_rejects_unlowered_physical_noise(noise: NoiseModel, matc ) -def test_szz_native_sampler_rejects_unlowered_physical_noise() -> None: +def test_szz_native_dem_rejects_traced_qis_p1() -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError, match=r"p1.*post-flow prefix pulse locations"): - build_native_sampler( + with pytest.raises(ValueError, match=r"p1 with circuit_source='traced_qis'.*post-flow prefix pulse locations"): + generate_circuit_level_dem_from_builder( patch, num_rounds=1, noise=NoiseModel(p1=0.001), interaction_basis="szz", + circuit_source="traced_qis", + ) + + +def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + +def test_szz_native_sampler_rejects_unlowered_idle_noise() -> None: + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match=r"dedicated idle noise.*post-flow prefix pulse locations"): + build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", ) From a624ce10e945acd0ad162bf563728b69aa069e8a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 22:58:53 -0600 Subject: [PATCH 130/388] Tighten SZZ prefix DEM coverage --- .../src/fault_tolerance_bindings.rs | 32 ++++++------- .../qec/surface/test_szz_interaction_basis.py | 47 ++++++++++++++++++- 2 files changed, 62 insertions(+), 17 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 402e594b4..a863414f2 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -325,12 +325,12 @@ fn apply_noise_options( p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { noise.p_idle = p_idle.unwrap_or(0.0); @@ -1482,7 +1482,7 @@ impl PyDetectorErrorModel { /// >>> print(dem.to_string()) /// >>> sampler = dem.to_sampler() #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &pyo3::Bound<'_, pyo3::PyAny>, @@ -1508,12 +1508,12 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { use pecos_qec::fault_tolerance::dem_builder::DemBuilder; @@ -1538,12 +1538,12 @@ impl PyDetectorErrorModel { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; if let Ok(dag) = @@ -1896,7 +1896,7 @@ impl PyDemBuilder { /// /// Returns: /// Self for method chaining. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -1922,12 +1922,12 @@ impl PyDemBuilder { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult> { slf.noise = apply_noise_options( @@ -1950,12 +1950,12 @@ impl PyDemBuilder { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; Ok(slf) @@ -3819,7 +3819,7 @@ impl PyDemSampler { /// >>> sampler = DemSampler.from_circuit(dag, p1=0.001, p2=0.01) /// >>> sampler = DemSampler.from_circuit(tc, p2=0.01) # TickCircuit also works #[staticmethod] - #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (circuit, p1=0.001, p2=0.01, p_meas=0.001, p_prep=0.001, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn from_circuit( circuit: &Bound<'_, pyo3::PyAny>, @@ -3845,12 +3845,12 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { let noise = apply_noise_options( @@ -3873,12 +3873,12 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; @@ -3990,7 +3990,7 @@ impl PyDemSampler { /// /// The `observables` argument defines observables. #[staticmethod] - #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (influence_map, detectors, observables, p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_detectors( influence_map: &PyDagFaultInfluenceMap, @@ -4018,12 +4018,12 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult { let noise = apply_noise_options( @@ -4046,12 +4046,12 @@ impl PyDemSampler { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) @@ -4563,7 +4563,7 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. - #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_gate_rates=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p1_gate_rates=None))] + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None, p_idle_linear_rate=None, p_idle_quadratic_rate=None, p_idle_x_linear_rate=None, p_idle_y_linear_rate=None, p_idle_z_linear_rate=None, p_idle_x_quadratic_rate=None, p_idle_y_quadratic_rate=None, p_idle_z_quadratic_rate=None, p_idle_quadratic_sine_rate=None, p_idle_x_quadratic_sine_rate=None, p_idle_y_quadratic_sine_rate=None, p_idle_z_quadratic_sine_rate=None, p1_weights=None, p2_weights=None, p2_replacement_approximation=None, p_meas_crosstalk_local=None, p_meas_crosstalk_global=None, p_meas_crosstalk_model=None, measurement_crosstalk_dem_mode=None, p2_gate_rates=None, p1_gate_rates=None))] #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, @@ -4589,12 +4589,12 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_sine_rate: Option, p1_weights: Option>, p2_weights: Option>, - p2_gate_rates: Option>, p2_replacement_approximation: Option, p_meas_crosstalk_local: Option, p_meas_crosstalk_global: Option, p_meas_crosstalk_model: Option>, measurement_crosstalk_dem_mode: Option, + p2_gate_rates: Option>, p1_gate_rates: Option>, ) -> PyResult> { slf.noise = apply_noise_options( @@ -4617,12 +4617,12 @@ impl PyDemSamplerBuilder { p_idle_z_quadratic_sine_rate, p1_weights, p2_weights, - p2_gate_rates, p2_replacement_approximation, p_meas_crosstalk_local, p_meas_crosstalk_global, p_meas_crosstalk_model, measurement_crosstalk_dem_mode, + p2_gate_rates, p1_gate_rates, )?; Ok(slf) diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index d5df2a1b8..a8bf1d96c 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -27,7 +27,14 @@ generate_stim_from_patch, generate_tick_circuit_from_patch, ) -from pecos.qec.surface.decode import build_memory_circuit, build_native_sampler, generate_circuit_level_dem_from_builder +from pecos.qec.surface.decode import ( + _dem_string_from_cached_surface_topology, + _surface_native_topology, + _surface_patch_cache_key, + build_memory_circuit, + build_native_sampler, + generate_circuit_level_dem_from_builder, +) def _to_numpy_complex(matrix: object) -> np.ndarray: @@ -528,6 +535,44 @@ def test_szz_native_influence_sampler_respects_override_only_p2() -> None: assert "mechanisms=0" not in repr(active_sampler.sampler) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_prefix_lowering_preserves_p2_influence_dem(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + patch_key = _surface_patch_cache_key(patch) + noise = NoiseModel(p1=0.0, p2=0.01, p_meas=0.0, p_prep=0.0) + + plain = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + False, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + lowered = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + False, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + + assert _dem_string_from_cached_surface_topology( + lowered, + noise, + decompose_errors=False, + ) == _dem_string_from_cached_surface_topology( + plain, + noise, + decompose_errors=False, + ) + + @pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_model: str) -> None: patch = SurfacePatch.create(distance=3) From b83ddc1fbdfadebfd6683a40c3b4cc2b110a0da8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 23:11:53 -0600 Subject: [PATCH 131/388] Enable SZZ idle prefix lowering --- crates/pecos-quantum/src/tick_circuit.rs | 63 +++++++- .../src/pecos/qec/surface/circuit_builder.py | 55 +++++-- .../src/pecos/qec/surface/decode.py | 19 ++- .../qec/surface/test_szz_interaction_basis.py | 145 ++++++++++++++++-- 4 files changed, 235 insertions(+), 47 deletions(-) diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 25c18752a..72698dd14 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -615,6 +615,14 @@ impl<'a> GateInstanceRef<'a> { } } +fn gate_batch_has_zero_physical_duration(gate: GateBatchRef<'_>) -> bool { + match gate.get_attr("_physical_duration") { + Some(Attribute::Float(duration)) => *duration == 0.0, + Some(Attribute::Int(duration)) => *duration == 0, + _ => false, + } +} + impl Tick { /// Create a new empty tick. #[must_use] @@ -2308,15 +2316,18 @@ impl TickCircuit { } for tick in &mut self.ticks { - // Metadata-only ticks are zero-duration bookkeeping markers. - // They preserve annotation order but must not create physical idle - // periods on qubits outside the metadata payload. + // Metadata-only and explicitly zero-duration ticks preserve + // ordering but must not create physical idle periods. + let batch_count = tick.gate_batches().len(); let meta_batches = tick - .gate_batches() - .iter() - .filter(|gate| gate.gate_type.is_meta()) + .iter_gate_batches() + .filter(|gate| gate.as_gate().gate_type.is_meta()) + .count(); + let zero_duration_batches = tick + .iter_gate_batches() + .filter(|gate| gate_batch_has_zero_physical_duration(*gate)) .count(); - if !tick.is_empty() && meta_batches == tick.gate_batches().len() { + if !tick.is_empty() && meta_batches + zero_duration_batches == batch_count { continue; } // A tick that mixes meta and physical gates has ambiguous idle @@ -2330,6 +2341,12 @@ impl TickCircuit { batches in their own tick so idle-duration accounting stays \ unambiguous" ); + assert!( + zero_duration_batches == 0, + "fill_idle_gates: tick mixes zero-duration and physical gates; emit \ + zero-duration frame updates in their own tick so idle-duration \ + accounting stays unambiguous" + ); let active = tick.active_qubits(); for &q in &all_qubits { if !active.contains(&q) { @@ -6197,6 +6214,26 @@ mod tests { assert!(meta_tick.gate_batches()[0].gate_type.is_meta()); } + #[test] + fn test_fill_idle_gates_skips_zero_duration_ticks() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0, 1]); + tc.tick() + .z(&[0]) + .meta("_physical_duration", Attribute::Float(0.0)); + tc.tick().h(&[0, 1]); + + tc.fill_idle_gates(); + + let zero_duration_tick = tc.get_tick(1).expect("zero-duration tick"); + assert_eq!(zero_duration_tick.gate_count(), 1); + assert_eq!(zero_duration_tick.gate_batches()[0].gate_type, GateType::Z); + assert_eq!( + zero_duration_tick.get_gate_attr(0, "_physical_duration"), + Some(&Attribute::Float(0.0)) + ); + } + #[test] #[should_panic(expected = "tick mixes meta and physical gates")] fn test_fill_idle_gates_rejects_mixed_meta_and_physical_tick() { @@ -6213,6 +6250,18 @@ mod tests { tc.fill_idle_gates(); } + #[test] + #[should_panic(expected = "tick mixes zero-duration and physical gates")] + fn test_fill_idle_gates_rejects_mixed_zero_duration_and_physical_tick() { + let mut tc = TickCircuit::new(); + tc.tick() + .h(&[0]) + .z(&[1]) + .meta("_physical_duration", Attribute::Float(0.0)); + + tc.fill_idle_gates(); + } + #[test] fn test_channel_gate_is_first_class_tick_operation() { let mut tc = TickCircuit::new(); diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index f4adb7d0f..a33d42255 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -659,13 +659,13 @@ def reset_for_prep(q: int, op: SurfaceCircuitStep) -> None: raise ValueError(msg) pending_by_qubit[q] = _SZZ_FLOW_IDENTITY - def discharge(q: int, host: SurfaceCircuitStep) -> None: + def discharge(q: int, host: SurfaceCircuitStep) -> tuple[SurfaceCircuitStep | None, SurfaceCircuitStep | None]: current = pending_for(q) if current == _SZZ_FLOW_IDENTITY: - return + return None, None if _szz_flow_is_virtual_z(current): pending_by_qubit[q] = _SZZ_FLOW_IDENTITY if host.op_type == OpType.MEASURE else current - return + return None, None try: virtual_gate, physical_gate = _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[current] except KeyError as exc: @@ -675,22 +675,30 @@ def discharge(q: int, host: SurfaceCircuitStep) -> None: f"{host.op_type.name} {host.label!r}" ) raise ValueError(msg) from exc + virtual_step = None if virtual_gate is not None: - lowered.append( - SurfaceCircuitStep( - virtual_gate, - [q], - f"szz_virtual_prefix:{virtual_gate.name}:{host.label}:q{q}", - ), - ) - lowered.append( - SurfaceCircuitStep( - physical_gate, + virtual_step = SurfaceCircuitStep( + virtual_gate, [q], - f"szz_physical_prefix:{physical_gate.name}:{host.label}:q{q}", - ), + f"szz_virtual_prefix:{virtual_gate.name}:{host.label}:q{q}", + ) + physical_step = SurfaceCircuitStep( + physical_gate, + [q], + f"szz_physical_prefix:{physical_gate.name}:{host.label}:q{q}", ) pending_by_qubit[q] = _SZZ_FLOW_IDENTITY + return virtual_step, physical_step + + def append_prefix_ticks(virtual_steps: list[SurfaceCircuitStep], physical_steps: list[SurfaceCircuitStep]) -> None: + if virtual_steps: + lowered.append(SurfaceCircuitStep(OpType.TICK)) + lowered.extend(virtual_steps) + lowered.append(SurfaceCircuitStep(OpType.TICK)) + if physical_steps: + lowered.append(SurfaceCircuitStep(OpType.TICK)) + lowered.extend(physical_steps) + lowered.append(SurfaceCircuitStep(OpType.TICK)) for op in ops: if op.op_type in {OpType.COMMENT, OpType.TICK, OpType.TRACKED_PAULI}: @@ -705,15 +713,26 @@ def discharge(q: int, host: SurfaceCircuitStep) -> None: pending_by_qubit[q] = _szz_flow_compose_pending_gate(pending_for(q), op.op_type) continue if op.op_type in {OpType.SZZ, OpType.SZZDG}: + virtual_steps: list[SurfaceCircuitStep] = [] + physical_steps: list[SurfaceCircuitStep] = [] for q in op.qubits: - discharge(q, op) + virtual_step, physical_step = discharge(q, op) + if virtual_step is not None: + virtual_steps.append(virtual_step) + if physical_step is not None: + physical_steps.append(physical_step) + append_prefix_ticks(virtual_steps, physical_steps) lowered.append(op) continue if op.op_type == OpType.CX: msg = "SZZ forward-flow lowering only supports SZZ/SZZdg two-qubit gates" raise ValueError(msg) if op.op_type == OpType.MEASURE: - discharge(op.qubits[0], op) + virtual_step, physical_step = discharge(op.qubits[0], op) + append_prefix_ticks( + [] if virtual_step is None else [virtual_step], + [] if physical_step is None else [physical_step], + ) lowered.append(op) continue lowered.append(op) @@ -2179,6 +2198,8 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non meta = get_ancilla_gate_metadata(q, op.label) if op.label: meta["label"] = op.label + if op.label.startswith("szz_virtual_prefix:"): + meta["_physical_duration"] = 0.0 apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.CX: diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 73287f300..ea49b5517 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1622,8 +1622,8 @@ def _reject_szz_unlowered_physical_noise( reasons: list[str] = [] if noise.p1 > 0.0 and circuit_source != "abstract": reasons.append("p1 with circuit_source='traced_qis'") - if _noise_uses_dedicated_idle_noise(noise): - reasons.append("dedicated idle noise") + if _noise_uses_dedicated_idle_noise(noise) and circuit_source != "abstract": + reasons.append("dedicated idle noise with circuit_source='traced_qis'") if not reasons: return joined = ", ".join(reasons) @@ -1631,8 +1631,7 @@ def _reject_szz_unlowered_physical_noise( "interaction_basis='szz' surface DEM generation does not yet support " f"{joined} because the DEM must use post-flow prefix pulse locations " "rather than the abstract H/SX/SZ scaffold; use circuit_source='abstract' " - "for p1 and omit dedicated idle noise until SZZ idle pulse-location DEM " - "lowering is enabled" + "for p1 or dedicated idle noise" ) raise ValueError(msg) @@ -1642,7 +1641,11 @@ def _use_szz_physical_prefixes( interaction_basis: str, circuit_source: Literal["abstract", "traced_qis"], ) -> bool: - return interaction_basis == "szz" and circuit_source == "abstract" and noise.p1 > 0.0 + return ( + interaction_basis == "szz" + and circuit_source == "abstract" + and (noise.p1 > 0.0 or _noise_uses_dedicated_idle_noise(noise)) + ) def _szz_prefix_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: @@ -1917,7 +1920,11 @@ def _cached_surface_native_dem_string( p_idle_y_quadratic_sine_rate=p_idle_y_quadratic_sine_rate, p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) - szz_physical_prefixes = interaction_basis == "szz" and circuit_source == "abstract" and p1 > 0.0 + szz_physical_prefixes = ( + interaction_basis == "szz" + and circuit_source == "abstract" + and (p1 > 0.0 or include_idle_gates) + ) topology = _cached_surface_native_topology( patch_key, num_rounds, diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index a8bf1d96c..248279cb6 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -150,6 +150,13 @@ def _assert_noiseless_record_metadata_is_zero(stim_text: str, tick_circuit: obje assert not np.any(obs_samples) +def _gate_labels_for_tick(tick_circuit: object, tick_index: int) -> list[str | None]: + return [ + tick_circuit.get_gate_meta(tick_index, gate_index, "label") + for gate_index, _gate in enumerate(tick_circuit.get_tick(tick_index).gate_batches()) + ] + + def test_szz_unitary_identities() -> None: assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) @@ -573,6 +580,44 @@ def test_szz_prefix_lowering_preserves_p2_influence_dem(basis: str) -> None: ) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + + saw_physical_prefix = False + saw_virtual_prefix = False + for tick_index in range(tick_circuit.num_ticks()): + tick = tick_circuit.get_tick(tick_index) + labels = _gate_labels_for_tick(tick_circuit, tick_index) + prefix_labels = [label for label in labels if label and label.startswith("szz_")] + if not prefix_labels: + continue + + assert len(prefix_labels) == len(labels) + if prefix_labels[0].startswith("szz_virtual_prefix:"): + saw_virtual_prefix = True + assert all(label.startswith("szz_virtual_prefix:") for label in prefix_labels) + for gate_index, gate in enumerate(tick.gate_batches()): + assert gate.gate_type.name == "Z" + assert tick_circuit.get_gate_meta(tick_index, gate_index, "_physical_duration") == 0.0 + else: + saw_physical_prefix = True + assert all(label.startswith("szz_physical_prefix:") for label in prefix_labels) + assert {gate.gate_type.name for gate in tick.gate_batches()} <= {"H", "F", "SY"} + for gate_index, _gate in enumerate(tick.gate_batches()): + assert tick_circuit.get_gate_meta(tick_index, gate_index, "_physical_duration") is None + + assert saw_physical_prefix + assert saw_virtual_prefix + + @pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_model: str) -> None: patch = SurfacePatch.create(distance=3) @@ -592,21 +637,19 @@ def test_szz_native_sampler_accepts_p1_with_physical_prefix_lowering(sampling_mo assert "mechanisms=0" not in repr(sampler.sampler) -@pytest.mark.parametrize( - ("noise", "match"), - [ - (NoiseModel(p_idle=0.001), r"dedicated idle noise.*post-flow prefix pulse locations"), - ], -) -def test_szz_native_dem_rejects_unlowered_physical_noise(noise: NoiseModel, match: str) -> None: +def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError, match=match): + with pytest.raises( + ValueError, + match=r"dedicated idle noise with circuit_source='traced_qis'.*post-flow prefix pulse locations", + ): generate_circuit_level_dem_from_builder( patch, num_rounds=1, - noise=noise, + noise=NoiseModel(p_idle=0.001), interaction_basis="szz", + circuit_source="traced_qis", ) @@ -636,13 +679,81 @@ def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: assert stim.DetectorErrorModel(dem).num_detectors > 0 -def test_szz_native_sampler_rejects_unlowered_idle_noise() -> None: +@pytest.mark.parametrize("sampling_model", ["dem", "influence_dem"]) +def test_szz_native_sampler_accepts_idle_with_physical_prefix_lowering(sampling_model: str) -> None: patch = SurfacePatch.create(distance=3) - with pytest.raises(ValueError, match=r"dedicated idle noise.*post-flow prefix pulse locations"): - build_native_sampler( - patch, - num_rounds=1, - noise=NoiseModel(p_idle=0.001), - interaction_basis="szz", - ) + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", + sampling_model=sampling_model, + ) + det_events, obs_flips = sampler.sample(4, seed=20260612) + + assert det_events.shape == (4, sampler.num_detectors) + assert obs_flips.shape == (4, sampler.num_observables) + if sampling_model == "influence_dem": + assert "mechanisms=0" not in repr(sampler.sampler) + + +def test_szz_native_dem_accepts_idle_with_physical_prefix_lowering() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p_idle=0.001), + interaction_basis="szz", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_idle_dem_uses_lowered_prefix_topology(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + patch_key = _surface_patch_cache_key(patch) + noise = NoiseModel(p_idle_z_linear_rate=0.01) + + actual = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + basis=basis, + noise=noise, + interaction_basis="szz", + decompose_errors=False, + ) + lowered = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + True, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + plain = _surface_native_topology( + patch_key, + 1, + basis, + None, + "abstract", + True, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + + expected = _dem_string_from_cached_surface_topology( + lowered, + noise, + decompose_errors=False, + ) + assert actual == expected + assert actual != _dem_string_from_cached_surface_topology( + plain, + noise, + decompose_errors=False, + ) From f97c1489daab3cab31ada2e56ff05b919ac8deb8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 12 Jun 2026 23:37:55 -0600 Subject: [PATCH 132/388] Tighten SZZ idle prefix guards --- crates/pecos-quantum/src/lib.rs | 4 +- crates/pecos-quantum/src/tick_circuit.rs | 11 ++-- python/pecos-rslib/pecos_rslib.pyi | 2 + .../pecos-rslib/src/dag_circuit_bindings.rs | 4 +- python/pecos-rslib/src/namespace_modules.rs | 4 ++ .../src/pecos/qec/surface/circuit_builder.py | 3 +- .../src/pecos/quantum/__init__.py | 2 + .../qec/surface/test_szz_interaction_basis.py | 64 ++++++++++++++++++- 8 files changed, 84 insertions(+), 10 deletions(-) diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index 44bf47a1b..4c4db3667 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -86,8 +86,8 @@ pub use dag_circuit::{ TraversalWorkBuffers, }; pub use tick_circuit::{ - CustomGateError, GateSignatureMismatchError, QubitConflictError, Tick, TickCircuit, - TickGateError, TickHandle, TickMeasRef, TickMeasureHandle, TickPrepHandle, + CustomGateError, GateSignatureMismatchError, PHYSICAL_DURATION_META_KEY, QubitConflictError, + Tick, TickCircuit, TickGateError, TickHandle, TickMeasRef, TickMeasureHandle, TickPrepHandle, }; // Re-export commonly used types from dependencies diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 72698dd14..fc273a14c 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -76,6 +76,9 @@ use crate::dag_circuit::{AnnotationKind, DagCircuit, PauliAnnotation}; use std::fmt; use std::ops::{Deref, Index}; +/// Gate metadata key for explicitly zero-duration physical frame updates. +pub const PHYSICAL_DURATION_META_KEY: &str = "_physical_duration"; + fn meta_json_array(circuit: &TickCircuit, key: &str) -> Result, String> { let Some(attr) = circuit.get_meta(key) else { return Ok(Vec::new()); @@ -616,7 +619,7 @@ impl<'a> GateInstanceRef<'a> { } fn gate_batch_has_zero_physical_duration(gate: GateBatchRef<'_>) -> bool { - match gate.get_attr("_physical_duration") { + match gate.get_attr(PHYSICAL_DURATION_META_KEY) { Some(Attribute::Float(duration)) => *duration == 0.0, Some(Attribute::Int(duration)) => *duration == 0, _ => false, @@ -6220,7 +6223,7 @@ mod tests { tc.tick().h(&[0, 1]); tc.tick() .z(&[0]) - .meta("_physical_duration", Attribute::Float(0.0)); + .meta(PHYSICAL_DURATION_META_KEY, Attribute::Float(0.0)); tc.tick().h(&[0, 1]); tc.fill_idle_gates(); @@ -6229,7 +6232,7 @@ mod tests { assert_eq!(zero_duration_tick.gate_count(), 1); assert_eq!(zero_duration_tick.gate_batches()[0].gate_type, GateType::Z); assert_eq!( - zero_duration_tick.get_gate_attr(0, "_physical_duration"), + zero_duration_tick.get_gate_attr(0, PHYSICAL_DURATION_META_KEY), Some(&Attribute::Float(0.0)) ); } @@ -6257,7 +6260,7 @@ mod tests { tc.tick() .h(&[0]) .z(&[1]) - .meta("_physical_duration", Attribute::Float(0.0)); + .meta(PHYSICAL_DURATION_META_KEY, Attribute::Float(0.0)); tc.fill_idle_gates(); } diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 1d78f7d76..65545d5df 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1397,6 +1397,8 @@ class llvm: # Tick Circuit # ============================================================================= +PHYSICAL_DURATION_META_KEY: str + class GateType: """Gate type marker.""" diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 515f688a1..ecc1deba7 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -26,7 +26,8 @@ use crate::dtypes::AngleParam; use crate::gate_registry_bindings::PyGateRegistry; use pecos_core::{Angle64, ChannelExpr, GateQubits, GateSignature, Pauli, TimeUnits}; use pecos_quantum::{ - Attribute, DagCircuit, Gate, GateType, QubitId, Tick, TickCircuit, TickGateError, + Attribute, DagCircuit, Gate, GateType, PHYSICAL_DURATION_META_KEY, QubitId, Tick, TickCircuit, + TickGateError, }; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList}; @@ -3823,6 +3824,7 @@ pub fn register_quantum_circuit_types(parent_module: &Bound<'_, PyModule>) -> Py parent_module.add_class::()?; parent_module.add_class::()?; parent_module.add_class::()?; + parent_module.add("PHYSICAL_DURATION_META_KEY", PHYSICAL_DURATION_META_KEY)?; // Add exceptions parent_module.add( diff --git a/python/pecos-rslib/src/namespace_modules.rs b/python/pecos-rslib/src/namespace_modules.rs index 865d6d718..83574b7a3 100644 --- a/python/pecos-rslib/src/namespace_modules.rs +++ b/python/pecos-rslib/src/namespace_modules.rs @@ -20,6 +20,10 @@ pub fn register_quantum_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { quantum.add("TickHandle", parent.getattr("TickHandle")?)?; quantum.add("TickPrepHandle", parent.getattr("TickPrepHandle")?)?; quantum.add("TickMeasureHandle", parent.getattr("TickMeasureHandle")?)?; + quantum.add( + "PHYSICAL_DURATION_META_KEY", + parent.getattr("PHYSICAL_DURATION_META_KEY")?, + )?; quantum.add( "DagCircuitWouldCycleError", parent.getattr("DagCircuitWouldCycleError")?, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index a33d42255..ee65203e3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -43,6 +43,7 @@ get_stabilizer_region, get_stabilizer_touch_label, ) +from pecos.quantum import PHYSICAL_DURATION_META_KEY if TYPE_CHECKING: from pecos.qec.surface._twirl_config import TwirlConfig @@ -2199,7 +2200,7 @@ def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> Non if op.label: meta["label"] = op.label if op.label.startswith("szz_virtual_prefix:"): - meta["_physical_duration"] = 0.0 + meta[PHYSICAL_DURATION_META_KEY] = 0.0 apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.CX: diff --git a/python/quantum-pecos/src/pecos/quantum/__init__.py b/python/quantum-pecos/src/pecos/quantum/__init__.py index d9c190b84..3a0d84c38 100644 --- a/python/quantum-pecos/src/pecos/quantum/__init__.py +++ b/python/quantum-pecos/src/pecos/quantum/__init__.py @@ -92,6 +92,7 @@ H5, H6, ISWAP, + PHYSICAL_DURATION_META_KEY, SWAP, SX, SXX, @@ -269,6 +270,7 @@ def pauli_string( "H5", "H6", "ISWAP", + "PHYSICAL_DURATION_META_KEY", "SWAP", "SX", "SXX", diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 248279cb6..c1b5de65b 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -24,6 +24,7 @@ _validate_szz_sign_vector, build_surface_code_circuit, generate_dag_circuit_from_patch, + generate_dem_from_tick_circuit, generate_stim_from_patch, generate_tick_circuit_from_patch, ) @@ -35,6 +36,7 @@ build_native_sampler, generate_circuit_level_dem_from_builder, ) +from pecos.quantum import PHYSICAL_DURATION_META_KEY def _to_numpy_complex(matrix: object) -> np.ndarray: @@ -157,6 +159,17 @@ def _gate_labels_for_tick(tick_circuit: object, tick_index: int) -> list[str | N ] +def _retag_virtual_prefix_duration(tick_circuit: object, duration: float) -> int: + count = 0 + for tick_index in range(tick_circuit.num_ticks()): + for gate_index, _gate in enumerate(tick_circuit.get_tick(tick_index).gate_batches()): + label = tick_circuit.get_gate_meta(tick_index, gate_index, "label") + if label and label.startswith("szz_virtual_prefix:"): + tick_circuit.set_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY, duration) + count += 1 + return count + + def test_szz_unitary_identities() -> None: assert _equiv_up_to_global_phase(SZZ @ _kron(SZDG, SZDG), CZ) assert _equiv_up_to_global_phase(SZZ @ ZZ, SZZDG) @@ -606,13 +619,13 @@ def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: assert all(label.startswith("szz_virtual_prefix:") for label in prefix_labels) for gate_index, gate in enumerate(tick.gate_batches()): assert gate.gate_type.name == "Z" - assert tick_circuit.get_gate_meta(tick_index, gate_index, "_physical_duration") == 0.0 + assert tick_circuit.get_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY) == 0.0 else: saw_physical_prefix = True assert all(label.startswith("szz_physical_prefix:") for label in prefix_labels) assert {gate.gate_type.name for gate in tick.gate_batches()} <= {"H", "F", "SY"} for gate_index, _gate in enumerate(tick.gate_batches()): - assert tick_circuit.get_gate_meta(tick_index, gate_index, "_physical_duration") is None + assert tick_circuit.get_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY) is None assert saw_physical_prefix assert saw_virtual_prefix @@ -757,3 +770,50 @@ def test_szz_idle_dem_uses_lowered_prefix_topology(basis: str) -> None: noise, decompose_errors=False, ) + + +def test_szz_virtual_prefix_ticks_do_not_contribute_idle_dem() -> None: + patch = SurfacePatch.create(distance=3) + noise_kwargs = { + "p1": 0.0, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + "p_idle_z_linear_rate": 0.01, + "decompose_errors": False, + } + + tagged = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_physical_prefixes=True, + ) + retagged = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_physical_prefixes=True, + ) + assert _retag_virtual_prefix_duration(retagged, 1.0) > 0 + + tagged.fill_idle_gates() + retagged.fill_idle_gates() + + tagged_dem = generate_dem_from_tick_circuit(tagged, **noise_kwargs) + retagged_dem = generate_dem_from_tick_circuit(retagged, **noise_kwargs) + + assert ( + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + basis="Z", + noise=NoiseModel(p_idle_z_linear_rate=0.01), + interaction_basis="szz", + decompose_errors=False, + ) + == tagged_dem + ) + assert retagged_dem != tagged_dem From ea742f8102fe629764463b7f2259775fdb7b3474 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 09:55:54 -0600 Subject: [PATCH 133/388] Expose surface interaction basis selection --- .../surface/native_dem_threshold_sweep.py | 164 ++++++++++++++---- .../src/pecos/qec/surface/decode.py | 32 +++- .../tests/qec/surface/test_surface_decoder.py | 30 +++- .../tests/qec/test_qec_ux_entrypoints.py | 18 ++ 4 files changed, 208 insertions(+), 36 deletions(-) diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 4839a9ad3..6a6b8baec 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -8,7 +8,8 @@ - direct ``selene_sim`` execution with either Selene ``Stim`` or the PECOS Selene stabilizer plugin - optional native DEM sampling via ``build_native_sampler(...)`` -- a depolarizing noise model with ``p2 = p``, ``p1 = p/30``, ``p_meas = p_prep = p/3`` +- circuit-level rates ``p2 = p``, ``p1 = p/30``, ``p_meas = p_prep = p/3`` + with selectable ``sim`` runtime noise builders - ``SurfaceDecoder(...)`` with PECOS-native DEMs (PyMatching or Tesseract) For the ``sim`` backend, decoding is performed relative to a cached noiseless @@ -261,7 +262,7 @@ def _backend_runtime_label(sample_backend: str, native_circuit_source: str = "ab if sample_backend == "sim": return ( "sim(Guppy(...)).classical(selene_engine()).quantum(pecos.stabilizer()) " - f"+ PECOS depolarizing noise + native DEM source={native_circuit_source} + noiseless " + f"+ PECOS runtime noise + native DEM source={native_circuit_source} + noiseless " "reference-trajectory calibration" ) if sample_backend == "selene_sim": @@ -552,7 +553,11 @@ def _noise_model_description(args: argparse.Namespace) -> str: p1s = getattr(args, "p1_scale", 1.0 / 30.0) pms = getattr(args, "p_meas_scale", 1.0 / 3.0) pps = getattr(args, "p_prep_scale", 1.0 / 3.0) - return f"depolarizing with p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" + sim_noise_model = getattr(args, "sim_noise_model", "depolarizing") + base = f"p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" + if sim_noise_model == "general": + return f"general_noise runtime ({base}, leak2depolar=True, p_idle_coherent=False)" + return f"depolarizing runtime ({base})" def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int = 5) -> object: @@ -652,6 +657,7 @@ def _decoder_runtime( physical_error_rate: float, dem_mode: str, native_circuit_source: str, + interaction_basis: str = "cx", decoder_type: str = "pymatching", ancilla_budget: int | None = None, p1_scale: float = 0.1, @@ -678,6 +684,7 @@ def _decoder_runtime( circuit_level_dem_mode=dem_mode, circuit_level_dem_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) return _DecoderRuntime( patch=patch, @@ -697,6 +704,7 @@ def _native_sampler_runtime( physical_error_rate: float, dem_mode: str, native_circuit_source: str, + interaction_basis: str = "cx", decoder_type: str = "pymatching", ancilla_budget: int | None = None, p1_scale: float = 0.1, @@ -714,6 +722,7 @@ def _native_sampler_runtime( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -727,6 +736,7 @@ def _native_sampler_runtime( basis=basis, circuit_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) # PyMatching needs decomposed (graph-like) DEMs; Tesseract and check-matrix # decoders handle hyperedges natively and should get the full DEM. @@ -741,6 +751,7 @@ def _native_sampler_runtime( decompose_errors=False, circuit_source=native_circuit_source, ancilla_budget=ancilla_budget, + interaction_basis=interaction_basis, ) dem_decoder = _create_dem_decoder(decoder_type, dem_str) # The traced-QIS sampler stack has a noticeable one-time initialization cost @@ -764,7 +775,9 @@ def _sim_reference_trajectory( distance: int, total_rounds: int, basis: str, -) -> tuple[tuple[tuple[int, ...], ...], tuple[tuple[int, ...], ...], tuple[int, ...]]: + interaction_basis: str, + sim_noise_model: str, +) -> tuple[tuple[tuple[int, ...], ...], tuple[tuple[int, ...], ...], tuple[int, ...], tuple[int, ...]]: """Cache a noiseless gate-level trajectory used as a decoding reference.""" import numpy as np from pecos.qec.surface import SurfacePatch @@ -778,6 +791,8 @@ def _sim_reference_trajectory( total_rounds=total_rounds, num_shots=1, seed=0, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) synx_rows = _reshape_round_values( @@ -793,32 +808,40 @@ def _sim_reference_trajectory( "synz", ) final = np.asarray(_result_rows_for_key(result_dict, "final")[0], dtype=np.uint8) + init_key = "init_synx" if basis.upper() == "Z" else "init_synz" + init = np.asarray(_result_rows_for_key(result_dict, init_key)[0], dtype=np.uint8) return ( tuple(tuple(int(v) for v in row) for row in synx_rows), tuple(tuple(int(v) for v in row) for row in synz_rows), tuple(int(v) for v in final.tolist()), + tuple(int(v) for v in init.tolist()), ) @cache -def _compiled_guppy_hugr(distance: int, total_rounds: int, basis: str) -> bytes: +def _compiled_guppy_hugr(distance: int, total_rounds: int, basis: str, interaction_basis: str = "cx") -> bytes: """Cache compiled HUGR bytes for the direct selene_sim backend.""" from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy import make_surface_code - program = make_surface_code(distance=distance, num_rounds=total_rounds, basis=basis) + program = make_surface_code( + distance=distance, + num_rounds=total_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) return compile_guppy_to_hugr(program) @cache -def _selene_instance(distance: int, total_rounds: int, basis: str) -> object: +def _selene_instance(distance: int, total_rounds: int, basis: str, interaction_basis: str = "cx") -> object: """Cache a built Selene instance for one circuit shape.""" from selene_sim import build instance = build( - _compiled_guppy_hugr(distance, total_rounds, basis), - name=f"surface_d{distance}_{basis.lower()}_r{total_rounds}", + _compiled_guppy_hugr(distance, total_rounds, basis, interaction_basis), + name=f"surface_d{distance}_{basis.lower()}_{interaction_basis}_r{total_rounds}", ) _CACHED_SELENE_INSTANCES.append(instance) return instance @@ -837,6 +860,8 @@ def _run_gate_backend_result_dict( p1_scale: float = 0.1, p_meas_scale: float = 0.5, p_prep_scale: float = 0.5, + interaction_basis: str = "cx", + sim_noise_model: str = "depolarizing", ) -> dict[str, list[list[int]]]: """Run one gate-level backend and normalize results to a shot-map-like dict.""" import os @@ -860,11 +885,11 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] ) compile_start = time.perf_counter() - _compiled_guppy_hugr(distance, total_rounds, basis) + _compiled_guppy_hugr(distance, total_rounds, basis, interaction_basis) compile_seconds = time.perf_counter() - compile_start build_start = time.perf_counter() - instance = _selene_instance(distance, total_rounds, basis) + instance = _selene_instance(distance, total_rounds, basis, interaction_basis) build_seconds = time.perf_counter() - build_start reset_start = time.perf_counter() @@ -885,7 +910,7 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] run_start = time.perf_counter() for shot_results in instance.run_shots( simulator=simulator, - n_qubits=get_num_qubits(distance), + n_qubits=get_num_qubits(distance, interaction_basis=interaction_basis), n_shots=num_shots, error_model=error_model, runtime=SimpleRuntime(), @@ -914,24 +939,45 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] if sample_backend == "sim": backend_start = time.perf_counter() noise_start = time.perf_counter() - noise_model = pecos.depolarizing_noise() - noise_model.set_probabilities( - physical_error_rate * p_prep_scale, # p_prep - physical_error_rate * p_meas_scale, # p_meas_0 - physical_error_rate * p_meas_scale, # p_meas_1 - physical_error_rate * p1_scale, # p1 (single-qubit gates) - physical_error_rate, # p2 (two-qubit gates) - ) + if sim_noise_model == "general": + use_coherent_idle = False + noise_model = ( + pecos.general_noise() + .with_prep_probability(physical_error_rate * p_prep_scale) + .with_meas_probability(physical_error_rate * p_meas_scale) + .with_p1_probability(physical_error_rate * p1_scale) + .with_p2_probability(physical_error_rate) + .with_leakage_scale(0.0) + .with_p_idle_coherent(use_coherent_idle) + .with_seed(seed) + ) + elif sim_noise_model == "depolarizing": + noise_model = pecos.depolarizing_noise() + noise_model.set_probabilities( + physical_error_rate * p_prep_scale, # p_prep + physical_error_rate * p_meas_scale, # p_meas_0 + physical_error_rate * p_meas_scale, # p_meas_1 + physical_error_rate * p1_scale, # p1 (single-qubit gates) + physical_error_rate, # p2 (two-qubit gates) + ) + else: + msg = f"Unknown sim noise model: {sim_noise_model}" + raise ValueError(msg) noise_seconds = time.perf_counter() - noise_start program_start = time.perf_counter() - program = make_surface_code(distance=distance, num_rounds=total_rounds, basis=basis) + program = make_surface_code( + distance=distance, + num_rounds=total_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) program_seconds = time.perf_counter() - program_start run_start = time.perf_counter() shot_vec = ( pecos.sim(program) .classical(pecos.selene_engine()) .quantum(pecos.stabilizer()) - .qubits(get_num_qubits(distance)) + .qubits(get_num_qubits(distance, interaction_basis=interaction_basis)) .noise(noise_model) .seed(seed) .run(num_shots) @@ -979,6 +1025,8 @@ def _profile_gate_backends( duration_rounds_by_distance: dict[int, tuple[int, ...]], shots: int, seed: int, + interaction_basis: str, + sim_noise_model: str, warmup_repetitions: int, benchmark_repetitions: int, ) -> None: @@ -1045,6 +1093,8 @@ def _profile_gate_backends( total_rounds=total_rounds, num_shots=shots, seed=combo_seed + rep, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) runs: list[dict[str, float]] = [] @@ -1059,6 +1109,8 @@ def _profile_gate_backends( num_shots=shots, seed=combo_seed + warmup_repetitions + rep, timing_sink=timing, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) runs.append(timing) @@ -1102,6 +1154,8 @@ def _run_memory_point( p1_scale: float = 0.1, p_meas_scale: float = 0.5, p_prep_scale: float = 0.5, + interaction_basis: str = "cx", + sim_noise_model: str = "depolarizing", ) -> SweepPoint: """Run one surface-memory point and decode it with native PECOS DEMs.""" import numpy as np @@ -1114,6 +1168,7 @@ def _run_memory_point( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -1130,15 +1185,18 @@ def _run_memory_point( num_raw_errors: int | None = 0 if sample_backend in {"sim", "selene_sim", "selene_stabilizer_plugin"}: - ref_synx_rows, ref_synz_rows, ref_final_row = _sim_reference_trajectory( + ref_synx_rows, ref_synz_rows, ref_final_row, ref_init_row = _sim_reference_trajectory( sample_backend, distance, total_rounds, basis.upper(), + interaction_basis, + sim_noise_model, ) ref_synx_list = [np.asarray(row, dtype=np.uint8) for row in ref_synx_rows] ref_synz_list = [np.asarray(row, dtype=np.uint8) for row in ref_synz_rows] ref_final = np.asarray(ref_final_row, dtype=np.uint8) + ref_init = np.asarray(ref_init_row, dtype=np.uint8) result_dict = _run_gate_backend_result_dict( sample_backend=sample_backend, distance=distance, @@ -1150,16 +1208,26 @@ def _run_memory_point( p1_scale=p1_scale, p_meas_scale=p_meas_scale, p_prep_scale=p_prep_scale, + interaction_basis=interaction_basis, + sim_noise_model=sim_noise_model, ) synx_rows = _result_rows_for_key(result_dict, "synx") synz_rows = _result_rows_for_key(result_dict, "synz") final_rows = _result_rows_for_key(result_dict, "final") - - if len(synx_rows) != num_shots or len(synz_rows) != num_shots or len(final_rows) != num_shots: + init_key = "init_synx" if basis.upper() == "Z" else "init_synz" + init_rows = _result_rows_for_key(result_dict, init_key) + + if ( + len(synx_rows) != num_shots + or len(synz_rows) != num_shots + or len(final_rows) != num_shots + or len(init_rows) != num_shots + ): msg = ( "Result register lengths do not match the requested shot count: " - f"synx={len(synx_rows)}, synz={len(synz_rows)}, final={len(final_rows)}, shots={num_shots}" + f"synx={len(synx_rows)}, synz={len(synz_rows)}, final={len(final_rows)}, " + f"{init_key}={len(init_rows)}, shots={num_shots}" ) raise ValueError( msg, @@ -1169,12 +1237,16 @@ def _run_memory_point( synx_list = _reshape_round_values(synx_rows[shot_idx], total_rounds, num_x_stab, "synx") synz_list = _reshape_round_values(synz_rows[shot_idx], total_rounds, num_z_stab, "synz") final = np.asarray(final_rows[shot_idx], dtype=np.uint8) + init = np.asarray(init_rows[shot_idx], dtype=np.uint8) if final.size != patch.geometry.num_data: msg = f"Register 'final' has {final.size} bits for one shot, expected {patch.geometry.num_data}" raise ValueError( msg, ) + if init.shape != ref_init.shape: + msg = f"Register {init_key!r} has shape {init.shape}, expected {ref_init.shape}" + raise ValueError(msg) # Decode relative to the noiseless gate-level baseline so the native # DEM sees deviations from the actual circuit trajectory. @@ -1187,6 +1259,7 @@ def _run_memory_point( for synz, ref_synz in zip(synz_list, ref_synz_list, strict=True) ] final = final ^ ref_final + init = init ^ ref_init raw_parity = int(sum(int(final[q]) for q in logical_qubits) % 2) if num_raw_errors is None: @@ -1195,9 +1268,9 @@ def _run_memory_point( num_raw_errors += raw_parity if basis.upper() == "Z": - is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final) + is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final, init_synx=init) else: - is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final) + is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final, init_synz=init) num_logical_errors += int(is_error) elif sample_backend == "native_sampler": native_runtime = _native_sampler_runtime( @@ -1207,6 +1280,7 @@ def _run_memory_point( physical_error_rate, dem_mode, native_circuit_source, + interaction_basis=interaction_basis, decoder_type=decoder_type, ancilla_budget=ancilla_budget, p1_scale=p1_scale, @@ -1874,6 +1948,8 @@ def _write_json_results( "shots": args.shots, "dem_mode": args.dem_mode, "native_circuit_source": args.native_circuit_source, + "interaction_basis": args.interaction_basis, + "sim_noise_model": args.sim_noise_model, "seed": args.seed, "backend_runtime_descriptions": { backend: _backend_runtime_label(backend, args.native_circuit_source) @@ -3511,6 +3587,8 @@ def _config_for_report(args: argparse.Namespace) -> dict[str, Any]: # appendix page can read the same field from either source. "sample_backend_mode": getattr(args, "sample_backend", None), "native_circuit_source": getattr(args, "native_circuit_source", None), + "interaction_basis": getattr(args, "interaction_basis", None), + "sim_noise_model": getattr(args, "sim_noise_model", None), "decoder": getattr(args, "decoder", ["pymatching"]), "noise_model": _noise_model_description(args), "seed": getattr(args, "seed", None), @@ -3621,6 +3699,21 @@ def _parse_args() -> argparse.Namespace: "matching the standard circuit-level noise model from the QEC literature." ), ) + parser.add_argument( + "--interaction-basis", + choices=["cx", "szz"], + default="cx", + help="Surface-memory two-qubit interaction basis to generate and analyze.", + ) + parser.add_argument( + "--sim-noise-model", + choices=["depolarizing", "general"], + default="depolarizing", + help=( + "Runtime noise model used by --sample-backend sim. The 'general' " + "option sets leak2depolar=True and p_idle_coherent=False." + ), + ) parser.add_argument( "--dem-mode", choices=["native_decomposed", "native_full"], @@ -3845,16 +3938,15 @@ def _print_config_banner( print(f"shots / point : {args.shots}") print(f"sample backend mode: {args.sample_backend}") print(f"executed backends: {backends}") + print(f"interaction basis: {args.interaction_basis}") + print(f"sim noise model : {args.sim_noise_model}") print(f"DEM mode : {args.dem_mode}") print(f"native circuit source: {args.native_circuit_source}") decoders = getattr(args, "decoder", ["pymatching"]) print(f"decoder(s) : {', '.join(decoders)} via SurfaceDecoder(native PECOS DEM)") for backend in backends: print(f"runtime[{backend}] : {_backend_runtime_label(backend, args.native_circuit_source)}") - p1s = getattr(args, "p1_scale", 0.1) - pms = getattr(args, "p_meas_scale", 0.5) - pps = getattr(args, "p_prep_scale", 0.5) - print(f"noise model : depolarizing with p1={p1s}*p, p2=p, p_meas={pms}*p, p_prep={pps}*p") + print(f"noise model : {_noise_model_description(args)}") print("fit model : p_L(r) = 0.5 * (1 - (1 - 2 * epsilon) ** r)") if output_dir is not None: print(f"artifact dir : {output_dir}") @@ -3902,6 +3994,8 @@ def _run_one_memory_point( p1_scale=getattr(args, "p1_scale", 0.1), p_meas_scale=getattr(args, "p_meas_scale", 0.5), p_prep_scale=getattr(args, "p_prep_scale", 0.5), + interaction_basis=getattr(args, "interaction_basis", "cx"), + sim_noise_model=getattr(args, "sim_noise_model", "depolarizing"), ) elapsed_seconds = time.perf_counter() - point_start naive_per_round = ler_per_round_exp(point.logical_error_rate, point.total_rounds) @@ -3919,6 +4013,7 @@ def _run_one_memory_point( "physical_error_rate": physical_error_rate, "total_rounds": total_rounds, "num_shots": args.shots, + "interaction_basis": getattr(args, "interaction_basis", "cx"), "elapsed_seconds": elapsed_seconds, } return point, timing_row @@ -4194,6 +4289,9 @@ def _print_post_sweep_analysis( def main() -> int: """Run the threshold sweep CLI and optionally write summary artifacts.""" args = _parse_args() + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + + args.interaction_basis = _normalize_interaction_basis(args.interaction_basis) if args.open_html: args.save_html = True if args.save_html: @@ -4238,6 +4336,8 @@ def main() -> int: duration_rounds_by_distance=duration_rounds_by_distance, shots=args.shots, seed=args.seed, + interaction_basis=args.interaction_basis, + sim_noise_model=args.sim_noise_model, warmup_repetitions=args.benchmark_warmup, benchmark_repetitions=args.benchmark_repetitions, ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ea49b5517..8d86956bb 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -2527,6 +2527,7 @@ def __init__( circuit_level_dem_mode: Literal["native_full", "native_decomposed"] = "native_full", circuit_level_dem_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, + interaction_basis: str = "cx", ) -> None: """Initialize decoder from surface code patch. @@ -2559,7 +2560,11 @@ def __init__( the native circuit-level DEM path. When provided, the decoder builds its DEM from the corresponding batched ancilla-reuse circuit instead of the default dedicated-ancilla circuit. + interaction_basis: Surface-memory two-qubit interaction basis, + ``"cx"`` or ``"szz"``. """ + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + self.patch = patch self.num_rounds = num_rounds self.noise = noise or NoiseModel(p2=0.01, p_meas=0.01) @@ -2568,6 +2573,7 @@ def __init__( self.circuit_level_dem_mode = circuit_level_dem_mode self.circuit_level_dem_source = circuit_level_dem_source self.ancilla_budget = ancilla_budget + self.interaction_basis = _normalize_interaction_basis(interaction_basis) # Lazily create decoders self._x_decoder = None @@ -2604,6 +2610,7 @@ def _get_circuit_level_dem(self, basis: str) -> str: decompose_errors=self.circuit_level_dem_mode == "native_decomposed", circuit_source=self.circuit_level_dem_source, ancilla_budget=self.ancilla_budget, + interaction_basis=self.interaction_basis, ) if basis.upper() == "Z": self._z_dem = dem @@ -3461,6 +3468,7 @@ class SimulationResult: raw_error_rate: Raw error rate (no decoding) decoded: Whether decoding was applied decoder_type: Decoder backend used (if decoded) + interaction_basis: Surface-memory two-qubit interaction basis. """ distance: int @@ -3473,6 +3481,7 @@ class SimulationResult: raw_error_rate: float decoded: bool decoder_type: str | None = None + interaction_basis: str = "cx" def _memory_noise_model( @@ -3502,6 +3511,7 @@ def surface_code_memory( decode: bool = True, circuit_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, + interaction_basis: str = "cx", ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -3523,6 +3533,8 @@ def surface_code_memory( decode: If false, report the raw observable-flip rate. circuit_source: ``"abstract"`` or ``"traced_qis"`` circuit source. ancilla_budget: Optional cap on simultaneously live ancillas. + interaction_basis: Surface-memory two-qubit interaction basis, + ``"cx"`` or ``"szz"``. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -3534,8 +3546,10 @@ def surface_code_memory( 0.0 """ from pecos.qec import ParsedDem + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis from pecos.qec.surface.patch import SurfacePatch + interaction_basis = _normalize_interaction_basis(interaction_basis) if distance < 1: msg = f"distance must be >= 1, got {distance}" raise ValueError(msg) @@ -3557,6 +3571,7 @@ def surface_code_memory( decompose_errors=True, ancilla_budget=ancilla_budget, circuit_source=circuit_source, + interaction_basis=interaction_basis, ) batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(shots, seed) num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) @@ -3573,6 +3588,7 @@ def surface_code_memory( raw_error_rate=num_raw_errors / shots if shots else 0.0, decoded=decode, decoder_type=decoder_type if decode else None, + interaction_basis=interaction_basis, ) @@ -3585,6 +3601,7 @@ def run_noisy_memory_experiment( *, decode: bool = True, decoder_type: str = "pymatching", + interaction_basis: str = "cx", ) -> SimulationResult: """Run a noisy surface code memory experiment with optional decoding. @@ -3602,6 +3619,8 @@ def run_noisy_memory_experiment( noise: Noise model parameters decode: If True, use decoding to correct errors decoder_type: Decoder backend (pymatching, fusion_blossom, bp_osd, etc.) + interaction_basis: Surface-memory two-qubit interaction basis, + ``"cx"`` or ``"szz"``. Returns: SimulationResult with error rate statistics @@ -3624,7 +3643,9 @@ def run_noisy_memory_experiment( from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy.surface import get_num_qubits, make_surface_code from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import _normalize_interaction_basis + interaction_basis = _normalize_interaction_basis(interaction_basis) # Create patch and decoder patch = SurfacePatch.create(distance=distance) geom = patch.geometry @@ -3645,11 +3666,17 @@ def run_noisy_memory_experiment( num_rounds=num_rounds, noise=noise, decoder_type=dt, + interaction_basis=interaction_basis, ) # Build and compile circuit - num_qubits = get_num_qubits(distance) - prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis) + num_qubits = get_num_qubits(distance, interaction_basis=interaction_basis) + prog = make_surface_code( + distance=distance, + num_rounds=num_rounds, + basis=basis, + interaction_basis=interaction_basis, + ) hugr_bytes = compile_guppy_to_hugr(prog) instance = build(hugr_bytes, name=f"surface_d{distance}") @@ -3724,6 +3751,7 @@ def run_noisy_memory_experiment( raw_error_rate=num_raw_errors / num_shots if num_shots > 0 else 0.0, decoded=decode, decoder_type=decoder_type if decode else None, + interaction_basis=interaction_basis, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index 4df6535ab..e456d689c 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -223,10 +223,10 @@ def test_get_dem_caches_circuit_level_dem(self, monkeypatch: pytest.MonkeyPatch) real_generate = decode_module.generate_circuit_level_dem_from_builder calls = 0 - def wrapped_generate(*args: object, **kwargs: object) -> str: + def wrapped_generate(*_args: object, **kwargs: object) -> str: nonlocal calls calls += 1 - return real_generate(*args, **kwargs) + return real_generate(*_args, **kwargs) monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) @@ -236,6 +236,32 @@ def wrapped_generate(*args: object, **kwargs: object) -> str: assert dem_1 == dem_2 assert calls == 1 + def test_get_dem_passes_interaction_basis_to_native_builder(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Decoder DEM generation should use the requested interaction basis.""" + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **kwargs: object) -> str: + seen["interaction_basis"] = kwargs.get("interaction_basis") + return "error(0.01) D0\n" + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + circuit_level_dem_mode="native_decomposed", + interaction_basis="SZZ", + ) + + assert decoder.interaction_basis == "szz" + assert decoder.get_dem("Z", circuit_level=True) == "error(0.01) D0\n" + assert seen["interaction_basis"] == "szz" + def test_decode_trivial_syndrome_z(self) -> None: """Decode trivial Z syndrome (no errors).""" patch = SurfacePatch.create(distance=3) diff --git a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py index 84e03a7a5..e3fb18deb 100644 --- a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py +++ b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py @@ -54,6 +54,24 @@ def test_surface_code_memory_runs_native_zero_noise_quick_start() -> None: assert result.num_rounds == 1 assert result.logical_error_rate == 0.0 assert result.raw_error_rate == 0.0 + assert result.interaction_basis == "cx" + + +def test_surface_code_memory_accepts_szz_interaction_basis() -> None: + from pecos.qec.surface import surface_code_memory + + result = surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=4, + rounds=1, + seed=123, + interaction_basis="szz", + ) + + assert result.interaction_basis == "szz" + assert result.logical_error_rate == 0.0 + assert result.raw_error_rate == 0.0 def test_surface_code_memory_rejects_ambiguous_noise_inputs() -> None: From 09291bad10d5c1f7d7c5712c2682f285ab9b5263 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 10:35:07 -0600 Subject: [PATCH 134/388] Fix traced SZZ DEM validation and raw sampler routing --- .../surface/native_dem_threshold_sweep.py | 18 +- .../src/pecos/decoders/__init__.py | 2 + .../src/pecos/qec/surface/circuit_builder.py | 8 + .../src/pecos/qec/surface/decode.py | 9 +- .../pecos/unit/test_surface_sweep_math.py | 7 + .../qec/surface/test_szz_interaction_basis.py | 162 +++++++++++++++++- 6 files changed, 186 insertions(+), 20 deletions(-) diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 6a6b8baec..90b27e54a 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -568,18 +568,18 @@ def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int via DemAwareDecoder which extracts the check matrix from the DEM. """ if decoder_type == "tesseract": - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder dem_filtered = "\n".join(line for line in dem_str.split("\n") if not line.startswith("logical_observable")) return TesseractDecoder.from_dem(dem_filtered, preset="fast", det_beam=tesseract_beam) if decoder_type in _CHECK_MATRIX_DECODERS: - from pecos_rslib.decoders import DemAwareDecoder + from pecos.decoders import DemAwareDecoder dem_filtered = "\n".join(line for line in dem_str.split("\n") if not line.startswith("logical_observable")) return DemAwareDecoder.from_dem(dem_filtered, decoder_type=decoder_type) - from pecos_rslib.decoders import PyMatchingDecoder + from pecos.decoders import PyMatchingDecoder return PyMatchingDecoder.from_dem(dem_str) @@ -618,7 +618,7 @@ def _decode_all_shots( ) # PyMatching batch: takes flattened (num_shots * num_detectors) u8 array - from pecos_rslib.decoders import PyMatchingDecoder + from pecos.decoders import PyMatchingDecoder if isinstance(dem_decoder, PyMatchingDecoder): flat = detection_events.astype(np.uint8).flatten().tolist() @@ -628,7 +628,7 @@ def _decode_all_shots( return int(np.sum(predicted != true_flips)) # Tesseract batch: takes list of syndromes, parallel rayon - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder if isinstance(dem_decoder, TesseractDecoder): syndromes = [detection_events[i].astype(np.uint8).tolist() for i in range(num_shots)] @@ -696,6 +696,13 @@ def _decoder_runtime( ) +def _native_sampler_model_for_decoder(decoder_type: str) -> str: + """Choose the native sampler model paired with a DEM decoder.""" + if decoder_type == "pymatching": + return "dem" + return "influence_dem" + + @cache def _native_sampler_runtime( distance: int, @@ -737,6 +744,7 @@ def _native_sampler_runtime( circuit_source=native_circuit_source, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + sampling_model=_native_sampler_model_for_decoder(decoder_type), ) # PyMatching needs decomposed (graph-like) DEMs; Tesseract and check-matrix # decoders handle hyperedges natively and should get the full DEM. diff --git a/python/quantum-pecos/src/pecos/decoders/__init__.py b/python/quantum-pecos/src/pecos/decoders/__init__.py index d2344d55b..56119208e 100644 --- a/python/quantum-pecos/src/pecos/decoders/__init__.py +++ b/python/quantum-pecos/src/pecos/decoders/__init__.py @@ -23,6 +23,7 @@ BpOsdDecoder, BpResult, CheckMatrix, + DemAwareDecoder, FusionBlossomDecoder, MinSumBpBuilder, MinSumBpDecoder, @@ -48,6 +49,7 @@ "BpOsdDecoder", "BpResult", "CheckMatrix", + "DemAwareDecoder", "DummyDecoder", "FusionBlossomDecoder", "MinSumBpBuilder", diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index ee65203e3..722a60a2a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2943,6 +2943,8 @@ def tick_circuit_to_stim( "H": ("H", "single"), "SX": ("SQRT_X", "single"), "SXdg": ("SQRT_X_DAG", "single"), + "SY": ("SQRT_Y", "single"), + "SYdg": ("SQRT_Y_DAG", "single"), "SZ": ("S", "single"), "SZdg": ("S_DAG", "single"), "X": ("X", "single"), @@ -2994,6 +2996,12 @@ def _gate_to_stim( msg = f"Unsupported traced Clifford RZ angle: {angle!r}" raise ValueError(msg) + if gate_name == "F": + return [("S_DAG", qubits), ("H", qubits)], "single" + + if gate_name == "Fdg": + return [("H", qubits), ("S", qubits)], "single" + if gate_name == "RZZ": if not gate.angles: return [], None diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8d86956bb..c30a37c3b 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1616,12 +1616,10 @@ def _reject_szz_unlowered_physical_noise( interaction_basis: str, circuit_source: Literal["abstract", "traced_qis"], ) -> None: - """Reject SZZ surface DEM noise that still needs post-flow pulse locations.""" + """Reject SZZ surface DEM noise without well-defined gate locations.""" if interaction_basis != "szz": return reasons: list[str] = [] - if noise.p1 > 0.0 and circuit_source != "abstract": - reasons.append("p1 with circuit_source='traced_qis'") if _noise_uses_dedicated_idle_noise(noise) and circuit_source != "abstract": reasons.append("dedicated idle noise with circuit_source='traced_qis'") if not reasons: @@ -1629,9 +1627,8 @@ def _reject_szz_unlowered_physical_noise( joined = ", ".join(reasons) msg = ( "interaction_basis='szz' surface DEM generation does not yet support " - f"{joined} because the DEM must use post-flow prefix pulse locations " - "rather than the abstract H/SX/SZ scaffold; use circuit_source='abstract' " - "for p1 or dedicated idle noise" + f"{joined} because idle noise needs explicit post-flow idle locations; " + "use circuit_source='abstract' for dedicated idle noise" ) raise ValueError(msg) diff --git a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py index 7f01a1e00..930d6654b 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py +++ b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py @@ -195,6 +195,13 @@ def test_linear_regression_requires_matching_lengths(sweep: ModuleType) -> None: sweep._linear_regression([1.0, 2.0], [3.0]) +def test_native_sampler_model_tracks_decoder_dem_requirements(sweep: ModuleType) -> None: + """Raw-DEM decoders should sample the exact native influence model.""" + assert sweep._native_sampler_model_for_decoder("pymatching") == "dem" + assert sweep._native_sampler_model_for_decoder("tesseract") == "influence_dem" + assert sweep._native_sampler_model_for_decoder("bp_osd") == "influence_dem" + + # --------------------------------------------------------------------------- # Per-round rate fit # --------------------------------------------------------------------------- diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index c1b5de65b..ddc4ab30e 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -4,6 +4,7 @@ from __future__ import annotations import json +import re import numpy as np import pecos as pc @@ -25,6 +26,7 @@ build_surface_code_circuit, generate_dag_circuit_from_patch, generate_dem_from_tick_circuit, + generate_dem_from_tick_circuit_via_stim, generate_stim_from_patch, generate_tick_circuit_from_patch, ) @@ -72,6 +74,16 @@ def _kron(left: np.ndarray, right: np.ndarray) -> np.ndarray: SXX = _kron(H, H) @ SZZ @ _kron(H, H) +def _raw_dem_errors(dem_text: str) -> dict[str, float]: + errors: dict[str, float] = {} + for line in dem_text.splitlines(): + match = re.match(r"error\(([^)]+)\)\s*(.*)", line.strip()) + if match: + target = match.group(2).strip() + errors[target] = errors.get(target, 0.0) + float(match.group(1)) + return errors + + def _equiv_up_to_global_phase(left: np.ndarray, right: np.ndarray, *, atol: float = 1e-10) -> bool: flat_right = right.ravel() flat_left = left.ravel() @@ -593,6 +605,87 @@ def test_szz_prefix_lowering_preserves_p2_influence_dem(basis: str) -> None: ) +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_lowered_native_dem_matches_stim_for_prefix_noise(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=True, + ) + p2 = 0.006 + noise_args = { + "p1": p2 / 30, + "p2": p2, + "p_meas": p2 / 3, + "p_prep": p2 / 3, + } + + native_errors = _raw_dem_errors( + generate_dem_from_tick_circuit( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} lowered SZZ DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_abstract_p2_only_raw_dem_matches_cx(basis: str) -> None: + patch = SurfacePatch.create(distance=3) + noise_args = { + "p1": 0.0, + "p2": 0.006, + "p_meas": 0.0, + "p_prep": 0.0, + } + cx_tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="cx", + ) + szz_tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=3, + basis=basis, + interaction_basis="szz", + szz_physical_prefixes=False, + ) + + assert generate_dem_from_tick_circuit( + cx_tick_circuit, + decompose_errors=False, + **noise_args, + ) == generate_dem_from_tick_circuit( + szz_tick_circuit, + decompose_errors=False, + **noise_args, + ) + + @pytest.mark.parametrize("basis", ["Z", "X"]) def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: patch = SurfacePatch.create(distance=3) @@ -655,7 +748,7 @@ def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: with pytest.raises( ValueError, - match=r"dedicated idle noise with circuit_source='traced_qis'.*post-flow prefix pulse locations", + match=r"dedicated idle noise with circuit_source='traced_qis'.*explicit post-flow idle locations", ): generate_circuit_level_dem_from_builder( patch, @@ -666,19 +759,70 @@ def test_szz_native_dem_rejects_traced_qis_idle_noise() -> None: ) -def test_szz_native_dem_rejects_traced_qis_p1() -> None: +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_traced_qis_native_dem_matches_stim_for_p1(basis: str) -> None: + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + patch = SurfacePatch.create(distance=3) + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis=basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ traced-QIS p1 test") + noise_args = { + "p1": 0.001, + "p2": 0.0, + "p_meas": 0.0, + "p_prep": 0.0, + } - with pytest.raises(ValueError, match=r"p1 with circuit_source='traced_qis'.*post-flow prefix pulse locations"): - generate_circuit_level_dem_from_builder( - patch, - num_rounds=1, - noise=NoiseModel(p1=0.001), - interaction_basis="szz", - circuit_source="traced_qis", + native_errors = _raw_dem_errors( + generate_dem_from_tick_circuit( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} traced-QIS SZZ p1 DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" ) +def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: + patch = SurfacePatch.create(distance=3) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.001), + interaction_basis="szz", + circuit_source="traced_qis", + ) + + assert "error(" in dem + assert stim.DetectorErrorModel(dem).num_detectors > 0 + + def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( From 9f81d49e1cb5ec84eb93743710bd1b96dff146e7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 10:48:25 -0600 Subject: [PATCH 135/388] Address review follow-ups S5/S7/S8/S9 and nits: IS reseeds only when seeded (entropy otherwise, matching MC), shots/outcomes alignment debug-asserts, Python .classical()+neo rejection, HUGR-arm and trace-path typed errors, path_enumeration shift saturation, and make PerGatePauliChannel measurement a propagating state flip to match the DEM convention --- exp/pecos-neo/src/noise/per_gate_pauli.rs | 63 ++++++++++++++++--- exp/pecos-neo/src/tool/simulation.rs | 60 ++++++++++++++++-- python/pecos-rslib/src/engine_builders.rs | 5 ++ python/pecos-rslib/src/sim.rs | 50 +++++++-------- .../tests/pecos/test_sim_stack_routing.py | 16 +++++ 5 files changed, 154 insertions(+), 40 deletions(-) diff --git a/exp/pecos-neo/src/noise/per_gate_pauli.rs b/exp/pecos-neo/src/noise/per_gate_pauli.rs index cc0bdb888..bcd64ddde 100644 --- a/exp/pecos-neo/src/noise/per_gate_pauli.rs +++ b/exp/pecos-neo/src/noise/per_gate_pauli.rs @@ -81,7 +81,12 @@ impl PerGatePauliChannel { self } - /// Set the default measurement X-flip and preparation X-error rates. + /// Set the default measurement and preparation error rates. + /// + /// The measurement error is a physical X injected before readout (it + /// propagates into the post-measurement state, matching the DEM's + /// measurement-fault convention), and the preparation error is a + /// physical X after prep. #[must_use] pub fn with_meas_init(mut self, p_meas: f64, p_init: f64) -> Self { assert_probability_vector(&[p_meas], "p_meas"); @@ -247,26 +252,33 @@ impl PerGatePauliChannel { } } - fn apply_after_measurement( + fn apply_before_measurement( &self, qubits: &[QubitId], ctx: &NoiseContext, rng: &mut PecosRng, ) -> NoiseResponse { - let mut flips: SmallVec<[QubitId; 4]> = SmallVec::new(); + // The DEM models a measurement error as a physical X at the + // measurement location (see pecos-qec's + // `process_meas_fault_source_tracked`: "Measurement error is a + // bit flip (X error)"), which propagates into the post-measurement + // state. Inject X before readout to match that convention, so this + // channel runs the same measurement physics the DEM encodes — + // a re-measured (un-reset) qubit flips at 2p(1-p), not p. + let mut gates: SmallVec<[GateCommand; 4]> = SmallVec::new(); for &qubit in qubits { if ctx.is_leaked(qubit) { continue; } let p = *self.measurement_rates.get(&qubit).unwrap_or(&self.p_meas); if p > 0.0 && rng.random::() < p { - flips.push(qubit); + gates.push(GateCommand::new(GateType::X, smallvec::smallvec![qubit])); } } - if flips.is_empty() { + if gates.is_empty() { NoiseResponse::None } else { - NoiseResponse::FlipOutcomes(flips) + NoiseResponse::inject_gates(gates) } } @@ -302,7 +314,7 @@ impl NoiseChannel for PerGatePauliChannel { && (gate_type.is_single_qubit() || gate_type.is_two_qubit()) && self.has_any_gate_noise() } - NoiseEvent::AfterMeasurement { .. } => { + NoiseEvent::BeforeMeasurement { .. } => { self.p_meas > 0.0 || !self.measurement_rates.is_empty() } NoiseEvent::AfterPreparation { .. } => self.p_init > 0.0 || !self.init_rates.is_empty(), @@ -320,8 +332,8 @@ impl NoiseChannel for PerGatePauliChannel { NoiseEvent::AfterGate { gate_type, qubits, .. } => self.apply_after_gate(*gate_type, qubits, ctx, rng), - NoiseEvent::AfterMeasurement { qubits, .. } => { - self.apply_after_measurement(qubits, ctx, rng) + NoiseEvent::BeforeMeasurement { qubits } => { + self.apply_before_measurement(qubits, ctx, rng) } NoiseEvent::AfterPreparation { qubits } => { self.apply_after_preparation(qubits, ctx, rng) @@ -501,6 +513,39 @@ mod tests { ); } + #[test] + fn measurement_error_propagates_to_a_re_measured_qubit() { + // The measurement error is a physical X before readout (DEM + // convention), so measuring the same qubit twice without a reset + // sees the second outcome flipped at 2p(1-p), not p. A record-only + // flip (the bug this guards) would give p. + let p = 0.25; + let channel = PerGatePauliChannel::new().with_meas_init(p, 0.0); + let model = ComposableNoiseModel::new().add_channel(channel); + + let commands = CommandBuilder::new().pz(&[0]).mz(&[0]).mz(&[0]).build(); + let mut state = SparseStab::new(2); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let mut second_ones = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); + let bits: Vec = outcomes.iter().map(|o| o.outcome).collect(); + assert_eq!(bits.len(), 2); + if bits[1] { + second_ones += 1; + } + } + let rate = second_ones as f64 / SHOTS as f64; + let expected = 2.0 * p * (1.0 - p); + assert!( + (rate - expected).abs() < five_sigma(expected), + "second-measure rate: got {rate}, expected {expected}" + ); + } + #[test] fn base_rates_back_fill_unlisted_gates() { // base p1 = 0.3 -> uniform X/Y/Z at 0.1 each; X and Y flip the diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index d06b679af..a42bb89b5 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -1357,7 +1357,14 @@ impl Sampling { Self::MonteCarlo { shots, .. } => *shots, Self::ImportanceSampling { config } => config.shots(), Self::SubsetSimulation { config } => config.samples_per_level, - Self::PathEnumeration { config } => 1usize << config.max_measurements, + // Saturate rather than overflow the shift: an out-of-range + // max_measurements is rejected with a friendly message at + // build time (the <= 24 path-enumeration assert), and this + // accessor must not panic with a raw shift overflow first. + Self::PathEnumeration { config } => u32::try_from(config.max_measurements) + .ok() + .and_then(|bits| 1usize.checked_shl(bits)) + .unwrap_or(usize::MAX), } } } @@ -3312,6 +3319,22 @@ fn unified_simulation_post_shot(resources: &mut Resources) { .push(shot); } + // `shots[i]` is consumed as the register view of `outcomes[i]`, so the + // two must stay index-aligned: a source must produce a shot record for + // every shot or for none. Anything in between (a source that returns + // Some on some shots and None on others) silently misaligns them. + let results = resources.get::(); + debug_assert!( + results + .shots + .as_ref() + .is_none_or(|shots| shots.shots.len() == results.outcomes.len()), + "shot records and outcomes are misaligned ({} shot records vs {} outcomes); \ + a CommandSource must return shot_results() for every shot or for none", + results.shots.as_ref().map_or(0, |s| s.shots.len()), + results.outcomes.len(), + ); + // Increment shot counter resources.get_mut::().shot_index += 1; } @@ -3452,13 +3475,19 @@ fn is_sim_startup(resources: &mut Resources) { } /// Pre-shot system for importance sampling: derive and set per-shot seeds. +/// +/// Only reseeds when a base seed was configured, mirroring the unified +/// Monte Carlo path ([`unified_simulation_pre_shot`]): an unseeded run +/// keeps the runner's entropy-seeded RNG rather than silently forcing a +/// deterministic stream. fn is_sim_pre_shot(resources: &mut Resources) { let config = resources.get::().clone(); let state = resources.get_mut::(); - let base_seed = config.seed.unwrap_or(0); - let shot_index = state.shot_index; - seed_importance_runner(&mut state.runner, base_seed, shot_index); + if let Some(base_seed) = config.seed { + let shot_index = state.shot_index; + seed_importance_runner(&mut state.runner, base_seed, shot_index); + } } /// Execute system for importance sampling: run one shot with biased noise. @@ -3971,7 +4000,10 @@ impl Simulation { ) -> SimulationResults { let shots = config.shots; let num_workers = is_config.workers; - let base_seed = config.seed.unwrap_or(0); + // Reseed per shot only when a base seed was configured; an + // unseeded run keeps each worker's entropy-seeded runner, matching + // the Monte Carlo path rather than forcing a deterministic stream. + let base_seed = config.seed; let shots_per_worker = distribute_shots(shots, num_workers); let mut start_indices = vec![0usize; num_workers]; @@ -3995,7 +4027,9 @@ impl Simulation { let mut runner = build_importance_runner(is_config, spec.num_qubits); let start = start_indices[worker_id]; for shot_index in start..start + worker_shots { - seed_importance_runner(&mut runner, base_seed, shot_index); + if let Some(base_seed) = base_seed { + seed_importance_runner(&mut runner, base_seed, shot_index); + } let result = runner.run_shot_fresh(&spec.circuit); outcomes.push(result.outcomes); weights.push(result.weight); @@ -4101,6 +4135,20 @@ impl Simulation { } } + // Cross-worker alignment: each worker's records are index-aligned + // (the post-shot debug-assert enforces it per worker), but merging + // a worker that produced shot records with one that did not would + // desync the flattened vectors. Guard the merged invariant too. + debug_assert!( + shots + .as_ref() + .is_none_or(|s| s.shots.len() == outcomes.len()), + "merged shot records and outcomes are misaligned ({} shot records vs {} \ + outcomes); all workers must agree on whether the source produces shot records", + shots.as_ref().map_or(0, |s| s.shots.len()), + outcomes.len(), + ); + SimulationResults { outcomes, weights: None, diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 350275686..09166704f 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -84,6 +84,7 @@ impl PyQasmEngineBuilder { explicit_num_qubits: None, foreign_object: None, stack: None, + classical_override: false, }), }) } @@ -260,6 +261,10 @@ pub struct PyQasmSimBuilder { pub(crate) explicit_num_qubits: Option, pub(crate) foreign_object: Option>, pub(crate) stack: Option, + /// True once `.classical()` has supplied an explicit engine builder. + /// The neo route rejects it (the facade contract has no classical + /// override on neo), matching the Rust `sim().stack(Neo)` behavior. + pub(crate) classical_override: bool, } /// Python wrapper for built QASM simulation diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 0d09e9e3c..6538966bc 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -107,6 +107,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { explicit_num_qubits: None, foreign_object: None, stack: None, + classical_override: false, }), }) } else if let Ok(qis_prog) = program.extract::(py) { @@ -266,6 +267,7 @@ impl PySimBuilder { drop(existing_engine_lock); sim_builder.engine_builder = Arc::new(Mutex::new(Some(qasm_engine.inner))); + sim_builder.classical_override = true; Ok(PySimBuilder { inner: self.inner.clone(), }) @@ -693,7 +695,10 @@ impl PySimBuilder { )); } } else { - sim_builder + return Err(PyTypeError::new_err( + "Unrecognized quantum engine builder type; expected state_vector(), \ + sparse_stab(), stabilizer(), stab_vec(), density_matrix(), or coin_toss()", + )); }; } @@ -710,7 +715,10 @@ impl PySimBuilder { { sim_builder.noise(biased.inner.clone()) } else { - sim_builder + return Err(PyTypeError::new_err( + "Unrecognized noise builder type; expected depolarizing_noise(), \ + biased_depolarizing_noise(), or general_noise()", + )); }; } @@ -1705,6 +1713,16 @@ fn run_qasm_via_facade( remove .wasm()/.foreign_object() or use the engines stack", )); } + if builder.stack == Some(PySimStack::Neo) && builder.classical_override { + // The facade contract has no classical-engine override on the neo + // stack (the Rust sim().stack(Neo) path rejects it the same way). + // Refuse rather than silently dropping the explicit engine and + // running with only its program. + return Err(PyRuntimeError::new_err( + "Explicit .classical() engine builders are not routed to the neo stack; \ + remove .classical() or use the engines stack", + )); + } // The Python QasmEngineBuilder can only carry a program and a WASM // module. A plain program re-enters through the facade's auto @@ -1847,11 +1865,6 @@ fn run_program_neo( noise: Option<&Py>, shots: usize, ) -> PyResult { - use crate::engine_builders::{ - PyBiasedDepolarizingNoiseModelBuilder, PyDepolarizingNoiseModelBuilder, - PyGeneralNoiseModelBuilder, - }; - if quantum.is_some() { return Err(PyRuntimeError::new_err( "Explicit quantum backends are not yet routed to the neo stack (it uses the \ @@ -1870,24 +1883,10 @@ fn run_program_neo( facade = facade.qubits(n); } if let Some(noise_py) = noise { - facade = Python::attach(|py| -> PyResult<_> { - if let Ok(general) = noise_py.extract::(py) { - Ok(facade.noise(general.inner.clone())) - } else if let Ok(depolarizing) = noise_py.extract::(py) - { - Ok(facade.noise(depolarizing.inner.clone())) - } else if let Ok(biased) = noise_py.extract::(py) - { - Ok(facade.noise(biased.inner.clone())) - } else { - // The engines path silently ignores unknown noise objects; - // the neo route refuses instead so nothing runs noiseless - // by accident. - Err(PyRuntimeError::new_err( - "Unrecognized noise builder type for the neo stack route", - )) - } - })?; + // Shared with the QASM route: extracts the known noise builder + // types and refuses unrecognized objects with a typed error + // listing the accepted constructors. + facade = apply_noise_to_facade(facade, noise_py)?; } match facade.run(shots) { Ok(shot_vec) => Ok(crate::shot_results_bindings::PyShotVec::new(shot_vec)), @@ -1911,6 +1910,7 @@ impl Clone for SimBuilderInner { explicit_num_qubits: builder.explicit_num_qubits, foreign_object: builder.foreign_object.as_ref().map(|obj| obj.clone_ref(py)), stack: builder.stack, + classical_override: builder.classical_override, }), SimBuilderInner::QisControl(builder) => { SimBuilderInner::QisControl(PyQisControlSimBuilder { diff --git a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py index 9415540ac..3b7855303 100644 --- a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py +++ b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py @@ -116,3 +116,19 @@ def test_missing_qasm_source_reports_the_real_problem() -> None: for stack in ["engines", "neo"]: with pytest.raises(RuntimeError, match="No QASM source specified"): qasm_engine().to_sim().stack(stack).run(2) + + +def test_neo_stack_rejects_explicit_classical_engine() -> None: + """An explicit .classical() engine must be refused on neo rather than + silently dropped (regression: review finding S9). The engines stack + still accepts it.""" + from pecos_rslib import qasm_engine + + explicit = qasm_engine().program(Qasm.from_string(X_MEASURE)) + + with pytest.raises(RuntimeError, match="not routed to the neo stack"): + sim(Qasm.from_string(X_MEASURE)).classical(explicit).stack("neo").run(5) + + # Same configuration is fine on the engines stack. + results = sim(Qasm.from_string(X_MEASURE)).classical(explicit).stack("engines").run(5) + assert len(list(results["c"])) == 5 From 04e5cfee97c57780258ea1eb166aa6b6dff0aab4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 11:09:00 -0600 Subject: [PATCH 136/388] Expose correlated PyMatching DEM option --- .../dem_builder/equivalence.rs | 6 ++- .../surface/native_dem_threshold_sweep.py | 48 ++++++++++++++----- .../src/pecos/qec/surface/decode.py | 13 +++-- .../pecos/unit/test_surface_sweep_math.py | 8 ++++ 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs index d18e60b01..67b5f7e72 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs @@ -19,8 +19,10 @@ //! //! - Two DEMs are equivalent if they produce the same probability distribution //! over (`detector_events`, `dem_output_flips`) patterns. -//! - Decomposed DEMs (using ^) create independent error channels that are `XORed`. -//! - Different decomposition strategies can produce equivalent sampling results. +//! - Decomposed DEMs (using ^) split one correlated error mechanism into +//! graphlike components that are `XORed` when that mechanism fires. +//! - Different decomposition strategies can produce equivalent sampling results +//! when their correlated mechanisms have the same combined effects. //! - For non-decomposed DEMs, mechanisms must match exactly. //! //! # Comparison Methods diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 90b27e54a..a1f64518f 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -563,9 +563,10 @@ def _noise_model_description(args: argparse.Namespace) -> str: def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int = 5) -> object: """Create a DEM-level decoder from a DEM string. - Supports MWPM decoders (pymatching), search decoders (tesseract), and - check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) - via DemAwareDecoder which extracts the check matrix from the DEM. + Supports MWPM decoders (pymatching, pymatching_correlated), search + decoders (tesseract), and check-matrix decoders (bp_osd, bp_lsd, + union_find, relay_bp, min_sum_bp) via DemAwareDecoder which extracts the + check matrix from the DEM. """ if decoder_type == "tesseract": from pecos.decoders import TesseractDecoder @@ -581,6 +582,9 @@ def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int from pecos.decoders import PyMatchingDecoder + if decoder_type == "pymatching_correlated": + return PyMatchingDecoder.from_dem_with_correlations(dem_str, enable_correlations=True) + return PyMatchingDecoder.from_dem(dem_str) @@ -679,7 +683,7 @@ def _decoder_runtime( patch, num_rounds=total_rounds, noise=noise, - decoder_type=decoder_type, + decoder_type="pymatching" if decoder_type == "pymatching_correlated" else decoder_type, use_circuit_level_dem=True, circuit_level_dem_mode=dem_mode, circuit_level_dem_source=native_circuit_source, @@ -698,7 +702,7 @@ def _decoder_runtime( def _native_sampler_model_for_decoder(decoder_type: str) -> str: """Choose the native sampler model paired with a DEM decoder.""" - if decoder_type == "pymatching": + if decoder_type in {"pymatching", "pymatching_correlated"}: return "dem" return "influence_dem" @@ -746,9 +750,11 @@ def _native_sampler_runtime( interaction_basis=interaction_basis, sampling_model=_native_sampler_model_for_decoder(decoder_type), ) - # PyMatching needs decomposed (graph-like) DEMs; Tesseract and check-matrix - # decoders handle hyperedges natively and should get the full DEM. - if decoder_type == "pymatching": + # PyMatching uses graphlike decomposed DEMs. The correlated variant also + # consumes decomposition separators as correlation metadata. Tesseract and + # check-matrix decoders handle hyperedges natively and should get the full + # DEM. + if decoder_type in {"pymatching", "pymatching_correlated"}: dem_str = runtime.decoder.get_dem(basis.upper(), circuit_level=True) else: dem_str = generate_circuit_level_dem_from_builder( @@ -1304,7 +1310,13 @@ def _run_memory_point( # The DemSampler keeps all per-shot data in Rust -- nothing crosses to Python. dem_str_for_rust = native_runtime.dem_str rust_sampler = getattr(sampler, "sampler", None) - if dem_str_for_rust and rust_sampler and hasattr(rust_sampler, "sample_decode_count"): + use_rust_sample_decode = ( + decoder_type != "pymatching_correlated" + and dem_str_for_rust + and rust_sampler + and hasattr(rust_sampler, "sample_decode_count") + ) + if use_rust_sample_decode: # Use parallel path for slow decoders (Tesseract, BP+OSD, etc.) if decoder_type != "pymatching" and hasattr(rust_sampler, "sample_decode_count_parallel"): num_logical_errors = rust_sampler.sample_decode_count_parallel( @@ -3726,16 +3738,30 @@ def _parse_args() -> argparse.Namespace: "--dem-mode", choices=["native_decomposed", "native_full"], default="native_decomposed", - help="PECOS native DEM mode. PyMatching typically wants native_decomposed.", + help=( + "PECOS native DEM mode. Graph decoders such as PyMatching require " + "native_decomposed; raw-DEM decoders should use native_full." + ), ) parser.add_argument( "--decoder", nargs="+", - choices=["pymatching", "tesseract", "bp_osd", "bp_lsd", "union_find", "relay_bp", "min_sum_bp"], + choices=[ + "pymatching", + "pymatching_correlated", + "tesseract", + "bp_osd", + "bp_lsd", + "union_find", + "relay_bp", + "min_sum_bp", + ], default=["pymatching"], help=( "Decoder(s) for circuit-level DEM decoding. Specify multiple to " "compare them side-by-side in plots and reports. Default: pymatching. " + "pymatching_correlated enables PyMatching's DEM-correlation mode for " + "decomposed errors. " "Check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) " "extract a check matrix from the DEM automatically." ), diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index c30a37c3b..8e2bd2765 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -2055,9 +2055,11 @@ def generate_circuit_level_dem_from_builder( num_rounds: Number of syndrome extraction rounds noise: Noise model parameters basis: Memory basis ('X' or 'Z') - decompose_errors: If True, return PECOS's native decomposed DEM - representation, which is more appropriate for graph-based - decoders like PyMatching. + decompose_errors: If True, return PECOS's native graphlike-decomposed + DEM representation for graph decoders such as PyMatching. The + decomposition preserves correlated mechanism metadata with ``^`` + separators, but graph decoders may still approximate hyperedge + correlations. ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, the native DEM is built from the same batched ancilla-reuse circuit family used by Guppy. @@ -2547,8 +2549,9 @@ def __init__( circuit_level_dem_mode: Which PECOS-native DEM representation to use when circuit-level DEMs are enabled. ``"native_full"`` preserves the current non-decomposed DEM output. ``"native_decomposed"`` - returns PECOS's graphlike decomposed DEM output, which is often - a better fit for graph decoders such as PyMatching. + returns PECOS's graphlike decomposed DEM output for graph + decoders such as PyMatching. This is a decoder-facing + approximation of hyperedge correlations, not an exact raw DEM. circuit_level_dem_source: Which ideal circuit to analyze when building native circuit-level DEMs. ``"abstract"`` uses the high-level surface TickCircuit, while ``"traced_qis"`` traces diff --git a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py index 930d6654b..1c4f80788 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py +++ b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_math.py @@ -198,10 +198,18 @@ def test_linear_regression_requires_matching_lengths(sweep: ModuleType) -> None: def test_native_sampler_model_tracks_decoder_dem_requirements(sweep: ModuleType) -> None: """Raw-DEM decoders should sample the exact native influence model.""" assert sweep._native_sampler_model_for_decoder("pymatching") == "dem" + assert sweep._native_sampler_model_for_decoder("pymatching_correlated") == "dem" assert sweep._native_sampler_model_for_decoder("tesseract") == "influence_dem" assert sweep._native_sampler_model_for_decoder("bp_osd") == "influence_dem" +def test_create_dem_decoder_supports_correlated_pymatching(sweep: ModuleType) -> None: + """The explicit correlated PyMatching option should construct a decoder.""" + decoder = sweep._create_dem_decoder("pymatching_correlated", "error(0.1) D0 ^ D1") + + assert "PyMatchingDecoder" in repr(decoder) + + # --------------------------------------------------------------------------- # Per-round rate fit # --------------------------------------------------------------------------- From c722969da7b477d9a11c2e7318899e8dd84560a0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 11:31:58 -0600 Subject: [PATCH 137/388] Add DEM decomposition diagnostics --- .../surface/dem_decomposition_diagnostics.py | 498 ++++++++++++++++++ 1 file changed, 498 insertions(+) create mode 100644 examples/surface/dem_decomposition_diagnostics.py diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py new file mode 100644 index 000000000..e558dc791 --- /dev/null +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -0,0 +1,498 @@ +"""Diagnose raw DEMs and graphlike decompositions for traced-QIS surface circuits. + +The script keeps sampling fixed: each case samples once from the exact native +influence-model DEM, then decodes the same detector events with several decoder +views of the model. This separates raw DEM generation from graphlike +decomposition quality. + +Example: + uv run python examples/surface/dem_decomposition_diagnostics.py \\ + --distances 3 5 --bases X Z --interaction-bases cx szz \\ + --p 0.006 --shots 10000 --tesseract-beams 5 20 +""" + +from __future__ import annotations + +import argparse +import json +import re +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np + +ERROR_RE = re.compile(r"error\(([^)]+)\)\s*(.*)") +DET_RE = re.compile(r"\bD(\d+)\b") +OBS_RE = re.compile(r"\bL(\d+)\b") + + +@dataclass(frozen=True) +class DemStats: + error_lines: int + probability_sum: float + separator_lines: int + hyperedge_lines: int + logical_lines: int + max_component_detectors: int + max_line_detectors: int + pure_logical_components: int + + +@dataclass(frozen=True) +class RawDemComparison: + native_errors: int + stim_errors: int + only_native: int + only_stim: int + common: int + max_abs_probability_diff: float + max_rel_probability_diff: float + l1_probability_diff: float + + +@dataclass(frozen=True) +class DecodeSummary: + decoder: str + logical_errors: int + logical_error_rate: float + elapsed_s: float + + +@dataclass(frozen=True) +class CaseResult: + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + shots: int + raw_comparison: RawDemComparison + dem_stats: dict[str, DemStats] + decoders: list[DecodeSummary] + + +def _combine_independent_probabilities(left: float, right: float) -> float: + """Combine independent mechanisms with the same XOR effect.""" + return left * (1.0 - right) + right * (1.0 - left) + + +def _toggle(values: set[int], value: int) -> None: + if value in values: + values.remove(value) + else: + values.add(value) + + +def _canonical_effect_key(targets: str) -> str: + """Canonicalize DEM targets by XORing all ``^`` components.""" + detectors: set[int] = set() + observables: set[int] = set() + for component in targets.split("^"): + for detector in DET_RE.findall(component): + _toggle(detectors, int(detector)) + for observable in OBS_RE.findall(component): + _toggle(observables, int(observable)) + tokens = [f"D{det}" for det in sorted(detectors)] + tokens.extend(f"L{obs}" for obs in sorted(observables)) + return " ".join(tokens) + + +def dem_effect_probabilities(dem_text: str) -> dict[str, float]: + """Aggregate DEM error probabilities by combined detector/observable effect.""" + effects: dict[str, float] = {} + for line in dem_text.splitlines(): + match = ERROR_RE.match(line.strip()) + if not match: + continue + probability = float(match.group(1)) + key = _canonical_effect_key(match.group(2)) + if not key: + continue + effects[key] = _combine_independent_probabilities(effects.get(key, 0.0), probability) + return effects + + +def compare_raw_dems(native_dem: str, stim_dem: str) -> RawDemComparison: + """Compare raw native and Stim DEMs after aggregating duplicate effects.""" + native = dem_effect_probabilities(native_dem) + stim = dem_effect_probabilities(stim_dem) + native_keys = set(native) + stim_keys = set(stim) + common = native_keys & stim_keys + + max_abs = 0.0 + max_rel = 0.0 + l1 = 0.0 + for key in common: + diff = abs(native[key] - stim[key]) + max_abs = max(max_abs, diff) + max_rel = max(max_rel, diff / max(native[key], stim[key], 1e-18)) + l1 += diff + for key in native_keys - stim_keys: + l1 += native[key] + for key in stim_keys - native_keys: + l1 += stim[key] + + return RawDemComparison( + native_errors=len(native), + stim_errors=len(stim), + only_native=len(native_keys - stim_keys), + only_stim=len(stim_keys - native_keys), + common=len(common), + max_abs_probability_diff=max_abs, + max_rel_probability_diff=max_rel, + l1_probability_diff=l1, + ) + + +def dem_stats(dem_text: str) -> DemStats: + """Summarize the structure of a DEM string.""" + error_lines = 0 + probability_sum = 0.0 + separator_lines = 0 + hyperedge_lines = 0 + logical_lines = 0 + max_component_detectors = 0 + max_line_detectors = 0 + pure_logical_components = 0 + + for line in dem_text.splitlines(): + match = ERROR_RE.match(line.strip()) + if not match: + continue + error_lines += 1 + probability_sum += float(match.group(1)) + targets = match.group(2) + components = targets.split(" ^ ") + if len(components) > 1: + separator_lines += 1 + if "L" in targets: + logical_lines += 1 + + line_detectors = len(DET_RE.findall(targets)) + max_line_detectors = max(max_line_detectors, line_detectors) + if line_detectors > 2: + hyperedge_lines += 1 + + for component in components: + component_detectors = len(DET_RE.findall(component)) + component_observables = len(OBS_RE.findall(component)) + max_component_detectors = max(max_component_detectors, component_detectors) + if component_detectors == 0 and component_observables > 0: + pure_logical_components += 1 + + return DemStats( + error_lines=error_lines, + probability_sum=probability_sum, + separator_lines=separator_lines, + hyperedge_lines=hyperedge_lines, + logical_lines=logical_lines, + max_component_detectors=max_component_detectors, + max_line_detectors=max_line_detectors, + pure_logical_components=pure_logical_components, + ) + + +def strip_logical_observable_lines(dem_text: str) -> str: + return "\n".join(line for line in dem_text.splitlines() if not line.startswith("logical_observable")) + + +def true_observable_flips(observable_flips: np.ndarray) -> np.ndarray: + if observable_flips.ndim == 1: + return observable_flips.astype(np.uint8) + if observable_flips.shape[1] == 0: + return np.zeros(observable_flips.shape[0], dtype=np.uint8) + return observable_flips[:, 0].astype(np.uint8) + + +def decode_with_tesseract( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + beam: int, +) -> int: + from pecos.decoders import TesseractDecoder + + decoder = TesseractDecoder.from_dem( + strip_logical_observable_lines(dem_text), + preset="fast", + det_beam=beam, + ) + expected = true_observable_flips(observable_flips) + syndromes = [detection_events[index].astype(np.uint8).tolist() for index in range(len(detection_events))] + results = decoder.decode_batch(syndromes) + return sum(int(int(result.observables_mask & 1) != expected[index]) for index, result in enumerate(results)) + + +def decode_with_pymatching( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + correlated: bool, +) -> int: + from pecos.decoders import PyMatchingDecoder + + if correlated: + decoder = PyMatchingDecoder.from_dem_with_correlations(dem_text, enable_correlations=True) + else: + decoder = PyMatchingDecoder.from_dem(dem_text) + + expected = true_observable_flips(observable_flips) + predictions = decoder.decode_batch( + detection_events.astype(np.uint8).flatten().tolist(), + len(detection_events), + ) + predicted = np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + return int(np.sum(predicted != expected)) + + +def _timed_decode(label: str, callback: Any, shots: int) -> DecodeSummary: + start = time.perf_counter() + errors = int(callback()) + elapsed = time.perf_counter() - start + return DecodeSummary( + decoder=label, + logical_errors=errors, + logical_error_rate=errors / shots if shots else 0.0, + elapsed_s=elapsed, + ) + + +def run_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + tesseract_beams: list[int], +) -> CaseResult: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, + ) + from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, + ) + + patch = SurfacePatch.create(distance=distance) + noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise_args = { + "p1": noise.p1, + "p2": noise.p2, + "p_meas": noise.p_meas, + "p_prep": noise.p_prep, + } + + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="DEM decomposition diagnostics") + + native_raw = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + native_decomposed = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + stim_raw = generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ) + stim_decomposed = generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=True, + **noise_args, + ) + + sampler = build_native_sampler( + patch, + rounds, + noise, + basis=basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + sampling_model="influence_dem", + ) + detection_events, observable_flips = sampler.sample(num_shots=shots, seed=seed) + + decoders = [ + _timed_decode( + f"native_raw_tesseract_b{beam}", + lambda beam=beam: decode_with_tesseract( + native_raw, + detection_events, + observable_flips, + beam=beam, + ), + shots, + ) + for beam in tesseract_beams + ] + decoders.extend( + [ + _timed_decode( + "native_decomp_pymatching", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + shots, + ), + _timed_decode( + "native_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + shots, + ), + _timed_decode( + "stim_decomp_pymatching", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + shots, + ), + _timed_decode( + "stim_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + shots, + ), + ], + ) + + return CaseResult( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=shots, + raw_comparison=compare_raw_dems(native_raw, stim_raw), + dem_stats={ + "native_raw": dem_stats(native_raw), + "native_decomposed": dem_stats(native_decomposed), + "stim_raw": dem_stats(stim_raw), + "stim_decomposed": dem_stats(stim_decomposed), + }, + decoders=decoders, + ) + + +def print_case(result: CaseResult) -> None: + print( + f"\n=== d={result.distance} r={result.rounds} basis={result.basis} " + f"basis2q={result.interaction_basis} p={result.p:g} shots={result.shots} ===", + ) + raw = result.raw_comparison + print( + "raw native vs Stim: " + f"native={raw.native_errors} stim={raw.stim_errors} " + f"only_native={raw.only_native} only_stim={raw.only_stim} " + f"max_rel={raw.max_rel_probability_diff:.3e} " + f"l1={raw.l1_probability_diff:.3e}", + ) + + print("DEM stats:") + print(" source errors psum sep hyper max_comp max_line pure_L") + for name, stats in result.dem_stats.items(): + print( + f" {name:<18} {stats.error_lines:6d} {stats.probability_sum:10.6f} " + f"{stats.separator_lines:5d} {stats.hyperedge_lines:5d} " + f"{stats.max_component_detectors:8d} {stats.max_line_detectors:8d} " + f"{stats.pure_logical_components:6d}", + ) + + print("Decode on identical raw influence samples:") + print(" decoder errors LER elapsed") + for summary in result.decoders: + print( + f" {summary.decoder:<36} {summary.logical_errors:6d} " + f"{summary.logical_error_rate:10.6f} {summary.elapsed_s:8.3f}s", + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X", "Z"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--p", nargs="+", type=float, default=[0.006]) + parser.add_argument("--shots", type=int, default=10000) + parser.add_argument("--seed", type=int, default=20260613) + parser.add_argument("--tesseract-beams", nargs="+", type=int, default=[5]) + parser.add_argument("--save-json", type=Path, default=None) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + results: list[CaseResult] = [] + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + for basis in args.bases: + for interaction_basis in args.interaction_bases: + for p in args.p: + result = run_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=args.shots, + seed=args.seed, + tesseract_beams=args.tesseract_beams, + ) + results.append(result) + print_case(result) + + if args.save_json is not None: + payload = [asdict(result) for result in results] + args.save_json.parent.mkdir(parents=True, exist_ok=True) + args.save_json.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + print(f"\nWrote {args.save_json}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From cba0f05695ea092150a7daf2b9f96d3e5f388dc4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 11:35:12 -0600 Subject: [PATCH 138/388] Add two-fault DEM decomposition diagnostics --- .../surface/dem_decomposition_diagnostics.py | 186 ++++++++++++++++-- 1 file changed, 165 insertions(+), 21 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index e558dc791..d6ffb66b5 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -60,6 +60,18 @@ class DecodeSummary: elapsed_s: float +@dataclass(frozen=True) +class PairAnalysisSummary: + decoder: str + pair_probability_mass: float + wrong_probability_mass: float + wrong_probability_fraction: float + disagree_tesseract_probability_mass: float + disagree_tesseract_probability_fraction: float + wrong_count: int + disagree_tesseract_count: int + + @dataclass(frozen=True) class CaseResult: distance: int @@ -71,6 +83,7 @@ class CaseResult: raw_comparison: RawDemComparison dem_stats: dict[str, DemStats] decoders: list[DecodeSummary] + pair_analysis: list[PairAnalysisSummary] | None def _combine_independent_probabilities(left: float, right: float) -> float: @@ -207,13 +220,24 @@ def true_observable_flips(observable_flips: np.ndarray) -> np.ndarray: return observable_flips[:, 0].astype(np.uint8) -def decode_with_tesseract( - dem_text: str, - detection_events: np.ndarray, - observable_flips: np.ndarray, - *, - beam: int, -) -> int: +def dense_effect_arrays(effects: dict[str, float]) -> tuple[list[str], np.ndarray, np.ndarray, np.ndarray]: + """Convert effect keys into dense detector rows and observable flips.""" + keys = list(effects) + probabilities = np.array([effects[key] for key in keys], dtype=float) + max_detector = max((int(detector) for key in keys for detector in DET_RE.findall(key)), default=-1) + detection_events = np.zeros((len(keys), max_detector + 1), dtype=np.uint8) + observable_flips = np.zeros(len(keys), dtype=np.uint8) + + for index, key in enumerate(keys): + detectors = [int(detector) for detector in DET_RE.findall(key)] + if detectors: + detection_events[index, detectors] = 1 + observable_flips[index] = 1 if "0" in OBS_RE.findall(key) else 0 + + return keys, probabilities, detection_events, observable_flips + + +def tesseract_predictions(dem_text: str, detection_events: np.ndarray, *, beam: int) -> np.ndarray: from pecos.decoders import TesseractDecoder decoder = TesseractDecoder.from_dem( @@ -221,32 +245,45 @@ def decode_with_tesseract( preset="fast", det_beam=beam, ) - expected = true_observable_flips(observable_flips) - syndromes = [detection_events[index].astype(np.uint8).tolist() for index in range(len(detection_events))] - results = decoder.decode_batch(syndromes) - return sum(int(int(result.observables_mask & 1) != expected[index]) for index, result in enumerate(results)) + results = decoder.decode_batch([row.tolist() for row in detection_events]) + return np.array([int(result.observables_mask & 1) for result in results], dtype=np.uint8) -def decode_with_pymatching( - dem_text: str, - detection_events: np.ndarray, - observable_flips: np.ndarray, - *, - correlated: bool, -) -> int: +def pymatching_predictions(dem_text: str, detection_events: np.ndarray, *, correlated: bool) -> np.ndarray: from pecos.decoders import PyMatchingDecoder if correlated: decoder = PyMatchingDecoder.from_dem_with_correlations(dem_text, enable_correlations=True) else: decoder = PyMatchingDecoder.from_dem(dem_text) - - expected = true_observable_flips(observable_flips) predictions = decoder.decode_batch( detection_events.astype(np.uint8).flatten().tolist(), len(detection_events), ) - predicted = np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + return np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + + +def decode_with_tesseract( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + beam: int, +) -> int: + expected = true_observable_flips(observable_flips) + predicted = tesseract_predictions(dem_text, detection_events, beam=beam) + return int(np.sum(predicted != expected)) + + +def decode_with_pymatching( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, + *, + correlated: bool, +) -> int: + expected = true_observable_flips(observable_flips) + predicted = pymatching_predictions(dem_text, detection_events, correlated=correlated) return int(np.sum(predicted != expected)) @@ -262,6 +299,82 @@ def _timed_decode(label: str, callback: Any, shots: int) -> DecodeSummary: ) +def two_fault_pair_analysis( + *, + native_raw: str, + native_decomposed: str, + stim_decomposed: str, + max_effects: int, +) -> list[PairAnalysisSummary] | None: + """Exhaustively compare decoders on all two-mechanism XOR combinations.""" + effects = dem_effect_probabilities(native_raw) + keys, probabilities, detection_events, observable_flips = dense_effect_arrays(effects) + if len(keys) > max_effects: + return None + + pair_rows: list[np.ndarray] = [] + pair_observables: list[int] = [] + pair_weights: list[float] = [] + for left in range(len(keys)): + for right in range(left + 1, len(keys)): + pair_rows.append(detection_events[left] ^ detection_events[right]) + pair_observables.append(int(observable_flips[left] ^ observable_flips[right])) + pair_weights.append(float(probabilities[left] * probabilities[right])) + + if not pair_rows: + return [] + + pair_detection_events = np.asarray(pair_rows, dtype=np.uint8) + pair_observable_flips = np.asarray(pair_observables, dtype=np.uint8) + weights = np.asarray(pair_weights, dtype=float) + total_weight = float(np.sum(weights)) + + predictions = { + "native_raw_tesseract_b5": tesseract_predictions(native_raw, pair_detection_events, beam=5), + "native_decomp_pymatching": pymatching_predictions( + native_decomposed, + pair_detection_events, + correlated=False, + ), + "native_decomp_pymatching_correlated": pymatching_predictions( + native_decomposed, + pair_detection_events, + correlated=True, + ), + "stim_decomp_pymatching": pymatching_predictions( + stim_decomposed, + pair_detection_events, + correlated=False, + ), + "stim_decomp_pymatching_correlated": pymatching_predictions( + stim_decomposed, + pair_detection_events, + correlated=True, + ), + } + reference = predictions["native_raw_tesseract_b5"] + + summaries = [] + for name, predicted in predictions.items(): + wrong = predicted != pair_observable_flips + disagree = predicted != reference + wrong_mass = float(np.sum(weights[wrong])) + disagree_mass = float(np.sum(weights[disagree])) + summaries.append( + PairAnalysisSummary( + decoder=name, + pair_probability_mass=total_weight, + wrong_probability_mass=wrong_mass, + wrong_probability_fraction=wrong_mass / total_weight if total_weight else 0.0, + disagree_tesseract_probability_mass=disagree_mass, + disagree_tesseract_probability_fraction=disagree_mass / total_weight if total_weight else 0.0, + wrong_count=int(np.sum(wrong)), + disagree_tesseract_count=int(np.sum(disagree)), + ), + ) + return summaries + + def run_case( *, distance: int, @@ -272,6 +385,8 @@ def run_case( shots: int, seed: int, tesseract_beams: list[int], + pair_analysis: bool, + pair_analysis_max_effects: int, ) -> CaseResult: from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler from pecos.qec.surface.circuit_builder import ( @@ -414,6 +529,14 @@ def run_case( "stim_decomposed": dem_stats(stim_decomposed), }, decoders=decoders, + pair_analysis=two_fault_pair_analysis( + native_raw=native_raw, + native_decomposed=native_decomposed, + stim_decomposed=stim_decomposed, + max_effects=pair_analysis_max_effects, + ) + if pair_analysis + else None, ) @@ -449,6 +572,19 @@ def print_case(result: CaseResult) -> None: f"{summary.logical_error_rate:10.6f} {summary.elapsed_s:8.3f}s", ) + if result.pair_analysis is None: + return + print("Exact two-fault analysis:") + print(" decoder wrong_mass wrong_frac disagree_mass disagree_frac") + for summary in result.pair_analysis: + print( + f" {summary.decoder:<36} " + f"{summary.wrong_probability_mass:10.6f} " + f"{summary.wrong_probability_fraction:10.4f} " + f"{summary.disagree_tesseract_probability_mass:13.6f} " + f"{summary.disagree_tesseract_probability_fraction:13.4f}", + ) + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) @@ -460,6 +596,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--shots", type=int, default=10000) parser.add_argument("--seed", type=int, default=20260613) parser.add_argument("--tesseract-beams", nargs="+", type=int, default=[5]) + parser.add_argument( + "--pair-analysis", + action="store_true", + help="Exhaustively compare decoders on all two-fault combinations when the effect count is small enough.", + ) + parser.add_argument("--pair-analysis-max-effects", type=int, default=400) parser.add_argument("--save-json", type=Path, default=None) return parser.parse_args() @@ -481,6 +623,8 @@ def main() -> int: shots=args.shots, seed=args.seed, tesseract_beams=args.tesseract_beams, + pair_analysis=args.pair_analysis, + pair_analysis_max_effects=args.pair_analysis_max_effects, ) results.append(result) print_case(result) From c64e79be858b8d6d75eb92d1e4f7ad2d562b0806 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 12:04:17 -0600 Subject: [PATCH 139/388] Add terminal graphlike DEM projection --- .../src/fault_tolerance/dem_builder/types.rs | 297 ++++++++++++++++++ .../surface/dem_decomposition_diagnostics.py | 155 +++++++++ .../surface/native_dem_threshold_sweep.py | 5 +- .../src/fault_tolerance_bindings.rs | 10 + .../src/pecos/qec/surface/decode.py | 98 ++++-- 5 files changed, 535 insertions(+), 30 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 84b824810..09b4762a4 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -5017,6 +5017,271 @@ impl DetectorErrorModel { lines.join("\n") } + fn detector_coordinate_map(&self) -> BTreeMap { + self.detectors + .iter() + .filter_map(|detector| detector.coords.map(|coords| (detector.id, coords))) + .collect() + } + + fn detector_coordinate_distance( + left: u32, + right: u32, + detector_coords: &BTreeMap, + ) -> f64 { + let left_coords = + detector_coords + .get(&left) + .copied() + .unwrap_or([f64::from(left), 0.0, 0.0]); + let right_coords = + detector_coords + .get(&right) + .copied() + .unwrap_or([f64::from(right), 0.0, 0.0]); + left_coords + .iter() + .zip(right_coords) + .map(|(a, b)| (*a - b).powi(2)) + .sum::() + .sqrt() + } + + fn min_coordinate_terminal_pairs( + detectors: &[u32], + detector_coords: &BTreeMap, + ) -> (Vec<(u32, u32)>, Vec) { + if detectors.len() > 20 { + return Self::greedy_coordinate_terminal_pairs(detectors, detector_coords); + } + + fn solve( + mask: u64, + detectors: &[u32], + detector_coords: &BTreeMap, + memo: &mut BTreeMap, Vec)>, + ) -> (f64, Vec<(u32, u32)>, Vec) { + if let Some(cached) = memo.get(&mask) { + return cached.clone(); + } + + let count = mask.count_ones(); + let result = if count == 0 { + (0.0, Vec::new(), Vec::new()) + } else if count == 1 { + let index = mask.trailing_zeros() as usize; + (0.0, Vec::new(), vec![detectors[index]]) + } else if count % 2 == 1 { + let mut best: Option<(f64, Vec<(u32, u32)>, Vec)> = None; + for index in 0..detectors.len() { + if mask & (1_u64 << index) == 0 { + continue; + } + let rest = mask & !(1_u64 << index); + let (cost, pairs, mut singles) = solve(rest, detectors, detector_coords, memo); + singles.push(detectors[index]); + if best + .as_ref() + .is_none_or(|(best_cost, _, _)| cost < *best_cost) + { + best = Some((cost, pairs, singles)); + } + } + best.expect("odd non-empty mask must have a singleton candidate") + } else { + let first = mask.trailing_zeros() as usize; + let rest_without_first = mask & !(1_u64 << first); + let mut best: Option<(f64, Vec<(u32, u32)>, Vec)> = None; + for second in first + 1..detectors.len() { + if rest_without_first & (1_u64 << second) == 0 { + continue; + } + let rest = rest_without_first & !(1_u64 << second); + let (sub_cost, mut pairs, singles) = + solve(rest, detectors, detector_coords, memo); + let pair = (detectors[first], detectors[second]); + let cost = sub_cost + + DetectorErrorModel::detector_coordinate_distance( + pair.0, + pair.1, + detector_coords, + ); + pairs.insert(0, pair); + if best + .as_ref() + .is_none_or(|(best_cost, _, _)| cost < *best_cost) + { + best = Some((cost, pairs, singles)); + } + } + best.expect("even mask with at least two bits must have a pair candidate") + }; + + memo.insert(mask, result.clone()); + result + } + + let mut memo = BTreeMap::new(); + let mask = (1_u64 << detectors.len()) - 1; + let (_, pairs, singles) = solve(mask, detectors, detector_coords, &mut memo); + (pairs, singles) + } + + fn greedy_coordinate_terminal_pairs( + detectors: &[u32], + detector_coords: &BTreeMap, + ) -> (Vec<(u32, u32)>, Vec) { + let mut remaining: BTreeSet = detectors.iter().copied().collect(); + let mut pairs = Vec::new(); + let mut singles = Vec::new(); + + if remaining.len() % 2 == 1 { + let singleton = remaining + .iter() + .copied() + .max_by(|left, right| { + let left_nearest = remaining + .iter() + .copied() + .filter(|candidate| candidate != left) + .map(|candidate| { + Self::detector_coordinate_distance(*left, candidate, detector_coords) + }) + .fold(f64::INFINITY, f64::min); + let right_nearest = remaining + .iter() + .copied() + .filter(|candidate| candidate != right) + .map(|candidate| { + Self::detector_coordinate_distance(*right, candidate, detector_coords) + }) + .fold(f64::INFINITY, f64::min); + left_nearest + .partial_cmp(&right_nearest) + .unwrap_or(Ordering::Equal) + }) + .expect("odd non-empty detector set should have a singleton"); + remaining.remove(&singleton); + singles.push(singleton); + } + + while let Some(left) = remaining.pop_first() { + let Some(right) = remaining.iter().copied().min_by(|a, b| { + let da = Self::detector_coordinate_distance(left, *a, detector_coords); + let db = Self::detector_coordinate_distance(left, *b, detector_coords); + da.partial_cmp(&db) + .unwrap_or(Ordering::Equal) + .then_with(|| a.cmp(b)) + }) else { + singles.push(left); + break; + }; + remaining.remove(&right); + pairs.push((left, right)); + } + + (pairs, singles) + } + + fn terminal_graphlike_parts( + effect: &FaultMechanism, + detector_coords: &BTreeMap, + ) -> Vec { + let (pairs, singles) = + Self::min_coordinate_terminal_pairs(&effect.detectors, detector_coords); + let mut parts: Vec = pairs + .into_iter() + .map(|(left, right)| FaultMechanism::from_unsorted([left, right], [])) + .collect(); + parts.extend( + singles + .into_iter() + .map(|detector| FaultMechanism::from_unsorted([detector], [])), + ); + + if parts.is_empty() { + if !effect.dem_outputs.is_empty() { + parts.push(FaultMechanism::from_unsorted( + std::iter::empty(), + effect.dem_outputs.iter().copied(), + )); + } + } else if !effect.dem_outputs.is_empty() { + let last = parts + .last_mut() + .expect("non-empty parts checked before attaching observables"); + last.dem_outputs = effect.dem_outputs.clone(); + } + + parts + } + + /// Converts the DEM to a terminal-only graphlike projection. + /// + /// Contributions are first grouped into the same raw mechanisms as + /// [`Self::to_string`]. Each grouped effect is then rendered as graphlike + /// components whose XOR is exactly the original detector/observable effect. + /// Pair components use only detectors present in the raw effect. Ordinary + /// low-weight effects use the exact minimum-total-distance pairing from + /// detector coordinates; unusually large effects use a deterministic + /// nearest-neighbor fallback to avoid exponential render time. This is a + /// decoder-facing projection for graph matchers; it is not source proof. + #[must_use] + pub fn to_string_terminal_graphlike_decomposed(&self) -> String { + let mut lines = Vec::new(); + + for det in &self.detectors { + if let Some([x, y, z]) = det.coords { + lines.push(format!("detector({x}, {y}, {z}) D{}", det.id)); + } else { + lines.push(format!("detector D{}", det.id)); + } + } + + for obs in &self.observables { + lines.push(format!("logical_observable L{}", obs.id)); + } + + let mut by_effect: BTreeMap = BTreeMap::new(); + for contrib in &self.contributions { + by_effect + .entry(contrib.effect.standard_effect()) + .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) + .or_insert(contrib.probability); + } + + let detector_coords = self.detector_coordinate_map(); + let mut by_targets: BTreeMap = BTreeMap::new(); + for (effect, total_prob) in by_effect { + if effect.is_standard_empty() || total_prob <= 0.0 { + continue; + } + + let targets = Self::format_decomposed_parts(Self::terminal_graphlike_parts( + &effect, + &detector_coords, + )); + if !targets.is_empty() { + by_targets + .entry(targets) + .and_modify(|p| *p = combine_independent_probs(*p, total_prob)) + .or_insert(total_prob); + } + } + + for (targets, total_prob) in by_targets { + if !targets.is_empty() && total_prob > 0.0 { + lines.push(format!( + "error({}) {}", + format_probability(total_prob), + targets + )); + } + } + + lines.join("\n") + } + fn collect_singleton_index(&self) -> SingletonDecompositionIndex { SingletonDecompositionIndex::from_contributions(&self.contributions) } @@ -6907,6 +7172,38 @@ mod tests { assert!(!maximal.contains("error(0.01) D0 D1")); } + #[test] + fn test_terminal_graphlike_decomposed_uses_min_coordinate_terminal_pairs() { + let mut dem = DetectorErrorModel::new(); + + dem.add_detector(DetectorDef::new(0).with_coords([0.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(1).with_coords([10.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(2).with_coords([1.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(3).with_coords([11.0, 0.0, 0.0])); + dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1, 2, 3], []), 0.01); + + let projected = dem.to_string_terminal_graphlike_decomposed(); + + assert!(projected.contains("error(0.01) D0 D2 ^ D1 D3")); + assert!(!projected.contains("D0 D1 ^ D2 D3")); + } + + #[test] + fn test_terminal_graphlike_decomposed_attaches_observable_to_terminal_component() { + let mut dem = DetectorErrorModel::new(); + + dem.add_detector(DetectorDef::new(0).with_coords([0.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(1).with_coords([1.0, 0.0, 0.0])); + dem.add_detector(DetectorDef::new(2).with_coords([10.0, 0.0, 0.0])); + dem.add_dem_output(DemOutput::new(0)); + dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1, 2], [0]), 0.01); + + let projected = dem.to_string_terminal_graphlike_decomposed(); + + assert!(projected.contains("logical_observable L0")); + assert!(projected.contains("error(0.01) D0 D1 ^ D2 L0")); + } + #[test] fn test_contribution_effect_summaries_include_graphlike_decomposable_count() { let mut dem = DetectorErrorModel::new(); diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index d6ffb66b5..f703cb9eb 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -15,6 +15,7 @@ import argparse import json +import math import re import time from dataclasses import asdict, dataclass @@ -26,6 +27,7 @@ ERROR_RE = re.compile(r"error\(([^)]+)\)\s*(.*)") DET_RE = re.compile(r"\bD(\d+)\b") OBS_RE = re.compile(r"\bL(\d+)\b") +DETECTOR_COORD_RE = re.compile(r"detector\(([^)]*)\) D(\d+)") @dataclass(frozen=True) @@ -160,6 +162,111 @@ def compare_raw_dems(native_dem: str, stim_dem: str) -> RawDemComparison: ) +def parse_detector_coords(dem_text: str) -> dict[int, tuple[float, ...]]: + """Parse detector coordinate annotations from DEM text.""" + coords: dict[int, tuple[float, ...]] = {} + for line in dem_text.splitlines(): + match = DETECTOR_COORD_RE.match(line.strip()) + if not match: + continue + values = tuple(float(value.strip()) for value in match.group(1).split(",") if value.strip()) + coords[int(match.group(2))] = values + return coords + + +def detector_coord_distance( + left: int, + right: int, + coords: dict[int, tuple[float, ...]], +) -> float: + """Coordinate distance with detector-id fallback for missing annotations.""" + left_coords = coords.get(left, (float(left),)) + right_coords = coords.get(right, (float(right),)) + dims = max(len(left_coords), len(right_coords)) + left_coords = left_coords + (0.0,) * (dims - len(left_coords)) + right_coords = right_coords + (0.0,) * (dims - len(right_coords)) + return math.sqrt(sum((a - b) ** 2 for a, b in zip(left_coords, right_coords, strict=True))) + + +def min_coord_terminal_pairs( + detectors: tuple[int, ...], + coords: dict[int, tuple[float, ...]], +) -> tuple[list[tuple[int, int]], list[int]]: + """Pair terminals by minimum coordinate distance, leaving one singleton if odd.""" + if len(detectors) <= 1: + return [], list(detectors) + if len(detectors) == 2: + return [(detectors[0], detectors[1])], [] + + best_cost: float | None = None + best_pairs: list[tuple[int, int]] = [] + best_singles: list[int] = [] + for left_index, left in enumerate(detectors): + for right in detectors[left_index + 1 :]: + rest = tuple(detector for detector in detectors if detector not in {left, right}) + pairs, singles = min_coord_terminal_pairs(rest, coords) + pairs = [(left, right), *pairs] + cost = sum(detector_coord_distance(a, b, coords) for a, b in pairs) + if best_cost is None or cost < best_cost: + best_cost = cost + best_pairs = pairs + best_singles = singles + return best_pairs, best_singles + + +def terminal_graphlike_projection(raw_dem: str) -> str: + """Project raw DEM effects into minimum-span terminal-only graphlike pieces. + + Each raw mechanism keeps its combined detector/observable effect exactly, + but the rendered decomposition uses only detectors present in that raw + effect. This avoids cancellation/path detectors introduced by graph-path + decompositions while still producing graphlike components for matching + decoders. + """ + coords = parse_detector_coords(raw_dem) + annotation_lines: list[str] = [] + by_targets: dict[str, float] = {} + + for line in raw_dem.splitlines(): + stripped = line.strip() + if stripped.startswith(("detector", "logical_observable")): + annotation_lines.append(line) + continue + + match = ERROR_RE.match(stripped) + if not match: + continue + probability = float(match.group(1)) + effect = _canonical_effect_key(match.group(2)) + detectors = tuple(sorted(int(detector) for detector in DET_RE.findall(effect))) + observables = sorted(int(observable) for observable in OBS_RE.findall(effect)) + pairs, singles = min_coord_terminal_pairs(detectors, coords) + + components = [f"D{left} D{right}" for left, right in pairs] + components.extend(f"D{detector}" for detector in singles) + if not components and observables: + # PyMatching cannot use a pure logical component. Preserve the raw + # effect so construction fails instead of silently changing it. + components = [f"L{observable}" for observable in observables] + else: + for observable in observables: + components[-1] = f"{components[-1]} L{observable}" + + rendered_targets = " ^ ".join(components) + if rendered_targets: + by_targets[rendered_targets] = _combine_independent_probabilities( + by_targets.get(rendered_targets, 0.0), + probability, + ) + + rendered_lines = [ + f"error({probability:.16g}) {targets}" + for targets, probability in sorted(by_targets.items()) + if probability > 0.0 + ] + return "\n".join([*annotation_lines, *rendered_lines]) + + def dem_stats(dem_text: str) -> DemStats: """Summarize the structure of a DEM string.""" error_lines = 0 @@ -304,6 +411,7 @@ def two_fault_pair_analysis( native_raw: str, native_decomposed: str, stim_decomposed: str, + terminal_decomposed: str, max_effects: int, ) -> list[PairAnalysisSummary] | None: """Exhaustively compare decoders on all two-mechanism XOR combinations.""" @@ -351,6 +459,16 @@ def two_fault_pair_analysis( pair_detection_events, correlated=True, ), + "terminal_decomp_pymatching": pymatching_predictions( + terminal_decomposed, + pair_detection_events, + correlated=False, + ), + "terminal_decomp_pymatching_correlated": pymatching_predictions( + terminal_decomposed, + pair_detection_events, + correlated=True, + ), } reference = predictions["native_raw_tesseract_b5"] @@ -434,6 +552,21 @@ def run_case( circuit_source="traced_qis", interaction_basis=interaction_basis, ) + try: + terminal_decomposed = generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="terminal_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + except RuntimeError as exc: + if "terminal graphlike" not in str(exc): + raise + terminal_decomposed = terminal_graphlike_projection(native_raw) stim_raw = generate_dem_from_tick_circuit_via_stim( tick_circuit, decompose_errors=False, @@ -511,6 +644,26 @@ def run_case( ), shots, ), + _timed_decode( + "terminal_decomp_pymatching", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=False, + ), + shots, + ), + _timed_decode( + "terminal_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=True, + ), + shots, + ), ], ) @@ -527,12 +680,14 @@ def run_case( "native_decomposed": dem_stats(native_decomposed), "stim_raw": dem_stats(stim_raw), "stim_decomposed": dem_stats(stim_decomposed), + "terminal_decomposed": dem_stats(terminal_decomposed), }, decoders=decoders, pair_analysis=two_fault_pair_analysis( native_raw=native_raw, native_decomposed=native_decomposed, stim_decomposed=stim_decomposed, + terminal_decomposed=terminal_decomposed, max_effects=pair_analysis_max_effects, ) if pair_analysis diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index a1f64518f..567c665e0 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -3736,11 +3736,12 @@ def _parse_args() -> argparse.Namespace: ) parser.add_argument( "--dem-mode", - choices=["native_decomposed", "native_full"], + choices=["native_decomposed", "native_full", "native_terminal_graphlike"], default="native_decomposed", help=( "PECOS native DEM mode. Graph decoders such as PyMatching require " - "native_decomposed; raw-DEM decoders should use native_full." + "native_decomposed or native_terminal_graphlike; raw-DEM decoders " + "should use native_full." ), ) parser.add_argument( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index a863414f2..3d7c68d0c 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1646,6 +1646,16 @@ impl PyDetectorErrorModel { self.inner.to_string_source_graphlike_decomposed() } + /// Convert the DEM to a terminal-only graphlike projection. + /// + /// Raw mechanisms are first grouped exactly as in `to_string()`. Each raw + /// effect is then projected to graphlike terminal components using detector + /// coordinates. This is a decoder-facing representation for graph matchers, + /// not source-proof decomposition. + fn to_string_terminal_graphlike_decomposed(&self) -> String { + self.inner.to_string_terminal_graphlike_decomposed() + } + /// Convert the DEM using the explicit historical graphlike-search renderer. /// /// This may decompose residual hyperedges by searching for graphlike diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 8e2bd2765..fd0a184af 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -61,6 +61,8 @@ P1Weights = Mapping[str, float] | Sequence[tuple[str, float]] P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] +NativeDemDecomposition = Literal["source_graphlike", "terminal_graphlike"] +CircuitLevelDemMode = Literal["native_full", "native_decomposed", "native_terminal_graphlike"] def _validate_probability(name: str, value: float) -> float: @@ -1834,6 +1836,7 @@ def _dem_string_from_cached_surface_topology( noise: NoiseModel, *, decompose_errors: bool, + dem_decomposition: NativeDemDecomposition = "source_graphlike", ) -> str: """Build a DEM string from cached topology and fresh noise parameters.""" from pecos.qec import DemBuilder @@ -1858,10 +1861,19 @@ def _dem_string_from_cached_surface_topology( ) if not decompose_errors: return dem.to_string() - source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) - if source_graphlike is not None: - return source_graphlike() - return dem.to_string_decomposed() + if dem_decomposition == "source_graphlike": + source_graphlike = getattr(dem, "to_string_source_graphlike_decomposed", None) + if source_graphlike is not None: + return source_graphlike() + return dem.to_string_decomposed() + if dem_decomposition == "terminal_graphlike": + terminal_graphlike = getattr(dem, "to_string_terminal_graphlike_decomposed", None) + if terminal_graphlike is None: + msg = "This pecos_rslib build does not support terminal graphlike DEM decomposition" + raise RuntimeError(msg) + return terminal_graphlike() + msg = f"Unknown native DEM decomposition mode {dem_decomposition!r}" + raise ValueError(msg) @cache @@ -1879,6 +1891,7 @@ def _cached_surface_native_dem_string( p_meas: float, p_prep: float, decompose_errors: bool, + dem_decomposition: NativeDemDecomposition = "source_graphlike", p2_weights: tuple[tuple[str, float], ...] | None = None, p2_replacement_approximation: str | None = None, p_idle: float | None = None, @@ -1962,6 +1975,7 @@ def _cached_surface_native_dem_string( p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ), decompose_errors=decompose_errors, + dem_decomposition=dem_decomposition, ) @@ -2033,6 +2047,7 @@ def generate_circuit_level_dem_from_builder( basis: str = "Z", *, decompose_errors: bool = False, + dem_decomposition: NativeDemDecomposition = "source_graphlike", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, @@ -2060,6 +2075,11 @@ def generate_circuit_level_dem_from_builder( decomposition preserves correlated mechanism metadata with ``^`` separators, but graph decoders may still approximate hyperedge correlations. + dem_decomposition: Which native graphlike projection to use when + ``decompose_errors=True``. ``"source_graphlike"`` preserves the + existing source-informed decomposition. ``"terminal_graphlike"`` + groups raw mechanisms first, then pairs only detector terminals + present in each raw effect by coordinate distance. ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, the native DEM is built from the same batched ancilla-reuse circuit family used by Guppy. @@ -2113,8 +2133,33 @@ def generate_circuit_level_dem_from_builder( topology, noise, decompose_errors=decompose_errors, + dem_decomposition=dem_decomposition, ) + cache_kwargs = { + "p2_weights": noise.p2_weights, + "p2_replacement_approximation": noise.p2_replacement_approximation, + "p_idle": noise.p_idle, + "t1": noise.t1, + "t2": noise.t2, + "p_idle_linear_rate": noise.p_idle_linear_rate, + "p_idle_quadratic_rate": noise.p_idle_quadratic_rate, + "p_idle_x_linear_rate": noise.p_idle_x_linear_rate, + "p_idle_y_linear_rate": noise.p_idle_y_linear_rate, + "p_idle_z_linear_rate": noise.p_idle_z_linear_rate, + "p_idle_x_quadratic_rate": noise.p_idle_x_quadratic_rate, + "p_idle_y_quadratic_rate": noise.p_idle_y_quadratic_rate, + "p_idle_z_quadratic_rate": noise.p_idle_z_quadratic_rate, + "p_idle_quadratic_sine_rate": noise.p_idle_quadratic_sine_rate, + "p_idle_x_quadratic_sine_rate": noise.p_idle_x_quadratic_sine_rate, + "p_idle_y_quadratic_sine_rate": noise.p_idle_y_quadratic_sine_rate, + "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, + "twirl": twirl, + "interaction_basis": interaction_basis, + } + if dem_decomposition != "source_graphlike": + cache_kwargs["dem_decomposition"] = dem_decomposition + return _cached_surface_native_dem_string( patch_key, num_rounds, @@ -2129,25 +2174,7 @@ def generate_circuit_level_dem_from_builder( noise.p_meas, noise.p_prep, decompose_errors=decompose_errors, - p2_weights=noise.p2_weights, - p2_replacement_approximation=noise.p2_replacement_approximation, - p_idle=noise.p_idle, - t1=noise.t1, - t2=noise.t2, - p_idle_linear_rate=noise.p_idle_linear_rate, - p_idle_quadratic_rate=noise.p_idle_quadratic_rate, - p_idle_x_linear_rate=noise.p_idle_x_linear_rate, - p_idle_y_linear_rate=noise.p_idle_y_linear_rate, - p_idle_z_linear_rate=noise.p_idle_z_linear_rate, - p_idle_x_quadratic_rate=noise.p_idle_x_quadratic_rate, - p_idle_y_quadratic_rate=noise.p_idle_y_quadratic_rate, - p_idle_z_quadratic_rate=noise.p_idle_z_quadratic_rate, - p_idle_quadratic_sine_rate=noise.p_idle_quadratic_sine_rate, - p_idle_x_quadratic_sine_rate=noise.p_idle_x_quadratic_sine_rate, - p_idle_y_quadratic_sine_rate=noise.p_idle_y_quadratic_sine_rate, - p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, - twirl=twirl, - interaction_basis=interaction_basis, + **cache_kwargs, ) @@ -2523,7 +2550,7 @@ def __init__( ] = "pymatching", *, use_circuit_level_dem: bool = True, - circuit_level_dem_mode: Literal["native_full", "native_decomposed"] = "native_full", + circuit_level_dem_mode: CircuitLevelDemMode = "native_full", circuit_level_dem_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, interaction_basis: str = "cx", @@ -2549,9 +2576,11 @@ def __init__( circuit_level_dem_mode: Which PECOS-native DEM representation to use when circuit-level DEMs are enabled. ``"native_full"`` preserves the current non-decomposed DEM output. ``"native_decomposed"`` - returns PECOS's graphlike decomposed DEM output for graph - decoders such as PyMatching. This is a decoder-facing - approximation of hyperedge correlations, not an exact raw DEM. + returns the source-informed graphlike output for graph decoders. + ``"native_terminal_graphlike"`` first groups raw mechanisms, + then projects each mechanism onto graphlike terminal components. + Decomposed modes are decoder-facing approximations of hyperedge + correlations, not exact raw DEMs. circuit_level_dem_source: Which ideal circuit to analyze when building native circuit-level DEMs. ``"abstract"`` uses the high-level surface TickCircuit, while ``"traced_qis"`` traces @@ -2570,6 +2599,13 @@ def __init__( self.noise = noise or NoiseModel(p2=0.01, p_meas=0.01) self.decoder_type = DecoderType(decoder_type) self.use_circuit_level_dem = use_circuit_level_dem + if circuit_level_dem_mode not in { + "native_full", + "native_decomposed", + "native_terminal_graphlike", + }: + msg = f"Unknown circuit_level_dem_mode {circuit_level_dem_mode!r}" + raise ValueError(msg) self.circuit_level_dem_mode = circuit_level_dem_mode self.circuit_level_dem_source = circuit_level_dem_source self.ancilla_budget = ancilla_budget @@ -2602,12 +2638,18 @@ def _get_circuit_level_dem(self, basis: str) -> str: Returns: DEM string in Stim format """ + dem_decomposition: NativeDemDecomposition = ( + "terminal_graphlike" + if self.circuit_level_dem_mode == "native_terminal_graphlike" + else "source_graphlike" + ) dem = generate_circuit_level_dem_from_builder( self.patch, self.num_rounds, self.noise, basis=basis, - decompose_errors=self.circuit_level_dem_mode == "native_decomposed", + decompose_errors=self.circuit_level_dem_mode != "native_full", + dem_decomposition=dem_decomposition, circuit_source=self.circuit_level_dem_source, ancilla_budget=self.ancilla_budget, interaction_basis=self.interaction_basis, From 6cbbadb38e563a2491d1d93eeb1e5267df6bdb54 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 13:00:40 -0600 Subject: [PATCH 140/388] Add graphlike DEM diagnostic filters --- .../surface/dem_decomposition_diagnostics.py | 135 ++++++++++-------- .../tests/qec/surface/test_surface_decoder.py | 31 ++++ 2 files changed, 108 insertions(+), 58 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index f703cb9eb..486d92b41 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -28,6 +28,14 @@ DET_RE = re.compile(r"\bD(\d+)\b") OBS_RE = re.compile(r"\bL(\d+)\b") DETECTOR_COORD_RE = re.compile(r"detector\(([^)]*)\) D(\d+)") +GRAPHLIKE_DECODER_CHOICES = [ + "native_decomp_pymatching", + "native_decomp_pymatching_correlated", + "stim_decomp_pymatching", + "stim_decomp_pymatching_correlated", + "terminal_decomp_pymatching", + "terminal_decomp_pymatching_correlated", +] @dataclass(frozen=True) @@ -503,6 +511,7 @@ def run_case( shots: int, seed: int, tesseract_beams: list[int], + decoder_names: set[str], pair_analysis: bool, pair_analysis_max_effects: int, ) -> CaseResult: @@ -602,69 +611,66 @@ def run_case( ) for beam in tesseract_beams ] - decoders.extend( - [ - _timed_decode( - "native_decomp_pymatching", - lambda: decode_with_pymatching( - native_decomposed, - detection_events, - observable_flips, - correlated=False, - ), - shots, + graphlike_decoder_specs = [ + ( + "native_decomp_pymatching", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=False, ), - _timed_decode( - "native_decomp_pymatching_correlated", - lambda: decode_with_pymatching( - native_decomposed, - detection_events, - observable_flips, - correlated=True, - ), - shots, + ), + ( + "native_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + native_decomposed, + detection_events, + observable_flips, + correlated=True, ), - _timed_decode( - "stim_decomp_pymatching", - lambda: decode_with_pymatching( - stim_decomposed, - detection_events, - observable_flips, - correlated=False, - ), - shots, + ), + ( + "stim_decomp_pymatching", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=False, ), - _timed_decode( - "stim_decomp_pymatching_correlated", - lambda: decode_with_pymatching( - stim_decomposed, - detection_events, - observable_flips, - correlated=True, - ), - shots, + ), + ( + "stim_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + stim_decomposed, + detection_events, + observable_flips, + correlated=True, ), - _timed_decode( - "terminal_decomp_pymatching", - lambda: decode_with_pymatching( - terminal_decomposed, - detection_events, - observable_flips, - correlated=False, - ), - shots, + ), + ( + "terminal_decomp_pymatching", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=False, ), - _timed_decode( - "terminal_decomp_pymatching_correlated", - lambda: decode_with_pymatching( - terminal_decomposed, - detection_events, - observable_flips, - correlated=True, - ), - shots, + ), + ( + "terminal_decomp_pymatching_correlated", + lambda: decode_with_pymatching( + terminal_decomposed, + detection_events, + observable_flips, + correlated=True, ), - ], + ), + ] + decoders.extend( + _timed_decode(name, callback, shots) + for name, callback in graphlike_decoder_specs + if name in decoder_names ) return CaseResult( @@ -751,6 +757,18 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--shots", type=int, default=10000) parser.add_argument("--seed", type=int, default=20260613) parser.add_argument("--tesseract-beams", nargs="+", type=int, default=[5]) + parser.add_argument( + "--skip-tesseract", + action="store_true", + help="Skip raw-DEM Tesseract decoding for larger graphlike-only sampled comparisons.", + ) + parser.add_argument( + "--decoders", + nargs="+", + choices=GRAPHLIKE_DECODER_CHOICES, + default=GRAPHLIKE_DECODER_CHOICES, + help="Graphlike decoder variants to run in sampled comparisons.", + ) parser.add_argument( "--pair-analysis", action="store_true", @@ -777,7 +795,8 @@ def main() -> int: p=p, shots=args.shots, seed=args.seed, - tesseract_beams=args.tesseract_beams, + tesseract_beams=[] if args.skip_tesseract else args.tesseract_beams, + decoder_names=set(args.decoders), pair_analysis=args.pair_analysis, pair_analysis_max_effects=args.pair_analysis_max_effects, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index e456d689c..f0956ddc3 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -262,6 +262,37 @@ def wrapped_generate(*_args: object, **kwargs: object) -> str: assert decoder.get_dem("Z", circuit_level=True) == "error(0.01) D0\n" assert seen["interaction_basis"] == "szz" + def test_get_dem_passes_terminal_graphlike_mode_to_native_builder( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The terminal graphlike mode should select the terminal DEM projection.""" + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **kwargs: object) -> str: + seen["decompose_errors"] = kwargs.get("decompose_errors") + seen["dem_decomposition"] = kwargs.get("dem_decomposition") + return "error(0.01) D0\n" + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + circuit_level_dem_mode="native_terminal_graphlike", + ) + + assert decoder.get_dem("Z", circuit_level=True) == "error(0.01) D0\n" + assert seen == { + "decompose_errors": True, + "dem_decomposition": "terminal_graphlike", + } + def test_decode_trivial_syndrome_z(self) -> None: """Decode trivial Z syndrome (no errors).""" patch = SurfacePatch.create(distance=3) From fc8db9223be46042db2396be6f7b5e01b011ee0e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 13:15:27 -0600 Subject: [PATCH 141/388] Add graphlike DEM projection benchmark --- .../graphlike_dem_projection_benchmark.py | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 examples/surface/graphlike_dem_projection_benchmark.py diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py new file mode 100644 index 000000000..fc1c4f71c --- /dev/null +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -0,0 +1,325 @@ +"""Benchmark graphlike DEM projections on fixed traced-QIS surface-code samples. + +This is a narrower companion to ``dem_decomposition_diagnostics.py``. It builds +each DEM view once, samples once from the exact native influence model, then +times correlated PyMatching construction and batch decoding for the selected +graphlike projections. +""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +import numpy as np +from dem_decomposition_diagnostics import ( + compare_raw_dems, + dem_stats, + terminal_graphlike_projection, + true_observable_flips, +) + + +@dataclass(frozen=True) +class TimedValue: + label: str + elapsed_s: float + + +@dataclass(frozen=True) +class VariantResult: + variant: str + dem_stats: dict[str, Any] + dem_build_s: float + decoder_build_s: float + decode_s: float + logical_errors: int + logical_error_rate: float + + +@dataclass(frozen=True) +class BenchmarkResult: + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + shots: int + setup_timings: list[TimedValue] + raw_comparison: dict[str, Any] + variants: list[VariantResult] + + +def timed(label: str, callback: Any) -> tuple[Any, float]: + print(f"[start] {label}", flush=True) + start = time.perf_counter() + value = callback() + elapsed = time.perf_counter() - start + print(f"[done] {label}: {elapsed:.3f}s", flush=True) + return value, elapsed + + +def decode_with_correlated_pymatching( + dem_text: str, + detection_events: np.ndarray, + observable_flips: np.ndarray, +) -> tuple[int, float, float]: + from pecos.decoders import PyMatchingDecoder + + decoder, decoder_build_s = timed( + "build correlated PyMatching", + lambda: PyMatchingDecoder.from_dem_with_correlations(dem_text, enable_correlations=True), + ) + + expected = true_observable_flips(observable_flips) + + def decode() -> list[list[int]]: + flat = detection_events.astype(np.uint8).flatten().tolist() + return decoder.decode_batch(flat, len(detection_events)) + + predictions, decode_s = timed("decode batch", decode) + predicted = np.array([prediction[0] if prediction else 0 for prediction in predictions], dtype=np.uint8) + logical_errors = int(np.sum(predicted != expected)) + return logical_errors, decoder_build_s, decode_s + + +def build_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + variants: list[str], +) -> BenchmarkResult: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, + ) + from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, + ) + + print( + f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} " + f"p={p:g} shots={shots} ===", + flush=True, + ) + setup_timings: list[TimedValue] = [] + patch = SurfacePatch.create(distance=distance) + noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) + noise_args = { + "p1": noise.p1, + "p2": noise.p2, + "p_meas": noise.p_meas, + "p_prep": noise.p_prep, + } + + tick_circuit, elapsed = timed( + "build traced-QIS tick circuit", + lambda: _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + setup_timings.append(TimedValue("build_traced_qis_tick_circuit", elapsed)) + normalize_traced_qis_tick_circuit(tick_circuit, context="graphlike DEM projection benchmark") + + native_raw, elapsed = timed( + "build native raw DEM", + lambda: generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + setup_timings.append(TimedValue("build_native_raw_dem", elapsed)) + + stim_raw, elapsed = timed( + "build Stim raw DEM", + lambda: generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + setup_timings.append(TimedValue("build_stim_raw_dem", elapsed)) + raw_comparison = asdict(compare_raw_dems(native_raw, stim_raw)) + print( + "raw native vs Stim: " + f"only_native={raw_comparison['only_native']} only_stim={raw_comparison['only_stim']} " + f"max_rel={raw_comparison['max_rel_probability_diff']:.3e}", + flush=True, + ) + + sampler, elapsed = timed( + "build native influence sampler", + lambda: build_native_sampler( + patch, + rounds, + noise, + basis=basis, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + sampling_model="influence_dem", + ), + ) + setup_timings.append(TimedValue("build_native_influence_sampler", elapsed)) + + (detection_events, observable_flips), elapsed = timed( + "sample native influence events", + lambda: sampler.sample(num_shots=shots, seed=seed), + ) + setup_timings.append(TimedValue("sample_native_influence_events", elapsed)) + + def build_variant_dem(variant: str) -> str: + if variant == "native_source": + return generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="source_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + if variant == "native_terminal": + try: + return generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=True, + dem_decomposition="terminal_graphlike", + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ) + except RuntimeError as exc: + if "terminal graphlike" not in str(exc): + raise + print("[info] using Python terminal graphlike projection fallback", flush=True) + return terminal_graphlike_projection(native_raw) + if variant == "stim": + return generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=True, + **noise_args, + ) + msg = f"unknown variant {variant!r}" + raise ValueError(msg) + + results: list[VariantResult] = [] + for variant in variants: + dem_text, dem_build_s = timed(f"build {variant} DEM", lambda variant=variant: build_variant_dem(variant)) + stats = asdict(dem_stats(dem_text)) + print( + f"{variant} DEM: errors={stats['error_lines']} sep={stats['separator_lines']} " + f"hyper={stats['hyperedge_lines']} max_line={stats['max_line_detectors']}", + flush=True, + ) + logical_errors, decoder_build_s, decode_s = decode_with_correlated_pymatching( + dem_text, + detection_events, + observable_flips, + ) + result = VariantResult( + variant=variant, + dem_stats=stats, + dem_build_s=dem_build_s, + decoder_build_s=decoder_build_s, + decode_s=decode_s, + logical_errors=logical_errors, + logical_error_rate=logical_errors / shots if shots else 0.0, + ) + print( + f"{variant}: errors={result.logical_errors} " + f"LER={result.logical_error_rate:.6f} " + f"build={result.dem_build_s:.3f}s " + f"matcher={result.decoder_build_s:.3f}s " + f"decode={result.decode_s:.3f}s", + flush=True, + ) + results.append(result) + + return BenchmarkResult( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=shots, + setup_timings=setup_timings, + raw_comparison=raw_comparison, + variants=results, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[7]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--p", type=float, default=0.006) + parser.add_argument("--shots", type=int, default=3000) + parser.add_argument("--seed", type=int, default=20260613) + parser.add_argument( + "--variants", + nargs="+", + choices=["native_source", "native_terminal", "stim"], + default=["native_terminal", "stim", "native_source"], + ) + parser.add_argument("--save-json", type=Path, default=None) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + results = [] + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + results.extend( + build_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=args.p, + shots=args.shots, + seed=args.seed, + variants=args.variants, + ) + for basis in args.bases + for interaction_basis in args.interaction_bases + ) + + if args.save_json is not None: + args.save_json.parent.mkdir(parents=True, exist_ok=True) + args.save_json.write_text( + json.dumps([asdict(result) for result in results], indent=2, sort_keys=True), + encoding="utf-8", + ) + print(f"\nWrote {args.save_json}", flush=True) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From c3d2f765ddff132ce11db9ff153de96cc98b3b15 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 13 Jun 2026 14:04:09 -0600 Subject: [PATCH 142/388] Add deterministic measurement crosstalk DEM replay mode --- .../fault_tolerance/dem_builder/builder.rs | 172 ++++++++++++++++-- .../src/fault_tolerance/dem_builder/types.rs | 4 + .../src/fault_tolerance_bindings.rs | 8 +- 3 files changed, 170 insertions(+), 14 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index ab0b8fa5a..48ced572a 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -903,7 +903,10 @@ impl<'a> DemBuilder<'a> { return Ok(()); } - if self.noise.p_meas_crosstalk_model.has_leakage() { + if self.noise.p_meas_crosstalk_model.has_leakage() + && self.noise.measurement_crosstalk_dem_mode + != MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + { return Err(DemBuilderError::ConfigurationError( "exact deterministic measurement crosstalk DEM replay does not yet support leakage transitions" .to_string(), @@ -1305,8 +1308,11 @@ impl<'a> DemBuilder<'a> { } GateType::MeasCrosstalkLocalPayload if !loc.before - && self.noise.measurement_crosstalk_dem_mode - == MeasurementCrosstalkDemMode::ExactDeterministic + && matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + ) && self.noise.p_meas_crosstalk_local > 0.0 => { self.process_measurement_crosstalk_local_source_tracked( @@ -1567,26 +1573,101 @@ impl<'a> DemBuilder<'a> { let hidden = self .hidden_mz_result_before_crosstalk_payload(context, loc) .expect("measurement crosstalk exact deterministic hidden result was validated"); - let transition_probability = if hidden.flip { - self.noise.p_meas_crosstalk_model.p_1_to_0 + let (bit_flip_probability, leak_probability) = if hidden.flip { + ( + self.noise.p_meas_crosstalk_model.p_1_to_0, + self.noise.p_meas_crosstalk_model.p_1_to_leak, + ) } else { - self.noise.p_meas_crosstalk_model.p_0_to_1 + ( + self.noise.p_meas_crosstalk_model.p_0_to_1, + self.noise.p_meas_crosstalk_model.p_0_to_leak, + ) }; - let p = self.noise.p_meas_crosstalk_local * transition_probability; - if p <= 0.0 { - return; + if self.noise.measurement_crosstalk_dem_mode + == MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + { + let leak_pauli_probability = leak_probability / 4.0; + self.process_measurement_crosstalk_local_pauli_rates_source_tracked( + loc_idx, + [ + self.noise.p_meas_crosstalk_local + * (bit_flip_probability + leak_pauli_probability), + self.noise.p_meas_crosstalk_local * leak_pauli_probability, + self.noise.p_meas_crosstalk_local * leak_pauli_probability, + ], + dem, + meas_to_detectors, + meas_to_observables, + ); + } else { + self.process_measurement_crosstalk_local_pauli_rates_source_tracked( + loc_idx, + [ + self.noise.p_meas_crosstalk_local * bit_flip_probability, + 0.0, + 0.0, + ], + dem, + meas_to_detectors, + meas_to_observables, + ); } + } - let mechanism = + /// Processes local measurement-crosstalk payloads as single-location Pauli + /// source channels while preserving crosstalk source metadata. + fn process_measurement_crosstalk_local_pauli_rates_source_tracked( + &self, + loc_idx: usize, + rates: [f64; 3], + dem: &mut DetectorErrorModel, + meas_to_detectors: &BTreeMap>, + meas_to_observables: &BTreeMap>, + ) { + let loc = &self.influence_map.locations[loc_idx]; + let [rate_x, rate_y, rate_z] = rates; + let x_effect = self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); - if !mechanism.is_empty() { + let z_effect = + self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); + + if rate_x > 0.0 && !x_effect.is_empty() { dem.add_direct_contribution_with_source( - mechanism, - p, + x_effect.clone(), + rate_x, SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]) .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), ); } + if rate_z > 0.0 && !z_effect.is_empty() { + dem.add_direct_contribution_with_source( + z_effect.clone(), + rate_z, + SourceMetadata::new(&[loc_idx], &[Pauli::Z], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } + + let y_effect = x_effect.xor(&z_effect); + if rate_y > 0.0 && !y_effect.is_empty() { + if !x_effect.is_empty() && !z_effect.is_empty() { + dem.add_y_decomposed_contribution_with_source( + &x_effect, + &z_effect, + rate_y, + SourceMetadata::new(&[loc_idx], &[Pauli::Y], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } else { + dem.add_direct_contribution_with_source( + y_effect, + rate_y, + SourceMetadata::new(&[loc_idx], &[Pauli::Y], &[loc.gate_type], &[loc.before]) + .with_direct_source_family(DirectSourceFamily::MeasurementCrosstalk), + ); + } + } } /// Processes a single-qubit gate fault with source tracking. @@ -4180,6 +4261,71 @@ mod tests { ); } + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_rejects_leakage() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let err = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect_err("plain exact deterministic crosstalk should reject leakage"); + + assert!(matches!(err, DemBuilderError::ConfigurationError(_))); + assert!( + err.to_string().contains("leakage transitions"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_exact_deterministic_local_measurement_crosstalk_leakage_as_depolarizing() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = single_qubit_local_crosstalk_circuit(false); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_local_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode( + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing, + ); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic leak2depolar local crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!(contributions.len(), 2); + let total_probability: f64 = contributions + .iter() + .map(|contribution| contribution.probability) + .sum(); + assert!((total_probability - 0.125).abs() < 1.0e-12); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::X] + && (contribution.probability - 0.1125).abs() < 1.0e-12 + })); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::Y] + && (contribution.probability - 0.0125).abs() < 1.0e-12 + })); + assert!(contributions.iter().all(|contribution| { + contribution.source_gate_types.as_slice() == [GateType::MeasCrosstalkLocalPayload] + })); + } + #[test] fn test_from_circuit_exact_branch_replay_skips_empty_replacement_effect() { use crate::fault_tolerance::dem_builder::PauliWeights; diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index 51025ff67..fe5fbd655 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2541,6 +2541,10 @@ pub enum MeasurementCrosstalkDemMode { /// Replay payloads exactly when the hidden measurement outcome is /// deterministic and state-independent. ExactDeterministic, + /// Replay deterministic local payloads while treating hidden leakage + /// transitions as the `leak2depolar` replacement channel: one quarter each + /// of I, X, Y, and Z on the victim qubit. + ExactDeterministicLeakageAsDepolarizing, } /// Hidden-measurement transition probabilities for local measurement crosstalk. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 80b0831e8..0bb5f7d20 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -219,8 +219,14 @@ fn parse_measurement_crosstalk_dem_mode( "exact_deterministic" | "exact" | "deterministic" => { Ok(MeasurementCrosstalkDemMode::ExactDeterministic) } + "exact_deterministic_leakage_as_depolarizing" + | "exact_leakage_as_depolarizing" + | "deterministic_leakage_as_depolarizing" + | "leakage_as_depolarizing" => { + Ok(MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing) + } _ => Err(pyo3::exceptions::PyValueError::new_err( - "measurement_crosstalk_dem_mode must be 'omitted' or 'exact_deterministic'", + "measurement_crosstalk_dem_mode must be 'omitted', 'exact_deterministic', or 'exact_deterministic_leakage_as_depolarizing'", )), } } From cd7edf4ccb46f9d237aa9a0b7b516e9fc518f5bf Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 14:07:29 -0600 Subject: [PATCH 143/388] Add surface interaction quality report --- .../surface/szz_circuit_quality_report.py | 491 ++++++++++++++++++ 1 file changed, 491 insertions(+) create mode 100644 examples/surface/szz_circuit_quality_report.py diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py new file mode 100644 index 000000000..2df111646 --- /dev/null +++ b/examples/surface/szz_circuit_quality_report.py @@ -0,0 +1,491 @@ +"""Report CX vs SZZ/SZZdg surface-memory circuit quality metrics. + +This script is intentionally descriptive rather than a threshold sweep. It +counts the gate locations that drive the simple circuit-level noise model and, +optionally, compares raw PECOS and Stim DEMs for the traced-QIS circuit path. + +Example: + uv run python examples/surface/szz_circuit_quality_report.py \\ + --distances 3 5 --bases X Z --interaction-bases cx szz +""" + +from __future__ import annotations + +import argparse +import json +import time +from collections import Counter +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any + +from dem_decomposition_diagnostics import compare_raw_dems, dem_stats +from pecos.qec.surface import NoiseModel, OpType, SurfacePatch, build_surface_code_circuit +from pecos.qec.surface.circuit_builder import ( + _analyze_szz_forward_flow, + generate_dem_from_tick_circuit_via_stim, + normalize_traced_qis_tick_circuit, +) +from pecos.qec.surface.decode import ( + _build_surface_tick_circuit_for_native_model, + generate_circuit_level_dem_from_builder, +) +from pecos.quantum import PHYSICAL_DURATION_META_KEY + +PREP_GATES = {"PZ", "PX", "PY", "QAlloc"} +MEASUREMENT_GATES = {"MZ", "MX", "MY", "MeasureFree"} +IDLE_GATES = {"Idle", "I"} +TWO_QUBIT_GATES = { + "CX", + "CY", + "CZ", + "CH", + "CRZ", + "SXX", + "SXXdg", + "SYY", + "SYYdg", + "SZZ", + "SZZdg", + "RXX", + "RYY", + "RZZ", + "SWAP", + "ISWAP", +} +THREE_QUBIT_GATES = {"CCX", "CCZ"} +SZZ_ABSTRACT_PREFIX_P1_FREE_GATES = {"Z", "SZ", "SZdg"} + + +@dataclass(frozen=True) +class TickCircuitStats: + source: str + build_s: float + total_ticks: int + nonempty_ticks: int + gate_batches: int + gate_locations: int + prep_locations: int + measurement_locations: int + single_qubit_locations: int + p1_model_locations: int + p1_exempt_locations: int + two_qubit_locations: int + idle_locations: int + zero_duration_locations: int + max_tick_width: int + gate_counts: dict[str, int] + first_order_fault_mass: dict[str, float] + + +@dataclass(frozen=True) +class AbstractCircuitStats: + step_counts: dict[str, int] + szz_forward_flow: dict[str, Any] | None + + +@dataclass(frozen=True) +class DemReport: + native_stats: dict[str, Any] + stim_stats: dict[str, Any] + native_vs_stim: dict[str, Any] + native_build_s: float + stim_build_s: float + + +@dataclass(frozen=True) +class CaseReport: + distance: int + rounds: int + basis: str + interaction_basis: str + p: float + p1_ratio: float + abstract: AbstractCircuitStats + tick_circuits: list[TickCircuitStats] + dem: DemReport | None + + +def _gate_type_name(gate: Any) -> str: + gate_type = getattr(gate, "gate_type", "") + return str(getattr(gate_type, "name", gate_type)).rsplit(".", maxsplit=1)[-1] + + +def _gate_location_count(gate_name: str, qubits: list[int]) -> int: + if gate_name in TWO_QUBIT_GATES: + return len(qubits) // 2 + if gate_name in THREE_QUBIT_GATES: + return len(qubits) // 3 + return len(qubits) + + +def _is_zero_duration(tick: Any, gate_index: int) -> bool: + value = tick.get_gate_attr(gate_index, PHYSICAL_DURATION_META_KEY) + return value == 0 + + +def _noise_fault_mass( + *, + p: float, + p1_ratio: float, + p1_locations: int, + p2_locations: int, + prep_locations: int, + measurement_locations: int, + idle_locations: int, + p_idle: float, +) -> dict[str, float]: + p1 = p / p1_ratio + p_prep = p / 3.0 + p_meas = p / 3.0 + return { + "p1": p1_locations * p1, + "p2": p2_locations * p, + "prep": prep_locations * p_prep, + "measurement": measurement_locations * p_meas, + "idle": idle_locations * p_idle, + "total": p1_locations * p1 + + p2_locations * p + + prep_locations * p_prep + + measurement_locations * p_meas + + idle_locations * p_idle, + } + + +def _tick_circuit_stats( + *, + source: str, + tick_circuit: Any, + build_s: float, + interaction_basis: str, + p: float, + p1_ratio: float, + p_idle: float, +) -> TickCircuitStats: + gate_counts: Counter[str] = Counter() + total_locations = 0 + gate_batches = 0 + prep_locations = 0 + measurement_locations = 0 + single_qubit_locations = 0 + p1_exempt_locations = 0 + two_qubit_locations = 0 + idle_locations = 0 + zero_duration_locations = 0 + nonempty_ticks = 0 + max_tick_width = 0 + p1_exempt_names = ( + SZZ_ABSTRACT_PREFIX_P1_FREE_GATES + if source == "abstract_physical_prefix" and interaction_basis == "szz" + else set() + ) + + for tick_index in range(int(tick_circuit.num_ticks())): + tick = tick_circuit.get_tick(tick_index) + if tick is None or tick.is_empty(): + continue + nonempty_ticks += 1 + tick_width = 0 + for gate_index, gate in enumerate(tick.gate_batches()): + gate_name = _gate_type_name(gate) + qubits = [int(qubit) for qubit in getattr(gate, "qubits", [])] + locations = _gate_location_count(gate_name, qubits) + gate_batches += 1 + total_locations += locations + tick_width += locations + gate_counts[gate_name] += locations + if _is_zero_duration(tick, gate_index): + zero_duration_locations += locations + if gate_name in PREP_GATES: + prep_locations += locations + elif gate_name in MEASUREMENT_GATES: + measurement_locations += locations + elif gate_name in IDLE_GATES: + idle_locations += locations + elif gate_name in TWO_QUBIT_GATES or gate_name in THREE_QUBIT_GATES: + two_qubit_locations += locations + else: + single_qubit_locations += locations + if gate_name in p1_exempt_names or _is_zero_duration(tick, gate_index): + p1_exempt_locations += locations + max_tick_width = max(max_tick_width, tick_width) + + p1_model_locations = single_qubit_locations - p1_exempt_locations + return TickCircuitStats( + source=source, + build_s=build_s, + total_ticks=int(tick_circuit.num_ticks()), + nonempty_ticks=nonempty_ticks, + gate_batches=gate_batches, + gate_locations=total_locations, + prep_locations=prep_locations, + measurement_locations=measurement_locations, + single_qubit_locations=single_qubit_locations, + p1_model_locations=p1_model_locations, + p1_exempt_locations=p1_exempt_locations, + two_qubit_locations=two_qubit_locations, + idle_locations=idle_locations, + zero_duration_locations=zero_duration_locations, + max_tick_width=max_tick_width, + gate_counts=dict(sorted(gate_counts.items())), + first_order_fault_mass=_noise_fault_mass( + p=p, + p1_ratio=p1_ratio, + p1_locations=p1_model_locations, + p2_locations=two_qubit_locations, + prep_locations=prep_locations, + measurement_locations=measurement_locations, + idle_locations=idle_locations, + p_idle=p_idle, + ), + ) + + +def _timed(callback: Any) -> tuple[Any, float]: + start = time.perf_counter() + value = callback() + return value, time.perf_counter() - start + + +def _abstract_stats(patch: SurfacePatch, rounds: int, basis: str, interaction_basis: str) -> AbstractCircuitStats: + ops, _allocation = build_surface_code_circuit( + patch, + rounds, + basis=basis, + interaction_basis=interaction_basis, + ) + step_counts = Counter(op.op_type.name for op in ops if op.op_type not in {OpType.COMMENT, OpType.TICK}) + flow = None + if interaction_basis == "szz": + summary = _analyze_szz_forward_flow(ops) + flow = asdict(summary) + flow.pop("pulses", None) + return AbstractCircuitStats(step_counts=dict(sorted(step_counts.items())), szz_forward_flow=flow) + + +def _build_tick_view( + *, + patch: SurfacePatch, + rounds: int, + basis: str, + interaction_basis: str, + source: str, +) -> tuple[Any, float]: + circuit_source = "abstract" + szz_physical_prefixes = False + if source == "traced_qis": + circuit_source = "traced_qis" + elif source == "abstract_physical_prefix": + if interaction_basis != "szz": + msg = "abstract_physical_prefix is only meaningful for interaction_basis='szz'" + raise ValueError(msg) + szz_physical_prefixes = True + elif source != "abstract": + msg = f"unknown tick circuit source {source!r}" + raise ValueError(msg) + + tick_circuit, elapsed = _timed( + lambda: _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source=circuit_source, + interaction_basis=interaction_basis, + szz_physical_prefixes=szz_physical_prefixes, + ), + ) + if source == "traced_qis": + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ circuit quality report") + return tick_circuit, elapsed + + +def _dem_report( + *, + patch: SurfacePatch, + rounds: int, + basis: str, + interaction_basis: str, + tick_circuit: Any, + p: float, + p1_ratio: float, +) -> DemReport: + noise = NoiseModel(p1=p / p1_ratio, p2=p, p_prep=p / 3.0, p_meas=p / 3.0) + noise_args = { + "p1": noise.p1, + "p2": noise.p2, + "p_prep": noise.p_prep, + "p_meas": noise.p_meas, + } + native_dem, native_build_s = _timed( + lambda: generate_circuit_level_dem_from_builder( + patch, + rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis=interaction_basis, + ), + ) + stim_dem, stim_build_s = _timed( + lambda: generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + **noise_args, + ), + ) + return DemReport( + native_stats=asdict(dem_stats(native_dem)), + stim_stats=asdict(dem_stats(stim_dem)), + native_vs_stim=asdict(compare_raw_dems(native_dem, stim_dem)), + native_build_s=native_build_s, + stim_build_s=stim_build_s, + ) + + +def build_case( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + p1_ratio: float, + p_idle: float, + sources: list[str], + include_dem: bool, +) -> CaseReport: + patch = SurfacePatch.create(distance=distance) + abstract = _abstract_stats(patch, rounds, basis, interaction_basis) + tick_stats: list[TickCircuitStats] = [] + traced_tick_circuit = None + + print(f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} ===", flush=True) + for source in sources: + tick_circuit, build_s = _build_tick_view( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + source=source, + ) + if source == "traced_qis": + traced_tick_circuit = tick_circuit + stats = _tick_circuit_stats( + source=source, + tick_circuit=tick_circuit, + build_s=build_s, + interaction_basis=interaction_basis, + p=p, + p1_ratio=p1_ratio, + p_idle=p_idle, + ) + tick_stats.append(stats) + mass = stats.first_order_fault_mass + print( + f"{source:24} ticks={stats.nonempty_ticks:5d}/{stats.total_ticks:<5d} " + f"p1_locs={stats.p1_model_locations:5d} " + f"p2_locs={stats.two_qubit_locations:5d} " + f"prep={stats.prep_locations:5d} meas={stats.measurement_locations:5d} " + f"mass={mass['total']:.6g} " + f"build={stats.build_s:.3f}s", + flush=True, + ) + + dem = None + if include_dem: + if traced_tick_circuit is None: + traced_tick_circuit, _build_s = _build_tick_view( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + source="traced_qis", + ) + print("building traced-QIS raw DEM comparison...", flush=True) + dem = _dem_report( + patch=patch, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + tick_circuit=traced_tick_circuit, + p=p, + p1_ratio=p1_ratio, + ) + comparison = dem.native_vs_stim + print( + "raw native vs Stim: " + f"only_native={comparison['only_native']} only_stim={comparison['only_stim']} " + f"max_rel={comparison['max_rel_probability_diff']:.3e}", + flush=True, + ) + + return CaseReport( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + p1_ratio=p1_ratio, + abstract=abstract, + tick_circuits=tick_stats, + dem=dem, + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) + parser.add_argument("--rounds", type=int, default=None, help="Rounds to use. Defaults to distance.") + parser.add_argument("--bases", nargs="+", choices=["X", "Z"], default=["X", "Z"]) + parser.add_argument("--interaction-bases", nargs="+", choices=["cx", "szz"], default=["cx", "szz"]) + parser.add_argument("--sources", nargs="+", choices=["abstract", "abstract_physical_prefix", "traced_qis"]) + parser.add_argument("--p", type=float, default=0.006) + parser.add_argument("--p1-ratio", type=float, default=30.0, help="Use p1=p/p1_ratio.") + parser.add_argument("--p-idle", type=float, default=0.0, help="Optional idle rate for the rough mass estimate.") + parser.add_argument("--include-dem", action="store_true", help="Also compare traced-QIS raw native and Stim DEMs.") + parser.add_argument("--save-json", type=Path, default=None) + return parser.parse_args() + + +def _default_sources(interaction_basis: str) -> list[str]: + if interaction_basis == "szz": + return ["abstract", "abstract_physical_prefix", "traced_qis"] + return ["abstract", "traced_qis"] + + +def main() -> int: + args = parse_args() + results: list[CaseReport] = [] + for distance in args.distances: + rounds = args.rounds if args.rounds is not None else distance + for basis in args.bases: + for interaction_basis in args.interaction_bases: + sources = args.sources if args.sources is not None else _default_sources(interaction_basis) + results.append( + build_case( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=args.p, + p1_ratio=args.p1_ratio, + p_idle=args.p_idle, + sources=sources, + include_dem=args.include_dem, + ), + ) + + if args.save_json is not None: + args.save_json.parent.mkdir(parents=True, exist_ok=True) + args.save_json.write_text( + json.dumps([asdict(result) for result in results], indent=2, sort_keys=True), + encoding="utf-8", + ) + print(f"\nWrote {args.save_json}", flush=True) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From dedd7abd6b9684c5b62aafc471dfbe51b3f23ae4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 14:35:55 -0600 Subject: [PATCH 144/388] Align SZZ frame gate noise accounting --- .../surface/dem_decomposition_diagnostics.py | 2 + .../graphlike_dem_projection_benchmark.py | 3 + .../surface/szz_circuit_quality_report.py | 5 +- python/pecos-rslib/pecos_rslib.pyi | 5 ++ .../pecos-rslib/src/dag_circuit_bindings.rs | 46 +++++++++++++ .../src/pecos/qec/surface/circuit_builder.py | 21 +++++- .../src/pecos/qec/surface/decode.py | 10 +-- .../qec/surface/test_szz_interaction_basis.py | 66 +++++++++++++++++++ .../qec/test_traced_qis_clifford_pipeline.py | 48 +++++++++++++- 9 files changed, 197 insertions(+), 9 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 486d92b41..8c529ba13 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -36,6 +36,7 @@ "terminal_decomp_pymatching", "terminal_decomp_pymatching_correlated", ] +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @dataclass(frozen=True) @@ -529,6 +530,7 @@ def run_case( noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) noise_args = { "p1": noise.p1, + "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep, diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py index fc1c4f71c..6be23f129 100644 --- a/examples/surface/graphlike_dem_projection_benchmark.py +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -23,6 +23,8 @@ true_observable_flips, ) +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} + @dataclass(frozen=True) class TimedValue: @@ -118,6 +120,7 @@ def build_case( noise = NoiseModel(p1=p / 30.0, p2=p, p_meas=p / 3.0, p_prep=p / 3.0) noise_args = { "p1": noise.p1, + "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep, diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py index 2df111646..fb55f78f2 100644 --- a/examples/surface/szz_circuit_quality_report.py +++ b/examples/surface/szz_circuit_quality_report.py @@ -55,6 +55,8 @@ } THREE_QUBIT_GATES = {"CCX", "CCZ"} SZZ_ABSTRACT_PREFIX_P1_FREE_GATES = {"Z", "SZ", "SZdg"} +SZZ_Z_FRAME_P1_FREE_SOURCES = {"abstract_physical_prefix", "traced_qis"} +SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @dataclass(frozen=True) @@ -176,7 +178,7 @@ def _tick_circuit_stats( max_tick_width = 0 p1_exempt_names = ( SZZ_ABSTRACT_PREFIX_P1_FREE_GATES - if source == "abstract_physical_prefix" and interaction_basis == "szz" + if source in SZZ_Z_FRAME_P1_FREE_SOURCES and interaction_basis == "szz" else set() ) @@ -312,6 +314,7 @@ def _dem_report( noise = NoiseModel(p1=p / p1_ratio, p2=p, p_prep=p / 3.0, p_meas=p / 3.0) noise_args = { "p1": noise.p1, + "p1_gate_rates": SZZ_Z_FRAME_P1_GATE_RATES if interaction_basis == "szz" else None, "p2": noise.p2, "p_prep": noise.p_prep, "p_meas": noise.p_meas, diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 65545d5df..48a19cb51 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1663,6 +1663,11 @@ class TickCircuit: def set_gate_meta(self, tick_idx: int, gate_idx: int, key: str, value: Any) -> None: ... def get_gate_meta(self, tick_idx: int, gate_idx: int, key: str) -> Any | None: ... def lower_clifford_rotations(self) -> None: ... + def remove_identity(self) -> None: ... + def cancel_inverses(self) -> None: ... + def merge_adjacent_rotations(self) -> None: ... + def peephole_optimize(self) -> None: ... + def absorb_basis_gates(self) -> None: ... def assign_missing_meas_ids(self) -> None: ... def insert_idle_after_two_qubit_gates(self, duration: float = 1.0) -> None: ... def fill_idle_gates(self) -> None: ... diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index ecc1deba7..153a68991 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -2672,6 +2672,52 @@ impl PyTickCircuit { SimplifyRotations.apply_tick(&mut self.inner); } + /// Remove identity gates and zero-angle rotations. + /// + /// Modifies the circuit in place. + fn remove_identity(&mut self) { + use pecos_quantum::pass::{CircuitPass, RemoveIdentity}; + RemoveIdentity.apply_tick(&mut self.inner); + } + + /// Cancel adjacent inverse gate pairs. + /// + /// This removes adjacent inverse pairs such as H-H, SX-SXdg, and SZZ-SZZdg + /// when they act on the same qubits with no intervening operation on those + /// qubits. Modifies the circuit in place. + fn cancel_inverses(&mut self) { + use pecos_quantum::pass::{CancelInverses, CircuitPass}; + CancelInverses.apply_tick(&mut self.inner); + } + + /// Merge adjacent same-axis rotation gates. + /// + /// Consecutive rotations such as RZ(a) followed by RZ(b) on the same qubit + /// become one RZ(a+b). Run lower_clifford_rotations() afterwards when + /// special-angle rotations should become named Clifford gates. + /// Modifies the circuit in place. + fn merge_adjacent_rotations(&mut self) { + use pecos_quantum::pass::{CircuitPass, MergeAdjacentRotations}; + MergeAdjacentRotations.apply_tick(&mut self.inner); + } + + /// Run PECOS's local peephole optimizer. + /// + /// Currently recognizes small Clifford patterns such as H-conjugated + /// two-qubit gates. Modifies the circuit in place. + fn peephole_optimize(&mut self) { + use pecos_quantum::pass::{CircuitPass, PeepholeOptimize}; + PeepholeOptimize.apply_tick(&mut self.inner); + } + + /// Absorb redundant Z-diagonal gates next to Z preparations/measurements. + /// + /// Modifies the circuit in place. + fn absorb_basis_gates(&mut self) { + use pecos_quantum::pass::{AbsorbBasisGates, CircuitPass}; + AbsorbBasisGates.apply_tick(&mut self.inner); + } + /// Assign MeasId to measurement gates that don't have them. /// /// Use on circuits from external sources (QIS trace, Stim import) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 722a60a2a..2853ee98f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2915,6 +2915,7 @@ def tick_circuit_to_stim( tc: TickCircuit, *, p1: float = 0.0, + p1_gate_rates: Mapping[str, float] | None = None, p2: float = 0.0, p_meas: float = 0.0, p_prep: float = 0.0, @@ -2927,6 +2928,9 @@ def tick_circuit_to_stim( Args: tc: TickCircuit instance with detector/observable metadata p1: Single-qubit error rate + p1_gate_rates: Optional per-gate override for single-qubit error + rates. Gate names are PECOS ``GateType`` names such as ``"Z"``, + ``"SZ"``, and ``"SZdg"``. p2: Two-qubit error rate p_meas: Measurement error rate p_prep: Initialization error rate @@ -3067,8 +3071,9 @@ def _gate_to_stim( op_qubit_str = " ".join(str(q) for q in op_qubits) lines.append(f"{stim_name} {op_qubit_str}") - if noise_kind == "single" and p1 > 0: - lines.append(f"DEPOLARIZE1({p1}) {qubit_str}") + p1_for_gate = p1 if p1_gate_rates is None else float(p1_gate_rates.get(gate.gate_type.name, p1)) + if noise_kind == "single" and p1_for_gate > 0: + lines.append(f"DEPOLARIZE1({p1_for_gate}) {qubit_str}") elif noise_kind == "two" and p2 > 0: lines.append(f"DEPOLARIZE2({p2}) {qubit_str}") elif noise_kind == "prep" and p_prep > 0: @@ -3444,6 +3449,7 @@ def generate_dem_from_tick_circuit_via_stim( tc: TickCircuit, *, p1: float = 0.01, + p1_gate_rates: Mapping[str, float] | None = None, p2: float = 0.01, p_meas: float = 0.01, p_prep: float = 0.01, @@ -3459,6 +3465,8 @@ def generate_dem_from_tick_circuit_via_stim( Args: tc: TickCircuit with detector/observable metadata p1: Single-qubit depolarizing error rate + p1_gate_rates: Optional per-gate override for single-qubit + depolarizing rates. Gate names are PECOS ``GateType`` names. p2: Two-qubit depolarizing error rate p_meas: Measurement error rate p_prep: Initialization (prep) error rate @@ -3478,7 +3486,14 @@ def generate_dem_from_tick_circuit_via_stim( msg = "Stim is required for this function. Install with: pip install stim" raise ImportError(msg) from e - stim_str = tick_circuit_to_stim(tc, p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + stim_str = tick_circuit_to_stim( + tc, + p1=p1, + p1_gate_rates=p1_gate_rates, + p2=p2, + p_meas=p_meas, + p_prep=p_prep, + ) circuit = stim.Circuit(stim_str) dem = circuit.detector_error_model(decompose_errors=decompose_errors or maximal_decomposition) if maximal_decomposition: diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index fd0a184af..af30a271a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -290,6 +290,7 @@ class _CachedNativeSurfaceTopology: dag_circuit: Any influence_map: Any szz_physical_prefixes: bool + z_frame_gate_p1_free: bool pauli_frame_lookup: Any | None detectors_json: str observables_json: str @@ -1647,8 +1648,8 @@ def _use_szz_physical_prefixes( ) -def _szz_prefix_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: - if not topology.szz_physical_prefixes: +def _szz_z_frame_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: + if not topology.z_frame_gate_p1_free: return None return {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -1793,6 +1794,7 @@ def _surface_native_topology( dag_circuit=dag, influence_map=influence_map, szz_physical_prefixes=szz_physical_prefixes, + z_frame_gate_p1_free=interaction_basis == "szz", pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, observables_json=observables_json, @@ -1844,7 +1846,7 @@ def _dem_string_from_cached_surface_topology( builder = _with_noise_compat( DemBuilder(topology.influence_map), noise, - p1_gate_rates=_szz_prefix_p1_gate_rates(topology), + p1_gate_rates=_szz_z_frame_p1_gate_rates(topology), ) if hasattr(builder, "with_exact_branch_replay_circuit"): builder = builder.with_exact_branch_replay_circuit(topology.dag_circuit) @@ -2014,7 +2016,7 @@ def _build_native_sampler_from_cached_surface_topology( _with_noise_compat( DemSamplerBuilder(topology.influence_map), noise, - p1_gate_rates=_szz_prefix_p1_gate_rates(topology), + p1_gate_rates=_szz_z_frame_p1_gate_rates(topology), ) .with_detectors_json(topology.detectors_json) .with_observables_json(topology.observables_json) diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index ddc4ab30e..58f03e1ed 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -823,6 +823,72 @@ def test_szz_public_native_dem_accepts_traced_qis_p1() -> None: assert stim.DetectorErrorModel(dem).num_detectors > 0 +@pytest.mark.parametrize("basis", ["Z", "X"]) +def test_szz_public_traced_qis_dem_matches_stim_with_z_frame_p1_free(basis: str) -> None: + from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + tick_circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis=basis, + circuit_source="traced_qis", + interaction_basis="szz", + ) + normalize_traced_qis_tick_circuit(tick_circuit, context="SZZ public traced-QIS p1 test") + noise = NoiseModel(p1=0.001) + + native_errors = _raw_dem_errors( + generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + interaction_basis="szz", + ), + ) + stim_errors = _raw_dem_errors( + generate_dem_from_tick_circuit_via_stim( + tick_circuit, + decompose_errors=False, + p1=noise.p1, + p2=0.0, + p_meas=0.0, + p_prep=0.0, + p1_gate_rates={"Z": 0.0, "SZ": 0.0, "SZdg": 0.0}, + ), + ) + + assert set(native_errors) == set(stim_errors) + for target, native_probability in native_errors.items(): + stim_probability = stim_errors[target] + rel_diff = abs(native_probability - stim_probability) / max( + native_probability, + stim_probability, + 1e-12, + ) + assert rel_diff < 0.005, ( + f"{basis} public traced-QIS SZZ p1 DEM mismatch for {target}: " + f"PECOS={native_probability:.8f}, Stim={stim_probability:.8f}" + ) + + +def test_szz_z_frame_gates_are_p1_free_for_native_noise() -> None: + from types import SimpleNamespace + + from pecos.qec.surface.decode import _szz_z_frame_p1_gate_rates + + assert _szz_z_frame_p1_gate_rates(SimpleNamespace(z_frame_gate_p1_free=True)) == { + "Z": 0.0, + "SZ": 0.0, + "SZdg": 0.0, + } + assert _szz_z_frame_p1_gate_rates(SimpleNamespace(z_frame_gate_p1_free=False)) is None + + def test_szz_native_dem_accepts_p1_with_physical_prefix_lowering() -> None: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 7cc55b476..8ca5eb525 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -7,7 +7,6 @@ import random import pytest - from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import normalize_traced_qis_tick_circuit from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model @@ -240,6 +239,53 @@ def test_normalize_traced_qis_tick_circuit_rejects_raw_rzz_after_lowering(): normalize_traced_qis_tick_circuit(tc, context="test non-Clifford RZZ normalization") +def _gate_names(tc): + return [ + gate.gate_type.name + for tick_index in range(tc.num_ticks()) + for gate in tc.get_tick(tick_index).gate_batches() + ] + + +def test_tick_circuit_pass_bindings_cancel_and_remove_simple_gates(): + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().h([0]) + tc.tick().i([1]) + + tc.cancel_inverses() + tc.remove_identity() + + assert _gate_names(tc) == [] + + +def test_tick_circuit_pass_bindings_merge_and_lower_rotations(): + tc = TickCircuit() + tc.tick().rz(math.pi / 4, [0]) + tc.tick().rz(math.pi / 4, [0]) + + tc.merge_adjacent_rotations() + tc.lower_clifford_rotations() + + assert _gate_names(tc) == ["SZ"] + + +def test_tick_circuit_pass_bindings_absorb_basis_and_peephole(): + absorbed = TickCircuit() + absorbed.tick().pz([0]) + absorbed.tick().sz([0]) + absorbed.tick().mz([0]) + absorbed.absorb_basis_gates() + assert _gate_names(absorbed) == ["PZ", "MZ"] + + optimized = TickCircuit() + optimized.tick().h([1]) + optimized.tick().cx([(0, 1)]) + optimized.tick().h([1]) + optimized.peephole_optimize() + assert _gate_names(optimized) == ["CZ"] + + def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) From 3bf2e7bbf80d4ee25a2499e09dcb58cd7e574d10 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 14:51:28 -0600 Subject: [PATCH 145/388] Simplify traced Clifford gate chains --- crates/pecos-quantum/src/pass.rs | 327 +++++++++++++++++- python/pecos-rslib/pecos_rslib.pyi | 1 + .../pecos-rslib/src/dag_circuit_bindings.rs | 8 + .../src/pecos/qec/surface/circuit_builder.py | 7 + .../qec/test_traced_qis_clifford_pipeline.py | 34 ++ 5 files changed, 376 insertions(+), 1 deletion(-) diff --git a/crates/pecos-quantum/src/pass.rs b/crates/pecos-quantum/src/pass.rs index 98160d49b..879ab67ab 100644 --- a/crates/pecos-quantum/src/pass.rs +++ b/crates/pecos-quantum/src/pass.rs @@ -21,7 +21,7 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use pecos_core::gate_type::GateType; -use pecos_core::{Angle64, Gate, GateQubits, QubitId}; +use pecos_core::{Angle64, Clifford, Gate, GateQubits, QubitId}; use crate::{Attribute, DagCircuit, Tick, TickCircuit}; @@ -106,6 +106,11 @@ pub fn absorb_basis_gates(circuit: &mut TickCircuit) { AbsorbBasisGates.apply_tick(circuit); } +/// Simplify adjacent single-qubit Clifford chains on each qubit. +pub fn simplify_single_qubit_clifford_chains(circuit: &mut TickCircuit) { + SimplifySingleQubitCliffordChains.apply_tick(circuit); +} + /// Compact ticks by ASAP scheduling (merge gates into earlier ticks). pub fn compact_ticks(circuit: &mut TickCircuit) { CompactTicks.apply_tick(circuit); @@ -1169,6 +1174,237 @@ impl CircuitPass for AbsorbBasisGates { } } +const SINGLE_QUBIT_CLIFFORD_CANDIDATES: [GateType; 12] = [ + GateType::Z, + GateType::SZ, + GateType::SZdg, + GateType::X, + GateType::Y, + GateType::H, + GateType::SX, + GateType::SXdg, + GateType::SY, + GateType::SYdg, + GateType::F, + GateType::Fdg, +]; + +#[derive(Clone, Debug)] +struct SingleQubitCliffordChain { + positions: Vec<(usize, usize)>, + gates: Vec, + product: Clifford, +} + +impl SingleQubitCliffordChain { + fn new(position: (usize, usize), gate_type: GateType, clifford: Clifford) -> Self { + Self { + positions: vec![position], + gates: vec![gate_type], + product: clifford, + } + } + + fn push(&mut self, position: (usize, usize), gate_type: GateType, clifford: Clifford) { + self.positions.push(position); + self.gates.push(gate_type); + self.product = clifford.compose(self.product); + } +} + +fn gate_type_to_single_qubit_clifford(gate_type: GateType) -> Option { + match gate_type { + GateType::I => Some(Clifford::I), + GateType::X => Some(Clifford::X), + GateType::Y => Some(Clifford::Y), + GateType::Z => Some(Clifford::Z), + GateType::H => Some(Clifford::H), + GateType::SX => Some(Clifford::SX), + GateType::SXdg => Some(Clifford::SXdg), + GateType::SY => Some(Clifford::SY), + GateType::SYdg => Some(Clifford::SYdg), + GateType::SZ => Some(Clifford::SZ), + GateType::SZdg => Some(Clifford::SZdg), + GateType::F => Some(Clifford::F), + GateType::Fdg => Some(Clifford::Fdg), + _ => None, + } +} + +fn plain_single_qubit_clifford_gate(gate: &Gate) -> Option { + if gate.qubits.len() != 1 + || !gate.angles.is_empty() + || !gate.params.is_empty() + || !gate.meas_ids.is_empty() + || gate.channel.is_some() + { + return None; + } + gate_type_to_single_qubit_clifford(gate.gate_type) +} + +fn single_qubit_clifford_sequence_product(sequence: &[GateType]) -> Clifford { + let mut product = Clifford::I; + for &gate_type in sequence { + let clifford = gate_type_to_single_qubit_clifford(gate_type) + .expect("candidate gate types must be one-qubit Cliffords"); + product = clifford.compose(product); + } + product +} + +fn is_z_axis_frame_candidate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::I | GateType::Z | GateType::SZ | GateType::SZdg + ) +} + +fn single_qubit_clifford_sequence_score(sequence: &[GateType]) -> (usize, usize) { + let non_frame_count = sequence + .iter() + .filter(|&&gate_type| !is_z_axis_frame_candidate(gate_type)) + .count(); + (non_frame_count, sequence.len()) +} + +fn canonical_single_qubit_clifford_sequence(clifford: Clifford) -> Vec { + if clifford == Clifford::I { + return Vec::new(); + } + + let mut best_sequence: Option> = None; + let mut best_score: Option<(usize, usize)> = None; + + for &candidate in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + let sequence = vec![candidate]; + if single_qubit_clifford_sequence_product(&sequence) == clifford { + let score = single_qubit_clifford_sequence_score(&sequence); + if best_score.is_none_or(|best| score < best) { + best_score = Some(score); + best_sequence = Some(sequence); + } + } + } + + for &first in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + for &second in &SINGLE_QUBIT_CLIFFORD_CANDIDATES { + let sequence = vec![first, second]; + if single_qubit_clifford_sequence_product(&sequence) == clifford { + let score = single_qubit_clifford_sequence_score(&sequence); + if best_score.is_none_or(|best| score < best) { + best_score = Some(score); + best_sequence = Some(sequence); + } + } + } + } + + best_sequence.unwrap_or_else(|| { + panic!("no existing-gate decomposition found for one-qubit Clifford {clifford}") + }) +} + +fn flush_single_qubit_clifford_chain( + chain: SingleQubitCliffordChain, + replacements: &mut BTreeMap<(usize, usize), GateType>, + to_remove: &mut HashSet<(usize, usize)>, +) { + if chain.positions.len() < 2 { + return; + } + + let canonical = canonical_single_qubit_clifford_sequence(chain.product); + let original_score = single_qubit_clifford_sequence_score(&chain.gates); + let canonical_score = single_qubit_clifford_sequence_score(&canonical); + if canonical_score >= original_score || canonical.len() > chain.positions.len() { + return; + } + + for (position, gate_type) in chain.positions.iter().zip(canonical.iter()) { + replacements.insert(*position, *gate_type); + } + for position in chain.positions.iter().skip(canonical.len()) { + to_remove.insert(*position); + } +} + +/// Simplify adjacent single-qubit Clifford chains on each qubit. +/// +/// The pass follows each qubit's operation timeline, composes adjacent plain +/// one-qubit Clifford gates exactly, and replaces the chain with a deterministic +/// sequence over existing PECOS gate names. Gates carrying parameters, +/// measurement IDs, channel payloads, or batch metadata are treated as barriers. +pub struct SimplifySingleQubitCliffordChains; + +impl CircuitPass for SimplifySingleQubitCliffordChains { + fn apply_tick(&self, circuit: &mut TickCircuit) { + split_batched_tick_commands(circuit); + + let mut pending: BTreeMap = BTreeMap::new(); + let mut replacements: BTreeMap<(usize, usize), GateType> = BTreeMap::new(); + let mut to_remove: HashSet<(usize, usize)> = HashSet::new(); + + for (ti, tick) in circuit.iter_ticks() { + for gate_ref in tick.iter_gate_batches() { + let position = (ti, gate_ref.batch_index()); + let gate = gate_ref.as_gate(); + + if gate_ref.attrs().next().is_none() + && let Some(clifford) = plain_single_qubit_clifford_gate(gate) + { + let qubit = gate.qubits[0]; + if let Some(chain) = pending.get_mut(&qubit) { + chain.push(position, gate.gate_type, clifford); + } else { + pending.insert( + qubit, + SingleQubitCliffordChain::new(position, gate.gate_type, clifford), + ); + } + continue; + } + + for &qubit in &gate.qubits { + if let Some(chain) = pending.remove(&qubit) { + flush_single_qubit_clifford_chain(chain, &mut replacements, &mut to_remove); + } + } + } + } + + for (_, chain) in pending { + flush_single_qubit_clifford_chain(chain, &mut replacements, &mut to_remove); + } + + for (&(ti, gi), &gate_type) in &replacements { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.update_gate_batch(gi, |gate| { + gate.gate_type = gate_type; + gate.angles.clear(); + gate.params.clear(); + gate.meas_ids.clear(); + gate.channel = None; + }) + .unwrap_or_else(|err| panic!("{err}")); + } + } + + let mut remove_list: Vec<(usize, usize)> = to_remove.into_iter().collect(); + remove_list.sort_unstable(); + for &(ti, gi) in remove_list.iter().rev() { + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.remove_gate(gi); + } + } + } + + fn apply_dag(&self, _circuit: &mut DagCircuit) { + // Tick-only for now: this pass intentionally preserves tick-local + // metadata and rewrites concrete scheduled gate positions. + } +} + /// ASAP-schedule gates to minimise tick count, then drop empty ticks. /// /// For each gate (processed in original tick order), the pass assigns it to @@ -1823,6 +2059,8 @@ mod tests { GateType::SYdg => Some(unitary_rep::SY(q0).dg()), GateType::SZ => Some(unitary_rep::SZ(q0)), GateType::SZdg => Some(unitary_rep::SZ(q0).dg()), + GateType::F => Some(unitary_rep::SZ(q0) * unitary_rep::SX(q0)), + GateType::Fdg => Some(unitary_rep::SX(q0).dg() * unitary_rep::SZ(q0).dg()), GateType::T => Some(unitary_rep::T(q0)), GateType::Tdg => Some(unitary_rep::T(q0).dg()), GateType::RX => { @@ -2885,6 +3123,93 @@ mod tests { assert!(saw_untouched); } + #[test] + fn single_qubit_clifford_canonical_sequences_cover_all_1q() { + for &clifford in Clifford::all_1q() { + let sequence = canonical_single_qubit_clifford_sequence(clifford); + assert_eq!( + single_qubit_clifford_sequence_product(&sequence), + clifford, + "canonical sequence {sequence:?} does not implement {clifford}" + ); + assert!( + sequence.len() <= 2, + "canonical sequence for {clifford} should use at most two gates" + ); + } + } + + #[test] + fn simplify_single_qubit_clifford_chains_reduces_batched_chains() { + let mut original = TickCircuit::new(); + original.tick().sx(&[0, 1]); + original.tick().sz(&[0, 1]); + + let mut simplified = original.clone(); + SimplifySingleQubitCliffordChains.apply_tick(&mut simplified); + + assert_circuits_equiv(&original, &simplified); + let gates: Vec<&Gate> = simplified + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .collect(); + assert_eq!(gates.len(), 2); + assert!(gates.iter().all(|gate| gate.gate_type == GateType::F)); + } + + #[test] + fn simplify_single_qubit_clifford_chains_removes_identity_products() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick().h(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + assert!(tc.ticks().iter().all(|tick| tick.is_empty())); + } + + #[test] + fn simplify_single_qubit_clifford_chains_respects_multi_qubit_barriers() { + let mut tc = TickCircuit::new(); + tc.tick().sx(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().sz(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + let gate_types: Vec = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .map(|gate| gate.gate_type) + .collect(); + assert_eq!(gate_types, vec![GateType::SX, GateType::CX, GateType::SZ]); + } + + #[test] + fn simplify_single_qubit_clifford_chains_preserves_annotated_gates() { + let mut tc = TickCircuit::new(); + tc.tick() + .sx(&[0]) + .meta("role", Attribute::String("calibrated".into())); + tc.tick().sz(&[0]); + + SimplifySingleQubitCliffordChains.apply_tick(&mut tc); + + let gate_types: Vec = tc + .ticks() + .iter() + .flat_map(super::super::tick_circuit::Tick::gate_batches) + .map(|gate| gate.gate_type) + .collect(); + assert_eq!(gate_types, vec![GateType::SX, GateType::SZ]); + assert_eq!( + tc.get_tick(0).unwrap().get_gate_attr(0, "role"), + Some(&Attribute::String("calibrated".into())) + ); + } + #[test] fn split_batched_tick_commands_preserves_payloads_attrs_and_counters() { let mut tc = TickCircuit::new(); diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index 48a19cb51..f5a792e9f 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -1668,6 +1668,7 @@ class TickCircuit: def merge_adjacent_rotations(self) -> None: ... def peephole_optimize(self) -> None: ... def absorb_basis_gates(self) -> None: ... + def simplify_single_qubit_clifford_chains(self) -> None: ... def assign_missing_meas_ids(self) -> None: ... def insert_idle_after_two_qubit_gates(self, duration: float = 1.0) -> None: ... def fill_idle_gates(self) -> None: ... diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 153a68991..50f110181 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -2718,6 +2718,14 @@ impl PyTickCircuit { AbsorbBasisGates.apply_tick(&mut self.inner); } + /// Simplify adjacent single-qubit Clifford chains. + /// + /// Modifies the circuit in place. + fn simplify_single_qubit_clifford_chains(&mut self) { + use pecos_quantum::pass::{CircuitPass, SimplifySingleQubitCliffordChains}; + SimplifySingleQubitCliffordChains.apply_tick(&mut self.inner); + } + /// Assign MeasId to measurement gates that don't have them. /// /// Use on circuits from external sources (QIS trace, Stim import) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 2853ee98f..980df977e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2714,6 +2714,7 @@ def normalize_traced_qis_tick_circuit( tick_circuit: object, *, context: str = "traced-QIS DEM construction", + simplify_single_qubit_clifford_chains: bool = True, ) -> None: """Normalize a traced-QIS TickCircuit before DEM/DAG analysis. @@ -2723,6 +2724,12 @@ def normalize_traced_qis_tick_circuit( this helper at every traced-QIS boundary before converting to a DAG. """ _call_required_tick_circuit_method(tick_circuit, "lower_clifford_rotations", context) + if simplify_single_qubit_clifford_chains: + _call_required_tick_circuit_method( + tick_circuit, + "simplify_single_qubit_clifford_chains", + context, + ) _call_required_tick_circuit_method(tick_circuit, "assign_missing_meas_ids", context) assert_traced_qis_tick_circuit_dem_ready(tick_circuit, context=context) diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 8ca5eb525..25c162953 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -286,6 +286,40 @@ def test_tick_circuit_pass_bindings_absorb_basis_and_peephole(): assert _gate_names(optimized) == ["CZ"] +def test_tick_circuit_pass_bindings_simplify_single_qubit_clifford_chains(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + tc.simplify_single_qubit_clifford_chains() + + assert _gate_names(tc) == ["F"] + + +def test_normalize_traced_qis_tick_circuit_simplifies_single_qubit_clifford_chains(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + normalize_traced_qis_tick_circuit(tc, context="test one-qubit Clifford normalization") + + assert _gate_names(tc) == ["F"] + + +def test_normalize_traced_qis_tick_circuit_can_skip_single_qubit_clifford_simplification(): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().sz([0]) + + normalize_traced_qis_tick_circuit( + tc, + context="test one-qubit Clifford normalization", + simplify_single_qubit_clifford_chains=False, + ) + + assert _gate_names(tc) == ["SX", "SZ"] + + def test_explicit_python_gate_names_map_to_rust_clifford_gates(): tc = build_explicit_clifford_gate_circuit() noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) From 0f6a41b6e96ae3949b77b68813f9eaaacfd4a07a Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 13 Jun 2026 15:41:39 -0600 Subject: [PATCH 146/388] Support averaged global measurement crosstalk DEM replay --- .../fault_tolerance/dem_builder/builder.rs | 505 ++++++++++++++---- .../src/fault_tolerance/dem_builder/types.rs | 12 +- .../src/fault_tolerance/influence_builder.rs | 46 +- .../src/fault_tolerance/propagator/dag.rs | 17 +- .../src/fault_tolerance_bindings.rs | 8 +- .../src/pecos/qec/surface/decode.py | 96 +++- .../tests/qec/test_from_guppy_dem.py | 53 ++ 7 files changed, 623 insertions(+), 114 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 48ced572a..1e7d35155 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -21,11 +21,12 @@ use super::types::{ ReplacementBranchApproximation, SourceMetadata, record_offset_to_absolute_index, }; use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; -use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; +use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Direction, Pauli, apply_gate}; use pecos_core::BitSet; use pecos_core::gate_type::GateType; use pecos_simulators::{ - SymbolicMeasurementResult, SymbolicSparseStab, symbolic_sparse_stab::MeasurementHistory, + PauliProp, SymbolicMeasurementResult, SymbolicSparseStab, + symbolic_sparse_stab::MeasurementHistory, }; use smallvec::SmallVec; use std::cell::RefCell; @@ -885,13 +886,6 @@ impl<'a> DemBuilder<'a> { )); } - if self.noise.p_meas_crosstalk_global > 0.0 { - return Err(DemBuilderError::ConfigurationError( - "exact deterministic measurement crosstalk DEM replay does not yet support global payloads" - .to_string(), - )); - } - if self.noise.p_meas_crosstalk_local > 0.0 && !has_local_payloads { return Err(DemBuilderError::ConfigurationError( "exact deterministic measurement crosstalk DEM replay requested a positive local rate, but the influence map contains no MeasCrosstalkLocalPayload locations" @@ -899,13 +893,16 @@ impl<'a> DemBuilder<'a> { )); } - if self.noise.p_meas_crosstalk_local <= 0.0 { + if self.noise.p_meas_crosstalk_local <= 0.0 && self.noise.p_meas_crosstalk_global <= 0.0 { return Ok(()); } if self.noise.p_meas_crosstalk_model.has_leakage() - && self.noise.measurement_crosstalk_dem_mode - != MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + && !matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) { return Err(DemBuilderError::ConfigurationError( "exact deterministic measurement crosstalk DEM replay does not yet support leakage transitions" @@ -913,15 +910,34 @@ impl<'a> DemBuilder<'a> { )); } + let needs_circuit_context = + self.noise.measurement_crosstalk_dem_mode != MeasurementCrosstalkDemMode::Omitted; let Some(context) = self.exact_branch_context else { - return Err(DemBuilderError::ConfigurationError( - "exact deterministic measurement crosstalk DEM replay requires a circuit-aware builder context" - .to_string(), - )); + if needs_circuit_context { + return Err(DemBuilderError::ConfigurationError( + "measurement crosstalk DEM replay requires a circuit-aware builder context" + .to_string(), + )); + } + return Ok(()); }; + let requires_deterministic_hidden_measurements = matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + ); + if !requires_deterministic_hidden_measurements { + return Ok(()); + } + for (loc_idx, loc) in self.influence_map.locations.iter().enumerate() { - if loc.before || loc.gate_type != GateType::MeasCrosstalkLocalPayload { + if loc.before + || !matches!( + loc.gate_type, + GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload + ) + { continue; } let result = self.hidden_mz_result_before_crosstalk_payload(context, loc)?; @@ -989,6 +1005,114 @@ impl<'a> DemBuilder<'a> { ))) } + fn exact_measurement_crosstalk_pauli_effect( + &self, + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + pauli: Pauli, + ) -> Result { + let mut triggered_dets: SmallVec<[u32; 4]> = SmallVec::new(); + let mut triggered_obs: SmallVec<[u32; 2]> = SmallVec::new(); + + for detector in &self.detectors { + let indices = + self.measurement_indices_from_refs(&detector.records, &detector.meas_ids)?; + if self.measurement_parity_anticommutes_after_crosstalk_payload( + context, loc, pauli, &indices, + )? { + xor_toggle_4(&mut triggered_dets, detector.id); + } + } + + for observable in &self.observables { + let indices = + self.measurement_indices_from_refs(&observable.records, &observable.meas_ids)?; + if self.measurement_parity_anticommutes_after_crosstalk_payload( + context, loc, pauli, &indices, + )? { + xor_toggle_2(&mut triggered_obs, observable.id); + } + } + + triggered_dets.sort_unstable(); + triggered_obs.sort_unstable(); + Ok(FaultMechanism::from_sorted_with_tracked_paulis( + triggered_dets, + triggered_obs, + SmallVec::new(), + )) + } + + fn measurement_parity_anticommutes_after_crosstalk_payload( + &self, + context: ExactBranchReplayContext<'_>, + loc: &DagSpacetimeLocation, + pauli: Pauli, + measurement_indices: &[usize], + ) -> Result { + let victim = loc.qubits.first().copied().ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload at node {} has no victim qubit", + loc.node + )) + })?; + let victim_index = victim.index(); + let mut prop = PauliProp::new(); + for &measurement_idx in measurement_indices { + let &(_, qubit, basis) = self + .influence_map + .measurements + .get(measurement_idx) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact replay has no measurement {measurement_idx}" + )) + })?; + match basis { + 0 => prop.track_z(&[qubit]), + 1 => prop.track_x(&[qubit]), + other => { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk exact replay does not support measurement basis {other}" + ))); + } + } + } + + let topo_order = context.circuit.topological_order(); + let payload_pos = topo_order + .iter() + .position(|&node| node == loc.node) + .ok_or_else(|| { + DemBuilderError::ConfigurationError(format!( + "measurement crosstalk payload node {} was not found in the replay circuit", + loc.node + )) + })?; + for &node in topo_order[payload_pos + 1..].iter().rev() { + if let Some(gate) = context.circuit.gate(node) { + apply_gate(&mut prop, gate, Direction::Backward); + } + } + + Ok(Self::pauli_anticommutes_with_prop_on_qubit( + &prop, + victim_index, + pauli, + )) + } + + fn pauli_anticommutes_with_prop_on_qubit(prop: &PauliProp, qubit: usize, pauli: Pauli) -> bool { + let has_x = prop.contains_x(qubit); + let has_z = prop.contains_z(qubit); + match pauli { + Pauli::I => false, + Pauli::X => has_z, + Pauli::Z => has_x, + Pauli::Y => has_x ^ has_z, + } + } + fn apply_symbolic_gate_for_crosstalk_hidden_mz( sim: &mut SymbolicSparseStab, node: usize, @@ -1007,105 +1131,112 @@ impl<'a> DemBuilder<'a> { } Ok(()) }; + let pairs = || -> Result, DemBuilderError> { + require(2)?; + if qubits.len() % 2 != 0 { + return Err(DemBuilderError::ConfigurationError(format!( + "measurement crosstalk replay expected gate {:?} at node {} to have an even number of qubits, got {}", + gate_type, + node, + qubits.len() + ))); + } + Ok(qubits + .chunks_exact(2) + .map(|pair| (pair[0], pair[1])) + .collect()) + }; match gate_type { GateType::H => { require(1)?; - sim.h(&[qubits[0]]); + sim.h(qubits); } GateType::F => { require(1)?; - sim.sx(&[qubits[0]]); - sim.sz(&[qubits[0]]); + sim.sx(qubits); + sim.sz(qubits); } GateType::Fdg => { require(1)?; - sim.szdg(&[qubits[0]]); - sim.sxdg(&[qubits[0]]); + sim.szdg(qubits); + sim.sxdg(qubits); } GateType::SX => { require(1)?; - sim.sx(&[qubits[0]]); + sim.sx(qubits); } GateType::SXdg => { require(1)?; - sim.sxdg(&[qubits[0]]); + sim.sxdg(qubits); } GateType::SY => { require(1)?; - sim.sy(&[qubits[0]]); + sim.sy(qubits); } GateType::SYdg => { require(1)?; - sim.sydg(&[qubits[0]]); + sim.sydg(qubits); } GateType::SZ => { require(1)?; - sim.sz(&[qubits[0]]); + sim.sz(qubits); } GateType::SZdg => { require(1)?; - sim.szdg(&[qubits[0]]); + sim.szdg(qubits); } GateType::X => { require(1)?; - sim.x(&[qubits[0]]); + sim.x(qubits); } GateType::Y => { require(1)?; - sim.y(&[qubits[0]]); + sim.y(qubits); } GateType::Z => { require(1)?; - sim.z(&[qubits[0]]); + sim.z(qubits); } GateType::CX => { - require(2)?; - sim.cx(&[(qubits[0], qubits[1])]); + sim.cx(&pairs()?); } GateType::CY => { - require(2)?; - sim.cy(&[(qubits[0], qubits[1])]); + sim.cy(&pairs()?); } GateType::CZ => { - require(2)?; - sim.cz(&[(qubits[0], qubits[1])]); + sim.cz(&pairs()?); } GateType::SXX => { - require(2)?; - sim.sxx(&[(qubits[0], qubits[1])]); + sim.sxx(&pairs()?); } GateType::SXXdg => { - require(2)?; - sim.sxxdg(&[(qubits[0], qubits[1])]); + sim.sxxdg(&pairs()?); } GateType::SYY => { - require(2)?; - sim.syy(&[(qubits[0], qubits[1])]); + sim.syy(&pairs()?); } GateType::SYYdg => { - require(2)?; - sim.syydg(&[(qubits[0], qubits[1])]); + sim.syydg(&pairs()?); } GateType::SZZ => { - require(2)?; - sim.szz(&[(qubits[0], qubits[1])]); + sim.szz(&pairs()?); } GateType::SZZdg => { - require(2)?; - sim.szzdg(&[(qubits[0], qubits[1])]); + sim.szzdg(&pairs()?); } GateType::SWAP => { - require(2)?; - sim.swap(&[(qubits[0], qubits[1])]); + sim.swap(&pairs()?); } GateType::MZ | GateType::MeasureFree => { require(1)?; - sim.mz(&[qubits[0]]); + sim.mz(qubits); } GateType::PZ | GateType::QAlloc => { require(1)?; - sim.pz(qubits[0]); + for &qubit in qubits { + sim.pz(qubit); + } } GateType::I | GateType::Idle @@ -1312,11 +1443,31 @@ impl<'a> DemBuilder<'a> { self.noise.measurement_crosstalk_dem_mode, MeasurementCrosstalkDemMode::ExactDeterministic | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing ) && self.noise.p_meas_crosstalk_local > 0.0 => { - self.process_measurement_crosstalk_local_source_tracked( + self.process_measurement_crosstalk_source_tracked( loc_idx, + self.noise.p_meas_crosstalk_local, + dem, + meas_to_detectors, + meas_to_observables, + ); + } + GateType::MeasCrosstalkGlobalPayload + if !loc.before + && matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) + && self.noise.p_meas_crosstalk_global > 0.0 => + { + self.process_measurement_crosstalk_source_tracked( + loc_idx, + self.noise.p_meas_crosstalk_global, dem, meas_to_detectors, meas_to_observables, @@ -1559,55 +1710,70 @@ impl<'a> DemBuilder<'a> { /// Processes local measurement-crosstalk payloads when hidden outcomes are /// deterministic and state-independent. - fn process_measurement_crosstalk_local_source_tracked( + fn process_measurement_crosstalk_source_tracked( &self, loc_idx: usize, + payload_rate: f64, dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, ) { - let context = self - .exact_branch_context - .expect("measurement crosstalk exact deterministic mode was validated with context"); - let loc = &self.influence_map.locations[loc_idx]; - let hidden = self - .hidden_mz_result_before_crosstalk_payload(context, loc) - .expect("measurement crosstalk exact deterministic hidden result was validated"); - let (bit_flip_probability, leak_probability) = if hidden.flip { - ( - self.noise.p_meas_crosstalk_model.p_1_to_0, - self.noise.p_meas_crosstalk_model.p_1_to_leak, - ) - } else { - ( - self.noise.p_meas_crosstalk_model.p_0_to_1, - self.noise.p_meas_crosstalk_model.p_0_to_leak, - ) - }; - if self.noise.measurement_crosstalk_dem_mode - == MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing - { + let (bit_flip_probability, leak_probability) = + match self.noise.measurement_crosstalk_dem_mode { + MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing => ( + 0.5 * (self.noise.p_meas_crosstalk_model.p_0_to_1 + + self.noise.p_meas_crosstalk_model.p_1_to_0), + 0.5 * (self.noise.p_meas_crosstalk_model.p_0_to_leak + + self.noise.p_meas_crosstalk_model.p_1_to_leak), + ), + MeasurementCrosstalkDemMode::ExactDeterministic + | MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing => { + let context = self.exact_branch_context.expect( + "measurement crosstalk exact deterministic mode was validated with context", + ); + let loc = &self.influence_map.locations[loc_idx]; + let hidden = self + .hidden_mz_result_before_crosstalk_payload(context, loc) + .expect( + "measurement crosstalk exact deterministic hidden result was validated", + ); + if hidden.flip { + ( + self.noise.p_meas_crosstalk_model.p_1_to_0, + self.noise.p_meas_crosstalk_model.p_1_to_leak, + ) + } else { + ( + self.noise.p_meas_crosstalk_model.p_0_to_1, + self.noise.p_meas_crosstalk_model.p_0_to_leak, + ) + } + } + MeasurementCrosstalkDemMode::Omitted => { + return; + } + }; + if matches!( + self.noise.measurement_crosstalk_dem_mode, + MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing + | MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing + ) { let leak_pauli_probability = leak_probability / 4.0; - self.process_measurement_crosstalk_local_pauli_rates_source_tracked( + self.process_measurement_crosstalk_pauli_rates_source_tracked( loc_idx, [ - self.noise.p_meas_crosstalk_local - * (bit_flip_probability + leak_pauli_probability), - self.noise.p_meas_crosstalk_local * leak_pauli_probability, - self.noise.p_meas_crosstalk_local * leak_pauli_probability, + payload_rate * (bit_flip_probability + leak_pauli_probability), + payload_rate * leak_pauli_probability, + payload_rate * leak_pauli_probability, ], dem, meas_to_detectors, meas_to_observables, ); } else { - self.process_measurement_crosstalk_local_pauli_rates_source_tracked( + self.process_measurement_crosstalk_pauli_rates_source_tracked( loc_idx, - [ - self.noise.p_meas_crosstalk_local * bit_flip_probability, - 0.0, - 0.0, - ], + [payload_rate * bit_flip_probability, 0.0, 0.0], dem, meas_to_detectors, meas_to_observables, @@ -1617,7 +1783,7 @@ impl<'a> DemBuilder<'a> { /// Processes local measurement-crosstalk payloads as single-location Pauli /// source channels while preserving crosstalk source metadata. - fn process_measurement_crosstalk_local_pauli_rates_source_tracked( + fn process_measurement_crosstalk_pauli_rates_source_tracked( &self, loc_idx: usize, rates: [f64; 3], @@ -1627,10 +1793,19 @@ impl<'a> DemBuilder<'a> { ) { let loc = &self.influence_map.locations[loc_idx]; let [rate_x, rate_y, rate_z] = rates; - let x_effect = - self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); - let z_effect = - self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); + let effect = |pauli| -> FaultMechanism { + if loc.gate_type == GateType::MeasCrosstalkGlobalPayload { + let context = self.exact_branch_context.expect( + "measurement crosstalk exact deterministic mode was validated with context", + ); + self.exact_measurement_crosstalk_pauli_effect(context, loc, pauli) + .expect("global measurement crosstalk exact replay was validated") + } else { + self.compute_mechanism(loc_idx, pauli, meas_to_detectors, meas_to_observables) + } + }; + let x_effect = effect(Pauli::X); + let z_effect = effect(Pauli::Z); if rate_x > 0.0 && !x_effect.is_empty() { dem.add_direct_contribution_with_source( @@ -2993,13 +3168,30 @@ fn omitted_branch_flips_measurement_parity_from_histories( ideal_history: &MeasurementHistory, branch_history: &MeasurementHistory, measurement_indices: &[usize], +) -> Result { + measurement_parity_differs_from_histories( + ideal_history, + branch_history, + measurement_indices, + &format!( + "exact_branch_replay omitted gate at node {}", + request.gate_node + ), + ) +} + +fn measurement_parity_differs_from_histories( + ideal_history: &MeasurementHistory, + branch_history: &MeasurementHistory, + measurement_indices: &[usize], + context: &str, ) -> Result { let ideal = measurement_parity_expression(ideal_history, measurement_indices, "ideal")?; let branch = measurement_parity_expression(branch_history, measurement_indices, "branch")?; if ideal.dependencies != branch.dependencies { return Err(DemBuilderError::ConfigurationError(format!( - "exact_branch_replay omitted gate at node {} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", - request.gate_node, measurement_indices + "{context} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", + measurement_indices ))); } Ok(ideal.flip ^ branch.flip) @@ -4086,7 +4278,12 @@ mod tests { .expect("exact branch replay should emit representable branch effects"); let contributions = dem.contributions_for_effect(&[0], &[]); - assert_eq!(contributions.len(), 1); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); assert!((contributions[0].probability - 0.01).abs() < 1.0e-12); assert!(contributions[0].replacement_branch); assert_eq!( @@ -4145,6 +4342,41 @@ mod tests { circuit } + fn two_qubit_global_crosstalk_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::meas_crosstalk_global_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + + fn two_qubit_global_crosstalk_random_victim_circuit() -> pecos_quantum::DagCircuit { + use pecos_core::{Gate, QubitId}; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::pz(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::h(&[QubitId(1)])); + circuit.add_gate_auto_wire(Gate::meas_crosstalk_global_payload(&[QubitId(0)])); + circuit.add_gate_auto_wire(Gate::mz(&[QubitId(1)])); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_emits_dem_source() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; @@ -4161,7 +4393,12 @@ mod tests { .expect("deterministic local measurement crosstalk should be representable"); let contributions = dem.contributions_for_effect(&[0], &[]); - assert_eq!(contributions.len(), 1); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); assert!((contributions[0].probability - 0.1).abs() < 1.0e-12); assert_eq!( contributions[0].direct_source_family, @@ -4174,6 +4411,40 @@ mod tests { assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); } + #[test] + fn test_exact_deterministic_global_measurement_crosstalk_emits_victim_dem_source() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = two_qubit_global_crosstalk_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model( + MeasurementCrosstalkTransitionModel::bit_flip(0.4, 0.0), + ) + .set_measurement_crosstalk_dem_mode(MeasurementCrosstalkDemMode::ExactDeterministic); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("deterministic global measurement crosstalk should be representable"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!( + contributions.len(), + 1, + "all effects:\n{}", + dem.all_contribution_effects() + ); + assert!((contributions[0].probability - 0.1).abs() < 1.0e-12); + assert_eq!( + contributions[0].direct_source_family, + Some(DirectSourceFamily::MeasurementCrosstalk) + ); + assert_eq!( + contributions[0].source_gate_types.as_slice(), + &[GateType::MeasCrosstalkGlobalPayload] + ); + assert_eq!(contributions[0].paulis.as_slice(), &[Pauli::X]); + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_requires_payloads() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; @@ -4261,6 +4532,48 @@ mod tests { ); } + #[test] + fn test_averaged_global_measurement_crosstalk_accepts_hidden_randomness() { + use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; + + let circuit = two_qubit_global_crosstalk_random_victim_circuit(); + let noise = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) + .set_measurement_crosstalk_global_rate(0.25) + .set_measurement_crosstalk_transition_model(MeasurementCrosstalkTransitionModel { + p_0_to_1: 0.4, + p_0_to_leak: 0.2, + p_1_to_0: 0.0, + p_1_to_leak: 0.0, + }) + .set_measurement_crosstalk_dem_mode( + MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing, + ); + + let dem = DemBuilder::try_from_circuit_with_noise_config(&circuit, noise) + .expect("averaged global leak2depolar crosstalk should handle random hidden MZ"); + + let contributions = dem.contributions_for_effect(&[0], &[]); + assert_eq!( + contributions.len(), + 2, + "all effects:\n{}", + dem.all_contribution_effects() + ); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::X] + && (contribution.probability - 0.05625).abs() < 1.0e-12 + })); + assert!(contributions.iter().any(|contribution| { + contribution.paulis.as_slice() == [Pauli::Y] + && (contribution.probability - 0.00625).abs() < 1.0e-12 + })); + assert!(contributions.iter().all(|contribution| { + contribution.direct_source_family == Some(DirectSourceFamily::MeasurementCrosstalk) + && contribution.source_gate_types.as_slice() + == [GateType::MeasCrosstalkGlobalPayload] + })); + } + #[test] fn test_exact_deterministic_local_measurement_crosstalk_rejects_leakage() { use crate::fault_tolerance::dem_builder::MeasurementCrosstalkTransitionModel; diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index fe5fbd655..aa08b7c00 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -2519,11 +2519,6 @@ pub struct NoiseConfig { /// DEM replay is enabled. pub p_meas_crosstalk_local: f64, /// Per-payload global measurement-crosstalk event rate. - /// - /// Global payload DEM replay is intentionally not implemented yet because - /// the source semantics need to be represented explicitly. If exact - /// crosstalk DEM replay is requested with a positive global rate, the DEM - /// builder fails loudly. pub p_meas_crosstalk_global: f64, /// Hidden-measurement transition probabilities used by measurement /// crosstalk DEM replay. @@ -2545,6 +2540,13 @@ pub enum MeasurementCrosstalkDemMode { /// transitions as the `leak2depolar` replacement channel: one quarter each /// of I, X, Y, and Z on the victim qubit. ExactDeterministicLeakageAsDepolarizing, + /// Replay payloads by averaging over the hidden measurement outcome while + /// treating leakage transitions as the `leak2depolar` replacement channel. + /// + /// This is appropriate when the hidden measurement is state-dependent + /// rather than deterministic, as happens for global measurement crosstalk + /// victims in generic circuits. + AveragedHiddenLeakageAsDepolarizing, } /// Hidden-measurement transition probabilities for local measurement crosstalk. diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index bef7ed93c..6a2b73b3c 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -36,7 +36,7 @@ use super::propagator::{DagFaultAnalyzer, DagPropagator, Direction, Pauli, apply use pecos_core::QubitId; use pecos_simulators::{PauliProp, SymbolicSparseStab}; use smallvec::SmallVec; -use std::collections::BinaryHeap; +use std::collections::{BTreeSet, BinaryHeap}; struct ObservablePropagationWork<'a> { recorder: &'a mut CompoundRecorder, @@ -479,6 +479,7 @@ impl<'a> InfluenceBuilder<'a> { /// Extract fault locations from the propagator. fn extract_locations(propagator: &DagPropagator<'_>) -> Vec { let mut locations = Vec::new(); + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { @@ -497,7 +498,20 @@ impl<'a> InfluenceBuilder<'a> { // Standard circuit noise model: one fault location per gate. // Measurement: before. All others: after. let before = is_measurement; - for &q in &qubits { + let location_qubits: Vec = + if gate.gate_type == pecos_quantum::GateType::MeasCrosstalkGlobalPayload { + qubits.iter().copied().for_each(|q| { + prepared_qubits.remove(&q); + }); + let victims = prepared_qubits.iter().copied().collect(); + qubits.iter().copied().for_each(|q| { + prepared_qubits.insert(q); + }); + victims + } else { + qubits.clone() + }; + for q in location_qubits { locations.push(DagSpacetimeLocation { node, qubits: vec![q], @@ -506,6 +520,12 @@ impl<'a> InfluenceBuilder<'a> { idle_duration: gate.idle_duration(), }); } + if matches!( + gate.gate_type, + pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + ) { + prepared_qubits.extend(qubits.iter().copied()); + } } } @@ -799,6 +819,7 @@ impl<'a> InfluenceBuilder<'a> { let mut map: std::collections::HashMap<(usize, bool), Vec<(usize, usize)>> = std::collections::HashMap::new(); let mut loc_idx = 0; + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { @@ -812,11 +833,30 @@ impl<'a> InfluenceBuilder<'a> { ); let before = is_measurement; - for q in &gate.qubits { + let location_qubits: Vec = + if gate.gate_type == pecos_quantum::GateType::MeasCrosstalkGlobalPayload { + gate.qubits.iter().copied().for_each(|q| { + prepared_qubits.remove(&q); + }); + let victims = prepared_qubits.iter().copied().collect(); + gate.qubits.iter().copied().for_each(|q| { + prepared_qubits.insert(q); + }); + victims + } else { + gate.qubits.to_vec() + }; + for q in &location_qubits { let qi = q.index(); map.entry((node, before)).or_default().push((qi, loc_idx)); loc_idx += 1; } + if matches!( + gate.gate_type, + pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + ) { + prepared_qubits.extend(gate.qubits.iter().copied()); + } } } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 8e11576d7..0c9d9b85b 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -1811,6 +1811,7 @@ impl<'a> DagFaultAnalyzer<'a> { let estimated_locations = topo_order.len() * 4; let mut locations = FaultLocations::with_capacity(estimated_locations, propagator.max_node()); + let mut prepared_qubits: BTreeSet = BTreeSet::new(); for &node in &topo_order { if let Some(gate) = propagator.gate(node) { @@ -1836,10 +1837,24 @@ impl<'a> DagFaultAnalyzer<'a> { } else { 0.0 }; - for &q in &qubits { + let location_qubits: Vec = + if gate.gate_type == GateType::MeasCrosstalkGlobalPayload { + for &q in &qubits { + prepared_qubits.remove(&q); + } + let victims = prepared_qubits.iter().copied().collect(); + prepared_qubits.extend(qubits.iter().copied()); + victims + } else { + qubits.iter().copied().collect() + }; + for q in location_qubits { let single_qubit: SmallVec<[usize; 2]> = smallvec::smallvec![q]; locations.push(node, single_qubit, before, gate.gate_type, idle_duration); } + if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + prepared_qubits.extend(qubits.iter().copied()); + } } } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 0bb5f7d20..30f55673f 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -225,8 +225,14 @@ fn parse_measurement_crosstalk_dem_mode( | "leakage_as_depolarizing" => { Ok(MeasurementCrosstalkDemMode::ExactDeterministicLeakageAsDepolarizing) } + "averaged_hidden_leakage_as_depolarizing" + | "average_hidden_leakage_as_depolarizing" + | "state_averaged_leakage_as_depolarizing" + | "averaged_leakage_as_depolarizing" => { + Ok(MeasurementCrosstalkDemMode::AveragedHiddenLeakageAsDepolarizing) + } _ => Err(pyo3::exceptions::PyValueError::new_err( - "measurement_crosstalk_dem_mode must be 'omitted', 'exact_deterministic', or 'exact_deterministic_leakage_as_depolarizing'", + "measurement_crosstalk_dem_mode must be 'omitted', 'exact_deterministic', 'exact_deterministic_leakage_as_depolarizing', or 'averaged_hidden_leakage_as_depolarizing'", )), } } diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index e4b360901..67092fa82 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -852,12 +852,42 @@ def _runtime_idle_seconds_to_time_units(duration_seconds: float) -> Any: return TimeUnits(units) -def _replay_qis_trace_into_tick_circuit(operations: list[dict[str, Any]]) -> Any: +def _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology: str | None, +) -> str | None: + if measurement_crosstalk_topology in (None, "none", "runtime_payloads"): + return None + if measurement_crosstalk_topology == "global_from_measurements": + return measurement_crosstalk_topology + msg = ( + "measurement_crosstalk_topology must be None, 'runtime_payloads', " + "or 'global_from_measurements'" + ) + raise ValueError(msg) + + +def _should_add_global_measurement_crosstalk_payload( + measurement_crosstalk_topology: str | None, +) -> bool: + return ( + _validate_measurement_crosstalk_topology(measurement_crosstalk_topology) + == "global_from_measurements" + ) + + +def _replay_qis_trace_into_tick_circuit( + operations: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: """Replay traced QIS operations into a PECOS TickCircuit.""" import heapq from pecos_rslib.quantum import TickCircuit + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology + ) tick_circuit = TickCircuit() active_slots: dict[int, int] = {} free_slots: list[int] = [] @@ -994,11 +1024,21 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: ) elif op_name == "Measure": program_id, result_id = tuple_args(payload, op_name, 2) + measurement_qubit = mapped_slot(int(program_id), op_name) + if _should_add_global_measurement_crosstalk_payload( + measurement_crosstalk_topology + ): + # Global crosstalk payload qubits are guaranteed not to be + # affected; for measurement-induced global crosstalk this is + # exactly the measured payload. + tick_circuit.tick().add_gate( + "MeasCrosstalkGlobalPayload", [measurement_qubit] + ) # Stamp the QIS-provided result_id as the MeasId rather than # discarding it and letting assign_missing_meas_ids() invent # sequential ids (which would be wrong for non-sequential ids). tick.mz_with_ids( - [mapped_slot(int(program_id), op_name)], + [measurement_qubit], [int(result_id)], ) elif op_name == "Reset": @@ -1029,7 +1069,11 @@ def _gate_triples(qubits: list[int], gate_type: str) -> list[tuple[int, int, int return [(qubits[i], qubits[i + 1], qubits[i + 2]) for i in range(0, len(qubits), 3)] -def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> Any: +def _replay_lowered_qis_trace_into_tick_circuit( + chunks: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: """Replay lowered post-Selene ByteMessage gate batches into a TickCircuit. The lowered trace emits gates one at a time. We replay each into its own @@ -1043,6 +1087,9 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> """ from pecos_rslib.quantum import TickCircuit + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology + ) tick_circuit = TickCircuit() for chunk in chunks: @@ -1089,6 +1136,15 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> if len(meas_ids) != len(qubits): msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" raise ValueError(msg) + if _should_add_global_measurement_crosstalk_payload( + measurement_crosstalk_topology + ): + # Global crosstalk payload qubits are guaranteed not to be + # affected; for measurement-induced global crosstalk this is + # exactly the measured payload. + tick_circuit.tick().add_gate( + "MeasCrosstalkGlobalPayload", qubits + ) tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) elif gate_type == "MeasCrosstalkGlobalPayload": tick.add_gate("MeasCrosstalkGlobalPayload", qubits) @@ -1178,12 +1234,22 @@ def _reject_partially_lowered_trace(chunks: list[dict[str, Any]]) -> None: raise ValueError(msg) -def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> Any: +def _replay_qis_trace_chunks_into_tick_circuit( + chunks: list[dict[str, Any]], + *, + measurement_crosstalk_topology: str | None = None, +) -> Any: """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" + measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( + measurement_crosstalk_topology + ) if any(chunk.get("lowered_quantum_ops") for chunk in chunks): _reject_partially_lowered_trace(chunks) try: - return _replay_lowered_qis_trace_into_tick_circuit(chunks) + return _replay_lowered_qis_trace_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) except ValueError as exc: if "missing measurement_result_ids" not in str(exc): raise @@ -1196,7 +1262,10 @@ def _replay_qis_trace_chunks_into_tick_circuit(chunks: list[dict[str, Any]]) -> operations: list[dict[str, Any]] = [] for chunk in chunks: operations.extend(list(chunk.get("operations", []))) - return _replay_qis_trace_into_tick_circuit(operations) + return _replay_qis_trace_into_tick_circuit( + operations, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) def named_result_traces_from_operation_trace(chunks: list[dict[str, Any]]) -> list[dict[str, Any]]: @@ -1233,10 +1302,17 @@ def trace_guppy_into_tick_circuit_with_result_traces( *, seed: int = 0, runtime: object | None = None, + measurement_crosstalk_topology: str | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a Guppy/QIS program into a ``TickCircuit`` plus result-tag provenance.""" chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return _replay_qis_trace_chunks_into_tick_circuit(chunks), named_result_traces_from_operation_trace(chunks) + return ( + _replay_qis_trace_chunks_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ), + named_result_traces_from_operation_trace(chunks), + ) def trace_guppy_into_tick_circuit( @@ -1245,6 +1321,7 @@ def trace_guppy_into_tick_circuit( *, seed: int = 0, runtime: object | None = None, + measurement_crosstalk_topology: str | None = None, ) -> Any: """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. @@ -1275,7 +1352,10 @@ def trace_guppy_into_tick_circuit( caller supplies that. """ chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return _replay_qis_trace_chunks_into_tick_circuit(chunks) + return _replay_qis_trace_chunks_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) def _generate_traced_surface_tick_circuit( diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 4b76c3acd..308009ad7 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -245,6 +245,59 @@ def test_lowered_replay_preserves_measurement_crosstalk_payloads() -> None: assert _flat_gate_qubits(tc, "MeasCrosstalkGlobalPayload") == [[3, 4]] +def test_lowered_replay_can_add_global_crosstalk_payloads_from_measurements() -> None: + chunks = [ + { + "operations": [{"Quantum": {"Measure": [0, 7]}}], + "lowered_quantum_ops": [ + { + "gate_type": "MZ", + "qubits": [11, 12], + "angles": [], + "params": [], + "measurement_result_ids": [7, 8], + }, + ], + }, + ] + + without_payloads = _replay_lowered_qis_trace_into_tick_circuit(chunks) + with_payloads = _replay_lowered_qis_trace_into_tick_circuit( + chunks, + measurement_crosstalk_topology="global_from_measurements", + ) + + assert _flat_gate_qubits(without_payloads, "MeasCrosstalkGlobalPayload") == [] + assert _flat_gate_qubits(with_payloads, "MeasCrosstalkGlobalPayload") == [ + [11, 12] + ] + assert _flat_mz_ids(with_payloads) == [7, 8] + + +def test_raw_replay_can_add_global_crosstalk_payloads_from_measurements() -> None: + operations = [ + {"AllocateQubit": {"id": 0}}, + {"AllocateResult": {"id": 9}}, + {"Quantum": {"Measure": [0, 9]}}, + ] + + tc = _replay_qis_trace_into_tick_circuit( + operations, + measurement_crosstalk_topology="global_from_measurements", + ) + + assert _flat_gate_qubits(tc, "MeasCrosstalkGlobalPayload") == [[0]] + assert _flat_mz_ids(tc) == [9] + + +def test_replay_rejects_unknown_measurement_crosstalk_topology() -> None: + with pytest.raises(ValueError, match="measurement_crosstalk_topology"): + _replay_lowered_qis_trace_into_tick_circuit( + [], + measurement_crosstalk_topology="local_from_vibes", + ) + + def test_lowered_runtime_idles_can_drive_memory_noise_dem() -> None: from pecos.qec import DetectorErrorModel From d7c30557841cde6781c67b01f8f2dcaf960a6c4f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 15:45:11 -0600 Subject: [PATCH 147/388] Document SZZ DEM modeling assumptions --- .../surface/dem_decomposition_diagnostics.py | 21 +++++++-- .../graphlike_dem_projection_benchmark.py | 14 +++++- .../surface/native_dem_threshold_sweep.py | 3 +- .../surface/szz_circuit_quality_report.py | 14 ++++++ .../src/pecos/qec/surface/circuit_builder.py | 8 +++- .../src/pecos/qec/surface/decode.py | 44 ++++++++++++++----- 6 files changed, 87 insertions(+), 17 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 8c529ba13..7788d5552 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -2,7 +2,8 @@ The script keeps sampling fixed: each case samples once from the exact native influence-model DEM, then decodes the same detector events with several decoder -views of the model. This separates raw DEM generation from graphlike +views of the model. The graphlike views are lossy hyperedge-to-edge projections +for graph decoders, so this separates raw DEM generation from graphlike decomposition quality. Example: @@ -36,6 +37,9 @@ "terminal_decomp_pymatching", "terminal_decomp_pymatching_correlated", ] +# The staged SZZ device model treats Z/SZ/SZdg frame updates as noiseless +# virtual operations. CX-vs-SZZ p1 location comparisons include this assumption +# as well as the gate-basis difference. SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -139,7 +143,12 @@ def dem_effect_probabilities(dem_text: str) -> dict[str, float]: def compare_raw_dems(native_dem: str, stim_dem: str) -> RawDemComparison: - """Compare raw native and Stim DEMs after aggregating duplicate effects.""" + """Compare raw native and Stim DEMs after aggregating duplicate effects. + + ``only_native`` and ``only_stim`` report structural differences. Nonzero + probability deltas with zero structural differences reflect independent + probability-combination and serialization-rounding conventions. + """ native = dem_effect_probabilities(native_dem) stim = dem_effect_probabilities(stim_dem) native_keys = set(native) @@ -230,7 +239,8 @@ def terminal_graphlike_projection(raw_dem: str) -> str: but the rendered decomposition uses only detectors present in that raw effect. This avoids cancellation/path detectors introduced by graph-path decompositions while still producing graphlike components for matching - decoders. + decoders. The result is a lossy decoder-facing projection of hyperedge + correlations, not an exact raw DEM. """ coords = parse_detector_coords(raw_dem) annotation_lines: list[str] = [] @@ -716,6 +726,11 @@ def print_case(result: CaseResult) -> None: f"max_rel={raw.max_rel_probability_diff:.3e} " f"l1={raw.l1_probability_diff:.3e}", ) + if raw.only_native == 0 and raw.only_stim == 0 and raw.max_rel_probability_diff > 0: + print( + " raw structures match; probability deltas reflect " + "combination/rounding conventions.", + ) print("DEM stats:") print(" source errors psum sep hyper max_comp max_line pure_L") diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py index 6be23f129..c1bab4fe5 100644 --- a/examples/surface/graphlike_dem_projection_benchmark.py +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -3,7 +3,8 @@ This is a narrower companion to ``dem_decomposition_diagnostics.py``. It builds each DEM view once, samples once from the exact native influence model, then times correlated PyMatching construction and batch decoding for the selected -graphlike projections. +graphlike projections. Those graphlike projections are lossy decoder-facing +views of raw hyperedge mechanisms. """ from __future__ import annotations @@ -23,6 +24,7 @@ true_observable_flips, ) +# SZZ/SZZdg surface diagnostics model Z-frame gates as virtual and p1-free. SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -169,6 +171,16 @@ def build_case( f"max_rel={raw_comparison['max_rel_probability_diff']:.3e}", flush=True, ) + if ( + raw_comparison["only_native"] == 0 + and raw_comparison["only_stim"] == 0 + and raw_comparison["max_rel_probability_diff"] > 0 + ): + print( + "raw structures match; max_rel is a probability-combination/" + "rounding delta.", + flush=True, + ) sampler, elapsed = timed( "build native influence sampler", diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 567c665e0..511d81f7f 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -3741,7 +3741,8 @@ def _parse_args() -> argparse.Namespace: help=( "PECOS native DEM mode. Graph decoders such as PyMatching require " "native_decomposed or native_terminal_graphlike; raw-DEM decoders " - "should use native_full." + "should use native_full. The graphlike modes are lossy " + "hyperedge-to-edge decoder projections." ), ) parser.add_argument( diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py index fb55f78f2..493661e79 100644 --- a/examples/surface/szz_circuit_quality_report.py +++ b/examples/surface/szz_circuit_quality_report.py @@ -3,6 +3,9 @@ This script is intentionally descriptive rather than a threshold sweep. It counts the gate locations that drive the simple circuit-level noise model and, optionally, compares raw PECOS and Stim DEMs for the traced-QIS circuit path. +For SZZ/SZZdg cases the staged device model treats Z/SZ/SZdg frame updates as +noiseless virtual operations, so p1 location comparisons include that +assumption as well as the gate-basis change. Example: uv run python examples/surface/szz_circuit_quality_report.py \\ @@ -56,6 +59,7 @@ THREE_QUBIT_GATES = {"CCX", "CCZ"} SZZ_ABSTRACT_PREFIX_P1_FREE_GATES = {"Z", "SZ", "SZdg"} SZZ_Z_FRAME_P1_FREE_SOURCES = {"abstract_physical_prefix", "traced_qis"} +# SZZ/SZZdg surface diagnostics model Z-frame gates as virtual and p1-free. SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -422,6 +426,16 @@ def build_case( f"max_rel={comparison['max_rel_probability_diff']:.3e}", flush=True, ) + if ( + comparison["only_native"] == 0 + and comparison["only_stim"] == 0 + and comparison["max_rel_probability_diff"] > 0 + ): + print( + "raw structures match; max_rel is a probability-combination/" + "rounding delta.", + flush=True, + ) return CaseReport( distance=distance, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 980df977e..d81ec155e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2937,7 +2937,9 @@ def tick_circuit_to_stim( p1: Single-qubit error rate p1_gate_rates: Optional per-gate override for single-qubit error rates. Gate names are PECOS ``GateType`` names such as ``"Z"``, - ``"SZ"``, and ``"SZdg"``. + ``"SZ"``, and ``"SZdg"``. The surface SZZ reference path uses + this to mirror the staged PECOS device model where Z/SZ/SZdg frame + updates are virtual and p1-free. p2: Two-qubit error rate p_meas: Measurement error rate p_prep: Initialization error rate @@ -3473,7 +3475,9 @@ def generate_dem_from_tick_circuit_via_stim( tc: TickCircuit with detector/observable metadata p1: Single-qubit depolarizing error rate p1_gate_rates: Optional per-gate override for single-qubit - depolarizing rates. Gate names are PECOS ``GateType`` names. + depolarizing rates. Gate names are PECOS ``GateType`` names. The + surface SZZ reference path uses this to mirror the staged PECOS + device model where Z/SZ/SZdg frame updates are virtual and p1-free. p2: Two-qubit depolarizing error rate p_meas: Measurement error rate p_prep: Initialization (prep) error rate diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index af30a271a..21e9edb4e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -61,6 +61,8 @@ P1Weights = Mapping[str, float] | Sequence[tuple[str, float]] P2Weights = Mapping[str, float] | Sequence[tuple[str, float]] +# Native graphlike decompositions are decoder-facing projections of raw +# hyperedge mechanisms, not alternate exact DEM serializations. NativeDemDecomposition = Literal["source_graphlike", "terminal_graphlike"] CircuitLevelDemMode = Literal["native_full", "native_decomposed", "native_terminal_graphlike"] @@ -1649,6 +1651,14 @@ def _use_szz_physical_prefixes( def _szz_z_frame_p1_gate_rates(topology: _CachedNativeSurfaceTopology) -> dict[str, float] | None: + """Return virtual-Z frame p1 overrides for the staged SZZ device model. + + The current SZZ surface basis treats Z/SZ/SZdg frame updates as noiseless + virtual operations. That device-model assumption is keyed from + ``interaction_basis == "szz"`` in the staged API, so CX-vs-SZZ p1 location + comparisons include this free-Z modeling choice as well as gate basis + differences. + """ if not topology.z_frame_gate_p1_free: return None return {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} @@ -1794,6 +1804,9 @@ def _surface_native_topology( dag_circuit=dag, influence_map=influence_map, szz_physical_prefixes=szz_physical_prefixes, + # Staged SZZ device model: Z/SZ/SZdg frame updates are virtual and + # receive no p1 noise. Keep CX-vs-SZZ p1 location comparisons scoped to + # that asymmetric device assumption. z_frame_gate_p1_free=interaction_basis == "szz", pauli_frame_lookup=pauli_frame_lookup, detectors_json=detectors_json, @@ -2074,14 +2087,15 @@ def generate_circuit_level_dem_from_builder( basis: Memory basis ('X' or 'Z') decompose_errors: If True, return PECOS's native graphlike-decomposed DEM representation for graph decoders such as PyMatching. The - decomposition preserves correlated mechanism metadata with ``^`` - separators, but graph decoders may still approximate hyperedge - correlations. + decomposition is a lossy hyperedge-to-edge projection; it preserves + correlated mechanism metadata with ``^`` separators where available, + but it is not an exact raw DEM serialization. dem_decomposition: Which native graphlike projection to use when ``decompose_errors=True``. ``"source_graphlike"`` preserves the existing source-informed decomposition. ``"terminal_graphlike"`` groups raw mechanisms first, then pairs only detector terminals - present in each raw effect by coordinate distance. + present in each raw effect by coordinate distance. Both modes are + decoder-facing approximations of raw hyperedge mechanisms. ancilla_budget: Optional cap on simultaneously live ancillas. When provided below the total stabilizer count, the native DEM is built from the same batched ancilla-reuse circuit family used by Guppy. @@ -2097,7 +2111,11 @@ def generate_circuit_level_dem_from_builder( twirl: Optional Pauli-frame randomization layout. Canonical Guppy frame-output mode is normalized to the same abstract raw lookup and DEM topology. - interaction_basis: Surface-memory two-qubit interaction basis. + interaction_basis: Surface-memory two-qubit interaction basis. The + staged ``"szz"`` path currently assumes a virtual-Z device model: + Z/SZ/SZdg frame updates are p1-free. That is a device assumption + keyed from this basis selector, not a general claim about CX + hardware. Returns: DEM string in standard format @@ -2578,11 +2596,14 @@ def __init__( circuit_level_dem_mode: Which PECOS-native DEM representation to use when circuit-level DEMs are enabled. ``"native_full"`` preserves the current non-decomposed DEM output. ``"native_decomposed"`` - returns the source-informed graphlike output for graph decoders. + returns the source-informed graphlike projection for graph + decoders. ``"native_terminal_graphlike"`` first groups raw mechanisms, then projects each mechanism onto graphlike terminal components. - Decomposed modes are decoder-facing approximations of hyperedge - correlations, not exact raw DEMs. + Decomposed modes are lossy decoder-facing approximations of + hyperedge correlations, not exact raw DEMs. Correlated graph + decoding can use some preserved ``^`` metadata, but raw-DEM + decoders should use ``"native_full"``. circuit_level_dem_source: Which ideal circuit to analyze when building native circuit-level DEMs. ``"abstract"`` uses the high-level surface TickCircuit, while ``"traced_qis"`` traces @@ -2592,7 +2613,8 @@ def __init__( builds its DEM from the corresponding batched ancilla-reuse circuit instead of the default dedicated-ancilla circuit. interaction_basis: Surface-memory two-qubit interaction basis, - ``"cx"`` or ``"szz"``. + ``"cx"`` or ``"szz"``. The staged ``"szz"`` path currently + treats Z/SZ/SZdg frame updates as p1-free virtual operations. """ from pecos.qec.surface.circuit_builder import _normalize_interaction_basis @@ -3924,7 +3946,9 @@ def build_native_sampler( frame-output mode is normalized to the same abstract raw lookup. interaction_basis: Surface-memory two-qubit interaction basis. sampling_model: Which native sampling backend to use. ``"dem"`` - samples the generated decomposed DEM and is the default. + samples the generated source-graphlike DEM projection and is the + default; this is a decoder-facing approximation of raw hyperedges, + not the exact raw DEM. ``"influence_dem"`` uses the influence-map-based DemSampler with detector definitions. ``"mnm"`` is accepted for compatibility and maps to ``"influence_dem"``. From bd0b1cc283d943f706ca80a46f692d8772913d29 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 16:34:25 -0600 Subject: [PATCH 148/388] Align Guppy DEM reference normalization --- .../tests/qec/test_from_guppy_dem.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 20588dcb9..17d1ffc59 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -13,7 +13,10 @@ from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel from pecos.qec.surface import NoiseModel, SurfacePatch -from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch +from pecos.qec.surface.circuit_builder import ( + generate_tick_circuit_from_patch, + normalize_traced_qis_tick_circuit, +) from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, _copy_surface_tick_circuit_metadata, @@ -270,7 +273,7 @@ def test_lowered_replay_can_add_global_crosstalk_payloads_from_measurements() -> assert _flat_gate_qubits(without_payloads, "MeasCrosstalkGlobalPayload") == [] assert _flat_gate_qubits(with_payloads, "MeasCrosstalkGlobalPayload") == [ - [11, 12] + [11, 12], ] assert _flat_mz_ids(with_payloads) == [7, 8] @@ -535,8 +538,7 @@ def test_from_guppy_surface_code_is_byte_identical_to_reference() -> None: basis, circuit_source="traced_qis", ) - ref.lower_clifford_rotations() - ref.assign_missing_meas_ids() + normalize_traced_qis_tick_circuit(ref, context="from_guppy surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() got = DetectorErrorModel.from_guppy( make_surface_code(distance=3, num_rounds=3, basis=basis), @@ -562,8 +564,7 @@ def test_from_guppy_szz_surface_code_is_byte_identical_to_reference(distance: in circuit_source="traced_qis", interaction_basis="szz", ) - ref.lower_clifford_rotations() - ref.assign_missing_meas_ids() + normalize_traced_qis_tick_circuit(ref, context="from_guppy SZZ surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **p).to_string() got = DetectorErrorModel.from_guppy( make_surface_code( @@ -643,8 +644,7 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): ancilla_budget=budget, circuit_source="traced_qis", ) - ref.lower_clifford_rotations() - ref.assign_missing_meas_ids() + normalize_traced_qis_tick_circuit(ref, context="from_guppy constrained surface reference") ref_dem = DetectorErrorModel.from_circuit(ref, **noise).to_string() got = DetectorErrorModel.from_guppy( @@ -663,7 +663,7 @@ def _constrained_surface_via_guppy(*, d, basis, rounds, budget, noise): [ (3, "Z", 2, 1), # small-and-fast, minimum budget (one stabilizer/batch) (3, "X", 2, 2), # asymmetric basis, X/Z paired per batch - (9, "Z", 3, 17), # canonical high-distance stress + (5, "Z", 3, 5), # medium constrained case without high-distance DEM cost ], ) def test_from_guppy_constrained_surface_dem_byte_identical( From 43726334a61df88b06cfbf3d7a7f80e6266d1eef Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 17:17:52 -0600 Subject: [PATCH 149/388] Fix re-review ordering gap: resolve the QASM program before the neo rejections so a sourceless .classical()+neo reports the missing source, not the classical-override rejection --- python/pecos-rslib/src/sim.rs | 24 ++++++++++--------- .../tests/pecos/test_sim_stack_routing.py | 11 +++++++++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 6538966bc..53bdee2a3 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -1707,6 +1707,15 @@ fn run_qasm_via_facade( engine_builder }; + // A builder with no resolvable QASM program is invalid regardless of + // stack or classical/WASM configuration. Resolve the program first so + // this fundamental error is reported ahead of the neo-specific + // rejections below — otherwise a sourceless `.classical()` + neo would + // misreport the missing source as an unrouted classical override. + let program = engine_builder.get_program().ok_or_else(|| { + PyRuntimeError::new_err("No QASM source specified. Use .qasm() or .qasm_file()") + })?; + if builder.stack == Some(PySimStack::Neo) && engine_builder.has_wasm() { return Err(PyRuntimeError::new_err( "WASM foreign objects are not routed to the neo stack; \ @@ -1729,17 +1738,10 @@ fn run_qasm_via_facade( // selection (identical construction); a WASM-configured engine is // kept verbatim via the classical override, where the facade never // reads the program field. - let mut facade = match engine_builder.get_program() { - Some(program) if !engine_builder.has_wasm() => pecos::sim(program), - Some(program) => pecos::sim(program).classical(engine_builder), - None => { - // Error here rather than letting the classical-override - // placeholder reach the facade, which would misreport the - // problem as an unrouted .classical() configuration. - return Err(PyRuntimeError::new_err( - "No QASM source specified. Use .qasm() or .qasm_file()", - )); - } + let mut facade = if engine_builder.has_wasm() { + pecos::sim(program).classical(engine_builder) + } else { + pecos::sim(program) }; match builder.stack { diff --git a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py index 3b7855303..ecb2946ef 100644 --- a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py +++ b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py @@ -132,3 +132,14 @@ def test_neo_stack_rejects_explicit_classical_engine() -> None: # Same configuration is fine on the engines stack. results = sim(Qasm.from_string(X_MEASURE)).classical(explicit).stack("engines").run(5) assert len(list(results["c"])) == 5 + + +def test_missing_source_wins_over_classical_override_on_neo() -> None: + """A sourceless .classical() builder must report the missing source, + not the neo classical-override rejection, since the missing source is + the more fundamental error (re-review S2/S9 ordering gap).""" + from pecos_rslib import qasm_engine + + for stack in ["engines", "neo"]: + with pytest.raises(RuntimeError, match="No QASM source specified"): + qasm_engine().to_sim().classical(qasm_engine()).stack(stack).run(1) From 63328dd58c40d94e06f58de3e0ccb8b2fd2c1bc0 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 13 Jun 2026 17:25:56 -0600 Subject: [PATCH 150/388] Report DEM diagnostic progress and partial results --- .../surface/dem_decomposition_diagnostics.py | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 7788d5552..3daf08f39 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -764,6 +764,17 @@ def print_case(result: CaseResult) -> None: ) +def write_results_json(path: Path, results: list[CaseResult]) -> None: + """Write completed case results to JSON. + + Diagnostics can be expensive for larger distance/round combinations, so the + CLI writes after every finished case instead of only at process exit. + """ + payload = [asdict(result) for result in results] + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) @@ -799,11 +810,20 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() results: list[CaseResult] = [] + total_cases = len(args.distances) * len(args.bases) * len(args.interaction_bases) * len(args.p) + case_index = 0 for distance in args.distances: rounds = args.rounds if args.rounds is not None else distance for basis in args.bases: for interaction_basis in args.interaction_bases: for p in args.p: + case_index += 1 + label = ( + f"d={distance} r={rounds} basis={basis} " + f"basis2q={interaction_basis} p={p:g} shots={args.shots}" + ) + print(f"\n[{case_index}/{total_cases}] Starting {label}", flush=True) + start = time.perf_counter() result = run_case( distance=distance, rounds=rounds, @@ -818,12 +838,15 @@ def main() -> int: pair_analysis_max_effects=args.pair_analysis_max_effects, ) results.append(result) + elapsed = time.perf_counter() - start + print(f"[{case_index}/{total_cases}] Finished {label} in {elapsed:.3f}s", flush=True) print_case(result) + if args.save_json is not None: + write_results_json(args.save_json, results) + print(f"Wrote partial results to {args.save_json}", flush=True) if args.save_json is not None: - payload = [asdict(result) for result in results] - args.save_json.parent.mkdir(parents=True, exist_ok=True) - args.save_json.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") + write_results_json(args.save_json, results) print(f"\nWrote {args.save_json}") return 0 From f727699e6d93fe1d947a35bd0e1d04a468040745 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 17:27:02 -0600 Subject: [PATCH 151/388] Add surface sweep JSON comparison tool --- .../surface/compare_surface_sweep_json.py | 332 ++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 examples/surface/compare_surface_sweep_json.py diff --git a/examples/surface/compare_surface_sweep_json.py b/examples/surface/compare_surface_sweep_json.py new file mode 100644 index 000000000..9db10b020 --- /dev/null +++ b/examples/surface/compare_surface_sweep_json.py @@ -0,0 +1,332 @@ +"""Compare two surface-sweep JSON artifacts. + +This helper is intentionally lightweight: it reads the JSON files emitted by +``native_dem_threshold_sweep.py`` and prints Markdown tables with matched +logical-error rates, Wilson binomial intervals, ratios, differences, and +normal-approximation z-scores. It is useful for comparing CX vs SZZ/SZZdg +surface-code runs that used the same sweep grid. + +Example: + python examples/surface/compare_surface_sweep_json.py \\ + /tmp/pecos-szz-validation/native_cx_d357_r1_5k_results.json \\ + /tmp/pecos-szz-validation/native_szz_d357_r1_5k_results.json \\ + --left-label CX --right-label SZZ +""" + +from __future__ import annotations + +import argparse +import json +import math +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +Z_95 = 1.959963984540054 + + +@dataclass(frozen=True) +class Point: + backend: str + basis: str + distance: int + p: float + rounds: int + errors: int + shots: int + + @property + def rate(self) -> float: + return self.errors / self.shots if self.shots else math.nan + + +@dataclass(frozen=True) +class Comparison: + key: tuple[str, str, int, float, int] + left: Point + right: Point + + +def _as_point(raw: dict[str, Any]) -> Point: + return Point( + backend=str(raw.get("backend", "")), + basis=str(raw["basis"]).upper(), + distance=int(raw["distance"]), + p=float(raw["physical_error_rate"]), + rounds=int(raw["total_rounds"]), + errors=int(raw["num_logical_errors"]), + shots=int(raw["num_shots"]), + ) + + +def load_points(path: Path) -> dict[tuple[str, str, int, float, int], Point]: + data = json.loads(path.read_text()) + points = data.get("points") + if not isinstance(points, list): + msg = f"{path} does not contain a list-valued 'points' field" + raise TypeError(msg) + + loaded: dict[tuple[str, str, int, float, int], Point] = {} + for raw in points: + point = _as_point(raw) + key = (point.backend, point.basis, point.distance, point.p, point.rounds) + if key in loaded: + msg = f"{path} contains duplicate point key {key}" + raise ValueError(msg) + loaded[key] = point + return loaded + + +def wilson_interval(errors: int, shots: int, z: float = Z_95) -> tuple[float, float]: + if shots <= 0: + return math.nan, math.nan + phat = errors / shots + denom = 1.0 + z * z / shots + center = (phat + z * z / (2.0 * shots)) / denom + half = z * math.sqrt((phat * (1.0 - phat) + z * z / (4.0 * shots)) / shots) / denom + return max(0.0, center - half), min(1.0, center + half) + + +def standard_error(errors: int, shots: int) -> float: + if shots <= 0: + return math.nan + rate = errors / shots + return math.sqrt(rate * (1.0 - rate) / shots) + + +def z_score(left: Point, right: Point) -> float: + denom = math.sqrt( + standard_error(left.errors, left.shots) ** 2 + + standard_error(right.errors, right.shots) ** 2, + ) + if denom == 0.0: + return math.nan + return (right.rate - left.rate) / denom + + +def ratio(left: Point, right: Point) -> float: + if left.rate == 0.0: + return math.inf if right.rate > 0.0 else 1.0 + return right.rate / left.rate + + +def format_rate(point: Point, *, include_ci: bool) -> str: + base = f"{point.rate:.4g} ({point.errors}/{point.shots})" + if not include_ci: + return base + low, high = wilson_interval(point.errors, point.shots) + return f"{base} [{low:.4g}, {high:.4g}]" + + +def format_float(value: float, precision: int = 2) -> str: + if math.isnan(value): + return "nan" + if math.isinf(value): + return "inf" + return f"{value:.{precision}f}" + + +def matched_comparisons( + left: dict[tuple[str, str, int, float, int], Point], + right: dict[tuple[str, str, int, float, int], Point], +) -> list[Comparison]: + return [Comparison(key, left[key], right[key]) for key in sorted(set(left) & set(right))] + + +def aggregate_points(points: list[Point]) -> Point: + if not points: + msg = "cannot aggregate an empty point list" + raise ValueError(msg) + first = points[0] + return Point( + backend=first.backend, + basis=first.basis, + distance=first.distance, + p=math.nan, + rounds=first.rounds, + errors=sum(point.errors for point in points), + shots=sum(point.shots for point in points), + ) + + +def emit_point_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, +) -> str: + lines = [ + "## Matched Points", + "", + f"| backend | basis | d | rounds | p | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|---|--------|---|------|------|-------|------|---|", + ] + for comparison in comparisons: + backend, basis, distance, p, rounds = comparison.key + left = comparison.left + right = comparison.right + lines.append( + "| " + f"{backend} | {basis} | {distance} | {rounds} | {p:g} | " + f"{format_rate(left, include_ci=include_ci)} | " + f"{format_rate(right, include_ci=include_ci)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def emit_aggregate_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, +) -> str: + grouped: dict[tuple[str, str, int, int], list[Comparison]] = defaultdict(list) + for comparison in comparisons: + backend, basis, distance, _p, rounds = comparison.key + grouped[(backend, basis, distance, rounds)].append(comparison) + + lines = [ + "## Aggregate Over Physical Error Rates", + "", + f"| backend | basis | d | rounds | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|---|--------|------|------|-------|------|---|", + ] + for key in sorted(grouped): + backend, basis, distance, rounds = key + group = grouped[key] + left = aggregate_points([comparison.left for comparison in group]) + right = aggregate_points([comparison.right for comparison in group]) + lines.append( + "| " + f"{backend} | {basis} | {distance} | {rounds} | " + f"{format_rate(left, include_ci=include_ci)} | " + f"{format_rate(right, include_ci=include_ci)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def emit_overall_table( + comparisons: list[Comparison], + *, + left_label: str, + right_label: str, + include_ci: bool, +) -> str: + grouped: dict[tuple[str, str], list[Comparison]] = defaultdict(list) + for comparison in comparisons: + backend, basis, _distance, _p, _rounds = comparison.key + grouped[(backend, basis)].append(comparison) + + lines = [ + "## Overall By Backend And Basis", + "", + f"| backend | basis | {left_label} | {right_label} | ratio | diff | z |", + "|---------|-------|------|------|-------|------|---|", + ] + for key in sorted(grouped): + backend, basis = key + group = grouped[key] + left = aggregate_points([comparison.left for comparison in group]) + right = aggregate_points([comparison.right for comparison in group]) + lines.append( + "| " + f"{backend} | {basis} | " + f"{format_rate(left, include_ci=include_ci)} | " + f"{format_rate(right, include_ci=include_ci)} | " + f"{format_float(ratio(left, right))} | " + f"{right.rate - left.rate:+.4g} | " + f"{format_float(z_score(left, right))} |", + ) + return "\n".join(lines) + + +def build_report( + left_path: Path, + right_path: Path, + *, + left_label: str, + right_label: str, + include_ci: bool, +) -> str: + left = load_points(left_path) + right = load_points(right_path) + comparisons = matched_comparisons(left, right) + if not comparisons: + msg = "No common (backend, basis, distance, p, rounds) points found" + raise ValueError(msg) + + left_only = len(set(left) - set(right)) + right_only = len(set(right) - set(left)) + lines = [ + f"# Sweep Comparison: {left_label} vs {right_label}", + "", + f"- left: `{left_path}`", + f"- right: `{right_path}`", + f"- matched points: {len(comparisons)}", + f"- left-only points: {left_only}", + f"- right-only points: {right_only}", + "", + emit_point_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + ), + "", + emit_aggregate_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + ), + "", + emit_overall_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + ), + ] + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("left", type=Path, help="First native_dem_threshold_sweep.py JSON artifact.") + parser.add_argument("right", type=Path, help="Second native_dem_threshold_sweep.py JSON artifact.") + parser.add_argument("--left-label", default="left", help="Label for the first artifact.") + parser.add_argument("--right-label", default="right", help="Label for the second artifact.") + parser.add_argument("--no-ci", action="store_true", help="Omit Wilson 95% binomial intervals.") + parser.add_argument("--output", type=Path, default=None, help="Optional Markdown output path.") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + report = build_report( + args.left, + args.right, + left_label=args.left_label, + right_label=args.right_label, + include_ci=not args.no_ci, + ) + if args.output is None: + print(report) + else: + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(report + "\n") + print(f"Wrote comparison report to {args.output}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From e01005ee70c3a9f57724772d5c47ce992970fdb2 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 18:34:10 -0600 Subject: [PATCH 152/388] Improve graphlike surface decoder validation --- .../surface/compare_surface_sweep_json.py | 110 +++++++++++--- .../surface/native_dem_threshold_sweep.py | 45 +++--- .../src/fault_tolerance_bindings.rs | 16 +- .../src/pecos/qec/surface/decode.py | 115 ++++++++++----- .../pecos/unit/test_surface_sweep_compare.py | 137 ++++++++++++++++++ .../tests/qec/surface/test_surface_decoder.py | 128 ++++++++++++++++ 6 files changed, 462 insertions(+), 89 deletions(-) create mode 100644 python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py diff --git a/examples/surface/compare_surface_sweep_json.py b/examples/surface/compare_surface_sweep_json.py index 9db10b020..5bbf8b137 100644 --- a/examples/surface/compare_surface_sweep_json.py +++ b/examples/surface/compare_surface_sweep_json.py @@ -1,10 +1,10 @@ """Compare two surface-sweep JSON artifacts. -This helper is intentionally lightweight: it reads the JSON files emitted by -``native_dem_threshold_sweep.py`` and prints Markdown tables with matched -logical-error rates, Wilson binomial intervals, ratios, differences, and -normal-approximation z-scores. It is useful for comparing CX vs SZZ/SZZdg -surface-code runs that used the same sweep grid. +This helper reads the JSON files emitted by ``native_dem_threshold_sweep.py`` +and prints Markdown tables with matched logical-error rates, binomial +intervals, ratios, differences, and descriptive normal-approximation z-scores. +It is useful for comparing CX vs SZZ/SZZdg surface-code runs that used the same +sweep grid. Example: python examples/surface/compare_surface_sweep_json.py \\ @@ -24,6 +24,7 @@ from typing import Any Z_95 = 1.959963984540054 +CI_METHODS = {"jeffreys", "wilson"} @dataclass(frozen=True) @@ -88,6 +89,31 @@ def wilson_interval(errors: int, shots: int, z: float = Z_95) -> tuple[float, fl return max(0.0, center - half), min(1.0, center + half) +def jeffreys_interval(errors: int, shots: int, confidence: float = 0.95) -> tuple[float, float]: + """Return a Jeffreys equal-tailed interval for one binomial proportion.""" + if shots <= 0: + return math.nan, math.nan + from scipy.stats import beta + + alpha = (1.0 - confidence) / 2.0 + lower = 0.0 if errors == 0 else float(beta.ppf(alpha, errors + 0.5, shots - errors + 0.5)) + upper = ( + 1.0 + if errors == shots + else float(beta.ppf(1.0 - alpha, errors + 0.5, shots - errors + 0.5)) + ) + return lower, upper + + +def binomial_interval(errors: int, shots: int, method: str) -> tuple[float, float]: + if method == "jeffreys": + return jeffreys_interval(errors, shots) + if method == "wilson": + return wilson_interval(errors, shots) + msg = f"unknown interval method {method!r}" + raise ValueError(msg) + + def standard_error(errors: int, shots: int) -> float: if shots <= 0: return math.nan @@ -111,11 +137,11 @@ def ratio(left: Point, right: Point) -> float: return right.rate / left.rate -def format_rate(point: Point, *, include_ci: bool) -> str: +def format_rate(point: Point, *, include_ci: bool, interval_method: str) -> str: base = f"{point.rate:.4g} ({point.errors}/{point.shots})" if not include_ci: return base - low, high = wilson_interval(point.errors, point.shots) + low, high = binomial_interval(point.errors, point.shots, interval_method) return f"{base} [{low:.4g}, {high:.4g}]" @@ -156,6 +182,7 @@ def emit_point_table( left_label: str, right_label: str, include_ci: bool, + interval_method: str, ) -> str: lines = [ "## Matched Points", @@ -170,8 +197,8 @@ def emit_point_table( lines.append( "| " f"{backend} | {basis} | {distance} | {rounds} | {p:g} | " - f"{format_rate(left, include_ci=include_ci)} | " - f"{format_rate(right, include_ci=include_ci)} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " f"{format_float(ratio(left, right))} | " f"{right.rate - left.rate:+.4g} | " f"{format_float(z_score(left, right))} |", @@ -185,6 +212,7 @@ def emit_aggregate_table( left_label: str, right_label: str, include_ci: bool, + interval_method: str, ) -> str: grouped: dict[tuple[str, str, int, int], list[Comparison]] = defaultdict(list) for comparison in comparisons: @@ -205,8 +233,8 @@ def emit_aggregate_table( lines.append( "| " f"{backend} | {basis} | {distance} | {rounds} | " - f"{format_rate(left, include_ci=include_ci)} | " - f"{format_rate(right, include_ci=include_ci)} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " f"{format_float(ratio(left, right))} | " f"{right.rate - left.rate:+.4g} | " f"{format_float(z_score(left, right))} |", @@ -214,12 +242,13 @@ def emit_aggregate_table( return "\n".join(lines) -def emit_overall_table( +def emit_cross_distance_pooled_table( comparisons: list[Comparison], *, left_label: str, right_label: str, include_ci: bool, + interval_method: str, ) -> str: grouped: dict[tuple[str, str], list[Comparison]] = defaultdict(list) for comparison in comparisons: @@ -227,7 +256,11 @@ def emit_overall_table( grouped[(backend, basis)].append(comparison) lines = [ - "## Overall By Backend And Basis", + "## Pooled Across Distances By Backend And Basis", + "", + "This table intentionally pools across distances. It is useful as a rough", + "event-count summary, but it is dominated by lower-distance points and is", + "not a scaling or threshold statement.", "", f"| backend | basis | {left_label} | {right_label} | ratio | diff | z |", "|---------|-------|------|------|-------|------|---|", @@ -240,8 +273,8 @@ def emit_overall_table( lines.append( "| " f"{backend} | {basis} | " - f"{format_rate(left, include_ci=include_ci)} | " - f"{format_rate(right, include_ci=include_ci)} | " + f"{format_rate(left, include_ci=include_ci, interval_method=interval_method)} | " + f"{format_rate(right, include_ci=include_ci, interval_method=interval_method)} | " f"{format_float(ratio(left, right))} | " f"{right.rate - left.rate:+.4g} | " f"{format_float(z_score(left, right))} |", @@ -256,6 +289,8 @@ def build_report( left_label: str, right_label: str, include_ci: bool, + interval_method: str, + include_cross_distance_pooled: bool, ) -> str: left = load_points(left_path) right = load_points(right_path) @@ -274,12 +309,18 @@ def build_report( f"- matched points: {len(comparisons)}", f"- left-only points: {left_only}", f"- right-only points: {right_only}", + f"- intervals: {'none' if not include_ci else f'{interval_method} 95%'}", + "- z-scores: descriptive unpooled Wald z-scores, uncorrected for multiple comparisons", + "- read low-count and zero-count rows cautiously", + "- cross-distance pooled totals are omitted by default; use " + "`--include-cross-distance-pooled` for a rough event-count summary", "", emit_point_table( comparisons, left_label=left_label, right_label=right_label, include_ci=include_ci, + interval_method=interval_method, ), "", emit_aggregate_table( @@ -287,15 +328,22 @@ def build_report( left_label=left_label, right_label=right_label, include_ci=include_ci, - ), - "", - emit_overall_table( - comparisons, - left_label=left_label, - right_label=right_label, - include_ci=include_ci, + interval_method=interval_method, ), ] + if include_cross_distance_pooled: + lines.extend( + [ + "", + emit_cross_distance_pooled_table( + comparisons, + left_label=left_label, + right_label=right_label, + include_ci=include_ci, + interval_method=interval_method, + ), + ], + ) return "\n".join(lines) @@ -305,7 +353,21 @@ def parse_args() -> argparse.Namespace: parser.add_argument("right", type=Path, help="Second native_dem_threshold_sweep.py JSON artifact.") parser.add_argument("--left-label", default="left", help="Label for the first artifact.") parser.add_argument("--right-label", default="right", help="Label for the second artifact.") - parser.add_argument("--no-ci", action="store_true", help="Omit Wilson 95% binomial intervals.") + parser.add_argument( + "--ci", + choices=sorted(CI_METHODS), + default="jeffreys", + help="Binomial interval method to report when intervals are enabled.", + ) + parser.add_argument("--no-ci", action="store_true", help="Omit 95% binomial intervals.") + parser.add_argument( + "--include-cross-distance-pooled", + action="store_true", + help=( + "Also emit totals pooled across all distances by backend and basis. " + "This is not a scaling or threshold summary." + ), + ) parser.add_argument("--output", type=Path, default=None, help="Optional Markdown output path.") return parser.parse_args() @@ -318,6 +380,8 @@ def main() -> int: left_label=args.left_label, right_label=args.right_label, include_ci=not args.no_ci, + interval_method=args.ci, + include_cross_distance_pooled=args.include_cross_distance_pooled, ) if args.output is None: print(report) diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 511d81f7f..9ce2b9124 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -563,10 +563,10 @@ def _noise_model_description(args: argparse.Namespace) -> str: def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int = 5) -> object: """Create a DEM-level decoder from a DEM string. - Supports MWPM decoders (pymatching, pymatching_correlated), search - decoders (tesseract), and check-matrix decoders (bp_osd, bp_lsd, - union_find, relay_bp, min_sum_bp) via DemAwareDecoder which extracts the - check matrix from the DEM. + Supports MWPM decoders (pymatching, pymatching_correlated, + pymatching_uncorrelated), search decoders (tesseract), and check-matrix + decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) via + DemAwareDecoder which extracts the check matrix from the DEM. """ if decoder_type == "tesseract": from pecos.decoders import TesseractDecoder @@ -582,7 +582,7 @@ def _create_dem_decoder(decoder_type: str, dem_str: str, *, tesseract_beam: int from pecos.decoders import PyMatchingDecoder - if decoder_type == "pymatching_correlated": + if decoder_type in {"pymatching", "pymatching_correlated"}: return PyMatchingDecoder.from_dem_with_correlations(dem_str, enable_correlations=True) return PyMatchingDecoder.from_dem(dem_str) @@ -683,7 +683,7 @@ def _decoder_runtime( patch, num_rounds=total_rounds, noise=noise, - decoder_type="pymatching" if decoder_type == "pymatching_correlated" else decoder_type, + decoder_type=decoder_type, use_circuit_level_dem=True, circuit_level_dem_mode=dem_mode, circuit_level_dem_source=native_circuit_source, @@ -702,7 +702,7 @@ def _decoder_runtime( def _native_sampler_model_for_decoder(decoder_type: str) -> str: """Choose the native sampler model paired with a DEM decoder.""" - if decoder_type in {"pymatching", "pymatching_correlated"}: + if decoder_type in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: return "dem" return "influence_dem" @@ -750,11 +750,11 @@ def _native_sampler_runtime( interaction_basis=interaction_basis, sampling_model=_native_sampler_model_for_decoder(decoder_type), ) - # PyMatching uses graphlike decomposed DEMs. The correlated variant also - # consumes decomposition separators as correlation metadata. Tesseract and - # check-matrix decoders handle hyperedges natively and should get the full - # DEM. - if decoder_type in {"pymatching", "pymatching_correlated"}: + # PyMatching uses graphlike decomposed DEMs. The production default and the + # explicit correlated alias consume decomposition separators as correlation + # metadata. Tesseract and check-matrix decoders handle hyperedges natively + # and should get the full DEM. + if decoder_type in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: dem_str = runtime.decoder.get_dem(basis.upper(), circuit_level=True) else: dem_str = generate_circuit_level_dem_from_builder( @@ -1303,33 +1303,28 @@ def _run_memory_point( ) sampler = native_runtime.sampler dem_decoder = native_runtime.dem_decoder - detection_events, observable_flips = sampler.sample(num_shots=num_shots, seed=seed) num_raw_errors = None # Fast path: sample+decode entirely in Rust via ObservableDecoder trait. # The DemSampler keeps all per-shot data in Rust -- nothing crosses to Python. dem_str_for_rust = native_runtime.dem_str rust_sampler = getattr(sampler, "sampler", None) - use_rust_sample_decode = ( - decoder_type != "pymatching_correlated" - and dem_str_for_rust - and rust_sampler - and hasattr(rust_sampler, "sample_decode_count") - ) + rust_decoder_type = "pymatching" if decoder_type == "pymatching_correlated" else decoder_type + use_rust_sample_decode = dem_str_for_rust and rust_sampler and hasattr(rust_sampler, "sample_decode_count") if use_rust_sample_decode: # Use parallel path for slow decoders (Tesseract, BP+OSD, etc.) - if decoder_type != "pymatching" and hasattr(rust_sampler, "sample_decode_count_parallel"): + if rust_decoder_type != "pymatching" and hasattr(rust_sampler, "sample_decode_count_parallel"): num_logical_errors = rust_sampler.sample_decode_count_parallel( dem_str_for_rust, num_shots, - decoder_type, + rust_decoder_type, seed, ) else: num_logical_errors = rust_sampler.sample_decode_count( dem_str_for_rust, num_shots, - decoder_type, + rust_decoder_type, seed, ) else: @@ -3751,6 +3746,7 @@ def _parse_args() -> argparse.Namespace: choices=[ "pymatching", "pymatching_correlated", + "pymatching_uncorrelated", "tesseract", "bp_osd", "bp_lsd", @@ -3762,8 +3758,9 @@ def _parse_args() -> argparse.Namespace: help=( "Decoder(s) for circuit-level DEM decoding. Specify multiple to " "compare them side-by-side in plots and reports. Default: pymatching. " - "pymatching_correlated enables PyMatching's DEM-correlation mode for " - "decomposed errors. " + "pymatching and pymatching_correlated enable PyMatching's DEM-correlation " + "mode for decomposed errors; pymatching_uncorrelated keeps the plain " + "graphlike baseline for A/B diagnostics. " "Check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) " "extract a check matrix from the DEM automatically." ), diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 228e047f0..3b607e317 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2217,7 +2217,7 @@ fn create_observable_decoder( }; match decoder_type { - "pymatching" => { + "pymatching" | "pymatching_correlated" => { // Default: correlated matching enabled (exploits X-Z correlations // from depolarizing noise for ~20% fewer errors at d>=5). let d = PyMatchingDecoder::from_dem_with_correlations(dem, true) @@ -3454,8 +3454,9 @@ impl PySampleBatch { /// /// Args: /// dem: DEM string in standard DEM text format for the decoder. - /// `decoder_type`: "pymatching", "tesseract", "`bp_osd`", "`bp_lsd`", "`union_find`", - /// "`relay_bp`", or "`min_sum_bp`". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", "`bp_osd`", + /// "`bp_lsd`", "`union_find`", "`relay_bp`", or "`min_sum_bp`". /// /// Returns: /// Number of logical errors. @@ -4428,7 +4429,9 @@ impl PyDemSampler { /// Args: /// dem: DEM string in standard DEM text format for the decoder. /// `num_shots`: Number of shots to sample and decode. - /// `decoder_type`: "pymatching" or "tesseract". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", or another + /// decoder accepted by `create_observable_decoder`. /// seed: Optional random seed for reproducibility. /// /// Returns: @@ -4473,7 +4476,9 @@ impl PyDemSampler { /// Args: /// dem: DEM string in standard DEM text format for the decoder. /// `num_shots`: Number of shots to sample and decode. - /// `decoder_type`: "pymatching", "tesseract", "`bp_osd`", "`bp_lsd`", or "`union_find`". + /// `decoder_type`: "pymatching", "`pymatching_correlated`", + /// "`pymatching_uncorrelated`", "tesseract", "`bp_osd`", + /// "`bp_lsd`", or "`union_find`". /// seed: Optional base random seed. Each thread gets seed + `thread_id`. /// `num_workers`: Number of parallel workers (default: number of CPUs). /// @@ -6420,6 +6425,7 @@ fn decoder_dem_requirement(decoder_type: &str) -> PyResult { let base = decoder_type.split(':').next().unwrap_or(decoder_type); match base { "pymatching" + | "pymatching_correlated" | "pymatching_uncorrelated" | "fusion_blossom" | "fusion_blossom_serial" diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 70afa1a55..68df785cb 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -80,6 +80,8 @@ class DecoderType(str, Enum): """Available decoder backends.""" PYMATCHING = "pymatching" + PYMATCHING_CORRELATED = "pymatching_correlated" + PYMATCHING_UNCORRELATED = "pymatching_uncorrelated" FUSION_BLOSSOM = "fusion_blossom" BP_OSD = "bp_osd" BP_LSD = "bp_lsd" @@ -87,6 +89,20 @@ class DecoderType(str, Enum): TESSERACT = "tesseract" +DEM_DECODER_TYPES = { + DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, + DecoderType.TESSERACT, +} + +PYMATCHING_DECODER_TYPES = { + DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, +} + + @dataclass class NoiseModel: """Circuit-level noise parameters for QEC simulation. @@ -918,7 +934,7 @@ def _replay_qis_trace_into_tick_circuit( from pecos_rslib.quantum import TickCircuit measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( - measurement_crosstalk_topology + measurement_crosstalk_topology, ) tick_circuit = TickCircuit() active_slots: dict[int, int] = {} @@ -1058,13 +1074,14 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: program_id, result_id = tuple_args(payload, op_name, 2) measurement_qubit = mapped_slot(int(program_id), op_name) if _should_add_global_measurement_crosstalk_payload( - measurement_crosstalk_topology + measurement_crosstalk_topology, ): # Global crosstalk payload qubits are guaranteed not to be # affected; for measurement-induced global crosstalk this is # exactly the measured payload. tick_circuit.tick().add_gate( - "MeasCrosstalkGlobalPayload", [measurement_qubit] + "MeasCrosstalkGlobalPayload", + [measurement_qubit], ) # Stamp the QIS-provided result_id as the MeasId rather than # discarding it and letting assign_missing_meas_ids() invent @@ -1120,7 +1137,7 @@ def _replay_lowered_qis_trace_into_tick_circuit( from pecos_rslib.quantum import TickCircuit measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( - measurement_crosstalk_topology + measurement_crosstalk_topology, ) tick_circuit = TickCircuit() @@ -1169,13 +1186,14 @@ def _replay_lowered_qis_trace_into_tick_circuit( msg = f"Lowered MZ gate carries {len(meas_ids)} measurement_result_ids for {len(qubits)} qubit(s)" raise ValueError(msg) if _should_add_global_measurement_crosstalk_payload( - measurement_crosstalk_topology + measurement_crosstalk_topology, ): # Global crosstalk payload qubits are guaranteed not to be # affected; for measurement-induced global crosstalk this is # exactly the measured payload. tick_circuit.tick().add_gate( - "MeasCrosstalkGlobalPayload", qubits + "MeasCrosstalkGlobalPayload", + qubits, ) tick.mz_with_ids(qubits, [int(meas_id) for meas_id in meas_ids]) elif gate_type == "MeasCrosstalkGlobalPayload": @@ -1273,7 +1291,7 @@ def _replay_qis_trace_chunks_into_tick_circuit( ) -> Any: """Replay captured QIS operation trace chunks into a ``TickCircuit``.""" measurement_crosstalk_topology = _validate_measurement_crosstalk_topology( - measurement_crosstalk_topology + measurement_crosstalk_topology, ) if any(chunk.get("lowered_quantum_ops") for chunk in chunks): _reject_partially_lowered_trace(chunks) @@ -1378,6 +1396,8 @@ def trace_guppy_into_tick_circuit( runtime: Optional Selene runtime selector/plugin. ``None`` selects the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. + measurement_crosstalk_topology: Optional measurement-crosstalk replay + mode for stamping global measurement-crosstalk payload markers. Returns: A ``TickCircuit`` with no detector/observable metadata attached; the @@ -2642,6 +2662,8 @@ def __init__( noise: NoiseModel | None = None, decoder_type: Literal[ "pymatching", + "pymatching_correlated", + "pymatching_uncorrelated", "fusion_blossom", "bp_osd", "bp_lsd", @@ -2662,7 +2684,13 @@ def __init__( num_rounds: Number of syndrome extraction rounds noise: Noise model for edge weights (defaults to uniform) decoder_type: Decoder backend to use: - - "pymatching": Fast C++ MWPM decoder (default) + - "pymatching": Fast C++ MWPM decoder (default). For + decomposed circuit-level DEMs, this enables PyMatching's + DEM-correlation metadata when available. + - "pymatching_correlated": Explicit alias for the circuit-level + correlated PyMatching path. + - "pymatching_uncorrelated": Plain graphlike PyMatching path, + useful for A/B diagnostics. - "fusion_blossom": Pure Rust MWPM decoder - "bp_osd": Belief Propagation + OSD - "bp_lsd": Belief Propagation + LSD @@ -2800,10 +2828,7 @@ def _get_z_decoder(self) -> Any: """Get or create decoder for Z-basis memory (decodes Z syndromes for X errors).""" if self._z_decoder is None: # For PyMatching and Tesseract with circuit-level DEMs, use DEM directly - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: self._z_decoder = self._create_decoder_from_dem("Z") else: self._z_decoder = self._create_decoder(self._get_z_check_matrix()) @@ -2821,8 +2846,12 @@ def _create_decoder(self, H: NDArray[np.uint8]) -> Any: data_weight = self._compute_weight(p_data) meas_weight = self._compute_weight(p_meas) - if self.decoder_type == DecoderType.PYMATCHING: - from pecos_rslib.decoders import CheckMatrix, PyMatchingDecoder + if self.decoder_type in PYMATCHING_DECODER_TYPES: + from pecos.decoders import CheckMatrix, PyMatchingDecoder + + if self.decoder_type == DecoderType.PYMATCHING_CORRELATED: + msg = "pymatching_correlated requires circuit-level DEM decoding" + raise ValueError(msg) weights = [data_weight] * num_data check_matrix = CheckMatrix.from_dense(H.tolist()).with_weights(weights) @@ -2836,7 +2865,7 @@ def _create_decoder(self, H: NDArray[np.uint8]) -> Any: ) if self.decoder_type == DecoderType.FUSION_BLOSSOM: - from pecos_rslib.decoders import FusionBlossomDecoder + from pecos.decoders import FusionBlossomDecoder # FusionBlossom uses check matrix directly # For multi-round, we need to construct the space-time graph manually @@ -2887,13 +2916,22 @@ def _create_decoder_from_dem(self, basis: str) -> Any: else: self._x_dem = dem - if self.decoder_type == DecoderType.PYMATCHING: - from pecos_rslib.decoders import PyMatchingDecoder + if self.decoder_type in PYMATCHING_DECODER_TYPES: + from pecos.decoders import PyMatchingDecoder + + if self.circuit_level_dem_mode == "native_full": + if self.decoder_type == DecoderType.PYMATCHING_CORRELATED: + msg = "pymatching_correlated requires a decomposed circuit-level DEM mode" + raise ValueError(msg) + return PyMatchingDecoder.from_dem(dem) + + if self.decoder_type == DecoderType.PYMATCHING_UNCORRELATED: + return PyMatchingDecoder.from_dem(dem) - return PyMatchingDecoder.from_dem(dem) + return PyMatchingDecoder.from_dem_with_correlations(dem, enable_correlations=True) if self.decoder_type == DecoderType.TESSERACT: - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder # Tesseract's remove_zero_probability_errors() doesn't handle # DEM_LOGICAL_OBSERVABLE instructions. Filter them out - the @@ -2911,7 +2949,7 @@ def _create_fusion_blossom_spacetime( meas_weight: float, ) -> Any: """Create FusionBlossom decoder with space-time matching graph.""" - from pecos_rslib.decoders import FusionBlossomDecoder + from pecos.decoders import FusionBlossomDecoder num_stab = H.shape[0] num_data = H.shape[1] @@ -2971,12 +3009,12 @@ def _create_ldpc_decoder( p_data: float, ) -> Any: """Create LDPC decoder (BP+OSD, BP+LSD, or UnionFind).""" - from pecos_rslib.decoders import SparseMatrix + from pecos.decoders import SparseMatrix sparse_H = SparseMatrix(H.tolist()) if self.decoder_type == DecoderType.BP_OSD: - from pecos_rslib.decoders import BpOsdBuilder + from pecos.decoders import BpOsdBuilder return ( BpOsdBuilder(sparse_H, error_rate=p_data) @@ -2988,12 +3026,12 @@ def _create_ldpc_decoder( ) if self.decoder_type == DecoderType.BP_LSD: - from pecos_rslib.decoders import BpLsdBuilder + from pecos.decoders import BpLsdBuilder return BpLsdBuilder(sparse_H, error_rate=p_data).max_iter(100).bp_method("product_sum").lsd_order(0).build() if self.decoder_type == DecoderType.UNION_FIND: - from pecos_rslib.decoders import UnionFindBuilder + from pecos.decoders import UnionFindBuilder return UnionFindBuilder(sparse_H).method("inversion").build() @@ -3007,7 +3045,7 @@ def _create_tesseract_decoder( _p_meas: float, ) -> Any: """Create Tesseract decoder from check matrix by generating DEM.""" - from pecos_rslib.decoders import TesseractDecoder + from pecos.decoders import TesseractDecoder # Determine stabilizer type based on check matrix shape z_check = self._get_z_check_matrix() @@ -3073,10 +3111,7 @@ def _get_x_decoder(self) -> Any: """Get or create decoder for X-basis memory (decodes X syndromes for Z errors).""" if self._x_decoder is None: # For PyMatching and Tesseract with circuit-level DEMs, use DEM directly - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: self._x_decoder = self._create_decoder_from_dem("X") else: self._x_decoder = self._create_decoder(self._get_x_check_matrix()) @@ -3086,6 +3121,8 @@ def _is_mwpm_decoder(self) -> bool: """Check if using an MWPM or Tesseract decoder (vs LDPC).""" return self.decoder_type in ( DecoderType.PYMATCHING, + DecoderType.PYMATCHING_CORRELATED, + DecoderType.PYMATCHING_UNCORRELATED, DecoderType.FUSION_BLOSSOM, DecoderType.TESSERACT, ) @@ -3378,10 +3415,7 @@ def decode_memory_z( final_parity = sum(final[q] for q in logical_z_qubits) % 2 # DEM-based path: compute full detection events matching DEM detector order - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: events = self._compute_dem_detection_events_z(synx_list, synz_list, final, init_synx=init_synx) events_flat = events.ravel().astype(np.uint8) @@ -3477,10 +3511,7 @@ def decode_memory_x( final_parity = sum(final[q] for q in logical_x_qubits) % 2 # DEM-based path: compute full detection events matching DEM detector order - if self.use_circuit_level_dem and self.decoder_type in ( - DecoderType.PYMATCHING, - DecoderType.TESSERACT, - ): + if self.use_circuit_level_dem and self.decoder_type in DEM_DECODER_TYPES: events = self._compute_dem_detection_events_x(synx_list, synz_list, final, init_synz=init_synz) events_flat = events.ravel().astype(np.uint8) @@ -3644,6 +3675,13 @@ def _memory_noise_model( return NoiseModel.uniform(p) +def _recommended_graphlike_decomposition_for_decoder(decoder_type: str) -> NativeDemDecomposition: + base = decoder_type.split(":", 1)[0] + if base in {"pymatching", "pymatching_correlated", "pymatching_uncorrelated"}: + return "terminal_graphlike" + return "source_graphlike" + + def surface_code_memory( *, distance: int = 3, @@ -3675,6 +3713,8 @@ def surface_code_memory( rounds: Number of syndrome-extraction rounds. Defaults to ``distance``. basis: Memory basis, ``"Z"`` or ``"X"``. decoder_type: Decoder backend passed to ``SampleBatch.decode_count``. + PyMatching-family decoders use PECOS's terminal graphlike DEM + projection for this recommended workflow. seed: Optional sampler seed. decode: If false, report the raw observable-flip rate. circuit_source: ``"abstract"`` or ``"traced_qis"`` circuit source. @@ -3715,6 +3755,7 @@ def surface_code_memory( noise=noise_model, basis=basis, decompose_errors=True, + dem_decomposition=_recommended_graphlike_decomposition_for_decoder(decoder_type), ancilla_budget=ancilla_budget, circuit_source=circuit_source, interaction_basis=interaction_basis, diff --git a/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py new file mode 100644 index 000000000..e20e8fdda --- /dev/null +++ b/python/quantum-pecos/tests/pecos/unit/test_surface_sweep_compare.py @@ -0,0 +1,137 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with +# the License.You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the +# specific language governing permissions and limitations under the License. + +"""Unit tests for examples/surface/compare_surface_sweep_json.py.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from types import ModuleType + + +def _repo_root() -> Path: + cur = Path(__file__).resolve() + for candidate in [cur, *cur.parents]: + if (candidate / "Justfile").is_file() and (candidate / "examples").is_dir(): + return candidate + msg = f"Could not locate repo root above {cur}" + raise RuntimeError(msg) + + +_COMPARE_MODULE_NAME = "_surface_sweep_compare_under_test" + + +def _load_compare_module() -> ModuleType: + example_path = _repo_root() / "examples" / "surface" / "compare_surface_sweep_json.py" + spec = importlib.util.spec_from_file_location(_COMPARE_MODULE_NAME, example_path) + if spec is None or spec.loader is None: + msg = f"Could not load comparison module from {example_path}" + raise RuntimeError(msg) + module = importlib.util.module_from_spec(spec) + sys.modules[_COMPARE_MODULE_NAME] = module + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(_COMPARE_MODULE_NAME, None) + raise + return module + + +@pytest.fixture(scope="module") +def compare() -> ModuleType: + return _load_compare_module() + + +def _point(*, distance: int, p: float, errors: int, shots: int = 1000) -> dict[str, object]: + return { + "backend": "native_sampler", + "basis": "Z", + "distance": distance, + "physical_error_rate": p, + "total_rounds": distance, + "num_logical_errors": errors, + "num_shots": shots, + } + + +def _write_sweep(path: Path, points: list[dict[str, object]]) -> None: + path.write_text(json.dumps({"points": points}), encoding="utf-8") + + +def test_report_defaults_to_jeffreys_and_omits_cross_distance_pooled( + compare: ModuleType, + tmp_path: Path, +) -> None: + left = tmp_path / "left.json" + right = tmp_path / "right.json" + _write_sweep(left, [_point(distance=3, p=0.004, errors=5), _point(distance=5, p=0.004, errors=2)]) + _write_sweep(right, [_point(distance=3, p=0.004, errors=6), _point(distance=5, p=0.004, errors=1)]) + + report = compare.build_report( + left, + right, + left_label="CX", + right_label="SZZ", + include_ci=True, + interval_method="jeffreys", + include_cross_distance_pooled=False, + ) + + assert "- intervals: jeffreys 95%" in report + assert "- z-scores: descriptive unpooled Wald z-scores" in report + assert "## Aggregate Over Physical Error Rates" in report + assert "## Pooled Across Distances" not in report + + +def test_cross_distance_pooled_table_is_explicitly_labeled( + compare: ModuleType, + tmp_path: Path, +) -> None: + left = tmp_path / "left.json" + right = tmp_path / "right.json" + _write_sweep(left, [_point(distance=3, p=0.004, errors=5), _point(distance=5, p=0.004, errors=2)]) + _write_sweep(right, [_point(distance=3, p=0.004, errors=6), _point(distance=5, p=0.004, errors=1)]) + + report = compare.build_report( + left, + right, + left_label="CX", + right_label="SZZ", + include_ci=True, + interval_method="jeffreys", + include_cross_distance_pooled=True, + ) + + assert "## Pooled Across Distances By Backend And Basis" in report + assert "not a scaling or threshold statement" in report + + +def test_duplicate_point_keys_raise(compare: ModuleType, tmp_path: Path) -> None: + duplicate = tmp_path / "duplicate.json" + point = _point(distance=3, p=0.004, errors=5) + _write_sweep(duplicate, [point, point]) + + with pytest.raises(ValueError, match="duplicate point key"): + compare.load_points(duplicate) + + +def test_wilson_interval_remains_available(compare: ModuleType) -> None: + low, high = compare.binomial_interval(0, 100, "wilson") + assert low == pytest.approx(0.0, abs=1e-15) + assert 0.0 < high < 0.1 diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index f0956ddc3..2caa90f22 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -187,6 +187,134 @@ def test_decoder_types(self) -> None: d3 = SurfaceDecoder(patch, decoder_type="bp_osd", noise=noise) assert d3.decoder_type.value == "bp_osd" + # Explicit PyMatching DEM-correlation modes + d4 = SurfaceDecoder(patch, decoder_type="pymatching_correlated", noise=noise) + assert d4.decoder_type.value == "pymatching_correlated" + + d5 = SurfaceDecoder(patch, decoder_type="pymatching_uncorrelated", noise=noise) + assert d5.decoder_type.value == "pymatching_uncorrelated" + + def test_circuit_level_pymatching_uses_correlations_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The production circuit-level PyMatching path should consume DEM correlation metadata.""" + import pecos.decoders as decoders_module + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **_kwargs: object) -> str: + return "error(0.01) D0 ^ D1 L0\n" + + class DummyPyMatchingDecoder: + @classmethod + def from_dem_with_correlations(cls, dem: str, *, enable_correlations: bool) -> object: + seen["method"] = "from_dem_with_correlations" + seen["dem"] = dem + seen["enable_correlations"] = enable_correlations + return object() + + @classmethod + def from_dem(cls, _dem: str) -> object: + raise AssertionError + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + monkeypatch.setattr(decoders_module, "PyMatchingDecoder", DummyPyMatchingDecoder) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + decoder_type="pymatching", + circuit_level_dem_mode="native_decomposed", + ) + + assert decoder._get_z_decoder() is not None # noqa: SLF001 + assert seen == { + "method": "from_dem_with_correlations", + "dem": "error(0.01) D0 ^ D1 L0\n", + "enable_correlations": True, + } + + def test_circuit_level_uncorrelated_pymatching_uses_plain_dem(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The explicit uncorrelated option remains available for A/B diagnostics.""" + import pecos.decoders as decoders_module + import pecos.qec.surface.decode as decode_module + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + seen: dict[str, object] = {} + + def wrapped_generate(*_args: object, **_kwargs: object) -> str: + return "error(0.01) D0 ^ D1 L0\n" + + class DummyPyMatchingDecoder: + @classmethod + def from_dem_with_correlations(cls, _dem: str, **_kwargs: object) -> object: + raise AssertionError + + @classmethod + def from_dem(cls, dem: str) -> object: + seen["method"] = "from_dem" + seen["dem"] = dem + return object() + + monkeypatch.setattr(decode_module, "generate_circuit_level_dem_from_builder", wrapped_generate) + monkeypatch.setattr(decoders_module, "PyMatchingDecoder", DummyPyMatchingDecoder) + + decoder = SurfaceDecoder( + patch, + num_rounds=3, + noise=noise, + decoder_type="pymatching_uncorrelated", + circuit_level_dem_mode="native_decomposed", + ) + + assert decoder._get_z_decoder() is not None # noqa: SLF001 + assert seen == { + "method": "from_dem", + "dem": "error(0.01) D0 ^ D1 L0\n", + } + + def test_correlated_pymatching_requires_circuit_level_dem(self) -> None: + """The correlated option needs DEM metadata and should fail without it.""" + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + decoder = SurfaceDecoder( + patch, + decoder_type="pymatching_correlated", + noise=noise, + use_circuit_level_dem=False, + ) + + with pytest.raises(ValueError, match="requires circuit-level DEM"): + decoder._get_z_decoder() # noqa: SLF001 + + def test_correlated_pymatching_requires_decomposed_dem_mode(self) -> None: + """The explicit correlated option needs decomposed DEM metadata.""" + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.01, p_meas=0.01) + decoder = SurfaceDecoder( + patch, + decoder_type="pymatching_correlated", + noise=noise, + circuit_level_dem_mode="native_full", + ) + + with pytest.raises(ValueError, match="requires a decomposed"): + decoder._get_z_decoder() # noqa: SLF001 + + def test_recommended_memory_workflow_uses_terminal_graphlike_for_pymatching(self) -> None: + """The high-level memory helper should use the best measured graphlike projection.""" + import pecos.qec.surface.decode as decode_module + + for decoder_type in ["pymatching", "pymatching_correlated", "pymatching_uncorrelated"]: + assert ( + decode_module._recommended_graphlike_decomposition_for_decoder(decoder_type) # noqa: SLF001 + == "terminal_graphlike" + ) + assert decode_module._recommended_graphlike_decomposition_for_decoder("tesseract") == "source_graphlike" # noqa: SLF001 + def test_get_dem(self) -> None: """Test DEM generation via decoder.""" patch = SurfacePatch.create(distance=3) From 76af0d3ea5d5aaffbad8fa70919eaf8732491694 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 13 Jun 2026 18:40:26 -0600 Subject: [PATCH 153/388] Make DEM diagnostics resumable from saved results --- .../surface/dem_decomposition_diagnostics.py | 124 +++++++++++++++++- 1 file changed, 121 insertions(+), 3 deletions(-) diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index 3daf08f39..c1564562a 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -41,6 +41,7 @@ # virtual operations. CX-vs-SZZ p1 location comparisons include this assumption # as well as the gate-basis difference. SZZ_Z_FRAME_P1_GATE_RATES = {"Z": 0.0, "SZ": 0.0, "SZdg": 0.0} +RESULT_SCHEMA_VERSION = 2 @dataclass(frozen=True) @@ -89,12 +90,16 @@ class PairAnalysisSummary: @dataclass(frozen=True) class CaseResult: + result_schema_version: int distance: int rounds: int basis: str interaction_basis: str p: float shots: int + seed: int + pair_analysis_requested: bool + pair_analysis_max_effects: int raw_comparison: RawDemComparison dem_stats: dict[str, DemStats] decoders: list[DecodeSummary] @@ -686,12 +691,16 @@ def run_case( ) return CaseResult( + result_schema_version=RESULT_SCHEMA_VERSION, distance=distance, rounds=rounds, basis=basis, interaction_basis=interaction_basis, p=p, shots=shots, + seed=seed, + pair_analysis_requested=pair_analysis, + pair_analysis_max_effects=pair_analysis_max_effects, raw_comparison=compare_raw_dems(native_raw, stim_raw), dem_stats={ "native_raw": dem_stats(native_raw), @@ -764,17 +773,95 @@ def print_case(result: CaseResult) -> None: ) -def write_results_json(path: Path, results: list[CaseResult]) -> None: +def result_payload(result: CaseResult | dict[str, Any]) -> dict[str, Any]: + """Return a JSON-serializable case payload.""" + if isinstance(result, CaseResult): + return asdict(result) + return result + + +def write_results_json(path: Path, results: list[CaseResult | dict[str, Any]]) -> None: """Write completed case results to JSON. Diagnostics can be expensive for larger distance/round combinations, so the CLI writes after every finished case instead of only at process exit. """ - payload = [asdict(result) for result in results] + payload = [result_payload(result) for result in results] path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8") +def expected_decoder_labels(args: argparse.Namespace) -> tuple[str, ...]: + """Return the decoder labels expected for this run configuration.""" + labels = [] if args.skip_tesseract else [f"native_raw_tesseract_b{beam}" for beam in args.tesseract_beams] + labels.extend(args.decoders) + return tuple(labels) + + +def case_key( + *, + distance: int, + rounds: int, + basis: str, + interaction_basis: str, + p: float, + shots: int, + seed: int, + decoder_labels: tuple[str, ...], + pair_analysis: bool, + pair_analysis_max_effects: int, +) -> tuple[Any, ...]: + """Build a strict cache key for one sampled diagnostic case.""" + return ( + RESULT_SCHEMA_VERSION, + distance, + rounds, + basis, + interaction_basis, + f"{p:.17g}", + shots, + seed, + decoder_labels, + pair_analysis, + pair_analysis_max_effects, + ) + + +def cached_case_key(payload: dict[str, Any]) -> tuple[Any, ...] | None: + """Return the cache key for a saved case, or ``None`` if it is incomplete.""" + try: + decoder_labels = tuple(decoder["decoder"] for decoder in payload["decoders"]) + return ( + payload["result_schema_version"], + payload["distance"], + payload["rounds"], + payload["basis"], + payload["interaction_basis"], + f"{float(payload['p']):.17g}", + payload["shots"], + payload["seed"], + decoder_labels, + payload["pair_analysis_requested"], + payload["pair_analysis_max_effects"], + ) + except KeyError: + return None + + +def load_cached_results(path: Path) -> tuple[list[dict[str, Any]], dict[tuple[Any, ...], dict[str, Any]]]: + """Load resumable diagnostic results from a previous JSON file.""" + payload = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(payload, list): + raise ValueError(f"Expected a list of case results in {path}") + results = [item for item in payload if isinstance(item, dict)] + by_key = { + key: result + for result in results + if (key := cached_case_key(result)) is not None + } + return results, by_key + + def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) @@ -804,12 +891,26 @@ def parse_args() -> argparse.Namespace: ) parser.add_argument("--pair-analysis-max-effects", type=int, default=400) parser.add_argument("--save-json", type=Path, default=None) + parser.add_argument( + "--resume", + action="store_true", + help="Reuse matching completed cases from --save-json instead of recomputing them.", + ) return parser.parse_args() def main() -> int: args = parse_args() - results: list[CaseResult] = [] + if args.resume and args.save_json is None: + raise ValueError("--resume requires --save-json so completed cases have a source") + + results: list[CaseResult | dict[str, Any]] = [] + cached_by_key: dict[tuple[Any, ...], dict[str, Any]] = {} + if args.resume and args.save_json is not None and args.save_json.exists(): + results, cached_by_key = load_cached_results(args.save_json) + print(f"Loaded {len(cached_by_key)} resumable cases from {args.save_json}", flush=True) + + decoder_labels = expected_decoder_labels(args) total_cases = len(args.distances) * len(args.bases) * len(args.interaction_bases) * len(args.p) case_index = 0 for distance in args.distances: @@ -822,6 +923,22 @@ def main() -> int: f"d={distance} r={rounds} basis={basis} " f"basis2q={interaction_basis} p={p:g} shots={args.shots}" ) + key = case_key( + distance=distance, + rounds=rounds, + basis=basis, + interaction_basis=interaction_basis, + p=p, + shots=args.shots, + seed=args.seed, + decoder_labels=decoder_labels, + pair_analysis=args.pair_analysis, + pair_analysis_max_effects=args.pair_analysis_max_effects, + ) + if key in cached_by_key: + print(f"\n[{case_index}/{total_cases}] Reusing cached {label}", flush=True) + continue + print(f"\n[{case_index}/{total_cases}] Starting {label}", flush=True) start = time.perf_counter() result = run_case( @@ -838,6 +955,7 @@ def main() -> int: pair_analysis_max_effects=args.pair_analysis_max_effects, ) results.append(result) + cached_by_key[key] = result_payload(result) elapsed = time.perf_counter() - start print(f"[{case_index}/{total_cases}] Finished {label} in {elapsed:.3f}s", flush=True) print_case(result) From 6df15f1d8bba83df159d1977143c669c52c2ebb1 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 21:36:19 -0600 Subject: [PATCH 154/388] Add surface check plan metadata --- .../src/pecos/qec/surface/_check_plan.py | 145 ++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 122 ++++++++++++--- .../tests/qec/surface/test_check_plan.py | 119 ++++++++++++++ 3 files changed, 361 insertions(+), 25 deletions(-) create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_check_plan.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_check_plan.py diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py new file mode 100644 index 000000000..e50cf9e1e --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -0,0 +1,145 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Resolved surface-code check-plan metadata.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from typing import Any + +CHECK_PLAN_METADATA_FORMAT = "pecos.surface.check_plan" +CHECK_PLAN_METADATA_VERSION = 1 +CHECK_PLAN_HASH_ALGORITHM = "sha256" +CHECK_PLAN_HASH_SERIALIZATION = "canonical-json-v1" + +_DEFAULT_CHECK_PLAN_BY_BASIS = { + "cx": "cx_standard_v1", + "szz": "szz_current_v1", +} + + +def _normalize_interaction_basis_name(interaction_basis: str) -> str: + normalized = interaction_basis.lower() + if normalized not in _DEFAULT_CHECK_PLAN_BY_BASIS: + msg = f"interaction_basis must be 'cx' or 'szz', got {interaction_basis!r}" + raise ValueError(msg) + return normalized + + +def _normalize_check_plan_id(check_plan: str) -> str: + normalized = check_plan.lower() + if normalized not in _PLAN_SEMANTICS: + msg = f"unknown check_plan {check_plan!r}; expected one of {sorted(_PLAN_SEMANTICS)}" + raise ValueError(msg) + return normalized + + +_PLAN_SEMANTICS: dict[str, dict[str, Any]] = { + "cx_standard_v1": { + "plan_id": "cx_standard_v1", + "interaction_basis": "cx", + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + "prefix_policy": "none", + }, + "szz_current_v1": { + "plan_id": "szz_current_v1", + "interaction_basis": "szz", + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, +} + + +def canonical_check_plan_json(value: dict[str, Any]) -> str: + """Serialize check-plan metadata with stable cross-platform bytes.""" + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + + +def _semantic_hash(semantic_content: dict[str, Any]) -> str: + encoded = canonical_check_plan_json(semantic_content).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class ResolvedSurfaceCheckPlan: + """Internal resolved check-plan metadata for current surface-memory presets.""" + + plan_id: str + interaction_basis: str + semantic_content: dict[str, Any] + resolved_metadata: dict[str, Any] + resolved_hash: str + + +def resolve_surface_check_plan( + *, + interaction_basis: str | None = None, + check_plan: str | None = None, +) -> ResolvedSurfaceCheckPlan: + """Resolve the public check-plan selector to deterministic metadata. + + ``check_plan`` is the source of truth. ``interaction_basis`` remains a + backward-compatible default-plan selector and must agree when both are + provided. + """ + normalized_basis = None if interaction_basis is None else _normalize_interaction_basis_name(interaction_basis) + if check_plan is None: + plan_id = _DEFAULT_CHECK_PLAN_BY_BASIS[normalized_basis or "cx"] + else: + plan_id = _normalize_check_plan_id(check_plan) + + semantic_content = json.loads(canonical_check_plan_json(_PLAN_SEMANTICS[plan_id])) + plan_basis = str(semantic_content["interaction_basis"]) + if normalized_basis is not None and normalized_basis != plan_basis: + msg = ( + f"interaction_basis={normalized_basis!r} conflicts with " + f"check_plan={plan_id!r}, which uses interaction_basis={plan_basis!r}" + ) + raise ValueError(msg) + + resolved_hash = _semantic_hash(semantic_content) + resolved_metadata = { + "format": CHECK_PLAN_METADATA_FORMAT, + "metadata_version": CHECK_PLAN_METADATA_VERSION, + "hash_algorithm": CHECK_PLAN_HASH_ALGORITHM, + "hash_serialization": CHECK_PLAN_HASH_SERIALIZATION, + "semantic_content": semantic_content, + } + return ResolvedSurfaceCheckPlan( + plan_id=plan_id, + interaction_basis=plan_basis, + semantic_content=semantic_content, + resolved_metadata=resolved_metadata, + resolved_hash=resolved_hash, + ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 68df785cb..ce4899f1f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -51,6 +51,8 @@ import numpy as np +from pecos.qec.surface._check_plan import resolve_surface_check_plan + if TYPE_CHECKING: import stim from numpy.typing import NDArray @@ -317,6 +319,10 @@ class _CachedNativeSurfaceTopology: num_detectors: int num_observables: int num_pauli_sites: int + interaction_basis: str + check_plan: str + resolved_check_plan: dict[str, Any] + resolved_check_plan_hash: str def _surface_patch_cache_key(patch: SurfacePatch) -> tuple[int, int, str, bool]: @@ -1900,6 +1906,7 @@ def _surface_native_topology( pauli_frame_lookup = PauliFrameLookup.from_circuit(dag, det_records, obs_records) num_pauli_sites = pauli_frame_lookup.num_pauli_sites + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis) return _CachedNativeSurfaceTopology( dag_circuit=dag, influence_map=influence_map, @@ -1916,6 +1923,10 @@ def _surface_native_topology( num_detectors=len(det_records), num_observables=len(obs_records), num_pauli_sites=num_pauli_sites, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -1931,8 +1942,10 @@ def _cached_surface_native_topology( twirl: TwirlConfig | None = None, interaction_basis: str = "cx", szz_physical_prefixes: bool = False, + resolved_check_plan_hash: str = "", ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" + _ = resolved_check_plan_hash return _surface_native_topology( patch_key, num_rounds, @@ -2026,8 +2039,10 @@ def _cached_surface_native_dem_string( p_idle_z_quadratic_sine_rate: float | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + resolved_check_plan_hash: str = "", ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" + _ = resolved_check_plan_hash include_idle_gates = _uses_dedicated_idle_noise( p_idle=p_idle, t1=t1, @@ -2060,6 +2075,7 @@ def _cached_surface_native_dem_string( twirl=twirl, interaction_basis=interaction_basis, szz_physical_prefixes=szz_physical_prefixes, + resolved_check_plan_hash=resolved_check_plan_hash, ) return _dem_string_from_cached_surface_topology( topology, @@ -2152,6 +2168,10 @@ def _build_native_sampler_from_cached_surface_topology( pauli_frame_lookup=topology.pauli_frame_lookup, num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, + interaction_basis=topology.interaction_basis, + check_plan=topology.check_plan, + resolved_check_plan=topology.resolved_check_plan, + resolved_check_plan_hash=topology.resolved_check_plan_hash, ) @@ -2167,7 +2187,8 @@ def generate_circuit_level_dem_from_builder( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -2211,11 +2232,14 @@ def generate_circuit_level_dem_from_builder( twirl: Optional Pauli-frame randomization layout. Canonical Guppy frame-output mode is normalized to the same abstract raw lookup and DEM topology. - interaction_basis: Surface-memory two-qubit interaction basis. The - staged ``"szz"`` path currently assumes a virtual-Z device model: - Z/SZ/SZdg frame updates are p1-free. That is a device assumption - keyed from this basis selector, not a general claim about CX - hardware. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. The staged SZZ plan currently assumes a virtual-Z device + model: Z/SZ/SZdg frame updates are p1-free. That is a device + assumption keyed from the resolved plan, not a general claim about + CX hardware. Returns: DEM string in standard format @@ -2229,9 +2253,9 @@ def generate_circuit_level_dem_from_builder( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) patch_key = _surface_patch_cache_key(patch) include_idle_gates = _noise_uses_dedicated_idle_noise(noise) @@ -2276,6 +2300,7 @@ def generate_circuit_level_dem_from_builder( "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, "twirl": twirl, "interaction_basis": interaction_basis, + "resolved_check_plan_hash": resolved_plan.resolved_hash, } if dem_decomposition != "source_graphlike": cache_kwargs["dem_decomposition"] = dem_decomposition @@ -3646,6 +3671,9 @@ class SimulationResult: decoded: Whether decoding was applied decoder_type: Decoder backend used (if decoded) interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. + resolved_check_plan: Canonical resolved check-plan metadata. + resolved_check_plan_hash: SHA-256 hash of the resolved plan semantics. """ distance: int @@ -3659,6 +3687,9 @@ class SimulationResult: decoded: bool decoder_type: str | None = None interaction_basis: str = "cx" + check_plan: str = "cx_standard_v1" + resolved_check_plan: dict[str, Any] | None = None + resolved_check_plan_hash: str = "" def _memory_noise_model( @@ -3695,7 +3726,8 @@ def surface_code_memory( decode: bool = True, circuit_source: Literal["abstract", "traced_qis"] = "abstract", ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -3719,8 +3751,11 @@ def surface_code_memory( decode: If false, report the raw observable-flip rate. circuit_source: ``"abstract"`` or ``"traced_qis"`` circuit source. ancilla_budget: Optional cap on simultaneously live ancillas. - interaction_basis: Surface-memory two-qubit interaction basis, - ``"cx"`` or ``"szz"``. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -3732,10 +3767,10 @@ def surface_code_memory( 0.0 """ from pecos.qec import ParsedDem - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis from pecos.qec.surface.patch import SurfacePatch - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis if distance < 1: msg = f"distance must be >= 1, got {distance}" raise ValueError(msg) @@ -3759,6 +3794,7 @@ def surface_code_memory( ancilla_budget=ancilla_budget, circuit_source=circuit_source, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, ) batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(shots, seed) num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) @@ -3776,6 +3812,9 @@ def surface_code_memory( decoded=decode, decoder_type=decoder_type if decode else None, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -3788,7 +3827,8 @@ def run_noisy_memory_experiment( *, decode: bool = True, decoder_type: str = "pymatching", - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> SimulationResult: """Run a noisy surface code memory experiment with optional decoding. @@ -3806,8 +3846,11 @@ def run_noisy_memory_experiment( noise: Noise model parameters decode: If True, use decoding to correct errors decoder_type: Decoder backend (pymatching, fusion_blossom, bp_osd, etc.) - interaction_basis: Surface-memory two-qubit interaction basis, - ``"cx"`` or ``"szz"``. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: SimulationResult with error rate statistics @@ -3830,9 +3873,9 @@ def run_noisy_memory_experiment( from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos.guppy.surface import get_num_qubits, make_surface_code from pecos.qec.surface import SurfacePatch - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis # Create patch and decoder patch = SurfacePatch.create(distance=distance) geom = patch.geometry @@ -3939,6 +3982,9 @@ def run_noisy_memory_experiment( decoded=decode, decoder_type=decoder_type if decode else None, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -3971,6 +4017,11 @@ class NativeSampler: sampling_model: Which native sampling backend is active dem_string: Optional graphlike-decomposed DEM string used to build the sampler. Populated when the ``"dem"`` sampling model is selected. + interaction_basis: Surface-memory two-qubit interaction basis resolved + from ``check_plan``. + check_plan: Named surface check-plan preset. + resolved_check_plan: Canonical resolved check-plan metadata. + resolved_check_plan_hash: SHA-256 hash of the resolved plan semantics. """ sampler: Any @@ -3984,6 +4035,10 @@ class NativeSampler: "dem" # "mnm" accepted for compat, mapped to "influence_dem" ) dem_string: str | None = None + interaction_basis: str = "cx" + check_plan: str = "cx_standard_v1" + resolved_check_plan: dict[str, Any] | None = None + resolved_check_plan_hash: str = "" def sample( self, @@ -4033,12 +4088,13 @@ def build_native_sampler( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, sampling_model: Literal[ "dem", "influence_dem", "mnm", ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", + check_plan: str | None = None, ) -> NativeSampler: """Build a PECOS native sampler for threshold estimation. @@ -4065,7 +4121,8 @@ def build_native_sampler( before native PECOS fault analysis. twirl: Optional Pauli-frame randomization layout. Canonical runtime frame-output mode is normalized to the same abstract raw lookup. - interaction_basis: Surface-memory two-qubit interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. sampling_model: Which native sampling backend to use. ``"dem"`` samples the generated source-graphlike DEM projection and is the default; this is a decoder-facing approximation of raw hyperedges, @@ -4073,6 +4130,9 @@ def build_native_sampler( ``"influence_dem"`` uses the influence-map-based DemSampler with detector definitions. ``"mnm"`` is accepted for compatibility and maps to ``"influence_dem"``. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: NativeSampler that can generate samples for threshold estimation @@ -4086,9 +4146,9 @@ def build_native_sampler( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis _reject_szz_unlowered_physical_noise(noise, interaction_basis, circuit_source) basis = basis.upper() patch_key = _surface_patch_cache_key(patch) @@ -4103,6 +4163,7 @@ def build_native_sampler( twirl=twirl, interaction_basis=interaction_basis, szz_physical_prefixes=szz_physical_prefixes, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -4138,6 +4199,7 @@ def build_native_sampler( p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, twirl=twirl, interaction_basis=interaction_basis, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -4150,6 +4212,10 @@ def build_native_sampler( num_pauli_sites=topology.num_pauli_sites, sampling_model=sampling_model, dem_string=dem_str, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) return _build_native_sampler_from_cached_surface_topology( topology, @@ -4167,7 +4233,8 @@ def build_native_sampler_from_dem( ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> NativeSampler: """Build a native sampler from a caller-supplied decomposed DEM string. @@ -4178,9 +4245,9 @@ def build_native_sampler_from_dem( """ ancilla_budget = _canonical_ancilla_budget(patch, ancilla_budget) twirl = _abstract_twirl_config(twirl) - from pecos.qec.surface.circuit_builder import _normalize_interaction_basis - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis basis = basis.upper() patch_key = _surface_patch_cache_key(patch) topology = _cached_surface_native_topology( @@ -4192,6 +4259,7 @@ def build_native_sampler_from_dem( include_idle_gates=False, twirl=twirl, interaction_basis=interaction_basis, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() return NativeSampler( @@ -4204,6 +4272,10 @@ def build_native_sampler_from_dem( num_pauli_sites=topology.num_pauli_sites, sampling_model="dem", dem_string=decomposed_dem, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id, + resolved_check_plan=resolved_plan.resolved_metadata, + resolved_check_plan_hash=resolved_plan.resolved_hash, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py new file mode 100644 index 000000000..11e7829db --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -0,0 +1,119 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Surface check-plan metadata tests.""" + +from __future__ import annotations + +import hashlib + +import pytest + + +def test_check_plan_default_resolves_to_cx_metadata() -> None: + from pecos.qec.surface._check_plan import canonical_check_plan_json, resolve_surface_check_plan + + plan = resolve_surface_check_plan() + + assert plan.plan_id == "cx_standard_v1" + assert plan.interaction_basis == "cx" + assert plan.resolved_metadata["metadata_version"] == 1 + assert plan.resolved_metadata["hash_algorithm"] == "sha256" + assert plan.resolved_metadata["hash_serialization"] == "canonical-json-v1" + assert "metadata_version" not in plan.semantic_content + assert plan.resolved_hash == hashlib.sha256( + canonical_check_plan_json(plan.semantic_content).encode("utf-8"), + ).hexdigest() + + +def test_check_plan_is_source_of_truth_for_basis() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_current_v1") + + assert plan.plan_id == "szz_current_v1" + assert plan.interaction_basis == "szz" + + +def test_check_plan_and_interaction_basis_mismatch_fails_loudly() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + with pytest.raises(ValueError, match="conflicts with check_plan"): + resolve_surface_check_plan( + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_surface_code_memory_records_resolved_check_plan() -> None: + from pecos.qec.surface import surface_code_memory + + result = surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=4, + rounds=1, + seed=123, + check_plan="szz_current_v1", + ) + + assert result.interaction_basis == "szz" + assert result.check_plan == "szz_current_v1" + assert result.resolved_check_plan is not None + assert result.resolved_check_plan["semantic_content"]["interaction_basis"] == "szz" + assert len(result.resolved_check_plan_hash) == 64 + + +def test_surface_code_memory_rejects_plan_basis_mismatch() -> None: + from pecos.qec.surface import surface_code_memory + + with pytest.raises(ValueError, match="conflicts with check_plan"): + surface_code_memory( + distance=3, + physical_error_rate=0.0, + shots=0, + rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_check_plan_does_not_change_current_szz_dem() -> None: + from pecos.qec.surface import NoiseModel, SurfacePatch + from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + patch = SurfacePatch.create(distance=3) + noise = NoiseModel(p2=0.001, p_meas=0.001, p_prep=0.001) + + by_basis = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + interaction_basis="szz", + ) + by_plan = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=noise, + check_plan="szz_current_v1", + ) + + assert by_plan == by_basis + + +def test_native_sampler_records_resolved_check_plan() -> None: + from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler + + patch = SurfacePatch.create(distance=3) + sampler = build_native_sampler( + patch, + num_rounds=1, + noise=NoiseModel(p2=0.001), + check_plan="szz_current_v1", + ) + + assert sampler.interaction_basis == "szz" + assert sampler.check_plan == "szz_current_v1" + assert sampler.resolved_check_plan is not None + assert sampler.resolved_check_plan["semantic_content"]["interaction_basis"] == "szz" + assert len(sampler.resolved_check_plan_hash) == 64 From 379d8c00a4e419b80715d91703337ebf4352226f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 23:58:07 -0600 Subject: [PATCH 155/388] Support MeasureFree in sim_neo extract_commands and align stale measurement-count tests to the circuit's num_measurements --- .../pecos-rslib-exp/src/sim_neo_bindings.rs | 6 +- .../tests/qec/test_meas_sampling_backend.py | 18 +++--- .../tests/qec/test_raw_measurement_result.py | 63 ++++++++++--------- 3 files changed, 50 insertions(+), 37 deletions(-) diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 57fd31c7f..92c5a405e 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -1295,7 +1295,11 @@ fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { cb = cb.tdg(&qubits); } - "MZ" => { + "MZ" | "Measure" | "MeasureFree" => { + // Z-basis measurement; ``MeasureFree`` additionally frees the + // qubit, which is a no-op for the fixed-width simulator. Kept + // consistent with build_rust_tick_circuit_from_gates and the + // surface DEM path, which treat all three as Z measurements. cb = cb.mz(&qubits); } "RX" => { diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py index a0564c682..23f04dd23 100644 --- a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py @@ -33,7 +33,7 @@ def coherent(): class TestD3SurfaceCode57vs48: def test_raw_output_is_57_measurements(self, d3_tc, depol): r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_nondet_measurement_mean_half(self, d3_tc, depol): shots = 5000 @@ -44,8 +44,12 @@ def test_nondet_measurement_mean_half(self, d3_tc, depol): def test_det_measurement_mean_low(self, d3_tc, depol): shots = 5000 r = sim_neo(d3_tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() - mean_4 = sum(s[4] for s in r) / shots - assert mean_4 < 0.1, f"meas[4]={mean_4:.3f}" + # Index 8 is the first round-1 Z-stabilizer measurement; on the |0_L> + # memory experiment it is deterministic (+1). Indices 0-7 are the four + # init-round X-stabilizer measurements plus round-1 X-stabilizers, which + # are random on |0_L>. + mean_det = sum(s[8] for s in r) / shots + assert mean_det < 0.1, f"meas[8]={mean_det:.3f}" def test_z_type_detection_rates_match_stabilizer(self, d3_tc, depol): """Z-type detector rates match stabilizer (known-good subset).""" @@ -150,11 +154,11 @@ def rates(results): class TestMethodDispatch: def test_auto_no_idle_rz(self, d3_tc, depol): r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_auto_with_idle_rz(self, d3_tc, coherent): r = sim_neo(d3_tc).quantum(meas_sampling("auto")).noise(coherent).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_stochastic_rejects_idle_rz(self, d3_tc, coherent): with pytest.raises(Exception, match="idle_rz"): @@ -162,11 +166,11 @@ def test_stochastic_rejects_idle_rz(self, d3_tc, coherent): def test_coherent_no_idle_rz(self, d3_tc, depol): r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(depol).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_coherent_with_idle_rz(self, d3_tc, coherent): r = sim_neo(d3_tc).quantum(meas_sampling("coherent")).noise(coherent).shots(10).seed(42).run() - assert len(r[0]) == 57 + assert len(r[0]) == int(d3_tc.get_meta("num_measurements")) def test_invalid_method(self, d3_tc, depol): with pytest.raises(Exception, match="Unknown"): diff --git a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py index 7bb169a1e..facf6b587 100644 --- a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py +++ b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py @@ -16,33 +16,38 @@ @pytest.fixture def d3_results(): - """Run both backends on the same circuit and return their results.""" + """Run both backends on the same circuit and return their results. + + The third element is the circuit's declared measurement count, used so the + assertions track the actual circuit contract rather than a hard-coded value. + """ patch = SurfacePatch.create(distance=3) tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") + num_meas = int(tc.get_meta("num_measurements")) depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) stab_r = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(100).seed(42).run() meas_r = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(100).seed(42).run() - return stab_r, meas_r + return stab_r, meas_r, num_meas class TestCommonProtocol: """Both backends return objects with the same interface.""" def test_len(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results assert len(stab_r) == 100 assert len(meas_r) == 100 def test_indexing(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results # r[shot] returns a sequence of u8 values s0 = stab_r[0] d0 = meas_r[0] - assert len(s0) == len(d0) == 57 # d=3 surface code has 57 measurements + assert len(s0) == len(d0) == num_meas def test_item_values(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # Individual values are 0 or 1 for val in stab_r[0]: assert val in (0, 1) @@ -50,80 +55,80 @@ def test_item_values(self, d3_results): assert val in (0, 1) def test_list_conversion(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results s_row = list(stab_r[0]) d_row = list(meas_r[0]) assert all(isinstance(v, int) for v in s_row) assert all(isinstance(v, int) for v in d_row) - assert len(s_row) == len(d_row) == 57 + assert len(s_row) == len(d_row) == num_meas def test_iteration(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results stab_count = 0 for row in stab_r: stab_count += 1 - assert len(row) == 57 + assert len(row) == num_meas assert stab_count == 100 dem_count = 0 for row in meas_r: dem_count += 1 - assert len(row) == 57 + assert len(row) == num_meas assert dem_count == 100 def test_out_of_range_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[100] with pytest.raises(IndexError): meas_r[100] def test_num_shots_property(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results assert stab_r.num_shots == 100 assert meas_r.num_shots == 100 def test_num_measurements_property(self, d3_results): - stab_r, meas_r = d3_results - assert stab_r.num_measurements == 57 - assert meas_r.num_measurements == 57 + stab_r, meas_r, num_meas = d3_results + assert stab_r.num_measurements == num_meas + assert meas_r.num_measurements == num_meas def test_get_method(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # get(shot, meas) returns 0 or 1 assert stab_r.get(0, 0) in (0, 1) assert meas_r.get(0, 0) in (0, 1) def test_get_out_of_range(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results with pytest.raises(IndexError): stab_r.get(100, 0) with pytest.raises(IndexError): - meas_r.get(0, 57) + meas_r.get(0, num_meas) def test_get_shot_method(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results s = stab_r.get_shot(0) d = meas_r.get_shot(0) - assert len(s) == len(d) == 57 + assert len(s) == len(d) == num_meas def test_to_list(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, num_meas = d3_results sl = stab_r.to_list() dl = meas_r.to_list() assert len(sl) == len(dl) == 100 - assert len(sl[0]) == len(dl[0]) == 57 + assert len(sl[0]) == len(dl[0]) == num_meas def test_negative_index_raises_index_error(self, d3_results): """Negative indexing raises IndexError, never OverflowError.""" - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[-1] with pytest.raises(IndexError): meas_r[-1] def test_negative_get_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results # Negative shot with pytest.raises(IndexError): stab_r.get(-1, 0) @@ -136,7 +141,7 @@ def test_negative_get_raises_index_error(self, d3_results): meas_r.get(0, -1) def test_get_shot_negative_raises_index_error(self, d3_results): - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r.get_shot(-1) with pytest.raises(IndexError): @@ -144,7 +149,7 @@ def test_get_shot_negative_raises_index_error(self, d3_results): def test_out_of_range_uses_len(self, d3_results): """result[len(result)] raises IndexError.""" - stab_r, meas_r = d3_results + stab_r, meas_r, _ = d3_results with pytest.raises(IndexError): stab_r[len(stab_r)] with pytest.raises(IndexError): @@ -173,7 +178,7 @@ def test_generic_consumer_stabilizer(self): result = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(1000).seed(42).run() means = self.compute_measurement_means(result) - assert len(means) == 57 + assert len(means) == int(tc.get_meta("num_measurements")) # Non-det measurements should be ~0.5, det should be ~0 nondet = sum(1 for m in means if abs(m - 0.5) < 0.15) assert nondet > 0 @@ -185,6 +190,6 @@ def test_generic_consumer_meas_sampling(self): result = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(1000).seed(42).run() means = self.compute_measurement_means(result) - assert len(means) == 57 + assert len(means) == int(tc.get_meta("num_measurements")) nondet = sum(1 for m in means if abs(m - 0.5) < 0.15) assert nondet > 0 From fe5d68ceef3f9cb6dd5fee77744e42a493e6b572 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 13 Jun 2026 23:58:07 -0600 Subject: [PATCH 156/388] Normalize the fresh traced circuit in the constrained-budget cache test to match the native DEM path --- .../tests/qec/surface/test_surface_decoder.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index 2caa90f22..eb3934c47 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -628,7 +628,11 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: DEM built fresh from the corresponding TickCircuit, for both the ``abstract`` and ``traced_qis`` sources -- pinning that caching is sound for constrained budgets, not just unconstrained ones.""" - from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit, generate_tick_circuit_from_patch + from pecos.qec.surface.circuit_builder import ( + generate_dem_from_tick_circuit, + generate_tick_circuit_from_patch, + normalize_traced_qis_tick_circuit, + ) from pecos.qec.surface.decode import ( _build_surface_tick_circuit_for_native_model, generate_circuit_level_dem_from_builder, @@ -658,6 +662,10 @@ def test_constrained_budget_uses_cache_and_matches_fresh_build(self) -> None: ancilla_budget=2, circuit_source="traced_qis", ) + # The native topology path normalizes traced-QIS circuits (Clifford-rotation + # lowering + single-qubit Clifford-chain simplification) before DEM + # construction; the fresh comparison must apply the same normalization. + normalize_traced_qis_tick_circuit(traced_tc, context="constrained budget cache test") cached_traced = generate_circuit_level_dem_from_builder( patch, num_rounds=2, From 9e4fceb8208c9900b122748446e64a759c3fe84d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 10:16:36 -0600 Subject: [PATCH 157/388] Restore QEC branch health checks --- .../pecos-rslib-exp/src/sim_neo_bindings.rs | 2 + .../src/fault_tolerance_bindings.rs | 16 +++ .../qec/test_decomposed_dem_invariants.py | 113 +++++++++++------- .../tests/qec/test_dem_sampler_vs_stim.py | 37 ++---- .../tests/qec/test_from_guppy_result_tags.py | 15 ++- 5 files changed, 101 insertions(+), 82 deletions(-) diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 92c5a405e..9f656c12f 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -925,6 +925,7 @@ fn build_rust_tick_circuit_from_gates( "QAlloc" | "PZ" | "Prep" => { pz_qubits.extend(qubit_ids); } + "TrackedPauli" | "TrackedPauliMeta" => {} _ => { let core_gate = build_gate_from_python(gate, &gate_name, &qubit_ids)?; other_gates.push(core_gate); @@ -1357,6 +1358,7 @@ fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { // Identity/Idle gates: skip (no-op for simulation) } + "TrackedPauli" | "TrackedPauliMeta" => {} _ => { return Err(PyErr::new::(format!( "Unsupported gate type '{name}' in extract_commands. \ diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 3b607e317..8cada712b 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -1445,6 +1445,22 @@ fn contribution_record_to_pydict( dict.set_item("direct_source_family", family_label)?; } dict.set_item("replacement_branch", contribution.replacement_branch)?; + if let Some(parts) = &contribution.source_component_effects { + dict.set_item( + "source_component_detectors", + parts + .iter() + .map(|part| part.detectors.to_vec()) + .collect::>(), + )?; + dict.set_item( + "source_component_dem_outputs", + parts + .iter() + .map(|part| part.dem_outputs.to_vec()) + .collect::>(), + )?; + } match contribution.source_type { RustFaultSourceType::Direct => { diff --git a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py index 11f2e90ad..45db9a669 100644 --- a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py +++ b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py @@ -104,6 +104,20 @@ def xor_effect_rows(left: dict[str, list[int]], right: dict[str, list[int]]) -> ) +def xor_source_components(row: dict[str, object]) -> tuple[list[int], list[int]]: + """XOR a structured row's source component effects.""" + dets: list[int] = [] + outputs: list[int] = [] + for part_dets, part_outputs in zip( + row["source_component_detectors"], + row["source_component_dem_outputs"], + strict=True, + ): + dets = xor_lists(dets, list(part_dets)) + outputs = xor_lists(outputs, list(part_outputs)) + return dets, outputs + + def parse_dem_error_probabilities(dem_str: str) -> dict[str, float]: """Map DEM target strings to their stated error probabilities.""" out: dict[str, float] = {} @@ -204,6 +218,7 @@ def _find_gate_attrs( phase: str | None = None, label_prefix: str | None = None, stabilizer: str | None = None, + syndrome_round: int | None = None, ) -> dict[str, object]: """Find the first DAG gate attribute record matching the requested filters.""" for node in sorted(dag.nodes()): @@ -217,10 +232,13 @@ def _find_gate_attrs( continue if stabilizer is not None and attrs.get("stabilizer") != stabilizer: continue + if syndrome_round is not None and attrs.get("syndrome_round") != syndrome_round: + continue return attrs msg = ( f"no gate attrs found for gate_type={gate_type!r}, phase={phase!r}, " - f"label_prefix={label_prefix!r}, stabilizer={stabilizer!r}" + f"label_prefix={label_prefix!r}, stabilizer={stabilizer!r}, " + f"syndrome_round={syndrome_round!r}" ) raise AssertionError(msg) @@ -238,18 +256,24 @@ def test_surface_tick_gate_metadata_preserves_phase_round_context_in_dag() -> No assert h_pre["syndrome_round"] == 0 assert "cx_round" not in h_pre - ancilla_reset = _find_gate_attrs(dag, "PZ", phase="syndrome_prep", label_prefix="ax") - assert ancilla_reset["phase"] == "syndrome_prep" - assert ancilla_reset["syndrome_round"] == 1 - assert "cx_round" not in ancilla_reset - assert ancilla_reset["stabilizer"] == "X0" - assert ancilla_reset["stabilizer_kind"] == "X" - assert ancilla_reset["stabilizer_index"] == 0 - assert ancilla_reset["stabilizer_is_boundary"] is True - assert ancilla_reset["stabilizer_region"] - assert ancilla_reset["ancilla_qubit"] >= patch.num_data - - cx = _find_gate_attrs(dag, "CX", phase="cx_round_1") + ancilla_prep = _find_gate_attrs( + dag, + "QAlloc", + phase="syndrome_prep", + label_prefix="ax", + syndrome_round=1, + ) + assert ancilla_prep["phase"] == "syndrome_prep" + assert ancilla_prep["syndrome_round"] == 1 + assert "cx_round" not in ancilla_prep + assert ancilla_prep["stabilizer"] == "X0" + assert ancilla_prep["stabilizer_kind"] == "X" + assert ancilla_prep["stabilizer_index"] == 0 + assert ancilla_prep["stabilizer_is_boundary"] is True + assert ancilla_prep["stabilizer_region"] + assert ancilla_prep["ancilla_qubit"] >= patch.num_data + + cx = _find_gate_attrs(dag, "CX", phase="cx_round_1", syndrome_round=0) assert cx["phase"] == "cx_round_1" assert cx["syndrome_round"] == 0 assert cx["cx_round"] == 1 @@ -263,7 +287,13 @@ def test_surface_tick_gate_metadata_preserves_phase_round_context_in_dag() -> No assert cx["ancilla_qubit"] >= patch.num_data assert cx["data_qubit"] < patch.num_data - ancilla_measure = _find_gate_attrs(dag, "MZ", phase="measure_ancilla", label_prefix="sx") + ancilla_measure = _find_gate_attrs( + dag, + "MeasureFree", + phase="measure_ancilla", + label_prefix="sx", + syndrome_round=0, + ) assert ancilla_measure["phase"] == "measure_ancilla" assert ancilla_measure["syndrome_round"] == 0 assert ancilla_measure["cx_round"] == 4 @@ -284,13 +314,13 @@ def test_surface_tick_gate_metadata_tracks_reused_ancillas_by_label() -> None: dag = tc.to_dag_circuit() first_alloc = _find_gate_attrs(dag, "QAlloc", phase="syndrome_prep", label_prefix="ax0") - reused_reset = _find_gate_attrs(dag, "PZ", phase="syndrome_prep", label_prefix="ax1") + reused_alloc = _find_gate_attrs(dag, "QAlloc", phase="syndrome_prep", label_prefix="ax1") reused_cx = _find_gate_attrs(dag, "CX", phase="cx_round_1", stabilizer="X1") assert first_alloc["stabilizer"] == "X0" - assert reused_reset["stabilizer"] == "X1" - assert reused_reset["ancilla_qubit"] == first_alloc["ancilla_qubit"] - assert reused_reset["ancilla_qubit"] == patch.num_data + assert reused_alloc["stabilizer"] == "X1" + assert reused_alloc["ancilla_qubit"] == first_alloc["ancilla_qubit"] + assert reused_alloc["ancilla_qubit"] == patch.num_data assert reused_cx["stabilizer"] == "X1" assert reused_cx["ancilla_qubit"] == patch.num_data @@ -481,20 +511,23 @@ def test_structured_source_tracking_bindings_are_self_consistent(basis: str) -> @pytest.mark.parametrize("basis", ["X", "Z"]) -def test_structured_source_tracking_y_decomposed_rows_xor_back_to_effect(basis: str) -> None: - """Y-decomposed structured rows should XOR back to their parent effect.""" +def test_structured_source_component_rows_xor_back_to_effect(basis: str) -> None: + """Source component rows should XOR back to their parent effect.""" dem = build_source_tracked_dem(distance=3, basis=basis, rounds=20) - summaries = [row for row in dem.contribution_effect_summaries() if row["y_decomposed_count"] > 0] - assert summaries + rows = [] + for summary in dem.contribution_effect_summaries(): + for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): + if "source_component_detectors" not in row: + continue + rows.append((summary, row)) - for summary in summaries[:20]: - contributions = dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]) - y_rows = [row for row in contributions if row["source_type"] == "YDecomposed"] - assert y_rows - for row in y_rows: - assert xor_lists(row["x_detectors"], row["z_detectors"]) == summary["detectors"] - assert xor_lists(row["x_dem_outputs"], row["z_dem_outputs"]) == summary["dem_outputs"] + assert rows + + for summary, row in rows[:100]: + dets, outputs = xor_source_components(row) + assert dets == summary["detectors"] + assert outputs == summary["dem_outputs"] @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -542,20 +575,9 @@ def test_structured_one_sided_direct_component_rows_are_exposed(basis: str) -> N assert rows for summary, row in rows[:100]: - left_non_empty = bool(row["component_1_detectors"] or row["component_1_dem_outputs"]) - right_non_empty = bool(row["component_2_detectors"] or row["component_2_dem_outputs"]) - assert left_non_empty != right_non_empty - assert row["direct_source_family"] == "TwoLocationOneSidedComponent" - direct_dets, direct_logs = xor_effect_rows( - { - "detectors": row["component_1_detectors"], - "dem_outputs": row["component_1_dem_outputs"], - }, - { - "detectors": row["component_2_detectors"], - "dem_outputs": row["component_2_dem_outputs"], - }, - ) + assert "source_component_detectors" in row + assert "source_component_dem_outputs" in row + direct_dets, direct_logs = xor_source_components(row) assert direct_dets == summary["detectors"] assert direct_logs == summary["dem_outputs"] @@ -576,7 +598,8 @@ def test_structured_direct_source_families_are_exposed_for_direct_rows(basis: st assert rows assert all("direct_source_family" in row for row in rows) assert any(row["direct_source_family"] == "SingleLocationY" for row in rows) - assert any(row["direct_source_family"] == "TwoLocationOneSidedComponent" for row in rows) + assert any(row["direct_source_family"] == "TwoLocationComponent" for row in rows) + assert any(row["source_type"] == "DirectOneSidedComponent" for row in rows) @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -626,7 +649,7 @@ def test_structured_render_summaries_reproduce_decomposed_regrouping(basis: str) assert probability == pytest.approx(decomposed_by_targets[targets], abs=5e-7) assert all("source_type_counts" in row for row in render_summaries) - assert any("YDecomposed" in row["source_type_counts"] for row in render_summaries) + assert any("DirectOneSidedComponent" in row["source_type_counts"] for row in render_summaries) @pytest.mark.parametrize("basis", ["X", "Z"]) diff --git a/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py b/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py index dd4f7d943..d4dd35a59 100644 --- a/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py +++ b/python/quantum-pecos/tests/qec/test_dem_sampler_vs_stim.py @@ -12,6 +12,7 @@ import numpy as np import pytest +from pecos.qec.surface import get_measurement_order_from_tick_circuit if TYPE_CHECKING: from pecos.quantum import DagCircuit, TickCircuit @@ -21,34 +22,8 @@ def extract_measurement_order(tc: "TickCircuit") -> list[int]: - """Extract measurement order from TickCircuit. - - Returns a list of qubit indices in the order they were measured. - This is needed to map detector record offsets (which use TickCircuit - measurement indices) to influence map indices (which use DAG order). - - Args: - tc: TickCircuit to extract measurement order from. - - Returns: - List of qubit indices in measurement execution order. - """ - measurement_order = [] - - for tick_idx in range(tc.num_ticks()): - tick = tc.get_tick(tick_idx) - if tick is None: - continue - for gate in tick.gate_batches(): - gate_type = str(gate.gate_type) - if "MZ" in gate_type: - for qubit in gate.qubits: - if hasattr(qubit, "index"): - measurement_order.append(qubit.index()) - else: - measurement_order.append(int(qubit)) - - return measurement_order + """Extract measurement order from TickCircuit.""" + return get_measurement_order_from_tick_circuit(tc) def parse_dem_string(dem_str: str) -> dict[tuple, float]: @@ -174,7 +149,11 @@ def test_dem_mechanism_counts_match( **noise_params, decompose_errors=False, ) - stim_dem = generate_dem_from_tick_circuit_via_stim(tc, **noise_params) + stim_dem = generate_dem_from_tick_circuit_via_stim( + tc, + **noise_params, + decompose_errors=False, + ) comparison = compare_dems(pecos_dem, stim_dem) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py index 160bff950..909f43b89 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_result_tags.py @@ -29,7 +29,7 @@ import pytest from guppylang import guppy from guppylang.std.builtins import result -from guppylang.std.quantum import measure, qubit, x +from guppylang.std.quantum import cx, h, measure, qubit from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -40,9 +40,9 @@ # tag_c -> [2] (ordinals of the measurements the tags actually record, not # the order of the result() calls). # -# Each qubit gets a *different* number of single-qubit gates before measure -# (qa: 0, qb: 1, qc: 2). With p1 > 0 those gates contribute distinct error -# mechanisms touching only that qubit's measurement, so the DEMs for +# The nontrivial pre-history entangles qb and qc after an H on qb. With +# p1/p2 > 0 this produces distinct mechanisms for detectors anchored to qa, +# qb, and qc, so the DEMs for # detectors anchored to records [-3], [-2], [-1] differ in their (number of) # mechanisms / probabilities. A test asserting result_tags equals positional # records is then load-bearing: a wrong ordinal mapping would produce a @@ -54,9 +54,8 @@ def _scrambled_three_measurements() -> None: qa = qubit() qb = qubit() qc = qubit() - x(qb) - x(qc) - x(qc) + h(qb) + cx(qb, qc) a = measure(qa) b = measure(qb) c = measure(qc) @@ -65,7 +64,7 @@ def _scrambled_three_measurements() -> None: result("tag_b", b) -_NOISE = {"p1": 0.01, "p2": 0.0, "p_meas": 0.1, "p_prep": 0.005} +_NOISE = {"p1": 0.01, "p2": 0.02, "p_meas": 0.1, "p_prep": 0.005} def _from_guppy(detectors_json: str, *, observables_json: str = "[]") -> str: From 930d4f061d5d2a7113d1718ace2f151e7eece18c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 12:27:20 -0600 Subject: [PATCH 158/388] Thread check plan metadata through topology cache --- .../quantum-pecos/src/pecos/qec/surface/decode.py | 13 ++++++++++++- .../tests/qec/surface/test_check_plan.py | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index ce4899f1f..79343afd8 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1846,6 +1846,7 @@ def _surface_native_topology( runtime: object | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + check_plan: str | None = None, szz_physical_prefixes: bool = False, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" @@ -1859,6 +1860,8 @@ def _surface_native_topology( normalize_traced_qis_tick_circuit, ) + resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + interaction_basis = resolved_plan.interaction_basis patch = _cached_surface_patch(patch_key) tc = _build_surface_tick_circuit_for_native_model( patch, @@ -1906,7 +1909,6 @@ def _surface_native_topology( pauli_frame_lookup = PauliFrameLookup.from_circuit(dag, det_records, obs_records) num_pauli_sites = pauli_frame_lookup.num_pauli_sites - resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis) return _CachedNativeSurfaceTopology( dag_circuit=dag, influence_map=influence_map, @@ -1941,6 +1943,7 @@ def _cached_surface_native_topology( *, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + check_plan: str | None = None, szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", ) -> _CachedNativeSurfaceTopology: @@ -1955,6 +1958,7 @@ def _cached_surface_native_topology( include_idle_gates, twirl=twirl, interaction_basis=interaction_basis, + check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, ) @@ -2039,6 +2043,7 @@ def _cached_surface_native_dem_string( p_idle_z_quadratic_sine_rate: float | None = None, twirl: TwirlConfig | None = None, interaction_basis: str = "cx", + check_plan: str | None = None, resolved_check_plan_hash: str = "", ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" @@ -2074,6 +2079,7 @@ def _cached_surface_native_dem_string( include_idle_gates, twirl=twirl, interaction_basis=interaction_basis, + check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_check_plan_hash, ) @@ -2271,6 +2277,7 @@ def generate_circuit_level_dem_from_builder( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, ) return _dem_string_from_cached_surface_topology( @@ -2300,6 +2307,7 @@ def generate_circuit_level_dem_from_builder( "p_idle_z_quadratic_sine_rate": noise.p_idle_z_quadratic_sine_rate, "twirl": twirl, "interaction_basis": interaction_basis, + "check_plan": resolved_plan.plan_id, "resolved_check_plan_hash": resolved_plan.resolved_hash, } if dem_decomposition != "source_graphlike": @@ -4162,6 +4170,7 @@ def build_native_sampler( _noise_uses_dedicated_idle_noise(noise), twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_plan.resolved_hash, ) @@ -4199,6 +4208,7 @@ def build_native_sampler( p_idle_z_quadratic_sine_rate=noise.p_idle_z_quadratic_sine_rate, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() @@ -4259,6 +4269,7 @@ def build_native_sampler_from_dem( include_idle_gates=False, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 11e7829db..02ccf46f0 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -110,6 +110,7 @@ def test_native_sampler_records_resolved_check_plan() -> None: num_rounds=1, noise=NoiseModel(p2=0.001), check_plan="szz_current_v1", + sampling_model="influence_dem", ) assert sampler.interaction_basis == "szz" From 659f996f8077119362cc65bbf4202ecfed416cac Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 14 Jun 2026 13:46:48 -0600 Subject: [PATCH 159/388] Clean decoder study report whitespace --- examples/surface/results/inner_decoder_study_report.md | 1 - 1 file changed, 1 deletion(-) diff --git a/examples/surface/results/inner_decoder_study_report.md b/examples/surface/results/inner_decoder_study_report.md index 0925e703d..82f40a73d 100644 --- a/examples/surface/results/inner_decoder_study_report.md +++ b/examples/surface/results/inner_decoder_study_report.md @@ -180,4 +180,3 @@ binomial per (family, d, p, inner). `k/n` = failures / shots. | memory | belief_matching | 27.7 | 24.13 | 482.5 | | memory | tesseract | 16.9 | 44.44 | 888.8 | | memory | pecos_uf:bp | 26.3 | 49.61 | 992.2 | - From c1b63bb63234ffa268b5b72464360b9d5d76e529 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 14:56:30 -0600 Subject: [PATCH 160/388] Make QSystem platform explicit and selectable in HUGR-QIS compilation (default Helios) --- crates/pecos-hugr-qis/src/compiler.rs | 44 +++++++++++++---- crates/pecos-hugr-qis/src/lib.rs | 6 +++ crates/pecos-hugr-qis/src/prelude.rs | 4 +- crates/pecos-qis/src/engine_builder.rs | 47 ++++++++++++++++--- crates/pecos-qis/src/prelude.rs | 4 ++ .../src/hugr_compilation_bindings.rs | 28 +++++++++-- 6 files changed, 114 insertions(+), 19 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 7d1ddda66..32ba4dbd0 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -67,9 +67,6 @@ use crate::utils::read_hugr_envelope; const LLVM_MAIN: &str = "qmain"; const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; -// PECOS targets the Selene Helios QIS runtime; keep the qsystem lowering -// platform explicit so new tket-qsystem platforms do not silently change codegen. -const QSYSTEM_PLATFORM: QSystemPlatform = QSystemPlatform::Helios; // Extension registry is defined in the parent module @@ -86,6 +83,14 @@ pub struct CompileArgs { pub target_triple: Option, /// Optimization level pub opt_level: OptimizationLevel, + /// Target `QSystem` platform for lowering and codegen. + /// + /// PECOS targets the Quantinuum Helios QIS runtime (the Selene Helios + /// plugin), so this defaults to [`QSystemPlatform::Helios`]. Set it + /// explicitly to select another supported platform such as + /// [`QSystemPlatform::Sol`]; unsupported platforms are rejected with a + /// clear error when compilation starts. + pub platform: QSystemPlatform, } impl Default for CompileArgs { @@ -96,21 +101,39 @@ impl Default for CompileArgs { save_hugr: None, target_triple: None, opt_level: OptimizationLevel::Default, + // PECOS targets the Selene Helios QIS runtime by default. + platform: QSystemPlatform::Helios, } } } +/// Reject `QSystem` platforms that PECOS has not wired through its QIS pipeline. +/// +/// [`QSystemPlatform`] is `#[non_exhaustive]`; fail loudly on any future variant +/// rather than silently lowering for a platform PECOS has not validated +/// end-to-end (codegen extensions + Selene runtime). +fn ensure_supported_platform(platform: QSystemPlatform) -> Result<()> { + match platform { + QSystemPlatform::Helios | QSystemPlatform::Sol => Ok(()), + other => Err(anyhow!( + "Unsupported QSystem platform {other:?}: pecos-hugr-qis supports Helios and Sol. \ + Wire a newer tket-qsystem platform through the QIS codegen and Selene runtime \ + before selecting it." + )), + } +} + /// Process HUGR by applying required passes. /// /// Note: `QSystemPass` internally calls `inline_constant_functions` when the /// `llvm` feature is enabled, so we don't need to call it separately. -fn process_hugr(hugr: &mut Hugr) -> Result<()> { - QSystemPass::defaults(QSYSTEM_PLATFORM).run(hugr)?; +fn process_hugr(hugr: &mut Hugr, platform: QSystemPlatform) -> Result<()> { + QSystemPass::defaults(platform).run(hugr)?; Ok(()) } /// Build codegen extensions for LLVM generation -fn codegen_extensions() -> CodegenExtsMap<'static, Hugr> { +fn codegen_extensions(platform: QSystemPlatform) -> CodegenExtsMap<'static, Hugr> { use crate::array::SeleneHeapArrayCodegen; let pcg = QISPreludeCodegen; @@ -124,7 +147,7 @@ fn codegen_extensions() -> CodegenExtsMap<'static, Hugr> { .add_default_static_array_extensions() .add_default_borrow_array_extensions(pcg.clone()) .add_extension(FuturesCodegenExtension) - .add_extension(QSystemCodegenExtension::new(QSYSTEM_PLATFORM, pcg.clone())) + .add_extension(QSystemCodegenExtension::new(platform, pcg.clone())) .add_extension(RandomCodegenExtension) .add_extension(ResultsCodegenExtension::new( SeleneHeapArrayCodegen::LOWERING, @@ -196,7 +219,7 @@ fn get_module_with_std_exts<'c>( namer: Rc, hugr: &'c mut Hugr, ) -> Result> { - process_hugr(hugr)?; + process_hugr(hugr, args.platform)?; if let Some(filename) = &args.save_hugr { let file = fs::File::create(filename)?; @@ -208,7 +231,7 @@ fn get_module_with_std_exts<'c>( namer, hugr, &args.name, - Rc::new(codegen_extensions()), + Rc::new(codegen_extensions(args.platform)), ) } @@ -335,6 +358,9 @@ fn compile<'c, 'hugr: 'c>( ctx: &'c Context, hugr: &'hugr mut Hugr, ) -> Result> { + // Fail fast before any expensive work if the platform is unsupported. + ensure_supported_platform(args.platform)?; + log::debug!("starting primary compilation"); let namer = Rc::new(Namer::new("__hugr__.", true)); diff --git a/crates/pecos-hugr-qis/src/lib.rs b/crates/pecos-hugr-qis/src/lib.rs index c051153b4..d1e836c76 100644 --- a/crates/pecos-hugr-qis/src/lib.rs +++ b/crates/pecos-hugr-qis/src/lib.rs @@ -80,6 +80,9 @@ pub use result_tags::{extract_result_tag_measurements, measurement_op_count}; // Re-export inkwell's OptimizationLevel for convenience pub use tket::hugr::llvm::inkwell::OptimizationLevel; +// Re-export the QSystem platform selector used by `CompileArgs`/`HugrCompilerConfig` +pub use tket_qsystem::QSystemPlatform; + // Extension registry used throughout the crate // Convenience functions @@ -101,6 +104,8 @@ pub struct HugrCompilerConfig { pub target_triple: Option, /// Optimization level (defaults to O2) pub opt_level: Option, + /// Target `QSystem` platform (defaults to [`QSystemPlatform::Helios`] when `None`) + pub platform: Option, } impl HugrCompilerConfig { @@ -112,6 +117,7 @@ impl HugrCompilerConfig { save_hugr: self.save_hugr.clone(), target_triple: self.target_triple.clone(), opt_level: self.opt_level.unwrap_or(OptimizationLevel::Default), + platform: self.platform.unwrap_or(QSystemPlatform::Helios), } } } diff --git a/crates/pecos-hugr-qis/src/prelude.rs b/crates/pecos-hugr-qis/src/prelude.rs index 53367a625..fe022dfbf 100644 --- a/crates/pecos-hugr-qis/src/prelude.rs +++ b/crates/pecos-hugr-qis/src/prelude.rs @@ -22,7 +22,9 @@ pub use crate::{ }; // Re-export types -pub use crate::{CompileArgs, HugrCompiler, HugrCompilerConfig, OptimizationLevel}; +pub use crate::{ + CompileArgs, HugrCompiler, HugrCompilerConfig, OptimizationLevel, QSystemPlatform, +}; // Re-export helper functions pub use crate::{ diff --git a/crates/pecos-qis/src/engine_builder.rs b/crates/pecos-qis/src/engine_builder.rs index adc994260..3b243c855 100644 --- a/crates/pecos-qis/src/engine_builder.rs +++ b/crates/pecos-qis/src/engine_builder.rs @@ -14,6 +14,9 @@ pub struct QisEngineBuilder { program_source: Option, // Store original program source for loading operation_trace_dir: Option, operation_trace_collector: Option, + /// `QSystem` platform used when lowering HUGR programs (defaults to Helios). + #[cfg(feature = "hugr")] + platform: pecos_hugr_qis::QSystemPlatform, } impl Clone for QisEngineBuilder { @@ -29,6 +32,8 @@ impl Clone for QisEngineBuilder { program_source: self.program_source.clone(), operation_trace_dir: self.operation_trace_dir.clone(), operation_trace_collector: self.operation_trace_collector.clone(), + #[cfg(feature = "hugr")] + platform: self.platform, } } } @@ -44,9 +49,25 @@ impl QisEngineBuilder { program_source: None, operation_trace_dir: None, operation_trace_collector: None, + // PECOS targets the Selene Helios QIS runtime by default. + #[cfg(feature = "hugr")] + platform: pecos_hugr_qis::QSystemPlatform::Helios, } } + /// Select the `QSystem` platform used when lowering HUGR programs. + /// + /// Defaults to [`pecos_hugr_qis::QSystemPlatform::Helios`]. Selecting another + /// supported platform (e.g. `Sol`) lowers both the executed QIS and the + /// interface for that platform; a matching Selene runtime is required to + /// execute the result. + #[cfg(feature = "hugr")] + #[must_use] + pub fn platform(mut self, platform: pecos_hugr_qis::QSystemPlatform) -> Self { + self.platform = platform; + self + } + /// Dump Helios-collected operation chunks to the given directory as JSON. /// /// This captures the lowered QIS operation stream before it is compressed into @@ -213,9 +234,16 @@ impl QisEngineBuilder { } else if let Some(hugr_prog) = any_program.downcast_ref::() { #[cfg(feature = "hugr")] { - self.program_source = Some(pecos_hugr_qis::compile_hugr_bytes_to_string( - &hugr_prog.hugr, - )?); + let args = pecos_hugr_qis::CompileArgs { + platform: self.platform, + ..Default::default() + }; + self.program_source = Some( + pecos_hugr_qis::compile_hugr_bytes_to_string_with_options( + &hugr_prog.hugr, + &args, + )?, + ); } #[cfg(not(feature = "hugr"))] { @@ -233,9 +261,16 @@ impl QisEngineBuilder { if let Some(qis_prog) = any_program.downcast_ref::() { log::debug!("Building interface from QIS program"); builder.build_from_qis_program(qis_prog.clone())? - } else if let Some(hugr_prog) = any_program.downcast_ref::() { - log::debug!("Building interface from HUGR program"); - builder.build_from_hugr_program(hugr_prog.clone())? + } else if any_program.is::() { + // `program_source` already holds the QIS lowered with the + // selected platform above; build the interface from it + // instead of re-compiling, so the interface and the executed + // QIS stay on the same platform. + log::debug!("Building interface from compiled HUGR program source"); + let source = self.program_source.clone().ok_or_else(|| { + PecosError::Processing("HUGR program produced no QIS source".to_string()) + })?; + builder.build_from_qis_program(pecos_programs::Qis::from_string(&source))? } else { // Unknown type, use default conversion with the default backend (Helios) log::debug!("Unknown program type, using into_qis_interface"); diff --git a/crates/pecos-qis/src/prelude.rs b/crates/pecos-qis/src/prelude.rs index 05adeebe0..81cc6f587 100644 --- a/crates/pecos-qis/src/prelude.rs +++ b/crates/pecos-qis/src/prelude.rs @@ -16,6 +16,10 @@ pub use crate::runtime::{QisRuntime, RuntimeError}; pub use crate::ccengine::QisEngine; pub use crate::engine_builder::{QisEngineBuilder, qis_engine}; +// QSystem platform selector for HUGR lowering (re-exported for `.platform(...)`) +#[cfg(feature = "hugr")] +pub use pecos_hugr_qis::QSystemPlatform; + // Program types pub use crate::program::{ InterfaceChoice, IntoQisInterface, ProgramType, QisEngineProgram, QisInterfaceBuilder, diff --git a/python/pecos-rslib/src/hugr_compilation_bindings.rs b/python/pecos-rslib/src/hugr_compilation_bindings.rs index 697f3c520..d3da7798a 100644 --- a/python/pecos-rslib/src/hugr_compilation_bindings.rs +++ b/python/pecos-rslib/src/hugr_compilation_bindings.rs @@ -5,6 +5,17 @@ use std::fs; use pyo3::prelude::*; use pyo3::types::PyDict; +/// Map a platform name to a [`QSystemPlatform`], failing loudly on unknown input. +fn parse_qsystem_platform(name: &str) -> PyResult { + match name.to_ascii_lowercase().as_str() { + "helios" => Ok(QSystemPlatform::Helios), + "sol" => Ok(QSystemPlatform::Sol), + other => Err(PyErr::new::(format!( + "Unknown QSystem platform {other:?}; expected 'helios' or 'sol'" + ))), + } +} + /// Compile HUGR to QIS (LLVM IR with quantum instructions) /// /// This function takes HUGR bytes (envelope format) and compiles them to QIS, @@ -13,12 +24,22 @@ use pyo3::types::PyDict; /// Args: /// `hugr_bytes`: HUGR program as envelope bytes /// `output_path`: Optional path to write the QIS output +/// `platform`: Target `QSystem` platform, `'helios'` (default) or `'sol'` /// /// Returns: /// QIS (LLVM IR) as a string -#[pyfunction(name = "compile_hugr_to_qis", signature = (hugr_bytes, output_path=None))] -pub fn py_compile_hugr_to_qis(hugr_bytes: &[u8], output_path: Option<&str>) -> PyResult { - let llvm_ir = compile_hugr_bytes_to_string(hugr_bytes) +#[pyfunction(name = "compile_hugr_to_qis", signature = (hugr_bytes, output_path=None, platform=None))] +pub fn py_compile_hugr_to_qis( + hugr_bytes: &[u8], + output_path: Option<&str>, + platform: Option<&str>, +) -> PyResult { + let mut args = CompileArgs::default(); + if let Some(name) = platform { + args.platform = parse_qsystem_platform(name)?; + } + + let llvm_ir = compile_hugr_bytes_to_string_with_options(hugr_bytes, &args) .map_err(|e| PyErr::new::(e.to_string()))?; if let Some(path) = output_path { @@ -48,6 +69,7 @@ pub fn get_compilation_backends(py: Python<'_>) -> PyResult> { backends.set_item("hugr-llvm", hugr_llvm_backend)?; result.set_item("backends", backends)?; + result.set_item("qsystem_platforms", vec!["helios", "sol"])?; Ok(result.into()) } From 838c349f7ab776de584735560baf1d64044e5e4e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 15:08:33 -0600 Subject: [PATCH 161/388] Apply rustfmt to QSystem platform threading --- crates/pecos-qis/src/engine_builder.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/crates/pecos-qis/src/engine_builder.rs b/crates/pecos-qis/src/engine_builder.rs index 3b243c855..d4384aa2c 100644 --- a/crates/pecos-qis/src/engine_builder.rs +++ b/crates/pecos-qis/src/engine_builder.rs @@ -238,12 +238,11 @@ impl QisEngineBuilder { platform: self.platform, ..Default::default() }; - self.program_source = Some( - pecos_hugr_qis::compile_hugr_bytes_to_string_with_options( + self.program_source = + Some(pecos_hugr_qis::compile_hugr_bytes_to_string_with_options( &hugr_prog.hugr, &args, - )?, - ); + )?); } #[cfg(not(feature = "hugr"))] { From 5b6fe83acf0e5ab00ac3ff2566c2ce479c030eaf Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 15:14:14 -0600 Subject: [PATCH 162/388] Make the automatic Clifford-angle rotation step defer to user-registered GateDefinitions decompositions (gate on has_decomposition in both dispatch chains) so explicit decompositions are not silently bypassed at Clifford angles --- exp/pecos-neo/src/runner.rs | 87 +++++++++++++++++++++++++++++++++---- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/exp/pecos-neo/src/runner.rs b/exp/pecos-neo/src/runner.rs index a68251a55..e16b73b30 100644 --- a/exp/pecos-neo/src/runner.rs +++ b/exp/pecos-neo/src/runner.rs @@ -22,8 +22,13 @@ //! 1. **Overrides**: Custom executors registered via `GateOverrides` //! 2. **Clifford trait methods**: Core Clifford gates via `CliffordGateable` //! 3. **Rotation trait methods**: If `rotations()` constructor was used -//! 4. **Decomposition**: Expand using `GateDefinitions` -//! 5. **Error**: If none of the above apply +//! 4. **Clifford-angle rotations**: Automatic fallback that runs a +//! rotation gate at a Clifford angle (e.g. `RZ(pi/2)` as `S`) on a +//! Clifford backend — but ONLY when the gate has no user-registered +//! decomposition (step 5), so an explicit decomposition is never +//! silently bypassed. +//! 5. **Decomposition**: Expand using `GateDefinitions` +//! 6. **Error**: If none of the above apply //! //! Before and after each gate, noise events and user handlers are dispatched. //! @@ -1239,7 +1244,11 @@ impl CircuitRunner { return Ok(()); } - // Execute through unified precedence chain + // Execute through unified precedence chain. The automatic + // Clifford-angle rotation step is a FALLBACK gated on the + // gate having no user-registered decomposition, so an + // explicit GateDefinitions decomposition wins at Clifford + // angles too (see execute_gate for the rationale). let mut rotation_attempt = CliffordRotationAttempt::NotARotation; let executed = self.try_execute_override(sim, gate_id, qubits, command.angles.as_slice()) @@ -1247,7 +1256,7 @@ impl CircuitRunner { || self.rotation_executor.is_some_and(|executor| { executor(sim, gate_id, command.angles.as_slice(), qubits) }) - || { + || (!self.definitions.has_decomposition(gate_id) && { rotation_attempt = Self::try_execute_clifford_rotation( sim, gate_id, @@ -1255,7 +1264,7 @@ impl CircuitRunner { command.angles.as_slice(), ); rotation_attempt == CliffordRotationAttempt::Executed - }; + }); if !executed { self.execute_via_decomposition( @@ -1437,18 +1446,23 @@ impl CircuitRunner { return Ok(()); } - // Try execution in order of precedence + // Try execution in order of precedence. The automatic + // Clifford-angle rotation step is a FALLBACK: it fires only when + // the gate has no user-registered decomposition, so an explicit + // GateDefinitions decomposition wins at Clifford angles too + // (registering a decomposition is an explicit user choice, like + // GateOverrides, and must not be silently bypassed). let mut rotation_attempt = CliffordRotationAttempt::NotARotation; let executed = self.try_execute_override(sim, gate_id, qubits, angles) || Self::try_execute_clifford(sim, gate_id, qubits) || self .rotation_executor .is_some_and(|executor| executor(sim, gate_id, angles, qubits)) - || { + || (!self.definitions.has_decomposition(gate_id) && { rotation_attempt = Self::try_execute_clifford_rotation(sim, gate_id, qubits, angles); rotation_attempt == CliffordRotationAttempt::Executed - }; + }); if !executed { self.execute_via_decomposition(sim, gate_id, qubits, angles, depth) @@ -3134,6 +3148,63 @@ mod tests { assert!(err.to_string().contains("state_vector()")); } + #[test] + fn user_registered_decomposition_wins_over_clifford_rotation_at_clifford_angle() { + use crate::extensible::{DecompEntry, DecompOp, Decomposition, GateDefinitions}; + use std::sync::Arc; + + // The automatic Clifford-angle rotation step is a fallback: an + // explicit user-registered decomposition must take precedence even + // at a Clifford angle, instead of being silently bypassed. + // + // Register RZ to "decompose" into a single X gate (not physically + // RZ, but distinguishable): if the decomposition runs, |0> -> |1> + // and the measurement is 1; if the Clifford-rotation fast path runs + // instead, RZ(pi/2) = S leaves |0> unchanged and the measurement + // is 0. + let mut defs = GateDefinitions::new(); + defs.set_decomposition( + gates::RZ, + DecompEntry { + requires: crate::extensible::GateSupportSet::from_iter([gates::X]), + decomposition: Decomposition::Dynamic(Arc::new(vec![DecompOp::gate1(gates::X, 0)])), + }, + ); + + let circuit = CommandBuilder::new() + .pz(&[0]) + .rz(&[0], Angle64::from_radians(std::f64::consts::FRAC_PI_2)) + .mz(&[0]) + .build(); + + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::with_definitions(defs).with_seed(42); + let outcomes = runner.apply_circuit(&mut state, &circuit).unwrap(); + + let bit = outcomes.iter().next().expect("one measurement").outcome; + assert!( + bit, + "the registered RZ->X decomposition must run (measure 1), not the \ + Clifford-rotation S fast path (which would measure 0)" + ); + + // Control: with no registered decomposition, the Clifford-rotation + // fast path executes RZ(pi/2) = S and |0> stays |0> (measure 0). + let control = CommandBuilder::new() + .pz(&[0]) + .rz(&[0], Angle64::from_radians(std::f64::consts::FRAC_PI_2)) + .mz(&[0]) + .build(); + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::new().with_seed(42); + let outcomes = runner.apply_circuit(&mut state, &control).unwrap(); + let bit = outcomes.iter().next().expect("one measurement").outcome; + assert!( + !bit, + "default RZ(pi/2) = S leaves |0> unchanged (measure 0)" + ); + } + // --- apply_gate (interpreter mode) tests --- #[test] From 547ed25b53d6bc085e308134b1869c89989ea223 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 18:16:58 -0600 Subject: [PATCH 163/388] Bump pyo3 to 0.29 to fix RUSTSEC-2026-0176 and RUSTSEC-2026-0177 --- Cargo.lock | 31 ++++++++++--------------------- Cargo.toml | 2 +- 2 files changed, 11 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 99311349c..1c7b44668 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4888,9 +4888,9 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.28.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" +checksum = "cd274650b21d4bfc26a0a47587962c1edb425f69287324355cd040c3ea66071c" dependencies = [ "libc", "num-complex 0.4.6", @@ -4903,19 +4903,18 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.28.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" +checksum = "c5e2a7d2f0d013342f295c048ad19237add5154a55b1c5a254c0ec93d4109078" dependencies = [ - "python3-dll-a", "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.28.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" +checksum = "ca85c467da1bbc8d866eea5deff9cf29ea5f7785054a17da36e65bda9c05845b" dependencies = [ "libc", "pyo3-build-config", @@ -4923,9 +4922,9 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.28.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" +checksum = "9ac53762fd065daa3194dd09337a38bd793a188100fd1a9304c4ab312d901771" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -4935,26 +4934,16 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" -version = "0.28.3" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" +checksum = "4ca3a1557399783172dc5bf39cfca835157732532cba56b71d2292161e53b362" dependencies = [ "heck 0.5.0", "proc-macro2", - "pyo3-build-config", "quote", "syn 2.0.117", ] -[[package]] -name = "python3-dll-a" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d80ba7540edb18890d444c5aa8e1f1f99b1bdf26fb26ae383135325f4a36042b" -dependencies = [ - "cc", -] - [[package]] name = "quick-error" version = "1.2.3" diff --git a/Cargo.toml b/Cargo.toml index 40c106469..23de5d02f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,7 +54,7 @@ cargo_metadata = "0.23" # at build time, which is what we want when maturin builds the cdylib but is # fatal for plain `cargo test`. Each pecos-rslib* crate gates them behind its # own `extension-module` feature; maturin opts in via pyproject.toml. -pyo3 = "0.28" +pyo3 = "0.29" # --- C/C++ FFI & build --- bindgen = "0.72" From 2f51bbbaa17b11f198f93fdd5489aa2a79facdc7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 18:41:46 -0600 Subject: [PATCH 164/388] Narrow the Sampling::SubsetSimulation variant doc to match the function/module accuracy caveat (the multi-level estimator biases upward; deep-rare-event estimates are approximate), per Codex review of the remaining-work doc --- exp/pecos-neo/src/tool/simulation.rs | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index a42bb89b5..5722a6d5a 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -1296,11 +1296,16 @@ pub enum Sampling { config: ImportanceSamplingBuilder, }, - /// Subset simulation for very rare event estimation (~1e-6 and below). - /// - /// Decomposes the rare event probability into a product of conditional - /// probabilities across adaptive levels. Produces an estimate in - /// [`SimulationResults::subset`] instead of per-shot outcomes. + /// Subset simulation: decompose a rare-event probability into a product + /// of conditional probabilities across adaptive levels. Produces an + /// estimate in [`SimulationResults::subset`] instead of per-shot + /// outcomes. + /// + /// Accuracy caveat: the current multi-level estimator biases upward + /// once more than one level engages (see the `sampling::subset` module + /// docs); treat deep-rare-event estimates as approximate until the + /// estimator overhaul lands. Single-level runs behave like direct + /// Monte Carlo and are unbiased. /// /// Use the [`subset_simulation()`] builder function to create this variant. SubsetSimulation { From 3a63c1e9e26b95c056c54c489d50a03344f1f6fa Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 19:16:19 -0600 Subject: [PATCH 165/388] Harden parser/binding robustness: SparseDem rejects malformed D/L tokens like the other parsers, and the two algorithm-decoder bindings return PyErr instead of panicking on empty segments or missing boundary-gate fields --- crates/pecos-decoder-core/src/dem.rs | 58 ++++++++++++--- .../src/fault_tolerance_bindings.rs | 73 ++++++++++++------- 2 files changed, 95 insertions(+), 36 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index eafc3dbc2..c2812664b 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -302,15 +302,25 @@ impl SparseDem { let mut det_set = std::collections::BTreeSet::new(); let mut obs_set = std::collections::BTreeSet::new(); for token in tokens.split('^').flat_map(str::split_whitespace) { - if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) - { + // Reject a malformed `D` / `L` token rather than + // silently dropping it -- matches DemCheckMatrix / + // DemMatchingGraph so all parsers agree on what is valid. + if let Some(d_str) = token.strip_prefix('D') { + let d: u32 = d_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid detector: {token}" + )) + })?; if !det_set.remove(&d) { det_set.insert(d); } max_detector = Some(max_detector.map_or(d, |m| m.max(d))); - } else if let Some(l) = - token.strip_prefix('L').and_then(|s| s.parse::().ok()) - { + } else if let Some(l_str) = token.strip_prefix('L') { + let l: u32 = l_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid observable: {token}" + )) + })?; if !obs_set.remove(&l) { obs_set.insert(l); } @@ -323,13 +333,22 @@ impl SparseDem { let mut detectors = Vec::new(); let mut observables = Vec::new(); for token in tokens.split_whitespace() { - if let Some(d) = token.strip_prefix('D').and_then(|s| s.parse::().ok()) - { + // Reject malformed `D` / `L` (parser-agreement + // contract, see the decomposed branch above). + if let Some(d_str) = token.strip_prefix('D') { + let d: u32 = d_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid detector: {token}" + )) + })?; detectors.push(d); max_detector = Some(max_detector.map_or(d, |m| m.max(d))); - } else if let Some(l) = - token.strip_prefix('L').and_then(|s| s.parse::().ok()) - { + } else if let Some(l_str) = token.strip_prefix('L') { + let l: u32 = l_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!( + "Invalid observable: {token}" + )) + })?; observables.push(l); max_observable = Some(max_observable.map_or(l, |m| m.max(l))); } @@ -1179,6 +1198,25 @@ mod tests { assert_eq!(dets, 8, "parse_dem_metadata must count bare detector D7"); } + #[test] + fn test_parsers_reject_malformed_detector_token() { + // A `D` / `L` token in an error line is malformed. All three + // error-line parsers must reject it (not silently drop it) so they agree + // on what a valid DEM is. SparseDem previously skipped these silently. + let bad_det = "error(0.01) Dfoo L0\n"; + assert!(SparseDem::from_dem_str(bad_det).is_err()); + assert!(DemCheckMatrix::from_dem_str(bad_det).is_err()); + assert!(DemMatchingGraph::from_dem_str(bad_det).is_err()); + + let bad_obs = "error(0.01) D0 Lbar\n"; + assert!(SparseDem::from_dem_str(bad_obs).is_err()); + assert!(DemCheckMatrix::from_dem_str(bad_obs).is_err()); + assert!(DemMatchingGraph::from_dem_str(bad_obs).is_err()); + + // A well-formed line still parses. + assert!(SparseDem::from_dem_str("error(0.01) D0 D1 L0\n").is_ok()); + } + #[test] fn test_parser_observable_count_is_max_plus_one_for_noncontiguous_ids() { // Only L2 present: index-addressed buffers need 3 slots, not 1. All diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index a468c9bbd..c2fc89d2d 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4936,6 +4936,23 @@ impl PyWindowedLogicalSubgraphDecoder { // Logical Algorithm Decoder (Python class) // ============================================================================= +/// Read a required `u32` bit field off a boundary-gate descriptor dict, returning +/// a clear `PyErr` (not a panic) when a malformed descriptor omits the field. +/// Shared by the two algorithm-decoder bindings below. +fn req_bit( + dict: &pyo3::Bound<'_, pyo3::types::PyDict>, + key: &str, + gate_type: &str, +) -> PyResult { + dict.get_item(key)? + .ok_or_else(|| { + PyErr::new::(format!( + "boundary gate '{gate_type}' missing required field '{key}'" + )) + })? + .extract() +} + /// Decoder for logical quantum algorithms with per-segment logical-subgraph decoder and /// Pauli frame propagation at transversal gate boundaries. /// @@ -4986,7 +5003,11 @@ impl PyLogicalAlgorithmDecoder { .extract()?; // Parse stab_coords from the first segment (original orientation) - let first_seg = &seg_list[0]; + let first_seg = seg_list.first().ok_or_else(|| { + PyErr::new::( + "algorithm descriptor has no segments", + ) + })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? .ok_or_else(|| PyErr::new::("stab_coords"))? @@ -5048,32 +5069,28 @@ impl PyLogicalAlgorithmDecoder { .extract()?; match gate_type.as_str() { "Hadamard" => { - let x: u32 = gate_dict.get_item("x_obs_bit")?.unwrap().extract()?; - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; bg_vec.push(BoundaryGate::Hadamard { - x_obs_bit: x, - z_obs_bit: z, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "Cnot" => { bg_vec.push(BoundaryGate::Cnot { - ctrl_x_bit: gate_dict.get_item("ctrl_x_bit")?.unwrap().extract()?, - ctrl_z_bit: gate_dict.get_item("ctrl_z_bit")?.unwrap().extract()?, - tgt_x_bit: gate_dict.get_item("tgt_x_bit")?.unwrap().extract()?, - tgt_z_bit: gate_dict.get_item("tgt_z_bit")?.unwrap().extract()?, + ctrl_x_bit: req_bit(gate_dict, "ctrl_x_bit", &gate_type)?, + ctrl_z_bit: req_bit(gate_dict, "ctrl_z_bit", &gate_type)?, + tgt_x_bit: req_bit(gate_dict, "tgt_x_bit", &gate_type)?, + tgt_z_bit: req_bit(gate_dict, "tgt_z_bit", &gate_type)?, }); } "SGate" => { - let x: u32 = gate_dict.get_item("x_obs_bit")?.unwrap().extract()?; - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; bg_vec.push(BoundaryGate::SGate { - x_obs_bit: x, - z_obs_bit: z, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "TGateInjection" => { - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; - let a: u32 = gate_dict.get_item("ancilla_z_bit")?.unwrap().extract()?; + let z = req_bit(gate_dict, "z_obs_bit", &gate_type)?; + let a = req_bit(gate_dict, "ancilla_z_bit", &gate_type)?; bg_vec.push(BoundaryGate::TGateInjection { z_obs_bit: z, ancilla_z_bit: a, @@ -5241,7 +5258,11 @@ impl PyLogicalCircuitDecoder { .extract()?; // Parse stab_coords from first segment - let first_seg = &seg_list[0]; + let first_seg = seg_list.first().ok_or_else(|| { + PyErr::new::( + "algorithm descriptor has no segments", + ) + })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? .ok_or_else(|| PyErr::new::("stab_coords"))? @@ -5303,27 +5324,27 @@ impl PyLogicalCircuitDecoder { match gate_type.as_str() { "Hadamard" => { bg_vec.push(BoundaryGate::Hadamard { - x_obs_bit: gate_dict.get_item("x_obs_bit")?.unwrap().extract()?, - z_obs_bit: gate_dict.get_item("z_obs_bit")?.unwrap().extract()?, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "Cnot" => { bg_vec.push(BoundaryGate::Cnot { - ctrl_x_bit: gate_dict.get_item("ctrl_x_bit")?.unwrap().extract()?, - ctrl_z_bit: gate_dict.get_item("ctrl_z_bit")?.unwrap().extract()?, - tgt_x_bit: gate_dict.get_item("tgt_x_bit")?.unwrap().extract()?, - tgt_z_bit: gate_dict.get_item("tgt_z_bit")?.unwrap().extract()?, + ctrl_x_bit: req_bit(gate_dict, "ctrl_x_bit", &gate_type)?, + ctrl_z_bit: req_bit(gate_dict, "ctrl_z_bit", &gate_type)?, + tgt_x_bit: req_bit(gate_dict, "tgt_x_bit", &gate_type)?, + tgt_z_bit: req_bit(gate_dict, "tgt_z_bit", &gate_type)?, }); } "SGate" => { bg_vec.push(BoundaryGate::SGate { - x_obs_bit: gate_dict.get_item("x_obs_bit")?.unwrap().extract()?, - z_obs_bit: gate_dict.get_item("z_obs_bit")?.unwrap().extract()?, + x_obs_bit: req_bit(gate_dict, "x_obs_bit", &gate_type)?, + z_obs_bit: req_bit(gate_dict, "z_obs_bit", &gate_type)?, }); } "TGateInjection" => { - let z: u32 = gate_dict.get_item("z_obs_bit")?.unwrap().extract()?; - let a: u32 = gate_dict.get_item("ancilla_z_bit")?.unwrap().extract()?; + let z = req_bit(gate_dict, "z_obs_bit", &gate_type)?; + let a = req_bit(gate_dict, "ancilla_z_bit", &gate_type)?; bg_vec.push(BoundaryGate::TGateInjection { z_obs_bit: z, ancilla_z_bit: a, From a0af8f894852f2931d452bc7d565712593f2fd47 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 21:07:43 -0600 Subject: [PATCH 166/388] Address residual review nits: reject boundary-gate observable bits >=64 in the descriptor bindings (+ debug_assert and obs_bits helper at the apply site), and correct the predecoder zero-alloc docstring --- .../src/logical_algorithm.rs | 74 +++++++++++++++++++ crates/pecos-uf-decoder/src/decoder.rs | 7 +- .../src/fault_tolerance_bindings.rs | 13 +++- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index dc5c283a5..12220e5a0 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -77,6 +77,32 @@ impl BoundaryGate { pub fn is_decision_point(&self) -> bool { matches!(self, Self::TGateInjection { .. }) } + + /// All observable-frame bit indices this gate references. Each must be < 64 + /// (they index a `u64` frame). Used to assert that invariant at apply time. + #[must_use] + pub fn obs_bits(&self) -> Vec { + match self { + Self::Hadamard { + x_obs_bit, + z_obs_bit, + } + | Self::SGate { + x_obs_bit, + z_obs_bit, + } => vec![*x_obs_bit, *z_obs_bit], + Self::Cnot { + ctrl_x_bit, + ctrl_z_bit, + tgt_x_bit, + tgt_z_bit, + } => vec![*ctrl_x_bit, *ctrl_z_bit, *tgt_x_bit, *tgt_z_bit], + Self::TGateInjection { + z_obs_bit, + ancilla_z_bit, + } => vec![*z_obs_bit, *ancilla_z_bit], + } + } } /// Full description of a logical algorithm for decoding. @@ -149,6 +175,14 @@ impl LogicalAlgorithmDecoder { /// Apply boundary gate to a Pauli frame. /// Used when consuming the frame at logical operations. pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) { + // All bits index the u64 observable frame, so each must be < 64. The + // Python descriptor binding enforces this fail-loud at construction; this + // documents and guards the invariant for direct Rust callers (a shift by + // >= 64 is otherwise an overflow panic in debug / unspecified in release). + debug_assert!( + gate.obs_bits().iter().all(|&b| b < 64), + "boundary gate observable bit >= 64" + ); match gate { BoundaryGate::Hadamard { x_obs_bit, @@ -812,6 +846,46 @@ mod tests { assert_eq!(frame, 0b10); // X became Z } + #[test] + fn test_boundary_gate_obs_bits_cover_all_fields() { + // The apply-time `< 64` assert relies on obs_bits() listing EVERY bit a + // gate references -- a missed field would let an out-of-range shift slip. + assert_eq!( + BoundaryGate::Hadamard { + x_obs_bit: 2, + z_obs_bit: 5 + } + .obs_bits(), + vec![2, 5] + ); + assert_eq!( + BoundaryGate::Cnot { + ctrl_x_bit: 1, + ctrl_z_bit: 2, + tgt_x_bit: 3, + tgt_z_bit: 4 + } + .obs_bits(), + vec![1, 2, 3, 4] + ); + assert_eq!( + BoundaryGate::SGate { + x_obs_bit: 7, + z_obs_bit: 9 + } + .obs_bits(), + vec![7, 9] + ); + assert_eq!( + BoundaryGate::TGateInjection { + z_obs_bit: 6, + ancilla_z_bit: 8 + } + .obs_bits(), + vec![6, 8] + ); + } + #[test] fn test_cnot_frame() { let mut frame = 0b0001u64; // X on control (bit 0) diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 974691b8a..85944b203 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -23,7 +23,12 @@ //! 5. Peel a spanning forest (BFS from boundary) to extract the correction: //! an edge is in the correction iff its subtree has odd parity. //! -//! All data structures are flat arrays. Zero per-shot allocation after init. +//! All data structures are flat arrays; the full grow+peel decode path does zero +//! per-shot allocation after init. The optional cluster predecoder +//! ([`UfDecoder::predecode_clusters`]) is the exception: it runs on `&self` +//! (before the per-shot state is reset) and allocates two small scratch buffers +//! per call. It is only entered for trivial syndromes (0-2 isolated defects), +//! falling through to the zero-alloc path otherwise. use pecos_decoder_core::correlated_decoder::MatchingDecoder; use pecos_decoder_core::dem::DemMatchingGraph; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index c2fc89d2d..2da8cc3d7 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4944,13 +4944,22 @@ fn req_bit( key: &str, gate_type: &str, ) -> PyResult { - dict.get_item(key)? + let bit: u32 = dict + .get_item(key)? .ok_or_else(|| { PyErr::new::(format!( "boundary gate '{gate_type}' missing required field '{key}'" )) })? - .extract() + .extract()?; + // Every boundary-gate bit indexes a u64 observable frame (`1u64 << bit`), so + // it must be < 64 -- reject out-of-range here rather than shift-overflow later. + if bit >= 64 { + return Err(PyErr::new::(format!( + "boundary gate '{gate_type}' field '{key}' = {bit} exceeds the 64-observable frame limit" + ))); + } + Ok(bit) } /// Decoder for logical quantum algorithms with per-segment logical-subgraph decoder and From 76846a829b5726228e8074ae3e62bb5858a59656 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 21:24:46 -0600 Subject: [PATCH 167/388] Guard subset_simulation against silently returning a biased estimate: default to a single unbiased level and require explicit .allow_biased_multilevel() to engage the (biased) multi-level path, fail-fast at build otherwise; mirrored in the Python binding --- exp/pecos-neo/src/tool/simulation.rs | 95 ++++++++++++++++++- .../pecos-rslib-exp/src/sim_neo_bindings.rs | 32 ++++++- .../qec/test_sim_neo_subset_simulation.py | 34 +++++++ 3 files changed, 157 insertions(+), 4 deletions(-) diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 5722a6d5a..352e33eec 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -1129,6 +1129,10 @@ pub struct SubsetSimulationBuilder { threshold_fraction: f64, max_levels: usize, min_conditional_prob: f64, + /// Explicit acknowledgment that the multi-level estimator is biased. + /// Required before `max_levels > 1` is allowed (see + /// [`SubsetSimulationBuilder::allow_biased_multilevel`]). + allow_biased_multilevel: bool, score: Option, failure: Option, } @@ -1139,6 +1143,7 @@ impl std::fmt::Debug for SubsetSimulationBuilder { .field("samples_per_level", &self.samples_per_level) .field("threshold_fraction", &self.threshold_fraction) .field("max_levels", &self.max_levels) + .field("allow_biased_multilevel", &self.allow_biased_multilevel) .field("min_conditional_prob", &self.min_conditional_prob) .field("score", &self.score.as_ref().map(|_| "Fn(..) -> f64")) .field("failure", &self.failure.as_ref().map(|_| "Fn(..) -> bool")) @@ -1179,13 +1184,35 @@ impl SubsetSimulationBuilder { self } - /// Set the maximum number of levels before giving up (default 20). + /// Set the maximum number of levels before giving up. + /// + /// Defaults to 1 (a single level, which is an unbiased direct-Monte- + /// Carlo estimate of the failure fraction). Setting more than one level + /// engages the multi-level estimator, which is currently BIASED upward + /// (the resample is unconditioned; see the `sampling::subset` module + /// docs). Requesting `levels > 1` therefore also requires an explicit + /// [`Self::allow_biased_multilevel`] acknowledgment, or `.run()` fails + /// at build time. #[must_use] pub fn max_levels(mut self, levels: usize) -> Self { self.max_levels = levels; self } + /// Acknowledge and accept the known upward bias of the multi-level + /// subset estimator, enabling `max_levels > 1`. + /// + /// Without this, [`subset_simulation`] runs a single unbiased level + /// (direct Monte Carlo). The multi-level path provides rare-event + /// reach but is currently biased (see the `sampling::subset` module + /// docs and the estimator-overhaul follow-up); call this only when an + /// approximate, biased estimate is acceptable. + #[must_use] + pub fn allow_biased_multilevel(mut self) -> Self { + self.allow_biased_multilevel = true; + self + } + /// Set the minimum conditional probability before declaring the /// failure event unreachable (default 1e-6). #[must_use] @@ -1251,8 +1278,12 @@ pub fn subset_simulation(samples_per_level: usize) -> SubsetSimulationBuilder { SubsetSimulationBuilder { samples_per_level, threshold_fraction: defaults.threshold_fraction, - max_levels: defaults.max_levels, + // Default to a single, unbiased level. The multi-level estimator is + // biased upward, so engaging it (`max_levels > 1`) requires an + // explicit `.allow_biased_multilevel()` acknowledgment. + max_levels: 1, min_conditional_prob: defaults.min_conditional_prob, + allow_biased_multilevel: false, score: None, failure: None, } @@ -2726,6 +2757,13 @@ impl SimNeoBuilder { "Subset simulation requires both .score(..) and .failure(..) on the \ subset_simulation(..) builder; neither has a sensible default." ); + assert!( + ss_config.max_levels <= 1 || ss_config.allow_biased_multilevel, + "subset_simulation with max_levels > 1 engages the multi-level estimator, \ + which is currently biased upward (the resample is unconditioned). Either \ + keep a single level (an unbiased direct-Monte-Carlo failure-fraction \ + estimate) or call .allow_biased_multilevel() to accept the documented bias." + ); let circuit = match &source { ProgramSource::Static(circuit) => circuit.clone(), _ => panic!( @@ -5382,6 +5420,59 @@ mod tests { .build(); } + #[test] + #[should_panic(expected = "biased upward")] + fn test_sim_neo_subset_multilevel_without_opt_in_is_build_error() { + // max_levels > 1 engages the biased multi-level estimator and must + // be refused unless explicitly acknowledged. + let _ = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling( + subset_simulation(100) + .score(count_ones) + .failure(all_ones) + .max_levels(5), + ) + .build(); + } + + #[test] + fn test_sim_neo_subset_multilevel_runs_with_opt_in() { + // With the explicit acknowledgment, the multi-level path runs. + let results = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling( + subset_simulation(500) + .score(count_ones) + .failure(all_ones) + .max_levels(5) + .allow_biased_multilevel(), + ) + .seed(42) + .run(); + assert!( + results.subset.is_some(), + "multi-level opt-in must produce an estimate" + ); + } + + #[test] + fn test_sim_neo_subset_default_is_single_level() { + // The default (no .max_levels) is a single unbiased level: exactly + // one level in the result. + let results = sim_neo(three_qubit_h_circuit()) + .auto() + .sampling(subset_simulation(500).score(count_ones).failure(all_ones)) + .seed(42) + .run(); + let subset = results.subset.expect("subset estimate"); + assert_eq!( + subset.levels.len(), + 1, + "default subset_simulation must run a single (unbiased) level" + ); + } + #[test] fn test_sim_neo_single_worker_matches_parallel() { // Critical test: 1 worker and multiple workers should produce identical diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index d9e7ff6b5..aef07e545 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -586,6 +586,7 @@ pub struct PySubsetSimulationBuilder { threshold_fraction: f64, max_levels: usize, min_conditional_prob: f64, + allow_biased_multilevel: bool, score: Option>, failure: Option>, } @@ -597,6 +598,7 @@ impl Clone for PySubsetSimulationBuilder { threshold_fraction: self.threshold_fraction, max_levels: self.max_levels, min_conditional_prob: self.min_conditional_prob, + allow_biased_multilevel: self.allow_biased_multilevel, score: self.score.as_ref().map(|f| f.clone_ref(py)), failure: self.failure.as_ref().map(|f| f.clone_ref(py)), }) @@ -629,13 +631,28 @@ impl PySubsetSimulationBuilder { c } - /// Maximum number of levels before giving up (default 20). + /// Maximum number of levels before giving up. + /// + /// Defaults to 1 (a single, unbiased direct-Monte-Carlo level). + /// Setting more than one level engages the multi-level estimator, + /// which is currently biased upward, and therefore also requires an + /// explicit `.allow_biased_multilevel()` acknowledgment, or `.run()` + /// raises. fn max_levels(&self, levels: usize) -> Self { let mut c = self.clone(); c.max_levels = levels; c } + /// Acknowledge and accept the known upward bias of the multi-level + /// subset estimator, enabling `max_levels > 1`. Without it, subset + /// simulation runs a single unbiased level (direct Monte Carlo). + fn allow_biased_multilevel(&self) -> Self { + let mut c = self.clone(); + c.allow_biased_multilevel = true; + c + } + /// Minimum conditional probability before declaring the failure event /// unreachable (default 1e-6). fn min_conditional_prob(&self, p: f64) -> Self { @@ -653,8 +670,11 @@ pub fn subset_simulation(samples_per_level: usize) -> PySubsetSimulationBuilder PySubsetSimulationBuilder { samples_per_level, threshold_fraction: defaults.threshold_fraction, - max_levels: defaults.max_levels, + // Default to a single, unbiased level; the biased multi-level path + // requires an explicit .allow_biased_multilevel() opt-in. + max_levels: 1, min_conditional_prob: defaults.min_conditional_prob, + allow_biased_multilevel: false, score: None, failure: None, } @@ -1019,6 +1039,14 @@ impl PySimNeoBuilder { subset_simulation(..) builder; neither has a sensible default.", )); }; + if config.max_levels > 1 && !config.allow_biased_multilevel { + return Err(pyo3::exceptions::PyValueError::new_err( + "subset_simulation with max_levels > 1 engages the multi-level estimator, \ + which is currently biased upward (the resample is unconditioned). Either \ + keep a single level (an unbiased direct-Monte-Carlo failure-fraction \ + estimate) or call .allow_biased_multilevel() to accept the documented bias.", + )); + } let noise = self .noise_config diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py b/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py index 329d5186c..d85f02f5d 100644 --- a/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py +++ b/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py @@ -127,3 +127,37 @@ def bad_score(_bits: list[int]) -> float: sim_neo(three_h_circuit()).auto().sampling( subset_simulation(50).score(bad_score).failure(lambda _bits: False), ).run() + + +def _ones_score(bits: list[int]) -> float: + return float(sum(bits)) + + +def _all_ones(bits: list[int]) -> bool: + return all(b == 1 for b in bits) + + +def test_subset_multilevel_without_opt_in_raises() -> None: + """max_levels > 1 engages the biased multi-level estimator and must be + refused unless explicitly acknowledged (mirrors the Rust guard).""" + with pytest.raises(ValueError, match="biased upward"): + sim_neo(three_h_circuit()).auto().sampling( + subset_simulation(100).score(_ones_score).failure(_all_ones).max_levels(5), + ).run() + + +def test_subset_multilevel_runs_with_opt_in() -> None: + result = ( + sim_neo(three_h_circuit()) + .auto() + .sampling( + subset_simulation(500) + .score(_ones_score) + .failure(_all_ones) + .max_levels(5) + .allow_biased_multilevel(), + ) + .seed(42) + .run() + ) + assert result.subset is not None From e31567749360dd984b4d2dc7232a704a06c99aa3 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 22:16:50 -0600 Subject: [PATCH 168/388] Fix inverted threshold selection in ProperSubsetSimulation/QecSubsetSimulation: descending-sorted scores must take the survivor threshold at index p0*n (keep top p0), not (1-p0)*n (which kept the bottom and made each level's conditional ~1-p0 instead of ~p0); add a discriminating threshold test plus the previously-missing analytical-unbiasedness guard for the multi-level path --- exp/pecos-neo/src/sampling/subset.rs | 104 ++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 9 deletions(-) diff --git a/exp/pecos-neo/src/sampling/subset.rs b/exp/pecos-neo/src/sampling/subset.rs index c0c32a53f..a9ebe04d9 100644 --- a/exp/pecos-neo/src/sampling/subset.rs +++ b/exp/pecos-neo/src/sampling/subset.rs @@ -1240,10 +1240,14 @@ impl ProperSubsetSimulation { .unwrap_or(std::cmp::Ordering::Equal) }); - // Find adaptive threshold: score at (1-p0) quantile + // Find the adaptive threshold so the top p0 fraction survives. + // Trajectories are sorted DESCENDING (highest score first), so + // the survivor threshold is the score at index floor(p0 * n) — + // index (1 - p0) * n would pick the BOTTOM fraction and invert + // the conditioning (~1-p0 survive instead of ~p0). #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - // p0 in [0,1] so (1-p0)*n fits in usize - let threshold_idx = ((1.0 - p0) * n as f64).floor() as usize; + // p0 in [0,1] so p0*n fits in usize + let threshold_idx = (p0 * n as f64).floor() as usize; let threshold_idx = threshold_idx.min(n - 1); let new_threshold = self.trajectories[threshold_idx].score; @@ -1488,10 +1492,11 @@ impl ProperSubsetSimulation { // If no trajectories exceed this threshold, use quantile-based threshold let actual_threshold = if num_above == 0 { - // Use the (1-p0) quantile as the new threshold + // Top p0 fraction survives: descending sort, so index + // floor(p0 * n) (index (1-p0)*n would pick the bottom). #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - // p0 in [0,1] so (1-p0)*n fits in usize - let threshold_idx = ((1.0 - p0) * n as f64).floor() as usize; + // p0 in [0,1] so p0*n fits in usize + let threshold_idx = (p0 * n as f64).floor() as usize; let threshold_idx = threshold_idx.min(n - 1); self.trajectories[threshold_idx].score } else { @@ -2068,10 +2073,12 @@ impl QecSubsetSimulation { .unwrap_or(std::cmp::Ordering::Equal) }); - // Find adaptive threshold: score at (1-p0) quantile + // Top p0 fraction survives: histories sorted DESCENDING, so the + // survivor threshold is at index floor(p0 * n); index (1-p0)*n + // would pick the BOTTOM fraction and invert the conditioning. #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] - // p0 in [0,1] so (1-p0)*n fits in usize - let threshold_idx = ((1.0 - p0) * n as f64).floor() as usize; + // p0 in [0,1] so p0*n fits in usize + let threshold_idx = (p0 * n as f64).floor() as usize; let threshold_idx = threshold_idx.min(n - 1); let new_threshold = histories[indices[threshold_idx]].final_score; @@ -2453,6 +2460,85 @@ mod tests { // estimate probabilities that direct MC would need many more samples for } + #[test] + fn proper_subset_threshold_keeps_top_fraction_not_bottom() { + // Directly pins the threshold-selection fix. Trajectories are sorted + // DESCENDING, so the survivor threshold must be the score at index + // floor(p0 * n) (keep the TOP p0). The pre-fix code indexed + // (1 - p0) * n, which kept the BOTTOM fraction and made each level's + // measured conditional ~ (1 - p0) instead of ~ p0. + // + // This is the discriminating assertion: with the fix the first + // level's conditional is near threshold_fraction (~0.2); with the + // inverted index it is near 1 - threshold_fraction (~0.8). The + // analytical-match check below does NOT catch this on its own (the + // estimate telescopes to the right value either way because the + // conditioning is correct — the quantile only controls how + // aggressively each level cuts), which is exactly why this explicit + // conditional check is needed. + let config = SubsetConfig::new() + .with_samples_per_level(3000) + .with_threshold_fraction(0.2) + .with_max_levels(20) + .with_seed(42); + let result = ProperSubsetSimulation::new(0.05, 1.0, 15.0, 100, config).run(); + + let first = result + .levels + .first() + .expect("at least one level") + .conditional_prob; + assert!( + first < 0.5, + "first-level conditional {first:.3} must be near threshold_fraction (0.2), \ + not its complement (0.8) — the threshold must keep the top fraction" + ); + } + + #[test] + fn proper_subset_simulation_matches_analytical() { + // Unbiasedness regression guard for ProperSubsetSimulation against + // the EXACT analytical probability of the same Bernoulli damage + // process. This guard was MISSING entirely: the existing analytical + // tests only exercised BernoulliSubsetSimulation (direct MC), never + // the multi-level Au-Beck path. It validates that the multi-level + // estimator is unbiased (it is, because the checkpoint resample + // conditions correctly); the inverted-quantile defect is pinned + // separately by proper_subset_threshold_keeps_top_fraction_not_bottom. + let p_damage = 0.05; + let increment = 1.0; + let num_rounds = 100; + // P(Binom(100, 0.05) >= 15), mean 5: well below 1/samples, so direct + // MC over samples_per_level would essentially never reach it; only + // multi-level conditioning estimates it stably. + let failure_threshold = 15.0; + let samples_per_level = 3000; + + let analytical = BernoulliSubsetSimulation::new(p_damage, num_rounds, failure_threshold) + .analytical_probability(); + assert!( + analytical > 0.0 && analytical < 1.0 / samples_per_level as f64, + "test setup: analytical {analytical:.2e} must be nonzero AND below 1/samples", + ); + + let config = SubsetConfig::new() + .with_samples_per_level(samples_per_level) + .with_threshold_fraction(0.2) + .with_max_levels(20) + .with_seed(42); + let estimate = + ProperSubsetSimulation::new(p_damage, increment, failure_threshold, num_rounds, config) + .run() + .probability(); + + let ratio = (estimate / analytical).max(analytical / estimate); + assert!( + estimate > 0.0 && ratio < 3.0, + "ProperSubsetSimulation estimate {estimate:.3e} should be within 3x of \ + analytical {analytical:.3e} (ratio {ratio:.2})" + ); + } + // ======================================================================== // ECS-Based Subset Simulation Tests // ======================================================================== From 14c5895ea9b0f1367525b697f15fe5c9f7f60829 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 14 Jun 2026 23:50:08 -0600 Subject: [PATCH 169/388] Make the 64-observable mask limit consistently fail-loud: sampler errors on >64 observables (observable_dem_output_mask returns Result) instead of silently dropping ids >=64, and the per-shot truth-mask helper takes the validated mask --- .../fault_tolerance/dem_builder/sampler.rs | 119 +++++++++++++++--- .../src/fault_tolerance_bindings.rs | 16 ++- 2 files changed, 117 insertions(+), 18 deletions(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 53d29c1c5..2e0d9f596 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -145,6 +145,44 @@ impl std::fmt::Display for TrackedPauliSamplingError { impl std::error::Error for TrackedPauliSamplingError {} +/// Error returned when a sampler's observables do not fit the `u64` decoder mask. +/// +/// Decoder-facing observable masks are packed into a `u64` (bit `i` = observable +/// `i`), so observable id `>= 64` cannot be represented. Rather than silently +/// dropping those observables from the truth mask (which would under-count +/// logical failures), the sampler errors. Lifting this limit is the +/// wider-observable-mask follow-up (see the `ObsMask` plan). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservableMaskCapacityError { + num_observables: usize, +} + +impl ObservableMaskCapacityError { + fn new(num_observables: usize) -> Self { + Self { num_observables } + } + + /// Number of observables the sampler carries (exceeds the 64-bit capacity). + #[must_use] + pub fn num_observables(&self) -> usize { + self.num_observables + } +} + +impl std::fmt::Display for ObservableMaskCapacityError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "sampler has {} observables, but decoder observable masks are packed \ + into a u64 and support at most 64. Observable ids >= 64 cannot be \ + represented; refusing to silently drop them.", + self.num_observables + ) + } +} + +impl std::error::Error for ObservableMaskCapacityError {} + /// Output mode for the unified sampler. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputMode { @@ -697,21 +735,32 @@ impl DemSampler { /// Bit mask selecting observable outputs. /// - /// Existing decoder APIs use `u64` observable masks, so outputs with index - /// \>= 64 are not representable here and are ignored consistently with the - /// existing mask-based paths. - #[must_use] - pub fn observable_dem_output_mask(&self) -> u64 { - self.observable_ids() - .into_iter() - .filter(|&idx| idx < u64::BITS as usize) - .fold(0u64, |acc, idx| acc | (1u64 << idx)) + /// Bitmask of which DEM outputs are decoder-facing observables. + /// + /// # Errors + /// + /// Returns [`ObservableMaskCapacityError`] if any observable id is `>= 64`: + /// decoder observable masks are packed into a `u64`, so such ids are not + /// representable. Erroring (rather than silently dropping them) keeps the + /// logical-failure count correct. Compute this once, up front, and pass the + /// result to [`Self::observable_mask_from_dem_output_flips`] per shot. + pub fn observable_dem_output_mask(&self) -> Result { + let ids = self.observable_ids(); + if let Some(&max_id) = ids.iter().max() + && max_id >= u64::BITS as usize + { + return Err(ObservableMaskCapacityError::new(ids.len())); + } + Ok(ids.into_iter().fold(0u64, |acc, idx| acc | (1u64 << idx))) } /// Converts a sampled DEM-output flip vector into an observable-only mask. + /// + /// `observable_mask` is the value returned by + /// [`Self::observable_dem_output_mask`] (validated to fit a `u64`); pass it + /// in so the per-shot path neither recomputes it nor needs to re-validate. #[must_use] - pub fn observable_mask_from_dem_output_flips(&self, flips: &[bool]) -> u64 { - let observable_mask = self.observable_dem_output_mask(); + pub fn observable_mask_from_dem_output_flips(&self, flips: &[bool], observable_mask: u64) -> u64 { flips .iter() .enumerate() @@ -1876,6 +1925,41 @@ mod tests { assert_eq!(sampler.sample(&mut rng), (vec![false], vec![true])); } + #[test] + fn observable_dem_output_mask_errors_above_64_observables() { + // >64 observables cannot be packed into a u64 mask. The sampler must + // ERROR rather than silently dropping observables with id >= 64 (which + // would under-count logical failures). Pins the Phase-0 fail-loud fix. + use super::super::builder::DemBuilder; + use pecos_quantum::Attribute; + + let n: usize = 65; + let mut circuit = DagCircuit::new(); + for i in 0..n { + circuit.pz(&[i]); + } + for i in 0..n { + circuit.mz(&[i]); + } + circuit.set_attr("num_measurements", Attribute::String(n.to_string())); + let obs: Vec = (0..n) + .map(|i| format!(r#"{{"id":{i},"records":[{}]}}"#, i as i64 - n as i64)) + .collect(); + circuit.set_attr( + "observables", + Attribute::String(format!("[{}]", obs.join(","))), + ); + + let dem = DemBuilder::from_circuit(&circuit, 0.03, 0.0, 0.02, 0.0); + let sampler = DemSampler::from_detector_error_model(&dem); + assert_eq!(sampler.num_dem_outputs(), n); + + let err = sampler + .observable_dem_output_mask() + .expect_err("65 observables must not fit a u64 mask"); + assert_eq!(err.num_observables(), n); + } + #[test] fn raw_mode_without_dem_outputs_reports_zero_dem_outputs() { let mut circuit = DagCircuit::new(); @@ -1923,9 +2007,16 @@ mod tests { .num_tracked_paulis(), 1 ); - assert_eq!(sampler.observable_dem_output_mask(), 1); - assert_eq!(sampler.observable_mask_from_dem_output_flips(&[false]), 0); - assert_eq!(sampler.observable_mask_from_dem_output_flips(&[true]), 1); + let obs_mask = sampler.observable_dem_output_mask().unwrap(); + assert_eq!(obs_mask, 1); + assert_eq!( + sampler.observable_mask_from_dem_output_flips(&[false], obs_mask), + 0 + ); + assert_eq!( + sampler.observable_mask_from_dem_output_flips(&[true], obs_mask), + 1 + ); } #[test] diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 2da8cc3d7..d20cb263e 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -3626,7 +3626,10 @@ impl PyDemSampler { let mut rng = PecosRng::seed_from_u64(actual_seed); let mut decoder = create_observable_decoder(dem, decoder_type)?; - let observable_mask = self.inner.observable_dem_output_mask(); + let observable_mask = self + .inner + .observable_dem_output_mask() + .map_err(|e| PyErr::new::(e.to_string()))?; // Tight sample+decode loop -- no Python involvement. // Single-threaded: sample and decode sequentially. @@ -3635,7 +3638,9 @@ impl PyDemSampler { let (det_events, obs_flips) = self.inner.sample(&mut rng); let syndrome: Vec = det_events.iter().map(|&b| u8::from(b)).collect(); let predicted_mask = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); - let true_mask = self.inner.observable_mask_from_dem_output_flips(&obs_flips); + let true_mask = self + .inner + .observable_mask_from_dem_output_flips(&obs_flips, observable_mask); if (predicted_mask & observable_mask) != true_mask { errors += 1; } @@ -3683,7 +3688,9 @@ impl PyDemSampler { let remainder = num_shots % n_workers; let sampler = &self.inner; - let observable_mask = sampler.observable_dem_output_mask(); + let observable_mask = sampler + .observable_dem_output_mask() + .map_err(|e| PyErr::new::(e.to_string()))?; let dem_str = dem.to_string(); let dt = decoder_type.to_string(); @@ -3710,7 +3717,8 @@ impl PyDemSampler { let syndrome: Vec = det_events.iter().map(|&b| u8::from(b)).collect(); let predicted = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); - let truth = my_sampler.observable_mask_from_dem_output_flips(&obs_flips); + let truth = my_sampler + .observable_mask_from_dem_output_flips(&obs_flips, observable_mask); if (predicted & observable_mask) != truth { errors += 1; } From 9deec9966605e8841d75c4b3a5609cb268ee46e1 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 00:31:51 -0600 Subject: [PATCH 170/388] Complete the coefficient-of-variation for the structurally-correct ProperSubsetSimulation/QecSubsetSimulation: include the previously-omitted final failure-fraction term (Codex round-3 finding), via a shared independent_levels_cv_squared helper that documents the remaining inter-level-correlation approximation as an optimistic lower bound; validate the reported CV against the empirical spread across 200 runs (lower-bound ~1.8x below empirical, final term proven included) --- exp/pecos-neo/src/sampling/subset.rs | 183 ++++++++++++++++++++++----- 1 file changed, 148 insertions(+), 35 deletions(-) diff --git a/exp/pecos-neo/src/sampling/subset.rs b/exp/pecos-neo/src/sampling/subset.rs index a9ebe04d9..e7d3ab813 100644 --- a/exp/pecos-neo/src/sampling/subset.rs +++ b/exp/pecos-neo/src/sampling/subset.rs @@ -692,6 +692,39 @@ impl BernoulliSubsetSimulation { prob } } +/// Squared coefficient of variation for a subset-simulation estimate under +/// the standard independent-levels approximation (Au & Beck 2001). +/// +/// The estimate is `P(F) = (prod_i p_i) * q`, where `p_i` are the committed +/// intermediate-level conditionals and `q = P(F | E_k)` is the final +/// failure fraction. Each factor estimated from `n` samples contributes a +/// relative variance `(1 - p) / (n * p)`; the total CV^2 is their sum. The +/// final-fraction term `(1 - q) / (n * q)` MUST be included — omitting it +/// (as the earlier code did) reports CIs that are too tight. +/// +/// This is the INDEPENDENT-levels estimate: it assumes the per-level +/// estimators are uncorrelated. The resampling that conditions each level +/// on the previous induces positive inter-level correlation, so this is a +/// LOWER bound on the true CV. The correlation-corrected Au-Beck estimator +/// scales each term by `(1 + gamma_i)` with `gamma_i` the level's chain +/// autocorrelation, which is not estimated here. Callers reporting this CV +/// should treat it as an optimistic (lower) bound, not an exact CV. +#[allow(clippy::cast_precision_loss)] // sample counts are far below f64 precision +fn independent_levels_cv_squared( + level_conditionals: impl Iterator, + final_fraction: f64, + n: usize, +) -> f64 { + let relative_variance = |p: f64| { + if p > 0.0 && p < 1.0 { + (1.0 - p) / (n as f64 * p) + } else { + 0.0 + } + }; + level_conditionals.map(relative_variance).sum::() + relative_variance(final_fraction) +} + /// Binomial probability mass function: C(n,k) * p^k * (1-p)^(n-k) #[allow(clippy::cast_precision_loss)] // mathematical calculation fn binomial_pmf(n: usize, k: usize, p: f64) -> f64 { @@ -1379,18 +1412,12 @@ impl ProperSubsetSimulation { // The probability estimate let probability = cumulative_prob * final_failure_fraction; - // Coefficient of variation estimate - let cv_squared: f64 = self - .levels - .iter() - .map(|l| { - if l.conditional_prob > 0.0 && l.conditional_prob < 1.0 { - (1.0 - l.conditional_prob) / (n as f64 * l.conditional_prob) - } else { - 0.0 - } - }) - .sum(); + // Independent-levels CV, including the final failure-fraction term. + let cv_squared = independent_levels_cv_squared( + self.levels.iter().map(|l| l.conditional_prob), + final_failure_fraction, + n, + ); SubsetResult { levels: self.levels, @@ -1632,18 +1659,12 @@ impl ProperSubsetSimulation { // The probability estimate let probability = cumulative_prob * final_failure_fraction; - // Coefficient of variation estimate - let cv_squared: f64 = self - .levels - .iter() - .map(|l| { - if l.conditional_prob > 0.0 && l.conditional_prob < 1.0 { - (1.0 - l.conditional_prob) / (n as f64 * l.conditional_prob) - } else { - 0.0 - } - }) - .sum(); + // Independent-levels CV, including the final failure-fraction term. + let cv_squared = independent_levels_cv_squared( + self.levels.iter().map(|l| l.conditional_prob), + final_failure_fraction, + n, + ); SubsetResult { levels: self.levels, @@ -2215,17 +2236,12 @@ impl QecSubsetSimulation { traj.is_failure = hist.is_failure; } - // Coefficient of variation estimate - let cv_squared: f64 = levels - .iter() - .map(|l| { - if l.conditional_prob > 0.0 && l.conditional_prob < 1.0 { - (1.0 - l.conditional_prob) / (n as f64 * l.conditional_prob) - } else { - 0.0 - } - }) - .sum(); + // Independent-levels CV, including the final failure-fraction term. + let cv_squared = independent_levels_cv_squared( + levels.iter().map(|l| l.conditional_prob), + final_failure_fraction, + n, + ); self.levels = levels .iter() @@ -2539,6 +2555,103 @@ mod tests { ); } + #[test] + #[allow(clippy::cast_precision_loss)] + fn proper_subset_reported_cv_tracks_the_empirical_spread() { + // Validate the reported coefficient of variation (now including the + // final failure-fraction term) against the EMPIRICAL CV measured + // across many independent runs. The reported CV is the + // independent-levels estimate, so it is a LOWER bound on the true + // spread (resampling correlation inflates the real variance). For + // this regime the empirical CV (~0.155) is ~1.8x the reported CV + // (~0.088), quantifying that correlation gap — which is exactly why + // the doc on `independent_levels_cv_squared` calls it an optimistic + // bound. The test requires the reported CV to be a right-order lower + // bound, and that the final-fraction term is genuinely included + // (dropping it, as the pre-fix code did, gives a strictly smaller + // CV). + let (p_damage, increment, num_rounds, failure_threshold) = (0.1, 1.0, 40, 11.0); + let n = 2000; + let k_runs = 200usize; + + let mut estimates = Vec::with_capacity(k_runs); + let mut reported_cv = Vec::with_capacity(k_runs); + let mut cv_sq_dropping_final = Vec::with_capacity(k_runs); + + for seed in 0..k_runs { + let config = SubsetConfig::new() + .with_samples_per_level(n) + .with_threshold_fraction(0.2) + .with_max_levels(20) + .with_seed(seed as u64 + 1); + let result = ProperSubsetSimulation::new( + p_damage, + increment, + failure_threshold, + num_rounds, + config, + ) + .run(); + estimates.push(result.probability); + reported_cv.push(result.coefficient_of_variation); + // Reconstruct the pre-fix CV (intermediate levels only, no final + // failure-fraction term) for the materiality check. + let nf = n as f64; + let intermediate: f64 = result + .levels + .iter() + .map(|l| { + let p = l.conditional_prob; + if p > 0.0 && p < 1.0 { + (1.0 - p) / (nf * p) + } else { + 0.0 + } + }) + .sum(); + cv_sq_dropping_final.push(intermediate); + } + + let mean_est = estimates.iter().sum::() / k_runs as f64; + let var_est = estimates + .iter() + .map(|e| (e - mean_est).powi(2)) + .sum::() + / (k_runs as f64 - 1.0); + let empirical_cv = var_est.sqrt() / mean_est; + let mean_reported_cv = reported_cv.iter().sum::() / k_runs as f64; + let mean_cv_dropping_final = + (cv_sq_dropping_final.iter().sum::() / k_runs as f64).sqrt(); + + println!( + "CV check: empirical={empirical_cv:.3}, reported={mean_reported_cv:.3}, \ + dropping_final_term={mean_cv_dropping_final:.3}" + ); + + // The reported CV is a right-order LOWER bound on the empirical + // spread: it should not substantially exceed empirical (it is a + // lower bound; allow 30% for finite-sample noise in the per-run + // conditionals) and should be within ~2.5x below it (it captures the + // right order, not a token number). + assert!( + mean_reported_cv < empirical_cv * 1.3, + "reported CV {mean_reported_cv:.3} should not exceed empirical {empirical_cv:.3} \ + by much (it is an independent-levels lower bound)" + ); + assert!( + mean_reported_cv > empirical_cv * 0.4, + "reported CV {mean_reported_cv:.3} should be the right order vs empirical \ + {empirical_cv:.3}, not a token value" + ); + // The final failure-fraction term is genuinely included: dropping it + // (the pre-fix behavior) gives a strictly smaller CV. + assert!( + mean_reported_cv > mean_cv_dropping_final, + "the final failure-fraction term must be included (reported {mean_reported_cv:.3} \ + must exceed the intermediate-levels-only {mean_cv_dropping_final:.3})" + ); + } + // ======================================================================== // ECS-Based Subset Simulation Tests // ======================================================================== From b394461e4922241660613f432c03a40b7de50a89 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 07:40:13 -0600 Subject: [PATCH 171/388] Lock the engines depolarizing-vs-GNM measurement-physics distinction (the B1 root cause) on both sides: add a GNM meas-twice equivalence cell pinning the record-flip convention (second measure = p) to complement the existing depolarizing state-flip cell (2p(1-p)), both cross-stack analytic-pinned so a GNM->state-flip mismapping fails --- .../tests/neo_equivalence_matrix_test.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index 737bf596d..c58cedbaa 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -290,3 +290,27 @@ fn meas_twice_without_reset_matches() { Some(2.0 * p * (1.0 - p)), ); } + +#[test] +fn meas_twice_gnm_is_record_flip_not_state_flip() { + // The COMPLEMENT of `meas_twice_without_reset_matches`, locking the + // OTHER engines measurement convention. GeneralNoiseModel readout error + // flips only the recorded outcome, never the post-measurement state, so + // the qubit stays |0> across both measurements and the SECOND outcome + // flips at exactly p (record flip) — NOT 2p(1-p) (state flip). Both + // stacks must agree (engines GNM record-flip maps to neo's record- + // flipping MeasurementChannel). Together with the depolarizing cell + // above, this pins the engines depolarizing-vs-GNM measurement-physics + // distinction (the B1 root cause) on BOTH sides, cross-stack. + let p = 0.25; + check_cell( + "meas_twice_gnm", + MEASURE_TWICE, + &NoiseCell::GnmSimple { + average_p1: 0.0, + p_meas: p, + }, + &["1"], + Some(p), + ); +} From bc30ff51b51262188ac7c9afdd3d7a8997354e85 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 07:51:08 -0600 Subject: [PATCH 172/388] Make the special.rs assert_close test helper enforce its stated RELATIVE tolerance: drop the OR'd absolute fallback that silently weakened it to an absolute check for |expected| < 1 (a claimed 1e-12 relative became 1e-12 absolute, ~1000x looser at small values); all 17 numerics tests still pass under the strict check, so the validation-gate numerics genuinely meet their stated accuracy --- crates/pecos-num/src/special.rs | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/crates/pecos-num/src/special.rs b/crates/pecos-num/src/special.rs index 5e35b43c5..214657bbf 100644 --- a/crates/pecos-num/src/special.rs +++ b/crates/pecos-num/src/special.rs @@ -296,12 +296,20 @@ pub fn betainc_inv(a: f64, b: f64, p: f64) -> f64 { mod tests { use super::*; - /// Assert relative agreement with a `SciPy` reference value. + /// Assert `actual` matches `expected` to a RELATIVE tolerance. + /// + /// A pure relative check: callers that legitimately expect a value of + /// exactly zero must handle that case separately (the `ln_gamma` test + /// does). An earlier version OR'd in an absolute `abs_err <= rel_tol` + /// fallback, which silently weakened the relative tolerance to an + /// absolute one for `|expected| < 1` (e.g. a claimed 1e-12 relative + /// became 1e-12 absolute — ~1000x looser at `expected = 1e-3`). fn assert_close(actual: f64, expected: f64, rel_tol: f64) { - let scale = expected.abs().max(f64::MIN_POSITIVE); + let denom = expected.abs().max(f64::MIN_POSITIVE); + let rel_err = (actual - expected).abs() / denom; assert!( - (actual - expected).abs() / scale <= rel_tol || (actual - expected).abs() <= rel_tol, - "expected {expected:.17e}, got {actual:.17e}" + rel_err <= rel_tol, + "expected {expected:.17e}, got {actual:.17e} (relative error {rel_err:.3e} > {rel_tol:.3e})" ); } From e9db300ba8a5f007bfcf530fcba2bb3b6e32836b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 07:58:10 -0600 Subject: [PATCH 173/388] Lock the realistic-nonzero-defaults trap in G4: test that PerGateTypeNoise::from_base_noise(NoiseConfig::default()).to_neo_channel() does not trip the idle guard and carries the 0.01 base/meas/init rates bit-identically to the hand-built neo channel (a dropped or rescaled default would diverge), the exact 0.01-everywhere case the GNM/qec mappings mishandled before --- .../tests/per_gate_neo_mapping_tests.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs b/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs index 1dd617271..c2da2ce5c 100644 --- a/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs +++ b/crates/pecos-qec/tests/per_gate_neo_mapping_tests.rs @@ -23,6 +23,7 @@ use pecos_core::QubitId; use pecos_core::gate_type::GateType; +use pecos_neo::noise::PerGatePauliChannel; use pecos_neo::prelude::*; use pecos_qec::fault_tolerance::dem_builder::{NoiseConfig, PerGateTypeNoise}; use pecos_simulators::SparseStab; @@ -234,3 +235,60 @@ fn idle_gate_entries_are_rejected_not_dropped() { .with_1q_rates(GateType::Idle, [0.001, 0.0, 0.0]); let _ = noise.to_neo_channel(); } + +#[test] +#[allow(clippy::cast_precision_loss)] +fn default_noise_config_carries_realistic_base_rates_without_idle_panic() { + // The realistic-nonzero-defaults trap: NoiseConfig::default() is 0.01 + // EVERYWHERE (p1/p2/p_meas/p_prep), not off — it bit the GNM and qec + // mappings before. Lock that to_neo_channel on the default config + // (a) does NOT trip the idle guard (default has p_idle = 0, t1/t2 = None, + // so this call would panic if it did) and (b) carries the 0.01 + // base/meas/init rates EXACTLY — bit-identical to the neo channel built + // by hand with those values (a mishandling that dropped or rescaled the + // defaults would diverge). + let from_default = PerGateTypeNoise::from_base_noise(NoiseConfig::default()).to_neo_channel(); + let by_hand = PerGatePauliChannel::new() + .with_base(0.01, 0.01) + .with_meas_init(0.01, 0.01); + + let commands = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); + + let count_ones = |channel: PerGatePauliChannel| -> usize { + let model = ComposableNoiseModel::new().add_channel(channel); + let mut state = SparseStab::new(1); + let mut runner = CircuitRunner::::new() + .with_noise(model) + .with_seed(42); + let qubits = [QubitId(0)]; + let mut ones = 0usize; + for _ in 0..SHOTS { + state.reset(); + let outcomes = runner.apply_circuit(&mut state, &commands).unwrap(); + if let Some(bits) = outcomes.bitstring(&qubits) + && bits[0] + { + ones += 1; + } + } + ones + }; + + let default_ones = count_ones(from_default); + let hand_ones = count_ones(by_hand); + + assert_eq!( + default_ones, hand_ones, + "to_neo_channel(NoiseConfig::default()) must carry the 0.01 base/meas/init rates \ + exactly (got {default_ones} vs hand-built {hand_ones})" + ); + // The defaults are NOT silently dropped: the circuit's nominal outcome + // is 1 (X flips |0> to |1>), so the error rate is the fraction reading + // 0 — a small but nonzero value from the combined 0.01 prep/gate/meas + // sources (~0.027), confirming the defaults carry rather than vanish. + let error_rate = 1.0 - default_ones as f64 / SHOTS as f64; + assert!( + error_rate > 0.0 && error_rate < 0.1, + "the realistic 0.01 defaults must produce a small nonzero error rate, got {error_rate}" + ); +} From 309e645f4ee0c95e589a2fbe1d45105038a04691 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 08:03:19 -0600 Subject: [PATCH 174/388] Knock out two cosmetics: bump the new pecos-results crate's moved-code headers to 2026 (matching the project new-files convention and the sibling shim), and give the two stacks independent seeds in the cross-stack STATISTICAL comparisons (matrix + routing depolarizing) so agreement comes from matching conventions, not a shared RNG stream that would make the check tautological if streams ever converged; deterministic same-seed bit-identical tests left unchanged --- crates/pecos-results/src/conversions.rs | 2 +- crates/pecos-results/src/data.rs | 2 +- crates/pecos-results/src/data_vec.rs | 2 +- crates/pecos-results/src/lib.rs | 2 +- crates/pecos-results/src/shot.rs | 2 +- crates/pecos-results/src/shot_map.rs | 2 +- crates/pecos-results/src/shot_tests.rs | 2 +- crates/pecos-results/src/shot_vec.rs | 2 +- crates/pecos/tests/neo_equivalence_matrix_test.rs | 13 ++++++++++++- crates/pecos/tests/neo_routing_test.rs | 13 ++++++++++--- 10 files changed, 30 insertions(+), 12 deletions(-) diff --git a/crates/pecos-results/src/conversions.rs b/crates/pecos-results/src/conversions.rs index 2807963ef..4c64d51e8 100644 --- a/crates/pecos-results/src/conversions.rs +++ b/crates/pecos-results/src/conversions.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-results/src/data.rs b/crates/pecos-results/src/data.rs index 1f0e5434e..13ac6f233 100644 --- a/crates/pecos-results/src/data.rs +++ b/crates/pecos-results/src/data.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-results/src/data_vec.rs b/crates/pecos-results/src/data_vec.rs index d6989b800..291f3b104 100644 --- a/crates/pecos-results/src/data_vec.rs +++ b/crates/pecos-results/src/data_vec.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-results/src/lib.rs b/crates/pecos-results/src/lib.rs index e568a5655..a2b4272a4 100644 --- a/crates/pecos-results/src/lib.rs +++ b/crates/pecos-results/src/lib.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-results/src/shot.rs b/crates/pecos-results/src/shot.rs index 83595a23b..705e287c4 100644 --- a/crates/pecos-results/src/shot.rs +++ b/crates/pecos-results/src/shot.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-results/src/shot_map.rs b/crates/pecos-results/src/shot_map.rs index f14ee6b0f..79eb50c8d 100644 --- a/crates/pecos-results/src/shot_map.rs +++ b/crates/pecos-results/src/shot_map.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-results/src/shot_tests.rs b/crates/pecos-results/src/shot_tests.rs index 412f76f9d..bc874c445 100644 --- a/crates/pecos-results/src/shot_tests.rs +++ b/crates/pecos-results/src/shot_tests.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos-results/src/shot_vec.rs b/crates/pecos-results/src/shot_vec.rs index 98b8fbc9c..4d24592d7 100644 --- a/crates/pecos-results/src/shot_vec.rs +++ b/crates/pecos-results/src/shot_vec.rs @@ -1,4 +1,4 @@ -// Copyright 2025 The PECOS Developers +// Copyright 2026 The PECOS Developers // // Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except // in compliance with the License.You may obtain a copy of the License at diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index c58cedbaa..7d03aac11 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -99,7 +99,18 @@ enum NoiseCell { impl NoiseCell { fn run(&self, qasm: &str, stack: SimStack) -> ShotVec { - let builder = sim(Qasm::from_string(qasm)).stack(stack).seed(SEED); + // Independent seed per stack. Each cell compares the two stacks' + // empirical rates (Jeffreys overlap), so the comparison must be + // between INDEPENDENT samples — a shared seed would make it + // tautological if the two stacks' per-shot RNG streams ever + // converged. Each stack is also checked against its analytic value, + // which holds for any seed. + let seed = if matches!(stack, SimStack::Neo) { + SEED ^ 0xA5A5 + } else { + SEED + }; + let builder = sim(Qasm::from_string(qasm)).stack(stack).seed(seed); let depol = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { pecos_engines::noise::DepolarizingNoiseModel::builder() .with_prep_probability(p_prep) diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index e82321b23..bfe1bf8f0 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -143,14 +143,21 @@ fn neo_stack_measurement_noise_rate_matches_engines() { #[test] fn neo_stack_uniform_depolarizing_rate_matches_engines() { // Uniform depolarizing through the convenience struct: the compound - // error rate must agree across stacks (same conventions, different - // RNG streams). + // error rate must agree across stacks. This is a direct stack-vs-stack + // comparison, so the two stacks use INDEPENDENT seeds — agreement must + // come from matching conventions, not from a shared RNG stream (which + // would make the check tautological if the streams ever converged). let shots = 4000; let run = |stack: SimStack| { + let seed = if matches!(stack, SimStack::Neo) { + 7 ^ 0xA5A5 + } else { + 7 + }; sim(x_measure_qasm()) .stack(stack) .noise(pecos_engines::DepolarizingNoise { p: 0.1 }) - .seed(7) + .seed(seed) .run(shots) .expect("run") }; From 7e63b2f82719481baf5b18d89180ee305960c927 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 09:46:19 -0600 Subject: [PATCH 175/388] Handle Selene runtime capacity after merged runtime changes. --- crates/pecos-qis/src/selene_runtime.rs | 307 +++++++++++++++++---- docs/development/from-guppy-dem-handoff.md | 6 +- 2 files changed, 249 insertions(+), 64 deletions(-) diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index a8643f0a7..385c3e659 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -202,6 +202,9 @@ pub struct SeleneRuntime { #[allow(dead_code)] instance: Option<*mut c_void>, + /// Number of qubits the current runtime instance was initialized with. + initialized_num_qubits: Option, + /// Current classical state state: ClassicalState, @@ -241,6 +244,9 @@ pub struct SeleneRuntime { /// End timestamp of the last scheduled physical operation per runtime qubit. last_gate_time_end_nanos: Vec, + + /// Shot metadata waiting for a lazily loaded runtime plugin. + pending_shot_start: Option<(u64, Option)>, } // SAFETY: SeleneRuntime owns its instance pointer exclusively. @@ -259,6 +265,7 @@ impl SeleneRuntime { library_search_dirs: Vec::new(), library: None, instance: None, + initialized_num_qubits: None, state: ClassicalState::default(), operations_buffer: Vec::new(), batch_size: 100, @@ -272,6 +279,7 @@ impl SeleneRuntime { program_to_runtime_results: BTreeMap::new(), runtime_to_program_results: BTreeMap::new(), last_gate_time_end_nanos: Vec::new(), + pending_shot_start: None, } } @@ -313,17 +321,11 @@ impl SeleneRuntime { self.interface.as_ref().map_or(0, |i| i.operations.len()) ); - // Update qubit and result counts from new execution - self.num_qubits = operations - .allocated_qubits - .iter() - .max() - .map_or(0, |&q| q + 1); - self.num_results = operations - .allocated_results - .iter() - .max() - .map_or(0, |&r| r + 1); + // Update capacities from both explicit allocation records and direct + // program handles used by older QIR/LLVM examples. + let (num_qubits, num_results) = collector_capacity(&operations); + self.num_qubits = num_qubits; + self.num_results = num_results; self.interface = Some(operations); self.current_op_index = 0; @@ -395,8 +397,85 @@ impl SeleneRuntime { self.library = Some(ManuallyDrop::new(lib)); self.instance = Some(instance); + self.initialized_num_qubits = Some(self.num_qubits); + } + + self.apply_pending_shot_start()?; + Ok(()) + } + + fn ensure_plugin_capacity(&mut self) -> Result<()> { + let Some(initialized_num_qubits) = self.initialized_num_qubits else { + return Ok(()); + }; + + if self.num_qubits <= initialized_num_qubits { + return Ok(()); + } + + debug!( + "Reinitializing Selene plugin capacity from {} to {} qubits", + initialized_num_qubits, self.num_qubits + ); + self.reset_plugin_instance()?; + self.load_plugin() + } + + fn reset_plugin_instance(&mut self) -> Result<()> { + if let Some(lib) = &self.library + && let Some(instance) = self.instance + { + unsafe { + if let Ok(exit_fn) = + lib.get:: i32>(b"selene_runtime_exit") + { + let errno = exit_fn(instance); + if errno != 0 { + return Err(RuntimeError::ExecutionError(format!( + "Selene runtime exit failed with errno {errno}" + ))); + } + } + } + } + + self.instance = None; + self.library = None; + self.initialized_num_qubits = None; + self.program_to_runtime_qubits.clear(); + self.program_to_runtime_results.clear(); + self.runtime_to_program_results.clear(); + self.last_gate_time_end_nanos.clear(); + Ok(()) + } + + fn apply_pending_shot_start(&mut self) -> Result<()> { + let Some((shot_id, seed)) = self.pending_shot_start else { + return Ok(()); + }; + let Some(lib) = &self.library else { + return Ok(()); + }; + let Some(instance) = self.instance else { + return Ok(()); + }; + + unsafe { + if let Ok(shot_start_fn) = lib + .get:: i32>( + b"selene_runtime_shot_start", + ) + { + let errno = shot_start_fn(instance, shot_id, seed.unwrap_or(0)); + if errno != 0 { + return Err(RuntimeError::ExecutionError(format!( + "Shot start failed with errno {errno}" + ))); + } + } } + self.pending_shot_start = None; Ok(()) } @@ -1035,6 +1114,7 @@ impl Clone for SeleneRuntime { library_search_dirs: self.library_search_dirs.clone(), library: None, // Will be reloaded on demand instance: None, // Will be recreated on demand + initialized_num_qubits: None, state: self.state.clone(), operations_buffer: self.operations_buffer.clone(), batch_size: self.batch_size, @@ -1048,8 +1128,91 @@ impl Clone for SeleneRuntime { program_to_runtime_results: self.program_to_runtime_results.clone(), runtime_to_program_results: self.runtime_to_program_results.clone(), last_gate_time_end_nanos: self.last_gate_time_end_nanos.clone(), + pending_shot_start: self.pending_shot_start, + } + } +} + +fn collector_capacity(interface: &OperationCollector) -> (usize, usize) { + let (mut num_qubits, mut num_results) = operation_capacity(&interface.operations); + + for &qubit in &interface.allocated_qubits { + include_qubit(&mut num_qubits, qubit); + } + for &result in &interface.allocated_results { + include_result(&mut num_results, result); + } + + (num_qubits, num_results) +} + +fn operation_capacity(operations: &[Operation]) -> (usize, usize) { + let mut num_qubits = 0; + let mut num_results = 0; + + for op in operations { + match op { + Operation::Quantum(qop) => { + include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results) + } + Operation::AllocateQubit { id } | Operation::ReleaseQubit { id } => { + include_qubit(&mut num_qubits, *id); + } + Operation::AllocateResult { id } => include_result(&mut num_results, *id), + Operation::RecordOutput { result_id, .. } => { + include_result(&mut num_results, *result_id); + } + Operation::Barrier => {} } } + + (num_qubits, num_results) +} + +fn include_quantum_op_capacity(qop: &QuantumOp, num_qubits: &mut usize, num_results: &mut usize) { + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) + | QuantumOp::Idle(_, qubit) + | QuantumOp::Reset(qubit) => include_qubit(num_qubits, *qubit), + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => { + include_qubit(num_qubits, *qubit_1); + include_qubit(num_qubits, *qubit_2); + } + QuantumOp::CCX(qubit_1, qubit_2, qubit_3) => { + include_qubit(num_qubits, *qubit_1); + include_qubit(num_qubits, *qubit_2); + include_qubit(num_qubits, *qubit_3); + } + QuantumOp::Measure(qubit, result) => { + include_qubit(num_qubits, *qubit); + include_result(num_results, *result); + } + } +} + +fn include_qubit(num_qubits: &mut usize, qubit: usize) { + *num_qubits = (*num_qubits).max(qubit + 1); +} + +fn include_result(num_results: &mut usize, result: usize) { + *num_results = (*num_results).max(result + 1); } impl QisRuntime for SeleneRuntime { @@ -1059,17 +1222,12 @@ impl QisRuntime for SeleneRuntime { interface.operations.len() ); - // Count qubits and results - self.num_qubits = interface - .allocated_qubits - .iter() - .max() - .map_or(0, |&q| q + 1); - self.num_results = interface - .allocated_results - .iter() - .max() - .map_or(0, |&r| r + 1); + // Count qubits and results from both explicit allocation records and + // direct program handles. Some legacy LLVM/QIR inputs use qubit handles + // like 0 and 1 without emitting __quantum__rt__qubit_allocate calls. + let (num_qubits, num_results) = collector_capacity(&interface); + self.num_qubits = num_qubits; + self.num_results = num_results; debug!( "Interface has {} qubits and {} result slots", @@ -1099,6 +1257,10 @@ impl QisRuntime for SeleneRuntime { } fn lower_operations(&mut self, operations: &[Operation]) -> Result> { + let (num_qubits, num_results) = operation_capacity(operations); + self.num_qubits = self.num_qubits.max(num_qubits); + self.num_results = self.num_results.max(num_results); + self.ensure_plugin_capacity()?; self.load_plugin()?; let mut lowered_ops = Vec::new(); @@ -1216,30 +1378,6 @@ impl QisRuntime for SeleneRuntime { } fn shot_start(&mut self, shot_id: u64, seed: Option) -> Result<()> { - // Try to load the plugin if not already loaded - if self.library.is_none() && std::path::Path::new(&self.plugin_path).exists() { - self.load_plugin()?; - } - - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(shot_start_fn) = lib - .get:: i32>( - b"selene_runtime_shot_start", - ) - { - let errno = shot_start_fn(instance, shot_id, seed.unwrap_or(0)); - if errno != 0 { - return Err(RuntimeError::ExecutionError(format!( - "Shot start failed with errno {errno}" - ))); - } - } - } - } - // Reset state for new shot self.state = ClassicalState::default(); self.current_op_index = 0; @@ -1249,6 +1387,8 @@ impl QisRuntime for SeleneRuntime { self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); self.last_gate_time_end_nanos.clear(); + self.pending_shot_start = Some((shot_id, seed)); + self.apply_pending_shot_start()?; Ok(()) } @@ -1265,6 +1405,7 @@ impl QisRuntime for SeleneRuntime { } } } + self.pending_shot_start = None; // Return the shot with measurements and registers let shot = Shot { @@ -1277,27 +1418,14 @@ impl QisRuntime for SeleneRuntime { } fn reset(&mut self) -> Result<()> { - // Clean up the runtime instance - if let Some(lib) = &self.library - && let Some(instance) = self.instance - { - unsafe { - if let Ok(exit_fn) = - lib.get:: i32>(b"selene_runtime_exit") - { - let _ = exit_fn(instance); - } - } - } - - self.instance = None; - self.library = None; + self.reset_plugin_instance()?; self.state = ClassicalState::default(); self.current_op_index = 0; self.program_to_runtime_qubits.clear(); self.program_to_runtime_results.clear(); self.runtime_to_program_results.clear(); self.last_gate_time_end_nanos.clear(); + self.pending_shot_start = None; Ok(()) } @@ -1372,4 +1500,61 @@ mod tests { vec![QuantumOp::Idle(20e-9, 0), QuantumOp::RXY(1.0, 0.5, 0)] ); } + + #[test] + fn test_collector_capacity_includes_direct_program_handles() { + let mut collector = OperationCollector::new(); + collector.queue_operation(QuantumOp::H(0).into()); + collector.queue_operation(QuantumOp::CX(0, 3).into()); + collector.queue_operation(QuantumOp::Measure(3, 7).into()); + collector.queue_operation(Operation::RecordOutput { + result_id: 7, + register_name: "c".to_string(), + }); + + assert_eq!(collector_capacity(&collector), (4, 8)); + } + + #[test] + fn test_collector_capacity_includes_explicit_allocations() { + let mut collector = OperationCollector::new(); + collector.queue_operation(Operation::AllocateQubit { id: 5 }); + collector.queue_operation(Operation::AllocateResult { id: 2 }); + collector.queue_operation(QuantumOp::H(1).into()); + + assert_eq!(collector_capacity(&collector), (6, 3)); + } + + #[test] + fn test_shot_start_defers_until_plugin_load() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.shot_start(42, Some(1234)).unwrap(); + + assert_eq!(runtime.pending_shot_start, Some((42, Some(1234)))); + + runtime.shot_end().unwrap(); + assert_eq!(runtime.pending_shot_start, None); + } + + #[test] + fn test_clone_does_not_reuse_initialized_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.num_qubits = 3; + runtime.initialized_num_qubits = Some(3); + + let cloned = runtime.clone(); + + assert_eq!(cloned.num_qubits, 3); + assert_eq!(cloned.initialized_num_qubits, None); + } + + #[test] + fn test_reset_clears_initialized_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.initialized_num_qubits = Some(3); + + runtime.reset().unwrap(); + + assert_eq!(runtime.initialized_num_qubits, None); + } } diff --git a/docs/development/from-guppy-dem-handoff.md b/docs/development/from-guppy-dem-handoff.md index 54766dbff..16c6d8c05 100644 --- a/docs/development/from-guppy-dem-handoff.md +++ b/docs/development/from-guppy-dem-handoff.md @@ -15,7 +15,7 @@ entry point. The intended shape is: This should support calls like: -```python +```python,notest from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -44,7 +44,7 @@ dem = DetectorErrorModel.from_guppy( `pecos_rslib.qec.DetectorErrorModel` is not currently subclassable from Python. Defining: -```python +```python,notest class DetectorErrorModel(_RustDetectorErrorModel): ... ``` @@ -58,7 +58,7 @@ TypeError: type 'pecos_rslib.qec.DetectorErrorModel' is not an acceptable base t The current local fix is to re-export the Rust class directly and attach the Python convenience constructor: -```python +```python,notest DetectorErrorModel = _RustDetectorErrorModel DetectorErrorModel.from_guppy = classmethod(...) ``` From 088671d17f7dab454cf6d659ca05dd69f0bd4e4c Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 10:24:42 -0600 Subject: [PATCH 176/388] Add logical decoder descriptor validation regressions. --- .../test_logical_circuit_decoder_windowing.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py index 5f1dd9a79..2e506748a 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_circuit_decoder_windowing.py @@ -38,6 +38,18 @@ def _memory_descriptor(d: int, rounds: int) -> dict: return b.build_algorithm_descriptor(p1=0.001, p2=0.001, p_meas=0.001) +def _h_boundary_descriptor() -> dict: + patch = SurfacePatch.create(3) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + b.add_transversal_h("A") + b.add_memory("A", 3, "X") + desc = b.build_algorithm_descriptor(p1=0.001, p2=0.001, p_meas=0.001) + assert desc["boundary_gates"][0][0]["type"] == "Hadamard" + return desc + + def test_unlimited_budget_reports_unlimited(): dec = LogicalCircuitDecoder(_memory_descriptor(3, 9), budget="unlimited") assert dec.effective_windowing == "unlimited" @@ -89,5 +101,32 @@ def test_windowed_full_fallback_still_decodes(): assert dec.decode([0] * ndet) == 0 +def test_logical_circuit_decoder_rejects_empty_segments(): + """Malformed algorithm descriptors should raise a Python error, not panic.""" + desc = _memory_descriptor(3, 3) + desc["segments"] = [] + + with pytest.raises(ValueError, match="no segments"): + LogicalCircuitDecoder(desc, budget="unlimited") + + +def test_logical_circuit_decoder_rejects_missing_boundary_gate_bit(): + """Boundary gate descriptors must fail loudly when required bit fields are absent.""" + desc = _h_boundary_descriptor() + del desc["boundary_gates"][0][0]["x_obs_bit"] + + with pytest.raises(ValueError, match="missing required field 'x_obs_bit'"): + LogicalCircuitDecoder(desc, budget="unlimited") + + +def test_logical_circuit_decoder_rejects_out_of_range_boundary_gate_bit(): + """Boundary gate bits index a u64 observable frame and must be below 64.""" + desc = _h_boundary_descriptor() + desc["boundary_gates"][0][0]["x_obs_bit"] = 64 + + with pytest.raises(ValueError, match="exceeds the 64-observable frame limit"): + LogicalCircuitDecoder(desc, budget="unlimited") + + if __name__ == "__main__": pytest.main([__file__, "-v"]) From 7b9f20cb1f586d6c022f8435f1826922c16552d4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 11:17:48 -0600 Subject: [PATCH 177/388] Fix subset CV double-count: compute coefficient of variation from committed product factors only, not terminal recorded-but-uncommitted levels --- exp/pecos-neo/src/sampling/subset.rs | 104 +++++++++++++++++++++++++-- 1 file changed, 99 insertions(+), 5 deletions(-) diff --git a/exp/pecos-neo/src/sampling/subset.rs b/exp/pecos-neo/src/sampling/subset.rs index e7d3ab813..79e7cb8ab 100644 --- a/exp/pecos-neo/src/sampling/subset.rs +++ b/exp/pecos-neo/src/sampling/subset.rs @@ -1264,6 +1264,9 @@ impl ProperSubsetSimulation { // Step 2: Iteratively apply subset simulation levels let mut current_threshold = 0.0; let mut cumulative_prob = 1.0; + // Conditionals actually committed into the estimate (terminal + // levels are recorded but not multiplied in); CV uses these. + let mut committed_conditionals: Vec = Vec::new(); for level in 0..self.config.max_levels { // Sort trajectories by final score (descending) @@ -1330,6 +1333,7 @@ impl ProperSubsetSimulation { } cumulative_prob *= conditional_prob; + committed_conditionals.push(conditional_prob); current_threshold = new_threshold; @@ -1414,7 +1418,7 @@ impl ProperSubsetSimulation { // Independent-levels CV, including the final failure-fraction term. let cv_squared = independent_levels_cv_squared( - self.levels.iter().map(|l| l.conditional_prob), + committed_conditionals.iter().copied(), final_failure_fraction, n, ); @@ -1497,6 +1501,9 @@ impl ProperSubsetSimulation { // Step 4: Iteratively apply subset simulation at each threshold let mut current_threshold = 0.0; let mut cumulative_prob = 1.0; + // Conditionals actually committed into the estimate (terminal + // levels are recorded but not multiplied in); CV uses these. + let mut committed_conditionals: Vec = Vec::new(); for (level, &target_threshold) in thresholds.iter().enumerate() { if level >= self.config.max_levels { @@ -1576,6 +1583,7 @@ impl ProperSubsetSimulation { if conditional_prob > 0.0 { cumulative_prob *= conditional_prob; + committed_conditionals.push(conditional_prob); } current_threshold = actual_threshold; @@ -1661,7 +1669,7 @@ impl ProperSubsetSimulation { // Independent-levels CV, including the final failure-fraction term. let cv_squared = independent_levels_cv_squared( - self.levels.iter().map(|l| l.conditional_prob), + committed_conditionals.iter().copied(), final_failure_fraction, n, ); @@ -2083,6 +2091,9 @@ impl QecSubsetSimulation { let mut current_threshold = 0.0; let mut cumulative_prob = 1.0; let mut levels = Vec::new(); + // Conditionals actually committed into the estimate (terminal + // levels are recorded but not multiplied in); CV uses these. + let mut committed_conditionals: Vec = Vec::new(); for level in 0..self.config.base.max_levels { // Sort by final score (descending) @@ -2148,6 +2159,7 @@ impl QecSubsetSimulation { } cumulative_prob *= conditional_prob; + committed_conditionals.push(conditional_prob); current_threshold = new_threshold; @@ -2238,7 +2250,7 @@ impl QecSubsetSimulation { // Independent-levels CV, including the final failure-fraction term. let cv_squared = independent_levels_cv_squared( - levels.iter().map(|l| l.conditional_prob), + committed_conditionals.iter().copied(), final_failure_fraction, n, ); @@ -2594,12 +2606,17 @@ mod tests { .run(); estimates.push(result.probability); reported_cv.push(result.coefficient_of_variation); - // Reconstruct the pre-fix CV (intermediate levels only, no final - // failure-fraction term) for the materiality check. + // Reconstruct the COMMITTED-intermediate CV (no final term) for + // the materiality check. Committed levels are those that did not + // trigger a terminal break (all-failed or threshold-reaches- + // failure); a terminal level is recorded in `levels` but its + // conditional is never a factor in the estimate, so it must be + // excluded here exactly as the production CV excludes it. let nf = n as f64; let intermediate: f64 = result .levels .iter() + .filter(|l| l.num_failures != n && l.threshold < failure_threshold) .map(|l| { let p = l.conditional_prob; if p > 0.0 && p < 1.0 { @@ -2652,6 +2669,83 @@ mod tests { ); } + #[test] + #[allow(clippy::cast_precision_loss)] + fn proper_subset_cv_counts_committed_factors_not_recorded_levels() { + // Discriminating regression for the CV double-count: a terminal level + // — one whose new threshold reaches the failure level — is RECORDED in + // `levels` for reporting, but its conditional is never multiplied into + // the estimator. The CV must be a function of the committed product + // factors only; including a terminal conditional double-counts the + // boundary (the terminal conditional is itself a measure of the + // failure tail, the same quantity the final failure fraction already + // captures). This reconstructs both the committed-only and the + // all-recorded-levels CV^2 from the result and asserts the reported + // CV uses the former. On the pre-fix code (CV over all recorded + // levels) the first assertion fails, so this test discriminates. + let (p_damage, increment, num_rounds, failure_threshold) = (0.1, 1.0, 40, 11.0); + let n = 2000; + let config = SubsetConfig::new() + .with_samples_per_level(n) + .with_threshold_fraction(0.2) + .with_max_levels(20) + .with_seed(1); + let result = + ProperSubsetSimulation::new(p_damage, increment, failure_threshold, num_rounds, config) + .run(); + + // The committed factors are exactly the non-terminal recorded levels. + let committed: Vec = result + .levels + .iter() + .filter(|l| l.num_failures != n && l.threshold < failure_threshold) + .map(|l| l.conditional_prob) + .collect(); + // A terminal level must actually be present, or the test proves nothing. + assert!( + result.levels.len() > committed.len(), + "test regime must produce a terminal recorded-but-uncommitted level \ + (recorded {}, committed {})", + result.levels.len(), + committed.len() + ); + + // The estimate is prod(committed) * q, so recover the final failure + // fraction q = probability / prod(committed) to rebuild the CV terms. + let nf = n as f64; + let prod_committed: f64 = committed.iter().product(); + let q = result.probability / prod_committed; + let term = |p: f64| { + if p > 0.0 && p < 1.0 { + (1.0 - p) / (nf * p) + } else { + 0.0 + } + }; + let committed_cv_sq: f64 = committed.iter().map(|&p| term(p)).sum::() + term(q); + let all_levels_cv_sq: f64 = result + .levels + .iter() + .map(|l| term(l.conditional_prob)) + .sum::() + + term(q); + let reported_cv_sq = result.coefficient_of_variation.powi(2); + + // The reported CV is built from committed factors only... + assert!( + (reported_cv_sq - committed_cv_sq).abs() <= 1e-9 * committed_cv_sq.max(1e-12), + "reported CV^2 {reported_cv_sq:.6e} must equal the committed-only formula \ + {committed_cv_sq:.6e}, not the all-recorded-levels formula {all_levels_cv_sq:.6e}" + ); + // ...and the all-recorded-levels formula (the pre-fix behavior) is a + // genuinely different, larger value, so the check above is not vacuous. + assert!( + all_levels_cv_sq > committed_cv_sq + 1e-9, + "all-recorded-levels CV^2 {all_levels_cv_sq:.6e} must exceed the committed-only \ + CV^2 {committed_cv_sq:.6e}: the terminal level must double-count the boundary" + ); + } + // ======================================================================== // ECS-Based Subset Simulation Tests // ======================================================================== From ceaccc9ae60b6d2d8148c2ff2bd4cb130c5f742f Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 11:20:12 -0600 Subject: [PATCH 178/388] Respect explicit QIS physical qubit capacity. --- crates/pecos-qis/src/ccengine.rs | 71 ++++++++++-- crates/pecos-qis/src/selene_runtime.rs | 148 +++++++++++++++++++++---- 2 files changed, 185 insertions(+), 34 deletions(-) diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 377ea7a64..af3978717 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -496,20 +496,27 @@ impl QisEngine { self.num_physical_slots = 0; } - fn allocate_qubit_slot(&mut self, program_id: usize) -> usize { + fn allocate_qubit_slot(&mut self, program_id: usize) -> Result { if let Some(&slot) = self.active_qubit_slots.get(&program_id) { - return slot; + return Ok(slot); } let slot = if let Some(slot) = self.free_qubit_slots.pop_first() { slot } else { + if let Some(limit) = self.num_qubits_hint + && self.num_physical_slots >= limit + { + return Err(PecosError::Generic(format!( + "QIS program requires more than the configured {limit} physical qubit slots while allocating program qubit {program_id}" + ))); + } self.num_physical_slots }; self.num_physical_slots = self.num_physical_slots.max(slot + 1); self.active_qubit_slots.insert(program_id, slot); self.seen_program_qubits.insert(program_id); - slot + Ok(slot) } fn release_qubit_slot(&mut self, program_id: usize) { @@ -529,7 +536,7 @@ impl QisEngine { ))); } - Ok(self.allocate_qubit_slot(program_id)) + self.allocate_qubit_slot(program_id) } /// Convert dynamic QIS operations into a `ByteMessage` for the quantum engine. @@ -548,7 +555,7 @@ impl QisEngine { for op in ops { match op { Operation::AllocateQubit { id } => { - let slot = self.allocate_qubit_slot(*id); + let slot = self.allocate_qubit_slot(*id)?; builder.pz(&[slot]); } Operation::ReleaseQubit { id } => { @@ -1275,11 +1282,11 @@ impl ClassicalEngine for QisEngine { // return the physical-slot high-water mark instead. The runtime can // report its own baseline (e.g. from `allocated_qubits` metadata) and // we take the larger of the two. - let num_qubits = self - .runtime - .num_qubits() - .max(self.num_physical_slots) - .max(self.num_qubits_hint.unwrap_or(0)); + let num_qubits = if let Some(hint) = self.num_qubits_hint { + hint + } else { + self.runtime.num_qubits().max(self.num_physical_slots) + }; debug!("QisEngine: num_qubits() returning {num_qubits}"); num_qubits } @@ -1884,4 +1891,48 @@ mod tests { "unexpected error: {err}" ); } + + #[test] + fn test_num_qubits_hint_is_physical_capacity_for_sparse_handles() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + engine.set_num_qubits_hint(98); + let ops = vec![ + Operation::AllocateQubit { id: 81 }, + Operation::AllocateQubit { id: 105 }, + QuantumOp::CX(81, 105).into(), + ]; + + let commands = engine + .operations_to_bytemessage(&ops) + .expect("sparse handles should map onto live physical slots"); + + let lowered = commands.quantum_ops().expect("parse lowered commands"); + assert_eq!(lowered[0].qubits.as_slice(), &[pecos_core::QubitId(0)]); + assert_eq!(lowered[1].qubits.as_slice(), &[pecos_core::QubitId(1)]); + assert_eq!( + lowered[2].qubits.as_slice(), + &[pecos_core::QubitId(0), pecos_core::QubitId(1)] + ); + assert_eq!(engine.num_physical_slots, 2); + assert_eq!(engine.num_qubits(), 98); + } + + #[test] + fn test_qubit_hint_rejects_too_many_live_physical_slots() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + engine.set_num_qubits_hint(1); + let ops = vec![ + Operation::AllocateQubit { id: 81 }, + Operation::AllocateQubit { id: 105 }, + ]; + + let Err(err) = engine.operations_to_bytemessage(&ops) else { + panic!("allocating beyond the physical qubit hint should error"); + }; + + assert!( + err.to_string().contains("more than the configured 1"), + "unexpected error: {err}" + ); + } } diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 385c3e659..f88ed9a1e 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -6,7 +6,7 @@ use crate::runtime::{ClassicalState, QisRuntime, Result, RuntimeError, Shot}; use log::{debug, trace}; use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::{CString, c_void}; use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; @@ -217,6 +217,21 @@ pub struct SeleneRuntime { /// Number of qubits num_qubits: usize, + /// Explicit physical runtime capacity requested by the caller. + /// + /// Some generated programs use sparse, monotonically increasing logical + /// handles while guaranteeing a smaller maximum number of live physical + /// slots. In those cases the `.qubits(...)` hint is the runtime capacity; + /// if the program actually exceeds it, plugin qalloc fails loudly. + num_qubits_hint: Option, + + /// Whether the loaded operation stream uses explicit qalloc/qfree records. + /// + /// In this mode program qubit IDs are logical handles, not dense physical + /// runtime slots. Runtime plugin capacity must therefore follow the maximum + /// simultaneously-live allocation count instead of `max(program_id) + 1`. + uses_explicit_qubit_allocation: bool, + /// Number of allocated result slots num_results: usize, @@ -270,6 +285,8 @@ impl SeleneRuntime { operations_buffer: Vec::new(), batch_size: 100, num_qubits: 0, + num_qubits_hint: None, + uses_explicit_qubit_allocation: false, num_results: 0, interface: None, current_op_index: 0, @@ -321,11 +338,14 @@ impl SeleneRuntime { self.interface.as_ref().map_or(0, |i| i.operations.len()) ); - // Update capacities from both explicit allocation records and direct - // program handles used by older QIR/LLVM examples. + // Update capacities from explicit allocation records when available, + // otherwise fall back to direct program handles used by older QIR/LLVM + // examples. let (num_qubits, num_results) = collector_capacity(&operations); self.num_qubits = num_qubits; self.num_results = num_results; + self.uses_explicit_qubit_allocation = + has_explicit_qubit_allocations(&operations.operations); self.interface = Some(operations); self.current_op_index = 0; @@ -340,11 +360,12 @@ impl SeleneRuntime { } self.apply_library_search_dirs()?; + let plugin_num_qubits = self.plugin_num_qubits(); debug!( "Loading Selene plugin from {} with {} qubits, {} results, and {} init args", self.plugin_path, - self.num_qubits, + plugin_num_qubits, self.num_results, self.init_args.len() ); @@ -383,7 +404,7 @@ impl SeleneRuntime { let mut instance: *mut c_void = std::ptr::null_mut(); let errno = init_fn( &raw mut instance, - self.num_qubits as u64, + plugin_num_qubits as u64, 0, // start time arg_ptrs.len() as u32, argv, @@ -397,7 +418,7 @@ impl SeleneRuntime { self.library = Some(ManuallyDrop::new(lib)); self.instance = Some(instance); - self.initialized_num_qubits = Some(self.num_qubits); + self.initialized_num_qubits = Some(plugin_num_qubits); } self.apply_pending_shot_start()?; @@ -409,18 +430,23 @@ impl SeleneRuntime { return Ok(()); }; - if self.num_qubits <= initialized_num_qubits { + let plugin_num_qubits = self.plugin_num_qubits(); + if plugin_num_qubits <= initialized_num_qubits { return Ok(()); } debug!( "Reinitializing Selene plugin capacity from {} to {} qubits", - initialized_num_qubits, self.num_qubits + initialized_num_qubits, plugin_num_qubits ); self.reset_plugin_instance()?; self.load_plugin() } + fn plugin_num_qubits(&self) -> usize { + self.num_qubits_hint.unwrap_or(self.num_qubits) + } + fn reset_plugin_instance(&mut self) -> Result<()> { if let Some(lib) = &self.library && let Some(instance) = self.instance @@ -550,7 +576,6 @@ impl SeleneRuntime { } Operation::AllocateQubit { id } => { trace!("Allocating qubit {id}"); - self.num_qubits = self.num_qubits.max(id + 1); self.current_op_index += 1; } Operation::AllocateResult { id } => { @@ -606,7 +631,9 @@ impl SeleneRuntime { let runtime_qubit = self.runtime_qalloc()?; self.program_to_runtime_qubits .insert(program_qubit, runtime_qubit); - self.num_qubits = self.num_qubits.max(program_qubit + 1); + if !self.uses_explicit_qubit_allocation { + self.num_qubits = self.num_qubits.max(program_qubit + 1); + } Ok(runtime_qubit) } @@ -877,6 +904,8 @@ impl SeleneRuntime { QuantumOp::Measure(qubit, result_id) => { let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; self.call_runtime_measure(runtime_qubit, *result_id)?; + self.program_to_runtime_qubits.remove(qubit); + self.runtime_qfree(runtime_qubit)?; } QuantumOp::Reset(qubit) => { let runtime_qubit = self.runtime_qubit_for_program(*qubit)?; @@ -1119,6 +1148,8 @@ impl Clone for SeleneRuntime { operations_buffer: self.operations_buffer.clone(), batch_size: self.batch_size, num_qubits: self.num_qubits, + num_qubits_hint: self.num_qubits_hint, + uses_explicit_qubit_allocation: self.uses_explicit_qubit_allocation, num_results: self.num_results, interface: self.interface.clone(), current_op_index: self.current_op_index, @@ -1134,10 +1165,14 @@ impl Clone for SeleneRuntime { } fn collector_capacity(interface: &OperationCollector) -> (usize, usize) { - let (mut num_qubits, mut num_results) = operation_capacity(&interface.operations); + let uses_explicit_allocations = has_explicit_qubit_allocations(&interface.operations); + let (mut num_qubits, mut num_results) = + operation_capacity_with_mode(&interface.operations, uses_explicit_allocations); - for &qubit in &interface.allocated_qubits { - include_qubit(&mut num_qubits, qubit); + if !uses_explicit_allocations { + for &qubit in &interface.allocated_qubits { + include_qubit(&mut num_qubits, qubit); + } } for &result in &interface.allocated_results { include_result(&mut num_results, result); @@ -1146,17 +1181,38 @@ fn collector_capacity(interface: &OperationCollector) -> (usize, usize) { (num_qubits, num_results) } -fn operation_capacity(operations: &[Operation]) -> (usize, usize) { +fn has_explicit_qubit_allocations(operations: &[Operation]) -> bool { + operations.iter().any(|op| { + matches!( + op, + Operation::AllocateQubit { .. } | Operation::ReleaseQubit { .. } + ) + }) +} + +fn operation_capacity_with_mode( + operations: &[Operation], + uses_explicit_allocations: bool, +) -> (usize, usize) { let mut num_qubits = 0; let mut num_results = 0; + let mut live_qubits = BTreeSet::new(); + let mut max_live_qubits = 0; for op in operations { match op { - Operation::Quantum(qop) => { + Operation::Quantum(qop) if !uses_explicit_allocations => { include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results) } - Operation::AllocateQubit { id } | Operation::ReleaseQubit { id } => { - include_qubit(&mut num_qubits, *id); + Operation::Quantum(qop) => { + include_quantum_result_capacity(qop, &mut num_results); + } + Operation::AllocateQubit { id } => { + live_qubits.insert(*id); + max_live_qubits = max_live_qubits.max(live_qubits.len()); + } + Operation::ReleaseQubit { id } => { + live_qubits.remove(id); } Operation::AllocateResult { id } => include_result(&mut num_results, *id), Operation::RecordOutput { result_id, .. } => { @@ -1165,10 +1221,19 @@ fn operation_capacity(operations: &[Operation]) -> (usize, usize) { Operation::Barrier => {} } } + if uses_explicit_allocations { + num_qubits = max_live_qubits; + } (num_qubits, num_results) } +fn include_quantum_result_capacity(qop: &QuantumOp, num_results: &mut usize) { + if let QuantumOp::Measure(_, result) = qop { + include_result(num_results, *result); + } +} + fn include_quantum_op_capacity(qop: &QuantumOp, num_qubits: &mut usize, num_results: &mut usize) { match qop { QuantumOp::H(qubit) @@ -1222,12 +1287,13 @@ impl QisRuntime for SeleneRuntime { interface.operations.len() ); - // Count qubits and results from both explicit allocation records and - // direct program handles. Some legacy LLVM/QIR inputs use qubit handles - // like 0 and 1 without emitting __quantum__rt__qubit_allocate calls. + // Count qubits from explicit allocation records when present, + // otherwise from direct program handles. Some legacy LLVM/QIR inputs + // use qubit handles like 0 and 1 without emitting allocation calls. let (num_qubits, num_results) = collector_capacity(&interface); self.num_qubits = num_qubits; self.num_results = num_results; + self.uses_explicit_qubit_allocation = has_explicit_qubit_allocations(&interface.operations); debug!( "Interface has {} qubits and {} result slots", @@ -1257,7 +1323,11 @@ impl QisRuntime for SeleneRuntime { } fn lower_operations(&mut self, operations: &[Operation]) -> Result> { - let (num_qubits, num_results) = operation_capacity(operations); + if has_explicit_qubit_allocations(operations) { + self.uses_explicit_qubit_allocation = true; + } + let (num_qubits, num_results) = + operation_capacity_with_mode(operations, self.uses_explicit_qubit_allocation); self.num_qubits = self.num_qubits.max(num_qubits); self.num_results = self.num_results.max(num_results); self.ensure_plugin_capacity()?; @@ -1354,10 +1424,11 @@ impl QisRuntime for SeleneRuntime { } fn num_qubits(&self) -> usize { - self.num_qubits + self.plugin_num_qubits() } fn set_num_qubits(&mut self, num_qubits: usize) { + self.num_qubits_hint = Some(num_qubits); self.num_qubits = self.num_qubits.max(num_qubits); } @@ -1520,9 +1591,38 @@ mod tests { let mut collector = OperationCollector::new(); collector.queue_operation(Operation::AllocateQubit { id: 5 }); collector.queue_operation(Operation::AllocateResult { id: 2 }); - collector.queue_operation(QuantumOp::H(1).into()); + collector.queue_operation(QuantumOp::H(5).into()); + + assert_eq!(collector_capacity(&collector), (1, 3)); + } + + #[test] + fn test_collector_capacity_uses_max_live_explicit_allocations() { + let mut collector = OperationCollector::new(); + collector.queue_operation(Operation::AllocateQubit { id: 81 }); + collector.queue_operation(Operation::AllocateQubit { id: 97 }); + collector.queue_operation(QuantumOp::CX(81, 97).into()); + collector.queue_operation(Operation::ReleaseQubit { id: 97 }); + collector.queue_operation(Operation::AllocateQubit { id: 105 }); + collector.queue_operation(QuantumOp::Measure(105, 9).into()); + + assert_eq!(collector_capacity(&collector), (2, 10)); + } + + #[test] + fn test_explicit_qubit_hint_caps_plugin_capacity() { + let mut runtime = SeleneRuntime::new("/path/to/selene.so"); + runtime.set_num_qubits(98); + + let (num_qubits, _) = operation_capacity_with_mode( + &[QuantumOp::CX(81, 105).into()], + runtime.uses_explicit_qubit_allocation, + ); + runtime.num_qubits = runtime.num_qubits.max(num_qubits); - assert_eq!(collector_capacity(&collector), (6, 3)); + assert_eq!(runtime.num_qubits, 106); + assert_eq!(runtime.plugin_num_qubits(), 98); + assert_eq!(runtime.num_qubits(), 98); } #[test] From 0b6b9257dbabc8797017685d3929972c7e9f7da6 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 11:42:03 -0600 Subject: [PATCH 179/388] Support constrained SZZ surface memory circuits. --- .../quantum-pecos/src/pecos/guppy/surface.py | 114 ++++++++----- .../src/pecos/qec/surface/circuit_builder.py | 156 ++++++++++-------- .../guppy/test_surface_ancilla_budget.py | 22 +++ .../tests/guppy/test_surface_twirl_render.py | 13 +- 4 files changed, 194 insertions(+), 111 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 9a33a75cf..77dbec321 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -173,8 +173,8 @@ def generate_guppy_source( num_rounds: Number of syndrome rounds to render. Required when ``twirl`` is enabled because twirled source is unrolled per round. interaction_basis: Surface-memory interaction basis: ``"cx"`` or - ``"szz"``. SZZ Guppy generation currently supports only the - unconstrained, non-twirled memory shape. + ``"szz"``. SZZ Guppy generation supports the same non-twirled + unconstrained and constrained ancilla shapes as the CX template. Returns: Python/Guppy source code as a string. @@ -206,13 +206,6 @@ def generate_guppy_source( total_ancilla = num_x_stab + num_z_stab effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) constrained = effective_budget < total_ancilla - if interaction_basis == "szz" and constrained: - msg = ( - "interaction_basis='szz' does not yet support constrained ancilla " - f"budgets on the Guppy runtime path (ancilla_budget={ancilla_budget} " - f"< total_ancilla={total_ancilla})" - ) - raise ValueError(msg) if interaction_basis == "szz" and twirl is not None: msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" raise ValueError(msg) @@ -498,6 +491,9 @@ def _append_szz_layer( lines.append( f' """Extract full syndrome in {len(batches)} ancilla-reuse batches (budget={effective_budget})."""', ) + if interaction_basis == "szz": + lines.append(" # Unpack data qubits") + _append_szz_data_unpack(lines, " ") idx = 0 for batch_idx, batch in enumerate(batches): lines.append("") @@ -514,13 +510,18 @@ def _append_szz_layer( batch_anc_var[(stab_type, stab_idx)] = var lines.append(f" {var} = qubit()") - x_in_batch = [(t, i) for (t, i) in batch if t == "X"] - if x_in_batch: - lines.append(" # Hadamard on X ancillas in this batch") - for stab_type, stab_idx in x_in_batch: + if interaction_basis == "cx": + x_in_batch = [(t, i) for (t, i) in batch if t == "X"] + if x_in_batch: + lines.append(" # Hadamard on X ancillas in this batch") + for stab_type, stab_idx in x_in_batch: + lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") + else: + lines.append(" # Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") - # Filter the full CX schedule to just this batch's stabilizers. + # Filter the full interaction schedule to just this batch's stabilizers. batch_keys = set(batch_anc_var.keys()) for rnd_idx, rnd_gates in enumerate(rounds): rnd_in_batch = [ @@ -532,17 +533,36 @@ def _append_szz_layer( continue lines.append("") lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") - for stab_type, stab_idx, data_q in rnd_in_batch: - anc = batch_anc_var[(stab_type, stab_idx)] - if stab_type == "X": - lines.append(f" cx({anc}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], {anc})") + if interaction_basis == "cx": + for stab_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(stab_type, stab_idx)] + if stab_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + else: + _append_szz_layer( + lines, + " ", + rnd_idx, + rnd_in_batch, + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (stab_type, stab_idx) + ], + _szz_data_expr, + ) - if x_in_batch: + if interaction_basis == "cx": + x_in_batch = [(t, i) for (t, i) in batch if t == "X"] + if x_in_batch: + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for stab_type, stab_idx in x_in_batch: + lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") + else: lines.append("") - lines.append(" # Hadamard on X ancillas in this batch") - for stab_type, stab_idx in x_in_batch: + lines.append(" # Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: lines.append(f" h({batch_anc_var[(stab_type, stab_idx)]})") lines.append("") @@ -670,8 +690,13 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: batch_anc_var[(selected_type, stab_idx)] = var lines.append(f" {var} = qubit()") - if stab_type == "X": - lines.append(" # Hadamard on X ancillas in this batch") + if interaction_basis == "cx": + if stab_type == "X": + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + else: + lines.append(" # Hadamard on SZZ ancillas in this batch") for selected_type, stab_idx in init_batch: lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") @@ -686,16 +711,34 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: continue lines.append("") lines.append(f" # Batch {batch_idx + 1} round {rnd_idx + 1}") - for selected_type, stab_idx, data_q in rnd_in_batch: - anc = batch_anc_var[(selected_type, stab_idx)] - if selected_type == "X": - lines.append(f" cx({anc}, surf.data[{data_q}])") - else: - lines.append(f" cx(surf.data[{data_q}], {anc})") - - if stab_type == "X": + if interaction_basis == "cx": + for selected_type, stab_idx, data_q in rnd_in_batch: + anc = batch_anc_var[(selected_type, stab_idx)] + if selected_type == "X": + lines.append(f" cx({anc}, surf.data[{data_q}])") + else: + lines.append(f" cx(surf.data[{data_q}], {anc})") + else: + _append_szz_layer( + lines, + " ", + rnd_idx, + rnd_in_batch, + lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (selected_type, stab_idx) + ], + _szz_data_expr, + ) + + if interaction_basis == "cx": + if stab_type == "X": + lines.append("") + lines.append(" # Hadamard on X ancillas in this batch") + for selected_type, stab_idx in init_batch: + lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") + else: lines.append("") - lines.append(" # Hadamard on X ancillas in this batch") + lines.append(" # Hadamard on SZZ ancillas in this batch") for selected_type, stab_idx in init_batch: lines.append(f" h({batch_anc_var[(selected_type, stab_idx)]})") @@ -1649,9 +1692,6 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 - if interaction_basis == "szz" and normalize_ancilla_budget(total_ancilla, ancilla_budget) < total_ancilla: - msg = "interaction_basis='szz' does not yet support constrained ancilla budgets on the Guppy runtime path" - raise ValueError(msg) if interaction_basis == "szz" and twirl is not None: msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index d81ec155e..5e614c979 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -882,12 +882,6 @@ def emit_gate_local_twirl_layer( cnot_rounds = compute_cnot_schedule(patch) if interaction_basis == "szz": - if effective_ancilla_budget != total_ancilla: - msg = ( - "interaction_basis='szz' does not yet support constrained " - "ancilla budgets; pass ancilla_budget=None or a budget >= total_ancilla" - ) - raise ValueError(msg) if twirl is not None: msg = "interaction_basis='szz' twirl integration is staged later; omit twirl for Stage 1" raise ValueError(msg) @@ -1213,8 +1207,64 @@ def z_anc_q(stab_idx: int) -> int: def anc_q(stabilizer_type: str, stab_idx: int) -> int: return x_anc_q(stab_idx) if stabilizer_type == "X" else z_anc_q(stab_idx) - def stabilizers_for(stabilizer_type: str) -> list: - return geom.x_stabilizers if stabilizer_type == "X" else geom.z_stabilizers + def stabilizer_batches_for(selected_type: str | None = None) -> list[list[tuple[str, int]]]: + """Return the same ancilla-reuse batches used by the CX template.""" + total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) + allocation_ancilla_count = len(set(allocation.x_ancilla_qubits + allocation.z_ancilla_qubits)) + if allocation_ancilla_count >= total_ancilla: + batch = [("X", s.index) for s in geom.x_stabilizers] + batch.extend(("Z", s.index) for s in geom.z_stabilizers) + batches = [batch] + else: + batches = _batched_stabilizers(patch, allocation_ancilla_count) + + if selected_type is None: + return batches + return [ + [(stab_type, stab_idx) for stab_type, stab_idx in batch if stab_type == selected_type] + for batch in batches + if any(stab_type == selected_type for stab_type, _stab_idx in batch) + ] + + def append_prepare_szz_ancillas(target_ops: list[SurfaceCircuitStep], batch: list[tuple[str, int]]) -> None: + target_ops.extend( + SurfaceCircuitStep( + OpType.ALLOC, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + + def append_measure_szz_ancillas(target_ops: list[SurfaceCircuitStep], batch: list[tuple[str, int]]) -> None: + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.H, + [anc_q(stab_type, stab_idx)], + f"a{stab_type.lower()}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) + + target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) + target_ops.extend( + SurfaceCircuitStep( + OpType.MEASURE, + [anc_q(stab_type, stab_idx)], + f"{'sx' if stab_type == 'X' else 'sz'}{stab_idx}", + ) + for stab_type, stab_idx in batch + ) def append_szz_layer( target_ops: list[SurfaceCircuitStep], @@ -1265,90 +1315,54 @@ def append_szz_layer( # init_{basis}_basis syndrome establishment # ========================================================================= init_stabilizer_type = "X" if basis.upper() == "Z" else "Z" - init_stabilizers = list(stabilizers_for(init_stabilizer_type)) ops.append( SurfaceCircuitStep( OpType.COMMENT, label=f"init_{init_stabilizer_type.lower()}_syndrome", ), ) - ops.extend( - SurfaceCircuitStep( - OpType.ALLOC, - [anc_q(init_stabilizer_type, s.index)], - f"a{init_stabilizer_type.lower()}{s.index}", - ) - for s in init_stabilizers - ) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) - ops.extend( - SurfaceCircuitStep( - OpType.H, - [anc_q(init_stabilizer_type, s.index)], - f"a{init_stabilizer_type.lower()}{s.index}", - ) - for s in init_stabilizers - ) - ops.append(SurfaceCircuitStep(OpType.TICK)) - - for rnd_idx, cnot_round in enumerate(cnot_rounds): - layer_gates = [ - (stab_type, stab_idx, data_idx) - for stab_type, stab_idx, data_idx in cnot_round - if stab_type == init_stabilizer_type - ] - append_szz_layer(ops, rnd_idx, layer_gates) + for init_batch in stabilizer_batches_for(init_stabilizer_type): + append_prepare_szz_ancillas(ops, init_batch) ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) - ops.extend( - SurfaceCircuitStep( - OpType.H, - [anc_q(init_stabilizer_type, s.index)], - f"a{init_stabilizer_type.lower()}{s.index}", - ) - for s in init_stabilizers - ) + init_keys = set(init_batch) + for rnd_idx, cnot_round in enumerate(cnot_rounds): + layer_gates = [ + (stab_type, stab_idx, data_idx) + for stab_type, stab_idx, data_idx in cnot_round + if (stab_type, stab_idx) in init_keys + ] + append_szz_layer(ops, rnd_idx, layer_gates) + ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) - init_label_prefix = "sx" if init_stabilizer_type == "X" else "sz" - ops.extend( - SurfaceCircuitStep( - OpType.MEASURE, - [anc_q(init_stabilizer_type, s.index)], - f"{init_label_prefix}{s.index}", - ) - for s in init_stabilizers - ) - ops.append(SurfaceCircuitStep(OpType.TICK)) + append_measure_szz_ancillas(ops, init_batch) + ops.append(SurfaceCircuitStep(OpType.TICK)) # ========================================================================= # syndrome_extraction # ========================================================================= + stabilizer_batches = stabilizer_batches_for() for rnd in range(num_rounds): ops.append( SurfaceCircuitStep(OpType.COMMENT, label=f"syndrome_extraction round {rnd + 1}"), ) - ops.extend(SurfaceCircuitStep(OpType.ALLOC, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) - ops.extend(SurfaceCircuitStep(OpType.ALLOC, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) - - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) - ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) - ops.extend(SurfaceCircuitStep(OpType.H, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) - ops.append(SurfaceCircuitStep(OpType.TICK)) - for rnd_idx, cnot_round in enumerate(cnot_rounds): - append_szz_layer(ops, rnd_idx, list(cnot_round)) + for batch in stabilizer_batches: + append_prepare_szz_ancillas(ops, batch) ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on SZZ ancillas")) - ops.extend(SurfaceCircuitStep(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) - ops.extend(SurfaceCircuitStep(OpType.H, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + batch_keys = set(batch) + for rnd_idx, cnot_round in enumerate(cnot_rounds): + layer_gates = [ + (stab_type, stab_idx, data_idx) + for stab_type, stab_idx, data_idx in cnot_round + if (stab_type, stab_idx) in batch_keys + ] + append_szz_layer(ops, rnd_idx, layer_gates) + ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) - ops.extend(SurfaceCircuitStep(OpType.MEASURE, [x_anc_q(s.index)], f"sx{s.index}") for s in geom.x_stabilizers) - ops.extend(SurfaceCircuitStep(OpType.MEASURE, [z_anc_q(s.index)], f"sz{s.index}") for s in geom.z_stabilizers) - ops.append(SurfaceCircuitStep(OpType.TICK)) + append_measure_szz_ancillas(ops, batch) + ops.append(SurfaceCircuitStep(OpType.TICK)) # ========================================================================= # measure_z_basis / measure_x_basis diff --git a/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py index 424f2efdf..a3b68082d 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py +++ b/python/quantum-pecos/tests/guppy/test_surface_ancilla_budget.py @@ -50,3 +50,25 @@ def test_constrained_ancilla_surface_code_traces_to_native_tick_circuit() -> Non assert circuit.get_meta("ancilla_budget") == "2" assert int(circuit.get_meta("num_measurements")) > 0 + + +def test_constrained_szz_surface_code_traces_to_native_tick_circuit() -> None: + """Budgeted SZZ/SZZdg surface programs should share the constrained Guppy path.""" + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + circuit = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds=1, + basis="Z", + ancilla_budget=2, + circuit_source="traced_qis", + interaction_basis="szz", + ) + + gate_counts = circuit.gate_counts_by_type() + assert circuit.get_meta("ancilla_budget") == "2" + assert int(circuit.get_meta("num_measurements")) > 0 + assert gate_counts.get("RZZ", 0) > 0 + assert gate_counts.get("CX", 0) == 0 diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index f219264b5..16a4551e1 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -51,10 +51,17 @@ def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: assert "s(d" in src -def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> None: - with pytest.raises(ValueError, match="constrained ancilla budgets"): - generate_guppy_source(patch, interaction_basis="szz", ancilla_budget=1) +def test_szz_source_supports_constrained_ancilla_budget(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz", ancilla_budget=1) + + assert "zz_phase(" in src + assert "cx(" not in src + assert '"""Extract full syndrome in 8 ancilla-reuse batches (budget=1)."""' in src + assert "_a_b0_p0 = qubit()" in src + assert "_init_a_b0_p0 = qubit()" in src + +def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> None: with pytest.raises(ValueError, match="twirl integration is staged later"): generate_guppy_source( patch, From b1042765dd4f16d7e6d9fb4cdc546874c85cfbbd Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 15:04:04 -0600 Subject: [PATCH 180/388] Thread check plans through Guppy surface generation --- .../quantum-pecos/src/pecos/guppy/surface.py | 129 ++++++++++++++---- .../src/pecos/qec/surface/decode.py | 26 +++- .../tests/qec/surface/test_check_plan.py | 37 +++++ 3 files changed, 160 insertions(+), 32 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 77dbec321..b56d4494a 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -22,6 +22,7 @@ if TYPE_CHECKING: from pecos.qec.surface import GuppyRngMaskConfig, SurfacePatch, TwirlConfig + from pecos.qec.surface._check_plan import ResolvedSurfaceCheckPlan # Module state container (avoids global statement) @@ -33,7 +34,7 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str], dict]] = {} _state = _ModuleState() @@ -45,6 +46,19 @@ def _normalize_surface_interaction_basis(interaction_basis: str) -> str: return _normalize_interaction_basis(interaction_basis) +def _resolve_surface_check_plan( + *, + interaction_basis: str | None = None, + check_plan: str | None = None, +) -> "ResolvedSurfaceCheckPlan": + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + return resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + + def _get_temp_dir() -> Path: """Get or create temporary directory for generated code.""" if _state.temp_dir is None: @@ -126,7 +140,8 @@ def generate_guppy_source( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Generate Guppy source code for a surface code patch. @@ -172,9 +187,12 @@ def generate_guppy_source( per-shot quantum entropy when ``twirl`` is enabled. num_rounds: Number of syndrome rounds to render. Required when ``twirl`` is enabled because twirled source is unrolled per round. - interaction_basis: Surface-memory interaction basis: ``"cx"`` or - ``"szz"``. SZZ Guppy generation supports the same non-twirled - unconstrained and constrained ancilla shapes as the CX template. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Current Guppy generation maps the resolved plan to the + corresponding CX or SZZ/SZZdg concrete template. Returns: Python/Guppy source code as a string. @@ -185,7 +203,11 @@ def generate_guppy_source( from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget from pecos.qec.surface.circuit_builder import _default_szz_residual_plan - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis if (twirl is None) != (rng is None): msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" raise ValueError(msg) @@ -260,6 +282,7 @@ def generate_guppy_source( f"Z stabilizers: {num_z_stab}", f"Ancilla qubits: {num_x_stab + num_z_stab} (one per stabilizer)", f"Interaction basis: {interaction_basis}", + f"Check plan: {resolved_plan.plan_id}", '"""', "", *imports, @@ -1493,7 +1516,8 @@ def _guppy_module_cache_key( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1508,9 +1532,17 @@ def _guppy_module_cache_key( """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" - base = f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}_b{effective_budget}{interaction_part}" + check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" + base = ( + f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" + f"_b{effective_budget}{interaction_part}{check_plan_part}" + ) if twirl is None: return base if rng is None or num_rounds is None: @@ -1532,7 +1564,8 @@ def _load_guppy_module( twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1550,14 +1583,20 @@ def _load_guppy_module( twirl: Pauli-twirl-site declaration (structural) rng: Runtime mask RNG seed (must be supplied with ``twirl``) num_rounds: Syndrome-round count for unrolled twirled source. - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Module dictionary with generated functions """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) @@ -1568,6 +1607,7 @@ def _load_guppy_module( rng, num_rounds, interaction_basis, + resolved_plan.plan_id if check_plan is not None else None, ) if cache_key in _state.module_cache: @@ -1580,6 +1620,7 @@ def _load_guppy_module( rng=rng, num_rounds=num_rounds, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, ) # Write to temp file (required for Guppy introspection). @@ -1610,7 +1651,8 @@ def generate_memory_experiment( ancilla_budget: int | None = None, twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> object: """Generate a memory experiment for a patch. @@ -1621,7 +1663,9 @@ def generate_memory_experiment( ancilla_budget: Optional cap on simultaneously live ancillas twirl: Pauli-twirl-site declaration; must be supplied with ``rng``. rng: Runtime mask RNG seed; must be supplied with ``twirl``. - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Guppy function for the experiment @@ -1633,6 +1677,7 @@ def generate_memory_experiment( rng=rng, num_rounds=num_rounds if twirl is not None else None, interaction_basis=interaction_basis, + check_plan=check_plan, ) if basis.upper() == "Z": @@ -1652,7 +1697,8 @@ def get_num_qubits( patch: "SurfacePatch | None" = None, ancilla_budget: int | None = None, twirl: "TwirlConfig | None" = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -1678,7 +1724,11 @@ def get_num_qubits( """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis if (d is None) == (patch is None): msg = "get_num_qubits requires exactly one of d=... or patch=..." raise ValueError(msg) @@ -1704,7 +1754,8 @@ def generate_surface_code_module( d: int, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Generate source code for a distance-d surface code module. @@ -1712,7 +1763,9 @@ def generate_surface_code_module( d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas; forwarded to ``generate_guppy_source``. - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Python/Guppy source code as a string @@ -1726,6 +1779,7 @@ def generate_surface_code_module( patch, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) @@ -1733,7 +1787,8 @@ def _surface_code_module_for_patch( patch: "SurfacePatch", *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -1745,11 +1800,23 @@ def _surface_code_module_for_patch( """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget - interaction_basis = _normalize_surface_interaction_basis(interaction_basis) + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = resolved_plan.interaction_basis geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) - cache_key = (patch.dx, patch.dz, geom.orientation.name, geom.rotated, effective_budget, interaction_basis) + cache_key = ( + patch.dx, + patch.dz, + geom.orientation.name, + geom.rotated, + effective_budget, + interaction_basis, + resolved_plan.plan_id, + ) if cache_key in _state.distance_module_cache: return _state.distance_module_cache[cache_key] @@ -1758,6 +1825,7 @@ def _surface_code_module_for_patch( patch, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, ) # Metadata derived from the actual patch geometry. @@ -1766,6 +1834,9 @@ def _surface_code_module_for_patch( module["num_stab"] = total_ancilla module["ancilla_budget"] = effective_budget module["interaction_basis"] = interaction_basis + module["check_plan"] = resolved_plan.plan_id + module["resolved_check_plan"] = resolved_plan.resolved_metadata + module["resolved_check_plan_hash"] = resolved_plan.resolved_hash _state.distance_module_cache[cache_key] = module return module @@ -1775,14 +1846,17 @@ def get_surface_code_module( d: int, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> dict: """Get a loaded surface code module for distance d. Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Dictionary with module contents and metadata @@ -1795,6 +1869,7 @@ def get_surface_code_module( patch, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) @@ -1804,7 +1879,8 @@ def make_surface_code( basis: str, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> object: """Create a surface code memory experiment. @@ -1817,7 +1893,9 @@ def make_surface_code( a finite budget emits a stabilizer-batched program that matches the abstract circuit's ``batched_stabilizers(patch, effective_budget)`` schedule. - interaction_basis: Surface-memory interaction basis. + interaction_basis: Backward-compatible selector for the default + ``check_plan`` of a two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Compiled Guppy program @@ -1830,6 +1908,7 @@ def make_surface_code( distance, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 79343afd8..3999c731f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1422,7 +1422,8 @@ def _generate_traced_surface_tick_circuit( basis: str, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, runtime: object | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -1445,6 +1446,7 @@ def _generate_traced_surface_tick_circuit( basis, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, runtime=runtime, ) return tc @@ -1456,7 +1458,8 @@ def _generate_traced_surface_tick_circuit_with_result_traces( basis: str, *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, runtime: object | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" @@ -1468,6 +1471,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( basis, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) return trace_guppy_into_tick_circuit_with_result_traces( program, @@ -1475,6 +1479,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( patch=patch, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ), seed=0, runtime=runtime, @@ -1490,7 +1495,8 @@ def _build_surface_tick_circuit_for_native_model( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, szz_physical_prefixes: bool = False, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" @@ -1498,7 +1504,11 @@ def _build_surface_tick_circuit_for_native_model( if twirl is not None: twirl.validate_runtime_supported() - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" raise ValueError(msg) @@ -1530,6 +1540,7 @@ def _build_surface_tick_circuit_for_native_model( basis, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, runtime=runtime, ) @@ -1572,7 +1583,7 @@ def build_memory_circuit( circuit_source: Literal["abstract", "traced_qis"] = "abstract", runtime: object | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1845,7 +1856,7 @@ def _surface_native_topology( *, runtime: object | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, ) -> _CachedNativeSurfaceTopology: @@ -1872,6 +1883,7 @@ def _surface_native_topology( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, ) if circuit_source == "traced_qis": @@ -1942,7 +1954,7 @@ def _cached_surface_native_topology( include_idle_gates: bool, *, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 02ccf46f0..557ff4c1f 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -45,6 +45,43 @@ def test_check_plan_and_interaction_basis_mismatch_fails_loudly() -> None: ) +def test_guppy_surface_code_module_records_resolved_check_plan() -> None: + from pecos.guppy import get_surface_code_module + + module = get_surface_code_module(3, check_plan="szz_current_v1") + + assert module["interaction_basis"] == "szz" + assert module["check_plan"] == "szz_current_v1" + assert module["resolved_check_plan"]["semantic_content"]["interaction_basis"] == "szz" + assert len(module["resolved_check_plan_hash"]) == 64 + + +def test_guppy_surface_code_rejects_plan_basis_mismatch() -> None: + from pecos.guppy import make_surface_code + + with pytest.raises(ValueError, match="conflicts with check_plan"): + make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + +def test_guppy_surface_code_accepts_check_plan_as_source_of_truth() -> None: + from pecos.guppy import make_surface_code + + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + check_plan="szz_current_v1", + ) + + assert program is not None + + def test_surface_code_memory_records_resolved_check_plan() -> None: from pecos.qec.surface import surface_code_memory From 45b612b3f8c29c9b6e180d686fc992b2046255ba Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 18:50:02 -0600 Subject: [PATCH 181/388] Phase 1 step 1: add ObsMask, an inline-optimized observable flip mask (one stack word for <=64 observables, spills to heap beyond) lifting the u64 observable cap --- crates/pecos-decoder-core/Cargo.toml | 1 + crates/pecos-decoder-core/src/lib.rs | 1 + crates/pecos-decoder-core/src/obs_mask.rs | 248 ++++++++++++++++++++++ 3 files changed, 250 insertions(+) create mode 100644 crates/pecos-decoder-core/src/obs_mask.rs diff --git a/crates/pecos-decoder-core/Cargo.toml b/crates/pecos-decoder-core/Cargo.toml index 0b1031d92..e06b6f8a3 100644 --- a/crates/pecos-decoder-core/Cargo.toml +++ b/crates/pecos-decoder-core/Cargo.toml @@ -17,6 +17,7 @@ thiserror.workspace = true anyhow.workspace = true pecos-random.workspace = true rayon.workspace = true +smallvec.workspace = true [lints] workspace = true diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index 1189cd51b..16120a4c6 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -40,6 +40,7 @@ pub mod logical_algorithm; pub mod logical_subgraph; pub mod matrix; pub mod multi_decoder; +pub mod obs_mask; pub mod pauli_frame; pub mod perturbed; pub mod preprocessor; diff --git a/crates/pecos-decoder-core/src/obs_mask.rs b/crates/pecos-decoder-core/src/obs_mask.rs new file mode 100644 index 000000000..7a86d0705 --- /dev/null +++ b/crates/pecos-decoder-core/src/obs_mask.rs @@ -0,0 +1,248 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Observable flip mask supporting arbitrarily many logical observables. +//! +//! Decoders report "which logical observables flipped" as a bitmask (bit `i` = +//! observable `i`). Historically this was a `u64`, capping observables at 64. +//! [`ObsMask`] lifts that cap while keeping the common case fast: +//! +//! - **<= 64 observables -> one inline word, zero heap allocation** (a +//! `SmallVec<[u64; 1]>` keeps the single word on the stack). The per-shot +//! decode hot path is unchanged from the old `u64`. +//! - **> 64 observables -> spills to N words on the heap**, no truncation. +//! +//! Word `w` holds observable bits `64*w ..= 64*w + 63` (little-endian). Trailing +//! zero words are permitted; equality compares the represented bit set, not the +//! storage, so `ObsMask::from_u64(0)`, `ObsMask::new()`, and a two-word `[5, 0]` +//! vs one-word `[5]` all compare as expected. + +use smallvec::{smallvec, SmallVec}; +use std::ops::BitXorAssign; + +const WORD_BITS: usize = u64::BITS as usize; + +/// A logical-observable flip mask of unbounded width. +/// +/// See the module docs. Cheap (`Copy`-like, one inline word) for the common +/// `<= 64` case; spills to the heap only beyond 64 observables. +#[derive(Clone, Debug, Default)] +pub struct ObsMask { + words: SmallVec<[u64; 1]>, +} + +impl ObsMask { + /// An empty (all-zero) mask. No heap allocation. + #[must_use] + pub fn new() -> Self { + Self { + words: SmallVec::new(), + } + } + + /// A mask from a single 64-bit word (observables 0..=63). No heap allocation. + #[must_use] + pub fn from_u64(value: u64) -> Self { + Self { + words: smallvec![value], + } + } + + /// Sets observable bit `bit` to 1, growing the storage if needed. + pub fn set(&mut self, bit: usize) { + let word = bit / WORD_BITS; + if word >= self.words.len() { + self.words.resize(word + 1, 0); + } + self.words[word] |= 1u64 << (bit % WORD_BITS); + } + + /// Returns whether observable bit `bit` is set. + #[must_use] + pub fn get(&self, bit: usize) -> bool { + let word = bit / WORD_BITS; + self.words + .get(word) + .is_some_and(|w| (w >> (bit % WORD_BITS)) & 1 != 0) + } + + /// Returns whether no observable bit is set. + #[must_use] + pub fn is_zero(&self) -> bool { + self.words.iter().all(|&w| w == 0) + } + + /// Number of set observable bits. + #[must_use] + pub fn count_ones(&self) -> u32 { + self.words.iter().map(|w| w.count_ones()).sum() + } + + /// The mask as a single `u64` if it fits in 64 bits, else `None`. + #[must_use] + pub fn to_u64(&self) -> Option { + if self.words.iter().skip(1).all(|&w| w == 0) { + Some(self.words.first().copied().unwrap_or(0)) + } else { + None + } + } + + /// The backing little-endian words (lowest observables first). Trailing zero + /// words may be present. Used to bridge to/from external representations + /// (e.g. a Python arbitrary-precision integer). + #[must_use] + pub fn words(&self) -> &[u64] { + &self.words + } + + /// Builds a mask from little-endian words (lowest observables first). + #[must_use] + pub fn from_words(words: &[u64]) -> Self { + Self { + words: SmallVec::from_slice(words), + } + } + + /// Iterates the indices of the set observable bits, ascending. + pub fn iter_set_bits(&self) -> impl Iterator + '_ { + self.words.iter().enumerate().flat_map(|(w, &word)| { + (0..WORD_BITS) + .filter(move |b| (word >> b) & 1 != 0) + .map(move |b| w * WORD_BITS + b) + }) + } +} + +impl From for ObsMask { + fn from(value: u64) -> Self { + Self::from_u64(value) + } +} + +impl BitXorAssign<&ObsMask> for ObsMask { + fn bitxor_assign(&mut self, rhs: &ObsMask) { + if rhs.words.len() > self.words.len() { + self.words.resize(rhs.words.len(), 0); + } + for (w, &r) in self.words.iter_mut().zip(rhs.words.iter()) { + *w ^= r; + } + } +} + +impl PartialEq for ObsMask { + fn eq(&self, other: &Self) -> bool { + // Value equality: compare every word, treating missing words as zero, so + // representations that differ only by trailing zero words are equal. + let n = self.words.len().max(other.words.len()); + (0..n).all(|i| { + self.words.get(i).copied().unwrap_or(0) == other.words.get(i).copied().unwrap_or(0) + }) + } +} + +impl Eq for ObsMask {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_is_zero() { + let m = ObsMask::new(); + assert!(m.is_zero()); + assert_eq!(m.count_ones(), 0); + assert_eq!(m.to_u64(), Some(0)); + } + + #[test] + fn from_u64_roundtrips() { + let m = ObsMask::from_u64(0b1011); + assert!(m.get(0)); + assert!(m.get(1)); + assert!(!m.get(2)); + assert!(m.get(3)); + assert_eq!(m.to_u64(), Some(0b1011)); + assert_eq!(m.count_ones(), 3); + } + + #[test] + fn set_below_64_stays_inline() { + let mut m = ObsMask::new(); + m.set(0); + m.set(63); + assert!(m.get(0)); + assert!(m.get(63)); + assert_eq!(m.words().len(), 1, "<=64 observables must use one word"); + assert_eq!(m.to_u64(), Some((1u64 << 63) | 1)); + } + + #[test] + fn set_at_and_above_64_spills_wide() { + let mut m = ObsMask::new(); + m.set(5); + m.set(64); + m.set(130); + assert!(m.get(5)); + assert!(m.get(64)); + assert!(m.get(130)); + assert!(!m.get(63)); + assert_eq!(m.words().len(), 3, "bit 130 needs 3 words"); + assert_eq!(m.to_u64(), None, "does not fit in 64 bits"); + assert_eq!(m.count_ones(), 3); + assert_eq!(m.iter_set_bits().collect::>(), vec![5, 64, 130]); + } + + #[test] + fn xor_same_and_mixed_width() { + let mut a = ObsMask::from_u64(0b1100); + a ^= &ObsMask::from_u64(0b1010); + assert_eq!(a.to_u64(), Some(0b0110)); + + // Wide ^ narrow grows the narrow side. + let mut wide = ObsMask::new(); + wide.set(70); + let mut narrow = ObsMask::from_u64(0b1); + narrow ^= &wide; + assert!(narrow.get(0)); + assert!(narrow.get(70)); + assert_eq!(narrow.count_ones(), 2); + + // x ^ x == 0 at any width. + let mut w = ObsMask::new(); + w.set(200); + let snapshot = w.clone(); + w ^= &snapshot; + assert!(w.is_zero()); + } + + #[test] + fn equality_ignores_trailing_zero_words() { + assert_eq!(ObsMask::new(), ObsMask::from_u64(0)); + assert_eq!(ObsMask::from_u64(5), ObsMask::from_words(&[5, 0, 0])); + assert_ne!(ObsMask::from_u64(5), ObsMask::from_words(&[5, 1])); + let mut wide_zero = ObsMask::new(); + wide_zero.set(100); + wide_zero.set(100); // toggle on, still set + assert_ne!(wide_zero, ObsMask::new()); + } + + #[test] + fn from_words_and_words_roundtrip() { + let m = ObsMask::from_words(&[0xff, 0x1]); + assert_eq!(m.words(), &[0xff, 0x1]); + assert!(m.get(0)); + assert!(m.get(64)); + assert_eq!(m.count_ones(), 9); + } +} From e9df730adfba1070336c2772bdd9123f7f33c402 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 18:50:02 -0600 Subject: [PATCH 182/388] Require check plans to match current surface renderers --- .../quantum-pecos/src/pecos/guppy/surface.py | 5 + .../src/pecos/qec/surface/_check_plan.py | 112 ++++++++++++++++++ .../src/pecos/qec/surface/decode.py | 10 +- .../tests/qec/surface/test_check_plan.py | 45 ++++++- 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index b56d4494a..9958cc1f0 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -201,12 +201,17 @@ def generate_guppy_source( ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget + from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer from pecos.qec.surface.circuit_builder import _default_szz_residual_plan resolved_plan = _resolve_surface_check_plan( interaction_basis=interaction_basis, check_plan=check_plan, ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="Guppy surface-code source generation", + ) interaction_basis = resolved_plan.interaction_basis if (twirl is None) != (rng is None): msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index e50cf9e1e..51a011e9f 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -14,6 +14,7 @@ CHECK_PLAN_METADATA_VERSION = 1 CHECK_PLAN_HASH_ALGORITHM = "sha256" CHECK_PLAN_HASH_SERIALIZATION = "canonical-json-v1" +CURRENT_SURFACE_CHECK_PLAN_RENDERER = "pecos.surface.current_renderer_v1" _DEFAULT_CHECK_PLAN_BY_BASIS = { "cx": "cx_standard_v1", @@ -41,6 +42,12 @@ def _normalize_check_plan_id(check_plan: str) -> str: "cx_standard_v1": { "plan_id": "cx_standard_v1", "interaction_basis": "cx", + "synthesis_identity": { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, "schedule": { "round_policy": "constant", "site_policy": "global", @@ -59,6 +66,12 @@ def _normalize_check_plan_id(check_plan: str) -> str: "szz_current_v1": { "plan_id": "szz_current_v1", "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, "schedule": { "round_policy": "constant", "site_policy": "global", @@ -86,6 +99,18 @@ def canonical_check_plan_json(value: dict[str, Any]) -> str: return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) +def surface_check_plan_ids() -> tuple[str, ...]: + """Return known surface check-plan IDs in deterministic order.""" + return tuple(sorted(_PLAN_SEMANTICS)) + + +def default_surface_check_plan_id(interaction_basis: str | None = None) -> str: + """Return the default check-plan ID for a two-qubit interaction basis.""" + if interaction_basis is None: + return _DEFAULT_CHECK_PLAN_BY_BASIS["cx"] + return _DEFAULT_CHECK_PLAN_BY_BASIS[_normalize_interaction_basis_name(interaction_basis)] + + def _semantic_hash(semantic_content: dict[str, Any]) -> str: encoded = canonical_check_plan_json(semantic_content).encode("utf-8") return hashlib.sha256(encoded).hexdigest() @@ -97,6 +122,7 @@ class ResolvedSurfaceCheckPlan: plan_id: str interaction_basis: str + synthesis_identity: dict[str, Any] semantic_content: dict[str, Any] resolved_metadata: dict[str, Any] resolved_hash: str @@ -139,7 +165,93 @@ def resolve_surface_check_plan( return ResolvedSurfaceCheckPlan( plan_id=plan_id, interaction_basis=plan_basis, + synthesis_identity=dict(semantic_content["synthesis_identity"]), semantic_content=semantic_content, resolved_metadata=resolved_metadata, resolved_hash=resolved_hash, ) + + +def require_current_surface_check_plan_renderer( + resolved_plan: ResolvedSurfaceCheckPlan, + *, + context: str, +) -> None: + """Fail if the current source renderers cannot realize ``resolved_plan``. + + Check plans are intended to be source-level circuit contracts, not metadata + labels pasted onto whatever circuit the old ``interaction_basis`` path + happened to emit. Keep this guard close to circuit generation until each new + plan has an explicit renderer. + """ + semantic = resolved_plan.semantic_content + synthesis = resolved_plan.synthesis_identity + schedule = semantic.get("schedule", {}) + + expected_synthesis = { + "cx": { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + "szz": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + }[resolved_plan.interaction_basis] + expected_schedule = { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + } + if synthesis != expected_synthesis or schedule != expected_schedule: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; synthesis_identity={synthesis!r}, " + f"schedule={schedule!r}" + ) + raise NotImplementedError(msg) + + if resolved_plan.interaction_basis == "cx": + expected_checks = { + "prefix_policy": "none", + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + } + else: + expected_checks = { + "prefix_policy": "forward_flow_virtual_z_v1", + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + } + + actual_checks = { + "prefix_policy": semantic.get("prefix_policy"), + "x_check": semantic.get("x_check"), + "z_check": semantic.get("z_check"), + } + if actual_checks != expected_checks: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; check templates={actual_checks!r}" + ) + raise NotImplementedError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 3999c731f..7d91551f1 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -51,7 +51,7 @@ import numpy as np -from pecos.qec.surface._check_plan import resolve_surface_check_plan +from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan if TYPE_CHECKING: import stim @@ -1508,6 +1508,10 @@ def _build_surface_tick_circuit_for_native_model( interaction_basis=interaction_basis, check_plan=check_plan, ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="surface native TickCircuit generation", + ) interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" @@ -1872,6 +1876,10 @@ def _surface_native_topology( ) resolved_plan = resolve_surface_check_plan(interaction_basis=interaction_basis, check_plan=check_plan) + require_current_surface_check_plan_renderer( + resolved_plan, + context="surface native topology construction", + ) interaction_basis = resolved_plan.interaction_basis patch = _cached_surface_patch(patch_key) tc = _build_surface_tick_circuit_for_native_model( diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 557ff4c1f..d10f75831 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -6,17 +6,32 @@ from __future__ import annotations import hashlib +from dataclasses import replace import pytest def test_check_plan_default_resolves_to_cx_metadata() -> None: - from pecos.qec.surface._check_plan import canonical_check_plan_json, resolve_surface_check_plan + from pecos.qec.surface._check_plan import ( + canonical_check_plan_json, + default_surface_check_plan_id, + resolve_surface_check_plan, + surface_check_plan_ids, + ) plan = resolve_surface_check_plan() + assert surface_check_plan_ids() == ("cx_standard_v1", "szz_current_v1") + assert default_surface_check_plan_id() == "cx_standard_v1" + assert default_surface_check_plan_id("SZZ") == "szz_current_v1" assert plan.plan_id == "cx_standard_v1" assert plan.interaction_basis == "cx" + assert plan.synthesis_identity == { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } assert plan.resolved_metadata["metadata_version"] == 1 assert plan.resolved_metadata["hash_algorithm"] == "sha256" assert plan.resolved_metadata["hash_serialization"] == "canonical-json-v1" @@ -33,6 +48,34 @@ def test_check_plan_is_source_of_truth_for_basis() -> None: assert plan.plan_id == "szz_current_v1" assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } + + +def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: + from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_current_v1") + unsupported = dict(plan.semantic_content) + unsupported["synthesis_identity"] = { + **dict(plan.synthesis_identity), + "szz_phase_pattern": "checkerboard", + } + unsupported_plan = replace( + plan, + synthesis_identity=dict(unsupported["synthesis_identity"]), + semantic_content=unsupported, + ) + + with pytest.raises(NotImplementedError, match=r"unit-test.*checkerboard"): + require_current_surface_check_plan_renderer( + unsupported_plan, + context="unit-test", + ) def test_check_plan_and_interaction_basis_mismatch_fails_loudly() -> None: From 51843b198f78fc616a3531eb11d89745a94e21be Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 18:54:15 -0600 Subject: [PATCH 183/388] Phase 1 step 2: add ObservableDecoder::decode_obs (wide ObsMask, default-bridges the u64 fast path) and give LogicalSubgraphDecoder a real wide implementation; its u64 path now narrows and errors on >64 instead of truncating --- crates/pecos-decoder-core/src/lib.rs | 20 ++++++++++++++++++ .../src/logical_subgraph.rs | 21 +++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index 16120a4c6..ea2baf8b5 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -177,6 +177,26 @@ pub trait ObservableDecoder { /// Returns [`DecoderError`] if decoding fails. fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result; + /// Decode a dense syndrome and return predicted observable flips as an + /// [`ObsMask`](crate::obs_mask::ObsMask), which supports more than 64 + /// observables. + /// + /// This is the general entry point used by the transport layer + /// (`SampleBatch`, the Python bindings). The default implementation bridges + /// [`Self::decode_to_observables`] — the 64-observable `u64` fast path that + /// inner decoders implement. Decoders that can produce more than 64 + /// observables (the per-observable aggregators) override this to build a + /// wide mask directly, with no truncation. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if decoding fails. + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64( + self.decode_to_observables(syndrome)?, + )) + } + /// Batch decode: flat buffer of `num_shots × num_detectors` bytes. /// Returns one `u64` observable mask per shot. /// diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index da8fb4c81..12e0c678b 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -57,6 +57,7 @@ use std::collections::{BTreeMap, BTreeSet}; use crate::ObservableDecoder; use crate::dem::{DemMatchingGraph, MatchingEdge, SparseDem}; use crate::errors::DecoderError; +use crate::obs_mask::ObsMask; // ============================================================================ // Stabilizer coordinate mapping @@ -653,8 +654,22 @@ impl LogicalSubgraphDecoder { } impl ObservableDecoder for LogicalSubgraphDecoder { + /// Narrowing wrapper over [`Self::decode_obs`]. Errors (rather than + /// truncating) if the decoder has more than 64 observables; callers that may + /// exceed 64 should use [`ObservableDecoder::decode_obs`]. fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = 0u64; + self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + ) + }) + } + + /// Decode every per-observable subgraph and pack the flips into a wide + /// [`ObsMask`], mapping each subgraph's local result to its GLOBAL observable + /// index. Supports more than 64 observables with no truncation. + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = ObsMask::new(); for (i, (sg, dec)) in self .subgraphs @@ -679,9 +694,7 @@ impl ObservableDecoder for LogicalSubgraphDecoder { let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - // Flip the subgraph's GLOBAL observable bit, not the list - // position: construction guarantees < 64 observables. - obs_mask |= 1u64 << sg.observable_idx; + obs_mask.set(sg.observable_idx); } } From 5692adfd2c4a483ee13c01618f02be9a58e92bc2 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 19:03:44 -0600 Subject: [PATCH 184/388] Accept check plans in direct surface builders --- .../src/pecos/qec/surface/circuit_builder.py | 49 ++++++++++--- .../src/pecos/qec/surface/decode.py | 4 ++ .../tests/qec/surface/test_check_plan.py | 70 +++++++++++++++++++ 3 files changed, 114 insertions(+), 9 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 5e614c979..6f58d17a4 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -35,6 +35,7 @@ from pecos.qec.surface._ancilla_batching import ( normalize_ancilla_budget as _normalize_ancilla_budget, ) +from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan # Stabilizer geometry helpers live in the low-level patch module (single # source of truth). Only the two used by the circuit renderer are imported @@ -753,7 +754,8 @@ def build_surface_code_circuit( ancilla_budget: int | None = None, *, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. @@ -780,6 +782,9 @@ def build_surface_code_circuit( ``"cx"`` preserves the existing CNOT extraction circuit. ``"szz"`` emits the direct-renderer SZZ/SZZdg abstract template with local data-qubit compensation. + check_plan: Named surface check-plan preset. This is the source of + truth when supplied; ``interaction_basis`` must agree if also + supplied. Returns: Tuple of (operations list, qubit allocation info) @@ -792,7 +797,15 @@ def build_surface_code_circuit( num_z_anc = len(geom.z_stabilizers) total_ancilla = num_x_anc + num_z_anc effective_ancilla_budget = _normalize_ancilla_budget(total_ancilla, ancilla_budget) - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="abstract surface-code circuit generation", + ) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) if twirl is not None: twirl.validate_runtime_supported() twirl_site_schedule = None if twirl is None else twirl.site_schedule @@ -2560,7 +2573,8 @@ def generate_stim_from_patch( basis: str = "Z", *, ancilla_budget: int | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, @@ -2575,6 +2589,7 @@ def generate_stim_from_patch( basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. p1: Single-qubit error rate p2: Two-qubit error rate p_meas: Measurement error rate @@ -2584,13 +2599,13 @@ def generate_stim_from_patch( Returns: Stim circuit string """ - interaction_basis = _normalize_interaction_basis(interaction_basis) ops, allocation = build_surface_code_circuit( patch, num_rounds, basis, ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, add_detectors=add_detectors) return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -2601,7 +2616,8 @@ def generate_guppy_from_patch( _num_rounds: int = 1, _basis: str = "Z", *, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> str: """Generate Guppy code from SurfacePatch. @@ -2618,13 +2634,14 @@ def generate_guppy_from_patch( _num_rounds: Unused (factory functions accept this at runtime) _basis: Unused (module includes both Z and X basis functions) interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: Guppy source code string (full module) """ from pecos.guppy.surface import generate_guppy_source - return generate_guppy_source(patch, interaction_basis=interaction_basis) + return generate_guppy_source(patch, interaction_basis=interaction_basis, check_plan=check_plan) def generate_dag_circuit_from_patch( @@ -2633,7 +2650,8 @@ def generate_dag_circuit_from_patch( basis: str = "Z", ancilla_budget: int | None = None, *, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, ) -> DagCircuit: """Generate PECOS DagCircuit from SurfacePatch. @@ -2643,6 +2661,7 @@ def generate_dag_circuit_from_patch( basis: 'Z' or 'X' ancilla_budget: Optional cap on simultaneously live ancillas interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: PECOS DagCircuit instance @@ -2653,6 +2672,7 @@ def generate_dag_circuit_from_patch( basis, ancilla_budget, interaction_basis=interaction_basis, + check_plan=check_plan, ) renderer = DagCircuitRenderer() return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -2667,7 +2687,8 @@ def generate_tick_circuit_from_patch( add_typed_annotations: bool = True, ancilla_budget: int | None = None, twirl: TwirlConfig | None = None, - interaction_basis: str = "cx", + interaction_basis: str | None = None, + check_plan: str | None = None, szz_physical_prefixes: bool = False, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -2697,13 +2718,22 @@ def generate_tick_circuit_from_patch( ``add_typed_annotations`` is false; that flag controls detector and observable typed annotations, not the twirl lookup channel. interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. szz_physical_prefixes: If true, lower the abstract SZZ single-qubit scaffold into physical prefix pulses for native DEM analysis. Returns: PECOS TickCircuit instance """ - interaction_basis = _normalize_interaction_basis(interaction_basis) + resolved_plan = resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) + require_current_surface_check_plan_renderer( + resolved_plan, + context="abstract surface TickCircuit generation", + ) + interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) if szz_physical_prefixes and interaction_basis != "szz": msg = "szz_physical_prefixes=True requires interaction_basis='szz'" raise ValueError(msg) @@ -2714,6 +2744,7 @@ def generate_tick_circuit_from_patch( ancilla_budget, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, ) if szz_physical_prefixes: ops = _lower_szz_forward_flow_ops(ops) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 7d91551f1..92e69bd3e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1525,6 +1525,7 @@ def _build_surface_tick_circuit_for_native_model( add_typed_annotations=False, twirl=twirl, interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, ) @@ -1588,6 +1589,7 @@ def build_memory_circuit( runtime: object | None = None, twirl: TwirlConfig | None = None, interaction_basis: str | None = None, + check_plan: str | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1611,6 +1613,7 @@ def build_memory_circuit( rejected because a runtime trace would bake one sampled mask into the circuit and can lose canonical result-id provenance. interaction_basis: Surface-memory two-qubit interaction basis. + check_plan: Named surface check-plan preset. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1644,6 +1647,7 @@ def build_memory_circuit( runtime=runtime, twirl=twirl, interaction_basis=interaction_basis, + check_plan=check_plan, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index d10f75831..365afde70 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -181,6 +181,76 @@ def test_check_plan_does_not_change_current_szz_dem() -> None: assert by_plan == by_basis +def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import ( + generate_dag_circuit_from_patch, + generate_guppy_from_patch, + generate_stim_from_patch, + generate_tick_circuit_from_patch, + ) + from pecos.qec.surface.decode import build_memory_circuit + + patch = SurfacePatch.create(distance=3) + + stim_text = generate_stim_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + add_detectors=False, + ) + assert "CX" not in stim_text + assert "SQRT_ZZ" in stim_text + + dag_circuit = generate_dag_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + assert "SZZ" in {dag_circuit.gate(node).gate_type.name for node in dag_circuit.nodes()} + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + memory_circuit = build_memory_circuit( + patch=patch, + rounds=1, + check_plan="szz_current_v1", + ) + assert int(memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) + + guppy_source = generate_guppy_from_patch(patch, check_plan="szz_current_v1") + assert "Check plan: szz_current_v1" in guppy_source + + +def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch + from pecos.qec.surface.decode import build_memory_circuit + + patch = SurfacePatch.create(distance=3) + + with pytest.raises(ValueError, match="conflicts with check_plan"): + generate_tick_circuit_from_patch( + patch, + num_rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + with pytest.raises(ValueError, match="conflicts with check_plan"): + build_memory_circuit( + patch=patch, + rounds=1, + interaction_basis="cx", + check_plan="szz_current_v1", + ) + + def test_native_sampler_records_resolved_check_plan() -> None: from pecos.qec.surface import NoiseModel, SurfacePatch, build_native_sampler From 2cffcf54a1db0f27975c0c6913b94d44092b51cd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 19:07:25 -0600 Subject: [PATCH 185/388] Phase 1 step 2c-1: widen the LogicalSubgraphDecoder batch decode-count path to ObsMask (decode_count_batched + SampleBatch.extract_obs_mask_wide), preserving identical <=64 behavior --- .../src/logical_subgraph.rs | 11 ++++---- .../src/fault_tolerance_bindings.rs | 26 +++++++++++++++---- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 12e0c678b..377b2e5ec 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -601,15 +601,16 @@ impl LogicalSubgraphDecoder { pub fn decode_count_batched( &mut self, syndromes: &[Vec], - expected_masks: &[u64], + expected_masks: &[ObsMask], ) -> Result { let num_shots = syndromes.len(); if num_shots == 0 { return Ok(0); } - // Per-shot observable predictions, accumulated across subgraphs. - let mut shot_obs: Vec = vec![0u64; num_shots]; + // Per-shot observable predictions, accumulated across subgraphs. Wide + // (`ObsMask`) so >64 observables are not truncated. + let mut shot_obs: Vec = vec![ObsMask::new(); num_shots]; for (sg, dec) in self.subgraphs.iter().zip(self.decoders.iter_mut()) { let n = sg.detector_map.len(); @@ -635,9 +636,7 @@ impl LogicalSubgraphDecoder { for (shot_idx, &sub_obs) in sub_masks.iter().enumerate() { if sub_obs & 1 != 0 { - // Flip the subgraph's GLOBAL observable bit, not the list - // position: construction guarantees < 64 observables. - shot_obs[shot_idx] |= 1u64 << sg.observable_idx; + shot_obs[shot_idx].set(sg.observable_idx); } } } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index d20cb263e..98daaf5a8 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2614,7 +2614,7 @@ impl PySampleBatch { } } - /// Extract observable mask for one shot. + /// Extract observable mask for one shot (`u64`; observables 0..=63 only). fn extract_obs_mask(&self, shot: usize) -> u64 { let word_idx = shot / 64; let bit_mask = 1u64 << (shot % 64); @@ -2627,6 +2627,20 @@ impl PySampleBatch { mask } + /// Extract the observable mask for one shot as a wide [`ObsMask`], with no + /// 64-observable cap (the columnar storage already supports >64 columns). + fn extract_obs_mask_wide(&self, shot: usize) -> pecos_decoder_core::obs_mask::ObsMask { + let word_idx = shot / 64; + let bit_mask = 1u64 << (shot % 64); + let mut mask = pecos_decoder_core::obs_mask::ObsMask::new(); + for (obs_idx, col) in self.obs_columns.iter().enumerate() { + if col[word_idx] & bit_mask != 0 { + mask.set(obs_idx); + } + } + mask + } + /// Build from columnar data (from generate_samples). fn from_columnar( det_columns: Vec>, @@ -4649,8 +4663,8 @@ impl PyLogicalSubgraphDecoder { s }) .collect(); - let observable_masks: Vec = (0..batch.num_shots) - .map(|i| batch.extract_obs_mask(i)) + let observable_masks: Vec = (0..batch.num_shots) + .map(|i| batch.extract_obs_mask_wide(i)) .collect(); self.inner .decode_count_batched(&detection_events, &observable_masks) @@ -4709,7 +4723,8 @@ impl PyLogicalSubgraphDecoder { s }) .collect(); - let masks: Vec = (0..n).map(|i| batch.extract_obs_mask(i)).collect(); + let masks: Vec = + (0..n).map(|i| batch.extract_obs_mask_wide(i)).collect(); let pool = rayon::ThreadPoolBuilder::new() .num_threads(num_workers.unwrap_or(0)) @@ -4745,7 +4760,8 @@ impl PyLogicalSubgraphDecoder { // Collect chunk syndromes and masks for batch decode let chunk_syns: Vec> = chunk.iter().map(|&i| events[i].clone()).collect(); - let chunk_masks: Vec = chunk.iter().map(|&i| masks[i]).collect(); + let chunk_masks: Vec = + chunk.iter().map(|&i| masks[i].clone()).collect(); dec.decode_count_batched(&chunk_syns, &chunk_masks) }) .try_reduce(|| 0, |a, b| Ok(a + b)) From f4af682201762bda33f412abb9654b2fc7a369fa Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 19:20:48 -0600 Subject: [PATCH 186/388] Phase 1 step 2c-2: support >64 observables on the LogicalSubgraphDecoder decode path -- remove the shared >64 construction reject, make the u64/parallel/windowed paths fail loud (not truncate), and add a 65-observable wide decode+count test --- .../src/logical_subgraph.rs | 84 +++++++++++++------ .../src/logical_subgraph_windowed.rs | 14 +++- 2 files changed, 70 insertions(+), 28 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 377b2e5ec..8e0b90fd1 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -332,20 +332,14 @@ pub fn coordinate_membership_from_dem( /// /// # Errors /// -/// Returns an error if `membership` has more than 64 entries: observable flips -/// are packed into a u64 mask (bit `i` = observable `i`), so any region source -/// feeding this extractor inherits the 64-observable limit. +/// Supports arbitrarily many observables: flips are packed into a wide +/// [`ObsMask`] by [`ObservableDecoder::decode_obs`]. (The `u64` +/// [`ObservableDecoder::decode_to_observables`] convenience errors above 64; the +/// windowed paths still cap at 64 — see their constructors.) pub fn subgraphs_from_membership( sdem: &SparseDem, membership: &[Vec], ) -> Result, DecoderError> { - if membership.len() > 64 { - return Err(DecoderError::InvalidConfiguration(format!( - "LogicalSubgraphDecoder packs observable flips into a u64 mask and \ - supports at most 64 observables, but got membership for {}", - membership.len(), - ))); - } let mut subgraphs = Vec::with_capacity(membership.len()); @@ -767,15 +761,19 @@ impl ParallelLogicalSubgraphDecoder { }) .collect(); - let mut obs_mask = 0u64; + let mut obs_mask = ObsMask::new(); for (sg, result) in self.subgraphs.iter().zip(results) { if result? { - // Flip the subgraph's GLOBAL observable bit, not the list - // position: construction guarantees < 64 observables. - obs_mask |= 1u64 << sg.observable_idx; + obs_mask.set(sg.observable_idx); } } - Ok(obs_mask) + // This convenience returns a u64; error (don't truncate) above 64. + obs_mask.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; the parallel u64 path supports at most 64" + .into(), + ) + }) } } @@ -912,12 +910,9 @@ mod tests { assert_eq!(sgs.len(), 1); assert_eq!(sgs[0].detector_map, vec![0, 1]); - // >64 observable membership is rejected by the extractor regardless of source. + // >64 observable membership is now SUPPORTED (wide ObsMask), not rejected. let big: Vec> = (0..65).map(|_| Vec::new()).collect(); - assert!(matches!( - subgraphs_from_membership(&sdem, &big), - Err(DecoderError::InvalidConfiguration(_)) - )); + assert_eq!(subgraphs_from_membership(&sdem, &big).unwrap().len(), 65); // An out-of-range membership detector id must error, not panic // (the DEM has 2 detectors D0,D1; detector 5 is past `inverse_map`). @@ -1059,18 +1054,55 @@ mod tests { } #[test] - fn test_too_many_observables_errors() { - // 65 observables exceeds the u64 mask capacity. + fn test_more_than_64_observables_decode_wide() { + // 65 observables: construction now SUCCEEDS (no >64 reject), and the wide + // `decode_obs` / `decode_count_batched` paths represent observable 64 + // without truncation. The `u64` convenience method errors instead. use std::fmt::Write; let mut dem = String::from("detector(1, 0, 0) D0\n"); for l in 0..65 { writeln!(dem, "error(0.01) D0 L{l}").unwrap(); } let sc = simple_stab_coords(); - let err = partition_dem_by_logical(&dem, &sc).unwrap_err(); - assert!( - matches!(err, DecoderError::InvalidConfiguration(_)), - "expected InvalidConfiguration, got {err:?}" + + // Every per-observable subgraph flips its observable when D0 fires. + let mut dec = LogicalSubgraphDecoder::from_dem(&dem, &sc, |_| { + Ok(Box::new(FixedDecoder(1)) as Box) + }) + .unwrap(); + + // Wide decode: all 65 observables flip when D0 is set; bit 64 is present. + let wide = dec.decode_obs(&[1]).unwrap(); + assert_eq!(wide.count_ones(), 65); + assert!(wide.get(64), "observable 64 must be representable"); + assert_eq!(wide.to_u64(), None, "65 bits does not fit a u64"); + + // The u64 convenience errors rather than truncating. + assert!(matches!( + dec.decode_to_observables(&[1]), + Err(DecoderError::InvalidConfiguration(_)) + )); + + // Batch decode-count compares wide masks: an all-flip expected mask + // matches the all-flip prediction (zero errors); a narrower expected + // mask (missing observable 64) is counted as an error. + let mut all_flip = ObsMask::new(); + for l in 0..65 { + all_flip.set(l); + } + let mut missing_64 = ObsMask::new(); + for l in 0..64 { + missing_64.set(l); + } + assert_eq!( + dec.decode_count_batched(&[vec![1]], std::slice::from_ref(&all_flip)) + .unwrap(), + 0 + ); + assert_eq!( + dec.decode_count_batched(&[vec![1]], std::slice::from_ref(&missing_64)) + .unwrap(), + 1 ); } diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index 2a41f3b35..8150b6e94 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -95,6 +95,16 @@ impl WindowedLogicalSubgraphDecoder { let mut subgraphs = Vec::with_capacity(plan.num_observables()); let mut max_local = 0usize; for entry in plan.entries() { + // The windowed path packs observable flips into a u64 mask; reject + // >64 here (the non-windowed `LogicalSubgraphDecoder` supports more + // via `decode_obs`, but the windowed core-commit path does not yet). + if entry.observable_idx >= 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "WindowedLogicalSubgraphDecoder packs flips into a u64 mask and supports at \ + most 64 observables, but saw observable index {}", + entry.observable_idx, + ))); + } let decoder = OverlappingWindowedDecoder::from_dem(&entry.sub_dem, window_config, |wdem| { UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) @@ -142,8 +152,8 @@ impl ObservableDecoder for WindowedLogicalSubgraphDecoder { } // The subgraph decodes a single observable as its local bit 0; map // that back to this observable's global bit. `observable_idx < 64` is - // guaranteed upstream (`subgraphs_from_membership` rejects >64 - // observables); assert it locally where the u64 shift consumes it. + // guaranteed by the hard reject in `from_dem`; assert it locally where + // the u64 shift consumes it. debug_assert!( sg.observable_idx < 64, "observable index {} exceeds u64 observable-mask capacity", From 48c2e75fc9bd53ef4102463aaf8af26523ce762c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 20:12:59 -0600 Subject: [PATCH 187/388] Phase 1 step 2c-3: LogicalSubgraphDecoder.decode/decode_batch return arbitrary-precision Python ints (ObsMask->int) so >64 observables are visible from Python with no truncation; add wide-observable Python tests --- .../src/fault_tolerance_bindings.rs | 52 +++++++++++--- .../tests/qec/test_wide_observables.py | 68 +++++++++++++++++++ 2 files changed, 111 insertions(+), 9 deletions(-) create mode 100644 python/quantum-pecos/tests/qec/test_wide_observables.py diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 98daaf5a8..2d48389c3 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -4491,6 +4491,29 @@ impl PyCssUfDecoder { /// ... "`fusion_blossom_serial`", /// ... ) /// >>> obs = decoder.decode(syndrome) +/// Convert a wide observable mask to a Python integer (arbitrary precision). +/// +/// `<= 64` observables become a plain `int` from the single `u64` (identical to +/// the historical return); `> 64` observables become a big `int` built from the +/// mask's little-endian words, with no truncation. +fn obsmask_to_py( + py: Python<'_>, + mask: &pecos_decoder_core::obs_mask::ObsMask, +) -> PyResult> { + if let Some(v) = mask.to_u64() { + return Ok(v.into_pyobject(py)?.into_any().unbind()); + } + let mut bytes = Vec::with_capacity(mask.words().len() * 8); + for &word in mask.words() { + bytes.extend_from_slice(&word.to_le_bytes()); + } + let py_bytes = pyo3::types::PyBytes::new(py, &bytes); + let int_type = py.get_type::(); + Ok(int_type + .call_method1("from_bytes", (py_bytes, "little"))? + .unbind()) +} + #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalSubgraphDecoder { inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, @@ -4605,11 +4628,17 @@ impl PyLogicalSubgraphDecoder { } /// Decode a syndrome and return observable flip predictions. - fn decode(&mut self, syndrome: Vec) -> PyResult { + /// + /// Returns a Python ``int`` (bit ``i`` = observable ``i``). The integer is + /// arbitrary precision, so decoders with more than 64 observables are + /// returned without truncation. + fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { use pecos_decoder_core::ObservableDecoder; - self.inner - .decode_to_observables(&syndrome) - .map_err(|e| PyErr::new::(e.to_string())) + let mask = self + .inner + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + obsmask_to_py(py, &mask) } /// Number of observables this decoder handles. @@ -4632,16 +4661,21 @@ impl PyLogicalSubgraphDecoder { /// syndromes: 2D numpy array of shape (`num_shots`, `num_detectors`). /// /// Returns: - /// List of observable flip masks (one per shot). - fn decode_batch(&mut self, syndromes: Vec>) -> PyResult> { + /// List of observable flip masks (one Python ``int`` per shot; arbitrary + /// precision, so more than 64 observables are not truncated). + fn decode_batch( + &mut self, + py: Python<'_>, + syndromes: Vec>, + ) -> PyResult>> { use pecos_decoder_core::ObservableDecoder; let mut results = Vec::with_capacity(syndromes.len()); for syn in &syndromes { - let obs = self + let mask = self .inner - .decode_to_observables(syn) + .decode_obs(syn) .map_err(|e| PyErr::new::(e.to_string()))?; - results.push(obs); + results.push(obsmask_to_py(py, &mask)?); } Ok(results) } diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py new file mode 100644 index 000000000..5b08d06d7 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -0,0 +1,68 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Wide (>64) observable support for ``LogicalSubgraphDecoder``. + +Observable flips are packed into a mask; historically a ``u64`` capped decoders +at 64 observables. These tests exercise the wide ``ObsMask`` path: construction, +per-shot ``decode``/``decode_batch`` returning arbitrary-precision Python ints, +and ``decode_count`` comparing wide masks end-to-end. +""" + +from __future__ import annotations + +from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem + + +def _wide_dem(n: int) -> tuple[str, list[list[int]]]: + """A DEM with ``n`` observables, each on its own detector ``D_l``/``L_l``.""" + dem = "".join(f"detector({l},0,0) D{l}\n" for l in range(n)) + "".join( + f"error(0.1) D{l} L{l}\n" for l in range(n) + ) + membership = [[l] for l in range(n)] + return dem, membership + + +def test_decode_returns_big_int_above_64_observables() -> None: + n = 65 + dem, membership = _wide_dem(n) + dec = LogicalSubgraphDecoder.from_membership(dem, membership, "pecos_uf:fast") + assert dec.num_observables() == n + + # Flipping detector 64 sets observable bit 64 -- not representable in a u64. + syn = [0] * n + syn[64] = 1 + result = dec.decode(syn) + assert isinstance(result, int) + assert (result >> 64) & 1 == 1 + assert result == 1 << 64 + + # Two flips, one beyond bit 63. + syn2 = [0] * n + syn2[0] = 1 + syn2[64] = 1 + assert dec.decode(syn2) == (1 << 64) | 1 + + +def test_decode_batch_above_64_observables() -> None: + n = 65 + dem, membership = _wide_dem(n) + dec = LogicalSubgraphDecoder.from_membership(dem, membership, "pecos_uf:fast") + + syndromes = [[0] * n, [0] * n] + syndromes[0][64] = 1 + syndromes[1][0] = 1 + results = dec.decode_batch(syndromes) + assert results[0] == 1 << 64 + assert results[1] == 1 + + +def test_decode_count_above_64_observables() -> None: + # The wide compare path (predicted vs truth ObsMask) must run end-to-end on a + # >64-observable batch without erroring or truncating. + n = 65 + dem, membership = _wide_dem(n) + dec = LogicalSubgraphDecoder.from_membership(dem, membership, "pecos_uf:fast") + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(2000, seed=1) + count = dec.decode_count(batch) + assert 0 <= count <= 2000 From e6bb21c8b954cc0459310045d6cf10af12267169 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 20:14:44 -0600 Subject: [PATCH 188/388] Fix engines angle-dependent two-qubit noise to read the signed principal angle and add GeneralNoiseModelBuilder::pauli_with_angle_scaling for cross-stack translation --- crates/pecos-engines/src/noise.rs | 2 +- crates/pecos-engines/src/noise/general.rs | 35 +++- .../src/noise/general/builder.rs | 193 ++++++++++++++++-- 3 files changed, 205 insertions(+), 25 deletions(-) diff --git a/crates/pecos-engines/src/noise.rs b/crates/pecos-engines/src/noise.rs index 5b30e2261..cdb08eada 100644 --- a/crates/pecos-engines/src/noise.rs +++ b/crates/pecos-engines/src/noise.rs @@ -28,7 +28,7 @@ pub use self::biased_depolarizing::{ BiasedDepolarizingNoiseModel, BiasedDepolarizingNoiseModelBuilder, }; pub use self::depolarizing::{DepolarizingNoiseModel, DepolarizingNoiseModelBuilder}; -pub use self::general::{GeneralNoiseModel, GeneralNoiseModelBuilder}; +pub use self::general::{GeneralNoiseModel, GeneralNoiseModelBuilder, PauliWithAngleScaling}; pub use self::noise_rng::NoiseRng; pub use self::pass_through::{PassThroughNoiseModel, PassThroughNoiseModelBuilder}; pub use self::utils::{NoiseUtils, ProbabilityValidator}; diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index d64b4dde2..7976130a4 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -77,7 +77,7 @@ mod builder; mod default; -pub use self::builder::GeneralNoiseModelBuilder; +pub use self::builder::{GeneralNoiseModelBuilder, PauliWithAngleScaling}; use crate::Gate; use crate::byte_message::{ByteMessage, ByteMessageBuilder, GateType}; @@ -505,6 +505,27 @@ impl GeneralNoiseModel { ) } + /// Get the angle-dependent two-qubit error coefficients `(a, b, c, d)`. + /// + /// These parameterize the error rate as a function of the rotation angle + /// (see [`Self::p2_angle_error_rate`]). Exposed so other simulation stacks + /// can read the model's defaults without re-declaring them. + #[must_use] + pub fn p2_angle_params(&self) -> (f64, f64, f64, f64) { + ( + self.p2_angle_a, + self.p2_angle_b, + self.p2_angle_c, + self.p2_angle_d, + ) + } + + /// Get the angle-dependent two-qubit error power exponent. + #[must_use] + pub fn p2_angle_power(&self) -> f64 { + self.p2_angle_power + } + /// Apply noise at the start of `QuantumSystem` processing (typically a collection of gates) /// /// # Panics @@ -608,9 +629,17 @@ impl GeneralNoiseModel { self.apply_sq_faults(&gate, &mut builder); } _ if gate.is_two_qubit() => { - // For angle-dependent error rates (rotation gates like RZZ) + // For angle-dependent error rates (rotation gates like RZZ). + // Use the SIGNED principal value (-pi, pi]: `p2_angle_error_rate` + // is sign-aware (separate coefficients for negative vs positive + // angles), and the gate unitaries themselves read the angle via + // `to_radians_signed`. The unsigned `to_radians` ([0, 2pi)) is + // always non-negative, so it would make the negative-angle + // coefficients dead via the gate path and assign RZZ(-pi/2) the + // noise of a 3pi/2 rotation (the two are equal up to global + // phase, so they must share a noise rate). let p2 = if gate.angle_arity() >= 1 && !gate.angles.is_empty() { - let angle = gate.angles[0].to_radians(); + let angle = gate.angles[0].to_radians_signed(); self.p2_angle_error_rate(angle) } else { self.p2 diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index 9a7b4b294..6d22939a0 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -5,6 +5,14 @@ use crate::noise::{ }; use std::collections::{BTreeMap, BTreeSet}; +/// The plain-Pauli probabilities plus optional angle-dependent two-qubit +/// scaling, as returned by +/// [`GeneralNoiseModelBuilder::pauli_with_angle_scaling`]. +/// +/// Layout: `(p_prep, p_meas_0, p_meas_1, p1, p2, angle)` where `angle` is +/// `Some((a, b, c, d, power))` when angle scaling is configured. +pub type PauliWithAngleScaling = (f64, f64, f64, f64, f64, Option<(f64, f64, f64, f64, f64)>); + /// Builder for creating general noise models #[derive(Debug, Clone)] pub struct GeneralNoiseModelBuilder { @@ -760,6 +768,51 @@ impl GeneralNoiseModelBuilder { /// common configuration without re-deriving probability conventions. #[must_use] pub fn simple_probabilities(&self) -> Option<(f64, f64, f64, f64, f64)> { + if self.is_plain_pauli_except_angle() && self.resolved_angle_scaling().is_none() { + Some(self.resolved_base_probabilities()) + } else { + None + } + } + + /// Like [`Self::simple_probabilities`], but ALSO permits angle-dependent + /// two-qubit scaling and returns it alongside the base probabilities. + /// + /// Returns `(p_prep, p_meas_0, p_meas_1, p1, p2, angle)` where `angle` is + /// `Some((a, b, c, d, power))` when any `p2_angle_*` parameter is + /// configured (the unset components take their model defaults), and `None` + /// otherwise. This lets other simulation stacks translate the common + /// "plain Pauli, plus optional angle-dependent two-qubit gate noise" + /// configuration — the angle-dependent error rate is + /// `p2 * (coeff * |theta/pi|^power + offset)` with separate + /// `(a, b)` for negative and `(c, d)` for positive angles + /// (see [`GeneralNoiseModel::p2_angle_error_rate`]). + /// + /// All the non-angle feature requirements of [`Self::simple_probabilities`] + /// still apply (leakage, emission, idle, crosstalk, scales, custom + /// samplers, and noiseless gates must be off). + #[must_use] + pub fn pauli_with_angle_scaling(&self) -> Option { + if self.is_plain_pauli_except_angle() { + let (p_prep, p_meas_0, p_meas_1, p1, p2) = self.resolved_base_probabilities(); + Some(( + p_prep, + p_meas_0, + p_meas_1, + p1, + p2, + self.resolved_angle_scaling(), + )) + } else { + None + } + } + + /// True when every non-Pauli feature is off EXCEPT possibly the + /// angle-dependent two-qubit scaling. Shared by `simple_probabilities` + /// (which additionally requires the angle scaling to be unset) and + /// `pauli_with_angle_scaling` (which extracts it). + fn is_plain_pauli_except_angle(&self) -> bool { let explicitly_zero = |v: Option| v == Some(0.0); let zero_or_unset = |v: Option| v.is_none() || v == Some(0.0); let one_or_unset = |v: Option| v.is_none() || v == Some(1.0); @@ -779,15 +832,14 @@ impl GeneralNoiseModelBuilder { && zero_or_unset(self.p_meas_crosstalk_local); // Custom samplers/models could change the Pauli distribution; the - // model defaults are uniform, so unset is standard. - let models_off = self.p_idle_linear_model.is_none() + // model defaults are uniform, so unset is standard. (Angle scaling is + // intentionally NOT required here — it is handled separately.) + let custom_models_off = self.p_idle_linear_model.is_none() && self.p1_emission_model.is_none() && self.p1_pauli_model.is_none() && self.p2_emission_model.is_none() && self.p2_pauli_model.is_none() - && self.p_meas_crosstalk_model.is_none() - && self.p2_angle_params.is_none() - && self.p2_angle_power.is_none(); + && self.p_meas_crosstalk_model.is_none(); let scales_neutral = one_or_unset(self.scale) && one_or_unset(self.idle_scale) @@ -800,26 +852,44 @@ impl GeneralNoiseModelBuilder { let gates_default = self.noiseless_gates.as_ref().is_none_or(BTreeSet::is_empty); - if defaulted_features_off + defaulted_features_off && optional_features_off - && models_off + && custom_models_off && scales_neutral && gates_default - { - // Unset probabilities take the model defaults; read them from - // GeneralNoiseModel::default() so they cannot drift. - let (d_prep, d_meas_0, d_meas_1, d_p1, d_p2, _) = - GeneralNoiseModel::default().probabilities(); - Some(( - self.p_prep.unwrap_or(d_prep), - self.p_meas_0.unwrap_or(d_meas_0), - self.p_meas_1.unwrap_or(d_meas_1), - self.p1.unwrap_or(d_p1), - self.p2.unwrap_or(d_p2), - )) - } else { - None + } + + /// Resolve the base Pauli probabilities `(p_prep, p_meas_0, p_meas_1, p1, + /// p2)`, filling unset values from `GeneralNoiseModel::default()` so they + /// cannot drift from the model's own defaults. + fn resolved_base_probabilities(&self) -> (f64, f64, f64, f64, f64) { + let (d_prep, d_meas_0, d_meas_1, d_p1, d_p2, _) = + GeneralNoiseModel::default().probabilities(); + ( + self.p_prep.unwrap_or(d_prep), + self.p_meas_0.unwrap_or(d_meas_0), + self.p_meas_1.unwrap_or(d_meas_1), + self.p1.unwrap_or(d_p1), + self.p2.unwrap_or(d_p2), + ) + } + + /// The configured angle-dependent two-qubit scaling `(a, b, c, d, power)`, + /// or `None` when no `p2_angle_*` parameter is set. Unset components take + /// the model default (read from `GeneralNoiseModel::default()` so they + /// cannot drift). + fn resolved_angle_scaling(&self) -> Option<(f64, f64, f64, f64, f64)> { + if self.p2_angle_params.is_none() && self.p2_angle_power.is_none() { + return None; } + let default = GeneralNoiseModel::default(); + let (a, b, c, d) = self + .p2_angle_params + .unwrap_or_else(|| default.p2_angle_params()); + let power = self + .p2_angle_power + .unwrap_or_else(|| default.p2_angle_power()); + Some((a, b, c, d, power)) } // scaling @@ -963,4 +1033,85 @@ mod tests { GeneralNoiseModel::default().probabilities(); assert_eq!(simple, (d_prep, d_meas_0, d_meas_1, d_p1, d_p2)); } + + /// The angle-aware extractor returns the same base probabilities as + /// `simple_probabilities` with `None` angle when no `p2_angle_*` is set. + #[test] + fn pauli_with_angle_scaling_matches_simple_when_no_angle() { + let builder = GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .with_average_p2_probability(0.4) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + + let simple = builder.simple_probabilities().expect("simple config"); + let (p_prep, p_meas_0, p_meas_1, p1, p2, angle) = builder + .pauli_with_angle_scaling() + .expect("simple config is also pauli-with-angle"); + assert_eq!((p_prep, p_meas_0, p_meas_1, p1, p2), simple); + assert!(angle.is_none()); + } + + /// Setting angle parameters keeps the config out of the strict simple + /// subset but inside the angle-aware subset, with the coefficients and + /// power surfaced verbatim. + #[test] + fn pauli_with_angle_scaling_extracts_configured_angle() { + let builder = GeneralNoiseModelBuilder::new() + .with_p2_probability(0.3) + .with_p2_angle_params(1.5, 0.0, 1.0, 0.0) + .with_p2_angle_power(2.0) + .with_average_p1_probability(0.0) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0); + + // Angle scaling is outside the STRICT simple subset. + assert!(builder.simple_probabilities().is_none()); + + let (_, _, _, _, p2, angle) = builder + .pauli_with_angle_scaling() + .expect("plain Pauli plus angle is in the angle-aware subset"); + assert!((p2 - 0.3).abs() < 1e-12); + assert_eq!(angle, Some((1.5, 0.0, 1.0, 0.0, 2.0))); + } + + /// An unset power takes the model default rather than dropping the angle. + #[test] + fn pauli_with_angle_scaling_fills_unset_power_from_default() { + let builder = GeneralNoiseModelBuilder::new() + .with_p2_probability(0.3) + .with_p2_angle_params(1.5, 0.0, 1.0, 0.0) + .with_average_p1_probability(0.0) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0); + + let (_, _, _, _, _, angle) = builder + .pauli_with_angle_scaling() + .expect("angle-aware subset"); + let default_power = GeneralNoiseModel::default().p2_angle_power(); + assert_eq!(angle, Some((1.5, 0.0, 1.0, 0.0, default_power))); + } + + /// Non-angle features beyond the subset still force `None`, even with an + /// angle configured. + #[test] + fn pauli_with_angle_scaling_rejects_non_angle_features() { + // Default emission ratio (0.5) is left in place -> beyond the subset. + let builder = GeneralNoiseModelBuilder::new() + .with_p2_probability(0.3) + .with_p2_angle_params(1.5, 0.0, 1.0, 0.0); + assert!(builder.pauli_with_angle_scaling().is_none()); + } } From 984bc0461c9fe02e40bf453feceb85c211614ad2 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 20:22:21 -0600 Subject: [PATCH 189/388] Add boundary-first SZZ surface check plan --- .../quantum-pecos/src/pecos/guppy/surface.py | 4 +- .../src/pecos/qec/surface/_check_plan.py | 57 ++++++++++++--- .../src/pecos/qec/surface/circuit_builder.py | 50 ++++++++++++- .../tests/qec/surface/test_check_plan.py | 70 ++++++++++++++++++- .../qec/surface/test_szz_interaction_basis.py | 15 +++- 5 files changed, 181 insertions(+), 15 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 9958cc1f0..d62ea3a54 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -202,7 +202,7 @@ def generate_guppy_source( """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer - from pecos.qec.surface.circuit_builder import _default_szz_residual_plan + from pecos.qec.surface.circuit_builder import _szz_residual_plan_for_check_plan resolved_plan = _resolve_surface_check_plan( interaction_basis=interaction_basis, @@ -360,7 +360,7 @@ def _szz_data_expr(data_q: int) -> str: rounds = compute_cnot_schedule(patch) szz_sign_by_touch: dict[tuple[str, int, int], int] = {} if interaction_basis == "szz": - residual_plan = _default_szz_residual_plan(patch) + residual_plan = _szz_residual_plan_for_check_plan(patch, resolved_plan) szz_sign_by_touch = { (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index 51a011e9f..3876ab7d1 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -91,6 +91,34 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "forward_flow_virtual_z_v1", }, + "szz_boundary_first_v1": { + "plan_id": "szz_boundary_first_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, } @@ -188,20 +216,26 @@ def require_current_surface_check_plan_renderer( synthesis = resolved_plan.synthesis_identity schedule = semantic.get("schedule", {}) - expected_synthesis = { - "cx": { + if resolved_plan.interaction_basis == "cx": + expected_synthesis = { "family": "cx", "szz_phase_pattern": "none", "interaction_order": "pecos-default", "ancilla_schedule": "default", - }, - "szz": { + } + else: + expected_synthesis = { "family": "szz", - "szz_phase_pattern": "standard", + "szz_phase_pattern": synthesis.get("szz_phase_pattern"), "interaction_order": "pecos-default", "ancilla_schedule": "default", - }, - }[resolved_plan.interaction_basis] + } + if synthesis.get("szz_phase_pattern") not in {"standard", "boundary-first"}: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; synthesis_identity={synthesis!r}" + ) + raise NotImplementedError(msg) expected_schedule = { "round_policy": "constant", "site_policy": "global", @@ -228,17 +262,22 @@ def require_current_surface_check_plan_renderer( }, } else: + szz_sign_policy_by_pattern = { + "standard": "default_szz_sign_vector_v1", + "boundary-first": "boundary_first_szz_sign_vector_v1", + } + sign_policy = szz_sign_policy_by_pattern[str(synthesis["szz_phase_pattern"])] expected_checks = { "prefix_policy": "forward_flow_virtual_z_v1", "x_check": { "template": "current_szz_x_check_v1", - "sign_policy": "default_szz_sign_vector_v1", + "sign_policy": sign_policy, "residual_policy": "per_touch_compensated", "measurement_sign_policy": "explicit_template_metadata", }, "z_check": { "template": "current_szz_z_check_v1", - "sign_policy": "default_szz_sign_vector_v1", + "sign_policy": sign_policy, "residual_policy": "per_touch_compensated", "measurement_sign_policy": "explicit_template_metadata", }, diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 6f58d17a4..9524b51b3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -47,6 +47,7 @@ from pecos.quantum import PHYSICAL_DURATION_META_KEY if TYPE_CHECKING: + from pecos.qec.surface._check_plan import ResolvedSurfaceCheckPlan from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import ( LogicalDescriptor, @@ -298,6 +299,34 @@ def _default_szz_sign_vector(patch: SurfacePatch) -> tuple[SzzTouchSign, ...]: return tuple(sorted(signs)) +def _boundary_first_szz_sign_vector(patch: SurfacePatch) -> tuple[SzzTouchSign, ...]: + """Return the SZZ sign vector with boundary daggers on first operands. + + This is the first non-default SZZ/SZZdg source-level check plan. It keeps + the same schedule and compensation model as the default plan but changes + the concrete signed SZZ touch chosen on each weight-2 boundary check. + """ + signs: list[SzzTouchSign] = [] + for stabilizer_type, stabilizer_index, data_qubits, is_boundary in _iter_surface_stabilizer_touches(patch): + if is_boundary and len(data_qubits) != 2: + msg = ( + "SZZ boundary-first expects boundary stabilizers to have weight 2; " + f"{stabilizer_type}{stabilizer_index} has weight {len(data_qubits)}" + ) + raise ValueError(msg) + for touch_index, data_qubit in enumerate(data_qubits): + sign = -1 if is_boundary and touch_index == 0 else 1 + signs.append( + SzzTouchSign( + stabilizer_type=stabilizer_type, + stabilizer_index=stabilizer_index, + data_qubit=data_qubit, + sign=sign, + ), + ) + return tuple(sorted(signs)) + + def _validate_szz_sign_vector( patch: SurfacePatch, signs: tuple[SzzTouchSign, ...], @@ -397,6 +426,25 @@ def _default_szz_residual_plan(patch: SurfacePatch) -> SzzResidualPlan: return _validate_szz_sign_vector(patch, _default_szz_sign_vector(patch)) +def _szz_residual_plan_for_check_plan( + patch: SurfacePatch, + resolved_plan: ResolvedSurfaceCheckPlan, +) -> SzzResidualPlan: + """Return the concrete SZZ residual plan for a resolved check plan.""" + if resolved_plan.interaction_basis != "szz": + msg = f"SZZ residual plans require interaction_basis='szz', got {resolved_plan.interaction_basis!r}" + raise ValueError(msg) + + pattern = str(resolved_plan.synthesis_identity["szz_phase_pattern"]) + if pattern == "standard": + return _default_szz_residual_plan(patch) + if pattern == "boundary-first": + return _validate_szz_sign_vector(patch, _boundary_first_szz_sign_vector(patch)) + + msg = f"unsupported SZZ phase pattern {pattern!r} for check_plan={resolved_plan.plan_id!r}" + raise NotImplementedError(msg) + + def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: """Propagate local Pauli-frame bits through uncompensated SZZ/SZZdg.""" common = x_a ^ x_b @@ -905,7 +953,7 @@ def emit_gate_local_twirl_layer( basis, allocation, cnot_rounds, - _default_szz_residual_plan(patch), + _szz_residual_plan_for_check_plan(patch, resolved_plan), ), allocation, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 365afde70..8b6acba73 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -21,7 +21,7 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: plan = resolve_surface_check_plan() - assert surface_check_plan_ids() == ("cx_standard_v1", "szz_current_v1") + assert surface_check_plan_ids() == ("cx_standard_v1", "szz_boundary_first_v1", "szz_current_v1") assert default_surface_check_plan_id() == "cx_standard_v1" assert default_surface_check_plan_id("SZZ") == "szz_current_v1" assert plan.plan_id == "cx_standard_v1" @@ -56,6 +56,23 @@ def test_check_plan_is_source_of_truth_for_basis() -> None: } +def test_boundary_first_szz_check_plan_resolves_to_concrete_synthesis() -> None: + from pecos.qec.surface._check_plan import resolve_surface_check_plan + + plan = resolve_surface_check_plan(check_plan="szz_boundary_first_v1") + + assert plan.plan_id == "szz_boundary_first_v1" + assert plan.interaction_basis == "szz" + assert plan.synthesis_identity == { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "default", + } + assert plan.semantic_content["x_check"]["sign_policy"] == "boundary_first_szz_sign_vector_v1" + assert plan.semantic_content["z_check"]["sign_policy"] == "boundary_first_szz_sign_vector_v1" + + def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan @@ -227,6 +244,57 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert "Check plan: szz_current_v1" in guppy_source +def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.circuit_builder import ( + OpType, + build_surface_code_circuit, + generate_guppy_from_patch, + generate_tick_circuit_from_patch, + ) + + patch = SurfacePatch.create(distance=3) + + current_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + boundary_first_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + check_plan="szz_boundary_first_v1", + ) + + def szz_gate_signature(ops: object) -> list[tuple[str, tuple[int, ...], str]]: + return [ + (op.op_type.name, tuple(op.qubits), op.label) + for op in ops + if op.op_type in {OpType.SZZ, OpType.SZZDG} + ] + + assert szz_gate_signature(boundary_first_ops) != szz_gate_signature(current_ops) + assert sum(op.op_type == OpType.SZZDG for op in boundary_first_ops) == sum( + op.op_type == OpType.SZZDG for op in current_ops + ) + + current_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_current_v1", + ) + boundary_first_tick = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + check_plan="szz_boundary_first_v1", + ) + assert boundary_first_tick.get_meta("detectors") == current_tick.get_meta("detectors") + assert boundary_first_tick.get_meta("observables") == current_tick.get_meta("observables") + + source = generate_guppy_from_patch(patch, check_plan="szz_boundary_first_v1") + assert "Check plan: szz_boundary_first_v1" in source + + def test_direct_surface_renderers_reject_plan_basis_mismatch() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import generate_tick_circuit_from_patch diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 58f03e1ed..2e5a1eda3 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -286,8 +286,19 @@ def test_szz_builder_emits_szz_template_and_rejects_stage_later_features() -> No ) assert data_compensation_count == szz_gate_count - with pytest.raises(ValueError, match="does not yet support constrained ancilla budgets"): - build_surface_code_circuit(patch, num_rounds=1, ancilla_budget=1, interaction_basis="szz") + constrained_szz_ops, _ = build_surface_code_circuit( + patch, + num_rounds=1, + ancilla_budget=1, + interaction_basis="szz", + ) + constrained_szz_gate_count = sum(op.op_type in {OpType.SZZ, OpType.SZZDG} for op in constrained_szz_ops) + constrained_compensation_count = sum( + op.label.startswith("szz_touch_comp:") and op.op_type in {OpType.SX, OpType.SXDG, OpType.SZ, OpType.SZDG} + for op in constrained_szz_ops + ) + assert constrained_szz_gate_count == szz_gate_count + assert constrained_compensation_count == constrained_szz_gate_count with pytest.raises(ValueError, match="twirl integration is staged later"): build_surface_code_circuit( From 202f08aa278bb05672a30fdc5b9c37ea7f794035 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 15 Jun 2026 20:22:59 -0600 Subject: [PATCH 190/388] Translate engines GeneralNoiseModel angle-dependent two-qubit noise to the neo stack in sim().stack(Neo) --- crates/pecos/src/unified_sim.rs | 50 +++++--- .../tests/neo_equivalence_matrix_test.rs | 114 +++++++++++++++++- exp/pecos-neo/src/noise.rs | 2 +- 3 files changed, 150 insertions(+), 16 deletions(-) diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index fd0da6d5f..4bb23d79f 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -297,7 +297,7 @@ fn map_noise_to_neo( ) -> Result, PecosError> { use pecos_engines::noise::{DepolarizingNoiseModelBuilder, PassThroughNoiseModelBuilder}; use pecos_engines::{DepolarizingNoise, PassThroughNoise}; - use pecos_neo::noise::GeneralNoiseModelBuilder; + use pecos_neo::noise::{AngleScaling, GeneralNoiseModelBuilder}; let uniform = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { GeneralNoiseModelBuilder::new() @@ -328,24 +328,46 @@ fn map_noise_to_neo( if let Some(builder) = noise.downcast_ref::() { // The stored p1/p2 are already in standard depolarizing convention // (the with_average_* setters convert on the way in), so they map - // one-to-one onto neo's builder. - let Some((p_prep, p_meas_0, p_meas_1, p1, p2)) = builder.simple_probabilities() else { + // one-to-one onto neo's builder. Angle-dependent two-qubit scaling, if + // present, is translated below; everything else outside the simple + // Pauli subset is still rejected. + let Some((p_prep, p_meas_0, p_meas_1, p1, p2, angle)) = builder.pauli_with_angle_scaling() + else { return Err(PecosError::Input( "This GeneralNoiseModel configuration uses features beyond the simple \ - probability subset (leakage, emission, seepage, idle, crosstalk, \ - angle-dependent noise, scales, or noiseless gates), which are not yet \ - mapped to the neo stack. Use .stack(SimStack::Engines) or configure \ - sim_neo() directly with a neo noise model." + probability subset (leakage, emission, seepage, idle, crosstalk, scales, \ + or noiseless gates), which are not yet mapped to the neo stack. Use \ + .stack(SimStack::Engines) or configure sim_neo() directly with a neo \ + noise model." .to_string(), )); }; - return Ok(Some( - GeneralNoiseModelBuilder::new() - .with_p_prep(p_prep) - .with_p_meas(p_meas_0, p_meas_1) - .with_p1(p1) - .with_p2(p2), - )); + let mut neo_builder = GeneralNoiseModelBuilder::new() + .with_p_prep(p_prep) + .with_p_meas(p_meas_0, p_meas_1) + .with_p1(p1) + .with_p2(p2); + if let Some((a, b, c, d, power)) = angle { + // Engines' angle-dependent two-qubit error rate is + // p2 * (a*|theta/pi|^power + b) for theta < 0 + // p2 * (c*|theta/pi|^power + d) for theta > 0 + // (GeneralNoiseModel::p2_angle_error_rate). neo's AngleScaling + // evaluates offset + linear*|theta/pi| + scale*|theta/pi|^power per + // sign, so the engines coefficients map to scale (the power term) + // and the engines offsets map to offset, with the linear terms + // zero. This reproduces engines exactly, including the zero-angle + // (b+d)/2 average. (NOT AngleScaling::from_general_params, which is + // a different symmetric offset/linear/scale parameterization.) + // + // Both stacks read the gate angle as the SIGNED principal value + // (-pi, pi] -- neo via `to_radians_signed`, engines likewise after + // its noise call site was aligned with its gate unitaries -- so the + // sign-dependent coefficients agree cross-stack at every angle + // (locked by `gnm_angle_scaling_negative_matches`). + neo_builder = neo_builder + .with_p2_angle_scaling(AngleScaling::asymmetric(b, 0.0, a, d, 0.0, c, power)); + } + return Ok(Some(neo_builder)); } Err(PecosError::Input( diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index 7d03aac11..8f495c4f0 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -86,6 +86,32 @@ const FEEDBACK: &str = r#" measure q[1] -> c[1]; "#; +/// RZZ at +pi/2 on |00> (a ZZ=+1 eigenstate): the gate leaves the state in +/// |00> (up to phase), so the only outcome change is from the angle-scaled +/// two-qubit depolarizing noise on the RZZ. The 8 of 15 non-identity Paulis +/// that anticommute with Z(x)Z flip the parity, giving `P(01 or 10) = 8*p_eff/15`. +const RZZ_POS: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + rzz(0.5*pi) q[0],q[1]; + measure q[0] -> c[0]; + measure q[1] -> c[1]; +"#; + +/// Same as `RZZ_POS` but at -pi/2, exercising the NEGATIVE-angle branch of the +/// asymmetric scaling (engines `(a, b)` / neo `neg_*`). +const RZZ_NEG: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + rzz(-0.5*pi) q[0],q[1]; + measure q[0] -> c[0]; + measure q[1] -> c[1]; +"#; + /// One noise configuration of the matrix, applied identically to both /// stacks (the facade maps it to neo's noise channels). enum NoiseCell { @@ -94,7 +120,19 @@ enum NoiseCell { P1(f64), P2(f64), Uniform(f64), - GnmSimple { average_p1: f64, p_meas: f64 }, + GnmSimple { + average_p1: f64, + p_meas: f64, + }, + /// `GeneralNoiseModel` two-qubit noise with angle-dependent scaling: stored + /// `p2`, asymmetric coefficients `(a, b, c, d)` and `power`. Everything + /// outside the two-qubit channel is zeroed so the physics is exactly the + /// angle-scaled depolarizing channel on the RZZ gate. + GnmAngle { + p2: f64, + angle_params: (f64, f64, f64, f64), + angle_power: f64, + }, } impl NoiseCell { @@ -143,6 +181,29 @@ impl NoiseCell { .with_meas_1_probability(p_meas), ) .run(SHOTS), + Self::GnmAngle { + p2, + angle_params: (a, b, c, d), + angle_power, + } => builder + .noise( + // Plain Pauli two-qubit noise with angle scaling; zero + // every other channel and the non-neutral GNM defaults so + // only the angle-scaled RZZ depolarizing noise remains. + pecos_engines::noise::GeneralNoiseModel::builder() + .with_p2_probability(p2) + .with_p2_angle_params(a, b, c, d) + .with_p2_angle_power(angle_power) + .with_average_p1_probability(0.0) + .with_p1_emission_ratio(0.0) + .with_p2_emission_ratio(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0), + ) + .run(SHOTS), }; results.expect("simulation run") } @@ -325,3 +386,54 @@ fn meas_twice_gnm_is_record_flip_not_state_flip() { Some(p), ); } + +/// One `GnmAngle` configuration shared by both angle cells: stored p2 = 0.3, +/// asymmetric coefficients with the NEGATIVE branch (a = 1.5) steeper than the +/// POSITIVE branch (c = 1.0), linear power. The facade translates engines' +/// `(a, b, c, d, power)` into neo's asymmetric `AngleScaling`; both cells pin +/// the cross-stack rate AND the analytic value, so a dropped or neg/pos-swapped +/// mapping fails loudly. +const ANGLE_CELL: NoiseCell = NoiseCell::GnmAngle { + p2: 0.3, + angle_params: (1.5, 0.0, 1.0, 0.0), + angle_power: 1.0, +}; + +#[test] +fn gnm_angle_scaling_positive_matches() { + // RZZ(+pi/2): the POSITIVE branch scales p2 by c*|theta/pi|^power + d = + // 1.0*0.5 + 0 = 0.5, so the effective p2 is 0.3*0.5 = 0.15 and the 8/15 + // parity-flipping Paulis give P(01 or 10) = 8*0.15/15 = 0.08. Dropping the + // angle scaling (the pre-mapping behavior) would give the unscaled + // 8*0.3/15 = 0.16, and the neg/pos-swapped mapping would give 0.12 — both + // far outside the band, so this discriminates. + check_cell( + "gnm_angle_pos", + RZZ_POS, + &ANGLE_CELL, + &["01", "10"], + Some(8.0 * (0.3 * 0.5) / 15.0), + ); +} + +#[test] +fn gnm_angle_scaling_negative_matches() { + // RZZ(-pi/2): the NEGATIVE branch scales p2 by a*|theta/pi|^power + b = + // 1.5*0.5 + 0 = 0.75, so the effective p2 is 0.3*0.75 = 0.225 and the rate + // is 8*0.225/15 = 0.12. Both stacks read the gate angle as the SIGNED + // principal value (-pi, pi] -- neo always did; engines' noise call site was + // aligned with its own gate unitaries (which all use `to_radians_signed`), + // fixing a bug where the unsigned [0, 2pi) angle made RZZ(-pi/2) take the + // POSITIVE branch as 3pi/2 and never reached the negative coefficients. So + // the stacks AGREE here, and the value differs from the positive cell + // (0.08), locking the asymmetry direction. A regression to the unsigned + // angle would push engines to 8*0.45/15 = 0.24 and fail the cross-stack + // overlap and the analytic containment. + check_cell( + "gnm_angle_neg", + RZZ_NEG, + &ANGLE_CELL, + &["01", "10"], + Some(8.0 * (0.3 * 0.75) / 15.0), + ); +} diff --git a/exp/pecos-neo/src/noise.rs b/exp/pecos-neo/src/noise.rs index d59838004..4bd490a01 100644 --- a/exp/pecos-neo/src/noise.rs +++ b/exp/pecos-neo/src/noise.rs @@ -137,7 +137,7 @@ pub use per_gate_pauli::PerGatePauliChannel; pub use plugin::{ContextObserver, EventHandler, NoiseModelConfig, NoisePlugin}; pub use preparation::PreparationChannel; pub use single_qubit::SingleQubitChannel; -pub use two_qubit::TwoQubitChannel; +pub use two_qubit::{AngleScaling, TwoQubitChannel}; use crate::command::GateCommand; use crate::command::GateType; From dba699fd0e39965e95629414f35fbcae15aeb432 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 15 Jun 2026 22:45:49 -0600 Subject: [PATCH 191/388] Use trace-friendly backend for Guppy operation capture --- python/quantum-pecos/src/pecos/qec/surface/decode.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 92e69bd3e..de5ae6ba7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1341,11 +1341,16 @@ def capture_guppy_operation_trace( ) -> list[dict[str, Any]]: """Capture a Guppy/QIS program's Selene operation trace chunks.""" import pecos + import pecos_rslib + # Trace capture records the runtime-lowered QIS operations and result tags; + # DEM validation/fault propagation happens after replay. Use a permissive + # trace backend instead of asking stabilizer evolution to validate every + # runtime-emitted rotation while we are only collecting provenance. sim_builder = ( pecos.sim(program) .classical(pecos.selene_engine(runtime)) - .quantum(pecos.stabilizer()) + .quantum(pecos_rslib.coin_toss()) .qubits(num_qubits) .seed(seed) ) From 8b2367f057581e7226f6b37ebfab26a272475bba Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 16 Jun 2026 08:23:13 -0600 Subject: [PATCH 192/388] Add V6 cross-stack example-circuit sweep covering 3-qubit, whole-register-measure, and deterministic facade circuits --- .../pecos/tests/neo_v6_example_sweep_test.rs | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 crates/pecos/tests/neo_v6_example_sweep_test.rs diff --git a/crates/pecos/tests/neo_v6_example_sweep_test.rs b/crates/pecos/tests/neo_v6_example_sweep_test.rs new file mode 100644 index 000000000..74806c167 --- /dev/null +++ b/crates/pecos/tests/neo_v6_example_sweep_test.rs @@ -0,0 +1,210 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Validation-gate item V6: the neo-routable EXAMPLE sweep. +//! +//! The repository's `sim()`-facade examples (`crates/pecos/examples/*.rs`) +//! exercise a handful of distinct circuits. Most pin an explicit quantum +//! backend (`.quantum(...)`), which the neo stack deliberately rejects, but the +//! CIRCUITS themselves are neo-routable. This sweep runs each example-derived +//! circuit through `sim().stack(...)` on BOTH stacks (auto-backend) and checks +//! that they agree, covering circuit shapes the curated V1 matrix does not: +//! a 3-qubit entangling circuit, whole-register `measure q -> c` syntax, and a +//! purely deterministic program. +//! +//! Circuits that only run via `sim_neo` directly, or that need QIS/PHIR/custom +//! backends, are NOT part of this sweep (they are not facade-routable); the +//! curated cross-stack physics lives in `neo_equivalence_matrix_test.rs`. + +#![cfg(feature = "neo")] + +use std::collections::BTreeSet; + +use pecos::{SimStack, sim}; +use pecos_engines::shot_results::ShotVec; +use pecos_num::jeffreys_interval; +use pecos_programs::Qasm; + +const SHOTS: usize = 10_000; +/// ~4.4 sigma per side: a real stack disagreement, not sampling noise, fails. +const CONFIDENCE: f64 = 0.99999; +const SEED: u64 = 42; + +// --- Example-derived circuits (verbatim from crates/pecos/examples/*.rs) --- + +/// Bell pair with WHOLE-REGISTER measurement (`measure q -> c`), the form used +/// by every facade example. Noiseless support is exactly {00, 11}. +const BELL_WHOLE_REG: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[2]; + h q[0]; + cx q[0], q[1]; + measure q -> c; +"#; + +/// The 3-qubit circuit from `unified_sim_demo.rs`: q2 = q0 XOR q1, so every +/// noiseless shot has even parity (q0^q1^q2 = 0) over outcomes {000,011,101,110}. +const GHZ3: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[3]; + creg c[3]; + h q[0]; + h q[1]; + cx q[0], q[2]; + cx q[1], q[2]; + measure q -> c; +"#; + +/// Deterministic program (`unified_sim_demo.rs`): always reads "1". +const X_DETERMINISTIC: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + x q[0]; + measure q -> c; +"#; + +/// Single Hadamard with per-bit measurement (`sim_api_examples.rs`): {0, 1}. +const SINGLE_H: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + h q[0]; + measure q[0] -> c[0]; +"#; + +fn run(program: &str, stack: SimStack, seed: u64, noise: Option) -> ShotVec { + let builder = sim(Qasm::from_string(program)).stack(stack).seed(seed); + let results = match noise { + Some(p) => builder + .noise(pecos_engines::DepolarizingNoise { p }) + .run(SHOTS), + None => builder.run(SHOTS), + }; + results.expect("simulation run") +} + +fn bitstrings(v: &ShotVec) -> Vec { + v.shots + .iter() + .map(|s| s.data["c"].to_bitstring().expect("c register bits")) + .collect() +} + +fn support(v: &ShotVec) -> BTreeSet { + bitstrings(v).into_iter().collect() +} + +/// Fraction of shots whose `c` register satisfies `pred`, with its count. +#[allow(clippy::cast_precision_loss)] +fn rate_where(v: &ShotVec, pred: impl Fn(&str) -> bool) -> (u64, f64) { + let count = bitstrings(v).iter().filter(|b| pred(b)).count() as u64; + (count, count as f64 / SHOTS as f64) +} + +/// Assert the two stacks' rates for `pred` are statistically compatible +/// (independent seeds, Jeffreys overlap) and print the sweep row. +fn assert_cross_stack_rate( + name: &str, + program: &str, + noise: Option, + pred: impl Fn(&str) -> bool, +) { + // Independent seeds: agreement must come from matching conventions, not a + // shared RNG stream. + let engines = run(program, SimStack::Engines, SEED, noise); + let neo = run(program, SimStack::Neo, SEED ^ 0xA5A5, noise); + let (e_count, _) = rate_where(&engines, &pred); + let (n_count, _) = rate_where(&neo, &pred); + let e_ci = jeffreys_interval(e_count, SHOTS as u64, CONFIDENCE); + let n_ci = jeffreys_interval(n_count, SHOTS as u64, CONFIDENCE); + println!( + "V6 {name}: engines {e_count}/{SHOTS} CI [{:.4}, {:.4}], neo {n_count}/{SHOTS} CI [{:.4}, {:.4}]", + e_ci.0, e_ci.1, n_ci.0, n_ci.1 + ); + assert!( + e_ci.0 <= n_ci.1 && n_ci.0 <= e_ci.1, + "V6 {name}: stack rates are statistically incompatible: \ + engines {e_count}/{SHOTS} vs neo {n_count}/{SHOTS}" + ); +} + +#[test] +fn v6_deterministic_example_is_bit_identical_cross_stack() { + // A noiseless deterministic program must produce IDENTICAL ShotVecs on both + // stacks (same seed, no randomness) and read "1" every shot. + let engines = run(X_DETERMINISTIC, SimStack::Engines, SEED, None); + let neo = run(X_DETERMINISTIC, SimStack::Neo, SEED, None); + assert_eq!( + engines, neo, + "deterministic example must be bit-identical across stacks" + ); + assert_eq!(support(&neo), BTreeSet::from(["1".to_string()])); +} + +#[test] +fn v6_bell_whole_register_measure_matches() { + // Noiseless Bell with whole-register `measure q -> c`: both stacks must + // produce exactly the correlated support {00, 11} and a compatible P(00). + for stack in [SimStack::Engines, SimStack::Neo] { + let v = run(BELL_WHOLE_REG, stack, SEED, None); + assert_eq!( + support(&v), + BTreeSet::from(["00".to_string(), "11".to_string()]), + "Bell ({stack:?}) must only produce the correlated outcomes 00/11" + ); + } + assert_cross_stack_rate("bell_p00", BELL_WHOLE_REG, None, |b| b == "00"); +} + +#[test] +fn v6_three_qubit_example_preserves_parity() { + // The 3-qubit example sets q2 = q0 XOR q1, so every noiseless shot has even + // parity. Both stacks must honor that (covers a circuit shape and a + // whole-register measurement wider than the V1 matrix). + let even_parity = |b: &str| b.bytes().filter(|&c| c == b'1').count() % 2 == 0; + for stack in [SimStack::Engines, SimStack::Neo] { + let v = run(GHZ3, stack, SEED, None); + assert!( + bitstrings(&v).iter().all(|b| even_parity(b)), + "3-qubit example ({stack:?}) must yield only even-parity outcomes" + ); + assert!( + support(&v).len() >= 3, + "3-qubit example ({stack:?}) should explore its 4-outcome support, got {:?}", + support(&v) + ); + } + // The even-parity fraction is 1.0 noiselessly on both stacks; under + // depolarizing noise it drops below 1 on BOTH stacks at a compatible rate. + assert_cross_stack_rate("ghz3_even_parity_noisy", GHZ3, Some(0.02), even_parity); +} + +#[test] +fn v6_single_hadamard_example_matches() { + // Single Hadamard, per-bit measurement: support {0, 1} on both stacks and a + // compatible P(0) ~ 0.5. + for stack in [SimStack::Engines, SimStack::Neo] { + let v = run(SINGLE_H, stack, SEED, None); + assert_eq!( + support(&v), + BTreeSet::from(["0".to_string(), "1".to_string()]), + "single-H ({stack:?}) must produce both 0 and 1" + ); + } + assert_cross_stack_rate("single_h_p0", SINGLE_H, None, |b| b == "0"); +} From 7934d1ad9be55d53d5b28956ad3b4982906a5752 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 16 Jun 2026 09:09:14 -0600 Subject: [PATCH 193/388] Test trace-friendly Guppy operation capture --- .../tests/qec/test_from_guppy_dem.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 17d1ffc59..93af19a64 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -28,7 +28,9 @@ _replay_qis_trace_into_tick_circuit, _surface_runtime_measurement_remap_from_result_traces, _validate_result_tag_remap_against_traced_measurements, + capture_guppy_operation_trace, generate_circuit_level_dem_from_builder, + named_result_traces_from_operation_trace, trace_guppy_into_tick_circuit_with_result_traces, ) @@ -53,6 +55,20 @@ def _measurement_feedback() -> None: result("b1", b1) +def test_operation_trace_capture_uses_trace_friendly_quantum_backend(monkeypatch: pytest.MonkeyPatch) -> None: + import pecos + + def forbidden_stabilizer(): + raise AssertionError("trace capture should not validate operations with stabilizer evolution") + + monkeypatch.setattr(pecos, "stabilizer", forbidden_stabilizer) + + chunks = capture_guppy_operation_trace(_single_measurement, num_qubits=1, seed=0) + result_names = [trace.get("name") for trace in named_result_traces_from_operation_trace(chunks)] + + assert "m" in result_names + + def _dem_text(*, detectors_json: str = "[]", observables_json: str = "[]") -> str: dem = DetectorErrorModel.from_guppy( _single_measurement, From 5e062ccfb10ba94fcd138390da4908dc4c7482f4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 16 Jun 2026 13:57:06 -0600 Subject: [PATCH 194/388] Reject HUGR on the neo stack until its result contract matches the engines named-register format --- crates/pecos/src/unified_sim.rs | 26 +++++++++++++++++++++++--- crates/pecos/tests/neo_routing_test.rs | 21 +++++++++++++++++++++ python/pecos-rslib/src/sim.rs | 2 +- 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 4bb23d79f..1d19dd8f9 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -34,7 +34,8 @@ pub enum SimStack { /// The data-oriented `pecos-neo` stack (experimental). /// /// Requires building pecos with the `neo` cargo feature. Currently - /// routes QASM and HUGR programs with the default quantum backend. + /// routes QASM programs with the default quantum backend (HUGR runs but + /// does not yet match the engines result contract, so it is rejected). /// Depolarizing-family noise (`PassThroughNoise`, `DepolarizingNoise`, /// `DepolarizingNoiseModel`) is translated with identical conventions; /// other noise types, explicit `.classical()`, and explicit @@ -219,10 +220,29 @@ impl ProgrammedSimBuilder { )); } match &self.program { - Program::Qasm(_) | Program::Hugr(_) => {} + Program::Qasm(_) => {} + Program::Hugr(_) => { + // HUGR runs on neo via pecos_hugr::hugr_engine, whose result + // contract differs from the engines/QASM path: for HUGR programs + // without explicit result() captures it emits per-qubit `q0`/`q1` + // values and a `measurements` array instead of the program's + // named classical register (e.g. "c"). Returning that silently + // would break consumers that index `shot.data["c"]`, so reject + // until the neo HUGR result contract is reconciled (the physics + // is already correct; only the register naming diverges). + return Err(PecosError::Input( + "HUGR programs are not yet routed to the neo stack: the neo HUGR \ + engine emits a different result contract (per-qubit q0/q1 plus a \ + `measurements` array) than the engines and QASM paths (the \ + program's named classical register, e.g. \"c\") for programs \ + without explicit result() captures, so neo HUGR results are not \ + yet drop-in compatible. Use .stack(SimStack::Engines) for HUGR." + .to_string(), + )); + } _ => { return Err(PecosError::Input( - "Only QASM and HUGR programs are routed to the neo stack so far; \ + "Only QASM programs are routed to the neo stack so far; \ use .stack(SimStack::Engines) for other program types." .to_string(), )); diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index bfe1bf8f0..f0dc9b888 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -247,6 +247,27 @@ fn neo_stack_rejects_unrouted_quantum_backend() { assert!(err.to_string().contains("not yet routed to the neo stack")); } +#[test] +fn neo_stack_rejects_hugr_until_result_contract_reconciled() { + // HUGR runs on neo with correct physics, but the neo HUGR engine emits a + // different result contract (per-qubit q0/q1 plus a `measurements` array) + // than the engines/QASM path (the program's named classical register) for + // programs without explicit result() captures. Rather than silently return + // a non-drop-in ShotVec, the neo route rejects HUGR until that contract is + // reconciled (see `run_neo` in unified_sim.rs). + let hugr = + pecos_programs::Hugr::from_bytes(include_bytes!("test_data/hugr/bell_state.hugr").to_vec()); + let err = sim(hugr) + .stack(SimStack::Neo) + .run(5) + .expect_err("HUGR is not yet contract-compatible on the neo stack"); + assert!( + err.to_string() + .contains("HUGR programs are not yet routed to the neo stack"), + "unexpected error: {err}" + ); +} + #[test] fn neo_stack_rejects_build() { let Err(err) = sim(deterministic_conditional_qasm()) diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 53bdee2a3..1e37adfbb 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -412,7 +412,7 @@ impl PySimBuilder { | SimBuilderInner::Phir(_) => { if parsed == PySimStack::Neo { return Err(PyValueError::new_err( - "Only QASM and HUGR programs are routed to the neo stack so far; \ + "Only QASM programs are routed to the neo stack so far; \ this program type runs on the engines stack", )); } From 9fd37148e649922ef0e6f975eb15da9ae19330e0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 16 Jun 2026 13:57:30 -0600 Subject: [PATCH 195/388] Map BiasedDepolarizingNoise to the neo stack with record-flip asymmetric measurement --- crates/pecos/src/unified_sim.rs | 35 ++++++++- .../tests/neo_equivalence_matrix_test.rs | 76 +++++++++++++++++++ crates/pecos/tests/neo_routing_test.rs | 29 +++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 1d19dd8f9..09f5dc8ad 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -315,8 +315,11 @@ impl ProgrammedSimBuilder { fn map_noise_to_neo( noise: &(dyn std::any::Any + Send), ) -> Result, PecosError> { - use pecos_engines::noise::{DepolarizingNoiseModelBuilder, PassThroughNoiseModelBuilder}; - use pecos_engines::{DepolarizingNoise, PassThroughNoise}; + use pecos_engines::noise::{ + BiasedDepolarizingNoiseModelBuilder, DepolarizingNoiseModelBuilder, + PassThroughNoiseModelBuilder, + }; + use pecos_engines::{BiasedDepolarizingNoise, DepolarizingNoise, PassThroughNoise}; use pecos_neo::noise::{AngleScaling, GeneralNoiseModelBuilder}; let uniform = |p_prep: f64, p_meas: f64, p1: f64, p2: f64| { @@ -327,6 +330,21 @@ fn map_noise_to_neo( .with_p2(p2) }; + // The biased-depolarizing family applies its measurement bias to the + // RECORDED outcome AFTER readout (`apply_bias_to_measurement`), never to + // the state -- the opposite of the plain depolarizing family, which injects + // a physical X BEFORE measurement. So its measurement maps to neo's + // record-flipping channel (`with_p_meas`), which also carries the + // asymmetric `p_meas_0` (0->1) / `p_meas_1` (1->0) bias one-to-one. Gate + // and prep noise are ordinary uniform depolarizing. + let biased = |p_prep: f64, p_meas_0: f64, p_meas_1: f64, p1: f64, p2: f64| { + GeneralNoiseModelBuilder::new() + .with_p_prep(p_prep) + .with_p_meas(p_meas_0, p_meas_1) + .with_p1(p1) + .with_p2(p2) + }; + if noise.downcast_ref::().is_some() || noise .downcast_ref::() @@ -345,6 +363,16 @@ fn map_noise_to_neo( let (p_prep, p_meas, p1, p2) = builder.clone().build().probabilities(); return Ok(Some(uniform(p_prep, p_meas, p1, p2))); } + if let Some(biased_noise) = noise.downcast_ref::() { + // `BiasedDepolarizingNoise { p }` builds `new_uniform(p)`: every rate is + // `p`, with symmetric measurement bias. + let p = biased_noise.p; + return Ok(Some(biased(p, p, p, p, p))); + } + if let Some(builder) = noise.downcast_ref::() { + let (p_prep, p_meas_0, p_meas_1, p1, p2) = builder.clone().build().probabilities(); + return Ok(Some(biased(p_prep, p_meas_0, p_meas_1, p1, p2))); + } if let Some(builder) = noise.downcast_ref::() { // The stored p1/p2 are already in standard depolarizing convention // (the with_average_* setters convert on the way in), so they map @@ -392,7 +420,8 @@ fn map_noise_to_neo( Err(PecosError::Input( "This noise type is not yet mapped to the neo stack (mapped so far: PassThroughNoise, \ - DepolarizingNoise, DepolarizingNoiseModelBuilder, GeneralNoiseModelBuilder's simple \ + DepolarizingNoise, DepolarizingNoiseModelBuilder, BiasedDepolarizingNoise, \ + BiasedDepolarizingNoiseModelBuilder, GeneralNoiseModelBuilder's simple \ probability subset). Remove .noise(), use .stack(SimStack::Engines), or configure \ sim_neo() directly with a neo noise model." .to_string(), diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index 8f495c4f0..0793f13c2 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -133,6 +133,15 @@ enum NoiseCell { angle_params: (f64, f64, f64, f64), angle_power: f64, }, + /// `BiasedDepolarizingNoiseModel` with zero gate/prep noise and asymmetric + /// record-flip measurement: `p_meas_0` flips a 0 outcome to 1, `p_meas_1` + /// flips a 1 outcome to 0. The bias is applied to the recorded outcome + /// after readout (never the state), so it must map to neo's record-flip + /// channel, not the state-flip one. + BiasedMeas { + p_meas_0: f64, + p_meas_1: f64, + }, } impl NoiseCell { @@ -204,6 +213,17 @@ impl NoiseCell { .with_meas_1_probability(0.0), ) .run(SHOTS), + Self::BiasedMeas { p_meas_0, p_meas_1 } => builder + .noise( + // Asymmetric record-flip measurement, no gate/prep noise. + pecos_engines::noise::BiasedDepolarizingNoiseModel::builder() + .with_prep_probability(0.0) + .with_meas_0_probability(p_meas_0) + .with_meas_1_probability(p_meas_1) + .with_single_qubit_probability(0.0) + .with_two_qubit_probability(0.0), + ) + .run(SHOTS), }; results.expect("simulation run") } @@ -437,3 +457,59 @@ fn gnm_angle_scaling_negative_matches() { Some(8.0 * (0.3 * 0.75) / 15.0), ); } + +#[test] +fn biased_meas_flip_1_to_0_matches() { + // BiasedDepolarizing flips the RECORDED outcome after readout. A |1> (from + // X, with gate noise zeroed) reads 0 with probability p_meas_1 (the 1->0 + // rate). The bias is asymmetric (p_meas_0 = 0.1 != p_meas_1 = 0.3), so a + // swapped 0<->1 mapping would read 0.1 here instead of 0.3. + check_cell( + "biased_meas_1to0", + X_MEASURE, + &NoiseCell::BiasedMeas { + p_meas_0: 0.1, + p_meas_1: 0.3, + }, + &["0"], + Some(0.3), + ); +} + +#[test] +fn biased_meas_flip_0_to_1_matches() { + // The complementary direction: a |0> (from reset) reads 1 with probability + // p_meas_0 (the 0->1 rate, 0.1). Together with the cell above this pins + // both the magnitude AND the direction of the asymmetric bias. + check_cell( + "biased_meas_0to1", + RESET_MEASURE, + &NoiseCell::BiasedMeas { + p_meas_0: 0.1, + p_meas_1: 0.3, + }, + &["1"], + Some(0.1), + ); +} + +#[test] +fn biased_meas_twice_is_record_flip_not_state_flip() { + // Like meas_twice_gnm: BiasedDepolarizing flips the record, never the + // state, so a qubit reset to |0> and measured twice reads 1 on the SECOND + // measurement at exactly p_meas_0 = 0.25 -- NOT 2p(1-p) = 0.375 (state + // flip). This pins that BiasedDepolarizing maps to neo's record-flip + // channel (with_p_meas), not the state-flip channel the plain + // depolarizing family uses. + let p = 0.25; + check_cell( + "biased_meas_twice", + MEASURE_TWICE, + &NoiseCell::BiasedMeas { + p_meas_0: p, + p_meas_1: p, + }, + &["1"], + Some(p), + ); +} diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index f0dc9b888..5bf09c4f5 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -171,6 +171,35 @@ fn neo_stack_uniform_depolarizing_rate_matches_engines() { ); } +#[test] +fn neo_stack_biased_depolarizing_struct_rate_matches_engines() { + // The BiasedDepolarizingNoise convenience struct (uniform p, with the + // biased family's record-flip measurement) must agree cross-stack through + // the facade mapping. Independent seeds, as above. + let shots = 4000; + let run = |stack: SimStack| { + let seed = if matches!(stack, SimStack::Neo) { + 7 ^ 0xA5A5 + } else { + 7 + }; + sim(x_measure_qasm()) + .stack(stack) + .noise(pecos_engines::BiasedDepolarizingNoise { p: 0.1 }) + .seed(seed) + .run(shots) + .expect("run") + }; + + let engines_rate = rate_of(&run(SimStack::Engines), "0"); + let neo_rate = rate_of(&run(SimStack::Neo), "0"); + + assert!( + (engines_rate - neo_rate).abs() < 0.035, + "biased struct compound rates should agree: engines={engines_rate}, neo={neo_rate}" + ); +} + #[test] fn neo_stack_general_noise_average_convention_matches() { // The critical convention test: engines' with_average_p1_probability From 5541d709f3c173e5dadfd0ef389646c1e1ed8474 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 16 Jun 2026 13:59:00 -0600 Subject: [PATCH 196/388] Bring zlup, guppy-zlup, and zluppy crates onto dev and fix AST/Ord/pyo3 drift so all three build --- Cargo.lock | 861 +- Cargo.toml | 3 + exp/guppy-zlup/.github/workflows/ci.yml | 115 + exp/guppy-zlup/Cargo.toml | 56 + exp/guppy-zlup/Justfile | 50 + exp/guppy-zlup/README.md | 213 + exp/guppy-zlup/docs/architecture.md | 252 + exp/guppy-zlup/docs/examples/bell-state.md | 167 + exp/guppy-zlup/docs/examples/grover.md | 213 + exp/guppy-zlup/docs/future/parallelism.md | 1205 +++ exp/guppy-zlup/docs/index.md | 183 + exp/guppy-zlup/docs/ir-format.md | 414 + exp/guppy-zlup/docs/rules.md | 507 + exp/guppy-zlup/ir.json | 96 + exp/guppy-zlup/mkdocs.yml | 68 + exp/guppy-zlup/scripts/validate_guppy.py | 104 + exp/guppy-zlup/src/compiler.rs | 9 + exp/guppy-zlup/src/compiler/parser.rs | 64 + exp/guppy-zlup/src/compiler/transform.rs | 1026 ++ exp/guppy-zlup/src/ir.rs | 2205 +++++ exp/guppy-zlup/src/lib.rs | 414 + exp/guppy-zlup/src/linter.rs | 18 + exp/guppy-zlup/src/linter/ast.rs | 595 ++ exp/guppy-zlup/src/linter/config.rs | 190 + exp/guppy-zlup/src/linter/diagnostic.rs | 188 + exp/guppy-zlup/src/linter/engine.rs | 181 + exp/guppy-zlup/src/linter/lower.rs | 930 ++ exp/guppy-zlup/src/linter/noqa.rs | 152 + exp/guppy-zlup/src/linter/output.rs | 306 + exp/guppy-zlup/src/linter/rules.rs | 77 + exp/guppy-zlup/src/linter/rules/zlup001.rs | 262 + exp/guppy-zlup/src/linter/rules/zlup002.rs | 349 + exp/guppy-zlup/src/linter/rules/zlup003.rs | 318 + exp/guppy-zlup/src/linter/rules/zlup004.rs | 300 + exp/guppy-zlup/src/linter/rules/zlup005.rs | 306 + exp/guppy-zlup/src/linter/rules/zlup006.rs | 291 + exp/guppy-zlup/src/linter/rules/zlup007.rs | 360 + exp/guppy-zlup/src/linter/rules/zlup008.rs | 265 + exp/guppy-zlup/src/linter/rules/zlup009.rs | 257 + exp/guppy-zlup/src/linter/rules/zlup010.rs | 238 + exp/guppy-zlup/src/main.rs | 882 ++ exp/guppy-zlup/tests/e2e_tests.rs | 690 ++ exp/guppy-zlup/tests/transform_tests.rs | 391 + exp/zlup/.gitignore | 22 + exp/zlup/Cargo.toml | 86 + exp/zlup/Justfile | 235 + exp/zlup/README.md | 96 + exp/zlup/benches/compiler.rs | 314 + exp/zlup/docs/cli.md | 244 + exp/zlup/docs/design.md | 1684 ++++ exp/zlup/docs/dev-notes.md | 509 + exp/zlup/docs/errors.md | 581 ++ exp/zlup/docs/future/alias-ast-sketch.rs | 294 + exp/zlup/docs/future/alias-design.md | 436 + exp/zlup/docs/future/build-system.md | 337 + exp/zlup/docs/future/custom-gates-design.md | 1493 +++ exp/zlup/docs/future/guppy-compat.md | 276 + exp/zlup/docs/future/stdlib-design.md | 266 + exp/zlup/docs/ide-setup.md | 247 + exp/zlup/docs/index.md | 269 + exp/zlup/docs/rust-integration.md | 483 + exp/zlup/docs/stdlib.md | 483 + exp/zlup/docs/syntax.md | 1243 +++ exp/zlup/docs/tutorial-error-handling.md | 666 ++ exp/zlup/docs/tutorial.md | 492 + exp/zlup/editors/jetbrains-zlup/.gitignore | 15 + exp/zlup/editors/jetbrains-zlup/README.md | 60 + .../editors/jetbrains-zlup/build.gradle.kts | 44 + .../editors/jetbrains-zlup/gradle.properties | 2 + .../gradle/wrapper/gradle-wrapper.properties | 7 + exp/zlup/editors/jetbrains-zlup/gradlew | 152 + .../jetbrains-zlup/settings.gradle.kts | 5 + .../kotlin/com/zlup/ide/ZlupBraceMatcher.kt | 22 + .../com/zlup/ide/ZlupColorSettingsPage.kt | 68 + .../main/kotlin/com/zlup/ide/ZlupCommenter.kt | 11 + .../com/zlup/ide/ZlupCompletionContributor.kt | 174 + .../src/main/kotlin/com/zlup/ide/ZlupFile.kt | 10 + .../main/kotlin/com/zlup/ide/ZlupFileType.kt | 13 + .../kotlin/com/zlup/ide/ZlupFoldingBuilder.kt | 117 + .../src/main/kotlin/com/zlup/ide/ZlupIcons.kt | 9 + .../main/kotlin/com/zlup/ide/ZlupLanguage.kt | 8 + .../src/main/kotlin/com/zlup/ide/ZlupLexer.kt | 233 + .../main/kotlin/com/zlup/ide/ZlupParser.kt | 24 + .../com/zlup/ide/ZlupParserDefinition.kt | 35 + .../kotlin/com/zlup/ide/ZlupPsiElement.kt | 6 + .../com/zlup/ide/ZlupSyntaxHighlighter.kt | 82 + .../com/zlup/ide/ZlupTemplateContextType.kt | 11 + .../kotlin/com/zlup/ide/ZlupTokenTypes.kt | 56 + .../src/main/resources/META-INF/plugin.xml | 96 + .../src/main/resources/icons/zlup.svg | 9 + .../src/main/resources/liveTemplates/Zlup.xml | 105 + exp/zlup/editors/vscode-zlup/README.md | 72 + .../vscode-zlup/language-configuration.json | 35 + exp/zlup/editors/vscode-zlup/package.json | 45 + .../editors/vscode-zlup/snippets/zlup.json | 176 + .../vscode-zlup/syntaxes/zlup.tmLanguage.json | 229 + exp/zlup/examples/README.md | 50 + exp/zlup/examples/bell_state.json | 86 + exp/zlup/examples/bell_state.qasm | 10 + exp/zlup/examples/bell_state.zlp | 27 + exp/zlup/examples/ghz_state.qasm | 11 + exp/zlup/examples/ghz_state.zlp | 25 + exp/zlup/examples/grover_2qubit.json | 193 + exp/zlup/examples/grover_2qubit.qasm | 20 + exp/zlup/examples/grover_2qubit.zlp | 45 + exp/zlup/examples/qft_3qubit.json | 198 + exp/zlup/examples/qft_3qubit.qasm | 18 + exp/zlup/examples/qft_3qubit.zlp | 46 + exp/zlup/examples/simple_qec.json | 177 + exp/zlup/examples/simple_qec.qasm | 17 + exp/zlup/examples/simple_qec.zlp | 54 + exp/zlup/examples/teleportation.json | 142 + exp/zlup/examples/teleportation.qasm | 14 + exp/zlup/examples/teleportation.zlp | 49 + exp/zlup/examples/test_lsp.zlp | 28 + exp/zlup/ffi/zlup-ffi/Cargo.lock | 7 + exp/zlup/ffi/zlup-ffi/Cargo.toml | 23 + exp/zlup/ffi/zlup-ffi/README.md | 77 + exp/zlup/ffi/zlup-ffi/src/lib.rs | 43 + exp/zlup/ffi/zlup-ffi/src/traits.rs | 216 + exp/zlup/ffi/zlup-ffi/src/types.rs | 602 ++ exp/zlup/fuzz/Cargo.lock | 771 ++ exp/zlup/fuzz/Cargo.toml | 44 + exp/zlup/fuzz/fuzz_targets/fuzz_comptime.rs | 40 + exp/zlup/fuzz/fuzz_targets/fuzz_parser.rs | 23 + exp/zlup/fuzz/fuzz_targets/fuzz_semantic.rs | 28 + exp/zlup/mkdocs.yml | 73 + exp/zlup/pyproject.toml | 14 + exp/zlup/scripts/check_docs.py | 268 + exp/zlup/src/analysis.rs | 1325 +++ exp/zlup/src/ast.rs | 1554 +++ exp/zlup/src/build.rs | 727 ++ exp/zlup/src/codegen.rs | 26 + exp/zlup/src/codegen/hugr.rs | 2613 +++++ exp/zlup/src/codegen/phir.rs | 1322 +++ exp/zlup/src/codegen/qasm.rs | 1129 +++ exp/zlup/src/codegen/slr.rs | 3841 +++++++ exp/zlup/src/comptime.rs | 3400 +++++++ exp/zlup/src/config.rs | 321 + exp/zlup/src/docgen.rs | 534 + exp/zlup/src/formatter.rs | 536 + exp/zlup/src/lib.rs | 181 + exp/zlup/src/linter.rs | 2309 +++++ exp/zlup/src/logging.rs | 113 + exp/zlup/src/lsp/main.rs | 850 ++ exp/zlup/src/lsp/semantic_tokens.rs | 279 + exp/zlup/src/main.rs | 2045 ++++ exp/zlup/src/module.rs | 671 ++ exp/zlup/src/optimize.rs | 2746 +++++ exp/zlup/src/parser.rs | 4054 ++++++++ exp/zlup/src/pretty.rs | 1900 ++++ exp/zlup/src/rational.rs | 844 ++ exp/zlup/src/semantic.rs | 8812 +++++++++++++++++ exp/zlup/src/test_runner.rs | 322 + exp/zlup/src/tests.rs | 1810 ++++ exp/zlup/src/zluppy.pest | 990 ++ exp/zlup/std/a64.zlp | 76 + exp/zlup/std/algorithm.zlp | 344 + exp/zlup/std/bits.zlp | 458 + exp/zlup/std/bits.zlup | 176 + exp/zlup/std/containers.zlup | 381 + exp/zlup/std/f64.zlp | 72 + exp/zlup/std/math.zlp | 523 + exp/zlup/std/math.zlup | 79 + exp/zlup/std/qec.zlup | 340 + exp/zlup/std/qec/decoder.zlp | 151 + exp/zlup/std/qec/errors.zlp | 210 + exp/zlup/std/qec/pauli.zlp | 175 + exp/zlup/std/qec/syndrome.zlp | 138 + exp/zlup/std/std.zlp | 88 + exp/zlup/std/std.zlup | 51 + exp/zlup/tests/cli.rs | 1037 ++ exp/zlup/tests/proptest.rs | 3120 ++++++ exp/zlup/uv.lock | 618 ++ exp/zluppy/Cargo.toml | 33 + exp/zluppy/README.md | 48 + exp/zluppy/pyproject.toml | 62 + exp/zluppy/python/zluppy/__init__.py | 164 + exp/zluppy/src/lib.rs | 1152 +++ exp/zluppy/tests/test_zluppy.py | 987 ++ exp/zluppy/uv.lock | 1140 +++ 181 files changed, 88746 insertions(+), 28 deletions(-) create mode 100644 exp/guppy-zlup/.github/workflows/ci.yml create mode 100644 exp/guppy-zlup/Cargo.toml create mode 100644 exp/guppy-zlup/Justfile create mode 100644 exp/guppy-zlup/README.md create mode 100644 exp/guppy-zlup/docs/architecture.md create mode 100644 exp/guppy-zlup/docs/examples/bell-state.md create mode 100644 exp/guppy-zlup/docs/examples/grover.md create mode 100644 exp/guppy-zlup/docs/future/parallelism.md create mode 100644 exp/guppy-zlup/docs/index.md create mode 100644 exp/guppy-zlup/docs/ir-format.md create mode 100644 exp/guppy-zlup/docs/rules.md create mode 100644 exp/guppy-zlup/ir.json create mode 100644 exp/guppy-zlup/mkdocs.yml create mode 100755 exp/guppy-zlup/scripts/validate_guppy.py create mode 100644 exp/guppy-zlup/src/compiler.rs create mode 100644 exp/guppy-zlup/src/compiler/parser.rs create mode 100644 exp/guppy-zlup/src/compiler/transform.rs create mode 100644 exp/guppy-zlup/src/ir.rs create mode 100644 exp/guppy-zlup/src/lib.rs create mode 100644 exp/guppy-zlup/src/linter.rs create mode 100644 exp/guppy-zlup/src/linter/ast.rs create mode 100644 exp/guppy-zlup/src/linter/config.rs create mode 100644 exp/guppy-zlup/src/linter/diagnostic.rs create mode 100644 exp/guppy-zlup/src/linter/engine.rs create mode 100644 exp/guppy-zlup/src/linter/lower.rs create mode 100644 exp/guppy-zlup/src/linter/noqa.rs create mode 100644 exp/guppy-zlup/src/linter/output.rs create mode 100644 exp/guppy-zlup/src/linter/rules.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup001.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup002.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup003.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup004.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup005.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup006.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup007.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup008.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup009.rs create mode 100644 exp/guppy-zlup/src/linter/rules/zlup010.rs create mode 100644 exp/guppy-zlup/src/main.rs create mode 100644 exp/guppy-zlup/tests/e2e_tests.rs create mode 100644 exp/guppy-zlup/tests/transform_tests.rs create mode 100644 exp/zlup/.gitignore create mode 100644 exp/zlup/Cargo.toml create mode 100644 exp/zlup/Justfile create mode 100644 exp/zlup/README.md create mode 100644 exp/zlup/benches/compiler.rs create mode 100644 exp/zlup/docs/cli.md create mode 100644 exp/zlup/docs/design.md create mode 100644 exp/zlup/docs/dev-notes.md create mode 100644 exp/zlup/docs/errors.md create mode 100644 exp/zlup/docs/future/alias-ast-sketch.rs create mode 100644 exp/zlup/docs/future/alias-design.md create mode 100644 exp/zlup/docs/future/build-system.md create mode 100644 exp/zlup/docs/future/custom-gates-design.md create mode 100644 exp/zlup/docs/future/guppy-compat.md create mode 100644 exp/zlup/docs/future/stdlib-design.md create mode 100644 exp/zlup/docs/ide-setup.md create mode 100644 exp/zlup/docs/index.md create mode 100644 exp/zlup/docs/rust-integration.md create mode 100644 exp/zlup/docs/stdlib.md create mode 100644 exp/zlup/docs/syntax.md create mode 100644 exp/zlup/docs/tutorial-error-handling.md create mode 100644 exp/zlup/docs/tutorial.md create mode 100644 exp/zlup/editors/jetbrains-zlup/.gitignore create mode 100644 exp/zlup/editors/jetbrains-zlup/README.md create mode 100644 exp/zlup/editors/jetbrains-zlup/build.gradle.kts create mode 100644 exp/zlup/editors/jetbrains-zlup/gradle.properties create mode 100644 exp/zlup/editors/jetbrains-zlup/gradle/wrapper/gradle-wrapper.properties create mode 100755 exp/zlup/editors/jetbrains-zlup/gradlew create mode 100644 exp/zlup/editors/jetbrains-zlup/settings.gradle.kts create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupBraceMatcher.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupColorSettingsPage.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCommenter.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCompletionContributor.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFile.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFileType.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFoldingBuilder.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupIcons.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLanguage.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLexer.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParser.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParserDefinition.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupPsiElement.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupSyntaxHighlighter.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTemplateContextType.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTokenTypes.kt create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/resources/META-INF/plugin.xml create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/resources/icons/zlup.svg create mode 100644 exp/zlup/editors/jetbrains-zlup/src/main/resources/liveTemplates/Zlup.xml create mode 100644 exp/zlup/editors/vscode-zlup/README.md create mode 100644 exp/zlup/editors/vscode-zlup/language-configuration.json create mode 100644 exp/zlup/editors/vscode-zlup/package.json create mode 100644 exp/zlup/editors/vscode-zlup/snippets/zlup.json create mode 100644 exp/zlup/editors/vscode-zlup/syntaxes/zlup.tmLanguage.json create mode 100644 exp/zlup/examples/README.md create mode 100644 exp/zlup/examples/bell_state.json create mode 100644 exp/zlup/examples/bell_state.qasm create mode 100644 exp/zlup/examples/bell_state.zlp create mode 100644 exp/zlup/examples/ghz_state.qasm create mode 100644 exp/zlup/examples/ghz_state.zlp create mode 100644 exp/zlup/examples/grover_2qubit.json create mode 100644 exp/zlup/examples/grover_2qubit.qasm create mode 100644 exp/zlup/examples/grover_2qubit.zlp create mode 100644 exp/zlup/examples/qft_3qubit.json create mode 100644 exp/zlup/examples/qft_3qubit.qasm create mode 100644 exp/zlup/examples/qft_3qubit.zlp create mode 100644 exp/zlup/examples/simple_qec.json create mode 100644 exp/zlup/examples/simple_qec.qasm create mode 100644 exp/zlup/examples/simple_qec.zlp create mode 100644 exp/zlup/examples/teleportation.json create mode 100644 exp/zlup/examples/teleportation.qasm create mode 100644 exp/zlup/examples/teleportation.zlp create mode 100644 exp/zlup/examples/test_lsp.zlp create mode 100644 exp/zlup/ffi/zlup-ffi/Cargo.lock create mode 100644 exp/zlup/ffi/zlup-ffi/Cargo.toml create mode 100644 exp/zlup/ffi/zlup-ffi/README.md create mode 100644 exp/zlup/ffi/zlup-ffi/src/lib.rs create mode 100644 exp/zlup/ffi/zlup-ffi/src/traits.rs create mode 100644 exp/zlup/ffi/zlup-ffi/src/types.rs create mode 100644 exp/zlup/fuzz/Cargo.lock create mode 100644 exp/zlup/fuzz/Cargo.toml create mode 100644 exp/zlup/fuzz/fuzz_targets/fuzz_comptime.rs create mode 100644 exp/zlup/fuzz/fuzz_targets/fuzz_parser.rs create mode 100644 exp/zlup/fuzz/fuzz_targets/fuzz_semantic.rs create mode 100644 exp/zlup/mkdocs.yml create mode 100644 exp/zlup/pyproject.toml create mode 100644 exp/zlup/scripts/check_docs.py create mode 100644 exp/zlup/src/analysis.rs create mode 100644 exp/zlup/src/ast.rs create mode 100644 exp/zlup/src/build.rs create mode 100644 exp/zlup/src/codegen.rs create mode 100644 exp/zlup/src/codegen/hugr.rs create mode 100644 exp/zlup/src/codegen/phir.rs create mode 100644 exp/zlup/src/codegen/qasm.rs create mode 100644 exp/zlup/src/codegen/slr.rs create mode 100644 exp/zlup/src/comptime.rs create mode 100644 exp/zlup/src/config.rs create mode 100644 exp/zlup/src/docgen.rs create mode 100644 exp/zlup/src/formatter.rs create mode 100644 exp/zlup/src/lib.rs create mode 100644 exp/zlup/src/linter.rs create mode 100644 exp/zlup/src/logging.rs create mode 100644 exp/zlup/src/lsp/main.rs create mode 100644 exp/zlup/src/lsp/semantic_tokens.rs create mode 100644 exp/zlup/src/main.rs create mode 100644 exp/zlup/src/module.rs create mode 100644 exp/zlup/src/optimize.rs create mode 100644 exp/zlup/src/parser.rs create mode 100644 exp/zlup/src/pretty.rs create mode 100644 exp/zlup/src/rational.rs create mode 100644 exp/zlup/src/semantic.rs create mode 100644 exp/zlup/src/test_runner.rs create mode 100644 exp/zlup/src/tests.rs create mode 100644 exp/zlup/src/zluppy.pest create mode 100644 exp/zlup/std/a64.zlp create mode 100644 exp/zlup/std/algorithm.zlp create mode 100644 exp/zlup/std/bits.zlp create mode 100644 exp/zlup/std/bits.zlup create mode 100644 exp/zlup/std/containers.zlup create mode 100644 exp/zlup/std/f64.zlp create mode 100644 exp/zlup/std/math.zlp create mode 100644 exp/zlup/std/math.zlup create mode 100644 exp/zlup/std/qec.zlup create mode 100644 exp/zlup/std/qec/decoder.zlp create mode 100644 exp/zlup/std/qec/errors.zlp create mode 100644 exp/zlup/std/qec/pauli.zlp create mode 100644 exp/zlup/std/qec/syndrome.zlp create mode 100644 exp/zlup/std/std.zlp create mode 100644 exp/zlup/std/std.zlup create mode 100644 exp/zlup/tests/cli.rs create mode 100644 exp/zlup/tests/proptest.rs create mode 100644 exp/zlup/uv.lock create mode 100644 exp/zluppy/Cargo.toml create mode 100644 exp/zluppy/README.md create mode 100644 exp/zluppy/pyproject.toml create mode 100644 exp/zluppy/python/zluppy/__init__.py create mode 100644 exp/zluppy/src/lib.rs create mode 100644 exp/zluppy/tests/test_zluppy.py create mode 100644 exp/zluppy/uv.lock diff --git a/Cargo.lock b/Cargo.lock index ffffaffbf..d1e1f3a80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,13 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli 0.32.3", +] + [[package]] name = "addr2line" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ - "gimli", + "gimli 0.33.0", ] [[package]] @@ -304,6 +313,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -338,6 +358,30 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be5eb007b7cacc6c660343e96f650fedf4b5a77512399eb952ca6642cf8d13f7" +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line 0.25.1", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + [[package]] name = "base64" version = "0.22.1" @@ -354,7 +398,7 @@ checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" name = "benchmarks" version = "0.2.0-dev.0" dependencies = [ - "criterion", + "criterion 0.8.2", "cxx", "nalgebra", "num", @@ -773,7 +817,7 @@ dependencies = [ "atty", "bitflags 1.3.2", "strsim 0.8.0", - "textwrap", + "textwrap 0.11.0", "unicode-width 0.1.14", "vec_map", ] @@ -1013,7 +1057,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", + "gimli 0.33.0", "hashbrown 0.16.1", "libm", "log", @@ -1125,6 +1169,32 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap 4.6.1", + "criterion-plot 0.5.0", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + [[package]] name = "criterion" version = "0.8.2" @@ -1136,7 +1206,7 @@ dependencies = [ "cast", "ciborium", "clap 4.6.1", - "criterion-plot", + "criterion-plot 0.8.2", "itertools 0.13.0", "num-traits", "oorandom", @@ -1150,6 +1220,16 @@ dependencies = [ "walkdir", ] +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + [[package]] name = "criterion-plot" version = "0.8.2" @@ -1827,6 +1907,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "funty" version = "2.0.0" @@ -1974,6 +2063,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width 0.2.2", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -2015,6 +2113,12 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gimli" version = "0.33.0" @@ -2207,6 +2311,27 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "guppy-zlup" +version = "0.1.0" +dependencies = [ + "clap 4.6.1", + "insta", + "miette", + "notify", + "notify-debouncer-mini", + "pretty_assertions", + "rayon", + "regex", + "rustpython-parser", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "zlup", +] + [[package]] name = "half" version = "2.7.1" @@ -2769,6 +2894,26 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "insta" version = "1.47.2" @@ -2805,6 +2950,18 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-macro" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57a3e447e24c22647738e4607f1df1e0ec6f72e16182c4cd199f647cdfb0e4" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -2816,6 +2973,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2831,6 +2994,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2976,6 +3148,26 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.11.1", + "libc", +] + [[package]] name = "lalrpop" version = "0.19.12" @@ -2988,7 +3180,7 @@ dependencies = [ "ena", "is-terminal", "itertools 0.10.5", - "lalrpop-util", + "lalrpop-util 0.19.12", "petgraph 0.6.5", "regex", "regex-syntax 0.6.29", @@ -3007,6 +3199,12 @@ dependencies = [ "regex", ] +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" + [[package]] name = "lazy_static" version = "1.5.0" @@ -3156,6 +3354,19 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "lsp-types" +version = "0.94.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c66bfd44a06ae10647fe3f8214762e9369fd4248df1350924b4ef9e770a85ea1" +dependencies = [ + "bitflags 1.3.2", + "serde", + "serde_json", + "serde_repr", + "url", +] + [[package]] name = "lzma-rust" version = "0.1.7" @@ -3185,6 +3396,64 @@ dependencies = [ "libc", ] +[[package]] +name = "malachite" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fbdf9cb251732db30a7200ebb6ae5d22fe8e11397364416617d2c2cf0c51cb5" +dependencies = [ + "malachite-base", + "malachite-nz", + "malachite-q", +] + +[[package]] +name = "malachite-base" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea0ed76adf7defc1a92240b5c36d5368cfe9251640dcce5bd2d0b7c1fd87aeb" +dependencies = [ + "hashbrown 0.14.5", + "itertools 0.11.0", + "libm", + "ryu", +] + +[[package]] +name = "malachite-bigint" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d149aaa2965d70381709d9df4c7ee1fc0de1c614a4efc2ee356f5e43d68749f8" +dependencies = [ + "derive_more 1.0.0", + "malachite", + "num-integer", + "num-traits", + "paste", +] + +[[package]] +name = "malachite-nz" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34a79feebb2bc9aa7762047c8e5495269a367da6b5a90a99882a0aeeac1841f7" +dependencies = [ + "itertools 0.11.0", + "libm", + "malachite-base", +] + +[[package]] +name = "malachite-q" +version = "0.4.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f235d5747b1256b47620f5640c2a17a88c7569eebdf27cd9cb130e1a619191" +dependencies = [ + "itertools 0.11.0", + "malachite-base", + "malachite-nz", +] + [[package]] name = "maplit" version = "1.0.2" @@ -3216,6 +3485,36 @@ dependencies = [ "rustix", ] +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap 0.16.2", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "min_max_macros" version = "0.1.1" @@ -3238,6 +3537,18 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + [[package]] name = "mio" version = "1.2.0" @@ -3451,6 +3762,36 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.11.1", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" +dependencies = [ + "crossbeam-channel", + "log", + "notify", +] + [[package]] name = "nt-time" version = "0.8.1" @@ -3625,6 +3966,15 @@ dependencies = [ "objc2-metal", ] +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.39.1" @@ -3662,7 +4012,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "243ed4464839d68c0b7a7c082b9f25f62b8cbe786054852cda8d2b791e5b17b0" dependencies = [ "lalrpop", - "lalrpop-util", + "lalrpop-util 0.19.12", "logos", "num", "petgraph 0.6.5", @@ -3717,6 +4067,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + [[package]] name = "page_size" version = "0.6.0" @@ -3817,7 +4173,7 @@ dependencies = [ "tar", "tempfile", "thiserror 2.0.18", - "toml", + "toml 1.1.2+spec-1.1.0", "toml_edit 0.22.27", "xz2", "zip", @@ -4142,7 +4498,7 @@ dependencies = [ name = "pecos-neo" version = "0.2.0-dev.0" dependencies = [ - "criterion", + "criterion 0.8.2", "num-complex 0.4.6", "num_cpus", "pecos-core", @@ -4669,21 +5025,70 @@ dependencies = [ ] [[package]] -name = "phf_shared" +name = "phf" version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "siphasher", + "phf_shared", ] [[package]] -name = "pin-project-lite" -version = "0.2.17" +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] name = "pkg-config" version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4840,6 +5245,16 @@ dependencies = [ "unicode-width 0.2.2", ] +[[package]] +name = "pretty_assertions" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae130e2f271fbc2ac3a40fb1d07180839cdbbe443c7a27e1e3c13c5cac0116d" +dependencies = [ + "diff", + "yansi", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -5565,7 +5980,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-rustls", - "tower", + "tower 0.5.3", "tower-http", "tower-service", "url", @@ -5782,6 +6197,63 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustpython-ast" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cdaf8ee5c1473b993b398c174641d3aa9da847af36e8d5eb8291930b72f31a5" +dependencies = [ + "is-macro", + "malachite-bigint", + "rustpython-parser-core", + "static_assertions", +] + +[[package]] +name = "rustpython-parser" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "868f724daac0caf9bd36d38caf45819905193a901e8f1c983345a68e18fb2abb" +dependencies = [ + "anyhow", + "is-macro", + "itertools 0.11.0", + "lalrpop-util 0.20.2", + "log", + "malachite-bigint", + "num-traits", + "phf", + "phf_codegen", + "rustc-hash 1.1.0", + "rustpython-ast", + "rustpython-parser-core", + "tiny-keccak", + "unic-emoji-char", + "unic-ucd-ident", + "unicode_names2", +] + +[[package]] +name = "rustpython-parser-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4b6c12fa273825edc7bccd9a734f0ad5ba4b8a2f4da5ff7efe946f066d0f4ad" +dependencies = [ + "is-macro", + "memchr", + "rustpython-parser-vendored", +] + +[[package]] +name = "rustpython-parser-vendored" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04fcea49a4630a3a5d940f4d514dc4f575ed63c14c3e3ed07146634aed7f67a6" +dependencies = [ + "memchr", + "once_cell", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -6054,6 +6526,26 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -6428,6 +6920,27 @@ dependencies = [ "vec_box", ] +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + [[package]] name = "syn" version = "1.0.109" @@ -6526,6 +7039,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "termtree" version = "0.5.1" @@ -6541,6 +7064,16 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6751,12 +7284,24 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio", + "mio 1.2.0", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -6767,6 +7312,31 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + [[package]] name = "toml" version = "1.1.2+spec-1.1.0" @@ -6775,7 +7345,7 @@ checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" dependencies = [ "indexmap 2.14.0", "serde_core", - "serde_spanned", + "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", @@ -6787,6 +7357,9 @@ name = "toml_datetime" version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] [[package]] name = "toml_datetime" @@ -6804,6 +7377,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", "toml_datetime 0.6.11", "toml_write", "winnow 0.7.15", @@ -6842,6 +7417,20 @@ version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", +] + [[package]] name = "tower" version = "0.5.3" @@ -6869,7 +7458,7 @@ dependencies = [ "http", "http-body", "pin-project-lite", - "tower", + "tower 0.5.3", "tower-layer", "tower-service", "url", @@ -6881,6 +7470,40 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +[[package]] +name = "tower-lsp" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ba052b54a6627628d9b3c34c176e7eda8359b7da9acd497b9f20998d118508" +dependencies = [ + "async-trait", + "auto_impl", + "bytes", + "dashmap", + "futures", + "httparse", + "lsp-types", + "memchr", + "serde", + "serde_json", + "tokio", + "tokio-util", + "tower 0.4.13", + "tower-lsp-macros", + "tracing", +] + +[[package]] +name = "tower-lsp-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fd902d4e0b9a4b27f2f440108dc034e1758628a9b702f8ec61ad66355422fa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -6978,12 +7601,70 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-emoji-char" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b07221e68897210270a38bde4babb655869637af0f69407f96053a34f76494d" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-segmentation" version = "1.13.2" @@ -7008,6 +7689,28 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unicode_names2" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1673eca9782c84de5f81b82e4109dcfb3611c8ba0d52930ec4a9478f547b2dd" +dependencies = [ + "phf", + "unicode_names2_generator", +] + +[[package]] +name = "unicode_names2_generator" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91e5b84611016120197efd7dc93ef76774f4e084cd73c9fb3ea4a86c570c56e" +dependencies = [ + "getopts", + "log", + "phf_codegen", + "rand 0.8.6", +] + [[package]] name = "unit-prefix" version = "0.5.2" @@ -7030,6 +7733,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -7288,7 +7992,7 @@ version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "372db8bbad8ec962038101f75ab2c3ffcd18797d7d3ae877a58ab9873cd0c4bd" dependencies = [ - "addr2line", + "addr2line 0.26.1", "async-trait", "bitflags 2.11.1", "bumpalo", @@ -7298,7 +8002,7 @@ dependencies = [ "log", "mach2", "memfd", - "object", + "object 0.39.1", "once_cell", "postcard", "pulley-interpreter", @@ -7331,11 +8035,11 @@ dependencies = [ "cranelift-bforest", "cranelift-bitset", "cranelift-entity", - "gimli", + "gimli 0.33.0", "hashbrown 0.16.1", "indexmap 2.14.0", "log", - "object", + "object 0.39.1", "postcard", "rustc-demangle", "serde", @@ -7372,10 +8076,10 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", + "gimli 0.33.0", "itertools 0.14.0", "log", - "object", + "object 0.39.1", "pulley-interpreter", "smallvec", "target-lexicon", @@ -7433,7 +8137,7 @@ dependencies = [ "cfg-if", "cranelift-codegen", "log", - "object", + "object 0.39.1", "wasmtime-environ", ] @@ -7837,6 +8541,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -7864,6 +8577,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -7906,6 +8634,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -7918,6 +8652,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -7930,6 +8670,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -7954,6 +8700,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -7966,6 +8718,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -7978,6 +8736,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -7990,6 +8754,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -8280,6 +9050,41 @@ dependencies = [ "zopfli", ] +[[package]] +name = "zlup" +version = "0.1.0" +dependencies = [ + "clap 4.6.1", + "criterion 0.5.1", + "env_logger", + "insta", + "log", + "miette", + "once_cell", + "pest", + "pest_derive", + "pretty_assertions", + "proptest", + "serde", + "serde_json", + "smol_str", + "tempfile", + "thiserror 1.0.69", + "tket", + "tokio", + "toml 0.8.23", + "tower-lsp", +] + +[[package]] +name = "zluppy-python" +version = "0.2.0-dev.0" +dependencies = [ + "pyo3", + "serde_json", + "zlup", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 1e2c2a3b7..b3443c4fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,9 @@ members = [ "crates/pecos*", "crates/benchmarks", "exp/pecos*", + "exp/zlup", + "exp/guppy-zlup", + "exp/zluppy", ] [workspace.package] diff --git a/exp/guppy-zlup/.github/workflows/ci.yml b/exp/guppy-zlup/.github/workflows/ci.yml new file mode 100644 index 000000000..1c81a37e1 --- /dev/null +++ b/exp/guppy-zlup/.github/workflows/ci.yml @@ -0,0 +1,115 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main, dev] + +env: + CARGO_TERM_COLOR: always + +jobs: + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Run tests + run: cargo test --all-features + working-directory: exp/guppy-zlup + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + with: + components: clippy, rustfmt + + - name: Check formatting + run: cargo fmt --check + working-directory: exp/guppy-zlup + + - name: Clippy + run: cargo clippy --all-features -- -D warnings + working-directory: exp/guppy-zlup + + build: + name: Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Build + run: cargo build --release --features cli + working-directory: exp/guppy-zlup + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: guppy-zlup + path: target/release/guppy-zlup + + validate: + name: Validate with guppylang + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-action@stable + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install guppylang + run: uv pip install guppylang --system + + - name: Build CLI + run: cargo build --features cli + working-directory: exp/guppy-zlup + + - name: Test validation script + run: | + # Create a test file + cat > /tmp/test_guppy.py << 'EOF' + from guppylang import guppy + from guppylang.std.quantum import qubit, h, measure + + @guppy + def test() -> bool: + q = qubit() + h(q) + return measure(q) + EOF + + # Run validation + cargo run --features cli -- validate /tmp/test_guppy.py + working-directory: exp/guppy-zlup diff --git a/exp/guppy-zlup/Cargo.toml b/exp/guppy-zlup/Cargo.toml new file mode 100644 index 000000000..678ef222c --- /dev/null +++ b/exp/guppy-zlup/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "guppy-zlup" +version = "0.1.0" +edition = "2024" +description = "Guppy to Zlup compiler" +license = "Apache-2.0" +repository = "https://github.com/PECOS-packages/PECOS" +readme = "README.md" +keywords = ["quantum", "compiler", "guppy", "zlup"] +categories = ["compilers", "command-line-utilities"] + +[lib] +name = "guppy_zlup" + +[[bin]] +name = "guppy-zlup" +path = "src/main.rs" +required-features = ["cli"] + +[dependencies] +# Python AST parsing (for linter) +rustpython-parser = "0.4" + +# Serialization +serde.workspace = true +serde_json.workspace = true +toml.workspace = true + +# Error handling +thiserror.workspace = true +miette = { version = "7.0", features = ["fancy"] } + +# Regex for noqa parsing +regex = "1.10" + +# CLI (optional) +clap = { workspace = true, optional = true } + +# Parallel processing (optional, for CLI) +rayon = { version = "1.10", optional = true } + +# File watching (optional, for CLI) +notify = { version = "6.1", optional = true } +notify-debouncer-mini = { version = "0.4", optional = true } + +# Link to zlup for AST types +zlup = { path = "../zlup" } + +[dev-dependencies] +pretty_assertions = "1.4" +insta = "1.40" +tempfile.workspace = true + +[features] +default = [] +cli = ["dep:clap", "dep:rayon", "dep:notify", "dep:notify-debouncer-mini"] diff --git a/exp/guppy-zlup/Justfile b/exp/guppy-zlup/Justfile new file mode 100644 index 000000000..0989b9c3f --- /dev/null +++ b/exp/guppy-zlup/Justfile @@ -0,0 +1,50 @@ +# Justfile for guppy-zlup documentation +# +# Uses the PECOS root's uv environment (which has mkdocs in dev deps) + +set shell := ["bash", "-cu"] + +# PECOS root directory (where pyproject.toml with mkdocs lives) +root := "../../.." + +# Default: serve docs +default: serve + +# Alias for consistency +docs port="8000": (serve port) + +# Serve docs locally (with hot reload, opens browser) +serve port="8000": + cd {{root}} && uv run mkdocs serve --open -a 127.0.0.1:{{port}} -f exp/guppy-zlup/mkdocs.yml + +# Serve docs without opening browser +serve-quiet port="8000": + cd {{root}} && uv run mkdocs serve -a 127.0.0.1:{{port}} -f exp/guppy-zlup/mkdocs.yml + +# Build static site +build: + cd {{root}} && uv run mkdocs build --clean -f exp/guppy-zlup/mkdocs.yml + +# Deploy to GitHub Pages +deploy: + cd {{root}} && uv run mkdocs gh-deploy --force -f exp/guppy-zlup/mkdocs.yml + +# Clean build artifacts +clean: + rm -rf site/ + +# Run tests +test: + cargo test + +# Build the CLI +build-cli: + cargo build --features cli --release + +# Check a Guppy file +check file: + cargo run --features cli -- check {{file}} + +# Compile a Guppy file to Zlup +compile file: + cargo run --features cli -- compile {{file}} --stdout diff --git a/exp/guppy-zlup/README.md b/exp/guppy-zlup/README.md new file mode 100644 index 000000000..c3fd7d207 --- /dev/null +++ b/exp/guppy-zlup/README.md @@ -0,0 +1,213 @@ +# guppy-zlup + +> **Note:** Zlup is an experimental toy language for exploring quantum programming +> language design concepts. This toolchain is for research and experimentation. + +A compiler toolchain for transforming Guppy quantum programs into Zlup, with +static analysis based on NASA's Power of 10 coding guidelines. + +## Overview + +guppy-zlup is a unified tool that: + +- **Validates** Guppy programs using guppylang (semantic validation) +- **Lints** against NASA Power of 10 safety rules (ZLUP001-010) +- **Emits** intermediate representation (IR) as JSON +- **Compiles** Guppy source or IR to Zlup source code +- **Verifies** generated Zlup is syntactically and semantically valid + +``` +Guppy Source (.py) + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ check │──▶ Diagnostics (errors, warnings) +└─────────────┘ + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ emit │──▶ Guppy IR (.json) +└─────────────┘ + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ compile │ +└─────────────┘ + │ + ▼ +Zlup Source (.zlp) +``` + +## Installation + +```bash +cargo install --path . --features cli +``` + +## Quick Start + +```bash +# Validate a Guppy file using guppylang (requires Python) +guppy-zlup validate program.py + +# Check a Guppy file for lint violations +guppy-zlup check program.py + +# Check with JSON output (for CI/tooling) +guppy-zlup check program.py --format json + +# Check with SARIF output (for GitHub Actions) +guppy-zlup check program.py --format sarif + +# Watch mode - re-lint on file changes +guppy-zlup check program.py --watch + +# Emit IR as JSON +guppy-zlup emit program.py -o program.json + +# Compile Guppy source directly to Zlup +guppy-zlup compile program.py -o program.zlp + +# Compile with guppylang validation first +guppy-zlup compile --validate program.py -o program.zlp + +# Or compile from existing IR JSON +guppy-zlup compile --ir program.json -o program.zlp + +# Analyze parallelism opportunities +guppy-zlup analyze program.py + +# Analyze with JSON output +guppy-zlup analyze program.py --format json + +# Compile and analyze in one step +guppy-zlup compile program.py --analyze +``` + +## Example + +Given a Guppy program `bell.py`: + +```python +def bell() -> None: + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) + m = measure(q) + result("measurements", m) +``` + +Run the toolchain: + +```bash +$ guppy-zlup check bell.py +No issues found. +All checks passed! + +$ guppy-zlup compile bell.py --stdout +fn bell() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + m := mz([2]u1) q; + result("measurements", m); + return; +} +``` + +## Lint Rules + +Based on NASA's Power of 10 coding guidelines for safety-critical systems: + +| Rule | Severity | Description | +|---------|----------|--------------------------------------| +| ZLUP001 | Error | Unbounded loops (while True) | +| ZLUP002 | Error | Recursive function calls | +| ZLUP003 | Error | Dynamic memory allocation in loops | +| ZLUP004 | Error | Dynamic dispatch (eval, getattr) | +| ZLUP005 | Warning | Unchecked error-prone operations | +| ZLUP006 | Warning | Missing type annotations | +| ZLUP007 | Warning | Excessive control flow complexity | +| ZLUP008 | Warning | Deep call nesting (>4 levels) | +| ZLUP009 | Info | Missing assertions in large functions| +| ZLUP010 | Warning | Global mutable state | + +### Suppressing Warnings + +Use `# noqa` comments to suppress specific warnings: + +```python +x = eval("expression") # noqa: ZLUP004 +``` + +## Validation Pipeline + +guppy-zlup performs multi-stage validation: + +1. **Syntax Check** - Python syntax via rustpython_parser +2. **Guppylang Validation** - Semantic validation via guppylang (optional, `--validate`) +3. **Lint Rules** - NASA Power of 10 safety checks (ZLUP001-010) +4. **IR Validation** - Schema and semantic checks on intermediate representation +5. **Transform Invariants** - Debug assertions during IR→Zlup transform +6. **Output Validation** - Generated Zlup is parsed and analyzed by Zlup's semantic analyzer + +This ensures both the input Guppy code and output Zlup code are valid. + +## Requirements + +- **Rust** 1.70+ for building +- **Python** 3.10+ with guppylang for validation (optional) + +Install guppylang for validation support: +```bash +uv pip install guppylang +# or +pip install guppylang +``` + +## Documentation + +- [docs/index.md](docs/index.md) - Full documentation index +- [docs/architecture.md](docs/architecture.md) - Pipeline design +- [docs/rules.md](docs/rules.md) - Lint rules reference +- [docs/ir-format.md](docs/ir-format.md) - IR JSON schema +- [docs/examples/](docs/examples/) - Example walkthroughs + +## Library Usage + +```rust +use guppy_zlup::{lint_source, compile, lint_and_compile, validate_ir, compile_with_roundtrip}; + +// Lint source code +let result = lint_source("def main(): pass", None); +if result.has_errors { + for diag in result.diagnostics { + println!("{}", diag); + } +} + +// Compile IR to Zlup +let zlup_source = compile(ir_json)?; + +// Full pipeline: lint + emit + validate IR + compile + validate output +let zlup_source = lint_and_compile(guppy_source, Some("example.py"))?; + +// Compile with round-trip validation (stricter) +let zlup_source = compile_with_roundtrip(ir_json)?; + +// Validate IR separately +let ir = guppy_zlup::ir::emit_ir(source, None)?; +let validation = validate_ir(&ir); +if !validation.is_valid() { + for error in &validation.errors { + println!("IR error: {}", error); + } +} +``` + +## License + +Apache-2.0 diff --git a/exp/guppy-zlup/docs/architecture.md b/exp/guppy-zlup/docs/architecture.md new file mode 100644 index 000000000..e511e0018 --- /dev/null +++ b/exp/guppy-zlup/docs/architecture.md @@ -0,0 +1,252 @@ +# Architecture + +This document describes the internal architecture of guppy-zlup. + +## Pipeline Overview + +``` +┌──────────────────────────────────────────────────────────────────────────┐ +│ guppy-zlup │ +│ │ +│ Python Source (.py) │ +│ │ │ +│ ▼ rustpython-parser │ +│ Python AST (rustpython_parser::ast) │ +│ │ │ +│ ├──────────────────────┬──────────────────────┐ │ +│ ▼ ▼ ▼ │ +│ Lint Rules (rules/*.rs) linter/lower.rs ir::emit_ir │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ Diagnostics Guppy AST Guppy IR (JSON) │ +│ (linter::ast) │ │ +│ │ │ +│ ▼ │ +│ ir::validate_ir ◄── IR Validation +│ │ │ +│ ▼ │ +│ compiler/parser.rs │ +│ │ │ +│ ▼ │ +│ compiler/transform.rs │ +│ (with invariant checks) │ +│ │ │ +│ ▼ │ +│ Zlup AST (zlup::ast) │ +│ │ │ +│ ▼ │ +│ zlup::pretty::pretty_print │ +│ │ │ +│ ▼ │ +│ Zlup Source (.zlp) │ +│ │ │ +│ ▼ │ +│ validate_zlup ◄── Output Validation +│ (parse + semantic analysis) │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +## AST Layers + +The toolchain uses multiple AST representations, each serving a specific purpose: + +### 1. Python AST (`rustpython_parser::ast`) + +The raw AST from parsing Python source code. This is a general-purpose Python +AST that includes all Python constructs, many of which aren't valid in Guppy. + +- **Source**: rustpython-parser crate +- **Used by**: `linter/lower.rs`, `ir.rs` + +### 2. Guppy AST (`linter::ast`) + +A clean, Guppy-specific AST that: +- Represents only constructs valid in Guppy +- Has first-class support for quantum operations (gates, measurements, qalloc) +- Isolates the codebase from Python parser API changes + +Key types: +```rust +pub struct Module { functions: Vec, ... } +pub struct Function { name: String, params: Vec, body: Vec, ... } +pub enum Stmt { Qalloc, Gate, Measure, For, While, If, ... } +pub enum Expr { IntLit, Name, BinOp, Call, ... } +pub enum GateKind { H, X, Y, Z, Cx, Cz, ... } +``` + +- **Source**: `src/linter/ast.rs` +- **Used by**: Lint rules + +### 3. Guppy IR (JSON) + +A serializable intermediate representation for tool interoperability: +- Can be consumed by any tool that reads JSON +- Enables caching of validated programs +- Allows alternative frontends to target the Zlup backend + +See [ir-format.md](./ir-format.md) for the full schema. + +- **Source**: `src/ir.rs` +- **Used by**: `emit` command (output), `compile --ir` command (input) + +### 4. Zlup AST (`zlup::ast`) + +The target language's AST. This is the canonical representation of Zlup +programs, used by the Zlup compiler for: +- Code generation (QASM, PHIR, HUGR) +- Optimization passes +- Semantic analysis + +- **Source**: `../zlup/src/ast.rs` +- **Used by**: `compiler/transform.rs`, Zlup pretty printer + +## Validation Layers + +The toolchain includes multiple validation layers to catch errors early and ensure correctness: + +### 1. IR Validation (`ir::validate_ir`) + +Validates the intermediate representation before transformation: +- **Schema validation**: Required fields present for each statement/expression kind +- **Semantic validation**: Variables defined before use, allocators exist before gate use +- **Gate arity**: Correct number of targets for each gate type +- **Operator validation**: Only known operators allowed + +```rust +let result = ir::validate_ir(&ir); +if !result.is_valid() { + // Handle errors +} +``` + +### 2. Transform Invariants + +Debug assertions during IR → Zlup transformation: +- Allocators must be registered before use in gates +- Variable scope tracking for assignments vs new bindings +- Consistency between qalloc_sizes and declared_vars + +These fire as panics in debug builds, catching internal bugs early. + +### 3. Output Validation (`validate_zlup`) + +Validates generated Zlup source by: +- Parsing it back through Zlup's parser +- Running Zlup's semantic analyzer (permissive mode) + +### 4. Round-Trip Validation (`validate_zlup_roundtrip`) + +Optional stricter validation: +- Compares original AST with re-parsed AST +- Verifies function counts, names, and parameter counts match +- Catches pretty-printer bugs + +## Design Decisions + +### Why a separate Guppy AST? + +1. **Parser isolation** - rustpython-parser API changes don't ripple through + the codebase +2. **Quantum-first** - Gates, measurements, and qalloc are first-class + constructs, not function calls +3. **Linting** - Easier to write lint rules against a clean AST + +### Why JSON for the IR? + +1. **Tool interop** - Other languages can emit Guppy IR +2. **Debugging** - Human-readable intermediate format +3. **Caching** - Save validated IR to disk + +### Why use Zlup's pretty printer? + +1. **Consistency** - Output matches Zlup's canonical style +2. **Maintenance** - One formatter to maintain, not two +3. **Correctness** - Zlup's formatter handles edge cases + +## Module Structure + +``` +src/ +├── lib.rs # Library entry point, public API +├── main.rs # CLI entry point +├── ir.rs # Guppy IR types and emit_ir() +├── compiler.rs # Compiler module +├── compiler/ +│ ├── parser.rs # JSON → GuppyIR +│ └── transform.rs # GuppyIR → zlup::ast::Program +├── linter.rs # Linter module +└── linter/ + ├── ast.rs # Guppy AST definitions + ├── config.rs # Configuration + ├── diagnostic.rs # Error/warning types + ├── engine.rs # Lint orchestration + ├── lower.rs # Python AST → Guppy AST + ├── rules.rs # Rules module + └── rules/ # Lint rule implementations + ├── zlup001.rs # Unbounded loops + ├── zlup002.rs # Recursion + ├── zlup003.rs # Dynamic allocation + ├── zlup004.rs # Dynamic dispatch + ├── zlup005.rs # Unchecked errors + ├── zlup006.rs # Missing types + ├── zlup007.rs # Complex control flow + ├── zlup008.rs # Deep call nesting + ├── zlup009.rs # Missing assertions + └── zlup010.rs # Global mutable state +``` + +## Key Transformations + +### Entry Function Returns + +In Guppy (following Quantinuum's design), entry/main functions must return `None`. +Values are emitted to the quantum runtime via explicit `result()` calls: + +**Guppy:** +```python +def main() -> None: + q = qubit[4] + h(q[0]) + cx(q[0], q[1]) + m = measure(q) + result("measurements", m) # Emit to runtime +``` + +**Zlup:** +```zlup +fn main() -> unit { + mut q := qalloc(4); + h q[0]; + cx (q[0], q[1]); + m := mz([4]u1) q; + result("measurements", m); + return; +} +``` + +This pattern: +- Entry functions return `None` (Guppy) / `unit` (Zlup) +- Results are explicitly tagged via `result(tag, value)` +- The quantum runtime collects all `result()` emissions + +## Extending the Toolchain + +### Adding a new lint rule + +1. Create `src/linter/rules/zlupNNN.rs` +2. Implement the `LintRule` trait +3. Register in `src/linter/rules.rs` +4. Add tests + +### Supporting a new Guppy construct + +1. Add the construct to `linter::ast` +2. Update `linter/lower.rs` to convert from Python AST +3. Update `ir.rs` to serialize/deserialize +4. Update `compiler/transform.rs` to emit Zlup + +### Alternative frontends + +Any tool can target guppy-zlup by emitting Guppy IR JSON. See +[ir-format.md](./ir-format.md) for the schema. diff --git a/exp/guppy-zlup/docs/examples/bell-state.md b/exp/guppy-zlup/docs/examples/bell-state.md new file mode 100644 index 000000000..a259ac50b --- /dev/null +++ b/exp/guppy-zlup/docs/examples/bell-state.md @@ -0,0 +1,167 @@ +# Example: Bell State + +This example walks through compiling a simple Bell state preparation circuit +from Guppy to Zlup. + +## The Guppy Program + +A Bell state is a maximally entangled two-qubit state. Here's the Guppy code: + +```python +# bell.py +def bell() -> None: + """Prepare and measure a Bell state |Φ+⟩ = (|00⟩ + |11⟩) / √2""" + q = qubit[2] # Allocate 2 qubits + h(q[0]) # Hadamard on first qubit + cx(q[0], q[1]) # CNOT: control=q[0], target=q[1] + m = measure(q) # Measure both qubits + result("measurements", m) # Emit results to runtime +``` + +## Step 1: Lint the Code + +First, check for any rule violations: + +```bash +$ guppy-zlup check bell.py +No issues found. +All checks passed! +``` + +The program passes all checks: +- ✓ No unbounded loops (ZLUP001) +- ✓ No recursion (ZLUP002) +- ✓ No dynamic allocation in loops (ZLUP003) +- ✓ No dynamic dispatch (ZLUP004) +- ✓ Return type annotated (ZLUP006) + +## Step 2: Emit IR + +Generate the intermediate representation: + +```bash +$ guppy-zlup emit bell.py -o bell.json +Wrote IR to bell.json +``` + +The generated IR: + +```json +{ + "version": "0.1.0", + "source_file": "bell.py", + "functions": [ + { + "name": "bell", + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + }, + { + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] + }, + { + "kind": "assign", + "target": {"kind": "ident", "name": "m"}, + "value": { + "kind": "call", + "callee": "measure", + "args": [{"kind": "ident", "name": "q"}] + } + }, + { + "kind": "result", + "tag": "measurements", + "value": {"kind": "ident", "name": "m"} + } + ] + } + ] +} +``` + +## Step 3: Compile to Zlup + +Transform the IR to Zlup source code: + +```bash +$ guppy-zlup compile bell.py -o bell.zlp +``` + +The generated Zlup code: + +```zlup +fn bell() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + m := mz([2]u1) q; + result("measurements", m); + return; +} +``` + +Note: Entry/main functions return `unit`. Use explicit `result(tag, value)` calls to emit outputs to the quantum runtime. + +## Understanding the Transformation + +| Guppy | Zlup | Notes | +|-----------------------------|----------------------------|------------------------------| +| `q = qubit[2]` | `mut q := qalloc(2);` | Qubit allocation | +| `h(q[0])` | `h q[0];` | Gate as statement | +| `cx(q[0], q[1])` | `cx (q[0], q[1]);` | Multi-qubit gate | +| `m = measure(q)` | `m := mz([2]u1) q;` | Measurement with result | +| `result("tag", val)` | `result("tag", val);` | Emit value to runtime | +| `-> None` | `-> unit` | Entry functions return unit | + +### Measurement Variants + +Zlup supports flexible measurement syntax: + +```zlup +// Per-qubit into array (default) +m := mz([2]u1) q; + +// Single qubit +bit := mz(u1) q[0]; + +// Pack into custom struct (for QEC syndromes, etc.) +syndrome := mz(pack Syndrome) [ancilla[0], ancilla[1], ancilla[2]]; +``` + +## The Quantum Circuit + +The Bell state circuit: + +``` +q[0] ──H──●──M── + │ +q[1] ─────X──M── +``` + +1. **H gate** puts q[0] in superposition: |0⟩ → (|0⟩ + |1⟩) / √2 +2. **CNOT** entangles the qubits: creates |Φ+⟩ = (|00⟩ + |11⟩) / √2 +3. **Measure** collapses to either |00⟩ or |11⟩ with equal probability + +## What's Next? + +The Zlup code can now be: + +- Compiled to QASM for hardware execution +- Optimized by the Zlup compiler +- Analyzed for resource usage diff --git a/exp/guppy-zlup/docs/examples/grover.md b/exp/guppy-zlup/docs/examples/grover.md new file mode 100644 index 000000000..9906df08b --- /dev/null +++ b/exp/guppy-zlup/docs/examples/grover.md @@ -0,0 +1,213 @@ +# Example: Grover's Algorithm + +This example demonstrates a more complex program with loops: a simplified +Grover's search iteration. + +## The Guppy Program + +```python +# grover.py +def grover_iteration(q: qubit[4], iterations: int) -> None: + """ + Perform Grover iterations on a 4-qubit register. + + This is a simplified version that applies: + 1. Oracle (placeholder: CZ on first two qubits) + 2. Diffusion operator (H gates + phase flip + H gates) + """ + for i in range(iterations): + # Oracle (simplified) + cz(q[0], q[1]) + + # Diffusion: H on all qubits + h(q[0]) + h(q[1]) + h(q[2]) + h(q[3]) + + # Phase flip would go here + + # Diffusion: H on all qubits again + h(q[0]) + h(q[1]) + h(q[2]) + h(q[3]) + + +def main() -> None: + """Run Grover's algorithm.""" + q = qubit[4] + + # Initialize superposition + h(q[0]) + h(q[1]) + h(q[2]) + h(q[3]) + + # Run iterations (optimal is ~π/4 * √N for N=16) + grover_iteration(q, 3) + + # Measure and emit results + m = measure(q) + result("measurements", m) +``` + +## Linting Results + +```bash +$ guppy-zlup check grover.py +No issues found. +All checks passed! +``` + +This program passes all checks: + +- ✓ **ZLUP001**: The `for` loop is bounded by `iterations` parameter +- ✓ **ZLUP002**: No recursive calls +- ✓ **ZLUP003**: No allocations inside the loop +- ✓ **ZLUP006**: All types annotated + +## What Would Fail? + +### Unbounded Loop (ZLUP001) + +```python +def bad_grover(q: qubit[4]) -> None: + while True: # ZLUP001: unbounded loop + cz(q[0], q[1]) + if converged(): + break +``` + +**Fix**: Use a bounded loop with maximum iterations. + +### Allocation in Loop (ZLUP003) + +```python +def bad_grover(iterations: int) -> None: + for i in range(iterations): + q = qubit[4] # ZLUP003: allocation in loop + h(q[0]) +``` + +**Fix**: Allocate qubits outside the loop. + +### Missing Types (ZLUP006) + +```python +def bad_grover(q, iterations): # ZLUP006: missing types + for i in range(iterations): + h(q[0]) +``` + +**Fix**: Add type annotations. + +## Generated IR + +```bash +$ guppy-zlup emit grover.py -o grover.json +``` + +Key parts of the IR for `grover_iteration`: + +```json +{ + "name": "grover_iteration", + "params": [ + {"name": "q", "type": {"kind": "qalloc", "size": {"kind": "literal", "value": 4}}}, + {"name": "iterations", "type": {"kind": "primitive", "name": "int"}} + ], + "body": [ + { + "kind": "for", + "var": "i", + "range": { + "start": {"kind": "literal", "value": 0}, + "end": {"kind": "ident", "name": "iterations"} + }, + "body": [ + {"kind": "gate", "gate": "cz", "targets": [...]}, + {"kind": "gate", "gate": "h", "targets": [...]}, + ... + ] + } + ] +} +``` + +## Generated Zlup + +```bash +$ guppy-zlup compile grover.json --stdout +``` + +```zlup +fn grover_iteration(q: &mut [4]qubit, iterations: i64) -> unit { + for 0..iterations |i| { + cz (q[0], q[1]); + h q[0]; + h q[1]; + h q[2]; + h q[3]; + h q[0]; + h q[1]; + h q[2]; + h q[3]; + } +} + +fn main() -> unit { + mut q := qalloc(4); + h q[0]; + h q[1]; + h q[2]; + h q[3]; + grover_iteration(&mut q, 3); + m := mz([4]u1) q; + result("measurements", m); + return; +} +``` + +## Key Observations + +### Loop Translation + +| Guppy | Zlup | +|--------------------------------|-------------------------| +| `for i in range(iterations):` | `for 0..iterations |i|` | +| `for i in range(0, n):` | `for 0..n |i|` | +| `for i in range(1, n, 2):` | `for 1..n:2 |i|` | + +### Type Mapping + +| Guppy | Zlup | +|----------------|-------------| +| `int` | `i64` | +| `float` | `f64` | +| `bool` | `bool` | +| `qubit[4]` | `qalloc(4)` | +| `None` | `unit` | + +## Circuit Visualization + +One Grover iteration: + +``` +q[0] ──●──H──────H── + │ +q[1] ──Z──H──────H── + +q[2] ─────H──────H── + +q[3] ─────H──────H── + │ │ + Oracle Diffusion +``` + +The full algorithm: +1. Initialize all qubits to |+⟩ (H gates) +2. Repeat O(√N) times: + - Apply oracle (marks solution) + - Apply diffusion (amplifies marked state) +3. Measure to get solution with high probability diff --git a/exp/guppy-zlup/docs/future/parallelism.md b/exp/guppy-zlup/docs/future/parallelism.md new file mode 100644 index 000000000..7a286fa69 --- /dev/null +++ b/exp/guppy-zlup/docs/future/parallelism.md @@ -0,0 +1,1205 @@ +# Parallelism in Zlup: Design Notes + +> **Note:** Zlup is an experimental toy language for exploring quantum programming +> language design. These notes are exploratory design discussions, not specifications +> for a production system. + +This document explores how Zlup's scope-aware allocator tracking could enable +automatic parallelization of quantum programs. + +## Design Philosophy: Parallelism Through Constraints + +**Zlup provides parallelism without threads, locks, or explicit parallel syntax.** + +But this isn't "implicit" parallelism where the compiler magically figures things out. +It's **constraint-based** parallelism: the type system explicitly constrains what +functions can access, and parallelism follows directly from those constraints. + +### The Core Principle + +> **Expressiveness through constraints, not features.** + +Instead of adding `@parallel` annotations or `spawn` keywords, we constrain the +language so that parallelism is **obvious and verifiable** from the code structure: + +| Constraint | What It Enables | +|------------|-----------------| +| No allocator param → can't touch qubits | Classical functions are trivially parallel with quantum | +| Allocators are owned, not aliased | Different names = different qubits = independent | +| Scopes define lifetimes | Explicit sync points without barrier syntax | +| Static allocation only | All parallelism decidable at compile time | + +This aligns with NASA Power of 10: don't add features to enable analysis—remove +possibilities until analysis is trivial. + +### Why Not Threads or Parallel Annotations? + +Threads and `@parallel` annotations solve problems Zlup doesn't have: + +| Traditional Problem | Zlup's Constraint-Based Solution | +|---------------------|----------------------------------| +| Shared mutable state | Allocators are owned, not shared | +| Data races | No aliasing - different names = different resources | +| What can parallelize? | Read the type signature | +| Where are sync points? | Scope boundaries | + +Adding explicit parallelism syntax would: + +1. **Duplicate information** already in the type system +2. **Add complexity** without enabling anything new +3. **Violate "explicit through constraints"** by adding yet another mechanism + +### What the Programmer Writes vs. What Executes + +```zlup +// Programmer writes sequential code: +h q[0]; +h q[1]; +h q[2]; +cx (q[0], q[1]); +cx (q[1], q[2]); +``` + +``` +// Compiler sees dependency graph: +[h q[0]] ──────┐ + ├──► [cx (q[0],q[1])] ──┐ +[h q[1]] ──────┘ ├──► [cx (q[1],q[2])] + │ +[h q[2]] ─────────────────────────────►┘ + +// Execution can parallelize independent operations: +Layer 1: h q[0], h q[1], h q[2] (all parallel) +Layer 2: cx (q[0], q[1]) +Layer 3: cx (q[1], q[2]) +``` + +No annotations needed. The syntax already contains all the information. + +## The Core Insight + +Zlup tracks which qubit allocators are accessible in each scope. This information, +combined with the NASA Power of 10 constraints (no dynamic allocation, bounded loops, +no recursion), enables powerful static analysis for parallelism detection. + +```zlup +fn example() -> unit { + mut q1 := qalloc(2); // Allocator q1 enters scope + mut q2 := qalloc(2); // Allocator q2 enters scope + + // These blocks operate on disjoint qubit sets + { + h q1[0]; // Only touches q1 + cx (q1[0], q1[1]); + } + { + h q2[0]; // Only touches q2 + cx (q2[0], q2[1]); + } +} +``` + +The compiler knows statically that these two blocks are independent and can execute +in parallel on hardware that supports it. + +## Dependency Analysis + +### What the Compiler Knows + +At compile time, Zlup has complete information about: + +1. **Allocator lifetimes** - When each allocator enters and exits scope +2. **Allocator sizes** - Fixed at allocation time (no dynamic sizing) +3. **Operation targets** - Which allocator(s) each gate/measurement touches +4. **Control flow structure** - Bounded loops, no recursion + +This enables construction of a precise **operation dependency graph**. + +### Dependency Rules + +Two operations are **independent** (parallelizable) if: + +1. They operate on disjoint qubit sets, AND +2. Neither reads a classical variable written by the other, AND +3. No control flow dependency exists between them + +Two operations are **dependent** (must be ordered) if: + +1. They share at least one qubit target (read-after-write, write-after-write), OR +2. One reads a classical variable written by the other (data dependency), OR +3. One is control-dependent on the other (branch/loop) + +### Example: Building the Dependency Graph + +```zlup +fn grover_iteration(mut q: [4]qubit) -> unit { + // Operation 1: Oracle (all qubits) + oracle q; + + // Operation 2-5: Diffusion operator + h q[0]; h q[1]; h q[2]; h q[3]; // Ops 2,3,4,5 - independent of each other + + x q[0]; x q[1]; x q[2]; x q[3]; // Ops 6,7,8,9 - independent of each other + + // Multi-controlled Z + mcz q; // Op 10 - depends on 6,7,8,9 + + x q[0]; x q[1]; x q[2]; x q[3]; // Ops 11,12,13,14 + h q[0]; h q[1]; h q[2]; h q[3]; // Ops 15,16,17,18 +} +``` + +Dependency graph (simplified): +``` + [1:oracle] + | + +----+----+----+ + v v v v + [2:h] [3:h] [4:h] [5:h] <- Parallel layer + | | | | + v v v v + [6:x] [7:x] [8:x] [9:x] <- Parallel layer + | | | | + +----+----+----+ + v + [10:mcz] + | + ... (continues) +``` + +## Levels of Parallelism + +### 1. Gate-Level Parallelism + +Gates on independent qubits within the same allocator can execute simultaneously: + +```zlup +mut q := qalloc(4); +h q[0]; // Can run in parallel with... +h q[1]; // ...this gate +h q[2]; // ...and this one +h q[3]; // ...and this one +``` + +This is the finest granularity. Most quantum hardware naturally supports this. + +### 2. Block-Level Parallelism + +Independent code blocks operating on disjoint allocators: + +```zlup +mut ancilla := qalloc(2); +mut data := qalloc(4); + +// These blocks could execute on different QPU regions +{ + // Prepare ancilla + h ancilla[0]; + cx (ancilla[0], ancilla[1]); +} +{ + // Prepare data register (independent) + for i in 0..4 { + h data[i]; + } +} +// Sync point: both blocks must complete before continuing +``` + +### Scopes as Implicit Barriers + +Scopes naturally provide synchronization points when you need them: + +```zlup +{ + // Region 1: everything here completes... + h q1[0]; + cx (q1[0], q1[1]); +} +// ...before this point +{ + // Region 2: can depend on Region 1's results + if some_classical_result { + ... + } +} +``` + +But if you *don't* want a barrier, just don't use a scope: + +```zlup +h q1[0]; +h q2[0]; +// No barrier - compiler free to interleave +cx (q1[0], q1[1]); +cx (q2[0], q2[1]); +// q1 and q2 operations form independent chains +``` + +This gives programmers control over synchronization *when they need it*, without +requiring explicit barrier syntax for the common case (maximum parallelism). + +### 3. Classical-Quantum Parallelism + +Classical computation can proceed while quantum operations execute, as long as +no data dependencies exist: + +```zlup +mut q := qalloc(4); +classical_prep := expensive_classical_computation(); // Could overlap with qalloc + +h q[0]; +cx (q[0], q[1]); + +// Measurement creates a sync point +m := mz([4]u1) q; + +// Classical post-processing can start immediately +processed := process_results(m); +``` + +### 4. Inter-Function Parallelism + +With whole-program analysis, independent function calls could parallelize: + +```zlup +fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + + // These function calls are independent + prepare_bell(q1); // Only touches q1 + prepare_ghz(q2); // Only touches q2 +} +``` + +## Measurements and Sync Points + +Measurements introduce **implicit synchronization** because: + +1. The quantum state must be fully evolved before measurement +2. Classical variables holding results must be available before use + +```zlup +mut q := qalloc(2); +h q[0]; +cx (q[0], q[1]); + +// SYNC POINT: All operations on q must complete +m := mz([2]u1) q; + +// Classical code can now use m +if m[0] == 1 { + // Conditional logic based on measurement +} +``` + +### Mid-Circuit Measurement Considerations + +Mid-circuit measurement creates **partial sync points**: + +```zlup +mut q := qalloc(4); +h q[0]; h q[1]; h q[2]; h q[3]; + +// Only q[0] needs to sync here +m0 := mz([1]u1) q[0..1]; + +// These can continue in parallel with processing m0 +h q[1]; h q[2]; h q[3]; + +// Classical processing of m0 can happen concurrently +if m0[0] == 1 { + // This branch doesn't touch q[1..4] + log("measured 1"); +} +``` + +## QEC and Classical Processing + +Quantum Error Correction (QEC) is a critical use case that stress-tests the ownership +model. QEC involves tight classical-quantum interaction with real-time constraints. + +### The QEC Data Flow + +```zlup +fn qec_round(mut data: [n]qubit, mut ancilla: [m]qubit) -> unit { + // 1. Syndrome extraction (quantum operations) + extract_stabilizers(data, ancilla); + + // 2. Measure ancilla → classical syndrome + syndrome := mz([m]u1) ancilla; + + // 3. Decode (pure classical computation) + corrections := decode(syndrome); + + // 4. Apply corrections (quantum operations) + apply_corrections(data, corrections); + + // 5. Reset ancilla for next round + reset ancilla; +} +``` + +### What Works Well + +The ownership model handles QEC's core patterns naturally: + +| QEC Pattern | How Ownership Helps | +|-------------|---------------------| +| Syndrome arrays | Values after measurement - no aliasing, pass freely | +| Decoder parallelism | Pure functions on values - multiple decoders can run in parallel | +| Clear data flow | Explicit: qubits → measurement → classical → corrections → qubits | +| No race conditions | Syndrome is a value, not shared mutable state | + +```zlup +// Parallel decoders work naturally - syndrome is a value +fn fault_tolerant_decode(syndrome: [m]u1) -> Corrections { + c1 := decoder_mwpm(syndrome); // These three calls + c2 := decoder_uf(syndrome); // are independent + c3 := decoder_nn(syndrome); // (same input value) + return vote(c1, c2, c3); +} +``` + +### Challenging Patterns + +Some QEC patterns require more thought: + +#### Syndrome History (Sliding Window Decoders) + +MWPM, Union-Find, and other decoders often need multiple rounds of syndrome data: + +```zlup +// Option A: Pass history as a value (copied) +fn decode_with_history( + current: [m]u1, + history: [[m]u1; k] // Copied - potentially expensive +) -> Corrections { ... } + +// Option B: Borrow history (reference) +fn decode_with_history( + current: [m]u1, + history: &[[m]u1; k] // Borrowed - no copy, but adds reference semantics +) -> Corrections { ... } + +// Option C: Decoder owns accumulating state +struct Decoder { + history: [[m]u1; k], + // ... decoder state +} +fn decode(self: &mut Decoder, current: [m]u1) -> Corrections { + self.history.push(current); + // ... decode using history +} +``` + +#### Pipelined QEC + +Real QEC systems pipeline: decode round N while extracting round N+1. + +```zlup +fn pipelined_qec(mut data: [n]qubit, mut ancilla: [m]qubit) { + syndrome_prev := extract_and_measure(data, ancilla); + + for round in 1..num_rounds { + // GOAL: These should overlap: + // - decode(syndrome_prev) → pure classical + // - extract_stabilizers(...) → quantum on ancilla + + // But in sequential code, how does the compiler know? + extract_stabilizers(data, ancilla); // Quantum + corrections := decode(syndrome_prev); // Classical + syndrome_curr := mz([m]u1) ancilla; + apply_corrections(data, corrections); + syndrome_prev = syndrome_curr; + } +} +``` + +### Approaches to Classical-Quantum Overlap + +Three approaches for expressing that classical and quantum operations can overlap: + +#### Approach A: Implicit Inference + +The compiler analyzes data flow and infers what can overlap. + +**Problem:** This is implicit, not explicit. The programmer writes code without knowing +what will parallelize. This conflicts with Zlup's "explicit over implicit" philosophy. + +#### Approach B: Explicit Annotations + +Add syntax like `@parallel` or `overlap { }` blocks. + +**Problem:** This adds complexity. The programmer must learn new syntax and manually +annotate parallelism that the type system already expresses. + +#### Approach C: Structural Separation + +Separate classical and quantum into distinct "lanes" with explicit channels. + +**Problem:** Major language addition. Overkill for simple cases. Introduces concurrency +primitives (channels, send/recv) that we're trying to avoid. + +### Our Recommendation: Constraint-Based (No New Syntax) + +None of the above. Instead, recognize that **the type system already expresses parallelism**. + +The key insight, aligned with NASA Power of 10 Rule 6 (minimize scope): + +> **If a function doesn't take allocator parameters, it cannot touch qubits.** +> This is explicit, enforced by the compiler, and requires no annotation. + +```zlup +// EXPLICIT: "I take classical values, I return classical values, I cannot touch qubits" +fn decode(syndrome: [m]u1) -> Corrections { ... } + +// EXPLICIT: "I require mutable qubit access" +fn extract_stabilizers(mut data: [n]qubit, mut ancilla: [m]qubit) -> unit { ... } +``` + +The function signature IS the parallelism declaration: + +| Signature | What It Says | Parallelizable With | +|-----------|--------------|---------------------| +| `fn f(x: int) -> int` | Pure classical | Any quantum op | +| `fn f(syndrome: [m]u1) -> Corrections` | Classical only | Any quantum op | +| `fn f(mut q: [n]qubit) -> unit` | Needs qubit access | Only disjoint allocators | +| `fn f(mut q1: [n]qubit, mut q2: [m]qubit)` | Needs both | Nothing touching q1 or q2 | + +This is: +- **Explicit**: The constraint is visible in the type signature +- **Simple**: No new syntax, no annotations +- **Constraint-based**: Expressiveness through what you CAN'T do + +### Why This Aligns with NASA Power of 10 + +| Power of 10 Rule | How It Applies | +|------------------|----------------| +| Rule 3: No dynamic allocation | Allocators are static → known at compile time | +| Rule 6: Minimize scope | Narrow allocator scope → clear parallelism boundaries | +| Rule 9: Limit pointer use | No aliasing → if `q1` and `q2` are different names, they're different allocators | + +The Power of 10 philosophy: **Don't add features to enable analysis. Remove possibilities until analysis is trivial.** + +We don't add `@parallel`. We constrain functions so parallelism is obvious: +- No allocator param? Can't touch qubits. Done. +- Different allocator params? Different qubits. Done. + +### Scopes as Explicit Sync Points + +If you need ordering, use a scope: + +```zlup +{ + // Everything in this scope completes... + extract_stabilizers(data, ancilla); + syndrome := mz([m]u1) ancilla; +} +// ...before this point + +corrections := decode(syndrome); // Can overlap with next scope + +{ + apply_corrections(data, corrections); +} +``` + +Scopes are explicit barriers. No scope = no enforced ordering. This is already in the language. + +### Why This Works for QEC + +The constraint-based model is perfect for QEC because: + +1. **Decoders are pure classical by construction** + +```zlup +fn decode(syndrome: [m]u1) -> Corrections { + // No allocator parameters → CANNOT touch qubits + // This isn't a promise - it's enforced by the type system +} +``` + +2. **The classical/quantum boundary is explicit and verified** + +```zlup +fn qec_round(mut data: [n]qubit, mut ancilla: [m]qubit) -> unit { + extract_stabilizers(data, ancilla); // Signature says: needs qubits + syndrome := mz([m]u1) ancilla; // Measurement: quantum → classical + corrections := decode(syndrome); // Signature says: classical only + apply_corrections(data, corrections); // Signature says: needs qubits +} +``` + +3. **Parallelism is obvious from signatures** + +Looking at just the types: +- `decode: [m]u1 -> Corrections` — classical, parallelizable with any quantum +- `extract_stabilizers: (mut [n]qubit, mut [m]qubit) -> unit` — needs both allocators + +No analysis needed. No annotations. The constraints tell you everything. + +4. **NASA Power of 10 compliance comes free** + +The same constraints that enable parallelism also enforce safety: +- Static allocators (Rule 3) +- Minimal scope (Rule 6) +- No aliasing (Rule 9) + +## Realistic QEC Examples + +Let's work through concrete QEC scenarios to verify the constraint-based model works. + +### Example 1: Repetition Code (Simplest Case) + +A 3-qubit repetition code with 2 syndrome qubits: + +```zlup +// Data qubits: |ψ⟩ encoded as |ψψψ⟩ +// Syndrome qubits: measure ZZ stabilizers + +fn repetition_round(mut data: [3]qubit, mut syndrome: [2]qubit) -> [2]u1 { + // Syndrome extraction: ZZ stabilizers + cx (data[0], syndrome[0]); + cx (data[1], syndrome[0]); + cx (data[1], syndrome[1]); + cx (data[2], syndrome[1]); + + // Measure syndrome qubits + s := mz([2]u1) syndrome; + + // Reset for next round + reset syndrome; + + return s; +} + +// Decoder: pure classical, no allocator params +fn decode_repetition(s: [2]u1) -> [3]u1 { + // Simple majority voting + // s[0] = data[0] ⊕ data[1] + // s[1] = data[1] ⊕ data[2] + mut corrections := [0, 0, 0]; + if s[0] == 1 && s[1] == 0 { + corrections[0] = 1; // Error on qubit 0 + } else if s[0] == 1 && s[1] == 1 { + corrections[1] = 1; // Error on qubit 1 + } else if s[0] == 0 && s[1] == 1 { + corrections[2] = 1; // Error on qubit 2 + } + return corrections; +} + +fn apply_corrections(mut data: [3]qubit, corrections: [3]u1) -> unit { + for i in 0..3 { + if corrections[i] == 1 { + x data[i]; + } + } +} + +fn qec_cycle(mut data: [3]qubit, mut syndrome: [2]qubit) -> unit { + s := repetition_round(data, syndrome); // Quantum: touches data, syndrome + corrections := decode_repetition(s); // Classical: no allocators + apply_corrections(data, corrections); // Quantum: touches data +} +``` + +**Parallelism analysis from signatures:** +- `repetition_round`: needs `data` and `syndrome` → serialized with anything touching those +- `decode_repetition`: takes `[2]u1`, returns `[3]u1` → pure classical → can overlap +- `apply_corrections`: needs `data` → must wait for decode, serialized with data ops + +### Example 2: Surface Code Patch + +A distance-3 surface code with X and Z stabilizers: + +```zlup +// Surface code layout (distance 3): +// D0 -- Z0 -- D1 +// | | +// X0 D4 X1 +// | | +// D2 -- Z1 -- D3 + +struct SurfaceCodePatch { + data: [5]qubit, // 5 data qubits + x_ancilla: [2]qubit, // 2 X stabilizer ancillas + z_ancilla: [2]qubit, // 2 Z stabilizer ancillas +} + +fn extract_x_stabilizers( + mut data: [5]qubit, + mut x_ancilla: [2]qubit +) -> unit { + // X0 stabilizer: X on D0, D2, D4 + h x_ancilla[0]; + cx (x_ancilla[0], data[0]); + cx (x_ancilla[0], data[2]); + cx (x_ancilla[0], data[4]); + h x_ancilla[0]; + + // X1 stabilizer: X on D1, D3, D4 + h x_ancilla[1]; + cx (x_ancilla[1], data[1]); + cx (x_ancilla[1], data[3]); + cx (x_ancilla[1], data[4]); + h x_ancilla[1]; +} + +fn extract_z_stabilizers( + mut data: [5]qubit, + mut z_ancilla: [2]qubit +) -> unit { + // Z0 stabilizer: Z on D0, D1, D4 + cx (data[0], z_ancilla[0]); + cx (data[1], z_ancilla[0]); + cx (data[4], z_ancilla[0]); + + // Z1 stabilizer: Z on D2, D3, D4 + cx (data[2], z_ancilla[1]); + cx (data[3], z_ancilla[1]); + cx (data[4], z_ancilla[1]); +} + +fn surface_code_round(mut patch: SurfaceCodePatch) -> SurfaceSyndrome { + // X and Z extraction both need data qubits + // But they use different ancilla allocators + extract_x_stabilizers(patch.data, patch.x_ancilla); + extract_z_stabilizers(patch.data, patch.z_ancilla); + + // Measure all ancillas + x_syndrome := mz([2]u1) patch.x_ancilla; + z_syndrome := mz([2]u1) patch.z_ancilla; + + reset patch.x_ancilla; + reset patch.z_ancilla; + + return SurfaceSyndrome { x: x_syndrome, z: z_syndrome }; +} +``` + +**Key observation:** X and Z stabilizer extraction both touch `data`, so they serialize. +But within each extraction, gates on different ancilla qubits can parallelize. + +### Example 3: Pipelined QEC with Decoder + +The real challenge: overlapping decode(round N) with extract(round N+1): + +```zlup +fn pipelined_qec( + mut data: [5]qubit, + mut ancilla: [4]qubit, + num_rounds: int +) -> unit { + // First round: no previous syndrome to decode + syndrome_prev := extract_and_measure(data, ancilla); + + for round in 1..num_rounds { + // KEY INSIGHT: These have different signatures + // + // extract_and_measure: (mut [5]qubit, mut [4]qubit) -> Syndrome + // ^^^^^^^^^^^^ ^^^^^^^^^^^^^ + // needs qubit allocators + // + // decode: (Syndrome, SyndromeHistory) -> Corrections + // ^^^^^^^^^^^^^^^^^^^^^^^^^ + // pure classical values, NO allocators + + // The compiler sees: + // - extract touches {data, ancilla} + // - decode touches {} (no allocators) + // Therefore: decode can overlap with extract + + { + // Scope groups the quantum operations + extract_stabilizers(data, ancilla); + syndrome_curr := mz([4]u1) ancilla; + reset ancilla; + } + + // decode runs on previous syndrome - can overlap with scope above + corrections := decode_surface(syndrome_prev); + + // apply must wait for both decode AND current extraction + apply_corrections(data, corrections); + + syndrome_prev = syndrome_curr; + } + + // Final decode and correct + corrections := decode_surface(syndrome_prev); + apply_corrections(data, corrections); +} +``` + +**The constraint-based parallelism:** + +``` +Timeline: + Round 1 | Round 2 | + extract(data,ancilla) | extract(data,ancilla) | + | | | | + v | v | + syndrome_1 | syndrome_2 | + | | | | + +---- decode(s0) -+ +--- decode(s1) + + | | + v v + corrections_1 corrections_2 + | | + apply(data, c1) apply(data, c2) +``` + +Decode overlaps with extract because their signatures prove they're independent. + +### Example 4: Multi-Patch Logical Operations + +Multiple surface code patches with independent QEC: + +```zlup +struct LogicalQubit { + patch: SurfaceCodePatch, + // ... other metadata +} + +fn parallel_qec_rounds( + mut logical_qubits: [n]LogicalQubit +) -> unit { + // Each logical qubit has its own allocators + // They're completely independent + + for i in 0..n { + // These iterations touch disjoint allocators + // Compiler can parallelize across logical qubits + qec_round(logical_qubits[i].patch); + } +} + +fn transversal_cnot( + mut control: LogicalQubit, + mut target: LogicalQubit +) -> unit { + // Transversal gate: independent physical CNOTs + for i in 0..5 { + // Each CNOT is on different physical qubits + // within the same logical operation + cx (control.patch.data[i], target.patch.data[i]); + } +} +``` + +**Parallelism from structure:** +- Different `LogicalQubit` instances have different allocators → independent +- Loop iterations on independent allocators → parallelizable +- Within transversal gate: different qubit indices → parallelizable + +### Example 5: Decoder with Syndrome History + +Realistic decoders need history. How does ownership handle this? + +```zlup +// Option A: Decoder owns its history (stateful decoder) +struct MWPMDecoder { + history: [[4]u1; window_size], + window_pos: int, + // ... matching graph, etc. +} + +impl MWPMDecoder { + // Decoder is a classical object with classical methods + // No allocator params anywhere → all classical + fn decode(self: &mut MWPMDecoder, current: [4]u1) -> Corrections { + // Add to history + self.history[self.window_pos] = current; + self.window_pos = (self.window_pos + 1) % window_size; + + // Run MWPM on history window + // ... matching algorithm ... + + return corrections; + } +} + +fn qec_with_stateful_decoder( + mut data: [5]qubit, + mut ancilla: [4]qubit, + decoder: &mut MWPMDecoder, // Classical reference, no qubits + num_rounds: int +) -> unit { + for round in 0..num_rounds { + syndrome := extract_and_measure(data, ancilla); + + // decoder.decode takes classical values only + // Signature: (&mut MWPMDecoder, [4]u1) -> Corrections + // No qubit allocators → can overlap with quantum + corrections := decoder.decode(syndrome); + + apply_corrections(data, corrections); + } +} +``` + +**Key insight:** The decoder struct contains classical data only. Its methods +take `&mut self` (classical reference) and classical values. No allocator +parameters → provably doesn't touch qubits → safe to overlap. + +### What These Examples Demonstrate + +1. **Simple cases work naturally** — repetition code shows basic pattern +2. **Complex extraction parallelizes** — within stabilizer extraction, independent gates parallelize +3. **Pipelining works** — decode/extract overlap follows from signatures +4. **Multi-patch parallelizes** — different logical qubits are independent +5. **Stateful decoders work** — classical state doesn't affect qubit access + +All parallelism follows from reading the type signatures. No annotations needed. + +## Compiler Passes for Parallelism + +### Pass 1: Allocator Scope Analysis + +Build a map of allocator lifetimes and which scopes can access them: + +``` +allocator "q1": defined at line 5, scope depth 1, size 4 + accessible in: main (lines 5-50), helper (lines 20-30) +allocator "q2": defined at line 10, scope depth 1, size 2 + accessible in: main (lines 10-50) +``` + +### Pass 2: Operation Tagging + +Tag each operation with the allocators it touches: + +``` +h q1[0] -> touches: {q1} +cx (q1[0], q2[0]) -> touches: {q1, q2} +m := mz q1 -> touches: {q1}, defines: {m} +``` + +### Pass 3: Dependency Graph Construction + +Build edges between dependent operations: + +```rust +struct DepGraph { + nodes: Vec, + edges: Vec<(OpId, OpId, DepKind)>, // (from, to, kind) +} + +enum DepKind { + QubitDep(String), // Shared qubit allocator + DataDep(String), // Shared classical variable + ControlDep, // Control flow +} +``` + +### Pass 4: Parallelism Detection + +Find maximal independent sets (parallel layers): + +```rust +fn find_parallel_layers(graph: &DepGraph) -> Vec> { + // Topological sort with level assignment + // Operations at the same level are parallelizable +} +``` + +### Pass 5: Schedule Generation + +Generate a schedule respecting dependencies while maximizing parallelism: + +``` +Layer 0: [qalloc q1, qalloc q2] +Layer 1: [h q1[0], h q2[0]] <- parallel +Layer 2: [cx (q1[0],q1[1]), cx (q2[0],q2[1])] <- parallel +Layer 3: [mz q1] <- sync point for q1 +Layer 4: [mz q2, classical_op(m1)] <- q2 measure + classical parallel +``` + +## Hardware Considerations + +### CAN vs SHOULD Parallelize + +The constraint-based model tells us what **CAN** parallelize. But hardware +constraints determine what **SHOULD** parallelize. + +``` +Constraints (language level): Scheduling (compiler level): +───────────────────────────── ──────────────────────────── +"These operations are "Given hardware limits, + independent" what's the best schedule?" + │ │ + ▼ ▼ +Type signatures Architecture + Noise model +Allocator ownership Control system parallelism +Scope boundaries Decoherence times +``` + +The language expresses independence. The compiler decides how to exploit it. + +### Limited Parallel Control + +Some hardware architectures have constrained parallelism due to: + +- Limited control resources (only N operations simultaneously) +- Shared control channels across qubit subsets +- Control electronics bottlenecks + +In these cases, **serializing a QEC gadget may be better** than spreading +resources across parallel operations: + +``` +Option A: Parallelize across patches (spread resources) +──────────────────────────────────────────────────────── +Patch 1: ░░░ extract ░░░░░░░░░░░░░░░░░░░░ +Patch 2: ░░░ extract ░░░░░░░░░░░░░░░░░░░░ + ^^^ + Shared control resources → both run slow + +Option B: Serialize patches (concentrate resources) +──────────────────────────────────────────────────────── +Patch 1: ▓▓ extract ▓▓ +Patch 2: ▓▓ extract ▓▓ + ^^^ + Full resources → each runs fast +``` + +For QEC with decoherence pressure, Option B may win: finish each syndrome +extraction quickly rather than have both running slowly. + +### What the Language Expresses vs What the Compiler Decides + +| Concern | Language (Constraints) | Compiler (Scheduling) | +|---------|------------------------|----------------------| +| Independence | Type signatures | — | +| Ordering requirements | Scope boundaries | — | +| Hardware parallelism limits | — | Architecture model | +| Decoherence optimization | — | Noise model | +| Resource allocation | — | Control system model | + +The language should express **what's possible**. The compiler should decide +**what's optimal** given the target hardware. + +### Philosophy: Start Simple, Add Knobs If Needed + +Our approach: + +1. **Language stays simple** — constraints express independence, nothing more +2. **Compiler has architecture knowledge** — scheduling decisions are backend-specific +3. **No premature optimization knobs** — don't add hints until we know we need them + +If the compiler has enough information (architecture + noise model), it should +make good scheduling decisions without programmer hints. If not, we can add +optional annotations later: + +```zlup +// Hypothetical future annotation (only if needed): +@schedule_hint(serialize) // "Run this gadget fast, don't spread resources" +fn syndrome_extraction(mut data: [n]qubit, mut ancilla: [m]qubit) -> [m]u1 { + // ... +} +``` + +But we should try to avoid this. The constraint-based model gives the compiler +freedom to schedule optimally. Adding hints constrains that freedom and burdens +the programmer. + +**Principle:** Let the compiler be smart. Add knobs only when proven necessary. + +### Connectivity Constraints + +Not all qubit pairs can interact directly. The compiler must: + +1. Respect hardware topology (coupling map) +2. Insert SWAP gates for non-adjacent interactions +3. Re-analyze parallelism after routing + +``` +Logical: cx (q[0], q[3]) <- May not be directly connected +Physical: swap q[1], q[2]; cx (q[0], q[1]); swap q[1], q[2] +``` + +### Execution Zones + +Some architectures have independent execution zones: + +``` +Zone A: qubits 0-15 +Zone B: qubits 16-31 + +Operations in different zones are naturally parallel. +Allocator assignment can optimize for zone boundaries. +``` + +### Timing Constraints + +Real hardware has gate timing constraints: + +- Different gates take different times +- Idle qubits may decohere +- Some parallelism is limited by control electronics + +The scheduler must balance parallelism against timing. + +## Connection to NASA Power of 10 + +The Power of 10 rules enable parallelism analysis: + +| Rule | How It Enables Parallelism | +|------|---------------------------| +| No unbounded loops | Loop iterations are finite → can unroll for analysis | +| No recursion | Call graph is a DAG → simpler interprocedural analysis | +| No dynamic allocation | All allocators known at compile time | +| Static dispatch | No runtime polymorphism to complicate analysis | +| Bounded complexity | Functions small enough for precise analysis | + +Without these constraints, parallelism analysis would require: +- Runtime profiling +- Conservative assumptions +- Complex pointer/alias analysis + +With them, we can do everything statically. + +## Design Decisions (Resolved) + +### Do we need explicit parallelism syntax? + +**No.** Parallelism is expressed through constraints already in the type system: + +- Function signature shows allocator access → shows what qubits it touches +- No allocator param = pure classical = parallel with any quantum op +- Different allocator params = different qubits = independent + +Adding `@parallel` would duplicate information already in the types. + +### Do we need explicit barriers? + +**No.** Scopes are explicit barriers: + +```zlup +{ + // This scope is a barrier + do_stuff(); +} +// Everything above completes before here +``` + +No scope = no enforced ordering = maximum parallelism. + +This is "explicit through constraints": the presence or absence of a scope +explicitly controls synchronization. No new syntax needed. + +### Is this "implicit" parallelism? + +**No.** It's constraint-based parallelism. The parallelism isn't hidden or +inferred—it's directly visible in the type signatures and scope structure. + +```zlup +fn decode(s: [m]u1) -> Corrections // ← This signature EXPLICITLY says "classical only" +``` + +The compiler doesn't guess. It reads the constraints you wrote. + +## Open Questions + +1. **Compiler architecture knowledge**: What information does the compiler need + about the target hardware? (Parallelism limits, noise model, connectivity) + +2. **Scheduling heuristics**: For constrained hardware, when should the compiler + serialize vs parallelize? (Decoherence vs. resource contention trade-off) + +3. **Do we need scheduling hints?**: Can the compiler make good decisions with + just architecture knowledge, or will we need programmer hints? (Start without, + add if proven necessary) + +4. **Allocator placement**: How should allocators be assigned to physical + qubits to maximize parallelism while respecting connectivity? + +5. **Syndrome history management**: What's the best pattern for decoders that + need sliding windows? (Value copy vs. borrowing vs. stateful decoder) + +6. **Real-time verification**: Should we add optional annotations to verify + timing constraints? (e.g., decoder must complete before next extraction) + +7. **Cross-backend portability**: If we add scheduling hints, can they be + portable across architectures or must they be backend-specific? + +## Future Work + +- [x] Implement allocator scope tracking in Zlup semantic analysis + - Done: `zlup::analysis::AllocatorAnalysis` +- [x] Add dependency graph construction pass + - Done: `zlup::analysis::DependencyGraph` +- [x] Prototype parallel layer extraction + - Done: `zlup::analysis::DependencyGraph::parallel_layers()` +- [x] Integrate analysis into compilation pipeline (CLI flags) + - Done: `zlup analyze` and `guppy-zlup analyze` commands + - Done: `--analyze` flag on compile commands +- [ ] Benchmark on representative quantum algorithms +- [ ] Explore integration with PECOS simulator for validation + +## Why This Works for Quantum + +Implicit parallelism is particularly well-suited for quantum computing: + +1. **Quantum operations are naturally pure** - A gate transforms its target qubits + and nothing else. No hidden state, no side effects. + +2. **Qubit identity is explicit** - Unlike classical memory (where pointers can alias), + qubits are always explicitly named. `q[0]` is unambiguously qubit 0 of allocator q. + +3. **Independence is common** - Quantum algorithms frequently apply the same operation + to many qubits (Hadamard on all qubits, etc.). These are trivially parallel. + +4. **Hardware wants parallelism** - Quantum hardware can naturally execute independent + gates simultaneously. The programming model should expose this, not hide it. + +5. **Coherence time pressure** - Faster execution = less decoherence. The compiler + should maximize parallelism automatically, not rely on programmer hints. + +## Summary + +Zlup is an experimental language exploring constraint-based parallelism: + +- **No threads** — ownership model makes them unnecessary +- **No locks** — no shared mutable state to protect +- **No `@parallel`** — type signatures already express parallelizability +- **No barrier syntax** — scopes are explicit sync points + +The key insight: **the function signature IS the parallelism declaration.** + +```zlup +fn decode(syndrome: [m]u1) -> Corrections // No allocators → classical only → parallel with any quantum +fn extract(mut q: [n]qubit) -> unit // Has allocator → needs qubit access → serialized with same allocator +``` + +This is: +- **Explicit** — constraints are visible in the type signature +- **Simple** — no new syntax to learn +- **Verifiable** — compiler enforces constraints, parallelism follows + +**For QEC specifically:** + +- Decoder signatures prove they can't touch qubits → safe overlap with extraction +- Classical/quantum boundary is explicit and compiler-verified +- Pipelining emerges from constraints, no concurrency primitives needed + +**Separation of concerns:** + +- **Language** (constraints) → expresses what CAN parallelize +- **Compiler** (scheduling) → decides what SHOULD parallelize given hardware + +The language stays simple. Architecture-specific optimization lives in the compiler. + +**Aligned with NASA Power of 10:** + +Don't add features to enable analysis. Remove possibilities until analysis is trivial. +The same constraints that make Zlup safe make parallelism obvious. + +## References + +- NASA Power of 10 Rules: https://spinroot.com/gerard/pdf/P10.pdf +- Quantum circuit scheduling: [various papers on ASAP/ALAP scheduling] +- QISKIT transpiler passes: routing, optimization, scheduling diff --git a/exp/guppy-zlup/docs/index.md b/exp/guppy-zlup/docs/index.md new file mode 100644 index 000000000..93570784a --- /dev/null +++ b/exp/guppy-zlup/docs/index.md @@ -0,0 +1,183 @@ +# guppy-zlup + +> **Note:** Zlup is an experimental toy language for exploring quantum programming +> language design concepts. This toolchain is for research and experimentation. + +A compiler toolchain for transforming Guppy quantum programs into Zlup, with +static analysis based on NASA's Power of 10 coding guidelines. + +## Overview + +guppy-zlup is a unified tool that: + +- **Validates** Guppy programs against safety rules (ZLUP001-010) +- **Analyzes** parallelism opportunities in compiled Zlup code +- **Emits** intermediate representation (IR) as JSON +- **Compiles** Guppy source or IR to Zlup source code + +``` +Guppy Source (.py) + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ check │──▶ Diagnostics (errors, warnings) +└─────────────┘ + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ emit │──▶ Guppy IR (.json) +└─────────────┘ + │ + ▼ +┌─────────────┐ +│ guppy-zlup │ +│ compile │ +└─────────────┘ + │ + ▼ +Zlup Source (.zlp) +``` + +## Quick Start + +### Installation + +```bash +cd exp/guppy-zlup +cargo build --features cli +``` + +### Basic Usage + +```bash +# Check a Guppy file for violations +guppy-zlup check program.py + +# Emit IR as JSON +guppy-zlup emit program.py -o program.json + +# Compile Guppy source directly to Zlup (lint + emit + compile) +guppy-zlup compile program.py -o program.zlp + +# Or compile from existing IR JSON +guppy-zlup compile --ir program.json -o program.zlp +``` + +### Example + +Given a Guppy program `bell.py`: + +```python +def bell() -> None: + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) + m = measure(q) + result("measurements", m) +``` + +Run the toolchain: + +```bash +$ guppy-zlup check bell.py +No issues found. +All checks passed! + +$ guppy-zlup compile bell.py --stdout +fn bell() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + m := mz([2]u1) q; + result("measurements", m); + return; +} +``` + +## CLI Reference + +### `guppy-zlup check` + +Validate a Guppy file against lint rules. + +```bash +guppy-zlup check ... [OPTIONS] + +Options: + -W, --warnings-as-errors Treat warnings as errors + --config Path to config file (pyproject.toml) + -D, --disable Disable specific rules (can be repeated) + --max-complexity Maximum complexity for ZLUP007 + -f, --format Output format: text (default), json, or sarif + -w, --watch Watch for file changes and re-lint +``` + +### `guppy-zlup emit` + +Emit validated IR as JSON. + +```bash +guppy-zlup emit [OPTIONS] + +Options: + -o, --output Output file (default: ir.json) + --skip-lint Skip lint check + --stdout Print to stdout instead of file +``` + +### `guppy-zlup compile` + +Compile Guppy source or IR to Zlup. + +```bash +guppy-zlup compile [OPTIONS] + +Options: + -o, --output Output file (default: .zlp) + --stdout Print to stdout instead of file + --ir Input is IR JSON (skip linting) + --validate Validate with guppylang before compiling (requires Python) + --analyze Run parallelism analysis after compilation +``` + +### `guppy-zlup analyze` + +Analyze parallelism opportunities in generated Zlup code. + +```bash +guppy-zlup analyze [OPTIONS] + +Options: + --ir Input is IR JSON (skip linting) + -f, --format Output format: text (default) or json + -v, --verbose Show detailed dependency information +``` + +## Documentation + +- [Architecture](./architecture.md) - Pipeline design, AST layers, internals +- [Lint Rules](./rules.md) - ZLUP001-010 reference with examples +- [IR Format](./ir-format.md) - Guppy IR JSON schema +- [Examples](./examples/bell-state.md) - Walkthrough of common patterns + +### Future / Design Notes + +- [Parallelism](./future/parallelism.md) - Scope-aware parallelism analysis + +## Why These Rules? + +Quantum programs run on hardware with strict constraints: + +1. **Bounded execution** - No infinite loops; hardware has finite coherence time +2. **Static resource allocation** - Qubit counts must be known at compile time +3. **Predictable control flow** - Dynamic dispatch complicates timing analysis +4. **Type safety** - Quantum operations require precise type information + +The lint rules (ZLUP001-010) enforce these constraints at the source level, +catching violations before they reach the quantum hardware. + +## License + +Apache-2.0 diff --git a/exp/guppy-zlup/docs/ir-format.md b/exp/guppy-zlup/docs/ir-format.md new file mode 100644 index 000000000..596f52520 --- /dev/null +++ b/exp/guppy-zlup/docs/ir-format.md @@ -0,0 +1,414 @@ +# Guppy IR Format + +The Guppy IR (Intermediate Representation) is a JSON format that represents +validated Guppy programs. It enables tool interoperability and caching of +validated programs. + +## Schema Overview + +```json +{ + "version": "0.1.0", + "source_file": "path/to/source.py", + "functions": [ + { + "name": "function_name", + "params": [...], + "return_type": {...}, + "body": [...], + "location": {...} + } + ] +} +``` + +## Top-Level Fields + +| Field | Type | Required | Description | +|--------------|------------|----------|---------------------------------| +| `version` | string | Yes | IR schema version ("0.1.0") | +| `source_file`| string | No | Original source file path | +| `functions` | Function[] | Yes | List of function definitions | + +## Function + +```json +{ + "name": "bell_state", + "params": [ + {"name": "n", "type": {"kind": "primitive", "name": "int"}} + ], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [...], + "is_pub": true, + "location": {"line": 1, "column": 1, "end_line": 10, "end_column": 1} +} +``` + +| Field | Type | Required | Description | +|---------------|------------|----------|--------------------------------| +| `name` | string | Yes | Function name | +| `params` | Param[] | No | Function parameters | +| `return_type` | TypeExpr | No | Return type annotation | +| `body` | Stmt[] | Yes | Function body statements | +| `is_pub` | boolean | No | Whether function is public | +| `location` | Location | No | Source location | + +## Parameter + +```json +{"name": "q", "type": {"kind": "qalloc", "size": {"kind": "literal", "value": 4}}} +``` + +| Field | Type | Required | Description | +|--------|----------|----------|----------------------| +| `name` | string | Yes | Parameter name | +| `type` | TypeExpr | No | Type annotation | + +## Type Expressions + +### Primitive Types + +```json +{"kind": "primitive", "name": "int"} +{"kind": "primitive", "name": "float"} +{"kind": "primitive", "name": "bool"} +{"kind": "primitive", "name": "str"} +{"kind": "primitive", "name": "None"} +``` + +### Qubit Allocation Type + +```json +{"kind": "qalloc", "size": {"kind": "literal", "value": 4}} +{"kind": "qalloc", "size": {"kind": "ident", "name": "n"}} +``` + +### Array Type + +```json +{"kind": "array", "element": {"kind": "primitive", "name": "int"}} +``` + +### Optional Type + +```json +{"kind": "optional", "element": {"kind": "primitive", "name": "int"}} +``` + +### Named Type + +```json +{"kind": "named", "name": "MyCustomType"} +``` + +## Statements + +### Qubit Allocation + +```json +{ + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 4} +} +``` + +Represents `q = qubit[4]`. + +### Gate Application + +```json +{ + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] +} +``` + +Represents `h(q[0])`. + +Supported gates: `h`, `x`, `y`, `z`, `t`, `tdg`, `sx`, `sy`, `sz`, `szdg`, +`rx`, `ry`, `rz`, `cx`, `cy`, `cz`, `swap`, `iswap`, `ccx`, `pz` + +> **Note:** Gate names follow PECOS conventions. Use `sz` (S-gate, sqrt of Z) and `szdg` (S-dagger) rather than `s`/`sdg`. + +For multi-qubit gates: + +```json +{ + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] +} +``` + +### Measurement + +As a statement (result discarded): +```json +{ + "kind": "measure", + "target": {"kind": "ident", "name": "q"} +} +``` + +As an assignment (result captured): +```json +{ + "kind": "assign", + "target": {"kind": "ident", "name": "m"}, + "value": { + "kind": "call", + "callee": "measure", + "args": [{"kind": "ident", "name": "q"}] + } +} +``` + +**Note on Zlup measurement syntax:** + +The generated Zlup supports flexible measurement syntax: + +```zlup +// Per-qubit measurement into array +m := mz([4]u1) q; + +// Single qubit measurement +bit := mz(u1) q[0]; + +// Pack into integer type +syndrome_byte := mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; + +// Pack into custom struct type +Syndrome := struct { x_parity: u1, z_parity: u1, flags: u2 }; +syndrome := mz(pack Syndrome) [ancilla[0], ancilla[1], ancilla[2], ancilla[3]]; +``` + +The `pack` modifier fills bits sequentially into the target type's bit layout (LSB first). + +### Assignment + +```json +{ + "kind": "assign", + "target": {"kind": "ident", "name": "x"}, + "value": {"kind": "literal", "value": 42} +} +``` + +Target kinds: `ident`, `index`, `attr`, `tuple` + +### For Loop + +```json +{ + "kind": "for", + "var": "i", + "range": { + "start": {"kind": "literal", "value": 0}, + "end": {"kind": "ident", "name": "n"} + }, + "body": [...] +} +``` + +### If Statement + +```json +{ + "kind": "if", + "condition": {"kind": "binary", "left": ..., "op": "==", "right": ...}, + "then_body": [...], + "else_body": [...] +} +``` + +### While Loop + +```json +{ + "kind": "while", + "condition": {"kind": "binary", ...}, + "body": [...] +} +``` + +### Return + +```json +{ + "kind": "return", + "return_value": {"kind": "ident", "name": "result"} +} +``` + +For entry/main functions, use `result()` instead of returning values. + +### Result Emission + +```json +{ + "kind": "result", + "tag": "measurements", + "value": {"kind": "ident", "name": "m"} +} +``` + +Represents `result("measurements", m)` - emits a tagged value to the quantum runtime. +Entry/main functions should use explicit `result()` calls rather than returning values. + +## Expressions + +### Literals + +```json +{"kind": "literal", "value": 42} +{"kind": "literal", "value": 3.14} +{"kind": "literal", "value": "hello"} +{"kind": "literal", "value": true} +{"kind": "literal", "value": null} +``` + +### Identifier + +```json +{"kind": "ident", "name": "variable_name"} +``` + +### Index Access + +```json +{ + "kind": "index", + "array": "q", + "index": {"kind": "literal", "value": 0} +} +``` + +Or with expression base: +```json +{ + "kind": "index", + "value": {"kind": "ident", "name": "arr"}, + "index": {"kind": "ident", "name": "i"} +} +``` + +### Binary Operations + +```json +{ + "kind": "binary", + "left": {"kind": "ident", "name": "a"}, + "op": "+", + "right": {"kind": "literal", "value": 1} +} +``` + +Arithmetic operators: `+`, `-`, `*`, `/`, `//`, `%`, `**`, `<<`, `>>`, `|`, `^`, `&`, `@` + +Comparison operators: `==`, `!=`, `<`, `<=`, `>`, `>=`, `is`, `is not`, `in`, `not in` + +Both arithmetic and comparison operations use `"kind": "binary"`: + +```json +{ + "kind": "binary", + "left": {"kind": "ident", "name": "x"}, + "op": "<", + "right": {"kind": "literal", "value": 10} +} + +### Function Call + +```json +{ + "kind": "call", + "callee": "function_name", + "args": [ + {"kind": "ident", "name": "arg1"}, + {"kind": "literal", "value": 42} + ] +} +``` + +Or with expression callee: +```json +{ + "kind": "call", + "func": {"kind": "field", "object": {...}, "field": "method"}, + "args": [...] +} +``` + +## Location + +```json +{ + "line": 1, + "column": 1, + "end_line": 5, + "end_column": 10 +} +``` + +All fields are 1-indexed. + +## Complete Example + +```json +{ + "version": "0.1.0", + "source_file": "bell.py", + "functions": [ + { + "name": "bell_state", + "params": [], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + }, + { + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] + }, + { + "kind": "assign", + "target": {"kind": "ident", "name": "m"}, + "value": { + "kind": "call", + "callee": "measure", + "args": [{"kind": "ident", "name": "q"}] + } + }, + { + "kind": "result", + "tag": "measurements", + "value": {"kind": "ident", "name": "m"} + } + ], + "location": {"line": 1, "column": 1, "end_line": 6, "end_column": 13} + } + ] +} +``` diff --git a/exp/guppy-zlup/docs/rules.md b/exp/guppy-zlup/docs/rules.md new file mode 100644 index 000000000..15842835b --- /dev/null +++ b/exp/guppy-zlup/docs/rules.md @@ -0,0 +1,507 @@ +# Lint Rules Reference + +The Guppy linter enforces rules derived from NASA's Power of 10 coding +guidelines, adapted for quantum computing. These rules ensure programs are +safe, predictable, and suitable for execution on quantum hardware. + +## Rule Summary + +| Rule | Severity | Description | +|---------|----------|--------------------------------------| +| ZLUP001 | Error | Unbounded loops | +| ZLUP002 | Error | Recursive function calls | +| ZLUP003 | Error | Dynamic memory allocation in loops | +| ZLUP004 | Error | Dynamic dispatch | +| ZLUP005 | Warning | Unchecked error-prone operations | +| ZLUP006 | Warning | Missing type annotations | +| ZLUP007 | Warning | Excessive control flow complexity | +| ZLUP008 | Warning | Deep call nesting (>4 levels) | +| ZLUP009 | Info | Missing assertions in large functions| +| ZLUP010 | Warning | Global mutable state | + +--- + +## ZLUP001: Unbounded Loops + +**Severity:** Error + +**Rationale:** Quantum hardware has finite coherence time. Programs must +terminate within a bounded time, which requires all loops to have statically +determinable bounds. + +### Triggers + +- `while True:` loops +- `while 1:` loops +- While loops without a clear termination condition + +### Examples + +```python +# BAD: Unbounded loop +def bad(): + while True: # ZLUP001: unbounded loop + do_something() + +# BAD: Effectively unbounded +def also_bad(): + while some_condition(): # ZLUP001: cannot prove termination + do_something() + +# GOOD: Bounded by break with finite iterations +def acceptable(): + for i in range(100): + if done(): + break + do_something() + +# GOOD: Clear termination condition +def good(): + for i in range(n): # Bounded by n + do_something() +``` + +### Fix + +Replace unbounded loops with bounded `for` loops or add explicit iteration limits. + +--- + +## ZLUP002: Recursion + +**Severity:** Error + +**Rationale:** Recursive calls can lead to unbounded stack growth and +unpredictable execution time. Quantum programs must have statically +determinable resource usage. + +### Triggers + +- Direct recursion (function calls itself) +- Indirect recursion (A calls B, B calls A) + +### Examples + +```python +# BAD: Direct recursion +def factorial(n: int) -> int: + if n <= 1: + return 1 + return n * factorial(n - 1) # ZLUP002: recursive call + +# BAD: Indirect recursion +def ping(n: int) -> int: + if n <= 0: + return 0 + return pong(n - 1) # ZLUP002: mutual recursion + +def pong(n: int) -> int: + return ping(n - 1) # ZLUP002: mutual recursion + +# GOOD: Iterative version +def factorial(n: int) -> int: + result = 1 + for i in range(1, n + 1): + result *= i + return result +``` + +### Fix + +Convert recursive algorithms to iterative versions using explicit loops. + +--- + +## ZLUP003: Dynamic Allocation in Loops + +**Severity:** Error + +**Rationale:** Dynamic memory allocation inside loops can lead to unbounded +memory growth. For quantum programs, qubit allocation must be statically +determinable. + +### Triggers + +- `qubit[n]` allocation inside loop bodies +- List creation (`[]`, `list()`) inside loops +- `.append()` calls inside loops +- List comprehensions that grow unboundedly + +### Examples + +```python +# BAD: Qubit allocation in loop +def bad(): + for i in range(10): + q = qubit[2] # ZLUP003: allocation in loop + h(q[0]) + +# BAD: List growth in loop +def also_bad(): + results = [] + for i in range(n): + results.append(measure(q)) # ZLUP003: dynamic growth + +# GOOD: Pre-allocate outside loop +def good(): + q = qubit[20] # Allocate once + for i in range(10): + h(q[i * 2]) + +# GOOD: Fixed-size collection +def also_good(n: int): + q = qubit[n] + results = [0] * n # Pre-sized + for i in range(n): + results[i] = measure(q[i]) +``` + +### Fix + +Move allocations outside loops. Pre-size collections before loop entry. + +--- + +## ZLUP004: Dynamic Dispatch + +**Severity:** Error + +**Rationale:** Dynamic dispatch (runtime method resolution) makes control flow +unpredictable and complicates timing analysis on quantum hardware. + +### Triggers + +- `getattr()` calls +- `eval()` calls +- `exec()` calls +- Subscript-based function calls (`funcs[i]()`) + +### Examples + +```python +# BAD: Dynamic attribute access +def bad(obj, method_name: str): + func = getattr(obj, method_name) # ZLUP004: dynamic dispatch + func() + +# BAD: eval +def also_bad(code: str): + eval(code) # ZLUP004: eval + +# BAD: Subscript dispatch +def dispatch(funcs, i: int): + funcs[i]() # ZLUP004: dynamic dispatch + +# GOOD: Static dispatch +def good(obj): + obj.known_method() # Static, known at compile time + +# GOOD: Match/if for dispatch +def also_good(choice: int): + if choice == 0: + func_a() + elif choice == 1: + func_b() +``` + +### Fix + +Use static method calls or explicit if/match statements for dispatch. + +--- + +## ZLUP005: Unchecked Error-Prone Operations + +**Severity:** Warning + +**Rationale:** Operations that can fail at runtime should be wrapped in +error handling to prevent unexpected crashes. + +### Triggers + +- Division operations not in try/except blocks +- `int()` conversions not in try/except blocks +- Other operations that may raise exceptions + +### Examples + +```python +# WARNING: Division might fail +def bad(a: int, b: int) -> int: + return a / b # ZLUP005: possible division by zero + +# OK: Division by literal +def ok(a: int) -> int: + return a / 2 # Literal cannot be zero + +# GOOD: Wrapped in try/except +def good(a: int, b: int) -> int: + try: + return a / b + except ZeroDivisionError: + return 0 + +# GOOD: Explicit check +def also_good(a: int, b: int) -> int: + if b == 0: + return 0 + return a / b +``` + +### Fix + +Wrap error-prone operations in try/except or add explicit validation. + +--- + +## ZLUP006: Missing Type Annotations + +**Severity:** Warning + +**Rationale:** Type annotations enable static analysis and catch errors at +compile time rather than runtime. They're especially important for quantum +programs where runtime debugging is difficult. + +### Triggers + +- Functions without return type annotations +- Parameters without type annotations +- Exception: `self` parameter (implicitly typed) + +### Examples + +```python +# WARNING: Missing return type +def bad(x: int): # ZLUP006: missing return type + return x + 1 + +# WARNING: Missing parameter type +def also_bad(x) -> int: # ZLUP006: missing parameter type + return x + 1 + +# GOOD: Fully annotated +def good(x: int) -> int: + return x + 1 + +# OK: self doesn't need annotation +class Foo: + def method(self, x: int) -> int: # self is fine + return x +``` + +### Fix + +Add type annotations to all function parameters and return types. + +--- + +## ZLUP007: Excessive Control Flow Complexity + +**Severity:** Warning + +**Rationale:** Complex control flow is harder to analyze, test, and reason +about. High cyclomatic complexity correlates with bugs. + +### Triggers + +- Functions with cyclomatic complexity > 10 (configurable) +- Deeply nested control structures + +### Complexity Calculation + +Each of the following adds 1 to complexity: +- `if` statement +- `elif` clause +- `for` loop +- `while` loop +- `except` handler +- `and` operator +- `or` operator +- Conditional expression (`x if cond else y`) +- Comprehension with `if` clause + +### Examples + +```python +# WARNING: Too complex +def bad(x: int, y: int, z: int) -> int: + if x > 0: + if y > 0: + if z > 0: + return 1 + else: + if x > y: + return 2 + else: + return 3 + else: + for i in range(x): + if i > y: + return 4 + # ... continues with more nesting + # ZLUP007: complexity > 10 + +# GOOD: Refactored into smaller functions +def handle_positive_z(x: int, y: int) -> int: + return 2 if x > y else 3 + +def handle_positive_y(x: int, y: int, z: int) -> int: + if z > 0: + return 1 + return handle_positive_z(x, y) + +def good(x: int, y: int, z: int) -> int: + if x <= 0: + return 0 + if y > 0: + return handle_positive_y(x, y, z) + return handle_negative_y(x, y) +``` + +### Fix + +Break complex functions into smaller, focused helper functions. + +--- + +## ZLUP008: Deep Call Nesting + +**Severity:** Warning + +**Rationale:** Deeply nested function calls (calls within calls within calls) +make code hard to verify and debug. NASA Power of 10 Rule 5 recommends keeping +assertions and expressions simple and verifiable. + +### Triggers + +- Function calls nested more than 4 levels deep (configurable) +- Chains like `a(b(c(d(e()))))` + +### Examples + +```python +# WARNING: Call depth exceeds 4 +def bad(): + result = a(b(c(d(e())))) # ZLUP008: too deep + +# GOOD: Intermediate variables +def good(): + e_result = e() + d_result = d(e_result) + c_result = c(d_result) + b_result = b(c_result) + result = a(b_result) +``` + +### Fix + +Extract nested calls into named intermediate variables. This improves +readability, debuggability, and makes assertions on intermediate values possible. + +--- + +## ZLUP009: Missing Assertions + +**Severity:** Info + +**Rationale:** NASA Power of 10 Rule 5 requires assertions to check invariants. +Non-trivial functions should contain assertions to validate preconditions, +postconditions, and invariants. + +### Triggers + +- Functions with 5+ statements that contain no `assert` statements +- Exception: test functions (`test_*`) and dunder methods (`__*__`) + +### Examples + +```python +# INFO: Function has 6 statements but no assertions +def process(data): + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b # ZLUP009: no assertions + +# GOOD: Has assertion for precondition +def process(data): + assert data is not None, "data must not be None" + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b +``` + +### Fix + +Add `assert` statements to validate preconditions, postconditions, or +loop invariants. Even simple assertions like `assert data is not None` +help document assumptions and catch errors early. + +--- + +## ZLUP010: Global Mutable State + +**Severity:** Warning + +**Rationale:** Mutable global state creates hidden dependencies between +functions, makes programs harder to reason about, and can cause issues +in concurrent execution (relevant for quantum-classical hybrid programs). + +### Triggers + +- Module-level variable assignments (lowercase names) +- Use of `global` keyword inside functions +- Exception: UPPER_CASE constants are allowed + +### Examples + +```python +# WARNING: Mutable global state +counter = 0 # ZLUP010: lowercase module-level variable + +def increment(): + global counter # ZLUP010: global keyword + counter += 1 + +# GOOD: Constants allowed +MAX_SIZE = 100 +DEFAULT_VALUE = 42 + +# GOOD: Pass state explicitly +def increment(counter: int) -> int: + return counter + 1 +``` + +### Fix + +Use UPPER_CASE names for true constants. For mutable state, pass values +explicitly through function parameters and return values. + +--- + +## Configuration + +Rules can be configured in `pyproject.toml`: + +```toml +[tool.guppy-zlup] +# Disable specific rules +disabled_rules = ["ZLUP005"] + +# Adjust complexity threshold +max_complexity = 15 + +# Treat warnings as errors +warnings_as_errors = true +``` + +Or via command line: + +```bash +guppy-zlup check program.py --disable ZLUP005 --max-complexity 15 +``` diff --git a/exp/guppy-zlup/ir.json b/exp/guppy-zlup/ir.json new file mode 100644 index 000000000..99ed6e744 --- /dev/null +++ b/exp/guppy-zlup/ir.json @@ -0,0 +1,96 @@ +{ + "version": "0.1.0", + "functions": [ + { + "name": "nested_cond", + "params": [ + { + "name": "a", + "type": { + "kind": "primitive", + "name": "int" + } + }, + { + "name": "b", + "type": { + "kind": "primitive", + "name": "int" + } + } + ], + "return_type": { + "kind": "primitive", + "name": "int" + }, + "body": [ + { + "kind": "if", + "condition": { + "kind": "binary", + "op": "gt", + "left": { + "kind": "ident", + "name": "a" + }, + "right": { + "kind": "literal", + "value": 0 + } + }, + "then_body": [ + { + "kind": "if", + "condition": { + "kind": "binary", + "op": "gt", + "left": { + "kind": "ident", + "name": "b" + }, + "right": { + "kind": "literal", + "value": 0 + } + }, + "then_body": [ + { + "kind": "return", + "return_value": { + "kind": "literal", + "value": 1 + } + } + ], + "else_body": [ + { + "kind": "return", + "return_value": { + "kind": "literal", + "value": 2 + } + } + ] + } + ], + "else_body": [ + { + "kind": "return", + "return_value": { + "kind": "literal", + "value": 3 + } + } + ] + } + ], + "location": { + "line": 1, + "column": 1, + "end_line": 8, + "end_column": 17 + } + } + ], + "source_file": "/tmp/test_nested_if.py" +} \ No newline at end of file diff --git a/exp/guppy-zlup/mkdocs.yml b/exp/guppy-zlup/mkdocs.yml new file mode 100644 index 000000000..d325425df --- /dev/null +++ b/exp/guppy-zlup/mkdocs.yml @@ -0,0 +1,68 @@ +site_name: guppy-zlup +site_description: Guppy linter and Zlup compiler +site_url: https://pecos-packages.github.io/PECOS/guppy-zlup/ + +repo_name: PECOS-packages/PECOS +repo_url: https://github.com/PECOS-packages/PECOS + +theme: + name: material + palette: + - media: "(prefers-color-scheme: light)" + scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - media: "(prefers-color-scheme: dark)" + scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + features: + - navigation.instant + - navigation.sections + - navigation.expand + - navigation.top + - content.code.copy + - content.code.annotate + icon: + repo: fontawesome/brands/github + +nav: + - Home: index.md + - Architecture: architecture.md + - Lint Rules: rules.md + - IR Format: ir-format.md + - Examples: + - Bell State: examples/bell-state.md + - Grover's Algorithm: examples/grover.md + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + line_spans: __span + pygments_lang_class: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - tables + - admonition + - pymdownx.details + - attr_list + - md_in_html + - toc: + permalink: true + +plugins: + - search + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/PECOS-packages/PECOS diff --git a/exp/guppy-zlup/scripts/validate_guppy.py b/exp/guppy-zlup/scripts/validate_guppy.py new file mode 100755 index 000000000..b7897aa6e --- /dev/null +++ b/exp/guppy-zlup/scripts/validate_guppy.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""Validate Guppy source files using guppylang. + +This script validates that Guppy source code is semantically correct +by attempting to compile it with guppylang. + +Usage: + python validate_guppy.py + +Exit codes: + 0 - Valid Guppy code + 1 - Invalid Guppy code (errors printed to stderr) + 2 - File not found or other IO error +""" + +import sys +import json +import importlib.util +from pathlib import Path + + +def validate_guppy_file(filepath: str) -> tuple[bool, list[dict]]: + """Validate a Guppy source file using guppylang. + + Returns: + (is_valid, errors) where errors is a list of error dicts + """ + path = Path(filepath) + if not path.exists(): + return False, [{"error": "FileNotFound", "message": f"File not found: {filepath}"}] + + try: + spec = importlib.util.spec_from_file_location("guppy_module", str(path)) + if spec is None or spec.loader is None: + return False, [{"error": "ImportError", "message": "Could not load module"}] + + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + # Find guppy functions and compile them + compiled = [] + for name in dir(module): + obj = getattr(module, name) + if hasattr(obj, 'compile') and hasattr(obj, 'check'): + # This is a GuppyFunctionDefinition + obj.compile() + compiled.append(name) + + if not compiled: + # No guppy functions found - just syntax checking passed + return True, [] + + return True, [] + + except Exception as e: + error_type = type(e).__name__ + error_msg = str(e) + + # Try to extract useful info from guppy errors + error_info = { + "error": error_type, + "message": error_msg, + } + + # Parse guppy error details if available + if "var=" in error_msg: + import re + match = re.search(r"var='(\w+)'", error_msg) + if match: + error_info["variable"] = match.group(1) + + return False, [error_info] + + +def main(): + if len(sys.argv) < 2: + print("Usage: validate_guppy.py ", file=sys.stderr) + sys.exit(2) + + filepath = sys.argv[1] + output_json = "--json" in sys.argv + + is_valid, errors = validate_guppy_file(filepath) + + if output_json: + result = { + "valid": is_valid, + "errors": errors, + "file": filepath, + } + print(json.dumps(result)) + else: + if is_valid: + print(f"Valid: {filepath}") + else: + print(f"Invalid: {filepath}", file=sys.stderr) + for err in errors: + print(f" {err['error']}: {err['message'][:200]}", file=sys.stderr) + + sys.exit(0 if is_valid else 1) + + +if __name__ == "__main__": + main() diff --git a/exp/guppy-zlup/src/compiler.rs b/exp/guppy-zlup/src/compiler.rs new file mode 100644 index 000000000..9b0d09945 --- /dev/null +++ b/exp/guppy-zlup/src/compiler.rs @@ -0,0 +1,9 @@ +//! Guppy IR to Zlup compiler module. +//! +//! Transforms validated Guppy IR into Zlup source code. + +pub mod parser; +pub mod transform; + +pub use parser::{parse_ir, ParseError}; +pub use transform::{transform, TransformError}; diff --git a/exp/guppy-zlup/src/compiler/parser.rs b/exp/guppy-zlup/src/compiler/parser.rs new file mode 100644 index 000000000..4e6797a51 --- /dev/null +++ b/exp/guppy-zlup/src/compiler/parser.rs @@ -0,0 +1,64 @@ +//! JSON IR parser. + +use crate::ir::GuppyIR; + +/// Parse Guppy IR from JSON string. +pub fn parse_ir(json: &str) -> Result { + serde_json::from_str(json).map_err(ParseError::Json) +} + +/// Parse Guppy IR from a file. +pub fn parse_ir_file(path: &str) -> Result { + let json = std::fs::read_to_string(path).map_err(ParseError::Io)?; + parse_ir(&json) +} + +/// Parse error. +#[derive(Debug, thiserror::Error)] +pub enum ParseError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON parse error: {0}")] + Json(#[from] serde_json::Error), + + #[error("Invalid IR version: expected {expected}, got {actual}")] + InvalidVersion { expected: String, actual: String }, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal() { + let json = r#"{"version": "0.1.0", "functions": []}"#; + let ir = parse_ir(json).unwrap(); + assert_eq!(ir.version, "0.1.0"); + assert!(ir.functions.is_empty()); + } + + #[test] + fn test_parse_function() { + let json = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test", + "params": [], + "body": [] + } + ] + }"#; + let ir = parse_ir(json).unwrap(); + assert_eq!(ir.functions.len(), 1); + assert_eq!(ir.functions[0].name, "test"); + } + + #[test] + fn test_parse_invalid_json() { + let json = "not valid json"; + let result = parse_ir(json); + assert!(result.is_err()); + } +} diff --git a/exp/guppy-zlup/src/compiler/transform.rs b/exp/guppy-zlup/src/compiler/transform.rs new file mode 100644 index 000000000..808c6c0d2 --- /dev/null +++ b/exp/guppy-zlup/src/compiler/transform.rs @@ -0,0 +1,1026 @@ +//! Transform Guppy IR to Zlup AST. + +use std::collections::BTreeMap; + +use zlup::ast::{self as zlup_ast, GateKind as ZlupGateKind}; + +use crate::ir::{Expr, ExprKind, Function, GateKind, GuppyIR, Param, Stmt, StmtKind, TypeExpr}; + +/// Transform context for tracking variable information with invariant checking. +#[derive(Default)] +struct TransformContext { + /// Maps qalloc variable names to their sizes (if known). + qalloc_sizes: BTreeMap, + /// Set of variable names that have been declared (for distinguishing new vars from reassignments). + declared_vars: std::collections::BTreeSet, + /// Track which allocators have been used in gates (for invariant checking). + #[cfg(debug_assertions)] + used_allocators: std::collections::BTreeSet, +} + +impl TransformContext { + /// Register a new qalloc. + fn register_qalloc(&mut self, name: &str, size: i128) { + debug_assert!(!self.qalloc_sizes.contains_key(name), + "Invariant violation: duplicate qalloc for '{}'", name); + self.qalloc_sizes.insert(name.to_string(), size); + self.declared_vars.insert(name.to_string()); + } + + /// Register a variable declaration. + fn declare_var(&mut self, name: &str) { + self.declared_vars.insert(name.to_string()); + } + + /// Check if a variable is declared. + fn is_declared(&self, name: &str) -> bool { + self.declared_vars.contains(name) + } + + /// Get qalloc size, with invariant check. + fn get_qalloc_size(&self, name: &str) -> Option { + let size = self.qalloc_sizes.get(name).copied(); + #[cfg(debug_assertions)] + if size.is_some() { + debug_assert!(self.declared_vars.contains(name), + "Invariant violation: qalloc '{}' in qalloc_sizes but not in declared_vars", name); + } + size + } + + /// Mark an allocator as used (for gates). + #[cfg(debug_assertions)] + fn mark_allocator_used(&mut self, name: &str) { + debug_assert!(self.qalloc_sizes.contains_key(name), + "Invariant violation: gate uses allocator '{}' before it was allocated", name); + self.used_allocators.insert(name.to_string()); + } + + #[cfg(not(debug_assertions))] + fn mark_allocator_used(&mut self, _name: &str) { + // No-op in release builds + } +} + +/// Transform Guppy IR to Zlup AST. +pub fn transform(ir: &GuppyIR) -> Result { + let mut declarations = Vec::new(); + + for func in &ir.functions { + let decl = transform_function(func)?; + declarations.push(zlup_ast::TopLevelDecl::Fn(decl)); + } + + Ok(zlup_ast::Program { + name: ir.source_file.clone().unwrap_or_else(|| "main".to_string()), + declarations, + location: None, + }) +} + +fn transform_function(func: &Function) -> Result { + let params = func + .params + .iter() + .map(transform_param) + .collect::, _>>()?; + + let return_type = func.return_type.as_ref().map(transform_type); + + let mut ctx = TransformContext::default(); + // Add function parameters to declared_vars + for param in &func.params { + ctx.declare_var(¶m.name); + } + let mut body = transform_block_with_ctx(&func.body, &mut ctx)?; + + // Check if the function returns unit and needs an explicit return statement. + // Zlup requires explicit `return unit;` for functions that return unit. + let is_unit_return = match &return_type { + None => true, + Some(zlup_ast::TypeExpr::Unit) => true, + Some(zlup_ast::TypeExpr::Named(path)) if path.segments == ["None"] => true, + _ => false, + }; + + // Check if the last statement is already a return + let has_trailing_return = body.statements.last().is_some_and(|stmt| { + matches!(stmt, zlup_ast::Stmt::Return(_)) + }); + + // Add implicit return if needed (return; is equivalent to return unit; in Zlup) + if is_unit_return && !has_trailing_return { + body.statements.push(zlup_ast::Stmt::Return(zlup_ast::ReturnStmt { + value: None, + location: None, + })); + } + + Ok(zlup_ast::FnDecl { + name: func.name.clone(), + params, + return_type, + body, + is_pub: func.is_pub.unwrap_or(false), + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }) +} + +fn transform_param(param: &Param) -> Result { + Ok(zlup_ast::Param { + name: param.name.clone(), + ty: transform_type(¶m.ty), + is_comptime: false, + location: None, + }) +} + +fn transform_type(ty: &TypeExpr) -> zlup_ast::TypeExpr { + match ty.kind.as_str() { + "primitive" => { + let name = ty.name.as_deref().unwrap_or("unknown"); + match name { + "int" => zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::IInt { bits: 64 }), + "float" => zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::F64), + "bool" => zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::Bool), + "None" => zlup_ast::TypeExpr::Unit, + _ => zlup_ast::TypeExpr::Named(zlup_ast::TypePath { + segments: vec![name.to_string()], + location: None, + }), + } + } + "qalloc" => zlup_ast::TypeExpr::QAlloc(ty.size.as_ref().map(|s| Box::new(transform_expr(s)))), + "array" => { + let element = ty + .element + .as_ref() + .map(|e| transform_type(e)) + .unwrap_or(zlup_ast::TypeExpr::Unit); + let size = ty.size.as_ref().map(|s| transform_expr(s)); + zlup_ast::TypeExpr::Array(Box::new(zlup_ast::ArrayType { + element, + size, + sentinel: None, + })) + } + "optional" => { + let element = ty + .element + .as_ref() + .map(|e| transform_type(e)) + .unwrap_or(zlup_ast::TypeExpr::Unit); + zlup_ast::TypeExpr::Optional(Box::new(element)) + } + "tuple" => { + // In quantum code, tuple[bool, ...] typically contains measurement results + // which are u1 in Zlup, so map bool->u1 within tuples + let elements: Vec = ty.elements.iter().map(|elem| { + if elem.kind == "primitive" && elem.name.as_deref() == Some("bool") { + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }) + } else { + transform_type(elem) + } + }).collect(); + zlup_ast::TypeExpr::Tuple(elements) + } + "named" => { + let name = ty.name.as_deref().unwrap_or("unknown"); + zlup_ast::TypeExpr::Named(zlup_ast::TypePath { + segments: vec![name.to_string()], + location: None, + }) + } + _ => zlup_ast::TypeExpr::Unit, + } +} + +fn transform_block(stmts: &[Stmt]) -> Result { + let mut ctx = TransformContext::default(); + transform_block_with_ctx(stmts, &mut ctx) +} + +fn transform_block_with_ctx(stmts: &[Stmt], ctx: &mut TransformContext) -> Result { + let mut statements = Vec::new(); + + for stmt in stmts { + let transformed = transform_stmt_with_ctx(stmt, ctx)?; + statements.extend(transformed); + } + + Ok(zlup_ast::Block { + label: None, + attrs: Vec::new(), + statements, + trailing_expr: None, + location: None, + }) +} + +fn transform_stmt(stmt: &Stmt) -> Result, TransformError> { + let mut ctx = TransformContext::default(); + transform_stmt_with_ctx(stmt, &mut ctx) +} + +fn transform_stmt_with_ctx(stmt: &Stmt, ctx: &mut TransformContext) -> Result, TransformError> { + match stmt.kind { + StmtKind::Qalloc => { + let name = stmt.name.as_ref().ok_or(TransformError::MissingField("name"))?; + + // Extract the size value for tracking + let size_value = stmt.size.as_ref().and_then(|s| { + if s.kind == ExprKind::Literal { + s.value.as_ref().and_then(|v| v.as_i64()).map(|i| i as i128) + } else { + None + } + }); + + // Track the qalloc size and register variable using context methods + ctx.register_qalloc(name, size_value.unwrap_or(1)); + + let size = stmt + .size + .as_ref() + .map(transform_expr) + .unwrap_or_else(|| zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 1, + suffix: None, + location: None, + })); + + // Create a binding with qalloc(N) call as the value + // In Zlup: `mut q := qalloc(N);` + let qalloc_call = zlup_ast::Expr::Call(Box::new(zlup_ast::CallExpr { + callee: zlup_ast::Expr::Ident(zlup_ast::Ident { + name: "qalloc".to_string(), + location: None, + }), + args: vec![size], + location: None, + })); + + Ok(vec![zlup_ast::Stmt::Binding(zlup_ast::Binding { + name: name.clone(), + ty: None, + value: Some(qalloc_call), + is_mutable: true, + is_pub: false, + doc_comment: None, + location: None, + })]) + } + + StmtKind::Gate => { + let gate = stmt.gate.ok_or(TransformError::MissingField("gate"))?; + let zlup_gate = transform_gate_kind(gate); + + // Invariant check: ensure allocators are defined before use + for target in &stmt.targets { + if let Some(allocator) = &target.array { + ctx.mark_allocator_used(allocator); + } + } + + let targets: Vec = stmt + .targets + .iter() + .map(transform_slot_ref) + .collect::, _>>()?; + + let params: Vec = stmt.params.iter().map(transform_expr).collect(); + + Ok(vec![zlup_ast::Stmt::Gate(zlup_ast::GateOp { + kind: zlup_gate, + targets, + params, + attrs: Vec::new(), + location: None, + })]) + } + + StmtKind::Measure => { + // Determine if we're measuring a single qubit or multiple qubits + // and construct the appropriate result type. + // + // Single qubit (Index expr like q[0]): mz(u1) q[0] + // Multiple qubits (Ident like q, or multiple targets): mz([N]u1) q + + let (targets_expr, result_type) = if stmt.targets.len() == 1 { + let target = &stmt.targets[0]; + if target.kind == ExprKind::Index { + // Single qubit measurement: mz(u1) q[i] + ( + transform_expr(target), + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }), + ) + } else if target.kind == ExprKind::Ident { + // Measuring entire register or single qubit + // Look up the size from the context + let var_name = target.name.as_deref().unwrap_or(""); + let size = ctx.get_qalloc_size(var_name); + + let result_type = if size == Some(1) { + // Single qubit from qubit() call - returns u1 + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }) + } else { + // Multi-qubit register: mz([N]u1) q + zlup_ast::TypeExpr::Array(Box::new(zlup_ast::ArrayType { + element: zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }), + size: size.map(|n| zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: n, + suffix: None, + location: None, + })), + sentinel: None, + })) + }; + + // For single qubits, use index [0] instead of the whole register + let target_expr = if size == Some(1) { + zlup_ast::Expr::Index(Box::new(zlup_ast::IndexExpr { + object: transform_expr(target), + index: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + location: None, + })) + } else { + transform_expr(target) + }; + + (target_expr, result_type) + } else { + // Other single target, default to u1 + ( + transform_expr(target), + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }), + ) + } + } else { + // Multiple explicit targets: mz([N]u1) [q[0], q[1], ...] + let n = stmt.targets.len() as i128; + ( + zlup_ast::Expr::BracketArray(Box::new(zlup_ast::BracketArrayExpr { + elements: stmt.targets.iter().map(transform_expr).collect(), + location: None, + })), + zlup_ast::TypeExpr::Array(Box::new(zlup_ast::ArrayType { + element: zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }), + size: Some(zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: n, + suffix: None, + location: None, + })), + sentinel: None, + })), + ) + }; + + // Create MeasureExpr: mz(T) targets + let measure_expr = zlup_ast::Expr::Measure(Box::new(zlup_ast::MeasureExpr { + result_type, + pack: false, + targets: targets_expr, + location: None, + })); + + // If there's a result variable, create a binding + // Otherwise just emit the measure as an expression statement + if let Some(result_name) = stmt.results.first() { + Ok(vec![zlup_ast::Stmt::Binding(zlup_ast::Binding { + name: result_name.clone(), + ty: None, + value: Some(measure_expr), + is_mutable: false, + is_pub: false, + doc_comment: None, + location: None, + })]) + } else { + Ok(vec![zlup_ast::Stmt::Expr(zlup_ast::ExprStmt { + expr: measure_expr, + attrs: Vec::new(), + location: None, + })]) + } + } + + StmtKind::For => { + let var = stmt.var.as_ref().ok_or(TransformError::MissingField("var"))?; + let range = stmt + .range + .as_ref() + .ok_or(TransformError::MissingField("range"))?; + + // Add loop variable to declared_vars + ctx.declare_var(var); + let body = transform_block_with_ctx(&stmt.body, ctx)?; + + Ok(vec![zlup_ast::Stmt::For(zlup_ast::ForStmt { + label: None, + is_inline: false, + range: zlup_ast::ForRange::Range { + start: transform_expr(&range.start), + end: transform_expr(&range.end), + }, + captures: vec![var.clone()], + body, + location: None, + })]) + } + + StmtKind::While => { + // Zlup doesn't support while loops (NASA Power of 10: bounded iteration only). + // The linter should have already flagged unbounded while loops. + // Transform to a bounded for loop with max iterations and break condition. + let condition = stmt + .condition + .as_ref() + .ok_or(TransformError::MissingField("condition"))?; + + let mut body = transform_block_with_ctx(&stmt.body, ctx)?; + + // Handle condition similar to if statements + let cond_expr = transform_expr(condition); + let bool_condition = match &condition.kind { + ExprKind::Ident => zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: cond_expr, + op: zlup_ast::BinaryOp::Ne, + right: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + location: None, + })), + _ => cond_expr, + }; + + // Insert: if (negated_condition) { break; } at the start of body + // We invert comparison operators directly to avoid precedence issues + // where `!i < limit` parses as `(!i) < limit` instead of `!(i < limit)`. + let negated_condition = negate_condition(&bool_condition); + let break_if_done = zlup_ast::Stmt::If(zlup_ast::IfStmt { + condition: negated_condition, + capture: None, + then_body: zlup_ast::Block { + label: None, + attrs: Vec::new(), + statements: vec![zlup_ast::Stmt::Break(zlup_ast::BreakStmt { + label: None, + value: None, + location: None, + })], + trailing_expr: None, + location: None, + }, + else_body: None, + location: None, + }); + body.statements.insert(0, break_if_done); + + // Use a bounded for loop (max 1000000 iterations as safety bound) + Ok(vec![zlup_ast::Stmt::For(zlup_ast::ForStmt { + label: None, + is_inline: false, + range: zlup_ast::ForRange::Range { + start: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + end: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 1000000, + suffix: None, + location: None, + }), + }, + captures: vec!["_while_iter".to_string()], + body, + location: None, + })]) + } + + StmtKind::Break => Ok(vec![zlup_ast::Stmt::Break(zlup_ast::BreakStmt { + label: None, + value: None, + location: None, + })]), + + StmtKind::Continue => Ok(vec![zlup_ast::Stmt::Continue(zlup_ast::ContinueStmt { + label: None, + location: None, + })]), + + StmtKind::If => { + let condition = stmt + .condition + .as_ref() + .ok_or(TransformError::MissingField("condition"))?; + + let then_body = transform_block_with_ctx(&stmt.then_body, ctx)?; + let else_body = if stmt.else_body.is_empty() { + None + } else { + Some(zlup_ast::ElseBranch::Else(transform_block_with_ctx(&stmt.else_body, ctx)?)) + }; + + // In Guppy, measurement results (bool) are used directly as conditions. + // In Zlup, measurements return u1, and if conditions require bool. + // We wrap simple identifiers in `!= 0` to convert u1 to bool. + // But we don't wrap comparisons or boolean ops since they already return bool. + let cond_expr = transform_expr(condition); + let bool_condition = match &condition.kind { + // Simple identifiers (like measurement results) need != 0 conversion + ExprKind::Ident => zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: cond_expr, + op: zlup_ast::BinaryOp::Ne, + right: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + location: None, + })), + // Comparisons and binary ops already return bool + _ => cond_expr, + }; + + Ok(vec![zlup_ast::Stmt::If(zlup_ast::IfStmt { + condition: bool_condition, + capture: None, + then_body, + else_body, + location: None, + })]) + } + + StmtKind::Assign => { + let target = stmt + .target + .as_ref() + .ok_or(TransformError::MissingField("target"))?; + let value = stmt + .value + .as_ref() + .ok_or(TransformError::MissingField("value"))?; + + // For simple identifier targets, track if this is a new variable or reassignment + // by checking if ctx has seen this name before. If not seen, create a binding. + // If seen, create an assignment. + if target.kind == ExprKind::Ident { + let name = target.name.as_ref().ok_or(TransformError::MissingField("name"))?; + if ctx.is_declared(name) { + // Reassignment to existing variable + Ok(vec![zlup_ast::Stmt::Assign(zlup_ast::AssignStmt { + target: transform_expr(target), + op: zlup_ast::AssignOp::Assign, + value: transform_expr(value), + location: None, + })]) + } else { + // New variable declaration + ctx.declare_var(name); + Ok(vec![zlup_ast::Stmt::Binding(zlup_ast::Binding { + name: name.clone(), + ty: None, + value: Some(transform_expr(value)), + is_mutable: false, + is_pub: false, + doc_comment: None, + location: None, + })]) + } + } else { + Ok(vec![zlup_ast::Stmt::Assign(zlup_ast::AssignStmt { + target: transform_expr(target), + op: zlup_ast::AssignOp::Assign, + value: transform_expr(value), + location: None, + })]) + } + } + + StmtKind::Return => { + let value = stmt.return_value.as_ref().map(transform_expr); + + Ok(vec![zlup_ast::Stmt::Return(zlup_ast::ReturnStmt { + value, + location: None, + })]) + } + + StmtKind::Expr => { + let expr = stmt + .expr + .as_ref() + .ok_or(TransformError::MissingField("expr"))?; + + Ok(vec![zlup_ast::Stmt::Expr(zlup_ast::ExprStmt { + expr: transform_expr(expr), + attrs: Vec::new(), + location: None, + })]) + } + + StmtKind::Binding => { + let name = stmt.name.as_ref().ok_or(TransformError::MissingField("name"))?; + let ty = stmt.ty.as_ref().map(transform_type); + let value = stmt.value.as_ref().map(transform_expr); + + // Register the variable as declared + ctx.declare_var(name); + + Ok(vec![zlup_ast::Stmt::Binding(zlup_ast::Binding { + name: name.clone(), + ty, + value, + is_mutable: stmt.is_mutable.unwrap_or(false), + is_pub: false, + doc_comment: None, + location: None, + })]) + } + + StmtKind::Barrier => Ok(vec![zlup_ast::Stmt::Barrier(zlup_ast::BarrierOp { + allocators: Vec::new(), + location: None, + })]), + + StmtKind::Result => { + let tag = stmt.tag.as_ref().ok_or(TransformError::MissingField("tag"))?; + let value = stmt + .value + .as_ref() + .ok_or(TransformError::MissingField("value"))?; + + // result(tag, value) becomes an expression statement with ResultExpr + Ok(vec![zlup_ast::Stmt::Expr(zlup_ast::ExprStmt { + expr: zlup_ast::Expr::Result(Box::new(zlup_ast::ResultExpr { + tag: tag.clone(), + value: transform_expr(value), + location: None, + })), + attrs: Vec::new(), + location: None, + })]) + } + } +} + +fn transform_expr(expr: &Expr) -> zlup_ast::Expr { + match expr.kind { + ExprKind::Literal => { + if let Some(value) = &expr.value { + match value { + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: i as i128, + suffix: None, + location: None, + }) + } else if let Some(f) = n.as_f64() { + zlup_ast::Expr::FloatLit(zlup_ast::FloatLit { + value: f, + suffix: None, + location: None, + }) + } else { + zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }) + } + } + serde_json::Value::Bool(b) => zlup_ast::Expr::BoolLit(zlup_ast::BoolLit { + value: *b, + location: None, + }), + serde_json::Value::String(s) => zlup_ast::Expr::StringLit(zlup_ast::StringLit { + value: s.clone(), + location: None, + }), + serde_json::Value::Null => zlup_ast::Expr::Null(zlup_ast::NullLit { location: None }), + _ => zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None }), + } + } else { + zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None }) + } + } + + ExprKind::Ident => { + let name = expr.name.as_deref().unwrap_or("_"); + zlup_ast::Expr::Ident(zlup_ast::Ident { + name: name.to_string(), + location: None, + }) + } + + ExprKind::Index => { + let array = expr.array.as_deref().unwrap_or("_"); + let index = expr + .index + .as_ref() + .map(|i| transform_expr(i)) + .unwrap_or_else(|| { + zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }) + }); + + zlup_ast::Expr::Index(Box::new(zlup_ast::IndexExpr { + object: zlup_ast::Expr::Ident(zlup_ast::Ident { + name: array.to_string(), + location: None, + }), + index, + location: None, + })) + } + + ExprKind::Binary => { + let left = expr + .left + .as_ref() + .map(|l| transform_expr(l)) + .unwrap_or_else(|| zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None })); + let right = expr + .right + .as_ref() + .map(|r| transform_expr(r)) + .unwrap_or_else(|| zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None })); + let op_str = expr.op.as_deref().unwrap_or("unknown"); + let op = transform_binary_op(op_str) + .unwrap_or_else(|_| panic!("IR contains unknown binary operator: {}", op_str)); + + zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + op, + left, + right, + location: None, + })) + } + + ExprKind::Unary => { + let operand = expr + .operand + .as_ref() + .map(|o| transform_expr(o)) + .unwrap_or_else(|| zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None })); + let op_str = expr.op.as_deref().unwrap_or("unknown"); + let op = transform_unary_op(op_str) + .unwrap_or_else(|_| panic!("IR contains unknown unary operator: {}", op_str)); + + zlup_ast::Expr::Unary(Box::new(zlup_ast::UnaryExpr { + op, + operand, + location: None, + })) + } + + ExprKind::Call => { + let callee = expr.callee.as_deref().unwrap_or("_"); + let args: Vec = expr.args.iter().map(transform_expr).collect(); + + zlup_ast::Expr::Call(Box::new(zlup_ast::CallExpr { + callee: zlup_ast::Expr::Ident(zlup_ast::Ident { + name: callee.to_string(), + location: None, + }), + args, + location: None, + })) + } + + ExprKind::Field => { + let object = expr + .object + .as_ref() + .map(|o| transform_expr(o)) + .unwrap_or_else(|| zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None })); + let field = expr.field.as_deref().unwrap_or("_"); + + zlup_ast::Expr::Field(Box::new(zlup_ast::FieldExpr { + object, + field: field.to_string(), + location: None, + })) + } + + ExprKind::Tuple => { + let elements: Vec = expr.args.iter().map(transform_expr).collect(); + zlup_ast::Expr::Tuple(Box::new(zlup_ast::TupleExpr { + elements, + location: None, + })) + } + } +} + +fn transform_slot_ref(expr: &Expr) -> Result { + match expr.kind { + ExprKind::Index => { + let allocator = expr.array.as_ref().ok_or(TransformError::InvalidSlotRef)?; + let index = expr + .index + .as_ref() + .map(|i| transform_expr(i)) + .ok_or(TransformError::InvalidSlotRef)?; + + Ok(zlup_ast::SlotRef { + allocator: allocator.clone(), + index: Box::new(index), + location: None, + }) + } + ExprKind::Ident => { + // Single qubit variable (e.g., q0 from q0 = qubit()) + // Treat as allocator[0] since it's a single-qubit allocation + let name = expr.name.as_ref().ok_or(TransformError::InvalidSlotRef)?; + Ok(zlup_ast::SlotRef { + allocator: name.clone(), + index: Box::new(zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + })), + location: None, + }) + } + _ => Err(TransformError::InvalidSlotRef), + } +} + +fn transform_gate_kind(gate: GateKind) -> ZlupGateKind { + match gate { + GateKind::H => ZlupGateKind::H, + GateKind::X => ZlupGateKind::X, + GateKind::Y => ZlupGateKind::Y, + GateKind::Z => ZlupGateKind::Z, + GateKind::T => ZlupGateKind::T, + GateKind::Tdg => ZlupGateKind::Tdg, + GateKind::S => ZlupGateKind::SZ, + GateKind::Sdg => ZlupGateKind::SZdg, + GateKind::Sx => ZlupGateKind::SX, + GateKind::Sy => ZlupGateKind::SY, + GateKind::Sz => ZlupGateKind::SZ, + GateKind::Rx => ZlupGateKind::RX, + GateKind::Ry => ZlupGateKind::RY, + GateKind::Rz => ZlupGateKind::RZ, + GateKind::Cx => ZlupGateKind::CX, + GateKind::Cy => ZlupGateKind::CY, + GateKind::Cz => ZlupGateKind::CZ, + GateKind::Swap => ZlupGateKind::SWAP, + GateKind::Iswap => ZlupGateKind::ISWAP, + GateKind::Ccx => ZlupGateKind::CCX, + GateKind::Pz => ZlupGateKind::PZ, + } +} + +/// Negate a condition expression by inverting comparison operators. +/// This avoids operator precedence issues with the unary `!` operator. +fn negate_condition(expr: &zlup_ast::Expr) -> zlup_ast::Expr { + match expr { + zlup_ast::Expr::Binary(bin) => { + // Invert comparison operators + let inverted_op = match bin.op { + zlup_ast::BinaryOp::Lt => Some(zlup_ast::BinaryOp::Ge), + zlup_ast::BinaryOp::Le => Some(zlup_ast::BinaryOp::Gt), + zlup_ast::BinaryOp::Gt => Some(zlup_ast::BinaryOp::Le), + zlup_ast::BinaryOp::Ge => Some(zlup_ast::BinaryOp::Lt), + zlup_ast::BinaryOp::Eq => Some(zlup_ast::BinaryOp::Ne), + zlup_ast::BinaryOp::Ne => Some(zlup_ast::BinaryOp::Eq), + _ => None, + }; + if let Some(new_op) = inverted_op { + zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: bin.left.clone(), + op: new_op, + right: bin.right.clone(), + location: bin.location.clone(), + })) + } else { + // For non-comparison binary ops (and, or, etc.), use == false + zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: expr.clone(), + op: zlup_ast::BinaryOp::Eq, + right: zlup_ast::Expr::BoolLit(zlup_ast::BoolLit { + value: false, + location: None, + }), + location: None, + })) + } + } + // For other expressions (idents, etc.), use == 0 to negate + _ => zlup_ast::Expr::Binary(Box::new(zlup_ast::BinaryExpr { + left: expr.clone(), + op: zlup_ast::BinaryOp::Eq, + right: zlup_ast::Expr::IntLit(zlup_ast::IntLit { + value: 0, + suffix: None, + location: None, + }), + location: None, + })), + } +} + +fn transform_binary_op(op: &str) -> Result { + match op { + "add" => Ok(zlup_ast::BinaryOp::Add), + "sub" => Ok(zlup_ast::BinaryOp::Sub), + "mul" => Ok(zlup_ast::BinaryOp::Mul), + "div" => Ok(zlup_ast::BinaryOp::Div), + "floordiv" => Ok(zlup_ast::BinaryOp::Div), // Integer division in Zlup + "mod" => Ok(zlup_ast::BinaryOp::Mod), + "eq" => Ok(zlup_ast::BinaryOp::Eq), + "ne" => Ok(zlup_ast::BinaryOp::Ne), + "lt" => Ok(zlup_ast::BinaryOp::Lt), + "le" => Ok(zlup_ast::BinaryOp::Le), + "gt" => Ok(zlup_ast::BinaryOp::Gt), + "ge" => Ok(zlup_ast::BinaryOp::Ge), + "and" => Ok(zlup_ast::BinaryOp::And), + "or" => Ok(zlup_ast::BinaryOp::Or), + "bitand" => Ok(zlup_ast::BinaryOp::BitAnd), + "bitor" => Ok(zlup_ast::BinaryOp::BitOr), + "bitxor" => Ok(zlup_ast::BinaryOp::BitXor), + "shl" => Ok(zlup_ast::BinaryOp::Shl), + "shr" => Ok(zlup_ast::BinaryOp::Shr), + _ => Err(TransformError::UnsupportedOp(format!("unknown binary operator: {}", op))), + } +} + +fn transform_unary_op(op: &str) -> Result { + match op { + "neg" => Ok(zlup_ast::UnaryOp::Neg), + "not" => Ok(zlup_ast::UnaryOp::Not), + "bitnot" => Ok(zlup_ast::UnaryOp::BitNot), + _ => Err(TransformError::UnsupportedOp(format!("unknown unary operator: {}", op))), + } +} + +/// Transform error. +#[derive(Debug, thiserror::Error)] +pub enum TransformError { + #[error("Missing required field: {0}")] + MissingField(&'static str), + + #[error("Invalid slot reference")] + InvalidSlotRef, + + #[error("Unsupported feature: {0}")] + Unsupported(String), + + #[error("Unsupported operator: {0}")] + UnsupportedOp(String), + + #[error("Generated code validation failed: {0}")] + ValidationFailed(String), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_transform_empty_program() { + let ir = GuppyIR { + version: "0.1.0".to_string(), + functions: vec![], + source_file: None, + }; + + let result = transform(&ir).unwrap(); + assert!(result.declarations.is_empty()); + } + + #[test] + fn test_transform_simple_function() { + let ir = GuppyIR { + version: "0.1.0".to_string(), + functions: vec![Function { + name: "main".to_string(), + params: vec![], + return_type: None, + body: vec![], + is_pub: None, + location: None, + }], + source_file: None, + }; + + let result = transform(&ir).unwrap(); + assert_eq!(result.declarations.len(), 1); + } +} diff --git a/exp/guppy-zlup/src/ir.rs b/exp/guppy-zlup/src/ir.rs new file mode 100644 index 000000000..0eabbc1de --- /dev/null +++ b/exp/guppy-zlup/src/ir.rs @@ -0,0 +1,2205 @@ +//! Guppy IR types and emitter. +//! +//! This module defines the intermediate representation for validated Guppy programs +//! and provides functions to emit IR from Python source code. + +use rustpython_parser::ast::{self, Constant, Expr as PyExpr, Mod, Stmt as PyStmt}; +use serde::{Deserialize, Serialize}; + +pub const IR_VERSION: &str = "0.1.0"; + +/// Expression kinds in the IR. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ExprKind { + Literal, + Ident, + Index, + Binary, + Unary, + Call, + Field, + Tuple, +} + +/// Statement kinds in the IR. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum StmtKind { + Qalloc, + Gate, + Measure, + For, + While, + If, + Assign, + Return, + Expr, + Binding, + Barrier, + Break, + Continue, + /// Result emission: result(tag, value) + Result, +} + +/// Gate types. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum GateKind { + H, + X, + Y, + Z, + T, + Tdg, + S, + Sdg, + Sx, + Sy, + Sz, + Rx, + Ry, + Rz, + Cx, + Cy, + Cz, + Swap, + Iswap, + Ccx, + Pz, +} + +impl GateKind { + pub fn from_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "h" => Some(GateKind::H), + "x" => Some(GateKind::X), + "y" => Some(GateKind::Y), + "z" => Some(GateKind::Z), + "t" => Some(GateKind::T), + "tdg" => Some(GateKind::Tdg), + "s" => Some(GateKind::S), + "sdg" => Some(GateKind::Sdg), + "sx" => Some(GateKind::Sx), + "sy" => Some(GateKind::Sy), + "sz" => Some(GateKind::Sz), + "rx" => Some(GateKind::Rx), + "ry" => Some(GateKind::Ry), + "rz" => Some(GateKind::Rz), + "cx" | "cnot" => Some(GateKind::Cx), + "cy" => Some(GateKind::Cy), + "cz" => Some(GateKind::Cz), + "swap" => Some(GateKind::Swap), + "iswap" => Some(GateKind::Iswap), + "ccx" | "toffoli" => Some(GateKind::Ccx), + "pz" | "reset" => Some(GateKind::Pz), + _ => None, + } + } + + pub fn to_zlup_name(&self) -> &'static str { + match self { + GateKind::H => "h", + GateKind::X => "x", + GateKind::Y => "y", + GateKind::Z => "z", + GateKind::T => "t", + GateKind::Tdg => "tdg", + GateKind::S => "sz", + GateKind::Sdg => "szdg", + GateKind::Sx => "sx", + GateKind::Sy => "sy", + GateKind::Sz => "sz", + GateKind::Rx => "rx", + GateKind::Ry => "ry", + GateKind::Rz => "rz", + GateKind::Cx => "cx", + GateKind::Cy => "cy", + GateKind::Cz => "cz", + GateKind::Swap => "swap", + GateKind::Iswap => "iswap", + GateKind::Ccx => "ccx", + GateKind::Pz => "pz", + } + } + + pub fn arity(&self) -> usize { + match self { + GateKind::Ccx => 3, + GateKind::Cx | GateKind::Cy | GateKind::Cz | GateKind::Swap | GateKind::Iswap => 2, + _ => 1, + } + } + + pub fn is_parameterized(&self) -> bool { + matches!(self, GateKind::Rx | GateKind::Ry | GateKind::Rz) + } +} + +/// Source location. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_line: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub end_column: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, +} + +/// Type expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypeExpr { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub element: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option>, + /// For tuple types: the element types + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub elements: Vec, +} + +/// Expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Expr { + pub kind: ExprKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub array: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub index: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub op: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub left: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub right: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub operand: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub callee: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub args: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub object: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub field: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +/// Range expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RangeExpr { + pub start: Expr, + pub end: Expr, +} + +/// Statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Stmt { + pub kind: StmtKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gate: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub targets: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub params: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub results: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub var: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub range: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub condition: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub then_body: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub else_body: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub return_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expr: Option, + #[serde(rename = "type", skip_serializing_if = "Option::is_none")] + pub ty: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_mutable: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub body: Vec, + /// Tag for result statements: result(tag, value) + #[serde(skip_serializing_if = "Option::is_none")] + pub tag: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +/// Function parameter. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Param { + pub name: String, + #[serde(rename = "type")] + pub ty: TypeExpr, +} + +/// Function definition. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Function { + pub name: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub params: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub return_type: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub body: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_pub: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +/// Root IR node. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GuppyIR { + pub version: String, + pub functions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub source_file: Option, +} + +impl GuppyIR { + pub fn new() -> Self { + Self { + version: IR_VERSION.to_string(), + functions: Vec::new(), + source_file: None, + } + } +} + +impl Default for GuppyIR { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// IR Validation +// ============================================================================= + +/// IR validation error. +#[derive(Debug, Clone)] +pub enum ValidationError { + /// Missing required field for statement kind. + MissingField { kind: StmtKind, field: &'static str, location: Option }, + /// Missing required field for expression kind. + MissingExprField { kind: ExprKind, field: &'static str }, + /// Undefined variable reference. + UndefinedVariable { name: String, location: Option }, + /// Undefined allocator (qubit register) reference. + UndefinedAllocator { name: String, location: Option }, + /// Gate used before qalloc. + GateBeforeAlloc { allocator: String, location: Option }, + /// Invalid gate arity. + InvalidGateArity { gate: GateKind, expected: usize, actual: usize, location: Option }, + /// Unknown operator. + UnknownOperator { op: String, location: Option }, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ValidationError::MissingField { kind, field, .. } => + write!(f, "missing required field '{}' for {:?} statement", field, kind), + ValidationError::MissingExprField { kind, field } => + write!(f, "missing required field '{}' for {:?} expression", field, kind), + ValidationError::UndefinedVariable { name, .. } => + write!(f, "undefined variable: {}", name), + ValidationError::UndefinedAllocator { name, .. } => + write!(f, "undefined qubit allocator: {}", name), + ValidationError::GateBeforeAlloc { allocator, .. } => + write!(f, "gate uses allocator '{}' before it was allocated", allocator), + ValidationError::InvalidGateArity { gate, expected, actual, .. } => + write!(f, "gate {:?} expects {} targets, got {}", gate, expected, actual), + ValidationError::UnknownOperator { op, .. } => + write!(f, "unknown operator: {}", op), + } + } +} + +impl std::error::Error for ValidationError {} + +/// IR validation result. +#[derive(Debug, Clone, Default)] +pub struct ValidationResult { + pub errors: Vec, + pub warnings: Vec, +} + +impl ValidationResult { + pub fn is_valid(&self) -> bool { + self.errors.is_empty() + } + + pub fn add_error(&mut self, error: ValidationError) { + self.errors.push(error); + } + + pub fn add_warning(&mut self, warning: String) { + self.warnings.push(warning); + } +} + +/// IR validator with semantic tracking. +#[derive(Debug, Default)] +pub struct IrValidator { + /// Known variables in scope. + variables: std::collections::BTreeSet, + /// Known qubit allocators. + allocators: std::collections::BTreeSet, + /// Validation results. + result: ValidationResult, +} + +impl IrValidator { + pub fn new() -> Self { + Self::default() + } + + /// Validate the entire IR. + pub fn validate(&mut self, ir: &GuppyIR) -> ValidationResult { + for func in &ir.functions { + self.validate_function(func); + } + std::mem::take(&mut self.result) + } + + /// Validate a function. + fn validate_function(&mut self, func: &Function) { + // Clear per-function state + self.variables.clear(); + self.allocators.clear(); + + // Add parameters to variables + for param in &func.params { + self.variables.insert(param.name.clone()); + } + + // Validate body + for stmt in &func.body { + self.validate_stmt(stmt); + } + } + + /// Validate a statement. + fn validate_stmt(&mut self, stmt: &Stmt) { + match stmt.kind { + StmtKind::Qalloc => { + // Schema: requires name + if stmt.name.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "name", + location: stmt.location.clone(), + }); + } else { + let name = stmt.name.as_ref().unwrap(); + self.allocators.insert(name.clone()); + self.variables.insert(name.clone()); + } + } + + StmtKind::Gate => { + // Schema: requires gate + if stmt.gate.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "gate", + location: stmt.location.clone(), + }); + } else { + let gate = stmt.gate.as_ref().unwrap(); + // Check arity + if stmt.targets.len() != gate.arity() { + self.result.add_error(ValidationError::InvalidGateArity { + gate: *gate, + expected: gate.arity(), + actual: stmt.targets.len(), + location: stmt.location.clone(), + }); + } + } + // Validate targets reference known allocators + for target in &stmt.targets { + self.validate_qubit_ref(target, &stmt.location); + } + // Validate params are valid expressions + for param in &stmt.params { + self.validate_expr(param); + } + } + + StmtKind::Measure => { + // Validate targets + for target in &stmt.targets { + self.validate_qubit_ref(target, &stmt.location); + } + // Add results to variables + for result in &stmt.results { + self.variables.insert(result.clone()); + } + } + + StmtKind::For => { + // Schema: requires var and range + if stmt.var.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "var", + location: stmt.location.clone(), + }); + } + if stmt.range.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "range", + location: stmt.location.clone(), + }); + } + // Add loop var to scope and validate body + if let Some(var) = &stmt.var { + self.variables.insert(var.clone()); + } + for s in &stmt.body { + self.validate_stmt(s); + } + } + + StmtKind::While => { + // Schema: requires condition + if stmt.condition.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "condition", + location: stmt.location.clone(), + }); + } else { + self.validate_expr(stmt.condition.as_ref().unwrap()); + } + for s in &stmt.body { + self.validate_stmt(s); + } + } + + StmtKind::If => { + // Schema: requires condition + if stmt.condition.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "condition", + location: stmt.location.clone(), + }); + } else { + self.validate_expr(stmt.condition.as_ref().unwrap()); + } + for s in &stmt.then_body { + self.validate_stmt(s); + } + for s in &stmt.else_body { + self.validate_stmt(s); + } + } + + StmtKind::Assign => { + // Validate target and value + if let Some(target) = &stmt.target { + // For simple idents, don't require prior definition (could be new var) + self.validate_expr_allow_new_var(target); + } + if let Some(value) = &stmt.value { + self.validate_expr(value); + } + // If target is a simple ident, add to variables + if let Some(target) = &stmt.target + && target.kind == ExprKind::Ident + && let Some(name) = &target.name + { + self.variables.insert(name.clone()); + } + } + + StmtKind::Binding => { + // Schema: requires name + if stmt.name.is_none() { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "name", + location: stmt.location.clone(), + }); + } else { + self.variables.insert(stmt.name.clone().unwrap()); + } + if let Some(value) = &stmt.value { + self.validate_expr(value); + } + } + + StmtKind::Return => { + if let Some(value) = &stmt.return_value { + self.validate_expr(value); + } + } + + StmtKind::Expr => { + if let Some(expr) = &stmt.expr { + self.validate_expr(expr); + } + } + + StmtKind::Result => { + if let Some(value) = &stmt.value { + self.validate_expr(value); + } + } + + StmtKind::Break | StmtKind::Continue | StmtKind::Barrier => { + // No additional validation needed + } + } + } + + /// Validate a qubit reference (index into allocator). + fn validate_qubit_ref(&mut self, expr: &Expr, stmt_location: &Option) { + match &expr.kind { + ExprKind::Index => { + if let Some(array) = &expr.array + && !self.allocators.contains(array) + { + self.result.add_error(ValidationError::UndefinedAllocator { + name: array.clone(), + location: stmt_location.clone(), + }); + } + } + ExprKind::Ident => { + // Single qubit reference + if let Some(name) = &expr.name + && !self.variables.contains(name) + { + self.result.add_error(ValidationError::UndefinedVariable { + name: name.clone(), + location: stmt_location.clone(), + }); + } + } + _ => {} + } + } + + /// Validate an expression (recursively). + fn validate_expr(&mut self, expr: &Expr) { + match &expr.kind { + ExprKind::Ident => { + if let Some(name) = &expr.name + && !self.variables.contains(name) + { + self.result.add_error(ValidationError::UndefinedVariable { + name: name.clone(), + location: expr.location.clone(), + }); + } + } + ExprKind::Index => { + if let Some(array) = &expr.array + && !self.variables.contains(array) && !self.allocators.contains(array) + { + self.result.add_error(ValidationError::UndefinedVariable { + name: array.clone(), + location: expr.location.clone(), + }); + } + if let Some(index) = &expr.index { + self.validate_expr(index); + } + } + ExprKind::Binary => { + // Validate operator + if let Some(op) = &expr.op { + let valid_ops = [ + "add", "sub", "mul", "div", "floordiv", "mod", + "eq", "ne", "lt", "le", "gt", "ge", + "and", "or", "bitand", "bitor", "bitxor", "shl", "shr", + ]; + if !valid_ops.contains(&op.as_str()) { + self.result.add_error(ValidationError::UnknownOperator { + op: op.clone(), + location: expr.location.clone(), + }); + } + } + if let Some(left) = &expr.left { + self.validate_expr(left); + } + if let Some(right) = &expr.right { + self.validate_expr(right); + } + } + ExprKind::Unary => { + if let Some(op) = &expr.op { + let valid_ops = ["neg", "not", "bitnot"]; + if !valid_ops.contains(&op.as_str()) { + self.result.add_error(ValidationError::UnknownOperator { + op: op.clone(), + location: expr.location.clone(), + }); + } + } + if let Some(operand) = &expr.operand { + self.validate_expr(operand); + } + } + ExprKind::Call => { + for arg in &expr.args { + self.validate_expr(arg); + } + } + ExprKind::Field => { + if let Some(object) = &expr.object { + self.validate_expr(object); + } + } + ExprKind::Tuple => { + for arg in &expr.args { + self.validate_expr(arg); + } + } + ExprKind::Literal => { + // No validation needed for literals + } + } + } + + /// Validate expression but allow undefined identifiers (for assignment targets). + fn validate_expr_allow_new_var(&mut self, expr: &Expr) { + match &expr.kind { + ExprKind::Ident => { + // Allow undefined - this might be a new variable + } + ExprKind::Index => { + // Array must exist, but we check elsewhere + if let Some(index) = &expr.index { + self.validate_expr(index); + } + } + _ => self.validate_expr(expr), + } + } +} + +/// Validate IR and return result. +pub fn validate_ir(ir: &GuppyIR) -> ValidationResult { + let mut validator = IrValidator::new(); + validator.validate(ir) +} + +// ============================================================================= +// IR Emission +// ============================================================================= + +/// Error during IR emission. +#[derive(Debug, Clone)] +pub enum EmitError { + /// Parse error from rustpython. + Parse(String), + /// The input was not a module. + NotAModule, + /// Unsupported Python construct. + Unsupported(String), + /// Skip this node (internal). + Skip, +} + +impl std::fmt::Display for EmitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EmitError::Parse(msg) => write!(f, "parse error: {}", msg), + EmitError::NotAModule => write!(f, "expected a module"), + EmitError::Unsupported(msg) => write!(f, "unsupported: {}", msg), + EmitError::Skip => write!(f, "skip"), + } + } +} + +impl std::error::Error for EmitError {} + +/// Emit IR from Python source. +pub fn emit_ir(source: &str, filename: Option<&str>) -> Result { + let parsed = rustpython_parser::parse( + source, + rustpython_parser::Mode::Module, + filename.unwrap_or(""), + ) + .map_err(|e| EmitError::Parse(e.to_string()))?; + + let mut ir = GuppyIR::new(); + ir.source_file = filename.map(String::from); + + let Mod::Module(module) = parsed else { + return Err(EmitError::NotAModule); + }; + + for stmt in module.body { + if let PyStmt::FunctionDef(func) = stmt { + ir.functions.push(convert_function(&func, source)?); + } + } + + Ok(ir) +} + +fn convert_function(func: &ast::StmtFunctionDef, source: &str) -> Result { + let params = func + .args + .args + .iter() + .map(|arg| { + let ty = arg + .def + .annotation + .as_ref() + .map(|ann| convert_type_annotation(ann)) + .unwrap_or_else(|| TypeExpr { + kind: "primitive".to_string(), + name: Some("unknown".to_string()), + element: None, + size: None, + elements: Vec::new(), + }); + Param { + name: arg.def.arg.to_string(), + ty, + } + }) + .collect(); + + let return_type = func + .returns + .as_ref() + .map(|ann| convert_type_annotation(ann)); + + let body = func + .body + .iter() + .filter_map(|stmt| convert_stmt(stmt).ok()) + .collect(); + + let (line, column) = offset_to_line_col(source, func.range.start().into()); + let (end_line, end_column) = offset_to_line_col(source, func.range.end().into()); + + Ok(Function { + name: func.name.to_string(), + params, + return_type, + body, + is_pub: None, + location: Some(SourceLocation { + line: line as u32, + column: column as u32, + end_line: Some(end_line as u32), + end_column: Some(end_column as u32), + file: None, + }), + }) +} + +fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { + let mut line = 1; + let mut col = 1; + for (i, ch) in source.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (line, col) +} + +fn convert_type_annotation(ann: &PyExpr) -> TypeExpr { + match ann { + PyExpr::Name(name) => { + let name_str = name.id.as_str(); + match name_str { + "int" | "float" | "bool" | "str" | "None" => TypeExpr { + kind: "primitive".to_string(), + name: Some(name_str.to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + "qubit" => TypeExpr { + kind: "qalloc".to_string(), + name: None, + element: None, + size: None, + elements: Vec::new(), + }, + _ => TypeExpr { + kind: "named".to_string(), + name: Some(name_str.to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + } + } + PyExpr::Subscript(sub) => { + if let PyExpr::Name(name) = sub.value.as_ref() { + let name_str = name.id.as_str(); + match name_str { + "list" | "List" => TypeExpr { + kind: "array".to_string(), + name: None, + element: Some(Box::new(convert_type_annotation(&sub.slice))), + size: None, + elements: Vec::new(), + }, + "Optional" => TypeExpr { + kind: "optional".to_string(), + name: None, + element: Some(Box::new(convert_type_annotation(&sub.slice))), + size: None, + elements: Vec::new(), + }, + "qubit" => TypeExpr { + kind: "qalloc".to_string(), + name: None, + element: None, + size: convert_expr(&sub.slice).ok().map(Box::new), + elements: Vec::new(), + }, + "tuple" => { + // tuple[T1, T2, ...] - the slice is a Tuple of types + let elements = if let PyExpr::Tuple(tuple) = sub.slice.as_ref() { + tuple.elts.iter().map(convert_type_annotation).collect() + } else { + // Single element tuple + vec![convert_type_annotation(&sub.slice)] + }; + TypeExpr { + kind: "tuple".to_string(), + name: None, + element: None, + size: None, + elements, + } + }, + _ => TypeExpr { + kind: "named".to_string(), + name: Some(name_str.to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + } + } else { + TypeExpr { + kind: "primitive".to_string(), + name: Some("unknown".to_string()), + element: None, + size: None, + elements: Vec::new(), + } + } + } + PyExpr::Constant(c) if matches!(c.value, Constant::None) => TypeExpr { + kind: "primitive".to_string(), + name: Some("None".to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + _ => TypeExpr { + kind: "primitive".to_string(), + name: Some("unknown".to_string()), + element: None, + size: None, + elements: Vec::new(), + }, + } +} + +fn convert_stmt(stmt: &PyStmt) -> Result { + match stmt { + PyStmt::Assign(assign) => { + if assign.targets.len() != 1 { + return Err(EmitError::Unsupported( + "multiple assignment targets".into(), + )); + } + + // Check for qalloc: q = qubit[4] + if let PyExpr::Subscript(sub) = assign.value.as_ref() + && let PyExpr::Name(name) = sub.value.as_ref() + && (name.id.as_str() == "qubit" || name.id.as_str() == "qalloc") + && let PyExpr::Name(target) = &assign.targets[0] + { + return Ok(Stmt { + kind: StmtKind::Qalloc, + name: Some(target.id.to_string()), + size: convert_expr(&sub.slice).ok(), + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Check for single qubit alloc: q = qubit() + if let PyExpr::Call(call) = assign.value.as_ref() + && let Some(callee_name) = get_callee_name(&call.func) + && (callee_name == "qubit" || callee_name == "qalloc") + && let PyExpr::Name(target) = &assign.targets[0] + { + // Single qubit allocation - size is 1 + return Ok(Stmt { + kind: StmtKind::Qalloc, + name: Some(target.id.to_string()), + size: Some(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(1)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }), + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Check for measure: m = measure(q) + if let PyExpr::Call(call) = assign.value.as_ref() + && let Some(callee_name) = get_callee_name(&call.func) + && (callee_name == "measure" || callee_name == "mz" || callee_name == "measure_all") + && let PyExpr::Name(result_var) = &assign.targets[0] + { + let targets = call + .args + .iter() + .filter_map(|arg| convert_expr(arg).ok()) + .collect(); + + return Ok(Stmt { + kind: StmtKind::Measure, + name: None, + size: None, + gate: None, + targets, + params: Vec::new(), + results: vec![result_var.id.to_string()], + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + let target = convert_expr(&assign.targets[0])?; + let value = convert_expr(&assign.value)?; + + Ok(Stmt { + kind: StmtKind::Assign, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: Some(target), + value: Some(value), + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + PyStmt::Expr(expr_stmt) => { + // Check for gate calls + if let PyExpr::Call(call) = expr_stmt.value.as_ref() { + let callee_name = get_callee_name(&call.func); + if let Some(name) = &callee_name { + if let Some(gate) = GateKind::from_name(name) { + let mut targets: Vec = call + .args + .iter() + .filter_map(|arg| convert_expr(arg).ok()) + .collect(); + + // For parameterized gates like rx(q, angle), the last argument is the angle + let mut params = Vec::new(); + if gate.is_parameterized() && !targets.is_empty() { + params.push(targets.pop().unwrap()); + } + + return Ok(Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: Some(gate), + targets, + params, + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Check for measure + if name == "measure" || name == "mz" || name == "measure_all" { + let targets = call + .args + .iter() + .filter_map(|arg| convert_expr(arg).ok()) + .collect(); + + return Ok(Stmt { + kind: StmtKind::Measure, + name: None, + size: None, + gate: None, + targets, + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Check for result: result(tag, value) + if name == "result" && call.args.len() >= 2 { + let tag = if let PyExpr::Constant(c) = &call.args[0] { + if let Constant::Str(s) = &c.value { + Some(s.to_string()) + } else { + None + } + } else { + None + }; + + let value = convert_expr(&call.args[1]).ok(); + + return Ok(Stmt { + kind: StmtKind::Result, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag, + location: None, + }); + } + } + } + + // Skip docstrings (string constant expressions) + if let PyExpr::Constant(c) = expr_stmt.value.as_ref() + && matches!(c.value, Constant::Str(_)) { + return Err(EmitError::Skip); + } + + let expr = convert_expr(&expr_stmt.value)?; + Ok(Stmt { + kind: StmtKind::Expr, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: Some(expr), + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + PyStmt::For(for_stmt) => { + let var = if let PyExpr::Name(name) = for_stmt.target.as_ref() { + name.id.to_string() + } else { + return Err(EmitError::Unsupported("complex for target".into())); + }; + + let range = if let PyExpr::Call(call) = for_stmt.iter.as_ref() { + if let PyExpr::Name(name) = call.func.as_ref() { + if name.id.as_str() == "range" { + let args: Vec<_> = call + .args + .iter() + .filter_map(|a| convert_expr(a).ok()) + .collect(); + + if args.len() == 1 { + Some(RangeExpr { + start: Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(0)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }, + end: args.into_iter().next().unwrap(), + }) + } else if args.len() >= 2 { + let mut iter = args.into_iter(); + Some(RangeExpr { + start: iter.next().unwrap(), + end: iter.next().unwrap(), + }) + } else { + None + } + } else { + None + } + } else { + None + } + } else { + None + }; + + let body = for_stmt + .body + .iter() + .filter_map(|s| convert_stmt(s).ok()) + .collect(); + + Ok(Stmt { + kind: StmtKind::For, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: Some(var), + range, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body, + tag: None, + location: None, + }) + } + + PyStmt::While(while_stmt) => { + let condition = convert_expr(&while_stmt.test)?; + let body = while_stmt + .body + .iter() + .filter_map(|s| convert_stmt(s).ok()) + .collect(); + + Ok(Stmt { + kind: StmtKind::While, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: Some(condition), + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body, + tag: None, + location: None, + }) + } + + PyStmt::Break(_) => Ok(Stmt { + kind: StmtKind::Break, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }), + + PyStmt::Continue(_) => Ok(Stmt { + kind: StmtKind::Continue, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }), + + PyStmt::If(if_stmt) => { + let condition = convert_expr(&if_stmt.test)?; + let then_body = if_stmt + .body + .iter() + .filter_map(|s| convert_stmt(s).ok()) + .collect(); + let else_body = if_stmt + .orelse + .iter() + .filter_map(|s| convert_stmt(s).ok()) + .collect(); + + Ok(Stmt { + kind: StmtKind::If, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: Some(condition), + then_body, + else_body, + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + PyStmt::Return(ret) => { + let return_value = ret.value.as_ref().and_then(|v| convert_expr(v).ok()); + + Ok(Stmt { + kind: StmtKind::Return, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + PyStmt::Pass(_) => Err(EmitError::Skip), + + PyStmt::AnnAssign(ann) => { + // Handle annotated assignment: x: int = 5 + let name = if let PyExpr::Name(name) = ann.target.as_ref() { + name.id.to_string() + } else { + return Err(EmitError::Unsupported("complex annotated target".into())); + }; + + // Check for qalloc: q: qubit[4] or q: qubit[4] = ... + if let PyExpr::Subscript(sub) = ann.annotation.as_ref() + && let PyExpr::Name(type_name) = sub.value.as_ref() + && (type_name.id.as_str() == "qubit" || type_name.id.as_str() == "qalloc") { + return Ok(Stmt { + kind: StmtKind::Qalloc, + name: Some(name), + size: convert_expr(&sub.slice).ok(), + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } + + // Regular annotated assignment -> Binding + let ty = convert_type_annotation(&ann.annotation); + let value = ann.value.as_ref().and_then(|v| convert_expr(v).ok()); + + Ok(Stmt { + kind: StmtKind::Binding, + name: Some(name), + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value, + return_value: None, + expr: None, + ty: Some(ty), + is_mutable: Some(true), // Assume mutable by default + body: Vec::new(), + tag: None, + location: None, + }) + } + + // Augmented assignment: i += 1, x -= 2, etc. + PyStmt::AugAssign(aug) => { + let target = convert_expr(&aug.target)?; + let value = convert_expr(&aug.value)?; + + // Convert augmented assignment to regular assignment with binary op + // i += 1 becomes i = i + 1 + let op = match aug.op { + ast::Operator::Add => "add", + ast::Operator::Sub => "sub", + ast::Operator::Mult => "mul", + ast::Operator::Div => "div", + ast::Operator::Mod => "mod", + ast::Operator::BitAnd => "bitand", + ast::Operator::BitOr => "bitor", + ast::Operator::BitXor => "bitxor", + ast::Operator::LShift => "shl", + ast::Operator::RShift => "shr", + _ => return Err(EmitError::Unsupported(format!("augmented operator: {:?}", aug.op))), + }; + + Ok(Stmt { + kind: StmtKind::Assign, + name: None, + size: None, + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: Some(target.clone()), + value: Some(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(target)), + right: Some(Box::new(value)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }), + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }) + } + + _ => Err(EmitError::Unsupported(format!( + "statement type: {:?}", + std::mem::discriminant(stmt) + ))), + } +} + +fn convert_expr(expr: &PyExpr) -> Result { + match expr { + PyExpr::Constant(c) => { + let value = match &c.value { + Constant::Int(i) => serde_json::json!(i.to_string().parse::().unwrap_or(0)), + Constant::Float(f) => serde_json::json!(f), + Constant::Str(s) => serde_json::json!(s), + Constant::Bool(b) => serde_json::json!(b), + Constant::None => serde_json::Value::Null, + _ => serde_json::json!(null), + }; + Ok(Expr { + kind: ExprKind::Literal, + value: Some(value), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } + + PyExpr::Name(name) => Ok(Expr { + kind: ExprKind::Ident, + value: None, + name: Some(name.id.to_string()), + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }), + + PyExpr::Subscript(sub) => { + if let PyExpr::Name(name) = sub.value.as_ref() { + let index = convert_expr(&sub.slice)?; + Ok(Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some(name.id.to_string()), + index: Some(Box::new(index)), + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } else { + Err(EmitError::Unsupported("complex subscript".into())) + } + } + + PyExpr::BinOp(binop) => { + let left = convert_expr(&binop.left)?; + let right = convert_expr(&binop.right)?; + let op = match binop.op { + ast::Operator::Add => "add", + ast::Operator::Sub => "sub", + ast::Operator::Mult => "mul", + ast::Operator::Div => "div", + ast::Operator::FloorDiv => "floordiv", + ast::Operator::Mod => "mod", + ast::Operator::BitAnd => "bitand", + ast::Operator::BitOr => "bitor", + ast::Operator::BitXor => "bitxor", + ast::Operator::LShift => "shl", + ast::Operator::RShift => "shr", + other => return Err(EmitError::Unsupported(format!("binary operator: {:?}", other))), + }; + + Ok(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(left)), + right: Some(Box::new(right)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } + + PyExpr::UnaryOp(unary) => { + let operand = convert_expr(&unary.operand)?; + let op = match unary.op { + ast::UnaryOp::UAdd => "pos", + ast::UnaryOp::USub => "neg", + ast::UnaryOp::Not => "not", + ast::UnaryOp::Invert => "bitnot", + }; + + Ok(Expr { + kind: ExprKind::Unary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: None, + right: None, + operand: Some(Box::new(operand)), + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } + + PyExpr::Compare(cmp) => { + let left = convert_expr(&cmp.left)?; + if let Some((cmpop, right_expr)) = cmp.ops.first().zip(cmp.comparators.first()) { + let right = convert_expr(right_expr)?; + let op = match cmpop { + ast::CmpOp::Eq => "eq", + ast::CmpOp::NotEq => "ne", + ast::CmpOp::Lt => "lt", + ast::CmpOp::LtE => "le", + ast::CmpOp::Gt => "gt", + ast::CmpOp::GtE => "ge", + other => return Err(EmitError::Unsupported(format!("comparison operator: {:?}", other))), + }; + + Ok(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(left)), + right: Some(Box::new(right)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }) + } else { + Ok(left) + } + } + + PyExpr::BoolOp(boolop) => { + // Handle `and` and `or` operators + // BoolOp can have multiple values: a and b and c + // We convert to nested binary expressions: (a and b) and c + let op = match boolop.op { + ast::BoolOp::And => "and", + ast::BoolOp::Or => "or", + }; + + if boolop.values.len() < 2 { + return Err(EmitError::Unsupported("boolop with < 2 values".into())); + } + + let mut iter = boolop.values.iter(); + let first = convert_expr(iter.next().unwrap())?; + let second = convert_expr(iter.next().unwrap())?; + + let mut result = Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(first)), + right: Some(Box::new(second)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }; + + // Handle chained operators: a and b and c -> (a and b) and c + for val in iter { + let right = convert_expr(val)?; + result = Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some(op.to_string()), + left: Some(Box::new(result)), + right: Some(Box::new(right)), + operand: None, + callee: None, + args: Vec::new(), + object: None, + field: None, + location: None, + }; + } + + Ok(result) + } + + PyExpr::Call(call) => { + let callee = get_callee_name(&call.func); + let args = call + .args + .iter() + .filter_map(|a| convert_expr(a).ok()) + .collect(); + + Ok(Expr { + kind: ExprKind::Call, + value: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee, + args, + object: None, + field: None, + location: None, + }) + } + + PyExpr::Attribute(attr) => { + let object = convert_expr(&attr.value)?; + Ok(Expr { + kind: ExprKind::Field, + value: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: Vec::new(), + object: Some(Box::new(object)), + field: Some(attr.attr.to_string()), + location: None, + }) + } + + PyExpr::Tuple(tuple) => { + let elements: Vec = tuple + .elts + .iter() + .filter_map(|e| convert_expr(e).ok()) + .collect(); + + Ok(Expr { + kind: ExprKind::Tuple, + value: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: elements, + object: None, + field: None, + location: None, + }) + } + + _ => Err(EmitError::Unsupported(format!( + "expression type: {:?}", + std::mem::discriminant(expr) + ))), + } +} + +fn get_callee_name(expr: &PyExpr) -> Option { + match expr { + PyExpr::Name(name) => Some(name.id.to_string()), + PyExpr::Attribute(attr) => Some(attr.attr.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod validation_tests { + use super::*; + + fn make_ir(body: Vec) -> GuppyIR { + GuppyIR { + version: IR_VERSION.to_string(), + functions: vec![Function { + name: "test".to_string(), + params: vec![], + return_type: None, + body, + is_pub: None, + location: None, + }], + source_file: None, + } + } + + #[test] + fn test_valid_qalloc_gate() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Qalloc, + name: Some("q".to_string()), + size: Some(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(4)), + name: None, array: None, index: None, op: None, + left: None, right: None, operand: None, + callee: None, args: vec![], object: None, field: None, location: None, + }), + gate: None, targets: vec![], params: vec![], results: vec![], + var: None, range: None, condition: None, then_body: vec![], + else_body: vec![], target: None, value: None, return_value: None, + expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, + }, + Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: Some(GateKind::H), + targets: vec![Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some("q".to_string()), + index: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(0)), + name: None, array: None, index: None, op: None, + left: None, right: None, operand: None, + callee: None, args: vec![], object: None, field: None, location: None, + })), + op: None, left: None, right: None, operand: None, + callee: None, args: vec![], object: None, field: None, location: None, + }], + params: vec![], results: vec![], var: None, range: None, condition: None, + then_body: vec![], else_body: vec![], target: None, value: None, + return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], + tag: None, location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!(result.is_valid(), "Expected valid IR, got errors: {:?}", result.errors); + } + + #[test] + fn test_undefined_allocator() { + let ir = make_ir(vec![ + // Use q[0] without allocating q first + Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: Some(GateKind::H), + targets: vec![Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some("q".to_string()), + index: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(0)), + name: None, array: None, index: None, op: None, + left: None, right: None, operand: None, + callee: None, args: vec![], object: None, field: None, location: None, + })), + op: None, left: None, right: None, operand: None, + callee: None, args: vec![], object: None, field: None, location: None, + }], + params: vec![], results: vec![], var: None, range: None, condition: None, + then_body: vec![], else_body: vec![], target: None, value: None, + return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], + tag: None, location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!(!result.is_valid(), "Expected validation error for undefined allocator"); + assert!(result.errors.iter().any(|e| matches!(e, ValidationError::UndefinedAllocator { .. }))); + } + + #[test] + fn test_missing_gate_field() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: None, // Missing required gate field + targets: vec![], params: vec![], results: vec![], var: None, range: None, + condition: None, then_body: vec![], else_body: vec![], target: None, value: None, + return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], + tag: None, location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!(!result.is_valid(), "Expected validation error for missing gate field"); + assert!(result.errors.iter().any(|e| matches!(e, ValidationError::MissingField { field: "gate", .. }))); + } + + #[test] + fn test_invalid_gate_arity() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Qalloc, + name: Some("q".to_string()), + size: Some(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(2)), + name: None, array: None, index: None, op: None, + left: None, right: None, operand: None, + callee: None, args: vec![], object: None, field: None, location: None, + }), + gate: None, targets: vec![], params: vec![], results: vec![], + var: None, range: None, condition: None, then_body: vec![], + else_body: vec![], target: None, value: None, return_value: None, + expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, + }, + Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: Some(GateKind::H), // H is 1-qubit gate + targets: vec![ + // But we provide 2 targets + Expr { kind: ExprKind::Index, value: None, name: None, array: Some("q".to_string()), + index: Some(Box::new(Expr { kind: ExprKind::Literal, value: Some(serde_json::json!(0)), + name: None, array: None, index: None, op: None, left: None, right: None, + operand: None, callee: None, args: vec![], object: None, field: None, location: None })), + op: None, left: None, right: None, operand: None, callee: None, args: vec![], + object: None, field: None, location: None }, + Expr { kind: ExprKind::Index, value: None, name: None, array: Some("q".to_string()), + index: Some(Box::new(Expr { kind: ExprKind::Literal, value: Some(serde_json::json!(1)), + name: None, array: None, index: None, op: None, left: None, right: None, + operand: None, callee: None, args: vec![], object: None, field: None, location: None })), + op: None, left: None, right: None, operand: None, callee: None, args: vec![], + object: None, field: None, location: None }, + ], + params: vec![], results: vec![], var: None, range: None, condition: None, + then_body: vec![], else_body: vec![], target: None, value: None, + return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], + tag: None, location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!(!result.is_valid(), "Expected validation error for invalid gate arity"); + assert!(result.errors.iter().any(|e| matches!(e, ValidationError::InvalidGateArity { .. }))); + } + + #[test] + fn test_unknown_operator() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Return, + name: None, size: None, gate: None, targets: vec![], params: vec![], + results: vec![], var: None, range: None, condition: None, then_body: vec![], + else_body: vec![], target: None, value: None, + return_value: Some(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some("invalid_op".to_string()), // Unknown operator + left: Some(Box::new(Expr { + kind: ExprKind::Literal, value: Some(serde_json::json!(1)), + name: None, array: None, index: None, op: None, left: None, right: None, + operand: None, callee: None, args: vec![], object: None, field: None, location: None, + })), + right: Some(Box::new(Expr { + kind: ExprKind::Literal, value: Some(serde_json::json!(2)), + name: None, array: None, index: None, op: None, left: None, right: None, + operand: None, callee: None, args: vec![], object: None, field: None, location: None, + })), + operand: None, callee: None, args: vec![], object: None, field: None, location: None, + }), + expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!(!result.is_valid(), "Expected validation error for unknown operator"); + assert!(result.errors.iter().any(|e| matches!(e, ValidationError::UnknownOperator { .. }))); + } + + #[test] + fn test_undefined_variable() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Return, + name: None, size: None, gate: None, targets: vec![], params: vec![], + results: vec![], var: None, range: None, condition: None, then_body: vec![], + else_body: vec![], target: None, value: None, + return_value: Some(Expr { + kind: ExprKind::Ident, + value: None, + name: Some("undefined_var".to_string()), + array: None, index: None, op: None, left: None, right: None, + operand: None, callee: None, args: vec![], object: None, field: None, location: None, + }), + expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!(!result.is_valid(), "Expected validation error for undefined variable"); + assert!(result.errors.iter().any(|e| matches!(e, ValidationError::UndefinedVariable { .. }))); + } + + #[test] + fn test_variable_from_binding() { + let ir = make_ir(vec![ + Stmt { + kind: StmtKind::Binding, + name: Some("x".to_string()), + size: None, gate: None, targets: vec![], params: vec![], results: vec![], + var: None, range: None, condition: None, then_body: vec![], else_body: vec![], + target: None, + value: Some(Expr { + kind: ExprKind::Literal, value: Some(serde_json::json!(42)), + name: None, array: None, index: None, op: None, left: None, right: None, + operand: None, callee: None, args: vec![], object: None, field: None, location: None, + }), + return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], + tag: None, location: None, + }, + Stmt { + kind: StmtKind::Return, + name: None, size: None, gate: None, targets: vec![], params: vec![], + results: vec![], var: None, range: None, condition: None, then_body: vec![], + else_body: vec![], target: None, value: None, + return_value: Some(Expr { + kind: ExprKind::Ident, + value: None, + name: Some("x".to_string()), + array: None, index: None, op: None, left: None, right: None, + operand: None, callee: None, args: vec![], object: None, field: None, location: None, + }), + expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, + }, + ]); + + let result = validate_ir(&ir); + assert!(result.is_valid(), "Expected valid IR with defined variable, got errors: {:?}", result.errors); + } +} diff --git a/exp/guppy-zlup/src/lib.rs b/exp/guppy-zlup/src/lib.rs new file mode 100644 index 000000000..eb1eddbe8 --- /dev/null +++ b/exp/guppy-zlup/src/lib.rs @@ -0,0 +1,414 @@ +//! # guppy-zlup +//! +//! Guppy linter and Zlup compiler. +//! +//! This crate provides: +//! - **Linting**: Validates Guppy quantum programs against NASA Power of 10 rules +//! - **Compilation**: Transforms validated Guppy IR into Zlup source code +//! +//! ## Usage +//! +//! ### Linting +//! +//! ```rust +//! use guppy_zlup::lint_source; +//! +//! // Lint source code +//! let result = lint_source("def main(): pass", None); +//! if result.has_errors { +//! for diag in &result.diagnostics { +//! println!("{}", diag); +//! } +//! } +//! assert!(!result.has_errors); +//! ``` +//! +//! To lint a file: +//! +//! ```rust,no_run +//! use guppy_zlup::lint_file; +//! +//! let result = lint_file("example.py").unwrap(); +//! ``` +//! +//! ### Compilation +//! +//! ```rust +//! use guppy_zlup::{compile, compile_to_ast}; +//! +//! let ir_json = r#"{ +//! "version": "0.1.0", +//! "functions": [{ +//! "name": "main", +//! "params": [], +//! "body": [ +//! {"kind": "qalloc", "name": "q", "size": {"kind": "literal", "value": 2}}, +//! {"kind": "gate", "gate": "h", "targets": [ +//! {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} +//! ]} +//! ] +//! }] +//! }"#; +//! +//! // Get Zlup source code directly +//! let source = compile(ir_json).unwrap(); +//! assert!(source.contains("fn main")); +//! +//! // Or get the Zlup AST for programmatic use +//! let ast = compile_to_ast(ir_json).unwrap(); +//! assert_eq!(ast.declarations.len(), 1); +//! ``` + +#![warn(clippy::all)] +#![allow(dead_code)] + +pub mod compiler; +pub mod ir; +pub mod linter; + +// Re-export zlup::ast for users who want to work with the AST +pub use zlup::ast as zlup_ast; + +// Re-export linter types +pub use linter::{Config, Diagnostic, LintResult, Linter, LowerError, OutputFormat, Severity}; + +// Re-export IR types +pub use ir::{GuppyIR, IrValidator, ValidationError, ValidationResult, validate_ir}; + +// Re-export compiler types +pub use compiler::{parse_ir, ParseError, TransformError}; + +/// Crate version +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +// ============================================================================= +// Linter API +// ============================================================================= + +/// Lint a source string and return diagnostics. +pub fn lint_source(source: &str, filename: Option<&str>) -> LintResult { + let config = Config::default(); + let linter = Linter::new(config); + linter.lint_source(source, filename.unwrap_or("")) +} + +/// Lint a file and return diagnostics. +pub fn lint_file(path: &str) -> Result { + let source = std::fs::read_to_string(path)?; + Ok(lint_source(&source, Some(path))) +} + +// ============================================================================= +// Compiler API +// ============================================================================= + +/// Compile Guppy IR JSON to Zlup AST. +/// +/// Use this when you need programmatic access to the Zlup AST, +/// e.g., for further transformation or analysis. +pub fn compile_to_ast(ir_json: &str) -> Result { + let ir = compiler::parse_ir(ir_json)?; + let zlup_ast = compiler::transform(&ir)?; + Ok(zlup_ast) +} + +/// Compile Guppy IR JSON to Zlup source code. +/// +/// This uses Zlup's canonical formatter for consistent output. +/// The output is validated by parsing it back through Zlup's parser. +pub fn compile(ir_json: &str) -> Result { + let zlup_ast = compile_to_ast(ir_json)?; + let options = zlup::pretty::PrettyOptions::default(); + let zlup_source = zlup::pretty::pretty_print(&zlup_ast, &options); + + // Validate the generated Zlup by parsing it back + validate_zlup(&zlup_source)?; + + Ok(zlup_source) +} + +/// Validate generated Zlup source by parsing and analyzing it. +/// +/// This ensures the generated code is syntactically and semantically valid. +pub fn validate_zlup(source: &str) -> Result<(), CompileError> { + // Parse the Zlup source + let program = zlup::parse(source).map_err(|e| { + CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Generated Zlup failed to parse: {}", + e + ))) + })?; + + // Run semantic analysis (permissive mode since we're validating generated code) + let mut analyzer = zlup::semantic::SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).map_err(|e| { + CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Generated Zlup failed semantic analysis: {}", + e + ))) + })?; + + Ok(()) +} + +/// Validate with round-trip: generated AST → source → parsed AST comparison. +/// +/// This ensures the pretty printer produces code that parses back to an equivalent AST. +pub fn validate_zlup_roundtrip(original_ast: &zlup::ast::Program, source: &str) -> Result<(), CompileError> { + // First do basic validation + validate_zlup(source)?; + + // Parse back + let reparsed = zlup::parse(source).map_err(|e| { + CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Round-trip: failed to reparse: {}", + e + ))) + })?; + + // Compare key structural properties + let original_fns: Vec<_> = original_ast.declarations.iter() + .filter_map(|d| match d { + zlup_ast::TopLevelDecl::Fn(f) => Some(f), + _ => None, + }) + .collect(); + + let reparsed_fns: Vec<_> = reparsed.declarations.iter() + .filter_map(|d| match d { + zlup_ast::TopLevelDecl::Fn(f) => Some(f), + _ => None, + }) + .collect(); + + if original_fns.len() != reparsed_fns.len() { + return Err(CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Round-trip: function count mismatch (original: {}, reparsed: {})", + original_fns.len(), + reparsed_fns.len() + )))); + } + + for (orig, repr) in original_fns.iter().zip(reparsed_fns.iter()) { + if orig.name != repr.name { + return Err(CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Round-trip: function name mismatch (original: {}, reparsed: {})", + orig.name, repr.name + )))); + } + if orig.params.len() != repr.params.len() { + return Err(CompileError::Transform(compiler::TransformError::ValidationFailed(format!( + "Round-trip: parameter count mismatch for '{}' (original: {}, reparsed: {})", + orig.name, orig.params.len(), repr.params.len() + )))); + } + } + + Ok(()) +} + +/// Compile and validate with round-trip checking. +pub fn compile_with_roundtrip(ir_json: &str) -> Result { + let ir = compiler::parse_ir(ir_json)?; + let zlup_ast = compiler::transform(&ir)?; + let options = zlup::pretty::PrettyOptions::default(); + let zlup_source = zlup::pretty::pretty_print(&zlup_ast, &options); + + // Validate with round-trip + validate_zlup_roundtrip(&zlup_ast, &zlup_source)?; + + Ok(zlup_source) +} + +/// Compile Guppy IR from a file to Zlup source code. +pub fn compile_file(path: &str) -> Result { + let json = std::fs::read_to_string(path).map_err(CompileError::Io)?; + compile(&json) +} + +/// Compile Guppy IR from a file to Zlup AST. +pub fn compile_file_to_ast(path: &str) -> Result { + let json = std::fs::read_to_string(path).map_err(CompileError::Io)?; + compile_to_ast(&json) +} + +/// Compilation error. +#[derive(Debug, thiserror::Error)] +pub enum CompileError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Parse error: {0}")] + Parse(#[from] compiler::ParseError), + + #[error("Transform error: {0}")] + Transform(#[from] compiler::TransformError), +} + +// ============================================================================= +// Combined API +// ============================================================================= + +/// Lint and compile a Guppy source file to Zlup. +/// +/// This is the main entry point for the full pipeline: +/// 1. Parse the Guppy source +/// 2. Run lint checks +/// 3. Lower to IR +/// 4. Validate IR +/// 5. Transform to Zlup AST +/// 6. Pretty-print to Zlup source +/// 7. Validate generated Zlup +pub fn lint_and_compile(source: &str, filename: Option<&str>) -> Result { + let filename = filename.unwrap_or(""); + + // Run linter + let config = Config::default(); + let linter = Linter::new(config); + let result = linter.lint_source(source, filename); + + if result.has_errors { + return Err(PipelineError::LintErrors(result)); + } + + // Emit IR + let ir = ir::emit_ir(source, Some(filename)).map_err(PipelineError::Emit)?; + + // Validate IR + let validation = ir::validate_ir(&ir); + if !validation.is_valid() { + return Err(PipelineError::IrValidation(validation)); + } + + // Compile to Zlup + let ir_json = serde_json::to_string(&ir).map_err(|e| PipelineError::Serialize(e.to_string()))?; + let zlup_source = compile(&ir_json)?; + + Ok(zlup_source) +} + +/// Pipeline error encompassing all stages. +#[derive(Debug, thiserror::Error)] +pub enum PipelineError { + #[error("Lint errors found")] + LintErrors(LintResult), + + #[error("IR emission error: {0}")] + Emit(#[from] ir::EmitError), + + #[error("IR validation errors: {0:?}")] + IrValidation(ir::ValidationResult), + + #[error("Serialization error: {0}")] + Serialize(String), + + #[error("Compilation error: {0}")] + Compile(#[from] CompileError), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() { + assert!(!VERSION.is_empty()); + } + + #[test] + fn test_lint_empty_source() { + let result = lint_source("", None); + assert!(result.is_ok(false)); + } + + #[test] + fn test_lint_simple_function() { + let source = r#" +def main() -> None: + pass +"#; + let result = lint_source(source, None); + assert!(!result.has_errors); + } + + #[test] + fn test_lint_while_true() { + let source = r#" +def main(): + while True: + pass +"#; + let result = lint_source(source, None); + assert!(result.has_errors); + assert!(result.diagnostics.iter().any(|d| d.rule_id == "ZLUP001")); + } + + #[test] + fn test_compile_simple() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "main", + "params": [], + "body": [ + {"kind": "qalloc", "name": "q", "size": {"kind": "literal", "value": 4}}, + {"kind": "gate", "gate": "h", "targets": [{"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}]} + ] + } + ] + }"#; + + let result = compile(ir); + assert!(result.is_ok(), "Compile failed: {:?}", result.err()); + let zlup = result.unwrap(); + assert!(zlup.contains("fn main")); + assert!(zlup.contains("qalloc")); + } + + #[test] + fn test_roundtrip_simple() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_func", + "params": [ + {"name": "x", "type": {"kind": "primitive", "name": "int"}} + ], + "return_type": {"kind": "primitive", "name": "int"}, + "body": [ + {"kind": "return", "return_value": {"kind": "ident", "name": "x"}} + ] + } + ] + }"#; + + let result = compile_with_roundtrip(ir); + assert!(result.is_ok(), "Round-trip compile failed: {:?}", result.err()); + } + + #[test] + fn test_roundtrip_complex() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "quantum_ops", + "params": [], + "body": [ + {"kind": "qalloc", "name": "q", "size": {"kind": "literal", "value": 2}}, + {"kind": "gate", "gate": "h", "targets": [{"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}]}, + {"kind": "gate", "gate": "cx", "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ]} + ] + } + ] + }"#; + + let result = compile_with_roundtrip(ir); + assert!(result.is_ok(), "Round-trip compile failed: {:?}", result.err()); + } +} diff --git a/exp/guppy-zlup/src/linter.rs b/exp/guppy-zlup/src/linter.rs new file mode 100644 index 000000000..0004c7666 --- /dev/null +++ b/exp/guppy-zlup/src/linter.rs @@ -0,0 +1,18 @@ +//! Guppy linter module. +//! +//! Validates Guppy quantum programs against NASA Power of 10 rules. + +pub mod ast; +pub mod config; +pub mod diagnostic; +pub mod engine; +pub mod lower; +pub mod noqa; +pub mod output; +pub mod rules; + +pub use config::Config; +pub use diagnostic::{Diagnostic, Severity}; +pub use engine::{LintResult, Linter}; +pub use lower::{lower_source, LowerError}; +pub use output::OutputFormat; diff --git a/exp/guppy-zlup/src/linter/ast.rs b/exp/guppy-zlup/src/linter/ast.rs new file mode 100644 index 000000000..f2d60fd88 --- /dev/null +++ b/exp/guppy-zlup/src/linter/ast.rs @@ -0,0 +1,595 @@ +//! Guppy AST - Clean Rust representation of Guppy programs. +//! +//! This module defines a Rust AST for Guppy quantum programs. It provides +//! a clean, typed representation that is independent of the Python parser. + +use serde::{Deserialize, Serialize}; + +/// A source span for error reporting. +#[derive(Debug, Clone, Copy, Default)] +pub struct Span { + pub start: usize, + pub end: usize, +} + +impl Span { + pub fn new(start: usize, end: usize) -> Self { + Self { start, end } + } +} + +/// A Guppy module (compilation unit). +#[derive(Debug, Clone)] +pub struct Module { + pub functions: Vec, + pub span: Span, +} + +/// A function definition. +#[derive(Debug, Clone)] +pub struct Function { + pub name: String, + pub params: Vec, + pub return_type: Option, + pub body: Vec, + pub decorators: Vec, + pub span: Span, +} + +/// A function parameter. +#[derive(Debug, Clone)] +pub struct Param { + pub name: String, + pub ty: Option, + pub span: Span, +} + +/// Type expressions. +#[derive(Debug, Clone)] +pub enum Type { + /// Primitive types: int, float, bool, str, None + Primitive(PrimitiveType), + /// Qubit register: qubit[n] + Qubit { size: Option> }, + /// Array/list type: list[T] + Array { element: Box }, + /// Optional type: Optional[T] + Optional { inner: Box }, + /// Named/custom type + Named { name: String }, + /// Tuple type + Tuple { elements: Vec }, + /// Unknown/unresolved type + Unknown, +} + +/// Primitive types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PrimitiveType { + Int, + Float, + Bool, + Str, + None, +} + +/// Statements in Guppy. +#[derive(Debug, Clone)] +pub enum Stmt { + /// Qubit allocation: q = qubit[n] + Qalloc { + name: String, + size: Expr, + span: Span, + }, + + /// Quantum gate application: h(q[0]), cx(q[0], q[1]) + Gate { + gate: GateKind, + targets: Vec, + params: Vec, + span: Span, + }, + + /// Measurement: m = measure(q) + Measure { + target: Expr, + result: Option, + span: Span, + }, + + /// For loop: for i in range(n) + For { + var: String, + iter: ForIter, + body: Vec, + orelse: Vec, + span: Span, + }, + + /// While loop: while cond + While { + test: Expr, + body: Vec, + orelse: Vec, + span: Span, + }, + + /// If statement + If { + test: Expr, + body: Vec, + orelse: Vec, + span: Span, + }, + + /// Assignment: x = expr + Assign { + target: AssignTarget, + value: Expr, + span: Span, + }, + + /// Annotated assignment: x: T = expr + AnnAssign { + target: String, + annotation: Type, + value: Option, + span: Span, + }, + + /// Augmented assignment: x += expr + AugAssign { + target: AssignTarget, + op: BinOp, + value: Expr, + span: Span, + }, + + /// Return statement + Return { + value: Option, + span: Span, + }, + + /// Expression statement + Expr { + value: Expr, + span: Span, + }, + + /// Pass statement + Pass { span: Span }, + + /// Break statement + Break { span: Span }, + + /// Continue statement + Continue { span: Span }, + + /// Barrier (quantum synchronization) + Barrier { + qubits: Vec, + span: Span, + }, + + /// Try/except block + Try { + body: Vec, + handlers: Vec, + orelse: Vec, + finalbody: Vec, + span: Span, + }, + + /// Assert statement + Assert { + test: Expr, + msg: Option, + span: Span, + }, + + /// With statement (context manager) + With { + items: Vec, + body: Vec, + span: Span, + }, +} + +/// Exception handler. +#[derive(Debug, Clone)] +pub struct ExceptHandler { + pub ty: Option, + pub name: Option, + pub body: Vec, + pub span: Span, +} + +/// With item (context manager). +#[derive(Debug, Clone)] +pub struct WithItem { + pub context: Expr, + pub target: Option, + pub span: Span, +} + +/// Assignment target. +#[derive(Debug, Clone)] +pub enum AssignTarget { + /// Simple name: x + Name { name: String, span: Span }, + /// Subscript: x[i] + Subscript { value: Box, slice: Box, span: Span }, + /// Attribute: x.attr + Attribute { value: Box, attr: String, span: Span }, + /// Tuple unpacking: (a, b) + Tuple { elts: Vec, span: Span }, +} + +/// For loop iterator. +#[derive(Debug, Clone)] +pub enum ForIter { + /// range(end) or range(start, end) or range(start, end, step) + Range { + start: Option>, + end: Box, + step: Option>, + }, + /// Arbitrary iterable + Iter(Box), +} + +/// Expressions in Guppy. +#[derive(Debug, Clone)] +pub enum Expr { + /// Integer literal + IntLit { value: i64, span: Span }, + + /// Float literal + FloatLit { value: f64, span: Span }, + + /// String literal + StrLit { value: String, span: Span }, + + /// Boolean literal + BoolLit { value: bool, span: Span }, + + /// None literal + NoneLit { span: Span }, + + /// Identifier/name + Name { name: String, span: Span }, + + /// Subscript: a[i] + Subscript { + value: Box, + slice: Box, + span: Span, + }, + + /// Attribute access: a.b + Attribute { + value: Box, + attr: String, + span: Span, + }, + + /// Binary operation: a + b + BinOp { + left: Box, + op: BinOp, + right: Box, + span: Span, + }, + + /// Unary operation: -a, not a + UnaryOp { + op: UnaryOp, + operand: Box, + span: Span, + }, + + /// Comparison: a < b, a == b + Compare { + left: Box, + ops: Vec, + comparators: Vec, + span: Span, + }, + + /// Boolean operation: a and b, a or b + BoolOp { + op: BoolOpKind, + values: Vec, + span: Span, + }, + + /// Function call: f(a, b) + Call { + func: Box, + args: Vec, + keywords: Vec, + span: Span, + }, + + /// Conditional expression: a if cond else b + IfExp { + test: Box, + body: Box, + orelse: Box, + span: Span, + }, + + /// List literal: [a, b, c] + List { elts: Vec, span: Span }, + + /// Tuple literal: (a, b, c) + Tuple { elts: Vec, span: Span }, + + /// Dict literal: {a: b, c: d} + Dict { + keys: Vec>, + values: Vec, + span: Span, + }, + + /// Set literal: {a, b, c} + Set { elts: Vec, span: Span }, + + /// List comprehension: [x for x in xs if cond] + ListComp { + elt: Box, + generators: Vec, + span: Span, + }, + + /// Dict comprehension: {k: v for k, v in items} + DictComp { + key: Box, + value: Box, + generators: Vec, + span: Span, + }, + + /// Set comprehension: {x for x in xs} + SetComp { + elt: Box, + generators: Vec, + span: Span, + }, + + /// Generator expression: (x for x in xs) + GeneratorExp { + elt: Box, + generators: Vec, + span: Span, + }, + + /// Lambda: lambda x: x + 1 + Lambda { + params: Vec, + body: Box, + span: Span, + }, +} + +impl Expr { + /// Get the span of this expression. + pub fn span(&self) -> Span { + match self { + Expr::IntLit { span, .. } + | Expr::FloatLit { span, .. } + | Expr::StrLit { span, .. } + | Expr::BoolLit { span, .. } + | Expr::NoneLit { span } + | Expr::Name { span, .. } + | Expr::Subscript { span, .. } + | Expr::Attribute { span, .. } + | Expr::BinOp { span, .. } + | Expr::UnaryOp { span, .. } + | Expr::Compare { span, .. } + | Expr::BoolOp { span, .. } + | Expr::Call { span, .. } + | Expr::IfExp { span, .. } + | Expr::List { span, .. } + | Expr::Tuple { span, .. } + | Expr::Dict { span, .. } + | Expr::Set { span, .. } + | Expr::ListComp { span, .. } + | Expr::DictComp { span, .. } + | Expr::SetComp { span, .. } + | Expr::GeneratorExp { span, .. } + | Expr::Lambda { span, .. } => *span, + } + } +} + +/// Keyword argument. +#[derive(Debug, Clone)] +pub struct Keyword { + pub name: Option, + pub value: Expr, +} + +/// Comprehension clause. +#[derive(Debug, Clone)] +pub struct Comprehension { + pub target: AssignTarget, + pub iter: Expr, + pub ifs: Vec, + pub is_async: bool, +} + +/// Binary operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BinOp { + Add, + Sub, + Mult, + Div, + FloorDiv, + Mod, + Pow, + LShift, + RShift, + BitOr, + BitXor, + BitAnd, + MatMult, +} + +/// Unary operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UnaryOp { + Invert, // ~ + Not, // not + UAdd, // + + USub, // - +} + +/// Comparison operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CmpOp { + Eq, + NotEq, + Lt, + LtE, + Gt, + GtE, + Is, + IsNot, + In, + NotIn, +} + +/// Boolean operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BoolOpKind { + And, + Or, +} + +/// Quantum gate kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GateKind { + // Single-qubit gates + H, + X, + Y, + Z, + T, + Tdg, + S, + Sdg, + Sx, + Sy, + Sz, + // Parameterized single-qubit gates + Rx, + Ry, + Rz, + // Two-qubit gates + Cx, + Cy, + Cz, + Swap, + Iswap, + // Three-qubit gates + Ccx, + // Other operations + Pz, // Reset + // Generic/unknown gate + Custom(u32), // Index into a name table +} + +impl GateKind { + /// Parse a gate name into a GateKind. + pub fn from_name(name: &str) -> Option { + match name.to_lowercase().as_str() { + "h" | "hadamard" => Some(GateKind::H), + "x" | "pauli_x" => Some(GateKind::X), + "y" | "pauli_y" => Some(GateKind::Y), + "z" | "pauli_z" => Some(GateKind::Z), + "t" => Some(GateKind::T), + "tdg" | "t_dagger" => Some(GateKind::Tdg), + "s" => Some(GateKind::S), + "sdg" | "s_dagger" => Some(GateKind::Sdg), + "sx" | "sqrt_x" => Some(GateKind::Sx), + "sy" | "sqrt_y" => Some(GateKind::Sy), + "sz" | "sqrt_z" => Some(GateKind::Sz), + "rx" => Some(GateKind::Rx), + "ry" => Some(GateKind::Ry), + "rz" => Some(GateKind::Rz), + "cx" | "cnot" => Some(GateKind::Cx), + "cy" => Some(GateKind::Cy), + "cz" => Some(GateKind::Cz), + "swap" => Some(GateKind::Swap), + "iswap" => Some(GateKind::Iswap), + "ccx" | "toffoli" | "ccnot" => Some(GateKind::Ccx), + "pz" | "reset" => Some(GateKind::Pz), + _ => None, + } + } + + /// Check if this gate is parameterized. + pub fn is_parameterized(&self) -> bool { + matches!(self, GateKind::Rx | GateKind::Ry | GateKind::Rz) + } + + /// Get the number of qubits this gate operates on. + pub fn num_qubits(&self) -> usize { + match self { + GateKind::H + | GateKind::X + | GateKind::Y + | GateKind::Z + | GateKind::T + | GateKind::Tdg + | GateKind::S + | GateKind::Sdg + | GateKind::Sx + | GateKind::Sy + | GateKind::Sz + | GateKind::Rx + | GateKind::Ry + | GateKind::Rz + | GateKind::Pz => 1, + GateKind::Cx + | GateKind::Cy + | GateKind::Cz + | GateKind::Swap + | GateKind::Iswap => 2, + GateKind::Ccx => 3, + GateKind::Custom(_) => 0, // Unknown + } + } + + /// Get the name of this gate. + pub fn name(&self) -> &'static str { + match self { + GateKind::H => "h", + GateKind::X => "x", + GateKind::Y => "y", + GateKind::Z => "z", + GateKind::T => "t", + GateKind::Tdg => "tdg", + GateKind::S => "s", + GateKind::Sdg => "sdg", + GateKind::Sx => "sx", + GateKind::Sy => "sy", + GateKind::Sz => "sz", + GateKind::Rx => "rx", + GateKind::Ry => "ry", + GateKind::Rz => "rz", + GateKind::Cx => "cx", + GateKind::Cy => "cy", + GateKind::Cz => "cz", + GateKind::Swap => "swap", + GateKind::Iswap => "iswap", + GateKind::Ccx => "ccx", + GateKind::Pz => "pz", + GateKind::Custom(_) => "custom", + } + } +} diff --git a/exp/guppy-zlup/src/linter/config.rs b/exp/guppy-zlup/src/linter/config.rs new file mode 100644 index 000000000..6ef9717bc --- /dev/null +++ b/exp/guppy-zlup/src/linter/config.rs @@ -0,0 +1,190 @@ +//! Configuration for guppy-zlup. + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Linter configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Enabled lint rules (all rules enabled by default) + pub enabled_rules: Vec, + + /// Disabled lint rules (takes precedence over enabled_rules) + pub disabled_rules: Vec, + + /// Maximum cyclomatic complexity for ZLUP007 + pub max_complexity: u32, + + /// Treat warnings as errors + pub treat_warnings_as_errors: bool, +} + +impl Default for Config { + fn default() -> Self { + Self { + enabled_rules: vec![ + "ZLUP001".to_string(), + "ZLUP002".to_string(), + "ZLUP003".to_string(), + "ZLUP004".to_string(), + "ZLUP005".to_string(), + "ZLUP006".to_string(), + "ZLUP007".to_string(), + "ZLUP008".to_string(), + "ZLUP009".to_string(), + "ZLUP010".to_string(), + ], + disabled_rules: vec![], + max_complexity: 10, + treat_warnings_as_errors: false, + } + } +} + +impl Config { + /// Load configuration from a pyproject.toml file. + pub fn from_pyproject(path: &Path) -> Result { + if !path.exists() { + return Ok(Self::default()); + } + + let content = std::fs::read_to_string(path).map_err(ConfigError::Io)?; + let toml: toml::Value = toml::from_str(&content).map_err(ConfigError::Toml)?; + + let tool_config = toml + .get("tool") + .and_then(|t| t.get("guppy-zlup")) + .cloned() + .unwrap_or(toml::Value::Table(toml::map::Map::new())); + + // Extract values with defaults + let enabled_rules = tool_config + .get("enabled_rules") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_else(|| Self::default().enabled_rules); + + let disabled_rules = tool_config + .get("disabled_rules") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let max_complexity = tool_config + .get("max_complexity") + .and_then(|v| v.as_integer()) + .map(|v| v as u32) + .unwrap_or(10); + + let treat_warnings_as_errors = tool_config + .get("warnings_as_errors") + .or_else(|| tool_config.get("treat_warnings_as_errors")) + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Ok(Self { + enabled_rules, + disabled_rules, + max_complexity, + treat_warnings_as_errors, + }) + } + + /// Check if a rule is enabled. + /// + /// A rule is enabled if it's in enabled_rules AND not in disabled_rules. + /// disabled_rules takes precedence. + pub fn is_rule_enabled(&self, rule_id: &str) -> bool { + if self.disabled_rules.iter().any(|r| r == rule_id) { + return false; + } + self.enabled_rules.iter().any(|r| r == rule_id) + } + + /// Add a rule to the disabled list. + pub fn disable_rule(&mut self, rule_id: &str) { + if !self.disabled_rules.contains(&rule_id.to_string()) { + self.disabled_rules.push(rule_id.to_string()); + } + } + + /// Try to find a pyproject.toml by walking up from the given file path. + pub fn find_pyproject(start_path: &Path) -> Option { + let mut current = if start_path.is_file() { + start_path.parent()? + } else { + start_path + }; + + loop { + let candidate = current.join("pyproject.toml"); + if candidate.exists() { + return Some(candidate); + } + current = current.parent()?; + } + } +} + +/// Configuration error. +#[derive(Debug, thiserror::Error)] +pub enum ConfigError { + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("TOML parse error: {0}")] + Toml(#[from] toml::de::Error), +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = Config::default(); + assert!(config.is_rule_enabled("ZLUP001")); + assert!(config.is_rule_enabled("ZLUP007")); + assert_eq!(config.max_complexity, 10); + } + + #[test] + fn test_rule_enabled() { + let mut config = Config::default(); + config.enabled_rules = vec!["ZLUP001".to_string(), "ZLUP002".to_string()]; + + assert!(config.is_rule_enabled("ZLUP001")); + assert!(config.is_rule_enabled("ZLUP002")); + assert!(!config.is_rule_enabled("ZLUP003")); + } + + #[test] + fn test_disabled_rules_take_precedence() { + let mut config = Config::default(); + // ZLUP001 is enabled by default + assert!(config.is_rule_enabled("ZLUP001")); + + // Disable it + config.disable_rule("ZLUP001"); + assert!(!config.is_rule_enabled("ZLUP001")); + + // Other rules still enabled + assert!(config.is_rule_enabled("ZLUP002")); + } + + #[test] + fn test_disable_rule_idempotent() { + let mut config = Config::default(); + config.disable_rule("ZLUP001"); + config.disable_rule("ZLUP001"); // Should not add duplicate + assert_eq!(config.disabled_rules.len(), 1); + } +} diff --git a/exp/guppy-zlup/src/linter/diagnostic.rs b/exp/guppy-zlup/src/linter/diagnostic.rs new file mode 100644 index 000000000..8da7e449a --- /dev/null +++ b/exp/guppy-zlup/src/linter/diagnostic.rs @@ -0,0 +1,188 @@ +//! Diagnostic types for lint results. + +use serde::{Deserialize, Serialize}; +use std::fmt; + +/// Source location for error reporting. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, + pub end_line: Option, + pub end_column: Option, + pub file: Option, +} + +impl SourceLocation { + pub fn new(line: u32, column: u32) -> Self { + Self { + line, + column, + end_line: None, + end_column: None, + file: None, + } + } + + pub fn with_end(line: u32, column: u32, end_line: u32, end_column: u32) -> Self { + Self { + line, + column, + end_line: Some(end_line), + end_column: Some(end_column), + file: None, + } + } + + pub fn with_file(mut self, file: impl Into) -> Self { + self.file = Some(file.into()); + self + } +} + +impl fmt::Display for SourceLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.file { + Some(file) => write!(f, "{}:{}:{}", file, self.line, self.column), + None => write!(f, "{}:{}", self.line, self.column), + } + } +} + +/// Diagnostic severity levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Severity { + Error, + Warning, + Info, + Hint, +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Severity::Error => write!(f, "error"), + Severity::Warning => write!(f, "warning"), + Severity::Info => write!(f, "info"), + Severity::Hint => write!(f, "hint"), + } + } +} + +/// A lint diagnostic/violation. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Diagnostic { + pub rule_id: String, + pub message: String, + pub severity: Severity, + pub location: SourceLocation, + pub suggestion: Option, + /// The source code line(s) for context (not serialized) + #[serde(skip)] + pub source_context: Option, +} + +impl Diagnostic { + pub fn new( + rule_id: impl Into, + message: impl Into, + severity: Severity, + location: SourceLocation, + ) -> Self { + Self { + rule_id: rule_id.into(), + message: message.into(), + severity, + location, + suggestion: None, + source_context: None, + } + } + + pub fn with_suggestion(mut self, suggestion: impl Into) -> Self { + self.suggestion = Some(suggestion.into()); + self + } + + pub fn with_source_context(mut self, source: &str) -> Self { + let line_num = self.location.line as usize; + if line_num > 0 + && let Some(line) = source.lines().nth(line_num - 1) { + self.source_context = Some(line.to_string()); + } + self + } + + pub fn error(rule_id: impl Into, message: impl Into, location: SourceLocation) -> Self { + Self::new(rule_id, message, Severity::Error, location) + } + + pub fn warning(rule_id: impl Into, message: impl Into, location: SourceLocation) -> Self { + Self::new(rule_id, message, Severity::Warning, location) + } +} + +impl fmt::Display for Diagnostic { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!( + f, + "{}: [{}] {}", + self.severity, self.rule_id, self.message + )?; + writeln!(f, " --> {}", self.location)?; + + // Show source context if available + if let Some(ref source_line) = self.source_context { + let line_num = self.location.line; + let col = self.location.column.saturating_sub(1) as usize; + let line_num_width = line_num.to_string().len(); + + // Empty line number gutter + writeln!(f, "{:width$} |", "", width = line_num_width)?; + // Source line + writeln!(f, "{} | {}", line_num, source_line)?; + // Underline pointing to the location + let underline_len = if let Some(end_col) = self.location.end_column { + (end_col.saturating_sub(self.location.column) as usize).max(1) + } else { + 1 + }; + writeln!( + f, + "{:width$} | {:>col$}{}", + "", + "", + "^".repeat(underline_len), + width = line_num_width, + col = col + )?; + } + + if let Some(suggestion) = &self.suggestion { + writeln!(f, " help: {}", suggestion)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_diagnostic_display() { + let diag = Diagnostic::error( + "ZLUP001", + "'while True' creates an unbounded loop", + SourceLocation::new(5, 4).with_file("test.py"), + ) + .with_suggestion("Use a for loop with a fixed upper bound instead"); + + let output = format!("{}", diag); + assert!(output.contains("error")); + assert!(output.contains("ZLUP001")); + assert!(output.contains("while True")); + assert!(output.contains("test.py:5:4")); + } +} diff --git a/exp/guppy-zlup/src/linter/engine.rs b/exp/guppy-zlup/src/linter/engine.rs new file mode 100644 index 000000000..91a207697 --- /dev/null +++ b/exp/guppy-zlup/src/linter/engine.rs @@ -0,0 +1,181 @@ +//! Main linter engine for guppy-zlup. + +use std::fmt; + +use rustpython_parser::{parse, Mode}; + +use super::config::Config; +use super::diagnostic::{Diagnostic, Severity, SourceLocation}; +use super::noqa; +use super::rules::{self, LintRule}; + +/// Result of linting a file. +#[derive(Debug, Clone, Default)] +pub struct LintResult { + pub diagnostics: Vec, + pub has_errors: bool, + pub has_warnings: bool, +} + +impl LintResult { + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, diagnostic: Diagnostic) { + match diagnostic.severity { + Severity::Error => self.has_errors = true, + Severity::Warning => self.has_warnings = true, + _ => {} + } + self.diagnostics.push(diagnostic); + } + + pub fn is_ok(&self, treat_warnings_as_errors: bool) -> bool { + if self.has_errors { + return false; + } + if treat_warnings_as_errors && self.has_warnings { + return false; + } + true + } + + pub fn merge(&mut self, other: LintResult) { + self.has_errors |= other.has_errors; + self.has_warnings |= other.has_warnings; + self.diagnostics.extend(other.diagnostics); + } +} + +impl fmt::Display for LintResult { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.diagnostics.is_empty() { + return write!(f, "No issues found."); + } + for (i, diag) in self.diagnostics.iter().enumerate() { + if i > 0 { + writeln!(f)?; + } + write!(f, "{}", diag)?; + } + Ok(()) + } +} + +/// Main linter for Guppy programs. +pub struct Linter { + config: Config, + rules: Vec>, +} + +impl Linter { + pub fn new(config: Config) -> Self { + let rules = Self::load_rules(&config); + Self { config, rules } + } + + fn load_rules(config: &Config) -> Vec> { + let mut rules: Vec> = Vec::new(); + + if config.is_rule_enabled("ZLUP001") { + rules.push(Box::new(rules::ZLUP001UnboundedLoops)); + } + if config.is_rule_enabled("ZLUP002") { + rules.push(Box::new(rules::ZLUP002Recursion)); + } + if config.is_rule_enabled("ZLUP003") { + rules.push(Box::new(rules::ZLUP003DynamicAllocation)); + } + if config.is_rule_enabled("ZLUP004") { + rules.push(Box::new(rules::ZLUP004DynamicDispatch)); + } + if config.is_rule_enabled("ZLUP005") { + rules.push(Box::new(rules::ZLUP005UncheckedErrors)); + } + if config.is_rule_enabled("ZLUP006") { + rules.push(Box::new(rules::ZLUP006MissingTypes)); + } + if config.is_rule_enabled("ZLUP007") { + rules.push(Box::new(rules::ZLUP007ComplexControlFlow::new(config.max_complexity))); + } + if config.is_rule_enabled("ZLUP008") { + rules.push(Box::new(rules::ZLUP008CallDepth::default())); + } + if config.is_rule_enabled("ZLUP009") { + rules.push(Box::new(rules::ZLUP009AssertionDensity)); + } + if config.is_rule_enabled("ZLUP010") { + rules.push(Box::new(rules::ZLUP010GlobalState)); + } + + rules + } + + pub fn lint_source(&self, source: &str, filename: &str) -> LintResult { + let mut result = LintResult::new(); + + // Parse noqa directives first + let noqa_directives = noqa::parse_noqa(source); + + // Parse Python source + let parsed = match parse(source, Mode::Module, filename) { + Ok(p) => p, + Err(e) => { + result.add(Diagnostic::error( + "PARSE", + format!("Syntax error: {}", e), + SourceLocation::new(1, 0).with_file(filename), + )); + return result; + } + }; + + // Run each lint rule + for rule in &self.rules { + let diagnostics = rule.check(&parsed, filename, source); + for diag in diagnostics { + // Filter out diagnostics that are suppressed by noqa comments + if !noqa_directives.is_suppressed(diag.location.line, &diag.rule_id) { + result.add(diag); + } + } + } + + result + } + + pub fn lint_file(&self, path: &str) -> Result { + let source = std::fs::read_to_string(path)?; + Ok(self.lint_source(&source, path)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lint_result_merge() { + let mut r1 = LintResult::new(); + r1.add(Diagnostic::error("TEST", "error", SourceLocation::new(1, 0))); + + let mut r2 = LintResult::new(); + r2.add(Diagnostic::warning("TEST", "warning", SourceLocation::new(2, 0))); + + r1.merge(r2); + assert!(r1.has_errors); + assert!(r1.has_warnings); + assert_eq!(r1.diagnostics.len(), 2); + } + + #[test] + fn test_lint_syntax_error() { + let config = Config::default(); + let linter = Linter::new(config); + let result = linter.lint_source("def foo(", "test.py"); + + assert!(result.has_errors); + assert!(result.diagnostics.iter().any(|d| d.rule_id == "PARSE")); + } +} diff --git a/exp/guppy-zlup/src/linter/lower.rs b/exp/guppy-zlup/src/linter/lower.rs new file mode 100644 index 000000000..05e2079dd --- /dev/null +++ b/exp/guppy-zlup/src/linter/lower.rs @@ -0,0 +1,930 @@ +//! Lower Python AST to Guppy AST. +//! +//! This module converts the rustpython-parser AST into our clean Guppy AST. +//! All Python-specific quirks are handled here, isolating the rest of the +//! codebase from parser API changes. + +use rustpython_parser::ast::{self, Constant, Expr as PyExpr, Mod, Ranged, Stmt as PyStmt}; + +use super::ast::{ + AssignTarget, BinOp, BoolOpKind, CmpOp, Comprehension, ExceptHandler, Expr, ForIter, + Function, GateKind, Keyword, Module, Param, PrimitiveType, Span, Stmt, Type, UnaryOp, + WithItem, +}; + +/// Errors that can occur during lowering. +#[derive(Debug, Clone)] +pub enum LowerError { + /// The input was not a module. + NotAModule, + /// Unsupported Python construct. + Unsupported(String, Span), + /// Parse error from rustpython. + Parse(String), +} + +impl std::fmt::Display for LowerError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + LowerError::NotAModule => write!(f, "expected a module"), + LowerError::Unsupported(msg, _) => write!(f, "unsupported: {}", msg), + LowerError::Parse(msg) => write!(f, "parse error: {}", msg), + } + } +} + +impl std::error::Error for LowerError {} + +/// Lower Python source code to a Guppy AST. +pub fn lower_source(source: &str, filename: &str) -> Result { + let parsed = rustpython_parser::parse(source, rustpython_parser::Mode::Module, filename) + .map_err(|e| LowerError::Parse(e.to_string()))?; + + lower_mod(&parsed) +} + +/// Lower a Python module AST to a Guppy module. +pub fn lower_mod(parsed: &Mod) -> Result { + let Mod::Module(module) = parsed else { + return Err(LowerError::NotAModule); + }; + + let mut functions = Vec::new(); + + for stmt in &module.body { + if let PyStmt::FunctionDef(func) = stmt { + functions.push(lower_function(func)?); + } + // Skip other top-level statements for now (imports, classes, etc.) + } + + Ok(Module { + functions, + span: Span::default(), + }) +} + +/// Lower a Python function definition to a Guppy function. +fn lower_function(func: &ast::StmtFunctionDef) -> Result { + let span = make_span(func.range); + + let params = func + .args + .args + .iter() + .map(|arg| { + let ty = arg.def.annotation.as_ref().map(|ann| lower_type(ann)); + Param { + name: arg.def.arg.to_string(), + ty, + span: make_span(arg.def.range), + } + }) + .collect(); + + let return_type = func.returns.as_ref().map(|ann| lower_type(ann)); + + let body = func + .body + .iter() + .filter_map(|stmt| lower_stmt(stmt).ok()) + .collect(); + + let decorators = func + .decorator_list + .iter() + .filter_map(|d| { + if let PyExpr::Name(name) = d { + Some(name.id.to_string()) + } else { + None + } + }) + .collect(); + + Ok(Function { + name: func.name.to_string(), + params, + return_type, + body, + decorators, + span, + }) +} + +/// Lower a Python type annotation to a Guppy type. +fn lower_type(ann: &PyExpr) -> Type { + match ann { + PyExpr::Name(name) => { + let name_str = name.id.as_str(); + match name_str { + "int" => Type::Primitive(PrimitiveType::Int), + "float" => Type::Primitive(PrimitiveType::Float), + "bool" => Type::Primitive(PrimitiveType::Bool), + "str" => Type::Primitive(PrimitiveType::Str), + "None" => Type::Primitive(PrimitiveType::None), + "qubit" => Type::Qubit { size: None }, + _ => Type::Named { + name: name_str.to_string(), + }, + } + } + PyExpr::Subscript(sub) => { + if let PyExpr::Name(name) = sub.value.as_ref() { + let name_str = name.id.as_str(); + match name_str { + "list" | "List" => Type::Array { + element: Box::new(lower_type(&sub.slice)), + }, + "Optional" => Type::Optional { + inner: Box::new(lower_type(&sub.slice)), + }, + "qubit" => Type::Qubit { + size: lower_expr(&sub.slice).ok().map(Box::new), + }, + "Tuple" | "tuple" => { + if let PyExpr::Tuple(tuple) = sub.slice.as_ref() { + Type::Tuple { + elements: tuple.elts.iter().map(lower_type).collect(), + } + } else { + Type::Tuple { + elements: vec![lower_type(&sub.slice)], + } + } + } + _ => Type::Named { + name: name_str.to_string(), + }, + } + } else { + Type::Unknown + } + } + PyExpr::Constant(c) if matches!(c.value, Constant::None) => { + Type::Primitive(PrimitiveType::None) + } + _ => Type::Unknown, + } +} + +/// Lower a Python statement to a Guppy statement. +fn lower_stmt(stmt: &PyStmt) -> Result { + let span = stmt_span(stmt); + + match stmt { + PyStmt::Assign(assign) => lower_assign(assign), + + PyStmt::AnnAssign(ann) => { + let target = if let PyExpr::Name(name) = ann.target.as_ref() { + name.id.to_string() + } else { + return Err(LowerError::Unsupported( + "complex annotated assignment target".into(), + span, + )); + }; + + Ok(Stmt::AnnAssign { + target, + annotation: lower_type(&ann.annotation), + value: ann.value.as_ref().and_then(|v| lower_expr(v).ok()), + span, + }) + } + + PyStmt::AugAssign(aug) => { + let target = lower_assign_target(&aug.target)?; + let op = lower_operator(aug.op); + let value = lower_expr(&aug.value)?; + + Ok(Stmt::AugAssign { + target, + op, + value, + span, + }) + } + + PyStmt::Expr(expr_stmt) => { + let expr = &expr_stmt.value; + + // Check if this is a gate call + if let PyExpr::Call(call) = expr.as_ref() + && let Some(stmt) = try_lower_gate_call(call, span)? { + return Ok(stmt); + } + + Ok(Stmt::Expr { + value: lower_expr(expr)?, + span, + }) + } + + PyStmt::For(for_stmt) => { + let var = if let PyExpr::Name(name) = for_stmt.target.as_ref() { + name.id.to_string() + } else { + return Err(LowerError::Unsupported("complex for target".into(), span)); + }; + + let iter = lower_for_iter(&for_stmt.iter)?; + + let body = for_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let orelse = for_stmt + .orelse + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::For { + var, + iter, + body, + orelse, + span, + }) + } + + PyStmt::While(while_stmt) => { + let test = lower_expr(&while_stmt.test)?; + + let body = while_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let orelse = while_stmt + .orelse + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::While { + test, + body, + orelse, + span, + }) + } + + PyStmt::If(if_stmt) => { + let test = lower_expr(&if_stmt.test)?; + + let body = if_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let orelse = if_stmt + .orelse + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::If { + test, + body, + orelse, + span, + }) + } + + PyStmt::Return(ret) => { + let value = ret.value.as_ref().and_then(|v| lower_expr(v).ok()); + Ok(Stmt::Return { value, span }) + } + + PyStmt::Pass(_) => Ok(Stmt::Pass { span }), + + PyStmt::Break(_) => Ok(Stmt::Break { span }), + + PyStmt::Continue(_) => Ok(Stmt::Continue { span }), + + PyStmt::Try(try_stmt) => { + let body = try_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let handlers = try_stmt + .handlers + .iter() + .map(|h| { + let ast::ExceptHandler::ExceptHandler(handler) = h; + ExceptHandler { + ty: handler.type_.as_ref().and_then(|t| lower_expr(t).ok()), + name: handler.name.as_ref().map(|n| n.to_string()), + body: handler + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(), + span: make_span(handler.range), + } + }) + .collect(); + + let orelse = try_stmt + .orelse + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + let finalbody = try_stmt + .finalbody + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::Try { + body, + handlers, + orelse, + finalbody, + span, + }) + } + + PyStmt::Assert(assert_stmt) => { + let test = lower_expr(&assert_stmt.test)?; + let msg = assert_stmt.msg.as_ref().and_then(|m| lower_expr(m).ok()); + + Ok(Stmt::Assert { test, msg, span }) + } + + PyStmt::With(with_stmt) => { + let items = with_stmt + .items + .iter() + .filter_map(|item| { + let context = lower_expr(&item.context_expr).ok()?; + let target = item + .optional_vars + .as_ref() + .and_then(|v| lower_assign_target(v).ok()); + // Use the context expression's range for the span + Some(WithItem { + context, + target, + span: make_span(item.context_expr.range()), + }) + }) + .collect(); + + let body = with_stmt + .body + .iter() + .filter_map(|s| lower_stmt(s).ok()) + .collect(); + + Ok(Stmt::With { items, body, span }) + } + + _ => Err(LowerError::Unsupported( + format!("statement: {:?}", std::mem::discriminant(stmt)), + span, + )), + } +} + +/// Try to lower a call expression as a gate or measurement. +fn try_lower_gate_call(call: &ast::ExprCall, span: Span) -> Result, LowerError> { + // Get the function name + let func_name = match call.func.as_ref() { + PyExpr::Name(name) => name.id.as_str(), + _ => return Ok(None), + }; + + // Check if it's a known gate + if let Some(gate) = GateKind::from_name(func_name) { + let targets = call + .args + .iter() + .map(lower_expr) + .collect::, _>>()?; + + return Ok(Some(Stmt::Gate { + gate, + targets, + params: Vec::new(), + span, + })); + } + + // Check if it's a measurement + if func_name == "measure" { + let target = call + .args + .first() + .map(lower_expr) + .transpose()? + .ok_or_else(|| LowerError::Unsupported("measure without target".into(), span))?; + + return Ok(Some(Stmt::Measure { + target, + result: None, + span, + })); + } + + // Check if it's a barrier + if func_name == "barrier" { + let qubits = call + .args + .iter() + .map(lower_expr) + .collect::, _>>()?; + + return Ok(Some(Stmt::Barrier { qubits, span })); + } + + Ok(None) +} + +/// Lower a Python assignment statement. +fn lower_assign(assign: &ast::StmtAssign) -> Result { + let span = make_span(assign.range); + + if assign.targets.len() != 1 { + return Err(LowerError::Unsupported("multiple assignment targets".into(), span)); + } + + let target_expr = &assign.targets[0]; + + // Check for qubit allocation: q = qubit[n] + if let PyExpr::Subscript(sub) = assign.value.as_ref() + && let PyExpr::Name(name) = sub.value.as_ref() + && name.id.as_str() == "qubit" { + let var_name = if let PyExpr::Name(n) = target_expr { + n.id.to_string() + } else { + return Err(LowerError::Unsupported("complex qalloc target".into(), span)); + }; + + let size = lower_expr(&sub.slice)?; + + return Ok(Stmt::Qalloc { + name: var_name, + size, + span, + }); + } + + // Check for measurement: m = measure(q) + if let PyExpr::Call(call) = assign.value.as_ref() + && let PyExpr::Name(func_name) = call.func.as_ref() + && func_name.id.as_str() == "measure" { + let result_name = if let PyExpr::Name(n) = target_expr { + n.id.to_string() + } else { + return Err(LowerError::Unsupported("complex measure target".into(), span)); + }; + + let target = call + .args + .first() + .map(lower_expr) + .transpose()? + .ok_or_else(|| LowerError::Unsupported("measure without target".into(), span))?; + + return Ok(Stmt::Measure { + target, + result: Some(result_name), + span, + }); + } + + // Regular assignment + let target = lower_assign_target(target_expr)?; + let value = lower_expr(&assign.value)?; + + Ok(Stmt::Assign { + target, + value, + span, + }) +} + +/// Lower an assignment target. +fn lower_assign_target(expr: &PyExpr) -> Result { + let span = make_span(expr.range()); + + match expr { + PyExpr::Name(name) => Ok(AssignTarget::Name { + name: name.id.to_string(), + span, + }), + PyExpr::Subscript(sub) => Ok(AssignTarget::Subscript { + value: Box::new(lower_expr(&sub.value)?), + slice: Box::new(lower_expr(&sub.slice)?), + span, + }), + PyExpr::Attribute(attr) => Ok(AssignTarget::Attribute { + value: Box::new(lower_expr(&attr.value)?), + attr: attr.attr.to_string(), + span, + }), + PyExpr::Tuple(tuple) => Ok(AssignTarget::Tuple { + elts: tuple + .elts + .iter() + .map(lower_assign_target) + .collect::>()?, + span, + }), + _ => Err(LowerError::Unsupported( + "complex assignment target".into(), + span, + )), + } +} + +/// Lower a for loop iterator. +fn lower_for_iter(expr: &PyExpr) -> Result { + // Check for range(...) + if let PyExpr::Call(call) = expr + && let PyExpr::Name(name) = call.func.as_ref() + && name.id.as_str() == "range" { + match call.args.len() { + 1 => { + return Ok(ForIter::Range { + start: None, + end: Box::new(lower_expr(&call.args[0])?), + step: None, + }); + } + 2 => { + return Ok(ForIter::Range { + start: Some(Box::new(lower_expr(&call.args[0])?)), + end: Box::new(lower_expr(&call.args[1])?), + step: None, + }); + } + 3 => { + return Ok(ForIter::Range { + start: Some(Box::new(lower_expr(&call.args[0])?)), + end: Box::new(lower_expr(&call.args[1])?), + step: Some(Box::new(lower_expr(&call.args[2])?)), + }); + } + _ => {} + } + } + + Ok(ForIter::Iter(Box::new(lower_expr(expr)?))) +} + +/// Lower a Python expression to a Guppy expression. +fn lower_expr(expr: &PyExpr) -> Result { + let span = make_span(expr.range()); + + match expr { + PyExpr::Constant(c) => match &c.value { + Constant::Int(i) => { + let value = i.to_string().parse::().unwrap_or(0); + Ok(Expr::IntLit { value, span }) + } + Constant::Float(f) => Ok(Expr::FloatLit { value: *f, span }), + Constant::Str(s) => Ok(Expr::StrLit { + value: s.clone(), + span, + }), + Constant::Bool(b) => Ok(Expr::BoolLit { value: *b, span }), + Constant::None => Ok(Expr::NoneLit { span }), + _ => Err(LowerError::Unsupported("constant type".into(), span)), + }, + + PyExpr::Name(name) => Ok(Expr::Name { + name: name.id.to_string(), + span, + }), + + PyExpr::Subscript(sub) => Ok(Expr::Subscript { + value: Box::new(lower_expr(&sub.value)?), + slice: Box::new(lower_expr(&sub.slice)?), + span, + }), + + PyExpr::Attribute(attr) => Ok(Expr::Attribute { + value: Box::new(lower_expr(&attr.value)?), + attr: attr.attr.to_string(), + span, + }), + + PyExpr::BinOp(binop) => Ok(Expr::BinOp { + left: Box::new(lower_expr(&binop.left)?), + op: lower_operator(binop.op), + right: Box::new(lower_expr(&binop.right)?), + span, + }), + + PyExpr::UnaryOp(unary) => Ok(Expr::UnaryOp { + op: lower_unary_op(unary.op), + operand: Box::new(lower_expr(&unary.operand)?), + span, + }), + + PyExpr::Compare(cmp) => { + let ops = cmp.ops.iter().map(|op| lower_cmp_op(*op)).collect(); + let comparators = cmp + .comparators + .iter() + .map(lower_expr) + .collect::>()?; + + Ok(Expr::Compare { + left: Box::new(lower_expr(&cmp.left)?), + ops, + comparators, + span, + }) + } + + PyExpr::BoolOp(boolop) => { + let op = match boolop.op { + ast::BoolOp::And => BoolOpKind::And, + ast::BoolOp::Or => BoolOpKind::Or, + }; + + let values = boolop + .values + .iter() + .map(lower_expr) + .collect::>()?; + + Ok(Expr::BoolOp { op, values, span }) + } + + PyExpr::Call(call) => { + let func = lower_expr(&call.func)?; + let args = call.args.iter().map(lower_expr).collect::>()?; + let keywords = call + .keywords + .iter() + .map(|kw| { + Ok(Keyword { + name: kw.arg.as_ref().map(|a| a.to_string()), + value: lower_expr(&kw.value)?, + }) + }) + .collect::>()?; + + Ok(Expr::Call { + func: Box::new(func), + args, + keywords, + span, + }) + } + + PyExpr::IfExp(ifexp) => Ok(Expr::IfExp { + test: Box::new(lower_expr(&ifexp.test)?), + body: Box::new(lower_expr(&ifexp.body)?), + orelse: Box::new(lower_expr(&ifexp.orelse)?), + span, + }), + + PyExpr::List(list) => { + let elts = list.elts.iter().map(lower_expr).collect::>()?; + Ok(Expr::List { elts, span }) + } + + PyExpr::Tuple(tuple) => { + let elts = tuple.elts.iter().map(lower_expr).collect::>()?; + Ok(Expr::Tuple { elts, span }) + } + + PyExpr::Dict(dict) => { + let keys = dict + .keys + .iter() + .map(|k| k.as_ref().and_then(|k| lower_expr(k).ok())) + .collect(); + let values = dict.values.iter().map(lower_expr).collect::>()?; + Ok(Expr::Dict { keys, values, span }) + } + + PyExpr::Set(set) => { + let elts = set.elts.iter().map(lower_expr).collect::>()?; + Ok(Expr::Set { elts, span }) + } + + PyExpr::ListComp(comp) => Ok(Expr::ListComp { + elt: Box::new(lower_expr(&comp.elt)?), + generators: comp + .generators + .iter() + .map(lower_comprehension) + .collect::>()?, + span, + }), + + PyExpr::DictComp(comp) => Ok(Expr::DictComp { + key: Box::new(lower_expr(&comp.key)?), + value: Box::new(lower_expr(&comp.value)?), + generators: comp + .generators + .iter() + .map(lower_comprehension) + .collect::>()?, + span, + }), + + PyExpr::SetComp(comp) => Ok(Expr::SetComp { + elt: Box::new(lower_expr(&comp.elt)?), + generators: comp + .generators + .iter() + .map(lower_comprehension) + .collect::>()?, + span, + }), + + PyExpr::GeneratorExp(gen_expr) => Ok(Expr::GeneratorExp { + elt: Box::new(lower_expr(&gen_expr.elt)?), + generators: gen_expr + .generators + .iter() + .map(lower_comprehension) + .collect::>()?, + span, + }), + + PyExpr::Lambda(lambda) => { + let params = lambda + .args + .args + .iter() + .map(|a| a.def.arg.to_string()) + .collect(); + Ok(Expr::Lambda { + params, + body: Box::new(lower_expr(&lambda.body)?), + span, + }) + } + + _ => Err(LowerError::Unsupported( + format!("expression: {:?}", std::mem::discriminant(expr)), + span, + )), + } +} + +/// Lower a comprehension clause. +fn lower_comprehension(comp: &ast::Comprehension) -> Result { + Ok(Comprehension { + target: lower_assign_target(&comp.target)?, + iter: lower_expr(&comp.iter)?, + ifs: comp.ifs.iter().filter_map(|e| lower_expr(e).ok()).collect(), + is_async: comp.is_async, + }) +} + +/// Lower a binary operator. +fn lower_operator(op: ast::Operator) -> BinOp { + match op { + ast::Operator::Add => BinOp::Add, + ast::Operator::Sub => BinOp::Sub, + ast::Operator::Mult => BinOp::Mult, + ast::Operator::Div => BinOp::Div, + ast::Operator::FloorDiv => BinOp::FloorDiv, + ast::Operator::Mod => BinOp::Mod, + ast::Operator::Pow => BinOp::Pow, + ast::Operator::LShift => BinOp::LShift, + ast::Operator::RShift => BinOp::RShift, + ast::Operator::BitOr => BinOp::BitOr, + ast::Operator::BitXor => BinOp::BitXor, + ast::Operator::BitAnd => BinOp::BitAnd, + ast::Operator::MatMult => BinOp::MatMult, + } +} + +/// Lower a unary operator. +fn lower_unary_op(op: ast::UnaryOp) -> UnaryOp { + match op { + ast::UnaryOp::Invert => UnaryOp::Invert, + ast::UnaryOp::Not => UnaryOp::Not, + ast::UnaryOp::UAdd => UnaryOp::UAdd, + ast::UnaryOp::USub => UnaryOp::USub, + } +} + +/// Lower a comparison operator. +fn lower_cmp_op(op: ast::CmpOp) -> CmpOp { + match op { + ast::CmpOp::Eq => CmpOp::Eq, + ast::CmpOp::NotEq => CmpOp::NotEq, + ast::CmpOp::Lt => CmpOp::Lt, + ast::CmpOp::LtE => CmpOp::LtE, + ast::CmpOp::Gt => CmpOp::Gt, + ast::CmpOp::GtE => CmpOp::GtE, + ast::CmpOp::Is => CmpOp::Is, + ast::CmpOp::IsNot => CmpOp::IsNot, + ast::CmpOp::In => CmpOp::In, + ast::CmpOp::NotIn => CmpOp::NotIn, + } +} + +/// Create a Span from a TextRange. +fn make_span(range: rustpython_parser::text_size::TextRange) -> Span { + Span { + start: range.start().into(), + end: range.end().into(), + } +} + +/// Get the span of a statement. +fn stmt_span(stmt: &PyStmt) -> Span { + match stmt { + PyStmt::FunctionDef(s) => make_span(s.range), + PyStmt::AsyncFunctionDef(s) => make_span(s.range), + PyStmt::ClassDef(s) => make_span(s.range), + PyStmt::Return(s) => make_span(s.range), + PyStmt::Delete(s) => make_span(s.range), + PyStmt::Assign(s) => make_span(s.range), + PyStmt::TypeAlias(s) => make_span(s.range), + PyStmt::AugAssign(s) => make_span(s.range), + PyStmt::AnnAssign(s) => make_span(s.range), + PyStmt::For(s) => make_span(s.range), + PyStmt::AsyncFor(s) => make_span(s.range), + PyStmt::While(s) => make_span(s.range), + PyStmt::If(s) => make_span(s.range), + PyStmt::With(s) => make_span(s.range), + PyStmt::AsyncWith(s) => make_span(s.range), + PyStmt::Match(s) => make_span(s.range), + PyStmt::Raise(s) => make_span(s.range), + PyStmt::Try(s) => make_span(s.range), + PyStmt::TryStar(s) => make_span(s.range), + PyStmt::Assert(s) => make_span(s.range), + PyStmt::Import(s) => make_span(s.range), + PyStmt::ImportFrom(s) => make_span(s.range), + PyStmt::Global(s) => make_span(s.range), + PyStmt::Nonlocal(s) => make_span(s.range), + PyStmt::Expr(s) => make_span(s.range), + PyStmt::Pass(s) => make_span(s.range), + PyStmt::Break(s) => make_span(s.range), + PyStmt::Continue(s) => make_span(s.range), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_lower_simple_function() { + let source = r#" +def foo(x: int) -> int: + return x + 1 +"#; + let module = lower_source(source, "").unwrap(); + assert_eq!(module.functions.len(), 1); + assert_eq!(module.functions[0].name, "foo"); + assert_eq!(module.functions[0].params.len(), 1); + assert_eq!(module.functions[0].params[0].name, "x"); + } + + #[test] + fn test_lower_gate_call() { + let source = r#" +def bell(): + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) +"#; + let module = lower_source(source, "").unwrap(); + assert_eq!(module.functions.len(), 1); + + let body = &module.functions[0].body; + assert!(matches!(body[0], Stmt::Qalloc { .. })); + assert!(matches!(body[1], Stmt::Gate { gate: GateKind::H, .. })); + assert!(matches!(body[2], Stmt::Gate { gate: GateKind::Cx, .. })); + } + + #[test] + fn test_lower_for_range() { + let source = r#" +def loop(): + for i in range(10): + pass +"#; + let module = lower_source(source, "").unwrap(); + let body = &module.functions[0].body; + + if let Stmt::For { iter, .. } = &body[0] { + assert!(matches!(iter, ForIter::Range { start: None, .. })); + } else { + panic!("expected for loop"); + } + } +} diff --git a/exp/guppy-zlup/src/linter/noqa.rs b/exp/guppy-zlup/src/linter/noqa.rs new file mode 100644 index 000000000..a412e96e3 --- /dev/null +++ b/exp/guppy-zlup/src/linter/noqa.rs @@ -0,0 +1,152 @@ +//! Support for inline disable comments (noqa). +//! +//! Supports: +//! - `# noqa` - suppress all warnings on this line +//! - `# noqa: ZLUP001` - suppress specific rule +//! - `# noqa: ZLUP001, ZLUP002` - suppress multiple rules +//! - `# type: ignore` - suppress type-related warnings (ZLUP006) + +use std::collections::{BTreeMap, BTreeSet}; + +use regex::Regex; + +/// Parsed noqa directives from source code. +#[derive(Debug, Default)] +pub struct NoqaDirectives { + /// Lines where all rules are suppressed (# noqa without specific rules). + pub suppress_all: BTreeSet, + + /// Map from line number to set of suppressed rule IDs (uppercase). + pub suppress_rules: BTreeMap>, + + /// File-level suppressions (rules suppressed for entire file, uppercase). + pub file_level: BTreeSet, +} + +impl NoqaDirectives { + /// Check if a diagnostic at the given line with the given rule should be suppressed. + pub fn is_suppressed(&self, line: u32, rule_id: &str) -> bool { + let rule_upper = rule_id.to_uppercase(); + + // Check file-level suppression + if self.file_level.contains(&rule_upper) || self.file_level.contains("*") { + return true; + } + + // Check line-level suppress all + if self.suppress_all.contains(&line) { + return true; + } + + // Check line-level rule suppression + if let Some(rules) = self.suppress_rules.get(&line) + && rules.contains(&rule_upper) { + return true; + } + + false + } +} + +/// Parse noqa directives from source code. +pub fn parse_noqa(source: &str) -> NoqaDirectives { + let mut directives = NoqaDirectives::default(); + + // Regex patterns (case insensitive for noqa rules) + let noqa_pattern = Regex::new(r"(?i)#\s*noqa(?:\s*:\s*([A-Z0-9,\s]+))?").unwrap(); + let type_ignore_pattern = Regex::new(r"#\s*type:\s*ignore").unwrap(); + + for (line_idx, line) in source.lines().enumerate() { + let line_num = (line_idx + 1) as u32; + + // Check for # noqa comments + if let Some(captures) = noqa_pattern.captures(line) { + if let Some(rules_match) = captures.get(1) { + // Specific rules: # noqa: ZLUP001, ZLUP002 + let rules: BTreeSet = rules_match + .as_str() + .split(',') + .map(|s| s.trim().to_uppercase()) + .filter(|s| !s.is_empty()) + .collect(); + + // Check if this is a file-level directive (first non-empty, non-comment line area) + if line_idx < 5 && line.trim().starts_with('#') { + directives.file_level.extend(rules.clone()); + } + + directives + .suppress_rules + .entry(line_num) + .or_default() + .extend(rules); + } else { + // Suppress all: # noqa + directives.suppress_all.insert(line_num); + } + } + + // Check for # type: ignore (suppresses ZLUP006) + if type_ignore_pattern.is_match(line) { + directives + .suppress_rules + .entry(line_num) + .or_default() + .insert("ZLUP006".to_string()); + } + } + + directives +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_noqa_all() { + let source = "x = 1 / y # noqa"; + let directives = parse_noqa(source); + assert!(directives.suppress_all.contains(&1)); + assert!(directives.is_suppressed(1, "ZLUP005")); + } + + #[test] + fn test_noqa_specific() { + let source = "x = 1 / y # noqa: ZLUP005"; + let directives = parse_noqa(source); + assert!(directives.is_suppressed(1, "ZLUP005")); + assert!(!directives.is_suppressed(1, "ZLUP001")); + } + + #[test] + fn test_noqa_multiple() { + let source = "x = 1 / y # noqa: ZLUP005, ZLUP006"; + let directives = parse_noqa(source); + assert!(directives.is_suppressed(1, "ZLUP005")); + assert!(directives.is_suppressed(1, "ZLUP006")); + assert!(!directives.is_suppressed(1, "ZLUP001")); + } + + #[test] + fn test_type_ignore() { + let source = "def foo(x): # type: ignore"; + let directives = parse_noqa(source); + assert!(directives.is_suppressed(1, "ZLUP006")); + assert!(!directives.is_suppressed(1, "ZLUP001")); + } + + #[test] + fn test_not_suppressed() { + let source = "x = 1 / y"; + let directives = parse_noqa(source); + assert!(!directives.is_suppressed(1, "ZLUP005")); + } + + #[test] + fn test_case_insensitive() { + let source = "x = 1 / y # noqa: zlup005"; + let directives = parse_noqa(source); + assert!(directives.is_suppressed(1, "ZLUP005")); + } +} diff --git a/exp/guppy-zlup/src/linter/output.rs b/exp/guppy-zlup/src/linter/output.rs new file mode 100644 index 000000000..dfaca7030 --- /dev/null +++ b/exp/guppy-zlup/src/linter/output.rs @@ -0,0 +1,306 @@ +//! Output formatters for lint results. + +use serde::Serialize; + +use super::diagnostic::{Diagnostic, Severity}; +use super::engine::LintResult; + +/// Output format for lint results. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum OutputFormat { + /// Human-readable text output (default). + #[default] + Text, + /// JSON output for machine parsing. + Json, + /// SARIF format for GitHub Actions integration. + Sarif, +} + +impl std::str::FromStr for OutputFormat { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "text" => Ok(OutputFormat::Text), + "json" => Ok(OutputFormat::Json), + "sarif" => Ok(OutputFormat::Sarif), + _ => Err(format!("Unknown output format: '{}'. Valid options: text, json, sarif", s)), + } + } +} + +impl LintResult { + /// Format the lint result as JSON. + pub fn to_json(&self) -> String { + let output = JsonOutput { + diagnostics: &self.diagnostics, + summary: JsonSummary { + total: self.diagnostics.len(), + errors: self.diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error)).count(), + warnings: self.diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(), + has_errors: self.has_errors, + has_warnings: self.has_warnings, + }, + }; + serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()) + } + + /// Format the lint result as SARIF (Static Analysis Results Interchange Format). + pub fn to_sarif(&self, tool_name: &str, tool_version: &str) -> String { + let sarif = SarifReport::from_lint_result(self, tool_name, tool_version); + serde_json::to_string_pretty(&sarif).unwrap_or_else(|_| "{}".to_string()) + } + + /// Format the lint result according to the specified format. + pub fn format(&self, format: OutputFormat) -> String { + match format { + OutputFormat::Text => self.to_string(), + OutputFormat::Json => self.to_json(), + OutputFormat::Sarif => self.to_sarif("guppy-zlup", env!("CARGO_PKG_VERSION")), + } + } +} + +// JSON output structures + +#[derive(Serialize)] +struct JsonOutput<'a> { + diagnostics: &'a [Diagnostic], + summary: JsonSummary, +} + +#[derive(Serialize)] +struct JsonSummary { + total: usize, + errors: usize, + warnings: usize, + has_errors: bool, + has_warnings: bool, +} + +// SARIF output structures (version 2.1.0) + +#[derive(Serialize)] +struct SarifReport { + #[serde(rename = "$schema")] + schema: &'static str, + version: &'static str, + runs: Vec, +} + +#[derive(Serialize)] +struct SarifRun { + tool: SarifTool, + results: Vec, +} + +#[derive(Serialize)] +struct SarifTool { + driver: SarifDriver, +} + +#[derive(Serialize)] +struct SarifDriver { + name: String, + version: String, + #[serde(rename = "informationUri")] + information_uri: &'static str, + rules: Vec, +} + +#[derive(Serialize)] +struct SarifRule { + id: String, + name: String, + #[serde(rename = "shortDescription")] + short_description: SarifMessage, + #[serde(rename = "defaultConfiguration")] + default_configuration: SarifConfiguration, +} + +#[derive(Serialize)] +struct SarifConfiguration { + level: &'static str, +} + +#[derive(Serialize)] +struct SarifResult { + #[serde(rename = "ruleId")] + rule_id: String, + level: &'static str, + message: SarifMessage, + locations: Vec, +} + +#[derive(Serialize)] +struct SarifMessage { + text: String, +} + +#[derive(Serialize)] +struct SarifLocation { + #[serde(rename = "physicalLocation")] + physical_location: SarifPhysicalLocation, +} + +#[derive(Serialize)] +struct SarifPhysicalLocation { + #[serde(rename = "artifactLocation")] + artifact_location: SarifArtifactLocation, + region: SarifRegion, +} + +#[derive(Serialize)] +struct SarifArtifactLocation { + uri: String, +} + +#[derive(Serialize)] +struct SarifRegion { + #[serde(rename = "startLine")] + start_line: u32, + #[serde(rename = "startColumn")] + start_column: u32, + #[serde(rename = "endLine", skip_serializing_if = "Option::is_none")] + end_line: Option, + #[serde(rename = "endColumn", skip_serializing_if = "Option::is_none")] + end_column: Option, +} + +impl SarifReport { + fn from_lint_result(result: &LintResult, tool_name: &str, tool_version: &str) -> Self { + // Collect unique rules + let mut rules: Vec = Vec::new(); + let mut seen_rules: std::collections::HashSet = std::collections::HashSet::new(); + + for diag in &result.diagnostics { + if !seen_rules.contains(&diag.rule_id) { + seen_rules.insert(diag.rule_id.clone()); + rules.push(SarifRule { + id: diag.rule_id.clone(), + name: diag.rule_id.clone(), + short_description: SarifMessage { + text: get_rule_description(&diag.rule_id), + }, + default_configuration: SarifConfiguration { + level: severity_to_sarif_level(&diag.severity), + }, + }); + } + } + + // Convert diagnostics to SARIF results + let results: Vec = result + .diagnostics + .iter() + .map(|diag| SarifResult { + rule_id: diag.rule_id.clone(), + level: severity_to_sarif_level(&diag.severity), + message: SarifMessage { + text: diag.message.clone(), + }, + locations: vec![SarifLocation { + physical_location: SarifPhysicalLocation { + artifact_location: SarifArtifactLocation { + uri: diag.location.file.clone().unwrap_or_default(), + }, + region: SarifRegion { + start_line: diag.location.line, + start_column: diag.location.column, + end_line: diag.location.end_line, + end_column: diag.location.end_column, + }, + }, + }], + }) + .collect(); + + SarifReport { + schema: "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json", + version: "2.1.0", + runs: vec![SarifRun { + tool: SarifTool { + driver: SarifDriver { + name: tool_name.to_string(), + version: tool_version.to_string(), + information_uri: "https://github.com/PECOS-packages/PECOS", + rules, + }, + }, + results, + }], + } + } +} + +fn severity_to_sarif_level(severity: &Severity) -> &'static str { + match severity { + Severity::Error => "error", + Severity::Warning => "warning", + Severity::Info => "note", + Severity::Hint => "note", + } +} + +fn get_rule_description(rule_id: &str) -> String { + match rule_id { + "ZLUP001" => "Unbounded loops are prohibited".to_string(), + "ZLUP002" => "Recursive function calls are prohibited".to_string(), + "ZLUP003" => "Dynamic allocation inside loops is prohibited".to_string(), + "ZLUP004" => "Dynamic dispatch is prohibited".to_string(), + "ZLUP005" => "Unchecked error conditions".to_string(), + "ZLUP006" => "Missing type annotations".to_string(), + "ZLUP007" => "Overly complex control flow".to_string(), + "ZLUP008" => "Excessive call depth".to_string(), + "ZLUP009" => "Missing assertions in non-trivial functions".to_string(), + "ZLUP010" => "Mutable global state".to_string(), + "PARSE" => "Syntax error".to_string(), + _ => format!("Rule {}", rule_id), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use super::super::diagnostic::SourceLocation; + + #[test] + fn test_output_format_parse() { + assert_eq!("text".parse::().unwrap(), OutputFormat::Text); + assert_eq!("json".parse::().unwrap(), OutputFormat::Json); + assert_eq!("sarif".parse::().unwrap(), OutputFormat::Sarif); + assert_eq!("JSON".parse::().unwrap(), OutputFormat::Json); + assert!("invalid".parse::().is_err()); + } + + #[test] + fn test_json_output() { + let mut result = LintResult::new(); + result.add(Diagnostic::error( + "ZLUP001", + "test error", + SourceLocation::new(1, 1).with_file("test.py"), + )); + + let json = result.to_json(); + assert!(json.contains("ZLUP001")); + assert!(json.contains("test error")); + assert!(json.contains("\"errors\": 1")); + } + + #[test] + fn test_sarif_output() { + let mut result = LintResult::new(); + result.add(Diagnostic::error( + "ZLUP001", + "test error", + SourceLocation::new(1, 1).with_file("test.py"), + )); + + let sarif = result.to_sarif("guppy-zlup", "0.1.0"); + assert!(sarif.contains("ZLUP001")); + assert!(sarif.contains("test.py")); + assert!(sarif.contains("\"version\": \"2.1.0\"")); + } +} diff --git a/exp/guppy-zlup/src/linter/rules.rs b/exp/guppy-zlup/src/linter/rules.rs new file mode 100644 index 000000000..b10e9e7ce --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules.rs @@ -0,0 +1,77 @@ +//! Lint rules for guppy-zlup. + +mod zlup001; +mod zlup002; +mod zlup003; +mod zlup004; +mod zlup005; +mod zlup006; +mod zlup007; +mod zlup008; +mod zlup009; +mod zlup010; + +pub use zlup001::ZLUP001UnboundedLoops; +pub use zlup002::ZLUP002Recursion; +pub use zlup003::ZLUP003DynamicAllocation; +pub use zlup004::ZLUP004DynamicDispatch; +pub use zlup005::ZLUP005UncheckedErrors; +pub use zlup006::ZLUP006MissingTypes; +pub use zlup007::ZLUP007ComplexControlFlow; +pub use zlup008::ZLUP008CallDepth; +pub use zlup009::ZLUP009AssertionDensity; +pub use zlup010::ZLUP010GlobalState; + +use rustpython_parser::ast::Mod; +use rustpython_parser::text_size::TextRange; + +use super::diagnostic::{Diagnostic, Severity, SourceLocation}; + +/// Trait for lint rules. +pub trait LintRule: Send + Sync { + /// Rule identifier (e.g., "ZLUP001"). + fn id(&self) -> &'static str; + + /// Human-readable rule name. + fn name(&self) -> &'static str; + + /// Full description of the rule. + fn description(&self) -> &'static str; + + /// Default severity level. + fn severity(&self) -> Severity; + + /// Check an AST for violations of this rule. + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec; +} + +/// Convert a byte offset to (line, column) using the source text. +fn offset_to_line_col(source: &str, offset: usize) -> (usize, usize) { + let mut line = 1; + let mut col = 1; + for (i, ch) in source.char_indices() { + if i >= offset { + break; + } + if ch == '\n' { + line += 1; + col = 1; + } else { + col += 1; + } + } + (line, col) +} + +/// Helper to create a source location from a TextRange. +pub fn make_location(range: TextRange, filename: &str, source: &str) -> SourceLocation { + let (line, column) = offset_to_line_col(source, range.start().into()); + let (end_line, end_column) = offset_to_line_col(source, range.end().into()); + SourceLocation { + line: line as u32, + column: column as u32, + end_line: Some(end_line as u32), + end_column: Some(end_column as u32), + file: Some(filename.to_string()), + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup001.rs b/exp/guppy-zlup/src/linter/rules/zlup001.rs new file mode 100644 index 000000000..07a44cd33 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup001.rs @@ -0,0 +1,262 @@ +//! ZLUP001: Detect unbounded loops. + +use rustpython_parser::ast::{self, Constant, Expr, Mod, Stmt}; + +use super::{make_location, LintRule}; +use super::super::diagnostic::{Diagnostic, Severity}; + +/// Detects unbounded loops that violate NASA Power of 10 rules. +pub struct ZLUP001UnboundedLoops; + +impl LintRule for ZLUP001UnboundedLoops { + fn id(&self) -> &'static str { + "ZLUP001" + } + + fn name(&self) -> &'static str { + "unbounded-loops" + } + + fn description(&self) -> &'static str { + "All loops must have a fixed upper bound. Unbounded loops \ + (while True, while with non-constant condition) are prohibited." + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + if let Mod::Module(module) = parsed { + for stmt in &module.body { + check_stmt(stmt, filename, source, &mut diagnostics); + } + } + + diagnostics + } +} + +fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { + match stmt { + Stmt::While(while_stmt) => { + check_while_loop(while_stmt, filename, source, diagnostics); + // Recursively check body + for s in &while_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::For(for_stmt) => { + for s in &for_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::If(if_stmt) => { + for s in &if_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(h) = handler; + for s in &h.body { + check_stmt(s, filename, source, diagnostics); + } + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_while_loop( + while_stmt: &ast::StmtWhile, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + let test = &while_stmt.test; + + // Check for `while True:` + if let Expr::Constant(c) = test.as_ref() { + if matches!(c.value, Constant::Bool(true)) { + // Only report if there's no unconditional break + if !has_unconditional_break(&while_stmt.body) { + diagnostics.push( + Diagnostic::error( + "ZLUP001", + "'while True' creates an unbounded loop", + make_location(while_stmt.range, filename, source), + ) + .with_suggestion("Use a for loop with a fixed upper bound instead") + .with_source_context(source), + ); + } + return; + } + + // Check for `while 1:` + if let Constant::Int(ref i) = c.value + && (i.to_u32_digits().1 == [1] || i.to_string() == "1") { + // Only report if there's no unconditional break + if !has_unconditional_break(&while_stmt.body) { + diagnostics.push( + Diagnostic::error( + "ZLUP001", + "'while 1' creates an unbounded loop", + make_location(while_stmt.range, filename, source), + ) + .with_suggestion("Use a for loop with a fixed upper bound instead") + .with_source_context(source), + ); + } + return; + } + } + + // Check for while loops without a clear termination condition + if !has_bounded_condition(test) && !has_unconditional_break(&while_stmt.body) { + diagnostics.push( + Diagnostic::error( + "ZLUP001", + "while loop may be unbounded", + make_location(while_stmt.range, filename, source), + ) + .with_suggestion( + "Consider using a for loop with range() or add a fixed iteration limit", + ) + .with_source_context(source), + ); + } +} + +fn has_bounded_condition(test: &Expr) -> bool { + match test { + // Comparisons against variables that might change are acceptable + Expr::Compare(_) => true, + // Boolean variables are acceptable (might become False) + Expr::Name(_) => true, + // Binary boolean operations + Expr::BoolOp(op) => op.values.iter().all(has_bounded_condition), + // Unary not + Expr::UnaryOp(unary) if matches!(unary.op, ast::UnaryOp::Not) => { + has_bounded_condition(&unary.operand) + } + _ => false, + } +} + +fn has_unconditional_break(body: &[Stmt]) -> bool { + for stmt in body { + if matches!(stmt, Stmt::Break(_)) { + return true; + } + // Check for guaranteed break in all branches of an if + if let Stmt::If(if_stmt) = stmt + && has_unconditional_break(&if_stmt.body) && has_unconditional_break(&if_stmt.orelse) { + return true; + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP001UnboundedLoops.check(&parsed, "", source) + } + + #[test] + fn test_while_true() { + let diagnostics = check_source( + r#" +while True: + pass +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("while True")); + } + + #[test] + fn test_while_one() { + let diagnostics = check_source( + r#" +while 1: + pass +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("while 1")); + } + + #[test] + fn test_while_with_comparison() { + let diagnostics = check_source( + r#" +x = 10 +while x > 0: + x -= 1 +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_while_with_break() { + let diagnostics = check_source( + r#" +while True: + if condition: + break + else: + break +"#, + ); + // Has unconditional break in both branches + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup002.rs b/exp/guppy-zlup/src/linter/rules/zlup002.rs new file mode 100644 index 000000000..b5fa7c5a6 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup002.rs @@ -0,0 +1,349 @@ +//! ZLUP002: Detect recursive function calls. + +use std::collections::{HashMap, HashSet}; + +use rustpython_parser::ast::{self, Expr, Mod, Stmt}; + +use super::{make_location, LintRule}; +use super::super::diagnostic::{Diagnostic, Severity}; + +/// Detects recursive function calls (call graph cycles). +pub struct ZLUP002Recursion; + +impl LintRule for ZLUP002Recursion { + fn id(&self) -> &'static str { + "ZLUP002" + } + + fn name(&self) -> &'static str { + "recursion" + } + + fn description(&self) -> &'static str { + "Recursive function calls are prohibited. All call graphs must be acyclic \ + to ensure bounded stack usage and predictable execution." + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + // Collect function definitions + let mut functions: HashMap = HashMap::new(); + for stmt in &module.body { + if let Stmt::FunctionDef(func) = stmt { + functions.insert(func.name.to_string(), func); + } + } + + // Build call graph + let mut call_graph: HashMap> = HashMap::new(); + for (name, func) in &functions { + let calls = collect_calls(&func.body, &functions); + call_graph.insert(name.clone(), calls); + } + + // Detect cycles + let mut reported: HashSet = HashSet::new(); + for func_name in functions.keys() { + if reported.contains(func_name) { + continue; + } + + if let Some(cycle) = find_cycle(func_name, &call_graph) { + let func = functions.get(func_name).unwrap(); + + if cycle.len() == 1 { + // Direct recursion + diagnostics.push( + Diagnostic::error( + "ZLUP002", + format!("function '{}' calls itself (direct recursion)", func_name), + make_location(func.range, filename, source), + ) + .with_suggestion( + "Replace recursion with iteration using a loop with a fixed upper bound", + ) + .with_source_context(source), + ); + } else { + // Indirect recursion + let cycle_str = cycle + .iter() + .chain(std::iter::once(&cycle[0])) + .cloned() + .collect::>() + .join(" -> "); + + diagnostics.push( + Diagnostic::error( + "ZLUP002", + format!( + "function '{}' is part of a recursive cycle: {}", + func_name, cycle_str + ), + make_location(func.range, filename, source), + ) + .with_suggestion( + "Break the cycle by restructuring the call graph or using iteration", + ) + .with_source_context(source), + ); + } + + // Mark all functions in the cycle as reported + for name in &cycle { + reported.insert(name.clone()); + } + } + } + + diagnostics + } +} + +fn collect_calls( + body: &[Stmt], + known_funcs: &HashMap, +) -> HashSet { + let mut calls = HashSet::new(); + + for stmt in body { + collect_calls_from_stmt(stmt, known_funcs, &mut calls); + } + + calls +} + +fn collect_calls_from_stmt( + stmt: &Stmt, + known_funcs: &HashMap, + calls: &mut HashSet, +) { + match stmt { + Stmt::Expr(expr_stmt) => { + collect_calls_from_expr(&expr_stmt.value, known_funcs, calls); + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + collect_calls_from_expr(value, known_funcs, calls); + } + } + Stmt::Assign(assign) => { + collect_calls_from_expr(&assign.value, known_funcs, calls); + } + Stmt::AugAssign(aug) => { + collect_calls_from_expr(&aug.value, known_funcs, calls); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + collect_calls_from_expr(value, known_funcs, calls); + } + } + Stmt::For(for_stmt) => { + collect_calls_from_expr(&for_stmt.iter, known_funcs, calls); + for s in &for_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &for_stmt.orelse { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + Stmt::While(while_stmt) => { + collect_calls_from_expr(&while_stmt.test, known_funcs, calls); + for s in &while_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &while_stmt.orelse { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + Stmt::If(if_stmt) => { + collect_calls_from_expr(&if_stmt.test, known_funcs, calls); + for s in &if_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &if_stmt.orelse { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &try_stmt.orelse { + collect_calls_from_stmt(s, known_funcs, calls); + } + for s in &try_stmt.finalbody { + collect_calls_from_stmt(s, known_funcs, calls); + } + } + _ => {} + } +} + +fn collect_calls_from_expr( + expr: &Expr, + known_funcs: &HashMap, + calls: &mut HashSet, +) { + match expr { + Expr::Call(call) => { + if let Expr::Name(name) = call.func.as_ref() { + let func_name = name.id.to_string(); + if known_funcs.contains_key(&func_name) { + calls.insert(func_name); + } + } + // Recurse into arguments + for arg in &call.args { + collect_calls_from_expr(arg, known_funcs, calls); + } + } + Expr::BinOp(binop) => { + collect_calls_from_expr(&binop.left, known_funcs, calls); + collect_calls_from_expr(&binop.right, known_funcs, calls); + } + Expr::UnaryOp(unary) => { + collect_calls_from_expr(&unary.operand, known_funcs, calls); + } + Expr::Compare(cmp) => { + collect_calls_from_expr(&cmp.left, known_funcs, calls); + for comparator in &cmp.comparators { + collect_calls_from_expr(comparator, known_funcs, calls); + } + } + Expr::BoolOp(boolop) => { + for value in &boolop.values { + collect_calls_from_expr(value, known_funcs, calls); + } + } + Expr::IfExp(ifexp) => { + collect_calls_from_expr(&ifexp.test, known_funcs, calls); + collect_calls_from_expr(&ifexp.body, known_funcs, calls); + collect_calls_from_expr(&ifexp.orelse, known_funcs, calls); + } + Expr::List(list) => { + for elt in &list.elts { + collect_calls_from_expr(elt, known_funcs, calls); + } + } + Expr::Tuple(tuple) => { + for elt in &tuple.elts { + collect_calls_from_expr(elt, known_funcs, calls); + } + } + Expr::Subscript(sub) => { + collect_calls_from_expr(&sub.value, known_funcs, calls); + collect_calls_from_expr(&sub.slice, known_funcs, calls); + } + _ => {} + } +} + +fn find_cycle(start: &str, graph: &HashMap>) -> Option> { + let mut visited: HashSet = HashSet::new(); + let mut path: Vec = Vec::new(); + + fn dfs( + node: &str, + graph: &HashMap>, + visited: &mut HashSet, + path: &mut Vec, + ) -> Option> { + if let Some(pos) = path.iter().position(|n| n == node) { + // Found a cycle + return Some(path[pos..].to_vec()); + } + + if visited.contains(node) { + return None; + } + + visited.insert(node.to_string()); + path.push(node.to_string()); + + if let Some(neighbors) = graph.get(node) { + for neighbor in neighbors { + if let Some(cycle) = dfs(neighbor, graph, visited, path) { + return Some(cycle); + } + } + } + + path.pop(); + None + } + + dfs(start, graph, &mut visited, &mut path) +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP002Recursion.check(&parsed, "", source) + } + + #[test] + fn test_direct_recursion() { + let diagnostics = check_source( + r#" +def factorial(n): + if n <= 1: + return 1 + return n * factorial(n - 1) +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("direct recursion")); + } + + #[test] + fn test_indirect_recursion() { + let diagnostics = check_source( + r#" +def foo(): + bar() + +def bar(): + foo() +"#, + ); + // Should detect a cycle + assert!(!diagnostics.is_empty()); + assert!(diagnostics[0].message.contains("recursive cycle")); + } + + #[test] + fn test_no_recursion() { + let diagnostics = check_source( + r#" +def foo(): + pass + +def bar(): + foo() + +def baz(): + bar() +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup003.rs b/exp/guppy-zlup/src/linter/rules/zlup003.rs new file mode 100644 index 000000000..0f2071a07 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup003.rs @@ -0,0 +1,318 @@ +//! ZLUP003: Detect dynamic allocation inside loops. + +use rustpython_parser::ast::{self, Expr, Mod, Stmt}; + +use super::{make_location, LintRule}; +use super::super::diagnostic::{Diagnostic, Severity}; + +/// Functions/types that perform allocation. +const ALLOCATION_FUNCTIONS: &[&str] = &[ + "qalloc", "qubit", "list", "dict", "set", "bytearray", "array", +]; + +/// Methods that may cause allocation. +const ALLOCATION_METHODS: &[&str] = &["append", "extend", "insert", "copy"]; + +/// Detects dynamic allocation inside loop bodies. +pub struct ZLUP003DynamicAllocation; + +impl LintRule for ZLUP003DynamicAllocation { + fn id(&self) -> &'static str { + "ZLUP003" + } + + fn name(&self) -> &'static str { + "dynamic-allocation" + } + + fn description(&self) -> &'static str { + "Dynamic allocation inside loops is prohibited. All allocations \ + should be performed at initialization time with fixed sizes." + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, 0, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + loop_depth: usize, + diagnostics: &mut Vec, +) { + match stmt { + Stmt::For(for_stmt) => { + for s in &for_stmt.body { + check_stmt(s, filename, source, loop_depth + 1, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::While(while_stmt) => { + for s in &while_stmt.body { + check_stmt(s, filename, source, loop_depth + 1, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, 0, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, 0, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, 0, diagnostics); + } + } + Stmt::If(if_stmt) => { + for s in &if_stmt.body { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(h) = handler; + for s in &h.body { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, loop_depth, diagnostics); + } + } + Stmt::Expr(expr_stmt) => { + if loop_depth > 0 { + check_expr(&expr_stmt.value, filename, source, diagnostics); + } + } + Stmt::Assign(assign) => { + if loop_depth > 0 { + check_expr(&assign.value, filename, source, diagnostics); + } + } + Stmt::AugAssign(aug) => { + if loop_depth > 0 { + check_expr(&aug.value, filename, source, diagnostics); + } + } + Stmt::AnnAssign(ann) => { + if loop_depth > 0 + && let Some(value) = &ann.value { + check_expr(value, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_expr(expr: &Expr, filename: &str, source: &str, diagnostics: &mut Vec) { + match expr { + Expr::Call(call) => { + // Check for direct allocation calls: list(), qalloc(), etc. + if let Expr::Name(name) = call.func.as_ref() { + let func_name = name.id.as_str(); + if ALLOCATION_FUNCTIONS.contains(&func_name) { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + format!("'{}()' allocates memory inside a loop", func_name), + make_location(call.range, filename, source), + ) + .with_suggestion( + "Move allocation outside the loop and reuse or pre-allocate with a fixed size", + ) + .with_source_context(source), + ); + } + } + + // Check for allocation methods: list.append(), etc. + if let Expr::Attribute(attr) = call.func.as_ref() { + let method_name = attr.attr.as_str(); + if ALLOCATION_METHODS.contains(&method_name) { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + format!("'.{}()' may allocate memory inside a loop", method_name), + make_location(call.range, filename, source), + ) + .with_suggestion( + "Pre-allocate the collection with sufficient capacity before the loop", + ) + .with_source_context(source), + ); + } + } + + // Recurse into arguments + for arg in &call.args { + check_expr(arg, filename, source, diagnostics); + } + } + + Expr::Subscript(sub) => { + // Check for qubit[n] allocation + if let Expr::Name(name) = sub.value.as_ref() + && name.id.as_str() == "qubit" { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "'qubit[...]' allocates qubits inside a loop", + make_location(sub.range, filename, source), + ) + .with_suggestion("Allocate qubits outside the loop") + .with_source_context(source), + ); + } + } + + Expr::ListComp(comp) => { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "List comprehension allocates memory inside a loop", + make_location(comp.range, filename, source), + ) + .with_suggestion("Pre-compute the list outside the loop") + .with_source_context(source), + ); + } + + Expr::DictComp(comp) => { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "Dict comprehension allocates memory inside a loop", + make_location(comp.range, filename, source), + ) + .with_suggestion("Pre-compute the dict outside the loop") + .with_source_context(source), + ); + } + + Expr::SetComp(comp) => { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "Set comprehension allocates memory inside a loop", + make_location(comp.range, filename, source), + ) + .with_suggestion("Pre-compute the set outside the loop") + .with_source_context(source), + ); + } + + // Recurse into sub-expressions + Expr::BinOp(binop) => { + check_expr(&binop.left, filename, source, diagnostics); + check_expr(&binop.right, filename, source, diagnostics); + } + Expr::UnaryOp(unary) => { + check_expr(&unary.operand, filename, source, diagnostics); + } + Expr::IfExp(ifexp) => { + check_expr(&ifexp.test, filename, source, diagnostics); + check_expr(&ifexp.body, filename, source, diagnostics); + check_expr(&ifexp.orelse, filename, source, diagnostics); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP003DynamicAllocation.check(&parsed, "", source) + } + + #[test] + fn test_list_in_loop() { + let diagnostics = check_source( + r#" +for i in range(10): + x = list() +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("list()")); + } + + #[test] + fn test_append_in_loop() { + let diagnostics = check_source( + r#" +result = [] +for i in range(10): + result.append(i) +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains(".append()")); + } + + #[test] + fn test_list_comprehension_in_loop() { + let diagnostics = check_source( + r#" +for i in range(10): + x = [j for j in range(5)] +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("List comprehension")); + } + + #[test] + fn test_allocation_outside_loop() { + let diagnostics = check_source( + r#" +x = list() +for i in range(10): + pass +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup004.rs b/exp/guppy-zlup/src/linter/rules/zlup004.rs new file mode 100644 index 000000000..82724fbfe --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup004.rs @@ -0,0 +1,300 @@ +//! ZLUP004: Detect dynamic dispatch and runtime evaluation. + +use rustpython_parser::ast::{Expr, Mod, Stmt}; + +use super::{make_location, LintRule}; +use super::super::diagnostic::{Diagnostic, Severity}; + +/// Functions that enable dynamic dispatch or code execution. +const DYNAMIC_FUNCTIONS: &[(&str, &str)] = &[ + ("eval", "executes arbitrary code at runtime"), + ("exec", "executes arbitrary code at runtime"), + ("compile", "compiles code at runtime"), + ("getattr", "dynamically resolves attributes"), + ("setattr", "dynamically sets attributes"), + ("delattr", "dynamically deletes attributes"), + ("hasattr", "dynamically checks attributes"), + ("__import__", "dynamically imports modules"), +]; + +/// Detects dynamic dispatch and runtime code execution. +pub struct ZLUP004DynamicDispatch; + +impl LintRule for ZLUP004DynamicDispatch { + fn id(&self) -> &'static str { + "ZLUP004" + } + + fn name(&self) -> &'static str { + "dynamic-dispatch" + } + + fn description(&self) -> &'static str { + "Dynamic dispatch and runtime code execution are prohibited. \ + All function calls and attribute accesses must be statically determinable." + } + + fn severity(&self) -> Severity { + Severity::Error + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { + match stmt { + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::For(for_stmt) => { + check_expr(&for_stmt.iter, filename, source, diagnostics); + for s in &for_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::While(while_stmt) => { + check_expr(&while_stmt.test, filename, source, diagnostics); + for s in &while_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::If(if_stmt) => { + check_expr(&if_stmt.test, filename, source, diagnostics); + for s in &if_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::Expr(expr_stmt) => { + check_expr(&expr_stmt.value, filename, source, diagnostics); + } + Stmt::Assign(assign) => { + check_expr(&assign.value, filename, source, diagnostics); + } + Stmt::AugAssign(aug) => { + check_expr(&aug.value, filename, source, diagnostics); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + check_expr(value, filename, source, diagnostics); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + check_expr(value, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_expr(expr: &Expr, filename: &str, source: &str, diagnostics: &mut Vec) { + match expr { + Expr::Call(call) => { + // Check for calls to dynamic functions + if let Expr::Name(name) = call.func.as_ref() { + let func_name = name.id.as_str(); + if let Some((_, reason)) = DYNAMIC_FUNCTIONS.iter().find(|(n, _)| *n == func_name) { + let suggestion = get_suggestion(func_name); + diagnostics.push( + Diagnostic::error( + "ZLUP004", + format!("'{}()' {}", func_name, reason), + make_location(call.range, filename, source), + ) + .with_suggestion(suggestion) + .with_source_context(source), + ); + } + } + + // Check for calling subscripted expressions (dynamic dispatch) + if let Expr::Subscript(_) = call.func.as_ref() { + diagnostics.push( + Diagnostic::error( + "ZLUP004", + "Calling a subscripted expression creates dynamic dispatch", + make_location(call.range, filename, source), + ) + .with_suggestion("Use explicit function calls or match statements instead") + .with_source_context(source), + ); + } + + // Recurse into callee and arguments + check_expr(&call.func, filename, source, diagnostics); + for arg in &call.args { + check_expr(arg, filename, source, diagnostics); + } + } + + // Recurse into sub-expressions + Expr::BinOp(binop) => { + check_expr(&binop.left, filename, source, diagnostics); + check_expr(&binop.right, filename, source, diagnostics); + } + Expr::UnaryOp(unary) => { + check_expr(&unary.operand, filename, source, diagnostics); + } + Expr::Compare(cmp) => { + check_expr(&cmp.left, filename, source, diagnostics); + for comparator in &cmp.comparators { + check_expr(comparator, filename, source, diagnostics); + } + } + Expr::BoolOp(boolop) => { + for value in &boolop.values { + check_expr(value, filename, source, diagnostics); + } + } + Expr::IfExp(ifexp) => { + check_expr(&ifexp.test, filename, source, diagnostics); + check_expr(&ifexp.body, filename, source, diagnostics); + check_expr(&ifexp.orelse, filename, source, diagnostics); + } + Expr::List(list) => { + for elt in &list.elts { + check_expr(elt, filename, source, diagnostics); + } + } + Expr::Tuple(tuple) => { + for elt in &tuple.elts { + check_expr(elt, filename, source, diagnostics); + } + } + Expr::Dict(dict) => { + for key in dict.keys.iter().flatten() { + check_expr(key, filename, source, diagnostics); + } + for value in &dict.values { + check_expr(value, filename, source, diagnostics); + } + } + Expr::Subscript(sub) => { + check_expr(&sub.value, filename, source, diagnostics); + check_expr(&sub.slice, filename, source, diagnostics); + } + Expr::Attribute(attr) => { + check_expr(&attr.value, filename, source, diagnostics); + } + _ => {} + } +} + +fn get_suggestion(func_name: &str) -> &'static str { + match func_name { + "eval" | "exec" | "compile" => "Use direct function calls or predefined operations", + "getattr" => "Use direct attribute access (obj.attr) or a dictionary lookup", + "setattr" => "Use direct attribute assignment (obj.attr = value)", + "delattr" => "Use direct attribute deletion (del obj.attr)", + "hasattr" => "Use direct attribute access with try/except or a dictionary", + "__import__" => "Use regular import statements", + _ => "Avoid dynamic dispatch", + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP004DynamicDispatch.check(&parsed, "", source) + } + + #[test] + fn test_eval() { + let diagnostics = check_source( + r#" +result = eval("1 + 2") +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("eval()")); + } + + #[test] + fn test_getattr() { + let diagnostics = check_source( + r#" +value = getattr(obj, "attr") +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("getattr()")); + } + + #[test] + fn test_subscript_call() { + let diagnostics = check_source( + r#" +func_table = {"a": func_a, "b": func_b} +func_table["a"]() +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("subscripted expression")); + } + + #[test] + fn test_normal_function_call() { + let diagnostics = check_source( + r#" +def foo(): + pass + +foo() +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup005.rs b/exp/guppy-zlup/src/linter/rules/zlup005.rs new file mode 100644 index 000000000..5999bfe45 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup005.rs @@ -0,0 +1,306 @@ +//! ZLUP005: Detect unchecked error conditions. + +use rustpython_parser::ast::{self, Constant, Expr, Mod, Stmt}; + +use super::{make_location, LintRule}; +use super::super::diagnostic::{Diagnostic, Severity}; + +/// Functions that may raise exceptions and should be wrapped in try/except. +const FUNCTIONS_THAT_MAY_RAISE: &[(&str, &str)] = &[ + ("open", "may raise FileNotFoundError or PermissionError"), + ("int", "may raise ValueError"), + ("float", "may raise ValueError"), +]; + +/// Detects potentially unchecked error conditions. +pub struct ZLUP005UncheckedErrors; + +impl LintRule for ZLUP005UncheckedErrors { + fn id(&self) -> &'static str { + "ZLUP005" + } + + fn name(&self) -> &'static str { + "unchecked-errors" + } + + fn description(&self) -> &'static str { + "Operations that may fail should have explicit error handling. \ + Assertions should be used to validate preconditions." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, false, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + in_try: bool, + diagnostics: &mut Vec, +) { + match stmt { + Stmt::Try(try_stmt) => { + // Inside try block, errors are handled + for s in &try_stmt.body { + check_stmt(s, filename, source, true, diagnostics); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(h) = handler; + for s in &h.body { + check_stmt(s, filename, source, false, diagnostics); + } + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, false, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, false, diagnostics); + } + } + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, false, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, false, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, false, diagnostics); + } + } + Stmt::For(for_stmt) => { + check_expr(&for_stmt.iter, filename, source, in_try, diagnostics); + for s in &for_stmt.body { + check_stmt(s, filename, source, in_try, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, in_try, diagnostics); + } + } + Stmt::While(while_stmt) => { + check_expr(&while_stmt.test, filename, source, in_try, diagnostics); + for s in &while_stmt.body { + check_stmt(s, filename, source, in_try, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, in_try, diagnostics); + } + } + Stmt::If(if_stmt) => { + check_expr(&if_stmt.test, filename, source, in_try, diagnostics); + for s in &if_stmt.body { + check_stmt(s, filename, source, in_try, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, in_try, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, in_try, diagnostics); + } + } + Stmt::Expr(expr_stmt) => { + check_expr(&expr_stmt.value, filename, source, in_try, diagnostics); + } + Stmt::Assign(assign) => { + check_expr(&assign.value, filename, source, in_try, diagnostics); + } + Stmt::AugAssign(aug) => { + check_expr(&aug.value, filename, source, in_try, diagnostics); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + check_expr(value, filename, source, in_try, diagnostics); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + check_expr(value, filename, source, in_try, diagnostics); + } + } + _ => {} + } +} + +fn check_expr( + expr: &Expr, + filename: &str, + source: &str, + in_try: bool, + diagnostics: &mut Vec, +) { + if in_try { + // Inside try block, don't report + return; + } + + match expr { + Expr::BinOp(binop) => { + // Check for division + if matches!( + binop.op, + ast::Operator::Div | ast::Operator::FloorDiv | ast::Operator::Mod + ) { + // Check if divisor is a non-zero literal + let is_safe = if let Expr::Constant(c) = binop.right.as_ref() { + match &c.value { + Constant::Int(i) => { + // Check if not zero + i.to_u32_digits().1.first() != Some(&0) || !i.to_u32_digits().1.is_empty() + } + Constant::Float(f) => *f != 0.0, + _ => false, + } + } else { + false + }; + + if !is_safe { + let op_name = match binop.op { + ast::Operator::Div => "division", + ast::Operator::FloorDiv => "floor division", + ast::Operator::Mod => "modulo", + _ => "operation", + }; + diagnostics.push( + Diagnostic::warning( + "ZLUP005", + format!("Unchecked {} may raise ZeroDivisionError", op_name), + make_location(binop.range, filename, source), + ) + .with_suggestion("Add an assertion or check that divisor is non-zero") + .with_source_context(source), + ); + } + } + + check_expr(&binop.left, filename, source, in_try, diagnostics); + check_expr(&binop.right, filename, source, in_try, diagnostics); + } + + Expr::Call(call) => { + // Check for calls to functions that may raise + if let Expr::Name(name) = call.func.as_ref() { + let func_name = name.id.as_str(); + if let Some((_, reason)) = + FUNCTIONS_THAT_MAY_RAISE.iter().find(|(n, _)| *n == func_name) + { + diagnostics.push( + Diagnostic::warning( + "ZLUP005", + format!("'{}()' {}", func_name, reason), + make_location(call.range, filename, source), + ) + .with_suggestion("Wrap in try/except or validate input first") + .with_source_context(source), + ); + } + } + + // Recurse into arguments + for arg in &call.args { + check_expr(arg, filename, source, in_try, diagnostics); + } + } + + // Recurse into sub-expressions + Expr::UnaryOp(unary) => { + check_expr(&unary.operand, filename, source, in_try, diagnostics); + } + Expr::Compare(cmp) => { + check_expr(&cmp.left, filename, source, in_try, diagnostics); + for comparator in &cmp.comparators { + check_expr(comparator, filename, source, in_try, diagnostics); + } + } + Expr::BoolOp(boolop) => { + for value in &boolop.values { + check_expr(value, filename, source, in_try, diagnostics); + } + } + Expr::IfExp(ifexp) => { + check_expr(&ifexp.test, filename, source, in_try, diagnostics); + check_expr(&ifexp.body, filename, source, in_try, diagnostics); + check_expr(&ifexp.orelse, filename, source, in_try, diagnostics); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP005UncheckedErrors.check(&parsed, "", source) + } + + #[test] + fn test_division() { + let diagnostics = check_source( + r#" +result = a / b +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("division")); + } + + #[test] + fn test_division_by_literal() { + let diagnostics = check_source( + r#" +result = a / 2 +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_int_conversion() { + let diagnostics = check_source( + r#" +x = int(user_input) +"#, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("int()")); + } + + #[test] + fn test_in_try_block() { + let diagnostics = check_source( + r#" +try: + x = int(user_input) +except ValueError: + x = 0 +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup006.rs b/exp/guppy-zlup/src/linter/rules/zlup006.rs new file mode 100644 index 000000000..6c0324ebd --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup006.rs @@ -0,0 +1,291 @@ +//! ZLUP006: Detect missing type annotations. + +use rustpython_parser::ast::{Mod, Stmt}; + +use super::{make_location, LintRule}; +use super::super::diagnostic::{Diagnostic, Severity}; + +/// Detects missing type annotations on function signatures. +pub struct ZLUP006MissingTypes; + +impl LintRule for ZLUP006MissingTypes { + fn id(&self) -> &'static str { + "ZLUP006" + } + + fn name(&self) -> &'static str { + "missing-types" + } + + fn description(&self) -> &'static str { + "All function parameters and return types should have explicit \ + type annotations for Zlup compilation." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { + match stmt { + Stmt::FunctionDef(func) => { + check_function(func, filename, source, diagnostics); + // Check nested functions + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + check_async_function(func, filename, source, diagnostics); + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::If(if_stmt) => { + for s in &if_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::For(for_stmt) => { + for s in &for_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::While(while_stmt) => { + for s in &while_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_function( + func: &rustpython_parser::ast::StmtFunctionDef, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + let func_name = func.name.as_str(); + + // Skip dunder methods and test functions + if func_name.starts_with("__") || func_name.starts_with("test_") { + return; + } + + // Check parameters (func.args instead of func.parameters) + for arg in &func.args.args { + let arg_name = arg.def.arg.as_str(); + + // Skip 'self' and 'cls' + if arg_name == "self" || arg_name == "cls" { + continue; + } + + if arg.def.annotation.is_none() { + diagnostics.push( + Diagnostic::warning( + "ZLUP006", + format!("Parameter '{}' is missing type annotation", arg_name), + make_location(func.range, filename, source), + ) + .with_suggestion(format!("Add type annotation: {}: ", arg_name)) + .with_source_context(source), + ); + } + } + + // Check return type + if func.returns.is_none() && !is_trivial_body(&func.body) { + diagnostics.push( + Diagnostic::warning( + "ZLUP006", + format!("Function '{}' is missing return type annotation", func_name), + make_location(func.range, filename, source), + ) + .with_suggestion(format!("Add return type: def {}(...) -> :", func_name)) + .with_source_context(source), + ); + } +} + +fn check_async_function( + func: &rustpython_parser::ast::StmtAsyncFunctionDef, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + let func_name = func.name.as_str(); + + // Skip dunder methods and test functions + if func_name.starts_with("__") || func_name.starts_with("test_") { + return; + } + + // Check parameters (func.args instead of func.parameters) + for arg in &func.args.args { + let arg_name = arg.def.arg.as_str(); + + if arg_name == "self" || arg_name == "cls" { + continue; + } + + if arg.def.annotation.is_none() { + diagnostics.push( + Diagnostic::warning( + "ZLUP006", + format!("Parameter '{}' is missing type annotation", arg_name), + make_location(func.range, filename, source), + ) + .with_suggestion(format!("Add type annotation: {}: ", arg_name)) + .with_source_context(source), + ); + } + } + + // Check return type + if func.returns.is_none() && !is_trivial_body(&func.body) { + diagnostics.push( + Diagnostic::warning( + "ZLUP006", + format!( + "Async function '{}' is missing return type annotation", + func_name + ), + make_location(func.range, filename, source), + ) + .with_suggestion(format!( + "Add return type: async def {}(...) -> :", + func_name + )) + .with_source_context(source), + ); + } +} + +fn is_trivial_body(body: &[Stmt]) -> bool { + if body.is_empty() { + return true; + } + if body.len() == 1 { + match &body[0] { + Stmt::Pass(_) => return true, + Stmt::Expr(expr_stmt) => { + // Check for docstring or ellipsis + if let rustpython_parser::ast::Expr::Constant(c) = expr_stmt.value.as_ref() { + match &c.value { + rustpython_parser::ast::Constant::Str(_) => return true, + rustpython_parser::ast::Constant::Ellipsis => return true, + _ => {} + } + } + } + _ => {} + } + } + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP006MissingTypes.check(&parsed, "", source) + } + + #[test] + fn test_missing_param_type() { + let diagnostics = check_source( + r#" +def foo(x): + return x + 1 +"#, + ); + assert!(!diagnostics.is_empty()); + assert!(diagnostics.iter().any(|d| d.message.contains("Parameter 'x'"))); + } + + #[test] + fn test_missing_return_type() { + let diagnostics = check_source( + r#" +def foo(x: int): + return x + 1 +"#, + ); + assert!(!diagnostics.is_empty()); + assert!(diagnostics.iter().any(|d| d.message.contains("return type"))); + } + + #[test] + fn test_fully_typed() { + let diagnostics = check_source( + r#" +def foo(x: int) -> int: + return x + 1 +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_stub_function() { + let diagnostics = check_source( + r#" +def foo(x): + pass +"#, + ); + // Still warns about parameter but not return (trivial body) + assert!(diagnostics.iter().any(|d| d.message.contains("Parameter"))); + assert!(!diagnostics.iter().any(|d| d.message.contains("return type"))); + } + + #[test] + fn test_skip_self() { + let diagnostics = check_source( + r#" +class Foo: + def bar(self, x: int) -> int: + return x +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup007.rs b/exp/guppy-zlup/src/linter/rules/zlup007.rs new file mode 100644 index 000000000..a7ce0c378 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup007.rs @@ -0,0 +1,360 @@ +//! ZLUP007: Detect overly complex control flow. + +use rustpython_parser::ast::{self, Expr, Mod, Stmt}; + +use super::{make_location, LintRule}; +use super::super::diagnostic::{Diagnostic, Severity}; + +/// Detects functions with overly complex control flow. +pub struct ZLUP007ComplexControlFlow { + max_complexity: u32, +} + +impl ZLUP007ComplexControlFlow { + pub fn new(max_complexity: u32) -> Self { + Self { max_complexity } + } +} + +impl LintRule for ZLUP007ComplexControlFlow { + fn id(&self) -> &'static str { + "ZLUP007" + } + + fn name(&self) -> &'static str { + "complex-control-flow" + } + + fn description(&self) -> &'static str { + "Functions should have a cyclomatic complexity below the threshold. \ + High complexity makes code harder to analyze and test." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, self.max_complexity, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + max_complexity: u32, + diagnostics: &mut Vec, +) { + match stmt { + Stmt::FunctionDef(func) => { + let complexity = calculate_complexity(&func.body); + if complexity > max_complexity { + diagnostics.push( + Diagnostic::warning( + "ZLUP007", + format!( + "Function '{}' has cyclomatic complexity {} (max: {})", + func.name, complexity, max_complexity + ), + make_location(func.range, filename, source), + ) + .with_suggestion( + "Break the function into smaller functions or simplify the control flow", + ) + .with_source_context(source), + ); + } + // Check nested functions + for s in &func.body { + check_stmt(s, filename, source, max_complexity, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + let complexity = calculate_complexity(&func.body); + if complexity > max_complexity { + diagnostics.push( + Diagnostic::warning( + "ZLUP007", + format!( + "Async function '{}' has cyclomatic complexity {} (max: {})", + func.name, complexity, max_complexity + ), + make_location(func.range, filename, source), + ) + .with_suggestion( + "Break the function into smaller functions or simplify the control flow", + ) + .with_source_context(source), + ); + } + for s in &func.body { + check_stmt(s, filename, source, max_complexity, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, max_complexity, diagnostics); + } + } + _ => {} + } +} + +/// Calculate cyclomatic complexity of a function body. +/// +/// Complexity = 1 + number of decision points +/// +/// Decision points: +/// - if/elif +/// - for/while loops +/// - and/or in boolean expressions +/// - except handlers +/// - assert statements +/// - ternary expressions (if expressions) +/// - comprehensions with if clauses +/// - match cases +fn calculate_complexity(body: &[Stmt]) -> u32 { + let mut decision_points = 0; + + for stmt in body { + decision_points += count_decision_points_stmt(stmt); + } + + 1 + decision_points +} + +fn count_decision_points_stmt(stmt: &Stmt) -> u32 { + let mut count = 0; + + match stmt { + Stmt::If(if_stmt) => { + count += 1; // The if itself + count += count_decision_points_expr(&if_stmt.test); + for s in &if_stmt.body { + count += count_decision_points_stmt(s); + } + for s in &if_stmt.orelse { + count += count_decision_points_stmt(s); + } + } + Stmt::For(for_stmt) => { + count += 1; // The for loop + for s in &for_stmt.body { + count += count_decision_points_stmt(s); + } + for s in &for_stmt.orelse { + count += count_decision_points_stmt(s); + } + } + Stmt::While(while_stmt) => { + count += 1; // The while loop + count += count_decision_points_expr(&while_stmt.test); + for s in &while_stmt.body { + count += count_decision_points_stmt(s); + } + for s in &while_stmt.orelse { + count += count_decision_points_stmt(s); + } + } + Stmt::Try(try_stmt) => { + count += try_stmt.handlers.len() as u32; // Each handler is a decision point + for s in &try_stmt.body { + count += count_decision_points_stmt(s); + } + for handler in &try_stmt.handlers { + let ast::ExceptHandler::ExceptHandler(h) = handler; + for s in &h.body { + count += count_decision_points_stmt(s); + } + } + for s in &try_stmt.orelse { + count += count_decision_points_stmt(s); + } + for s in &try_stmt.finalbody { + count += count_decision_points_stmt(s); + } + } + Stmt::Assert(_) => { + count += 1; // Assert is a decision point + } + Stmt::Match(match_stmt) => { + count += match_stmt.cases.len() as u32; // Each case is a decision point + for case in &match_stmt.cases { + for s in &case.body { + count += count_decision_points_stmt(s); + } + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + count += count_decision_points_stmt(s); + } + } + Stmt::Expr(expr_stmt) => { + count += count_decision_points_expr(&expr_stmt.value); + } + Stmt::Assign(assign) => { + count += count_decision_points_expr(&assign.value); + } + Stmt::AugAssign(aug) => { + count += count_decision_points_expr(&aug.value); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + count += count_decision_points_expr(value); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + count += count_decision_points_expr(value); + } + } + _ => {} + } + + count +} + +fn count_decision_points_expr(expr: &Expr) -> u32 { + let mut count = 0; + + match expr { + Expr::BoolOp(boolop) => { + // Each 'and' or 'or' adds (len(values) - 1) decision points + count += (boolop.values.len() - 1) as u32; + for value in &boolop.values { + count += count_decision_points_expr(value); + } + } + Expr::IfExp(_) => { + count += 1; // Ternary expression + } + Expr::ListComp(comp) => { + for generator in &comp.generators { + count += generator.ifs.len() as u32; + } + } + Expr::SetComp(comp) => { + for generator in &comp.generators { + count += generator.ifs.len() as u32; + } + } + Expr::DictComp(comp) => { + for generator in &comp.generators { + count += generator.ifs.len() as u32; + } + } + Expr::GeneratorExp(gen_expr) => { + for generator in &gen_expr.generators { + count += generator.ifs.len() as u32; + } + } + Expr::Call(call) => { + for arg in &call.args { + count += count_decision_points_expr(arg); + } + } + Expr::BinOp(binop) => { + count += count_decision_points_expr(&binop.left); + count += count_decision_points_expr(&binop.right); + } + Expr::UnaryOp(unary) => { + count += count_decision_points_expr(&unary.operand); + } + Expr::Compare(cmp) => { + count += count_decision_points_expr(&cmp.left); + for comparator in &cmp.comparators { + count += count_decision_points_expr(comparator); + } + } + _ => {} + } + + count +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source_with_max(source: &str, max: u32) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP007ComplexControlFlow::new(max).check(&parsed, "", source) + } + + #[test] + fn test_simple_function() { + let diagnostics = check_source_with_max( + r#" +def foo(): + return 1 +"#, + 10, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_complex_function() { + let diagnostics = check_source_with_max( + r#" +def foo(x): + if x > 0: + if x > 10: + if x > 100: + return 3 + return 2 + return 1 + elif x < 0: + if x < -10: + return -2 + return -1 + else: + return 0 +"#, + 3, + ); + assert_eq!(diagnostics.len(), 1); + assert!(diagnostics[0].message.contains("cyclomatic complexity")); + } + + #[test] + fn test_boolean_operators() { + let diagnostics = check_source_with_max( + r#" +def foo(a, b, c, d): + if a and b and c and d: + return 1 + return 0 +"#, + 3, + ); + // 1 (base) + 1 (if) + 3 (and operators) = 5 > 3 + assert_eq!(diagnostics.len(), 1); + } + + #[test] + fn test_comprehension_with_if() { + let diagnostics = check_source_with_max( + r#" +def foo(items): + return [x for x in items if x > 0 if x < 100] +"#, + 2, + ); + // 1 (base) + 2 (if clauses in comprehension) = 3 > 2 + assert_eq!(diagnostics.len(), 1); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup008.rs b/exp/guppy-zlup/src/linter/rules/zlup008.rs new file mode 100644 index 000000000..dacb63f8c --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup008.rs @@ -0,0 +1,265 @@ +//! ZLUP008: Detect excessive call depth (deeply nested function calls). + +use rustpython_parser::ast::{Expr, Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{make_location, LintRule}; + +/// Maximum allowed call depth (function calls nested within other calls). +const DEFAULT_MAX_CALL_DEPTH: u32 = 4; + +/// Detects excessive call depth that makes code hard to verify. +pub struct ZLUP008CallDepth { + max_depth: u32, +} + +impl ZLUP008CallDepth { + pub fn new(max_depth: u32) -> Self { + Self { max_depth } + } +} + +impl Default for ZLUP008CallDepth { + fn default() -> Self { + Self::new(DEFAULT_MAX_CALL_DEPTH) + } +} + +impl LintRule for ZLUP008CallDepth { + fn id(&self) -> &'static str { + "ZLUP008" + } + + fn name(&self) -> &'static str { + "call-depth" + } + + fn description(&self) -> &'static str { + "Deeply nested function calls make code hard to verify and debug. \ + Keep call chains shallow for better analyzability." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, self.max_depth, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + max_depth: u32, + diagnostics: &mut Vec, +) { + match stmt { + Stmt::FunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + for s in &func.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::For(for_stmt) => { + check_expr(&for_stmt.iter, filename, source, max_depth, 0, diagnostics); + for s in &for_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &for_stmt.orelse { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::While(while_stmt) => { + check_expr(&while_stmt.test, filename, source, max_depth, 0, diagnostics); + for s in &while_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &while_stmt.orelse { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::If(if_stmt) => { + check_expr(&if_stmt.test, filename, source, max_depth, 0, diagnostics); + for s in &if_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &if_stmt.orelse { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::With(with_stmt) => { + for s in &with_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::Try(try_stmt) => { + for s in &try_stmt.body { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &try_stmt.orelse { + check_stmt(s, filename, source, max_depth, diagnostics); + } + for s in &try_stmt.finalbody { + check_stmt(s, filename, source, max_depth, diagnostics); + } + } + Stmt::Expr(expr_stmt) => { + check_expr(&expr_stmt.value, filename, source, max_depth, 0, diagnostics); + } + Stmt::Assign(assign) => { + check_expr(&assign.value, filename, source, max_depth, 0, diagnostics); + } + Stmt::AugAssign(aug) => { + check_expr(&aug.value, filename, source, max_depth, 0, diagnostics); + } + Stmt::AnnAssign(ann) => { + if let Some(value) = &ann.value { + check_expr(value, filename, source, max_depth, 0, diagnostics); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + check_expr(value, filename, source, max_depth, 0, diagnostics); + } + } + _ => {} + } +} + +fn check_expr( + expr: &Expr, + filename: &str, + source: &str, + max_depth: u32, + current_depth: u32, + diagnostics: &mut Vec, +) { + match expr { + Expr::Call(call) => { + let new_depth = current_depth + 1; + + if new_depth > max_depth { + diagnostics.push( + Diagnostic::warning( + "ZLUP008", + format!( + "Call depth {} exceeds maximum {} - consider breaking into intermediate variables", + new_depth, max_depth + ), + make_location(call.range, filename, source), + ) + .with_suggestion("Extract nested calls into named intermediate values") + .with_source_context(source), + ); + } + + // Check the function being called + check_expr(&call.func, filename, source, max_depth, new_depth, diagnostics); + + // Check arguments + for arg in &call.args { + check_expr(arg, filename, source, max_depth, new_depth, diagnostics); + } + } + + // Recurse into sub-expressions + Expr::BinOp(binop) => { + check_expr(&binop.left, filename, source, max_depth, current_depth, diagnostics); + check_expr(&binop.right, filename, source, max_depth, current_depth, diagnostics); + } + Expr::UnaryOp(unary) => { + check_expr(&unary.operand, filename, source, max_depth, current_depth, diagnostics); + } + Expr::Compare(cmp) => { + check_expr(&cmp.left, filename, source, max_depth, current_depth, diagnostics); + for comparator in &cmp.comparators { + check_expr(comparator, filename, source, max_depth, current_depth, diagnostics); + } + } + Expr::BoolOp(boolop) => { + for value in &boolop.values { + check_expr(value, filename, source, max_depth, current_depth, diagnostics); + } + } + Expr::IfExp(ifexp) => { + check_expr(&ifexp.test, filename, source, max_depth, current_depth, diagnostics); + check_expr(&ifexp.body, filename, source, max_depth, current_depth, diagnostics); + check_expr(&ifexp.orelse, filename, source, max_depth, current_depth, diagnostics); + } + Expr::List(list) => { + for elt in &list.elts { + check_expr(elt, filename, source, max_depth, current_depth, diagnostics); + } + } + Expr::Tuple(tuple) => { + for elt in &tuple.elts { + check_expr(elt, filename, source, max_depth, current_depth, diagnostics); + } + } + Expr::Subscript(sub) => { + check_expr(&sub.value, filename, source, max_depth, current_depth, diagnostics); + check_expr(&sub.slice, filename, source, max_depth, current_depth, diagnostics); + } + Expr::Attribute(attr) => { + check_expr(&attr.value, filename, source, max_depth, current_depth, diagnostics); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source_with_max(source: &str, max: u32) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP008CallDepth::new(max).check(&parsed, "", source) + } + + #[test] + fn test_simple_call() { + let diagnostics = check_source_with_max("result = foo()", 4); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_nested_calls() { + let diagnostics = check_source_with_max("result = foo(bar())", 4); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_deeply_nested_calls() { + let diagnostics = check_source_with_max("result = a(b(c(d(e()))))", 3); + assert!(!diagnostics.is_empty()); + assert!(diagnostics[0].message.contains("Call depth")); + } + + #[test] + fn test_call_in_arguments() { + let diagnostics = check_source_with_max("result = foo(bar(baz(qux())))", 2); + assert!(!diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup009.rs b/exp/guppy-zlup/src/linter/rules/zlup009.rs new file mode 100644 index 000000000..0b51f3ae7 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup009.rs @@ -0,0 +1,257 @@ +//! ZLUP009: Require assertions in non-trivial functions. + +use rustpython_parser::ast::{Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::LintRule; + +/// Minimum number of statements in a function body to require assertions. +const MIN_STATEMENTS_FOR_ASSERTION: usize = 5; + +/// Detects functions without assertions that should have them. +pub struct ZLUP009AssertionDensity; + +impl LintRule for ZLUP009AssertionDensity { + fn id(&self) -> &'static str { + "ZLUP009" + } + + fn name(&self) -> &'static str { + "assertion-density" + } + + fn description(&self) -> &'static str { + "Non-trivial functions should contain assertions to validate preconditions, \ + postconditions, and invariants. This helps catch errors early." + } + + fn severity(&self) -> Severity { + Severity::Info + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_stmt(stmt, filename, source, &mut diagnostics); + } + + diagnostics + } +} + +fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { + match stmt { + Stmt::FunctionDef(func) => { + check_function(&func.name, &func.body, func.range, filename, source, diagnostics); + // Check nested functions + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::AsyncFunctionDef(func) => { + check_function(&func.name, &func.body, func.range, filename, source, diagnostics); + for s in &func.body { + check_stmt(s, filename, source, diagnostics); + } + } + Stmt::ClassDef(class) => { + for s in &class.body { + check_stmt(s, filename, source, diagnostics); + } + } + _ => {} + } +} + +fn check_function( + name: &str, + body: &[Stmt], + range: rustpython_parser::text_size::TextRange, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + // Skip test functions, dunder methods, and trivial functions + if name.starts_with("test_") || name.starts_with("__") { + return; + } + + // Count statements (flattening simple control flow) + let stmt_count = count_statements(body); + + if stmt_count < MIN_STATEMENTS_FOR_ASSERTION { + return; + } + + // Check if function has any assertions + let has_assertion = has_assertion_in_body(body); + + if !has_assertion { + diagnostics.push( + Diagnostic::new( + "ZLUP009", + format!( + "Function '{}' has {} statements but no assertions", + name, stmt_count + ), + Severity::Info, + super::make_location(range, filename, source), + ) + .with_suggestion( + "Add assert statements to validate preconditions, postconditions, or invariants", + ) + .with_source_context(source), + ); + } +} + +fn count_statements(body: &[Stmt]) -> usize { + let mut count = 0; + + for stmt in body { + count += 1; + + // Count statements in nested blocks + match stmt { + Stmt::If(if_stmt) => { + count += count_statements(&if_stmt.body); + count += count_statements(&if_stmt.orelse); + } + Stmt::For(for_stmt) => { + count += count_statements(&for_stmt.body); + count += count_statements(&for_stmt.orelse); + } + Stmt::While(while_stmt) => { + count += count_statements(&while_stmt.body); + count += count_statements(&while_stmt.orelse); + } + Stmt::With(with_stmt) => { + count += count_statements(&with_stmt.body); + } + Stmt::Try(try_stmt) => { + count += count_statements(&try_stmt.body); + count += count_statements(&try_stmt.orelse); + count += count_statements(&try_stmt.finalbody); + } + _ => {} + } + } + + count +} + +fn has_assertion_in_body(body: &[Stmt]) -> bool { + for stmt in body { + if matches!(stmt, Stmt::Assert(_)) { + return true; + } + + // Check nested blocks + let has_nested = match stmt { + Stmt::If(if_stmt) => { + has_assertion_in_body(&if_stmt.body) || has_assertion_in_body(&if_stmt.orelse) + } + Stmt::For(for_stmt) => { + has_assertion_in_body(&for_stmt.body) || has_assertion_in_body(&for_stmt.orelse) + } + Stmt::While(while_stmt) => { + has_assertion_in_body(&while_stmt.body) || has_assertion_in_body(&while_stmt.orelse) + } + Stmt::With(with_stmt) => has_assertion_in_body(&with_stmt.body), + Stmt::Try(try_stmt) => { + has_assertion_in_body(&try_stmt.body) + || has_assertion_in_body(&try_stmt.orelse) + || has_assertion_in_body(&try_stmt.finalbody) + } + Stmt::FunctionDef(_) | Stmt::AsyncFunctionDef(_) => { + // Don't look into nested function definitions + false + } + _ => false, + }; + + if has_nested { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP009AssertionDensity.check(&parsed, "", source) + } + + #[test] + fn test_small_function() { + let diagnostics = check_source( + r#" +def foo(): + x = 1 + return x +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_large_function_without_assertion() { + let diagnostics = check_source( + r#" +def process(data): + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b +"#, + ); + assert!(!diagnostics.is_empty()); + assert!(diagnostics[0].message.contains("no assertions")); + } + + #[test] + fn test_large_function_with_assertion() { + let diagnostics = check_source( + r#" +def process(data): + assert data is not None + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b +"#, + ); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_skip_test_functions() { + let diagnostics = check_source( + r#" +def test_something(): + x = 1 + y = 2 + z = 3 + a = x + y + b = y + z + return a + b +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/linter/rules/zlup010.rs b/exp/guppy-zlup/src/linter/rules/zlup010.rs new file mode 100644 index 000000000..32030b5f0 --- /dev/null +++ b/exp/guppy-zlup/src/linter/rules/zlup010.rs @@ -0,0 +1,238 @@ +//! ZLUP010: Restrict mutable global state. + +use rustpython_parser::ast::{Mod, Stmt}; + +use super::super::diagnostic::{Diagnostic, Severity}; +use super::{make_location, LintRule}; + +/// Names that are allowed as module-level constants (typically UPPER_CASE). +fn is_constant_name(name: &str) -> bool { + // Allow UPPER_CASE names as constants + name.chars().all(|c| c.is_uppercase() || c == '_' || c.is_numeric()) + && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') +} + +/// Known safe module-level definitions. +fn is_safe_assignment(name: &str) -> bool { + // Type aliases and protocol definitions + name.ends_with("Type") + || name.ends_with("Protocol") + || name.ends_with("T") // Generic type vars + || name == "__all__" + || name == "__version__" + || name == "__author__" +} + +/// Detects mutable global state that can cause issues in concurrent/quantum code. +pub struct ZLUP010GlobalState; + +impl LintRule for ZLUP010GlobalState { + fn id(&self) -> &'static str { + "ZLUP010" + } + + fn name(&self) -> &'static str { + "global-state" + } + + fn description(&self) -> &'static str { + "Mutable global state is prohibited. Use constants (UPPER_CASE names) \ + or pass state explicitly through function parameters." + } + + fn severity(&self) -> Severity { + Severity::Warning + } + + fn check(&self, parsed: &Mod, filename: &str, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + let Mod::Module(module) = parsed else { + return diagnostics; + }; + + for stmt in &module.body { + check_module_level_stmt(stmt, filename, source, &mut diagnostics); + } + + diagnostics + } +} + +fn check_module_level_stmt( + stmt: &Stmt, + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + match stmt { + // Check for module-level variable assignments + Stmt::Assign(assign) => { + for target in &assign.targets { + if let rustpython_parser::ast::Expr::Name(name) = target { + let var_name = name.id.as_str(); + + // Allow constants and safe assignments + if !is_constant_name(var_name) && !is_safe_assignment(var_name) { + diagnostics.push( + Diagnostic::warning( + "ZLUP010", + format!( + "Module-level variable '{}' creates mutable global state", + var_name + ), + make_location(assign.range, filename, source), + ) + .with_suggestion(format!( + "Use UPPER_CASE for constants (e.g., {}) or pass as function parameter", + var_name.to_uppercase() + )) + .with_source_context(source), + ); + } + } + } + } + + // Check for annotated assignments at module level + Stmt::AnnAssign(ann) => { + if let rustpython_parser::ast::Expr::Name(name) = ann.target.as_ref() { + let var_name = name.id.as_str(); + + if !is_constant_name(var_name) && !is_safe_assignment(var_name) { + diagnostics.push( + Diagnostic::warning( + "ZLUP010", + format!( + "Module-level variable '{}' creates mutable global state", + var_name + ), + make_location(ann.range, filename, source), + ) + .with_suggestion(format!( + "Use UPPER_CASE for constants (e.g., {}) or pass as function parameter", + var_name.to_uppercase() + )) + .with_source_context(source), + ); + } + } + } + + // Check for use of 'global' keyword in functions + Stmt::FunctionDef(func) => { + check_global_in_function(&func.body, filename, source, diagnostics); + } + Stmt::AsyncFunctionDef(func) => { + check_global_in_function(&func.body, filename, source, diagnostics); + } + + // Skip class definitions, imports, etc. + _ => {} + } +} + +fn check_global_in_function( + body: &[Stmt], + filename: &str, + source: &str, + diagnostics: &mut Vec, +) { + for stmt in body { + match stmt { + Stmt::Global(global_stmt) => { + for name in &global_stmt.names { + diagnostics.push( + Diagnostic::warning( + "ZLUP010", + format!("Use of 'global {}' creates hidden state dependencies", name), + make_location(global_stmt.range, filename, source), + ) + .with_suggestion("Pass the value as a function parameter instead") + .with_source_context(source), + ); + } + } + Stmt::If(if_stmt) => { + check_global_in_function(&if_stmt.body, filename, source, diagnostics); + check_global_in_function(&if_stmt.orelse, filename, source, diagnostics); + } + Stmt::For(for_stmt) => { + check_global_in_function(&for_stmt.body, filename, source, diagnostics); + } + Stmt::While(while_stmt) => { + check_global_in_function(&while_stmt.body, filename, source, diagnostics); + } + Stmt::With(with_stmt) => { + check_global_in_function(&with_stmt.body, filename, source, diagnostics); + } + Stmt::Try(try_stmt) => { + check_global_in_function(&try_stmt.body, filename, source, diagnostics); + check_global_in_function(&try_stmt.orelse, filename, source, diagnostics); + check_global_in_function(&try_stmt.finalbody, filename, source, diagnostics); + } + Stmt::FunctionDef(func) => { + // Check nested functions + check_global_in_function(&func.body, filename, source, diagnostics); + } + _ => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rustpython_parser::{parse, Mode}; + + fn check_source(source: &str) -> Vec { + let parsed = parse(source, Mode::Module, "").unwrap(); + ZLUP010GlobalState.check(&parsed, "", source) + } + + #[test] + fn test_constant_allowed() { + let diagnostics = check_source("MAX_SIZE = 100"); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_lowercase_variable_flagged() { + let diagnostics = check_source("counter = 0"); + assert!(!diagnostics.is_empty()); + assert!(diagnostics[0].message.contains("mutable global state")); + } + + #[test] + fn test_global_keyword_flagged() { + let diagnostics = check_source( + r#" +counter = 0 + +def increment(): + global counter + counter += 1 +"#, + ); + // Should flag both the module-level variable and the global statement + assert!(diagnostics.len() >= 2); + } + + #[test] + fn test_dunder_allowed() { + let diagnostics = check_source("__all__ = ['foo', 'bar']"); + assert!(diagnostics.is_empty()); + } + + #[test] + fn test_function_def_ok() { + let diagnostics = check_source( + r#" +def foo(): + x = 1 + return x +"#, + ); + assert!(diagnostics.is_empty()); + } +} diff --git a/exp/guppy-zlup/src/main.rs b/exp/guppy-zlup/src/main.rs new file mode 100644 index 000000000..15cfb0a11 --- /dev/null +++ b/exp/guppy-zlup/src/main.rs @@ -0,0 +1,882 @@ +//! CLI for guppy-zlup - Guppy linter and Zlup compiler + +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::mpsc::channel; +use std::time::Duration; + +use clap::{Parser, Subcommand, ValueEnum}; +use guppy_zlup::{compile_file, ir, lint_source, CompileError, Config, LintResult, Linter, OutputFormat, Severity}; +use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode}; +use rayon::prelude::*; + +#[derive(Parser)] +#[command(name = "guppy-zlup")] +#[command(author, version, about = "Guppy linter and Zlup compiler")] +#[command(long_about = "Validate Guppy quantum programs against NASA Power of 10 rules and compile to Zlup")] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +/// Output format for lint results. +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +enum CliOutputFormat { + /// Human-readable text output + #[default] + Text, + /// JSON output for machine parsing + Json, + /// SARIF format for GitHub Actions integration + Sarif, +} + +impl From for OutputFormat { + fn from(f: CliOutputFormat) -> Self { + match f { + CliOutputFormat::Text => OutputFormat::Text, + CliOutputFormat::Json => OutputFormat::Json, + CliOutputFormat::Sarif => OutputFormat::Sarif, + } + } +} + +#[derive(Subcommand)] +enum Commands { + /// Validate Guppy files using guppylang (requires Python with guppylang installed) + Validate { + /// Files to validate + #[arg(required = true)] + files: Vec, + + /// Output as JSON + #[arg(long)] + json: bool, + }, + + /// Check files for lint violations + Check { + /// Files or directories to check + #[arg(required = true)] + files: Vec, + + /// Treat warnings as errors + #[arg(short = 'W', long)] + warnings_as_errors: bool, + + /// Path to config file (pyproject.toml) + #[arg(long)] + config: Option, + + /// Disable specific rules (can be used multiple times) + #[arg(long = "disable", short = 'D')] + disabled_rules: Vec, + + /// Maximum complexity for ZLUP007 + #[arg(long)] + max_complexity: Option, + + /// Output format + #[arg(long, short = 'f', value_enum, default_value = "text")] + format: CliOutputFormat, + + /// Watch for file changes and re-lint + #[arg(long, short = 'w')] + watch: bool, + }, + + /// Emit validated IR as JSON + Emit { + /// File to emit + file: PathBuf, + + /// Output file (default: ir.json) + #[arg(short, long, default_value = "ir.json")] + output: PathBuf, + + /// Skip lint check + #[arg(long)] + skip_lint: bool, + + /// Print to stdout instead of writing to file + #[arg(long)] + stdout: bool, + }, + + /// Compile Guppy source or IR to Zlup + Compile { + /// Input file (.py for source, .json for IR) + input: PathBuf, + + /// Output file (default: .zlp) + #[arg(short, long)] + output: Option, + + /// Print to stdout instead of writing to file + #[arg(long)] + stdout: bool, + + /// Input is IR JSON (skip linting) + #[arg(long)] + ir: bool, + + /// Validate with guppylang before compiling (requires Python) + #[arg(long)] + validate: bool, + + /// Run parallelism analysis on generated Zlup + #[arg(long)] + analyze: bool, + }, + + /// Analyze Guppy source for parallelism opportunities (compiles to Zlup first) + Analyze { + /// Input file (.py for source, .json for IR) + input: PathBuf, + + /// Input is IR JSON (skip linting) + #[arg(long)] + ir: bool, + + /// Output format (text or json) + #[arg(short, long, value_enum, default_value = "text")] + format: AnalyzeFormat, + + /// Show detailed dependency graph + #[arg(long, short)] + verbose: bool, + }, +} + +/// Output format for analysis results. +#[derive(Debug, Clone, Copy, Default, ValueEnum)] +enum AnalyzeFormat { + /// Human-readable text output + #[default] + Text, + /// JSON output for tooling integration + Json, +} + +fn main() -> ExitCode { + let cli = Cli::parse(); + + match cli.command { + Commands::Validate { files, json } => cmd_validate(&files, json), + + Commands::Check { + files, + warnings_as_errors, + config, + disabled_rules, + max_complexity, + format, + watch, + } => { + if watch { + cmd_watch(&files, warnings_as_errors, config.as_deref(), &disabled_rules, max_complexity, format.into()) + } else { + cmd_check(&files, warnings_as_errors, config.as_deref(), &disabled_rules, max_complexity, format.into()) + } + } + + Commands::Emit { + file, + output, + skip_lint, + stdout, + } => cmd_emit(&file, &output, skip_lint, stdout), + + Commands::Compile { + input, + output, + stdout, + ir, + validate, + analyze, + } => { + if ir { + cmd_compile_ir(&input, output.as_deref(), stdout, analyze) + } else { + cmd_compile_source(&input, output.as_deref(), stdout, validate, analyze) + } + } + + Commands::Analyze { + input, + ir, + format, + verbose, + } => cmd_analyze(&input, ir, format, verbose), + } +} + +/// Collect all Python files from paths (files or directories). +fn collect_python_files(paths: &[PathBuf]) -> Vec { + let mut files = Vec::new(); + + for path in paths { + if path.is_file() { + files.push(path.clone()); + } else if path.is_dir() { + // Recursively find all .py files + if let Ok(entries) = walkdir(path) { + files.extend(entries); + } + } + } + + files +} + +/// Walk directory recursively to find Python files. +fn walkdir(dir: &Path) -> std::io::Result> { + let mut files = Vec::new(); + + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + + if path.is_dir() { + // Skip hidden directories and common non-source dirs + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if !name.starts_with('.') && name != "__pycache__" && name != "node_modules" && name != "venv" { + files.extend(walkdir(&path)?); + } + } else if path.extension().is_some_and(|ext| ext == "py") { + files.push(path); + } + } + + Ok(files) +} + +fn cmd_check( + paths: &[PathBuf], + warnings_as_errors: bool, + config_path: Option<&Path>, + disabled_rules: &[String], + max_complexity: Option, + format: OutputFormat, +) -> ExitCode { + // Collect all Python files + let files = collect_python_files(paths); + + if files.is_empty() { + eprintln!("No Python files found."); + return ExitCode::from(1); + } + + // Load base config + let mut base_config = if let Some(path) = config_path { + match Config::from_pyproject(path) { + Ok(c) => c, + Err(e) => { + eprintln!("Error loading config from {}: {}", path.display(), e); + return ExitCode::from(1); + } + } + } else if let Some(first_file) = files.first() { + if let Some(pyproject) = Config::find_pyproject(first_file) { + match Config::from_pyproject(&pyproject) { + Ok(c) => c, + Err(e) => { + eprintln!("Warning: Error loading {}: {}", pyproject.display(), e); + Config::default() + } + } + } else { + Config::default() + } + } else { + Config::default() + }; + + // Apply CLI overrides + if warnings_as_errors { + base_config.treat_warnings_as_errors = true; + } + + for rule in disabled_rules { + base_config.disable_rule(rule); + } + + if let Some(complexity) = max_complexity { + base_config.max_complexity = complexity; + } + + // Lint all files in parallel + let results: Vec<(PathBuf, Result)> = files + .par_iter() + .map(|file| { + let result = std::fs::read_to_string(file).map(|source| { + let linter = Linter::new(base_config.clone()); + linter.lint_source(&source, file.to_str().unwrap_or("")) + }); + (file.clone(), result) + }) + .collect(); + + // Merge all results + let mut combined = LintResult::default(); + let mut io_errors = Vec::new(); + + for (file, result) in results { + match result { + Ok(lint_result) => combined.merge(lint_result), + Err(e) => io_errors.push((file, e)), + } + } + + // Report IO errors + for (file, e) in &io_errors { + eprintln!("Error reading {}: {}", file.display(), e); + } + + // Output results in the requested format + let output = combined.format(format); + println!("{}", output); + + // Summary for text format + if format == OutputFormat::Text && !combined.diagnostics.is_empty() { + let error_count = combined + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error)) + .count(); + let warning_count = combined + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + println!( + "\nFound {} error(s) and {} warning(s) in {} file(s).", + error_count, warning_count, files.len() + ); + } + + if !io_errors.is_empty() || !combined.is_ok(base_config.treat_warnings_as_errors) { + return ExitCode::from(1); + } + + if format == OutputFormat::Text { + println!("All checks passed!"); + } + ExitCode::SUCCESS +} + +fn cmd_watch( + paths: &[PathBuf], + warnings_as_errors: bool, + config_path: Option<&Path>, + disabled_rules: &[String], + max_complexity: Option, + format: OutputFormat, +) -> ExitCode { + println!("Watching for changes... (press Ctrl+C to stop)"); + + // Run initial check + let _ = cmd_check(paths, warnings_as_errors, config_path, disabled_rules, max_complexity, format); + println!("\n---\n"); + + // Set up file watcher + let (tx, rx) = channel(); + + let mut debouncer = match new_debouncer(Duration::from_millis(500), tx) { + Ok(d) => d, + Err(e) => { + eprintln!("Error creating file watcher: {}", e); + return ExitCode::from(1); + } + }; + + // Watch all paths + for path in paths { + let watch_path = if path.is_file() { + path.parent().unwrap_or(path) + } else { + path.as_path() + }; + + if let Err(e) = debouncer.watcher().watch(watch_path, RecursiveMode::Recursive) { + eprintln!("Error watching {}: {}", watch_path.display(), e); + } + } + + // Convert config_path to owned PathBuf for the loop + let config_path_owned = config_path.map(|p| p.to_path_buf()); + let disabled_rules_owned: Vec = disabled_rules.to_vec(); + + // Watch loop + loop { + match rx.recv() { + Ok(Ok(events)) => { + // Check if any Python files changed + let has_py_changes = events.iter().any(|e| { + e.path.extension().is_some_and(|ext| ext == "py") + }); + + if has_py_changes { + // Clear screen (optional, works on most terminals) + print!("\x1B[2J\x1B[1;1H"); + println!("File changed, re-checking...\n"); + + let _ = cmd_check( + paths, + warnings_as_errors, + config_path_owned.as_deref(), + &disabled_rules_owned, + max_complexity, + format, + ); + println!("\n---\nWatching for changes... (press Ctrl+C to stop)"); + } + } + Ok(Err(errors)) => { + eprintln!("Watch error: {:?}", errors); + } + Err(e) => { + eprintln!("Channel error: {}", e); + return ExitCode::from(1); + } + } + } +} + +fn cmd_emit(file: &PathBuf, output: &PathBuf, skip_lint: bool, to_stdout: bool) -> ExitCode { + let source = match std::fs::read_to_string(file) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file: {}", e); + return ExitCode::from(1); + } + }; + + if !skip_lint { + let config = Config::default(); + let linter = Linter::new(config); + let result = linter.lint_source(&source, file.to_str().unwrap_or("")); + + if result.has_errors { + println!("{}", result); + println!("\nCannot emit IR due to lint errors."); + return ExitCode::from(1); + } + + if result.has_warnings { + println!("{}", result); + println!(); + } + } + + // Emit IR + match ir::emit_ir(&source, file.to_str()) { + Ok(ir_data) => { + let json = serde_json::to_string_pretty(&ir_data).unwrap(); + + if to_stdout { + println!("{}", json); + return ExitCode::SUCCESS; + } + + if let Err(e) = std::fs::write(output, json) { + eprintln!("Error writing IR: {}", e); + return ExitCode::from(1); + } + println!("Wrote IR to {}", output.display()); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("Error emitting IR: {}", e); + ExitCode::from(1) + } + } +} + +fn cmd_compile_ir(input: &Path, output: Option<&Path>, to_stdout: bool, analyze: bool) -> ExitCode { + let result = match compile_file(input.to_str().unwrap_or("")) { + Ok(zlup) => zlup, + Err(e) => { + eprintln!("Error: {}", format_error(&e)); + return ExitCode::from(1); + } + }; + + if to_stdout { + println!("{}", result); + if analyze { + eprintln!(); + run_analysis(&result, AnalyzeFormat::Text, false); + } + return ExitCode::SUCCESS; + } + + let output_path = output + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| input.with_extension("zlp")); + + if let Err(e) = std::fs::write(&output_path, &result) { + eprintln!("Error writing output: {}", e); + return ExitCode::from(1); + } + + println!("Compiled {} -> {}", input.display(), output_path.display()); + + if analyze { + println!(); + run_analysis(&result, AnalyzeFormat::Text, false); + } + + ExitCode::SUCCESS +} + +fn cmd_compile_source(input: &PathBuf, output: Option<&Path>, to_stdout: bool, validate: bool, analyze: bool) -> ExitCode { + // Run guppylang validation if requested + if validate { + let exit = cmd_validate(std::slice::from_ref(input), false); + if exit != ExitCode::SUCCESS { + eprintln!("\nCannot compile due to validation errors."); + return exit; + } + } + + let source = match std::fs::read_to_string(input) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file: {}", e); + return ExitCode::from(1); + } + }; + + // Run linter + let result = lint_source(&source, input.to_str()); + if result.has_errors { + println!("{}", result); + let error_count = result + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error)) + .count(); + println!("\nCannot compile due to {} lint error(s).", error_count); + return ExitCode::from(1); + } + + if result.has_warnings { + println!("{}", result); + println!(); + } + + // Emit IR + let ir_data = match ir::emit_ir(&source, input.to_str()) { + Ok(ir) => ir, + Err(e) => { + eprintln!("Error emitting IR: {}", e); + return ExitCode::from(1); + } + }; + + // Compile IR to Zlup + let ir_json = serde_json::to_string(&ir_data).unwrap(); + let zlup_source = match guppy_zlup::compile(&ir_json) { + Ok(src) => src, + Err(e) => { + eprintln!("Error compiling to Zlup: {}", e); + return ExitCode::from(1); + } + }; + + if to_stdout { + println!("{}", zlup_source); + if analyze { + eprintln!(); + run_analysis(&zlup_source, AnalyzeFormat::Text, false); + } + return ExitCode::SUCCESS; + } + + let output_path = output + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| input.with_extension("zlp")); + + if let Err(e) = std::fs::write(&output_path, &zlup_source) { + eprintln!("Error writing output: {}", e); + return ExitCode::from(1); + } + + println!("Compiled {} -> {}", input.display(), output_path.display()); + + if analyze { + println!(); + run_analysis(&zlup_source, AnalyzeFormat::Text, false); + } + + ExitCode::SUCCESS +} + +fn format_error(e: &CompileError) -> String { + match e { + CompileError::Io(io) => format!("IO error: {}", io), + CompileError::Parse(parse) => format!("Parse error: {}", parse), + CompileError::Transform(transform) => format!("Transform error: {}", transform), + } +} + +fn cmd_analyze(input: &PathBuf, is_ir: bool, format: AnalyzeFormat, verbose: bool) -> ExitCode { + // Get Zlup source - either compile from Guppy/IR or read directly + let zlup_source = if is_ir { + match compile_file(input.to_str().unwrap_or("")) { + Ok(zlup) => zlup, + Err(e) => { + eprintln!("Error compiling IR: {}", format_error(&e)); + return ExitCode::from(1); + } + } + } else { + let source = match std::fs::read_to_string(input) { + Ok(s) => s, + Err(e) => { + eprintln!("Error reading file: {}", e); + return ExitCode::from(1); + } + }; + + // Run linter first + let result = lint_source(&source, input.to_str()); + if result.has_errors { + println!("{}", result); + let error_count = result + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error)) + .count(); + println!("\nCannot analyze due to {} lint error(s).", error_count); + return ExitCode::from(1); + } + + // Emit IR and compile to Zlup + let ir_data = match ir::emit_ir(&source, input.to_str()) { + Ok(ir) => ir, + Err(e) => { + eprintln!("Error emitting IR: {}", e); + return ExitCode::from(1); + } + }; + + let ir_json = serde_json::to_string(&ir_data).unwrap(); + match guppy_zlup::compile(&ir_json) { + Ok(src) => src, + Err(e) => { + eprintln!("Error compiling to Zlup: {}", e); + return ExitCode::from(1); + } + } + }; + + run_analysis(&zlup_source, format, verbose); + ExitCode::SUCCESS +} + +fn run_analysis(zlup_source: &str, format: AnalyzeFormat, verbose: bool) { + use zlup::analysis::{analyze_parallelism, AllocatorAnalysis, DependencyGraph, OperationTagger}; + + // Parse the Zlup source + let program = match zlup::parse(zlup_source) { + Ok(p) => p, + Err(e) => { + eprintln!("Error parsing generated Zlup: {}", e); + return; + } + }; + + // Run analysis passes + let allocator_analysis = AllocatorAnalysis::analyze(&program); + let tagger = OperationTagger::tag(&program); + let dep_graph = DependencyGraph::build(tagger.operations); + let summaries = analyze_parallelism(&program); + + match format { + AnalyzeFormat::Text => { + println!("=== Parallelism Analysis ===\n"); + + // Allocator summary + println!("Allocators:"); + if allocator_analysis.allocators.is_empty() { + println!(" (none)"); + } else { + for (name, info) in &allocator_analysis.allocators { + let size_str = info.size.map(|s| format!("[{}]", s)).unwrap_or_else(|| "[?]".to_string()); + println!(" {} {}qubit (scope depth: {}, line: {})", + name, size_str, info.scope_depth, info.defined_at_line); + } + } + println!(); + + // Function summaries + println!("Function Analysis:"); + for summary in &summaries { + println!(" {}:", summary.function_name); + println!(" Total operations: {}", summary.total_ops); + println!(" Quantum operations: {}", summary.quantum_ops); + println!(" Classical operations: {}", summary.classical_ops); + println!(" Parallel layers: {}", summary.num_layers); + println!(" Max parallelism: {} ops/layer", summary.max_parallelism); + println!(); + } + + // Detailed output if verbose + if verbose { + println!("=== Dependency Graph ===\n"); + dep_graph.debug_print(); + } + } + AnalyzeFormat::Json => { + use serde_json::json; + + let allocators: Vec<_> = allocator_analysis.allocators.iter().map(|(name, info)| { + json!({ + "name": name, + "size": info.size, + "scope_depth": info.scope_depth, + "defined_at_line": info.defined_at_line, + }) + }).collect(); + + let functions: Vec<_> = summaries.iter().map(|s| { + json!({ + "name": s.function_name, + "total_ops": s.total_ops, + "quantum_ops": s.quantum_ops, + "classical_ops": s.classical_ops, + "num_layers": s.num_layers, + "max_parallelism": s.max_parallelism, + }) + }).collect(); + + let layers = dep_graph.parallel_layers(); + let layer_details: Vec<_> = layers.iter().enumerate().map(|(i, ops)| { + let op_details: Vec<_> = ops.iter().map(|&id| { + let op = &dep_graph.operations[id]; + json!({ + "id": op.id, + "description": op.description, + "line": op.line, + "is_quantum": op.touches_qubits(), + }) + }).collect(); + json!({ + "layer": i, + "operations": op_details, + }) + }).collect(); + + let output = json!({ + "allocators": allocators, + "functions": functions, + "parallel_layers": layer_details, + "total_operations": dep_graph.operations.len(), + "total_dependencies": dep_graph.edges.len(), + }); + + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } + } +} + +fn cmd_validate(files: &[PathBuf], json_output: bool) -> ExitCode { + // Find the validation script relative to the executable or in known locations + let script_locations = [ + // Relative to executable + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|p| p.join("scripts/validate_guppy.py"))), + // In the source tree + Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("scripts/validate_guppy.py")), + ]; + + let script_path = script_locations + .iter() + .filter_map(|p| p.as_ref()) + .find(|p| p.exists()); + + let script_path = match script_path { + Some(p) => p.clone(), + None => { + eprintln!("Error: Could not find validate_guppy.py script"); + eprintln!("Make sure guppy-zlup is properly installed"); + return ExitCode::from(1); + } + }; + + let python_files = collect_python_files(files); + if python_files.is_empty() { + eprintln!("No Python files found."); + return ExitCode::from(1); + } + + let mut all_valid = true; + let mut results: Vec = Vec::new(); + + for file in &python_files { + // Try uv first, then fall back to python3 + let output = std::process::Command::new("uv") + .args(["run", "python"]) + .arg(&script_path) + .arg(file) + .arg(if json_output { "--json" } else { "" }) + .output() + .or_else(|_| { + std::process::Command::new("python3") + .arg(&script_path) + .arg(file) + .arg(if json_output { "--json" } else { "" }) + .output() + }); + + match output { + Ok(out) => { + let stdout = String::from_utf8_lossy(&out.stdout); + let stderr = String::from_utf8_lossy(&out.stderr); + + if json_output { + if let Ok(json) = serde_json::from_str::(&stdout) { + results.push(json); + } + } else { + if !stdout.is_empty() { + print!("{}", stdout); + } + if !stderr.is_empty() { + eprint!("{}", stderr); + } + } + + if !out.status.success() { + all_valid = false; + } + } + Err(e) => { + eprintln!("Error running validation for {}: {}", file.display(), e); + eprintln!("Make sure Python with guppylang is available (uv run python or python3)"); + all_valid = false; + } + } + } + + if json_output { + let combined = serde_json::json!({ + "files": results, + "all_valid": all_valid, + }); + println!("{}", serde_json::to_string_pretty(&combined).unwrap()); + } else if all_valid { + println!("\nAll {} file(s) validated successfully!", python_files.len()); + } else { + println!("\nValidation failed for some files."); + } + + if all_valid { + ExitCode::SUCCESS + } else { + ExitCode::from(1) + } +} diff --git a/exp/guppy-zlup/tests/e2e_tests.rs b/exp/guppy-zlup/tests/e2e_tests.rs new file mode 100644 index 000000000..8ded7b1b7 --- /dev/null +++ b/exp/guppy-zlup/tests/e2e_tests.rs @@ -0,0 +1,690 @@ +//! End-to-end tests for Guppy to Zlup compilation. +//! +//! These tests verify the full pipeline from Guppy source to valid Zlup output. + +use guppy_zlup::{lint_and_compile, lint_source}; + +/// Test helper to compile Guppy source and verify Zlup output. +fn compile_and_check(source: &str, expected_patterns: &[&str]) { + let result = lint_and_compile(source, None); + match result { + Ok(zlup) => { + for pattern in expected_patterns { + assert!( + zlup.contains(pattern), + "Expected pattern '{}' not found in output:\n{}", + pattern, + zlup + ); + } + } + Err(e) => panic!("Compilation failed: {:?}", e), + } +} + +/// Test helper to verify linting catches errors. +fn lint_should_error(source: &str, expected_rule: &str) { + let result = lint_source(source, None); + assert!( + result.has_errors, + "Expected lint errors but got none for rule {}", + expected_rule + ); + assert!( + result.diagnostics.iter().any(|d| d.rule_id == expected_rule), + "Expected rule {} but got: {:?}", + expected_rule, + result.diagnostics.iter().map(|d| &d.rule_id).collect::>() + ); +} + +// ============================================================================= +// Basic Function Tests +// ============================================================================= + +#[test] +fn test_e2e_simple_function() { + let source = r#" +def add(a: int, b: int) -> int: + return a + b +"#; + compile_and_check(source, &["fn add(a: i64, b: i64)", "-> i64", "return a + b"]); +} + +#[test] +fn test_e2e_void_function() { + let source = r#" +def noop() -> None: + pass +"#; + compile_and_check(source, &["fn noop()", "-> unit", "return;"]); +} + +#[test] +fn test_e2e_variable_binding() { + let source = r#" +def compute() -> int: + x: int = 10 + y: int = 20 + return x + y +"#; + compile_and_check(source, &["mut x: i64 = 10", "mut y: i64 = 20", "return x + y"]); +} + +// ============================================================================= +// Control Flow Tests +// ============================================================================= + +#[test] +fn test_e2e_if_else() { + let source = r#" +def abs_val(x: int) -> int: + if x < 0: + return -x + else: + return x +"#; + compile_and_check(source, &["if (x < 0)", "return -x", "else", "return x"]); +} + +#[test] +fn test_e2e_for_loop() { + let source = r#" +def sum_range() -> int: + total: int = 0 + for i in range(10): + total = total + i + return total +"#; + // total = total + i (assignment, not binding) since total is already declared + compile_and_check(source, &["for i in 0..10", "total = total + i"]); +} + +#[test] +fn test_e2e_nested_for() { + let source = r#" +def nested() -> int: + sum: int = 0 + for i in range(3): + for j in range(3): + sum = sum + 1 + return sum +"#; + compile_and_check(source, &["for i in 0..3", "for j in 0..3"]); +} + +// ============================================================================= +// Quantum Operation Tests +// ============================================================================= + +#[test] +fn test_e2e_single_qubit_gate() { + let source = r#" +def apply_h() -> None: + q = qubit[1] + h(q[0]) +"#; + compile_and_check(source, &["qalloc(1)", "h q[0]"]); +} + +#[test] +fn test_e2e_two_qubit_gate() { + let source = r#" +def apply_cx() -> None: + q = qubit[2] + cx(q[0], q[1]) +"#; + compile_and_check(source, &["qalloc(2)", "cx (q[0], q[1])"]); +} + +#[test] +fn test_e2e_multiple_gates() { + let source = r#" +def bell_prep() -> None: + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) +"#; + compile_and_check(source, &["h q[0]", "cx (q[0], q[1])"]); +} + +#[test] +fn test_e2e_measure_single() { + let source = r#" +def measure_one() -> None: + q = qubit[1] + h(q[0]) + m = measure(q[0]) +"#; + compile_and_check(source, &["h q[0]", "mz(u1) q[0]"]); +} + +#[test] +fn test_e2e_measure_register() { + let source = r#" +def measure_all() -> None: + q = qubit[4] + for i in range(4): + h(q[i]) + m = measure(q) +"#; + compile_and_check(source, &["qalloc(4)", "for i in 0..4", "mz([4]u1) q"]); +} + +// ============================================================================= +// Lint Rule Tests +// ============================================================================= + +#[test] +fn test_e2e_lint_unbounded_loop() { + let source = r#" +def bad_loop() -> None: + while True: + pass +"#; + lint_should_error(source, "ZLUP001"); +} + +#[test] +fn test_e2e_lint_recursion() { + let source = r#" +def factorial(n: int) -> int: + if n <= 1: + return 1 + return n * factorial(n - 1) +"#; + lint_should_error(source, "ZLUP002"); +} + +#[test] +fn test_e2e_lint_dynamic_alloc_in_loop() { + let source = r#" +def bad_alloc() -> None: + for i in range(10): + items = [] + items.append(i) +"#; + lint_should_error(source, "ZLUP003"); +} + +#[test] +fn test_e2e_lint_eval() { + let source = r#" +def bad_eval() -> None: + eval("print('hello')") +"#; + lint_should_error(source, "ZLUP004"); +} + +// ============================================================================= +// Type Tests +// ============================================================================= + +#[test] +fn test_e2e_bool_type() { + let source = r#" +def check(x: int) -> bool: + return x > 0 +"#; + compile_and_check(source, &["-> bool", "return x > 0"]); +} + +#[test] +fn test_e2e_float_type() { + let source = r#" +def half(x: float) -> float: + return x / 2.0 +"#; + compile_and_check(source, &["x: f64", "-> f64"]); +} + +#[test] +fn test_e2e_parameterized_gate() { + // Note: Guppy convention is gate(qubit, angle) + let source = r#" +def rotate() -> None: + q = qubit[1] + rz(q[0], 3.14) +"#; + compile_and_check(source, &["qalloc(1)", "rz(3.14) q[0]"]); +} + +#[test] +fn test_e2e_three_qubit_gate() { + let source = r#" +def toffoli_test() -> None: + q = qubit[3] + ccx(q[0], q[1], q[2]) +"#; + compile_and_check(source, &["qalloc(3)", "ccx"]); +} + +#[test] +fn test_e2e_multiple_different_gates() { + let source = r#" +def gate_sequence() -> None: + q = qubit[2] + h(q[0]) + x(q[1]) + cx(q[0], q[1]) + z(q[0]) +"#; + compile_and_check(source, &["h q[0]", "x q[1]", "cx (q[0], q[1])", "z q[0]"]); +} + +// ============================================================================= +// Expression Tests +// ============================================================================= + +#[test] +fn test_e2e_arithmetic() { + let source = r#" +def math(a: int, b: int) -> int: + return (a + b) * (a - b) +"#; + compile_and_check(source, &["a + b", "a - b"]); +} + +#[test] +fn test_e2e_comparison() { + let source = r#" +def compare(a: int, b: int) -> bool: + return a <= b +"#; + compile_and_check(source, &["a <= b"]); +} + +#[test] +fn test_e2e_unary() { + let source = r#" +def negate(x: int) -> int: + return -x +"#; + compile_and_check(source, &["return -x"]); +} + +#[test] +fn test_e2e_boolean_and() { + let source = r#" +def both(a: bool, b: bool) -> bool: + return a and b +"#; + compile_and_check(source, &["a and b"]); +} + +#[test] +fn test_e2e_boolean_or() { + let source = r#" +def either(a: bool, b: bool) -> bool: + return a or b +"#; + compile_and_check(source, &["a or b"]); +} + +#[test] +fn test_e2e_chained_boolean() { + let source = r#" +def all_three(a: bool, b: bool, c: bool) -> bool: + return a and b and c +"#; + // Chained: (a and b) and c + compile_and_check(source, &["and"]); +} + +// ============================================================================= +// Control Flow Tests (while, break, continue) +// ============================================================================= + +#[test] +fn test_e2e_while_loop_with_break() { + // While loops are transformed to bounded for loops with break condition + let source = r#" +def count_up(limit: int) -> int: + i: int = 0 + while i < limit: + i = i + 1 + return i +"#; + // Should transform to for loop with break + compile_and_check(source, &["for", "break"]); +} + +#[test] +fn test_e2e_break_statement() { + let source = r#" +def find_first() -> int: + result: int = 0 + for i in range(10): + if i > 5: + result = i + break + return result +"#; + compile_and_check(source, &["break"]); +} + +#[test] +fn test_e2e_continue_statement() { + let source = r#" +def skip_evens() -> int: + total: int = 0 + for i in range(10): + if i % 2 == 0: + continue + total = total + i + return total +"#; + compile_and_check(source, &["continue"]); +} + +// ============================================================================= +// Edge Case Tests +// ============================================================================= + +#[test] +fn test_e2e_augmented_assignment_add() { + let source = r#" +def increment() -> int: + x: int = 5 + x += 3 + return x +"#; + compile_and_check(source, &["mut x: i64 = 5", "x = x + 3"]); +} + +#[test] +fn test_e2e_augmented_assignment_sub() { + let source = r#" +def decrement() -> int: + x: int = 10 + x -= 4 + return x +"#; + compile_and_check(source, &["x = x - 4"]); +} + +#[test] +fn test_e2e_augmented_assignment_mul() { + let source = r#" +def double() -> int: + x: int = 5 + x *= 2 + return x +"#; + compile_and_check(source, &["x = x * 2"]); +} + +#[test] +fn test_e2e_nested_for_loops() { + let source = r#" +def matrix_sum() -> int: + total: int = 0 + for i in range(3): + for j in range(4): + total += 1 + return total +"#; + compile_and_check(source, &["for i in 0..3", "for j in 0..4", "total = total + 1"]); +} + +#[test] +fn test_e2e_deeply_nested_loops() { + let source = r#" +def cube_sum() -> int: + total: int = 0 + for i in range(2): + for j in range(2): + for k in range(2): + total += 1 + return total +"#; + compile_and_check(source, &["for i in 0..2", "for j in 0..2", "for k in 0..2"]); +} + +#[test] +fn test_e2e_nested_loop_with_outer_var() { + let source = r#" +def product_sum() -> int: + total: int = 0 + for i in range(5): + for j in range(3): + total = total + i * j + return total +"#; + compile_and_check(source, &["total = total + i * j"]); +} + +#[test] +fn test_e2e_break_in_nested_loop() { + let source = r#" +def find_product() -> int: + result: int = 0 + for i in range(5): + for j in range(5): + if i * j > 6: + result = i * j + break + return result +"#; + compile_and_check(source, &["if (i * j > 6)", "break"]); +} + +#[test] +fn test_e2e_multiple_variables() { + let source = r#" +def swap_like() -> int: + a: int = 1 + b: int = 2 + c: int = 3 + a = b + b = c + c = a + return a + b + c +"#; + compile_and_check(source, &["mut a: i64 = 1", "mut b: i64 = 2", "mut c: i64 = 3"]); +} + +#[test] +fn test_e2e_complex_arithmetic() { + let source = r#" +def calculate(x: int, y: int) -> int: + return (x + y) * (x - y) + x * y +"#; + compile_and_check(source, &["x + y", "x - y", "x * y"]); +} + +#[test] +fn test_e2e_chained_comparison() { + let source = r#" +def in_range(x: int, lo: int, hi: int) -> bool: + return x >= lo and x <= hi +"#; + compile_and_check(source, &["x >= lo", "x <= hi", "and"]); +} + +#[test] +fn test_e2e_boolean_operators() { + let source = r#" +def complex_bool(a: bool, b: bool, c: bool) -> bool: + return (a and b) or (a and c) or (b and c) +"#; + compile_and_check(source, &["and", "or"]); +} + +#[test] +fn test_e2e_if_elif_else() { + let source = r#" +def classify(x: int) -> int: + if x < 0: + return -1 + elif x == 0: + return 0 + else: + return 1 +"#; + compile_and_check(source, &["if (x < 0)", "return -1", "else", "return 1"]); +} + +#[test] +fn test_e2e_nested_if() { + let source = r#" +def nested_cond(a: int, b: int) -> int: + if a > 0: + if b > 0: + return 1 + else: + return 2 + else: + return 3 +"#; + compile_and_check(source, &["if (a > 0)", "if (b > 0)", "return 1", "return 2", "return 3"]); +} + +#[test] +fn test_e2e_while_with_multiple_conditions() { + let source = r#" +def bounded_count(limit: int) -> int: + i: int = 0 + count: int = 0 + while i < limit and count < 50: + count += 1 + i += 1 + return count +"#; + compile_and_check(source, &["for", "break", "and"]); +} + +#[test] +fn test_e2e_multiple_quantum_registers() { + let source = r#" +def multi_reg() -> None: + a = qubit[2] + b = qubit[3] + h(a[0]) + h(b[0]) + cx(a[0], b[0]) +"#; + compile_and_check(source, &["qalloc(2)", "qalloc(3)", "h a[0]", "h b[0]", "cx (a[0], b[0])"]); +} + +#[test] +fn test_e2e_loop_with_quantum_ops() { + let source = r#" +def apply_h_to_all() -> None: + q = qubit[4] + for i in range(4): + h(q[i]) +"#; + compile_and_check(source, &["qalloc(4)", "for i in 0..4", "h q[i]"]); +} + +#[test] +fn test_e2e_modulo_operator() { + let source = r#" +def is_even(x: int) -> bool: + return x % 2 == 0 +"#; + compile_and_check(source, &["x % 2 == 0"]); +} + +#[test] +fn test_e2e_integer_division() { + let source = r#" +def half(x: int) -> int: + return x // 2 +"#; + compile_and_check(source, &["x / 2"]); +} + +// ============================================================================= +// Analysis Integration Tests +// ============================================================================= + +#[test] +fn test_e2e_analysis_after_compile() { + use zlup::analysis::{analyze_parallelism, AllocatorAnalysis}; + + let source = r#" +def bell() -> None: + q = qubit[2] + h(q[0]) + cx(q[0], q[1]) +"#; + + // Compile Guppy to Zlup + let zlup_source = lint_and_compile(source, None).expect("compile failed"); + + // Parse Zlup and run analysis + let program = zlup::parse(&zlup_source).expect("parse failed"); + + let allocator_analysis = AllocatorAnalysis::analyze(&program); + assert!(allocator_analysis.allocators.contains_key("q"), "Should detect allocator q"); + + let summaries = analyze_parallelism(&program); + assert!(!summaries.is_empty(), "Should have function summaries"); + + let bell_summary = summaries.iter().find(|s| s.function_name == "bell"); + assert!(bell_summary.is_some(), "Should have bell function summary"); + assert!(bell_summary.unwrap().quantum_ops >= 2, "Should have at least 2 quantum ops"); +} + +#[test] +fn test_e2e_analysis_disjoint_registers() { + use zlup::analysis::analyze_parallelism; + + let source = r#" +def parallel_prep() -> None: + q1 = qubit[2] + q2 = qubit[2] + h(q1[0]) + h(q2[0]) +"#; + + let zlup_source = lint_and_compile(source, None).expect("compile failed"); + let program = zlup::parse(&zlup_source).expect("parse failed"); + + let summaries = analyze_parallelism(&program); + let summary = &summaries[0]; + + // Two independent H gates on different allocators should enable parallelism + assert!(summary.max_parallelism >= 2, "Disjoint allocators should enable parallelism"); +} + +#[test] +fn test_e2e_analysis_classical_quantum_independence() { + use zlup::analysis::analyze_parallelism; + + let source = r#" +def mixed_ops() -> None: + q = qubit[2] + x = 1 + 2 + h(q[0]) + y = x * 3 +"#; + + let zlup_source = lint_and_compile(source, None).expect("compile failed"); + let program = zlup::parse(&zlup_source).expect("parse failed"); + + let summaries = analyze_parallelism(&program); + let summary = &summaries[0]; + + assert!(summary.classical_ops > 0, "Should have classical ops"); + assert!(summary.quantum_ops > 0, "Should have quantum ops"); +} + +#[test] +fn test_e2e_lint_error_blocks_analysis() { + // Source with lint error (unbounded loop) + let source = r#" +def bad_loop() -> None: + while True: + pass +"#; + + let result = lint_source(source, None); + assert!(result.has_errors, "Should detect lint error"); + + // lint_and_compile should fail + let compile_result = lint_and_compile(source, None); + assert!(compile_result.is_err(), "Should fail to compile with lint errors"); +} diff --git a/exp/guppy-zlup/tests/transform_tests.rs b/exp/guppy-zlup/tests/transform_tests.rs new file mode 100644 index 000000000..66450463f --- /dev/null +++ b/exp/guppy-zlup/tests/transform_tests.rs @@ -0,0 +1,391 @@ +//! Integration tests for Guppy IR to Zlup transformation. + +use guppy_zlup::{compile, CompileError}; + +#[test] +fn test_compile_empty_program() { + let ir = r#"{ + "version": "0.1.0", + "functions": [] + }"#; + + let result = compile(ir); + assert!(result.is_ok()); +} + +#[test] +fn test_compile_simple_function() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "main", + "params": [], + "body": [] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("fn main()")); +} + +#[test] +fn test_compile_qalloc() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "bell", + "params": [], + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("qalloc")); + assert!(result.contains("q")); +} + +#[test] +fn test_compile_gate() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_gate", + "params": [], + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("h q[0]")); +} + +#[test] +fn test_compile_two_qubit_gate() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "bell", + "params": [], + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + }, + { + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("h q[0]")); + assert!(result.contains("cx")); +} + +#[test] +fn test_compile_for_loop() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_loop", + "params": [], + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 4} + }, + { + "kind": "for", + "var": "i", + "range": { + "start": {"kind": "literal", "value": 0}, + "end": {"kind": "literal", "value": 4} + }, + "body": [ + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "ident", "name": "i"}} + ] + } + ] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("for")); + assert!(result.contains("0..4")); +} + +#[test] +fn test_compile_if_statement() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_if", + "params": [ + {"name": "cond", "type": {"kind": "primitive", "name": "bool"}} + ], + "body": [ + { + "kind": "if", + "condition": {"kind": "ident", "name": "cond"}, + "then_body": [ + {"kind": "return", "return_value": {"kind": "literal", "value": 1}} + ], + "else_body": [ + {"kind": "return", "return_value": {"kind": "literal", "value": 0}} + ] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + // Simple identifier conditions are converted to != 0 for u1/bool compatibility + assert!(result.contains("if (cond != 0)")); + assert!(result.contains("else")); +} + +#[test] +fn test_compile_function_with_params() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "add", + "params": [ + {"name": "a", "type": {"kind": "primitive", "name": "int"}}, + {"name": "b", "type": {"kind": "primitive", "name": "int"}} + ], + "return_type": {"kind": "primitive", "name": "int"}, + "body": [ + { + "kind": "return", + "return_value": { + "kind": "binary", + "op": "add", + "left": {"kind": "ident", "name": "a"}, + "right": {"kind": "ident", "name": "b"} + } + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("fn add(a: i64, b: i64)")); + assert!(result.contains("-> i64")); +} + +#[test] +fn test_compile_invalid_json() { + let ir = "not valid json"; + let result = compile(ir); + assert!(matches!(result, Err(CompileError::Parse(_)))); +} + +#[test] +fn test_compile_result_statement() { + // Test with proper measure statement (new IR format) + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "bell", + "params": [], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 2} + }, + { + "kind": "gate", + "gate": "h", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}} + ] + }, + { + "kind": "gate", + "gate": "cx", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ] + }, + { + "kind": "measure", + "targets": [{"kind": "ident", "name": "q"}], + "results": ["m"] + }, + { + "kind": "result", + "tag": "measurements", + "value": {"kind": "ident", "name": "m"} + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("fn bell()")); + assert!(result.contains("-> unit")); + assert!(result.contains("qalloc(2)")); + assert!(result.contains("h q[0]")); + assert!(result.contains("cx")); + // Measure entire register: mz([2]u1) q + assert!(result.contains("mz([2]u1) q")); + assert!(result.contains(r#"result("measurements", m)"#)); + // Should have implicit return (return; is equivalent to return unit; in Zlup) + assert!(result.contains("return;")); +} + +#[test] +fn test_compile_single_qubit_measure() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_single", + "params": [], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 4} + }, + { + "kind": "measure", + "targets": [{"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}], + "results": ["m"] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + // Single qubit measurement: mz(u1) q[0] + assert!(result.contains("mz(u1) q[0]")); +} + +#[test] +fn test_compile_multiple_qubit_measure() { + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_multi", + "params": [], + "return_type": {"kind": "primitive", "name": "None"}, + "body": [ + { + "kind": "qalloc", + "name": "q", + "size": {"kind": "literal", "value": 4} + }, + { + "kind": "measure", + "targets": [ + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 0}}, + {"kind": "index", "array": "q", "index": {"kind": "literal", "value": 1}} + ], + "results": ["m"] + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + // Multiple explicit qubits: mz([2]u1) [q[0], q[1]] + assert!(result.contains("mz([2]u1) [q[0], q[1]]")); +} + +#[test] +fn test_compile_binding_statement() { + // Test binding (from annotated assignment like x: int = 5) + let ir = r#"{ + "version": "0.1.0", + "functions": [ + { + "name": "test_binding", + "params": [], + "return_type": {"kind": "primitive", "name": "int"}, + "body": [ + { + "kind": "binding", + "name": "x", + "type": {"kind": "primitive", "name": "int"}, + "value": {"kind": "literal", "value": 5}, + "is_mutable": true + }, + { + "kind": "return", + "return_value": {"kind": "ident", "name": "x"} + } + ] + } + ] + }"#; + + let result = compile(ir).unwrap(); + assert!(result.contains("fn test_binding()")); + assert!(result.contains("mut x: i64 = 5")); + assert!(result.contains("return x")); +} diff --git a/exp/zlup/.gitignore b/exp/zlup/.gitignore new file mode 100644 index 000000000..537d901d3 --- /dev/null +++ b/exp/zlup/.gitignore @@ -0,0 +1,22 @@ +# Build artifacts +/target/ + +# Fuzzing - corpus and artifacts are machine-generated +/fuzz/target/ +/fuzz/corpus/ +/fuzz/artifacts/ + +# Proptest regressions (auto-generated failure cases) +*.proptest-regressions + +# IDE +.idea/ +*.iml + +# Python / uv +.venv/ +__pycache__/ +*.pyc + +# MkDocs +/site/ diff --git a/exp/zlup/Cargo.toml b/exp/zlup/Cargo.toml new file mode 100644 index 000000000..0cdcbf095 --- /dev/null +++ b/exp/zlup/Cargo.toml @@ -0,0 +1,86 @@ +[package] +name = "zlup" +version = "0.1.0" +edition = "2024" +description = "Zlup: Experimental quantum language with Zig semantics and Rust/Python syntax for reliable QEC" +license = "Apache-2.0" +repository = "https://github.com/PECOS-packages/PECOS" +readme = "README.md" +keywords = ["quantum", "programming-language", "experimental"] +categories = ["science", "compilers"] + +# This is an experimental crate - not for production use +[badges] +maintenance = { status = "experimental" } + +[lib] +name = "zlup" + +# CLI binary +[[bin]] +name = "zlup" +path = "src/main.rs" +required-features = ["cli"] + +# LSP server binary +[[bin]] +name = "zlups" +path = "src/lsp/main.rs" +required-features = ["lsp"] + +[dependencies] +# Parser generator +pest = "2.7" +pest_derive = "2.7" + +# Error handling +thiserror = "1.0" +miette = { version = "7.0", features = ["fancy"] } + +# Utilities +smol_str = "0.3" # Interned strings for identifiers + +# Logging +log = "0.4" +env_logger = "0.11" + +# Serialization (for SLR-AST JSON bridge) +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Config file parsing +toml = "0.8" + +# CLI (optional) +clap = { version = "4.5", features = ["derive", "env"], optional = true } + +# HUGR codegen (optional) +tket = { workspace = true, optional = true } + +# LSP server (optional) +tower-lsp = { version = "0.20", optional = true } +tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "io-std"], optional = true } +once_cell = { version = "1.19", optional = true } + +[dev-dependencies] +pretty_assertions = "1.4" +insta = "1.40" # Snapshot testing +tket.workspace = true # For testing HUGR codegen +tempfile = "3.10" # For module system tests +criterion = { version = "0.5", features = ["html_reports"] } # Benchmarking +proptest = "1.4" # Property-based testing + +[[bench]] +name = "compiler" +harness = false + +[features] +default = [] +# Enable CLI binary +cli = ["dep:clap"] +# Enable HUGR code generation +hugr = ["dep:tket"] +# Enable LSP server +lsp = ["dep:tower-lsp", "dep:tokio", "dep:once_cell"] +# All features for full build +full = ["cli", "hugr", "lsp"] diff --git a/exp/zlup/Justfile b/exp/zlup/Justfile new file mode 100644 index 000000000..536fdc89b --- /dev/null +++ b/exp/zlup/Justfile @@ -0,0 +1,235 @@ +# Zlup Development Justfile +# Cross-platform command runner (Windows, macOS, Linux) +# Install: cargo install just +# Usage: just or just --list + +# Default recipe: show help +default: + @just --list + +# ============================================================================= +# Settings +# ============================================================================= + +set shell := ["bash", "-cu"] +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] + +# ============================================================================= +# Documentation +# ============================================================================= + +# Build and serve documentation (opens in browser) +docs port="8000": + #!/usr/bin/env bash + set -euo pipefail + if lsof -i :{{port}} >/dev/null 2>&1; then + echo "Port {{port}} is already in use." + echo "" + echo "Options:" + echo " - Use a different port: just docs 8001" + echo " - Stop existing server: just docs-stop {{port}}" + echo " - Find process: lsof -i :{{port}}" + exit 1 + fi + echo "Starting documentation server at http://127.0.0.1:{{port}}" + uv run mkdocs serve --open -a 127.0.0.1:{{port}} + +# Build documentation to site/ +docs-build: + uv run mkdocs build --clean + +# Serve documentation without opening browser +docs-serve port="8000": + #!/usr/bin/env bash + set -euo pipefail + if lsof -i :{{port}} >/dev/null 2>&1; then + echo "Port {{port}} is already in use. Try: just docs-stop {{port}}" + exit 1 + fi + uv run mkdocs serve -a 127.0.0.1:{{port}} + +# Stop documentation server on a port +docs-stop port="8000": + #!/usr/bin/env bash + set -euo pipefail + pid=$(lsof -t -i :{{port}} 2>/dev/null || true) + if [ -n "$pid" ]; then + echo "Stopping process $pid on port {{port}}..." + kill $pid + echo "Done." + else + echo "No process found on port {{port}}." + fi + +# ============================================================================= +# Building +# ============================================================================= + +# Build Zlup compiler (debug) +build: + cargo build --features cli + +# Build Zlup compiler (release) +build-release: + cargo build --release --features cli + +# Build Zlup compiler (release with native CPU optimizations) +build-native: + RUSTFLAGS="-C target-cpu=native" cargo build --release --features cli + +# ============================================================================= +# Testing +# ============================================================================= + +# Run all tests +test: + cargo test --features cli + +# Run all tests with output +test-verbose: + cargo test --features cli -- --nocapture + +# Run specific test by name +test-one name: + cargo test --features cli {{name}} -- --nocapture + +# Run property-based tests only +test-proptest: + cargo test --features cli proptest + +# Run CLI integration tests only +test-cli: + cargo test --features cli cli_tests + +# ============================================================================= +# Linting / Formatting +# ============================================================================= + +# Check code with clippy +clippy: + cargo clippy --features cli --all-targets -- -D warnings + +# Check Rust formatting +fmt: + cargo fmt --check + +# Fix Rust formatting +fmt-fix: + cargo fmt + +# Run all linting checks +lint: fmt clippy + +# Fix all auto-fixable issues +lint-fix: fmt-fix + cargo clippy --features cli --fix --allow-dirty + +# ============================================================================= +# Running +# ============================================================================= + +# Run Zlup compiler on a file +run file: + cargo run --features cli -- {{file}} + +# Run Zlup compiler on a file (release build) +run-release file: + cargo run --release --features cli -- {{file}} + +# Evaluate an expression +eval expr: + cargo run --features cli -- eval "{{expr}}" + +# Check a file without running +check file: + cargo run --features cli -- check {{file}} + +# Compile to SLR-AST JSON +compile-slr file: + cargo run --features cli -- compile --target slr {{file}} + +# Compile to OpenQASM +compile-qasm file: + cargo run --features cli -- compile --target qasm {{file}} + +# ============================================================================= +# Development +# ============================================================================= + +# Watch for changes and run tests +watch: + cargo watch -x "test --features cli" + +# Watch for changes and check +watch-check: + cargo watch -x "check --features cli" + +# Generate and open rustdoc +rustdoc: + cargo doc --features cli --open + +# ============================================================================= +# Examples +# ============================================================================= + +# Run all example programs +examples: + #!/usr/bin/env bash + set -euo pipefail + echo "Running Zlup examples..." + for f in examples/*.zlp; do + echo "==> $f" + cargo run --features cli -- "$f" || true + echo "" + done + +# Compile all examples to SLR-AST +examples-slr: + #!/usr/bin/env bash + set -euo pipefail + echo "Compiling examples to SLR-AST..." + for f in examples/*.zlp; do + echo "==> $f" + cargo run --features cli -- compile --target slr "$f" || true + echo "" + done + +# ============================================================================= +# Documentation Checks +# ============================================================================= + +# Check Zlup code snippets in documentation for syntax/semantic errors +check-docs: build + python3 scripts/check_docs.py + +# Check docs with verbose output (shows each snippet result) +check-docs-verbose: build + python3 scripts/check_docs.py --verbose + +# ============================================================================= +# Cleaning +# ============================================================================= + +# Clean build artifacts +clean: + cargo clean + rm -rf site/ + +# Clean everything including Python venv +clean-all: clean + rm -rf .venv/ + rm -f uv.lock + +# ============================================================================= +# Setup +# ============================================================================= + +# Install Python dependencies for docs +setup-docs: + uv sync + +# Install all development dependencies +setup: setup-docs + @echo "Development environment ready!" + @echo " - Run 'just test' to run tests" + @echo " - Run 'just docs' to view documentation" diff --git a/exp/zlup/README.md b/exp/zlup/README.md new file mode 100644 index 000000000..ab6f72ff2 --- /dev/null +++ b/exp/zlup/README.md @@ -0,0 +1,96 @@ +# Zlup + +> **EXPERIMENTAL / EXPLORATORY** - Zlup is a research experiment, not a production language. + +A quantum programming language for QEC research: simple, low-level, and predictable by design. + +## Overview + +Zlup is the **low-level complement to Guppy** in the PECOS ecosystem. While Guppy provides a high-level, Pythonic experience with linear types for safety, Zlup explores a different point in the design space: + +| | Guppy | Zlup | +|---|---|---| +| **Philosophy** | High-level, Pythonic | Low-level, explicit | +| **Safety mechanism** | Linear type system | Constraints make unsafe impossible | +| **Target users** | QEC researchers | Systems programmers | + +**Design principles:** Simple. Explicit. No magic. + +- Zig semantics with Rust/Python syntax +- NASA Power of 10 compliance for reliability +- Safe by constraint (no recursion, no escaping references) + +## Quick Example + +```zig +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Create GHZ state + h q[0]; + cx (q[0], q[1]); + cx (q[1], q[2]); + cx (q[2], q[3]); + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + return; +} +``` + +## Building + +```bash +# Build the compiler +cargo build --features cli + +# Run tests +cargo test --features cli + +# Compile a program +cargo run --features cli -- compile program.zlp + +# Check without compiling +cargo run --features cli -- check program.zlp +``` + +Or use the Justfile: + +```bash +just build # Build compiler +just test # Run tests +just docs # View documentation +``` + +## Documentation + +Full documentation is available in the `docs/` directory: + +| Document | Description | +|----------|-------------| +| [Tutorial](docs/tutorial.md) | Getting started guide | +| [Language Syntax](docs/syntax.md) | Complete language reference | +| [CLI Reference](docs/cli.md) | Command-line interface | +| [Standard Library](docs/stdlib.md) | Standard library reference | +| [Error Messages](docs/errors.md) | Error messages guide | +| [Design Philosophy](docs/design.md) | Why Zlup exists | +| [Rust Integration](docs/rust-integration.md) | FFI and native backends | +| [IDE Setup](docs/ide-setup.md) | Editor configuration | + +To view docs locally: + +```bash +just docs # Starts server at http://127.0.0.1:8000 +``` + +## Status + +Zlup is **highly experimental**—an exploration of language design, not a production tool. The goal is to learn from building it, not to replace existing tools. + +See the [Design Philosophy](docs/design.md) for more on why Zlup exists. + +## License + +Apache-2.0 diff --git a/exp/zlup/benches/compiler.rs b/exp/zlup/benches/compiler.rs new file mode 100644 index 000000000..012a07ad8 --- /dev/null +++ b/exp/zlup/benches/compiler.rs @@ -0,0 +1,314 @@ +//! Performance benchmarks for the Zlup compiler. +//! +//! Run with: cargo bench --bench compiler + +use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use zlup::codegen::{QasmCodegen, SlrCodegen}; +use zlup::semantic::SemanticAnalyzer; + +// ============================================================================= +// Test Programs +// ============================================================================= + +/// Minimal Bell state program (2 qubits, 3 operations) +const SMALL_PROGRAM: &str = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + return unit; +} +"#; + +/// Medium-sized GHZ state program (4 qubits, 6 operations) +const MEDIUM_PROGRAM: &str = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + h q[0]; + cx (q[0], q[1]); + cx (q[0], q[2]); + cx (q[0], q[3]); + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + return unit; +} +"#; + +/// Generate a program with N qubits creating a GHZ state +fn generate_ghz_program(n: usize) -> String { + let mut s = String::new(); + s.push_str("pub fn main() -> unit {\n"); + s.push_str(&format!(" q := qalloc({n});\n")); + s.push_str(" pz q;\n"); + s.push_str(" h q[0];\n"); + + // Create entanglement chain + for i in 1..n { + s.push_str(&format!(" cx (q[0], q[{i}]);\n")); + } + + // Measurement + let indices: Vec = (0..n).map(|i| format!("q[{i}]")).collect(); + s.push_str(&format!( + " results: [{n}]u1 = mz([{n}]u1) [{}];\n", + indices.join(", ") + )); + s.push_str(" return unit;\n"); + s.push_str("}\n"); + s +} + +/// Generate a program with multiple functions +fn generate_multi_function_program(n_funcs: usize) -> String { + let mut s = String::new(); + + // Helper functions + for i in 0..n_funcs { + s.push_str(&format!( + r#" +fn helper_{i}(x: i32) -> i32 {{ + y := x + {i}; + return y; +}} +"# + )); + } + + // Main function that calls helpers + s.push_str("\npub fn main() -> unit {\n"); + s.push_str(" q := qalloc(2);\n"); + s.push_str(" pz q;\n"); + s.push_str(" h q[0];\n"); + s.push_str(" cx (q[0], q[1]);\n"); + + // Call each helper + for i in 0..n_funcs { + s.push_str(&format!(" val_{i} := helper_{i}({i});\n")); + } + + s.push_str(" return unit;\n"); + s.push_str("}\n"); + s +} + +// ============================================================================= +// Parsing Benchmarks +// ============================================================================= + +fn bench_parsing(c: &mut Criterion) { + let mut group = c.benchmark_group("parsing"); + + // Small program + group.throughput(Throughput::Bytes(SMALL_PROGRAM.len() as u64)); + group.bench_function("small_2q", |b| { + b.iter(|| zlup::parse(black_box(SMALL_PROGRAM)).unwrap()) + }); + + // Medium program + group.throughput(Throughput::Bytes(MEDIUM_PROGRAM.len() as u64)); + group.bench_function("medium_4q", |b| { + b.iter(|| zlup::parse(black_box(MEDIUM_PROGRAM)).unwrap()) + }); + + // Scaling with qubit count + for n in [8, 16, 32, 64] { + let program = generate_ghz_program(n); + group.throughput(Throughput::Bytes(program.len() as u64)); + group.bench_with_input(BenchmarkId::new("ghz", n), &program, |b, prog| { + b.iter(|| zlup::parse(black_box(prog)).unwrap()) + }); + } + + // Scaling with function count + for n in [5, 10, 20] { + let program = generate_multi_function_program(n); + group.throughput(Throughput::Bytes(program.len() as u64)); + group.bench_with_input(BenchmarkId::new("multi_func", n), &program, |b, prog| { + b.iter(|| zlup::parse(black_box(prog)).unwrap()) + }); + } + + group.finish(); +} + +// ============================================================================= +// Semantic Analysis Benchmarks +// ============================================================================= + +fn bench_semantic(c: &mut Criterion) { + let mut group = c.benchmark_group("semantic"); + + // Small program + let small_ast = zlup::parse(SMALL_PROGRAM).unwrap(); + group.bench_function("small_2q", |b| { + b.iter(|| { + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(black_box(&small_ast)).unwrap() + }) + }); + + // Medium program + let medium_ast = zlup::parse(MEDIUM_PROGRAM).unwrap(); + group.bench_function("medium_4q", |b| { + b.iter(|| { + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(black_box(&medium_ast)).unwrap() + }) + }); + + // Scaling with qubit count + for n in [8, 16, 32, 64] { + let program = generate_ghz_program(n); + let ast = zlup::parse(&program).unwrap(); + group.bench_with_input(BenchmarkId::new("ghz", n), &ast, |b, ast| { + b.iter(|| { + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(black_box(ast)).unwrap() + }) + }); + } + + // Scaling with function count + for n in [5, 10, 20] { + let program = generate_multi_function_program(n); + let ast = zlup::parse(&program).unwrap(); + group.bench_with_input(BenchmarkId::new("multi_func", n), &ast, |b, ast| { + b.iter(|| { + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(black_box(ast)).unwrap() + }) + }); + } + + group.finish(); +} + +// ============================================================================= +// SLR Code Generation Benchmarks +// ============================================================================= + +fn bench_slr_codegen(c: &mut Criterion) { + let mut group = c.benchmark_group("codegen_slr"); + + // Small program + let small_ast = zlup::parse(SMALL_PROGRAM).unwrap(); + group.bench_function("small_2q", |b| { + b.iter(|| { + let mut codegen = SlrCodegen::new(); + codegen.compile(black_box(&small_ast)).unwrap() + }) + }); + + // Medium program + let medium_ast = zlup::parse(MEDIUM_PROGRAM).unwrap(); + group.bench_function("medium_4q", |b| { + b.iter(|| { + let mut codegen = SlrCodegen::new(); + codegen.compile(black_box(&medium_ast)).unwrap() + }) + }); + + // Scaling with qubit count + for n in [8, 16, 32, 64] { + let program = generate_ghz_program(n); + let ast = zlup::parse(&program).unwrap(); + group.bench_with_input(BenchmarkId::new("ghz", n), &ast, |b, ast| { + b.iter(|| { + let mut codegen = SlrCodegen::new(); + codegen.compile(black_box(ast)).unwrap() + }) + }); + } + + group.finish(); +} + +// ============================================================================= +// QASM Code Generation Benchmarks +// ============================================================================= + +fn bench_qasm_codegen(c: &mut Criterion) { + let mut group = c.benchmark_group("codegen_qasm"); + + // Small program + let small_ast = zlup::parse(SMALL_PROGRAM).unwrap(); + group.bench_function("small_2q", |b| { + b.iter(|| { + let mut codegen = QasmCodegen::new(); + codegen.compile(black_box(&small_ast)).unwrap() + }) + }); + + // Medium program + let medium_ast = zlup::parse(MEDIUM_PROGRAM).unwrap(); + group.bench_function("medium_4q", |b| { + b.iter(|| { + let mut codegen = QasmCodegen::new(); + codegen.compile(black_box(&medium_ast)).unwrap() + }) + }); + + // Scaling with qubit count + for n in [8, 16, 32, 64] { + let program = generate_ghz_program(n); + let ast = zlup::parse(&program).unwrap(); + group.bench_with_input(BenchmarkId::new("ghz", n), &ast, |b, ast| { + b.iter(|| { + let mut codegen = QasmCodegen::new(); + codegen.compile(black_box(ast)).unwrap() + }) + }); + } + + group.finish(); +} + +// ============================================================================= +// End-to-End Pipeline Benchmarks +// ============================================================================= + +fn bench_full_pipeline(c: &mut Criterion) { + let mut group = c.benchmark_group("full_pipeline"); + + // Parse -> Semantic -> SLR + for n in [4, 16, 64] { + let program = generate_ghz_program(n); + group.bench_with_input(BenchmarkId::new("to_slr", n), &program, |b, prog| { + b.iter(|| { + let ast = zlup::parse(black_box(prog)).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&ast).unwrap(); + let mut codegen = SlrCodegen::new(); + codegen.compile(&ast).unwrap() + }) + }); + } + + // Parse -> Semantic -> QASM + for n in [4, 16, 64] { + let program = generate_ghz_program(n); + group.bench_with_input(BenchmarkId::new("to_qasm", n), &program, |b, prog| { + b.iter(|| { + let ast = zlup::parse(black_box(prog)).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&ast).unwrap(); + let mut codegen = QasmCodegen::new(); + codegen.compile(&ast).unwrap() + }) + }); + } + + group.finish(); +} + +criterion_group!( + benches, + bench_parsing, + bench_semantic, + bench_slr_codegen, + bench_qasm_codegen, + bench_full_pipeline, +); +criterion_main!(benches); diff --git a/exp/zlup/docs/cli.md b/exp/zlup/docs/cli.md new file mode 100644 index 000000000..0326a34fd --- /dev/null +++ b/exp/zlup/docs/cli.md @@ -0,0 +1,244 @@ +# CLI Reference + +Zlup provides a command-line interface for compiling, checking, and running Zlup programs. + +## Project Commands + +### Initialize a Project + +```bash +# Create a new project +zlup init my-project + +# Create project in current directory +zlup init my-project --here +``` + +### Build a Project + +```bash +# Build using zlup.toml configuration +zlup build + +# Build with strict mode override +zlup build --strict true + +# Build to different target +zlup build --target hugr +``` + +## Direct Compilation + +### Compile + +```bash +# Compile to SLR-AST JSON (default) +zlup compile program.zlp + +# Compile to specific target +zlup compile --target slr program.zlp +zlup compile --target qasm program.zlp +zlup compile --target hugr program.zlp + +# Read from stdin +echo 'fn main() -> unit { return; }' | zlup compile - +``` + +### Check + +```bash +# Check syntax and semantics +zlup check program.zlp + +# Strict mode (NASA Power of 10 checks) +zlup check --strict program.zlp +``` + +## Formatting + +Format Zlup source files to a consistent style. + +```bash +# Format and print to stdout +zlup fmt program.zlp + +# Format in place +zlup fmt --write program.zlp +zlup fmt -w program.zlp + +# Check formatting (exit 1 if not formatted) +zlup fmt --check program.zlp + +# Read from stdin +cat program.zlp | zlup fmt - +``` + +## Linting + +Zlup includes a linter with automatic fix capabilities, similar to ruff for Python. + +### Basic Usage + +```bash +# Run linter (strict mode by default) +zlup lint program.zlp + +# Run with relaxed or minimal rules +zlup lint --level relaxed program.zlp +zlup lint --level minimal program.zlp +``` + +### Auto-Fix + +```bash +# Preview fixes without applying (shows diff) +zlup lint --diff program.zlp + +# Apply safe fixes automatically +zlup lint --fix program.zlp + +# Apply both safe and unsafe fixes +zlup lint --fix --unsafe-fixes program.zlp +``` + +### Output Options + +```bash +# Show statistics only +zlup lint --statistics program.zlp + +# Treat warnings as errors +zlup lint --deny-warnings program.zlp + +# Output formats +zlup lint --format pretty program.zlp # Human-readable (default) +zlup lint --format json program.zlp # JSON for tooling +zlup lint --format compact program.zlp # One line per diagnostic +``` + +### Fix Safety Levels + +| Safety | Description | Examples | +|--------|-------------|----------| +| Safe | Preserves semantics, guaranteed correct | Prefix unused vars with `_`, decimal to fraction | +| Unsafe | Probably safe, may need manual verification | Complex refactorings | + +### Common Lint Rules + +| Rule | Description | +|------|-------------| +| `unused_variable` | Detects unused variables (fixable: prefix with `_`) | +| `unused_function` | Detects unused non-public functions | +| `function_naming` | Enforces snake_case for function names | +| `variable_naming` | Enforces snake_case for variable names | +| `type_naming` | Enforces PascalCase for type names | +| `deep_nesting` | Warns on excessive nesting depth (NASA PoT Rule 1) | +| `low_assertion_density` | Warns when code lacks assertions (NASA PoT Rule 5) | +| `prefer_fraction_turns` | Suggests exact fractions over decimals (fixable) | +| `prefer_turns_over_radians` | Suggests turns over radians for precision | + +## Parallelism Analysis + +Analyze Zlup programs for parallelism opportunities: + +```bash +# Analyze a program +zlup analyze program.zlp + +# JSON output for tooling +zlup analyze program.zlp --format json + +# Verbose output with dependency graph +zlup analyze program.zlp --verbose + +# Read from stdin +cat program.zlp | zlup analyze - +``` + +The analyzer identifies: +- Qubit allocator lifetimes and scopes +- Operation dependencies (qubit and data dependencies) +- Parallel execution layers (operations that can run simultaneously) + +## Expression Evaluation + +Quick evaluation of expressions for experimentation: + +```bash +# Evaluate arithmetic +zlup eval "2 + 3 * 4" # 14 + +# Evaluate comparisons +zlup eval "10 > 5" # true + +# Strings +zlup eval '"hello"' # "hello" + +# Read from stdin +echo "100 / 4" | zlup eval - + +# Verbose mode (show AST) +zlup eval --verbose "1 + 2" +``` + +## Project Configuration + +Zlup projects use a `zlup.toml` file for configuration: + +```toml +[package] +name = "my-quantum-program" +version = "0.1.0" +entry = "main.zlp" +description = "A quantum application" +authors = ["Alice"] + +[build] +strict = false # Enable NASA Power of 10 strict checks +target = "slr" # Default: "slr" or "hugr" +output_dir = "build" # Output directory +``` + +## Compilation Targets + +Zlup can compile to multiple targets: + +``` +Zlup Source (.zlp) + | + v + Zlup AST + | + +---+---+ + | | + v v +SLR-AST HUGR + | | + v v + Guppy Native + QASM Execution +``` + +| Target | Description | +|--------|-------------| +| `slr` | SLR-AST JSON for Python/PECOS bridge (default) | +| `qasm` | OpenQASM 2.0 output | +| `hugr` | HUGR IR for native execution | + +## Debugging + +### Parse AST + +Dump the parsed AST for debugging and development: + +```bash +# Debug format (Rust Debug output) +zlup parse program.zlp + +# JSON format +zlup parse --format json program.zlp +zlup parse -f json program.zlp + +# Read from stdin +echo 'fn main() -> unit { return; }' | zlup parse - +``` diff --git a/exp/zlup/docs/design.md b/exp/zlup/docs/design.md new file mode 100644 index 000000000..555b9c01a --- /dev/null +++ b/exp/zlup/docs/design.md @@ -0,0 +1,1684 @@ +# Zlup: Zig Semantics with Rust/Python Syntax + +## Status + +> **EXPERIMENTAL / EXPLORATORY** - Zlup is a research experiment exploring whether a quantum language with these design goals is feasible. It is not a production language—do not use it for real projects. The value is in what we learn from building it. + +--- + +## Overview + +Zlup is the **low-level reflection of Guppy** in the PECOS ecosystem. Where Guppy provides a high-level, Pythonic experience with linear types for safety, Zlup explores the opposite end of the design spectrum: **simple, explicit, low-level control**. + +### The Guppy-Zlup Duality + +| Concern | Guppy | Zlup | +|---------|-------|------| +| **Abstraction** | High-level, hide complexity | Low-level, expose control | +| **Safety model** | Linear type system | Simplicity + constraints | +| **Resource management** | Implicit (type system tracks) | Explicit (allocators) | +| **Target audience** | QEC researchers, algorithm developers | Low-level QEC developers, systems programmers | +| **Integration** | Python-embedded | Rust-native | +| **Philosophy** | "Make it easy" | "Make it clear" | + +### When to Use Zlup + +Zlup is designed for **quantum orchestration and Rust integration**: + +- **Quantum control flow**: Gate sequences, syndrome extraction, correction application +- **FFI to Rust backends**: Calling decoders, simulators, and classical algorithms +- **Simulation infrastructure**: Noise modeling, error injection, state tracking +- **Compilation target**: Guppy programs can target Zlup for Rust execution + +**Complex classical algorithms (MWPM, ML decoders, etc.) belong in native code**—Rust is preferred for safety, but C/C++/Zig are supported via FFI (C ABI). Zlup orchestrates the quantum side and calls native backends for heavy classical computation. + +To make Rust integration ergonomic, Zlup provides the **`zlup-ffi` crate**—a Rust library with: +- `Decoder`, `NoiseModel`, `Simulator` traits that decoders and backends implement +- `#[zlup_export]` proc macro that generates C ABI wrappers automatically +- FFI-safe types (`PackedBits`, `QubitId`, `GateType`) for type-safe interop +- `zlup bindgen` command to generate Zlup declarations from Rust code + +See [docs/rust-integration.md](rust-integration.md) for the full guide. + +Most QEC researchers who prefer Python workflows should use **Guppy**. Zlup is for the systems layer that connects quantum operations to classical backends. + +Design influences include: + +- **Rust's safety goals**: Inspired by Rust's commitment to safety, but pursuing it through + simplicity and constraints rather than a complex type system +- **More constrained than Zig**: Zig demonstrates that expressivity doesn't require complexity— + Zlup pushes further, adding constraints so that complex safety mechanisms become unnecessary +- **Rust/Python syntax**: Familiar surface syntax for broader accessibility +- **NASA Power of 10**: Bounded loops, fixed resource limits, predictable execution + +### Safety Through Constraints, Not Complexity + +The core philosophy: **if the language is constrained enough, you don't need sophisticated type systems for safety**. + +| Approach | How It Achieves Safety | Complexity | +|----------|------------------------|------------| +| Rust | Borrow checker, lifetimes, ownership | High (but powerful) | +| Guppy | Linear types | Medium-high | +| Zig | Explicit, no hidden behavior | Medium | +| **Zlup** | Constraints + simplicity | Low | + +Zlup explores whether scoping rules, bounded resources, and explicit control flow can be designed so that: +- The "obvious" code is the safe code +- Unsafe patterns are structurally impossible, not just flagged by a type checker +- You don't need to understand complex type theory to write correct programs + +This is **explicit over implicit**—no magic, no hidden behavior, no surprises. The constraints aren't limitations on expressivity; they're guardrails that make unsafe patterns impossible to express in the first place. + +Zlup compiles to multiple targets: + +- **SLR-AST**: JSON bridge enabling integration with Guppy and Python/PECOS +- **HUGR**: For hardware backends (shared with Guppy) +- **PHIR**: For simulator targeting (future) + +--- + +## Design Principles + +- **Expressivity through simplicity**: Powerful programs without complexity or magic +- **Safety through constraints**: Restrictions that make unsafe patterns impossible +- **Reliability and predictability**: Bounded, analyzable programs for QEC + +--- + +## Relationship to Guppy + +Zlup complements Guppy by exploring a different point in the design space. Guppy's +linear type system provides strong safety guarantees with an elegant Python-embedded +experience. Zlup explores whether similar safety can be achieved through explicit +allocator-based resource management, which may be useful for: + +- Rust-native simulation workflows +- Cases where explicit low-level control is preferred +- Compiling Guppy programs to a Rust-friendly representation + +| Aspect | Guppy | Zlup | +|-------------------|--------------------------|----------------------| +| Type safety | Linear types | Allocator model | +| Integration | Python-embedded | Standalone / Rust | +| Abstraction | Higher-level | Lower-level | +| Resource tracking | Type system | Structural lifetimes | +| Primary use | User-facing programs | Compilation target | + +### Why Explore This Approach? + +Zlup is inspired by Rust's commitment to safety but pursues it through simplicity and +constraints. Where Rust achieves safety through a sophisticated type system, Zlup explores +whether similar guarantees can emerge from a simpler language with stricter constraints. + +Zig's design principles—particularly its commitment to no magic and its demonstration that +expressivity doesn't require complexity—align well with this goal. Zlup may push even +further toward simplicity given the NASA Power of 10 emphasis, while preserving the +expressivity needed for complex QEC algorithms. + +Zig's principles map well to quantum computing needs: + +| Zig Principle | Quantum Application | +|-----------------------|-----------------------------------------| +| No hidden allocations | Explicit qubit lifecycle | +| Comptime | Circuit generation, parameterized codes | +| Simple and explicit | Readable QEC protocols | +| Safety without GC | Resource safety via allocators | + +### NASA Power of 10 Alignment + +Zlup follows NASA's Power of 10 rules—designed for mission-critical systems where failure is not an option. **Production QEC infrastructure has the same requirements**: a subtle bug in a decoder, an unbounded loop during syndrome processing, or unexpected memory allocation could corrupt an entire quantum computation. + +These constraints aren't limitations—they're what mature, reliable QEC infrastructure demands: + +| Rule | Zlup Implementation | +|--------------------------------|-------------------------------------------| +| 1. Simple control flow | No goto, no while, no recursion | +| 2. Fixed loop bounds | Only bounded `for i in 0..n` loops | +| 3. No dynamic alloc after init | Base allocator in main, children derive | +| 4. Small functions | Encouraged by module system | +| 5. Assertions | Built-in `test` blocks, comptime checks | +| 6. Minimal scope | Block scoping, automatic release | +| 7. Check returns | Error unions, optional types | +| 8. Limited preprocessor | Comptime replaces macros | +| 9. Limited pointers | Allocator refs, not raw pointers | +| 10. All warnings | Strict mode enabled by default | + +**Safe by Constraint**: Zlup enforces safety unconditionally—recursion is always forbidden, references to local variables cannot escape functions, and dangling pointers are structurally impossible. These aren't optional "strict mode" checks; they're fundamental to the language's memory model. + +--- + +## Language Design + +### Core Concepts + +#### 1. Allocator-Based Qubit Management + +```zlup +pub fn main() -> unit { + // Simple case: gates don't require mut + q := qalloc(4); + pz q; + h q[0]; + cx (q[0], q[1]); + return; +} + +pub fn partitioned_example() -> unit { + // Partitioning: mut required on parent for .child() + mut base := qalloc(100); + + // Children track resources from parent (base is mutated) + // but children themselves don't need mut for gate application + data := base.child(9); + ancilla := base.child(8); + + pz data; + h data[0]; + cx (data[0], ancilla[0]); + + // Automatic release when scope ends + return; +} +``` + +#### 2. Two Slot States + +Qubits have exactly two states (simplicity): + +``` +┌────────────┐ prepare() ┌──────────┐ +│ unprepared │ ──────────> │ prepared │ +└────────────┘ └──────────┘ + ^ │ + │ mz() │ + └──────────────────────────┘ +``` + +- **unprepared**: Initial state, or after measurement +- **prepared**: Ready for gate operations + +Gates on unprepared slots are compile-time errors. + +#### 3. Comptime Metaprogramming + +```zlup_nocheck +// Parameterized surface code +pub fn surface_code(comptime distance: u32) -> type { + num_data := distance * distance; + num_ancilla := (distance - 1) * (distance - 1) * 2; + + return struct { + data: qalloc(num_data), + ancilla: qalloc(num_ancilla), + + // Self is implicitly available (Rust-style) + + pub fn init(base: *Alloc) -> Self { + return Self { + data: base.child(num_data), + ancilla: base.child(num_ancilla), + }; + } + + pub fn syndrome_round(&mut self) -> [num_ancilla]bit { + pz self.ancilla; + + // Compile-time loop unrolling + inline for i in 0..num_ancilla / 2 { + self.measure_x_stabilizer(i); + } + inline for i in 0..num_ancilla / 2 { + self.measure_z_stabilizer(i); + } + + // return mz([num_ancilla]u1) self.ancilla[..]; + } + }; +} + +// Usage +pub fn main() -> unit { + mut base := qalloc(50); + mut code := surface_code(3).init(&base); + + for _ in 0..1000 { + syndrome := code.syndrome_round(); + // decode and correct... + } +} +``` + +#### 4. Error Handling + +Zlup follows Zig's error-as-values philosophy with extensions for quantum error correction. +The design is **explicit over implicit** — you must acknowledge every potential error, +aligning with NASA Power of 10 requirements for checking all return values. + +##### Faults vs Errors + +Zlup distinguishes **faults** from **errors** based on their origin and typical handling pattern: + +| Aspect | Fault | Error | +|--------|-------|-------| +| **Origin** | Quantum hardware (physical layer) | Classical logic (software layer) | +| **Nature** | Expected imperfection we're designed to handle | Unexpected problem that means something is wrong | +| **Handling** | Collect for later analysis (QEC pattern) | Stop execution immediately | +| **Keyword** | `fault` | `error` | + +The distinction is not about severity — a fault can absolutely cause a logical error. Rather, +it's about **where the problem originates** and **how we typically want to handle it**: + +- **Faults happen at the physical layer.** The quantum hardware did something imperfect: + a gate didn't apply cleanly, a qubit leaked, a measurement was noisy. These are expected — + QEC is designed to handle a certain fault rate. Stopping on every fault would make + error correction impossible. A QEC round might collect 50 faults and that's fine; + that's what error correction is for. + +- **Errors happen at the logical layer.** Something in the classical algorithm went wrong: + the decoder couldn't find a valid correction, a file wasn't found, an invalid state was + reached. These indicate either a bug or an unrecoverable situation. If the decoder says + "I can't figure out a correction," that's an error — the logical algorithm failed. + +**Mental model:** +- Faults = "expected badness we're designed to handle" +- Errors = "unexpected badness that means something is wrong" + +**Quantum faults** — physical events from the quantum hardware: +```zlup +// Use `fault` keyword for quantum/physical faults +QuantumFault := fault { + Leakage, + QubitLoss, + GateFailure, +}; +``` + +**Classical errors** — logical problems in the software: +```zlup +// Use `error` keyword for classical/logical errors +DecodeError := error { + SyndromeAmbiguous, + WeightTooHigh, +}; + +IoError := error { + FileNotFound, + PermissionDenied, +}; +``` + +| Category | Keyword | Behavior in `try` | Behavior in `try!` | Examples | +|----------|---------|-------------------|-------------------|----------| +| Quantum Fault | `fault` | Collected, continues | Stops immediately | Leakage, qubit loss, gate failure | +| Classical Error | `error` | Stops immediately | Stops immediately | Decoder failure, I/O error, invalid state | + +##### Explicit Handling Required + +Unlike languages that allow exceptions to propagate silently or errors to be ignored, +Zlup requires **explicit handling of both faults and errors**. This aligns with NASA Power +of 10 Rule 7: "Check the return value of all non-void functions." + +**Learning from Rust's `?` operator criticism:** + +Rust has been criticized for making error propagation *too easy*. The `?` operator lets +developers mechanically propagate errors without thinking about them: + +```rust +// Rust: ? makes it easy to just bubble errors up without thought +fn do_something() -> Result { + let x = step1()?; // Just propagate, don't think + let y = step2()?; // Just propagate, don't think + let z = step3()?; // Just propagate, don't think + Ok(z) +} +``` + +While this is "explicit" in that you must write `?`, it becomes so automatic that +developers stop thinking about error handling. The errors bubble up, but nobody along +the way considered what to do about them. + +**Zlup's approach: deliberate handling over mechanical propagation** + +In QEC, errors and faults carry crucial diagnostic information. Mechanical propagation +loses context and makes debugging difficult. Zlup encourages *deliberate* handling: + +```zlup_nocheck +// INVALID: ignoring the return value +qec_round(q); // Compile error: unhandled faults/errors + +// DISCOURAGED: mechanical propagation without thought +// (Zlup intentionally doesn't have a `?` operator) + +// ENCOURAGED: deliberate handling with context +faults, result := qec_round(q); +result catch |err| { + log("QEC round failed at step {}: {}", step, err); + log("Faults before failure: {}", faults); + return err; // Propagate, but with added context +}; + +// VALID: explicitly discard if truly not needed +_ = qec_round(q); // Explicit discard - you meant to ignore it +``` + +The absence of a `?`-like operator is intentional. When you propagate an error, Zlup +wants you to think about it — add context, log diagnostics, or transform it. This +makes error handling visible, auditable, and meaningful rather than mechanical. + +**Why this matters for quantum computing:** + +In classical computing, a propagated error eventually reaches a handler somewhere. +In quantum computing, by the time an error surfaces, the quantum state may be +irretrievably corrupted. Understanding *where* and *why* things went wrong is +essential for: +- Debugging QEC implementations +- Tuning error thresholds +- Identifying systematic hardware issues +- Post-mortem analysis of failed computations + +Silent or mechanical error propagation loses this crucial information. + +##### Promoting Faults to Errors + +Sometimes accumulated faults cross a threshold where they should become a logical error. +Zlup supports **promoting faults to errors** when the situation warrants it: + +```zlup_nocheck +fn qec_round(q: []qubit) try -> ([]QuantumFault, QecError!Syndrome) { + // Run the circuit, collecting faults + faults, syndrome := run_stabilizers(q); + + // Too many faults? Promote to a classical error + if (faults.len > max_correctable) { + return error.TooManyFaults; // Stops execution, returns collected faults + } + + // Faults within tolerance - continue + return syndrome; +} + +// Caller receives both the faults that occurred AND the error/result +faults, result := qec_round(q); + +result catch |err| { + // err might be TooManyFaults - we still have access to `faults` + // to see what happened before the threshold was crossed + log("QEC failed with {} faults: {}", faults.len, err); + return; +}; +``` + +This pattern allows: +- **Graceful degradation**: Collect faults until a threshold, then fail cleanly +- **Diagnostic information**: Even on failure, you know what faults occurred +- **Policy flexibility**: Different QEC codes can set different thresholds + +The key insight: faults don't automatically become errors. Your code decides when +accumulated faults constitute a logical failure, making the policy explicit and tunable. + +##### Return Type Syntax + +```zlup_nocheck +// Single error union: either E or T (Zig style) +E!T + +// Quantum faults + classical errors with value (QEC pattern) +// Returns: ([]QuantumFault, ClassicalError!T) +([]QuantumFault, DecodeError!T) +``` + +##### Two Error Handling Modes + +**`try!` — Stop on First Error/Fault (Traditional/Strict)** + +Matches Zig/Rust semantics. Any error or fault stops execution immediately. + +```zlup_nocheck +fn strict_circuit(q: []qubit) try! -> QuantumFault!unit { + h q[0]; // if fault occurs, return immediately + cx (q[0], q[1]); // only runs if h succeeded +} + +// Caller handles single fault +strict_circuit(q) catch |fault| { + log("Failed: {} on qubit {}", fault.type, fault.qubit); +}; +``` + +**`try` — Collect Faults, Stop on Errors (QEC Pattern)** + +Quantum-specific extension: +- Quantum faults: collected into array, execution continues +- Classical errors: stops execution, returns collected faults + error + +##### Summary: Behavior by Mode + +| Mode | Quantum Fault | Classical Error | Return Type | +|------|---------------|-----------------|-------------| +| `try!` | **Stops immediately** | **Stops immediately** | `E!T` | +| `try` | Collected, continues | Stops, returns collected faults | `([]QuantumFault, ClassicalE!T)` | + +```zlup_nocheck +fn qec_round(q: []qubit) try -> ([]QuantumFault, DecodeError!Syndrome) { + // Quantum faults - collected, continues + cx (q[0], q[1]); // Leakage detected → recorded, keeps going + cx (q[1], q[2]); // QubitLoss detected → recorded, keeps going + + syndrome := mz([2]u1) [q[3], q[4]]; + + // Classical error - stops execution, returns collected faults + correction := decode(syndrome); // WeightTooHigh → STOP + + apply(correction, q); + return syndrome; +} +``` + +##### Caller Side + +```zlup_nocheck +faults, result := qec_round(q); + +// faults: []QuantumFault - all quantum faults detected during execution +// result: DecodeError!Syndrome - either error that stopped us, or final value + +result catch |err| { + log("Classical error: {}", err); + log("Quantum faults before failure: {}", faults); + return; +}; + +// Success path - unwrap result +syndrome := result.!; +if (faults.len > 0) { + // Faults may or may not have caused logical errors + corrections := analyze_faults(faults); + apply_corrections(q, corrections); +} +``` + +##### Block Syntax + +Error handling can also be scoped to blocks within functions: + +```zlup_nocheck +fn complex_circuit(q: []qubit) -> unit { + // Strict section - any error stops + try! { + prepare_logical_zero(q); + logical_h(q); + } catch |err| { + abort("Logical prep failed: {}", err); + } + + // QEC section - soft errors collected, hard errors stop + soft_errors, result := try { + cx (q[0], q[3]); + cx (q[1], q[3]); + syndrome := mz([2]u1) [q[3], q[4]]; + decode(syndrome) // hard error stops here + }; + + result catch |hard_err| { + log("Decode failed: {}", hard_err); + return; + }; + + if (soft_errors.len > 0) { + apply_corrections(q, soft_errors); + } +} +``` + +##### Rich Error Context + +Errors automatically carry context (compiler-injected): + +```zlup_nocheck +soft_errors, result := qec_round(q); + +for (soft_errors) |err| { + switch (err) { + .Leakage => |ctx| { + log("Leakage in {} on qubit {}", ctx.gate, ctx.qubit); + reset_qubit(q[ctx.qubit]); + }, + .QubitLoss => |ctx| { + log("Lost qubit {} during {}", ctx.qubit, ctx.gate); + flag_qubit_lost(ctx.qubit); + }, + else => log_error(err), + } +} +``` + +##### Classical-Only Functions + +Functions without quantum operations use standard Zig-style error handling: + +```zlup_nocheck +fn decode(syndrome: []const bit) -> DecodeError!Correction { + if (weight(syndrome) > threshold) { + return error.WeightTooHigh; + } + return compute_correction(syndrome); +} + +fn run_with_recovery(code: *SurfaceCode) -> unit { + syndrome := code.syndrome_round(); + + correction := decode(syndrome) catch |err| switch (err) { + error.WeightTooHigh => Correction.identity, + error.SyndromeAmbiguous => { + return run_with_recovery(code); + }, + }; + + apply(correction, code.data); +} +``` + +##### Explicit Returns Required + +Zlup requires the `return` keyword for all function returns. Unlike Rust, which uses +implicit trailing expressions (the last expression without a semicolon becomes the +return value), Zlup makes returns explicit: + +```zlup_nocheck +// INVALID in Zlup (valid in Rust): implicit return +fn add(a: i32, b: i32) -> i32 { + a + b // Rust would return this implicitly - Zlup requires explicit return +} + +// VALID in Zlup: explicit return +fn add(a: i32, b: i32) -> i32 { + return a + b; +} +``` + +**Why require explicit returns?** + +1. **Clarity of intent**: An explicit `return` makes it unambiguous that you intend + to exit the function with a value. In Rust, forgetting a semicolon can accidentally + change a statement into a return expression. + +2. **Consistency**: Every return looks the same, whether it's at the end of the function, + in the middle, or inside a conditional. No special rules for "trailing position." + +3. **NASA Power of 10 alignment**: Rule 1 emphasizes simple control flow. Explicit + returns make control flow obvious — you can grep for `return` to find all exit points. + +4. **Error handling clarity**: When combined with error handling, explicit returns + make it clear what value is being returned: + +```zlup_nocheck +fn qec_round(q: []qubit) try -> ([]QuantumFault, DecodeError!Syndrome) { + faults, syndrome := run_stabilizers(q); + + if (faults.len > threshold) { + return error.TooManyFaults; // Clearly returning an error + } + + return syndrome; // Clearly returning success value +} +``` + +5. **Auditable code**: In safety-critical quantum computing, code reviewers can easily + verify that every code path has an explicit return with the correct type. + +**Block expressions vs function returns:** + +Note that block expressions (like `if` expressions used for assignment) can still +evaluate to values — the explicit return requirement applies to *function* returns: + +```zlup_nocheck +// Block expression for assignment - this is fine +x := if (condition) { 42 } else { 0 }; + +// But function must use explicit return +fn get_value(condition: bool) -> i32 { + return if (condition) { 42 } else { 0 }; +} +``` + +**Unit functions require explicit return:** + +All functions must explicitly return their value, including unit functions. For unit +functions, `return;` is shorthand for `return unit;` and is the preferred style: + +```zlup_nocheck +// INVALID: unit function without return +fn do_work() -> unit { + process_data(); +} + +// VALID: unit function with explicit return +fn do_work() -> unit { + process_data(); + return; // Preferred: return; is shorthand for return unit; +} +``` + +This requirement serves several purposes: + +1. **Uniform control flow**: Every function has an explicit exit point, making code flow + analysis and review easier. + +2. **Prevent accidental fallthrough**: Without explicit returns, it's easy to forget that + control reaches the end of a function. With `return;`, you're forced to think about it. + +3. **NASA Power of 10 compliance**: All control flow is explicit. There's no implicit + "fall off the end" behavior. + +4. **Exception**: Only `never` functions (functions that never return normally, like + `panic()` or `abort()`) are exempt from this requirement. + +> **Note:** `return;` without a value is only valid in functions returning `unit`. +> Using `return;` in a function with a non-unit return type is a compile error. + +> **Implementation note:** The semantic analyzer should enforce that: +> 1. All functions have explicit `return` statements on all code paths +> 2. Trailing expressions in function bodies are not treated as implicit returns +> 3. Missing returns produce clear compile-time errors +> 4. `return;` is only allowed in unit-returning functions + +##### Design Rationale + +| Principle | Implementation | +|-----------|----------------| +| Explicit over implicit | Must use `try`/`try!`/`catch` — no silent error dropping | +| Explicit returns | Use `return` keyword, not implicit trailing expressions | +| NASA Power of 10 | All return values checked, errors are values | +| QEC-friendly | Quantum faults collected, classical errors stop execution | +| Faults vs Errors | `fault` for faults (physical), `error` for errors (logical) | +| Rich diagnostics | Faults/errors carry gate, qubit, and location context | +| Zig-aligned | `E!T` syntax, `catch` handling, error sets | + +#### 5. Module System + +```zlup_nocheck +// lib/qec/surface.zlp +std := @import("std"); + +pub Distance := enum(u32) { + d3 = 3, + d5 = 5, + d7 = 7, +}; + +pub SurfaceCode := struct { + distance: Distance, + data: Alloc, + ancilla: Alloc, + + pub fn init(base: *Alloc, distance: Distance) -> SurfaceCode { + d := @enumToInt(distance); + return .{ + distance, + data: base.child(d * d), + ancilla: base.child((d-1) * (d-1) * 2), + }; + } +}; + +// main.zlp +surface := @import("qec/surface.zlp"); + +pub fn main() -> unit { + mut base := qalloc(100); + mut code := surface.SurfaceCode.init(&base, .d5); + // ... +} +``` + +--- + +## Syntax Reference + +### Declarations + +Zlup uses `:=` (walrus operator) for type-inferred declarations and `: T =` for explicit types. + +```zlup_nocheck +// Constants (type inferred from value) +pi := 3.14159; // f64 inferred +num_qubits := 17; // integer inferred + +// Constants (explicit type) +pi: f64 = 3.14159; +num_qubits: u32 = 17; + +// Mutable variables +mut count := 0; // type inferred +mut count: u32 = 0; // explicit type +mut syndrome: [8]bit = undefined; + +// Type declarations (constructor makes type obvious) +Point := struct { x: f64, y: f64 }; +Color := enum { Red, Green, Blue }; +QubitError := error { Leakage, QubitLoss, GateFailure }; + +// Set literals +targets := set { q[0], q[1], q[2] }; + +// Public exports +pub Config := struct { ... }; +pub fn process() -> unit { ... } +``` + +#### Declaration Mental Model + +| Syntax | Meaning | +|--------|---------| +| `name := value` | Type inferred from value | +| `name: T = value` | Explicit type annotation | +| `mut name := value` | Mutable, type inferred | +| `mut name: T = value` | Mutable, explicit type | +| `Name := struct { }` | Type definition (constructor infers "type") | + +### Types + +```zlup_nocheck +// Primitives - arbitrary-width integers (1-128 bits, like Zig) +u1, u2, u3, ..., u128 // Unsigned N-bit integers +i1, i2, i3, ..., i128 // Signed N-bit integers +usize, isize // Pointer-sized integers +f32, f64 // Floats +a64 // Angle (backed by PECOS Angle64) +bool // Boolean +unit // Unit type (single value) + +// Quantum types +qubit // Single qubit (abstract) +bit // Classical bit (measurement result) +Alloc // Qubit allocator +qalloc(N) // Allocator with capacity N (comptime) +// Classical data uses standard array types: +[N]bit // Array of classical bits +[N]u8 // Array of bytes + +// Compound types +[N]T // Array of N elements of type T +[]T // Slice (runtime-sized view) +*T // Pointer to T +?T // Optional (T or none) +E!T // Error union (error E or value T) - Zig style +[]E!T // Collected errors plus value (QEC extension) +Set(T) // Unordered set of unique elements + +// User-defined +struct { ... } +enum { ... } +union(enum) { ... } +error { ... } // Error set definition +``` + +### Type Ascription + +Type ascription uses a space-separated postfix syntax, providing a clean and consistent way to specify types on expressions: + +```zlup +// Literal type ascription +x := 42 u32; // 42 as u32 +y := 100 i64; // 100 as i64 +pi := 3.14159 f64; // Float as f64 + +// Expression type ascription (evaluates, then converts) +half := 1/2 f64; // 0.5 (division yields float, then typed) +quarter := 1/4 f64; // 0.25 (non-exact division automatically floats) + +// Angle unit suffix (same pattern) +angle := 1/4 turns; // Quarter turn +theta := 3.14159 rad; // Radians +``` + +**Design rationale:** + +- **Unified syntax**: Type suffixes (`42 u32`), angle units (`1/4 turns`), and type ascription all use the same postfix pattern +- **Reads naturally**: "42 as u32", "one quarter turns" +- **No truncation surprises**: Integer division `1/4` produces `0.25` (float) when the result isn't exact, not `0` (truncated) +- **Explicit over implicit**: The type/unit is always visible at the expression site + +This differs from Zig's `@as(u32, 42)` builtin in favor of the more readable postfix form. + +### Angle Literals + +Angles require explicit units—no implicit radians or degrees: + +```zlup_nocheck +// Turns (native unit) - 1 turn = full rotation +rz(1/4 turns) q[0]; // Quarter turn +rz(1/8 turns) q[0]; // T gate (eighth turn) +rz(0.5 turns) q[0]; // Half turn (Z gate equivalent) + +// Radians (for those who prefer mathematical convention) +rz(std.f64.pi/4 rad) q[0]; // pi/4 radians = 1/8 turns +rz(std.f64.pi/2 rad) q[0]; // pi/2 radians = 1/4 turns + +// Fractions preferred over decimals (exact representation) +rz(1/4 turns) q[0]; // Exact +rz(0.25 turns) q[0]; // Also works, but 1/4 is clearer +``` + +**Design rationale:** + +- **Explicit units prevent bugs**: No confusion about radians vs degrees vs turns (Mars Climate Orbiter!) +- **Turns as native**: Common QEC angles are simple fractions (1/4, 1/8, 1/2) +- **Backed by PECOS Angle64**: Uses fixed-point representation that's exact for fractions of turns +- **Fractions encouraged**: `1/4 turns` is both more readable and more precise than `0.25 turns` + +**Angle64 Internal Representation:** + +The `a64` type uses a 64-bit fixed-point representation where the full range [0, 2^64) maps to [0, 1) turns: + +| Angle | Fixed-Point Value | Common Use | +|-------|------------------|------------| +| 0 turns | 0 | Identity | +| 1/8 turns | 2^61 | T-gate (π/4 rad) | +| 1/4 turns | 2^62 | S-gate (π/2 rad) | +| 1/2 turns | 2^63 | Z-gate (π rad) | + +This representation provides: +- **Exact arithmetic** for all dyadic fractions (powers of 2 in denominator) +- **Wrapping at full turn** via natural integer overflow +- **Efficient operations** using integer arithmetic +- **No floating-point precision issues** for common quantum angles + +Pre-defined constants in `zlup-ffi`: +```rust +Angle64::ZERO // 0 turns +Angle64::EIGHTH_TURN // 1/8 turns (T-gate) +Angle64::QUARTER_TURN // 1/4 turns (S-gate) +Angle64::HALF_TURN // 1/2 turns (Z-gate) +``` + +### Control Flow + +```zlup_nocheck +// Conditionals +if condition { + // ... +} else if other { + // ... +} else { + // ... +} + +// If as expression +max := if a > b { a } else { b }; + +// Bounded for loop (preferred - NASA Rule 2) +for i in 0..n { + process(i); +} + +// For with collection +for item in items { + use(item); +} + +// For with index (enumerate) +for i, item in items { + use(i, item); +} + +// Switch +switch (value) { + 0 => handle_zero(), + 1..10 => handle_small(), + else => handle_other(), +} + +// Labeled blocks (for break with value) +result := blk: { + if early_exit { break :blk default_value; } + break :blk computed_value; +}; +``` + +### Quantum Operations + +```zlup_nocheck +// Allocator operations +mut base := qalloc(100); // mut needed - will call .child() +data := base.child(9); // no mut needed - just applying gates +pz data; // Prepare all slots + +// Single-qubit gates (lowercase names) +h data[0]; +x data[1]; +z data[2]; + +// Parameterized gates with explicit angle units +rz(1/4 turns) data[0]; // Quarter turn (T² equivalent) +rz(1/8 turns) data[0]; // T gate angle +rx(1/4 turns) data[1]; // Quarter turn around X +rz(std.f64.pi/4 rad) data[0]; // Same as 1/8 turns, in radians + +// Two-qubit gates +cx (data[0], data[1]); +cz(ancilla[0], data[0]); + +// Batch operations with set semantics (unordered) +h { data[0], data[1], data[2] }; // Apply H to multiple qubits +cx { (data[0], data[1]), (data[2], data[3]) }; // Multiple CX gates + +// Typed measurements with array semantics (ordered) +r := mz(u1) data[0]; // Single qubit → u1 +results := mz([2]u1) [data[0], data[1]]; // Multiple qubits → [2]u1 (explicit size) +results := mz([4]u1) slice; // From slice (size must match) + +// Conditional operations +if r == 1 { + x data[1]; +} +``` + +#### Batch vs Array Semantics + +Zlup distinguishes between unordered and ordered operations: + +| Syntax | Semantics | Use Case | +|--------|-----------|----------| +| `{ }` | Set/unordered | Batch gates (order doesn't matter) | +| `[ ]` | Array/ordered | Measurements (result order matters) | + +```zlup_nocheck +// Batch gate: order doesn't matter, all applied "simultaneously" +h { q[0], q[1], q[2] }; + +// Measurement: order matters, results[0] corresponds to q[0] +results := mz([3]u1) [q[0], q[1], q[2]]; +``` + +### Tick Blocks (Parallel Layers) + +Tick blocks represent atomic time slices where operations execute in parallel. They act as **optimization barriers** - the optimizer cannot move operations across tick boundaries. + +```zlup_nocheck +// Basic tick block +tick { + h data[0]; + h data[1]; +} + +// Labeled tick +tick syndrome_round { + cx({(data[0], ancilla[0]), (data[1], ancilla[1])}); +} +``` + +**Note**: Nested tick blocks are disallowed. A tick represents an atomic time slice, and nesting would create ambiguity about timing semantics. Use sequential ticks instead: + +```zlup_nocheck +// Sequential ticks (correct) +tick layer1 { h({data[0], data[1]}); } +tick layer2 { cx({(data[0], data[2]), (data[1], data[3])}); } +``` + +### Attributes + +Metadata can be attached to ticks and gates: + +```zlup_fragment +// Single attribute +@attr(round, 0) +tick syndrome_check { + cx (data[0], ancilla[0]); +} + +// Multiple attributes +@attrs({round: 0, kind: "syndrome"}) +tick syndrome_check { + cx (data[0], ancilla[0]); +} + +// Gate attributes +@attrs({syndrome: "X", ancilla: true}) +cx (data[0], ancilla[0]); + +// Inline attributes on ticks +tick @attr(round, 1) layer1 { + h data[0]; +} +``` + +### Optimization Barriers + +Zlup provides several mechanisms to control optimization, particularly important for QEC where gate ordering and timing can affect error correction. + +#### Preserve Attributes + +These attributes prevent the optimizer from modifying or removing operations: + +| Attribute | Purpose | Use Case | +|-----------|---------|----------| +| `@preserve` | Prevent any optimization | Debugging, calibration sequences | +| `@timing` | Preserve timing relationships | Time-sensitive QEC protocols | +| `@identity` | Keep intentional identity operations | Noise characterization, benchmarking | +| `@noopt` | Disable all optimizations in scope | Development, debugging | + +```zlup_nocheck +// Prevent gate cancellation +@preserve +h q[0]; +@preserve +h q[0]; // Both H gates preserved, won't cancel + +// Preserve timing-critical sequence +@timing { + cx (data[0], ancilla[0]); + mz(u1) ancilla[0]; +} + +// Keep intentional identity +@identity { + x q[0]; + x q[0]; // Won't be optimized away +} + +// Disable optimization in block +@noopt { + h q[0]; + h q[0]; + z q[0]; // Nothing optimized +} +``` + +#### QEC Round Tracking + +The `@round(n)` attribute tracks QEC syndrome rounds, preventing gate cancellation across round boundaries: + +```zlup_nocheck +// Gates in different rounds won't cancel +@round(0) { + h ancilla[0]; + mz(u1) ancilla[0]; +} + +@round(1) { + h ancilla[0]; // Same gate, different round - won't cancel with round 0 + mz(u1) ancilla[0]; +} +``` + +**Design rationale**: In QEC, the same gate sequence may appear in multiple rounds, but each round's operations must execute independently. `@round(n)` prevents the optimizer from incorrectly combining gates across logical round boundaries. + +#### Tick Blocks as Barriers + +Tick blocks are always optimization barriers - operations cannot be moved across tick boundaries: + +```zlup_fragment +tick { + h q[0]; +} +// Optimizer cannot move this H into the tick above +h q[0]; +tick { + h q[0]; +} +``` + +This ensures that timing-critical sequences remain intact even when optimization is enabled. + +### Functions + +```zlup_nocheck +// Basic function +fn add(a: i32, b: i32) -> i32 { + return a + b; +} + +// Function with error return (Zig-style) +fn divide(a: f64, b: f64) -> error{DivByZero}!f64 { + if b == 0 { return error.DivByZero; } + return a / b; +} + +// Comptime parameters +fn make_array(comptime T: type, comptime N: usize) -> [N]T { + return [_]T{0} ** N; +} + +// Method syntax (Rust-style receivers) +Counter := struct { + value: u32, + + fn increment(&mut self) -> unit { + self.value += 1; + } +}; +``` + +### Error-Handling Functions + +```zlup_nocheck +// try! function: stop on first fault/error (traditional/strict) +fn strict_circuit(q: []qubit) try! -> QuantumFault!unit { + h q[0]; // if fault occurs, return immediately + cx (q[0], q[1]); // only runs if h succeeded +} + +// try! with return value +fn strict_measure(q: []qubit) try! -> QuantumFault![]u1 { + h q[0]; + mz([2]u1) [q[0], q[1]] +} + +// try function: collect faults, stop on errors (QEC pattern) +fn qec_round(q: []qubit) try -> []QuantumFault!unit { + cx (q[0], q[3]); // fault recorded, continues + cx (q[1], q[3]); // runs regardless of previous faults +} + +// try with return value +fn qec_measure(q: []qubit) try -> []QuantumFault!u1 { + cx (q[0], q[3]); + mz(u1) q[3] +} +``` + +### Error Handling + +```zlup_nocheck +// Catch single fault/error +strict_circuit(q) catch |fault| { + log("Fault: {}", fault); +}; + +// Destructure collected faults and value +faults, result := qec_measure(q); + +// Error blocks within functions +try! { + h q[0]; + cx (q[0], q[1]); +} catch |err| { + handle_error(err); +}; + +syndromes := try { + cx (q[0], q[3]); + cx (q[1], q[3]); +}; +``` + +--- + +## Implementation Architecture + +### Parsing Strategy: Recursive Descent + +We use recursive descent parsing rather than the visitor pattern for several reasons: + +1. **Simplicity**: Direct mapping from grammar to code +2. **Explicitness**: Clear control flow, no hidden dispatch +3. **Debuggability**: Easy to step through +4. **NASA Power of 10**: Predictable call structure + +```rust +// Example parser structure +impl Parser { + fn parse_program(&mut self) -> Result { + let mut decls = Vec::new(); + + while !self.at_end() { + decls.push(self.parse_top_level_decl()?); + } + + Ok(Program { declarations: decls }) + } + + fn parse_top_level_decl(&mut self) -> Result { + if self.check(Token::Const) { + self.parse_const_decl() + } else if self.check(Token::Var) { + self.parse_var_decl() + } else if self.check(Token::Fn) { + self.parse_fn_decl() + } else if self.check(Token::Struct) { + self.parse_struct_decl() + } else { + Err(self.error("expected declaration")) + } + } + + fn parse_statement(&mut self) -> Result { + // Direct dispatch based on current token + match self.current().kind { + Token::Const => self.parse_const_decl().map(Statement::Const), + Token::Var => self.parse_var_decl().map(Statement::Var), + Token::If => self.parse_if_stmt(), + Token::For => self.parse_for_stmt(), + Token::Return => self.parse_return_stmt(), + _ => self.parse_expr_stmt(), + } + } + + // ... etc +} +``` + +### Compilation Pipeline + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Zlup Source (.zlp) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Lexer (pest grammar → tokens) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Recursive Descent Parser → Zlup AST (Rust) │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────┐ +│ Semantic Analysis │ +│ - Type checking │ +│ - Allocator validation (capacity, lifecycle) │ +│ - Qubit state validation (unprepared/prepared) │ +│ - Comptime evaluation │ +└───────────────────────────────┬─────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ +│ SLR-AST Path │ │ HUGR Path │ │ PHIR Path │ +│ (Python) │ │ (Experiments) │ │ (Simulators) │ +│ │ │ │ │ │ +│ Zlup AST │ │ Zlup AST │ │ Zlup AST │ +│ │ │ │ │ │ │ │ │ +│ ▼ │ │ ▼ │ │ ▼ │ +│ SLR-AST │ │ HUGR │ │ PHIR │ +│ │ │ │ │ │ │ │ │ +│ ▼ │ │ ▼ │ │ ▼ │ +│ ┌──────────┐ │ │ ┌──────────┐ │ │ ┌──────────┐ │ +│ │ Guppy │ │ │ │ Hardware │ │ │ │ PECOS │ │ +│ │ codegen │ │ │ │ backends │ │ │ │ sims │ │ +│ │ QASM │ │ │ │ TKET2 │ │ │ │ │ │ +│ │ codegen │ │ │ └──────────┘ │ │ └──────────┘ │ +│ └──────────┘ │ │ │ │ │ +└───────────────────┘ └───────────────────┘ └───────────────────┘ +``` + +### HUGR vs PHIR: MLIR-Inspired IRs + +Both HUGR and PHIR are MLIR-inspired intermediate representations, but target different use cases: + +**HUGR (Hierarchical Unified Graph Representation)** +- Used by TKET2 compiler and Guppy +- Targets hardware/experimental backends +- Rich optimization framework +- Serializable for tool interop + +**PHIR (Program Hierarchical IR)** +- Targets simulator backends +- Optimized for simulation semantics +- Used by PECOS simulation engines + +Compiling to both enables: +1. **Hardware path**: Zlup → HUGR → TKET2 → Hardware +2. **Simulation path**: Zlup → PHIR → PECOS simulators +3. **Python path**: Zlup → SLR-AST → Guppy/QASM + +### AST Design (Rust) + +The Rust AST mirrors the Python SLR-AST for easy conversion: + +```rust +// src/ast.rs + +#[derive(Debug, Clone, PartialEq)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, + pub file: Option, +} + +#[derive(Debug, Clone)] +pub struct Program { + pub name: String, + pub declarations: Vec, + pub location: Option, +} + +#[derive(Debug, Clone)] +pub enum Declaration { + Const(ConstDecl), + Var(VarDecl), + Fn(FnDecl), + Struct(StructDecl), + Enum(EnumDecl), + Allocator(AllocatorDecl), +} + +#[derive(Debug, Clone)] +pub struct AllocatorDecl { + pub name: String, + pub capacity: u32, + pub parent: Option, + pub location: Option, +} + +#[derive(Debug, Clone)] +pub enum Statement { + Const(ConstDecl), + Var(VarDecl), + Assign(AssignStmt), + If(IfStmt), + While(WhileStmt), + For(ForStmt), + Return(ReturnStmt), + Break(BreakStmt), + Continue(ContinueStmt), + Defer(DeferStmt), + Block(Block), + Expr(ExprStmt), + + // Quantum operations + Gate(GateOp), + Prepare(PrepareOp), + Measure(MeasureOp), +} + +#[derive(Debug, Clone)] +pub struct GateOp { + pub kind: GateKind, + pub targets: Vec, + pub params: Vec, + pub location: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateKind { + // Single-qubit + X, Y, Z, H, T, Tdg, + SX, SY, SZ, SXdg, SYdg, SZdg, + RX, RY, RZ, + RXY1q, + + // Two-qubit + CX, CY, CZ, CH, + SXX, SYY, SZZ, SXXdg, SYYdg, SZZdg, + RZZ, + + // Face rotations + F, Fdg, F4, F4dg, +} + +// ... etc +``` + +--- + +## Example Programs + +### Hello Quantum World + +```zlup +// hello.zlp +pub fn main() -> unit { + q := qalloc(2); // no mut needed - just applying gates + pz q; + + // Create Bell state + h q[0]; + cx (q[0], q[1]); + + // Measure with explicit type + results := mz([2]u1) [q[0], q[1]]; + return; +} +``` + +### Teleportation Protocol + +```zlup_nocheck +// teleport.zlp +pub fn main() -> unit { + mut base := qalloc(10); + + // Prepare initial state + mut psi := base.child(1); + pz psi; + ry(0.7) psi[0]; // Some arbitrary state + + // Create EPR pair + mut epr := base.child(2); + pz epr; + h epr[0]; + cx (epr[0], epr[1]); + + // Bell measurement + cx (psi[0], epr[0]); + h psi[0]; + + m1 := mz(u1) psi[0]; + m2 := mz(u1) epr[0]; + + // Classical corrections + if m2 == 1 { x epr[1]; } + if m1 == 1 { z epr[1]; } + + // epr[1] now holds the teleported state +} +``` + +### Surface Code QEC + +```zlup_nocheck +// surface_qec.zlp +std := @import("std"); + +pub fn SurfaceCode(comptime distance: u32) -> type { + d := distance; + num_data := d * d; + num_x_ancilla := (d - 1) * d; + num_z_ancilla := d * (d - 1); + num_ancilla := num_x_ancilla + num_z_ancilla; + + return struct { + data: qalloc(num_data), + ancilla: qalloc(num_ancilla), + syndrome: [num_ancilla]bit = undefined, + + pub fn init(base: *Alloc) -> Self { + return .{ + data: base.child(num_data), + ancilla: base.child(num_ancilla), + syndrome: undefined, + }; + } + + pub fn prepare_logical_zero(&mut self) -> unit { + pz self.data; + // All data qubits start in |0⟩ + } + + pub fn syndrome_round(&mut self) -> unit { + pz self.ancilla; + + // X stabilizers + inline for i in 0..num_x_ancilla { + a := i; + h self.ancilla[a]; + + // Connect to neighboring data qubits + neighbors := comptime x_stabilizer_neighbors(i); + inline for n in neighbors { + cx (self.ancilla[a], self.data[n]); + } + + h self.ancilla[a]; + } + + // Z stabilizers + inline for i in 0..num_z_ancilla { + a := num_x_ancilla + i; + + neighbors := comptime z_stabilizer_neighbors(i); + inline for n in neighbors { + cx (self.data[n], self.ancilla[a]); + } + } + + // Measure all ancilla (typed measurement) + // self.syndrome = mz([num_ancilla]u1) self.ancilla[..]; + } + + pub fn decode_and_correct(&mut self) -> unit { + correction := mwpm_decode(self.syndrome); + apply_correction(self.data, correction); + } + + // Comptime helper functions + fn x_stabilizer_neighbors(comptime i: u32) -> [4]u32 { + // ... compute neighbors at compile time + } + + fn z_stabilizer_neighbors(comptime i: u32) -> [4]u32 { + // ... compute neighbors at compile time + } + }; +} + +pub fn main() -> unit { + mut base := qalloc(50); + mut code := SurfaceCode(3).init(&base); + + code.prepare_logical_zero(); + + // QEC rounds + for round in 0..1000 { + code.syndrome_round(); + code.decode_and_correct(); + + // Optional: inject errors for testing + if round % 100 == 0 { + x code.data[0]; // Inject X error + } + } + + // Final logical measurement + // logical_result := mz([num_data]u1) code.data[..]; +} +``` + +--- + +## Implementation Plan + +### Phase 1: Core Language (MVP) ✓ COMPLETE + +1. **Lexer/Parser**: Pest grammar → Rust AST ✓ +2. **Basic types**: Integers, bools, arrays, a64 angles ✓ +3. **Control flow**: if, for (bounded only, no while) ✓ +4. **Functions**: Basic fn declarations ✓ +5. **Quantum ops**: Gates, typed measurements, allocators ✓ +6. **Batch operations**: Set literals for parallel gates ✓ +7. **Tick blocks**: Parallel layers with labels (nesting disallowed) ✓ +8. **Attributes**: `@key(value)` metadata on ticks/gates ✓ +9. **Optimization**: Constant folding, dead code elimination, gate cancellation ✓ +10. **Optimization barriers**: `@preserve`, `@timing`, `@identity`, `@noopt`, `@round(n)` ✓ +11. **Build system**: `build.zlp` infrastructure with targets and steps ✓ + +### Phase 2: Type System ✓ COMPLETE + +1. **Type checking**: Basic static type analysis ✓ +2. **Allocator validation**: Capacity and lifecycle ✓ +3. **Qubit state tracking**: Compile-time state validation ✓ +4. **Error unions**: Zig-style `E!T` with `catch` and `.!` unwrap ✓ +5. **Collected errors**: QEC-style `[]E!T` for fault collection ✓ +6. **Try blocks**: `try { }` (collect) and `try! { }` (propagate) syntax ✓ +7. **Try functions**: `fn foo() try -> []E!T` syntax ✓ +8. **Fault sets**: Quantum-specific `fault { Leakage, QubitLoss }` ✓ + +### Phase 3: Comptime ✓ COMPLETE + +1. **Inline for loop unrolling**: `inline for i in 0..N { }` unrolls at compile time ✓ + - Semantic validation: errors for non-comptime ranges, break/continue in inline for + - Optimization pass: variable substitution, recursive unrolling for nested loops +2. **Advanced builtins**: Type reflection with snake_case naming ✓ + - `@type_info(T)` - returns structured type information + - `@field_names(T)` - returns array of struct field names + - `@enum_fields(T)` - returns array of enum variant names + - `@type_from_info(info)` - constructs type from TypeInfo struct +3. **Generic type instantiation**: Functions with comptime params get specialized ✓ + - `fn make_array(comptime T: type, comptime N: u32) -> [N]T` + - Automatic mangling and caching of instantiated functions +4. **Comptime function memoization**: Cache comptime function results ✓ + - Structural type serialization for cache keys (handles anonymous structs) + - Avoids redundant evaluation for same arguments + +### Phase 4: Integration (IN PROGRESS) + +1. **SLR-AST bridge**: Convert to Python AST ✓ +2. **PyO3 bindings**: Use from Python ✓ +3. **QASM codegen**: Direct QASM output ✓ +4. **HUGR codegen**: Direct HUGR output ✓ + +### Phase 5: Tooling (IN PROGRESS) + +1. **LSP server**: IDE support - see [IDE Setup Guide](ide-setup.md) ✓ +2. **Formatter**: `zlup fmt` for canonical code style ✓ +3. **Linter**: `zlup lint` with auto-fix capabilities ✓ +4. **Documentation generator**: From doc comments (planned) +5. **Test runner**: Built-in test support (planned) + +--- + +## Open Questions + +1. ~~**Recursion policy**: Disallow entirely (strict Power of 10) or allow with depth limits?~~ → Resolved: Recursion is unconditionally disallowed. This is fundamental to Zlup's "safe by constraint" memory model—not a strict mode option. Use `inline for` with comptime bounds or regular `for` with runtime bounds instead. Recursive algorithms should be implemented iteratively or in native Rust code called via FFI. + +2. ~~**Parallel blocks**: How to express `parallel { }` from SLR?~~ → Resolved: `tick { }` blocks + +3. ~~**Interop with Guppy**: Can we call Guppy functions from Zlup?~~ → Design doc: [future/guppy-compat.md](future/guppy-compat.md) + - Strategy: Guppy linter enforcing NASA Power of 10 constraints ("Reliable Guppy" subset) + - Mechanical conversion to Zlup when code passes lint + - The linter is valuable independently, even without Zlup adoption + +4. ~~**Standard library**: What should be in `@import("std")`?~~ → Design doc: [future/stdlib-design.md](future/stdlib-design.md) + - Modules: math, bits, mem, qec, testing, ffi + - Zig-style import semantics with Rust/Python syntax + - Comptime-first, bounded containers, QEC-focused + +5. ~~**Build system**: Zig uses `build.zig`, what should we use?~~ → Implemented in `src/build.rs` + - `build.zlp` - the build system IS the language + - Full comptime power for build configuration + - Integrated Rust FFI library building + - Supports targets, optimization levels, build steps, and `-Dname=value` options + +6. ~~**Quantum fault handling**: How to handle gate faults (leakage, loss, etc.)?~~ → Resolved: + - `try { }` collects quantum faults (QEC pattern), returns `([]QuantumFault, ClassicalE!T)` + - `try! { }` stops on first fault/error (traditional), returns `E!T` + - `fn foo() try -> ...` and `fn foo() try! -> E!T` function syntax + - `fault { }` for faults (physical), `error { }` for errors (logical) + - Faults/errors carry rich context (gate, qubits, location) + +--- + +## Summary + +Zlup is an experimental language combining Zig semantics with Rust/Python-flavored syntax. +Inspired by Rust's commitment to safety and Zig's demonstration that expressivity doesn't +require complexity or magic, it explores achieving powerful, safe programs through simplicity +and constraints. + +It complements Guppy by providing: + +- **Expressivity through simplicity**: Powerful programs without hidden behavior or magic +- **Safety through constraints**: A naturally safe language given its restrictions +- **Rust-native workflows**: Direct integration with PECOS's Rust simulation backends +- **QEC reliability**: NASA Power of 10 principles for the reliability and predictability + that large-scale quantum error correction demands + +Zlup bridges to Python via SLR-AST JSON and targets hardware via HUGR (shared with Guppy), +enabling interoperability across the PECOS ecosystem while Guppy remains the primary +user-facing quantum programming language. diff --git a/exp/zlup/docs/dev-notes.md b/exp/zlup/docs/dev-notes.md new file mode 100644 index 000000000..7193ff83c --- /dev/null +++ b/exp/zlup/docs/dev-notes.md @@ -0,0 +1,509 @@ +# Development Notes + +This document tracks recent development work on Zlup, implementation details, and context for contributors. Read this to understand recent changes, current test status, and suggested next tasks. + +## What is Zlup? + +Zlup is a quantum programming language with Zig-inspired semantics and Rust/Python-flavored syntax. It compiles to multiple backends (SLR-AST, HUGR; QASM and PHIR planned) and features: +- Static type checking with quantum-aware semantics +- Qubit state tracking (prepared/unprepared lifecycle) +- NASA Power of 10 compliance options (strict mode) +- Comptime evaluation +- Batch-oriented quantum gate API + +## Recent Work Completed + +### Error Handling Documentation (February 2026) + +Created comprehensive error handling tutorial at `docs/tutorial-error-handling.md`: +- Faults vs Errors distinction with practical explanations +- Error sets and fault sets definitions and usage +- Error union syntax (`E!T` and `[]E!T`) +- `try` (collect mode) vs `try!` (propagate mode) blocks +- Try functions with both modes +- The explicit handling philosophy (no `?` operator) +- Four practical QEC examples: + - Basic syndrome extraction + - Full QEC round with threshold + - Promoting faults to errors + - Rich fault context inspection +- Quick reference and best practices + +Updated `docs/tutorial.md` to reference the new detailed guide. + +### Alias MVP Implementation (February 2026) + +Added the `alias` keyword for creating safe slice views with overlap detection: + +**Grammar changes:** +- Added `alias` to reserved keywords +- Added `alias_stmt = { "alias" ~ identifier ~ ":=" ~ expr ~ ";" }` + +**AST changes:** +- Added `AliasBinding` struct with name, source, location +- Added `Stmt::Alias(AliasBinding)` variant + +**Semantic analysis:** +- Added `AliasInfo` struct for tracking alias metadata (name, source, range, location) +- Added `aliases: BTreeMap` to SemanticAnalyzer +- Implemented `analyze_alias()` with overlap detection +- Added `extract_alias_source_info()` for parsing slice expressions +- Added `ranges_overlap()` helper for range intersection testing +- New error types: `OverlappingAlias`, `AliasSourceNotSlice`, `AliasRangeNotComptime` + +**MVP scope:** +- Immutable aliases only (no `mut alias`) +- Static ranges only (must be comptime-evaluable) +- Error on any overlap (simpler than mutable-only rule) + +**Files modified:** +- `src/zluppy.pest` - grammar rules +- `src/ast.rs` - AliasBinding, Stmt::Alias +- `src/parser.rs` - parse_alias_stmt() +- `src/semantic.rs` - AliasInfo, analyze_alias(), overlap detection +- `src/pretty.rs` - print_alias_binding() +- `src/comptime.rs` - eval alias as source expression + +### Phase 3: Comptime Features (February 2026) + +Completed the full comptime implementation plan with four features: + +**1. Inline For Loop Unrolling:** +- `inline for i in 0..N { }` unrolls at compile time in `optimize.rs` +- Semantic validation in `semantic.rs`: + - `InlineForRangeNotComptime` - range must be comptime-evaluable + - `BreakInInlineFor` - break disallowed in inline for + - `ContinueInInlineFor` - continue disallowed in inline for +- Recursive unrolling for nested inline for loops +- Variable substitution replaces loop variable with concrete values + +**2. Advanced Builtins (snake_case naming):** +- `@type_info(T)` - returns TypeInfo struct with kind, name, fields, variants +- `@field_names(T)` - returns array of struct field name strings +- `@enum_fields(T)` - returns array of enum variant name strings +- `@type_from_info(info)` - constructs Type from TypeInfo (reverse of @type_info) +- Both snake_case and camelCase names supported; snake_case preferred + +**3. Generic Type Instantiation:** +- Functions with `comptime` params get specialized versions at call sites +- Cache: `generic_instantiations: BTreeMap<(String, String), String>` in SemanticAnalyzer +- Mangled names: `make_array__u32_4` for `make_array(u32, 4)` +- Original declarations stored for cloning and substitution + +**4. Comptime Function Memoization:** +- `memo_cache: BTreeMap<(String, String), ComptimeValue>` in ComptimeEvaluator +- Structural type serialization for cache keys (handles anonymous structs correctly) +- Avoids redundant evaluation when same function called with same args + +**Files modified:** +- `src/comptime.rs` - memoization, advanced builtins, TypeInfoKind enum +- `src/semantic.rs` - inline for validation, generic instantiation, error types +- `src/optimize.rs` - inline for unrolling pass +- `src/zluppy.pest` - `comptime_modifier` rule for parser +- `src/parser.rs` - comptime parameter detection + +### Error Handling Improvements (February 2026) + +Enhanced the error handling type system with several improvements: + +**Grammar Rule Ordering Fix:** +- Fixed parser ambiguity where `error_set_decl` and `fault_set_decl` were being parsed as bindings +- Reordered `top_level_decl` rules to try error/fault set declarations before general bindings +- Now `GateFaults := fault { ... }` correctly parses as `FaultSetDecl`, not `Binding` + +**Error/Fault Set Union Support:** +- Error sets can be combined with `|` operator: `IoError | NetworkError` +- Fault sets can be combined similarly: `GateFaults | MeasurementFaults` +- Unions combine variant names, deduplicating common variants +- Type checking verifies operands are compatible error/fault sets + +**Associated Data Types:** +- Error and fault variants can now have associated data types +- Example: `FileError := error { NotFound: struct { path: []u8 }, PermissionDenied };` +- Types are resolved during semantic analysis and stored in `Type::ErrorSet`/`Type::FaultSet` +- Imported modules store variant names without associated types (resolved locally) + +**Try Block Type Inference:** +- `try {}` (collect mode) returns `[]AnyError!T` - slice of error unions +- `try! {}` (propagate mode) returns `AnyError!T` - single error union +- With catch clause, the result type is based on the body type +- Uses `Type::AnyError` as a conservative error type (full inference is future work) + +### Parallelism Analysis Module (February 2026) + +Added `src/analysis.rs` providing constraint-based parallelism analysis: + +**Analysis Passes:** +- `AllocatorAnalysis`: Tracks allocator lifetimes and accessibility scopes +- `OperationTagger`: Tags each quantum operation with resources it touches +- `DependencyGraph`: Builds edges between dependent operations +- `parallel_layers()`: Extracts maximal independent operation sets + +**CLI Integration:** +- `zlup analyze program.zlp` - Analyze a program for parallelism +- `--format json` - Machine-readable output +- `--verbose` - Show dependency graph + +**Design Philosophy:** +Parallelism follows from type signatures and allocator ownership: +- No allocator param → pure classical → parallelizable with any quantum +- Different allocator params → different qubits → independent +- Scopes act as implicit barriers + +### Safe-by-Constraint Memory Model (February 2026) + +Implemented unconditional safety checks that don't require strict mode: + +**Escape Analysis:** +- Functions cannot return references/slices to local variables +- `check_no_local_escape()` runs on all return statements +- Parameters are safe (caller owns the data) + +**Recursion Prevention:** +- All recursion is rejected (direct and mutual) +- Tracking always enabled, not just in strict mode +- `RecursionTracker` with `enter_function()`/`exit_function()` + +**@swap Validation:** +- Requires exactly 2 pointer arguments +- Types must match +- Proper error messages + +### Slice Type System (February 2026) + +**Type Distinction:** +- `[N]T` = Array (fixed size, known at compile time) +- `[]T` = Slice (dynamic view into memory) +- These are distinct types - arrays don't implicitly coerce to slices + +**Slice Syntax:** +- `arr[0..5]` - slice from index 0 to 4 +- `arr[2..]` - slice from index 2 to end +- `arr[..5]` - slice from start to index 4 +- `arr[..]` - full slice (converts array to slice) + +**Re-slicing:** +- `slice[0..2]` on a slice returns a slice +- `slice[0]` on a slice returns the element type +- Chained slicing works: `arr[0..10][2..5][0..2]` + +**Implementation:** +- Parser: `parse_range_expr()` for range syntax +- Semantic: `[]T` parsed as `Type::Slice`, not `Type::Array { size: None }` +- Type checking: `is_slice_op` detection in Index expression + +### Gate Naming Cleanup (February 2026) + +**Removed:** +- `s` and `sdg` gate names (replaced with `sz`/`szdg` per PECOS naming conventions) +- `GateKind::S` and `GateKind::Sdg` from AST + +**Correct Names:** +- `sz` = S gate (sqrt of Z) +- `szdg` = S-dagger gate + +**Updated Files:** +- `ast.rs` - removed enum variants +- `parser.rs` - updated gate name mapping +- `semantic.rs` - updated get_gate_info() +- `optimize.rs` - updated cancellation rules +- All codegen files (slr.rs, qasm.rs, phir.rs) +- Documentation (syntax.md, tutorial.md, README.md) + +### Deterministic Compilation (February 2026) + +- Replaced `HashMap`/`HashSet` with `BTreeMap`/`BTreeSet` throughout +- Ensures consistent output order across compilations +- Files: semantic.rs, module.rs, comptime.rs, optimize.rs, linter.rs, build.rs, all codegens + +### Duplicate Qubit Detection in Measurements (February 2026) + +- Extended tick block duplicate detection to include `Expr::Measure` +- Measurements on the same qubit in parallel are now caught +- Added to `collect_qubit_ids_from_expr()` + +### Boolean Literal Parsing Fix + +Fixed a parser issue where boolean literals on the LEFT side of `and`/`or` operators failed to parse (e.g., `true and y` failed but `y and true` worked). + +**Root cause**: Pest's implicit `WHITESPACE` rule inserted whitespace matching between elements, which interfered with keyword-based operator matching when the left operand was a keyword-like literal (`true`, `false`). + +**Solution**: Created a compound-atomic `atom` rule in `zluppy.pest` to wrap leaf expressions (literals, identifiers) that don't recursively contain `expr`. This prevents implicit whitespace from interfering with keyword operator matching while keeping recursive expressions (like `paren_expr`) outside the compound-atomic context. + +**Files modified:** +- `zluppy.pest`: Added `atom` rule with compound-atomic modifier (`${ }`) +- `parser.rs`: Added `Rule::atom` handling +- `optimize.rs`: Removed outdated comments about the parsing limitation + +### Advanced Optimization Framework + +Added a comprehensive optimization framework with QEC-aware barriers: + +**Optimization passes in `src/optimize.rs`:** +- Constant folding (arithmetic, boolean, comparison expressions) +- Dead code elimination (unreachable code after return/break) +- Gate cancellation (self-inverse gates like H·H = I, X·X = I) + +**Optimization barriers for QEC:** +- `@preserve` - Prevent any optimization on marked operations +- `@timing` - Preserve timing relationships +- `@identity` - Keep intentional identity operations +- `@noopt` - Disable all optimizations in scope +- `@round(n)` - QEC round tracking, prevents cross-round gate cancellation + +**Tick blocks as barriers:** +- `tick {}` blocks always act as optimization barriers +- Operations cannot be moved across tick boundaries +- Nested tick blocks are now disallowed (semantic error) + +### Build System Infrastructure + +Added `build.zlp` infrastructure in `src/build.rs`: +- `Target` enum: x86_64, aarch64, wasm32, etc. +- `Optimize` enum: Debug, ReleaseSafe, ReleaseFast, ReleaseSmall +- `Build` struct with addExecutable, addLibrary, addTest, addStep methods +- `BuildRunner` for executing build steps +- Support for `-Dname=value` options + +### 1. Numeric Literal Type Suffixes +- Added support for type suffixes on numeric literals: `42u32`, `1.5f32`, `0xFF_u16` +- Files modified: `zluppy.pest`, `parser.rs`, `ast.rs`, `semantic.rs` +- Suffixes are parsed and used for type inference in expressions + +### 2. Strict Mode Qubit Duplicate Detection in Tick Blocks +- Added `DuplicateQubitInTick` error for detecting when the same qubit is used multiple times in a tick block +- Tick blocks represent parallel quantum operations - same qubit can't be targeted twice +- Only enforced in strict mode (NASA Power of 10 compliance) +- Added helper methods: `check_duplicate_qubits_in_tick()`, `collect_qubit_ids_from_stmt()`, `collect_qubit_ids_from_expr()` + +### 3. Break/Continue Loop Validation +- Added `loop_depth` tracking to `SemanticAnalyzer` +- Added `BreakContinueOutsideLoop` error for break/continue statements outside loops +- Properly tracks nested loop depth for for loops + +### 4. Type Inference from Range Expressions in For Loops +- Added `infer_for_range_type()` helper method +- For loops now infer the loop variable type from the range expression +- `for i in 0u32..10u32` correctly infers `i` as `u32` +- Supports both `ForRange::Range` and `ForRange::Collection` + +### 5. Comptime Evaluation of Array Sizes +- Array type sizes are now evaluated at compile time +- `[10]u32` correctly resolves to `Type::Array { size: Some(10) }` +- Uses `ComptimeEvaluator` for evaluation + +### 6. Const Propagation for Array Sizes +- Constants are now evaluated at comptime and stored in `comptime_values` HashMap +- Array sizes can reference values: `N := 10; mut arr: [N]u32` +- Supports chained references: `A := 4; B := A; mut arr: [B]u32` + +## Current Test Status + +All tests pass: +- 621 library tests (including alias, comptime, inline-for tests) +- 16 main binary tests +- 48 CLI integration tests +- 174 proptest integration tests +- 9 doctests +- **Total: 868 tests** + +## Remaining TODOs in Code + +### semantic.rs +1. ~~**Line ~1940**: `// TODO: Implement proper error set type`~~ **FIXED** + - ~~Error values currently return `Type::Unknown`~~ + - ~~Should track actual error set types for better type safety~~ + - Error values now properly return `Type::ErrorSet` with the correct error set name and variants + - Module-exported error sets now include variant names for proper lookup + - Type checking now verifies error value assignments match expected error union types + +2. **Line ~2835**: `// TODO: Extract actual function signature from AST` + - Imported module functions have empty signatures + - Should extract actual parameter and return types + +### parser.rs +1. **Line ~1855**: `// TODO: Parse struct body for type definition` + - Struct types used in declarations don't parse their full body + +2. ~~**Lines ~2228, ~2236**: `// TODO: parse sentinel for [*:0] pointers`~~ **FIXED** + - ~~Sentinel-terminated pointer syntax not fully implemented~~ + - Now supports arbitrary sentinel values: `[*:expr]T` for pointers, `[N:expr]T` for arrays + - Added grammar rule `pointer_prefix` for flexible sentinel parsing + - Examples: `[*:0]u8`, `[*:255]u8`, `[10:0]u8`, `[:0]u8` + +## Roadmap + +### Current State (February 2026) + +**Completed:** +- Phase 1 (Core Language): Parser, basic types, control flow, quantum ops ✓ +- Phase 2 (Type System): Type checking, allocator validation, error unions ✓ +- Phase 3 (Comptime): Inline for, advanced builtins, generic instantiation, memoization ✓ +- Alias MVP: Safe slice views with overlap detection ✓ +- Tooling: LSP, formatter, linter ✓ +- Codegens: SLR-AST, QASM, HUGR ✓ + +**In Progress:** +- Phase 4 (Integration): PyO3 bindings working, PHIR planned +- Phase 5 (Tooling): Doc generator and test runner planned + +### Immediate Priorities (Polish & Stability) + +1. **Parser error recovery** - Unknown gate names currently panic; should return proper errors + +2. **Module function signature extraction** - Extract actual function signatures from imported modules for better type checking + +3. **Improve type display names** - Better error messages for complex types (arrays, slices, functions) + +### Short-term (Feature Completion) + +4. **Array bounds checking** - Add compile-time bounds checking when array size is known + +5. **Documentation generator** - Generate docs from `///` comments + +6. **Built-in test runner** - `zlup test` command for running test blocks + +### Architectural Decision: Custom Gates + +The [Custom Gates Design](future/custom-gates-design.md) proposes a significant rethink of how gates work: + +- **All gates become target-provided** (including `h`, `cx`, `pz`, `mz`) +- **`std.gates` becomes declarations**, not built-ins +- **Composite gates** allow full subroutines (prep, measurement, control flow) +- **Compile-time target validation** against target gate sets +- **IDE support** via project config and `@import("target")` + +**Impact if pursued:** +- Changes how every quantum operation works +- Requires target definition system +- Affects IDE/LSP significantly +- More flexible but more explicit (requires imports) + +**Decision needed:** Is this the right direction? If yes, it becomes high priority and affects subsequent work. + +### Medium-term (Post-Decision) + +If custom gates design is adopted: +- Implement target definition system +- Refactor `std.gates` as declarations +- Update IDE/LSP for target awareness +- Update all examples and docs + +If not adopted: +- Continue with current built-in gate model +- Focus on PHIR codegen +- Guppy compatibility work + +### Longer-term + +- **PHIR codegen** - For PECOS simulator targeting +- **Guppy compatibility** - Linter for "Reliable Guppy" subset, mechanical conversion +- **Full stdlib** - math, bits, mem, qec, testing, ffi modules + +### Lower Priority (Nice to Have) + +- **Array-to-slice coercion** - Consider implicit array→slice in function arguments +- **Struct body parsing** - Parse full struct bodies when used as types + +## Completed Features + +### FFI Support (External Functions) +- Added `extern "C" fn name(params) -> type;` syntax for declaring external functions +- Supports calling conventions: `"C"` (C ABI), `"Rust"` (Rust ABI) +- Library linking via `@link("libname")` attribute +- Useful for integrating classical decoders (MWPM, Union-Find, etc.) +- Grammar: `extern_fn_decl` in `zluppy.pest` +- AST: `ExternFnDecl` struct in `ast.rs` +- SLR codegen generates `ExternDecl` and `ExternCall` nodes +- C-compatible type mapping: primitives (`u8`, `u32`, etc.), pointers (`[*]T`), arrays + +### Gate Extensions +- Added SWAP, ISWAP, and CCX (Toffoli) gates to `GateKind` enum +- Updated SLR codegen to support new gates +- Three-qubit gate support (CCX has arity 3) + +## Key Files + +| File | Purpose | +|------|---------| +| `src/zluppy.pest` | PEG grammar definition | +| `src/parser.rs` | Parser implementation | +| `src/ast.rs` | AST node definitions | +| `src/semantic.rs` | Semantic analysis, type checking, symbol table | +| `src/comptime.rs` | Compile-time evaluation | +| `src/optimize.rs` | AST optimization passes (constant folding, DCE, gate cancellation) | +| `src/analysis.rs` | Parallelism analysis (allocators, dependencies, layers) | +| `src/build.rs` | Build system infrastructure for `build.zlp` | +| `src/codegen/slr.rs` | SLR-AST code generation | +| `src/codegen/qasm.rs` | OpenQASM code generation | +| `src/codegen/hugr.rs` | HUGR code generation | +| `src/main.rs` | CLI entry point | + +## Building and Testing + +```bash +# Build with CLI features +cargo build --features cli + +# Run all tests +cargo test --features cli + +# Run specific test +cargo test --features cli test_const_propagation +``` + +## Quantum Gate API + +The API uses DSL-style syntax (gate followed by target) with batch operations using set literals: +```zlup_nocheck +// Single-qubit gates (space-separated syntax) +h q[0]; +x q[1]; +rz(pi/4) q[0]; + +// Batch operations with set literals +h {q[0], q[1], q[2]}; +cx {(q[0], q[1]), (q[2], q[3])}; + +// Typed measurements +r: u1 = mz(u1) q[0]; // Single qubit +results: [2]u1 = mz([2]u1) [q[0], q[1]]; // Multiple qubits -> [2]u1 +syndrome: u8 = mz(pack u8) [q[0], q[1], ...]; // Pack into integer +``` + +## Example Program + +```zlup +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Apply Hadamard to all qubits + h {q[0], q[1], q[2], q[3]}; + + // Create entanglement + cx {(q[0], q[1]), (q[2], q[3])}; + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + return; +} +``` + +## Important Notes for Future Sessions + +### Variable Naming +Single-letter names like `s`, `h`, `x`, `y`, `z`, `t` are gate names. When writing tests or examples with slice/array parameters, use names like `data`, `arr`, `items` to avoid parsing conflicts. + +### Type System Key Points +- `[]T` and `[N]T` are distinct types +- Use `arr[..]` to convert array to slice +- Escape analysis prevents returning slices of local variables +- Parameters are safe to slice and return (caller owns data) + +### Safety Model +The language is "safe by constraint" - no recursion, no dangling references, no escaping locals. These checks are always enabled, not just in strict mode. + +--- +*Last updated: February 2026* diff --git a/exp/zlup/docs/errors.md b/exp/zlup/docs/errors.md new file mode 100644 index 000000000..8e159c56f --- /dev/null +++ b/exp/zlup/docs/errors.md @@ -0,0 +1,581 @@ +# Zlup Error Messages Guide + +This guide explains common error messages and how to fix them. + +## Parse Errors + +Parse errors occur when the code doesn't match the expected syntax. + +### "expected an identifier" + +**Cause:** A name (variable, function, type) was expected but something else was found. + +```zlup_nocheck +// Bad +fn 123() -> unit { } // Numbers can't be function names + +// Good +fn my_func() -> unit { } +``` + +### "expected a type" + +**Cause:** A type annotation was expected after `:`. + +```zlup_nocheck +// Bad +x: = 42; // Missing type + +// Good +x := 42; // Infer type +x: u32 = 42; // Explicit type +``` + +### "expected ';'" + +**Cause:** Statements must end with semicolons. + +```zlup_nocheck +// Bad +x := 42 +y := 10 + +// Good +x := 42; +y := 10; +``` + +### "expected '{'" + +**Cause:** Blocks require curly braces. + +```zlup_nocheck +// Bad +if condition + do_something(); + +// Good +if condition { + do_something(); +} +``` + +### "expected ')'" / "expected ']'" + +**Cause:** Unmatched parentheses or brackets. + +```zlup_nocheck +// Bad +result := add(1, 2; +array := [1, 2, 3; + +// Good +result := add(1, 2); +array := [1, 2, 3]; +``` + +### "expected ':=' for binding or '=' for assignment" + +**Cause:** Incorrect binding syntax. + +```zlup_nocheck +// Bad +x = 42; // This is assignment, not binding + +// Good +x := 42; // Binding (creates new variable) +mut y := 0; +y = 42; // Assignment (to existing variable) +``` + +### "expected function parameter (name: Type)" + +**Cause:** Function parameters must have names and types. + +```zlup_nocheck +// Bad +fn add(u32, u32) -> u32 { } + +// Good +fn add(a: u32, b: u32) -> u32 { } +``` + +### "expected '-> T' return type" + +**Cause:** Function return type syntax uses arrow. + +```zlup_nocheck +// Bad +fn add(a: u32, b: u32) u32 { } + +// Good +fn add(a: u32, b: u32) -> u32 { } +``` + +--- + +## Semantic Errors + +Semantic errors occur when code is syntactically valid but logically incorrect. + +### "undefined symbol 'name'" + +**Cause:** Using a variable, function, or type that hasn't been declared. + +```zlup_nocheck +// Bad +fn main() -> unit { + y := x + 1; // x not defined + return; +} + +// Good +fn main() -> unit { + x := 10; + y := x + 1; + return; +} +``` + +### "symbol 'name' already defined" + +**Cause:** Declaring the same name twice in the same scope. + +```zlup_nocheck +// Bad +x := 1; +x := 2; // Can't redeclare + +// Good +x := 1; +y := 2; // Different name + +// Or use mut for reassignment +mut x := 1; +x = 2; // Assignment, not redeclaration +``` + +### "cannot assign to immutable variable 'name'" + +**Cause:** Trying to reassign a variable that wasn't declared with `mut`. + +```zlup_nocheck +// Bad +x := 1; +x = 2; // x is immutable! + +// Good +mut x := 1; +x = 2; // x is mutable +``` + +### "unsafe blocks are forbidden" + +**Cause:** Using an `unsafe` block without the `--allow-unsafe` flag. + +```zlup_nocheck +// This will fail without --allow-unsafe +fn example() -> unit { + unsafe { + // ... + } + return; +} +``` + +**Solution:** Either: +1. Remove the unsafe block and rewrite using safe constructs +2. Compile with `--allow-unsafe` flag (for development/testing) + +Production code should avoid unsafe blocks. They exist as an escape hatch for expert use cases. + +### "type mismatch: expected T, found U" + +**Cause:** Using a value of the wrong type. + +```zlup_nocheck +// Bad +x: u32 = "hello"; // String is not u32 + +// Good +x: u32 = 42; +s: []const u8 = "hello"; +``` + +### "cannot infer type for 'name'" + +**Cause:** Type cannot be determined from context. + +```zlup_nocheck +// Bad +x := undefined; // What type? + +// Good +x: u32 = undefined; // Explicit type +``` + +--- + +## Gate Errors + +### "gate 'G' requires N qubits, got M" + +**Cause:** Wrong number of qubits for the gate. + +```zlup_nocheck +// Bad - too few qubits +cx q[0]; // CX needs 2 qubits +ccx (q[0], q[1]); // CCX (Toffoli) needs 3 qubits + +// Bad - too many qubits +h (q[0], q[1]); // H is single-qubit gate + +// Good +h q[0]; // Single qubit +cx (q[0], q[1]); // Two qubits (control, target) +ccx (q[0], q[1], q[2]); // Three qubits (Toffoli) +``` + +### "ambiguous target for multi-qubit gate" + +**Cause:** Multi-qubit gates need explicit tuple or set syntax. + +```zlup_nocheck +// Bad +cx q; // Which qubits? + +// Good +cx (q[0], q[1]); // Explicit pair +cx {(q[0], q[1]), (q[2], q[3])}; // Batch +``` + +### "invalid gate syntax: use 'gate target' instead" + +**Cause:** Gates use space-separated syntax, not function call syntax. + +```zlup_nocheck +// Bad (old syntax) +h(q[0]); +cx(q[0], q[1]); + +// Good (current syntax) +h q[0]; +cx (q[0], q[1]); +``` + +--- + +## Qubit Errors + +### "qubit 'alloc[i]' is not prepared" + +**Cause:** Using a qubit before preparing it, or after measurement without re-preparing. + +```zlup_nocheck +// Bad - never prepared +q := qalloc(2); +h q[0]; // Qubit not prepared! + +// Bad - used after measurement +q := qalloc(2); +pz q; +h q[0]; +r := mz(u1) q[0]; // Measurement resets qubit state +h q[0]; // Error: qubit no longer prepared! + +// Good - prepare before use +q := qalloc(2); +pz q; // Prepare first +h q[0]; + +// Good - re-prepare after measurement +q := qalloc(2); +pz q; +h q[0]; +r := mz(u1) q[0]; +pz q[0]; // Re-prepare after measurement +h q[0]; // Now OK +``` + +### "qubit 'alloc[i]' is already prepared" + +**Cause:** Preparing an already-prepared qubit (in strict mode). + +```zlup_nocheck +// Bad +pz q[0]; +pz q[0]; // Already prepared + +// Good +pz q[0]; +// ... use qubit ... +// Reset only if needed +``` + +### "qubit index N out of bounds for allocator (capacity: M)" + +**Cause:** Accessing a qubit index beyond the allocator's capacity. + +```zlup_nocheck +// Bad +q := qalloc(4); +h q[10]; // Only 0-3 available + +// Good +q := qalloc(4); +h q[3]; // Index 0-3 valid +``` + +### "cannot call .child() on immutable allocator" + +**Cause:** Parent allocator must be mutable to create children. + +```zlup_nocheck +// Bad +q := qalloc(10); +data := q.child(5); // q is immutable + +// Good +mut q := qalloc(10); // Make mutable +data := q.child(5); +``` + +### "qubit used multiple times within tick block" + +**Cause:** Same qubit appears in multiple operations within one tick. + +```zlup_nocheck +// Bad +tick { + h q[0]; + x q[0]; // Same qubit! +} + +// Good +tick { h q[0]; } +tick { x q[0]; } // Different ticks +``` + +--- + +## Measurement Errors + +### "invalid measurement type" + +**Cause:** Measurement type must be u1, u8, u64, or arrays thereof. + +```zlup_nocheck +// Bad +r: f64 = mz(f64) q[0]; // Float not valid + +// Good +r: u1 = mz(u1) q[0]; +``` + +### "measurement type mismatch: declared [N]T but measuring M qubits" + +**Cause:** Array size doesn't match number of qubits. + +```zlup_nocheck +// Bad +r: [4]u1 = mz([4]u1) [q[0], q[1]]; // Only 2 qubits + +// Good +r: [2]u1 = mz([2]u1) [q[0], q[1]]; +``` + +### "single qubit measurement requires scalar type" + +**Cause:** Measuring one qubit requires u1 (or similar), not array. + +```zlup_nocheck +// Bad +r: [1]u1 = mz([1]u1) q[0]; // Single qubit + +// Good +r: u1 = mz(u1) q[0]; +``` + +### "multiple qubit measurement requires array type" + +**Cause:** Measuring multiple qubits requires array type. + +```zlup_nocheck +// Bad +r: u1 = mz(u1) [q[0], q[1]]; // Multiple qubits + +// Good +r: [2]u1 = mz([2]u1) [q[0], q[1]]; +``` + +### "pack mode: type T has N bits but measuring M qubits" + +**Cause:** Pack type doesn't have enough bits. + +```zlup_nocheck +// Bad +r: u4 = mz(pack u4) [q[0], q[1], q[2], q[3], q[4]]; // 5 qubits, only 4 bits + +// Good +r: u8 = mz(pack u8) [q[0], q[1], q[2], q[3], q[4]]; // 8 bits >= 5 qubits +``` + +--- + +## Control Flow Errors + +### "unbounded loop detected" + +**Cause:** Loops must have bounded iteration (NASA Power of 10). + +```zlup_nocheck +// Bad +while condition { } // while loops not allowed + +// Good +for i in 0..100 { // Bounded iteration + if !condition { break; } +} +``` + +### "loop bound too large" + +**Cause:** Loop iteration count exceeds maximum (in strict mode). + +```zlup_nocheck +// Bad (if max is 1000) +for i in 0..1000000 { } + +// Good +for i in 0..1000 { } +``` + +### "recursion detected in function" + +**Cause:** Recursive calls are not allowed in strict mode (NASA Power of 10). + +```zlup_nocheck +// Bad +fn factorial(n: u32) -> u32 { + if n <= 1 { return 1; } + return n * factorial(n - 1); // Recursion! +} + +// Good - use iteration +fn factorial(n: u32) -> u32 { + mut result: u32 = 1; + for i in 1..n+1 { + result *= i; + } + return result; +} + +// Alternative - use unsafe block (requires --allow-unsafe) +fn factorial(n: u32) -> u32 { + unsafe { + if n <= 1 { return 1; } + return n * factorial(n - 1); + } +} +``` + +### "mutual recursion detected" + +**Cause:** Two or more functions call each other, forming a cycle. + +```zlup_nocheck +// Bad +fn is_even(n: u32) -> bool { + if n == 0 { return true; } + return is_odd(n - 1); // Calls is_odd +} + +fn is_odd(n: u32) -> bool { + if n == 0 { return false; } + return is_even(n - 1); // Calls is_even - cycle! +} + +// Good - use iteration or combine into one function +fn is_even(n: u32) -> bool { + return n % 2 == 0; +} +``` + +### "break outside of loop" / "continue outside of loop" + +**Cause:** Using break/continue outside a loop context. + +```zlup_nocheck +// Bad +fn main() -> unit { + break; // Not in a loop + return; +} + +// Good +fn main() -> unit { + for i in 0..10 { + if i == 5 { break; } + } + return; +} +``` + +### "missing return statement" + +**Cause:** Function doesn't return on all paths. + +```zlup_nocheck +// Bad +fn get_value(x: bool) -> u32 { + if x { + return 1; + } + // Missing return for else case! +} + +// Good +fn get_value(x: bool) -> u32 { + if x { + return 1; + } else { + return 0; + } +} +``` + +--- + +## Module Errors + +### "module not found: name" + +**Cause:** Imported module doesn't exist or isn't in search path. + +```zlup_nocheck +// Bad +utils := @import("nonexistent.zlup"); + +// Solutions: +// 1. Check file exists +// 2. Set ZLUP_STDLIB_PATH for std imports +// 3. Check relative path is correct +``` + +--- + +## Tips for Debugging + +1. **Read the full error message** - It often includes the exact location and suggestion. + +2. **Check the line number** - The error points to where the problem was detected, which may be after where it was caused. + +3. **Look for typos** - Common issues: `:=` vs `=`, missing semicolons, wrong brackets. + +4. **Use `zlup check`** - Runs semantic analysis without full compilation. + +5. **Try `zlup eval`** - Test small expressions interactively. + +6. **Enable verbose mode** - Some commands have `--verbose` for more details. + +7. **Simplify** - If you can't find the error, try removing code until it compiles, then add back piece by piece. diff --git a/exp/zlup/docs/future/alias-ast-sketch.rs b/exp/zlup/docs/future/alias-ast-sketch.rs new file mode 100644 index 000000000..64cbf982c --- /dev/null +++ b/exp/zlup/docs/future/alias-ast-sketch.rs @@ -0,0 +1,294 @@ +// ============================================================================= +// AST Sketch for Alias Feature +// ============================================================================= +// This is a design sketch, not actual implementation code. + +/// Alias binding - creates a named view into existing data. +/// +/// Syntax: +/// - `alias name := source;` - immutable alias +/// - `mut alias name := source;` - mutable alias (can be reassigned) +/// - `alias { a := x, b := y, ... }` - grouped aliases +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasBinding { + /// Name of the alias + pub name: String, + /// The source expression (typically a slice expression) + pub source: Expr, + /// Whether this alias can be reassigned to point elsewhere + pub is_mutable: bool, + /// Optional type annotation + pub ty: Option, + /// Documentation comment + pub doc_comment: Option, + pub location: Option, +} + +/// Grouped alias declaration for partitioning. +/// +/// Syntax: +/// ```zlup +/// alias { +/// data := q[0..4], +/// ancilla := q[4..8], +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasGroup { + /// The aliases in this group (checked for non-overlap) + pub aliases: Vec, + pub location: Option, +} + +// In Stmt enum, add: +pub enum Stmt { + // ... existing variants ... + + /// Single alias binding + Alias(AliasBinding), + + /// Grouped aliases (enables overlap checking within group) + AliasGroup(AliasGroup), +} + +// ============================================================================= +// Semantic Analysis Additions +// ============================================================================= + +/// Information about an alias tracked during semantic analysis. +#[derive(Debug, Clone)] +pub struct AliasInfo { + /// Name of the alias + pub name: String, + /// Name of the source variable + pub source_var: String, + /// Static range if known at compile time + pub static_range: Option, + /// Whether this alias is mutable + pub is_mutable: bool, + /// The type of elements in the alias + pub element_type: Type, + /// Location for error reporting + pub location: Option, +} + +/// A compile-time known range. +#[derive(Debug, Clone)] +pub struct StaticRange { + pub start: i128, + pub end: i128, // exclusive +} + +impl StaticRange { + pub fn overlaps(&self, other: &StaticRange) -> bool { + self.start < other.end && other.start < self.end + } + + pub fn len(&self) -> i128 { + self.end - self.start + } +} + +// Add to SymbolKind enum: +pub enum SymbolKind { + // ... existing variants ... + + /// An alias (named view into another variable) + Alias { + /// Type of the alias (slice type) + ty: Type, + /// The source variable this aliases + source: String, + /// Static range if known + range: Option, + /// Whether the alias binding is mutable + is_mutable: bool, + }, +} + +// ============================================================================= +// Overlap Checking +// ============================================================================= + +impl SemanticAnalyzer { + /// Check if a new alias overlaps with existing aliases to the same source. + fn check_alias_overlap(&self, new_alias: &AliasInfo) -> SemanticResult<()> { + // Find all existing aliases to the same source + for (name, symbol) in self.iter_symbols() { + if let SymbolKind::Alias { source, range, is_mutable, .. } = &symbol.kind { + if source == &new_alias.source_var { + // Same source - check for overlap + if let (Some(existing_range), Some(new_range)) = (range, &new_alias.static_range) { + if existing_range.overlaps(new_range) { + // Overlap detected + if *is_mutable || new_alias.is_mutable { + // At least one is mutable - error + return Err(SemanticError::OverlappingMutableAlias { + new_alias: new_alias.name.clone(), + existing_alias: name.clone(), + source: new_alias.source_var.clone(), + location: new_alias.location.clone().unwrap_or_default(), + }); + } + // Both immutable - could warn but allow + } + } + } + } + } + Ok(()) + } + + /// Analyze an alias binding. + fn analyze_alias(&mut self, alias: &AliasBinding) -> SemanticResult<()> { + // 1. Analyze the source expression + let source_ty = self.analyze_expr(&alias.source)?; + + // 2. Verify source is slice-able + let element_ty = match &source_ty { + Type::Slice { element, .. } => *element.clone(), + Type::Array { element, .. } => *element.clone(), + _ => { + return Err(SemanticError::TypeMismatch { + expected: "slice or array".to_string(), + found: source_ty.display_name(), + location: alias.location.clone().unwrap_or_default(), + }); + } + }; + + // 3. Extract source variable name and range (if static) + let (source_var, static_range) = self.extract_alias_source_info(&alias.source)?; + + // 4. Check for overlaps with existing aliases + let alias_info = AliasInfo { + name: alias.name.clone(), + source_var: source_var.clone(), + static_range: static_range.clone(), + is_mutable: alias.is_mutable, + element_type: element_ty.clone(), + location: alias.location.clone(), + }; + self.check_alias_overlap(&alias_info)?; + + // 5. Define the alias in the symbol table + let alias_ty = Type::Slice { + element: Box::new(element_ty), + is_mut: alias.is_mutable, + }; + + self.symbols.define(Symbol { + name: alias.name.clone(), + kind: SymbolKind::Alias { + ty: alias_ty, + source: source_var, + range: static_range, + is_mutable: alias.is_mutable, + }, + location: alias.location.clone(), + })?; + + Ok(()) + } + + /// Extract source variable and static range from a slice expression. + fn extract_alias_source_info(&self, expr: &Expr) -> SemanticResult<(String, Option)> { + match expr { + Expr::Slice(slice) => { + // Get the base variable name + let source_var = match &*slice.base { + Expr::Ident(ident) => ident.name.clone(), + _ => return Ok(("".to_string(), None)), + }; + + // Try to evaluate range bounds at compile time + let start = self.try_eval_comptime_int(&slice.start); + let end = self.try_eval_comptime_int(&slice.end); + + let static_range = match (start, end) { + (Some(s), Some(e)) => Some(StaticRange { start: s, end: e }), + _ => None, + }; + + Ok((source_var, static_range)) + } + Expr::Ident(ident) => { + // Aliasing entire variable - no range restriction + Ok((ident.name.clone(), None)) + } + _ => Ok(("".to_string(), None)), + } + } +} + +// ============================================================================= +// New Semantic Error Variant +// ============================================================================= + +pub enum SemanticError { + // ... existing variants ... + + #[error("mutable alias '{new_alias}' overlaps with '{existing_alias}' (both alias '{source}')")] + OverlappingMutableAlias { + new_alias: String, + existing_alias: String, + source: String, + location: SourceLocation, + }, + + #[error("alias '{name}' would outlive its source")] + AliasOutlivesSource { + name: String, + location: SourceLocation, + }, +} + +// ============================================================================= +// Parser Grammar Sketch (PEG) +// ============================================================================= + +/* +// Add to statement rule: +statement = { + // ... existing ... + | alias_stmt + | alias_group +} + +alias_stmt = { + mut_modifier? ~ "alias" ~ ws ~ identifier ~ ws ~ + (":" ~ ws ~ type_expr ~ ws)? ~ + ":=" ~ ws ~ expr ~ ";" +} + +alias_group = { + "alias" ~ ws ~ "{" ~ ws ~ + (alias_item ~ ("," ~ ws ~ alias_item)* ~ ","?)? ~ + ws ~ "}" +} + +alias_item = { + identifier ~ ws ~ (":" ~ ws ~ type_expr ~ ws)? ~ ":=" ~ ws ~ expr +} + +mut_modifier = { "mut" ~ ws } +*/ + +// ============================================================================= +// Code Generation (compiles to slice) +// ============================================================================= + +// Aliases compile directly to slice references - no runtime overhead. +// The alias keyword is purely for semantic analysis and safety checking. + +impl CodeGen { + fn gen_alias(&mut self, alias: &AliasBinding) -> Result<()> { + // Generate code for the source expression (produces a slice) + let source_value = self.gen_expr(&alias.source)?; + + // Bind the slice to the alias name + self.define_local(&alias.name, source_value); + + Ok(()) + } +} diff --git a/exp/zlup/docs/future/alias-design.md b/exp/zlup/docs/future/alias-design.md new file mode 100644 index 000000000..94ecbac49 --- /dev/null +++ b/exp/zlup/docs/future/alias-design.md @@ -0,0 +1,436 @@ +# Alias Design Notes + +> **Status:** MVP implemented (February 2026) + +This document explores adding an `alias` keyword to Zlup for creating named views +into existing data structures, particularly qubit registers. + +## Motivation + +In QEC code, it's common to partition a qubit register into logical regions: + +```zlup_nocheck +// Current approach: use slices directly +q := qalloc(9); +data := q[0..1]; // Is this a copy or a view? Unclear at a glance +x_ancilla := q[1..5]; +z_ancilla := q[5..9]; +``` + +Problems with the current approach: +1. **Intent unclear** - Is `data` a view or a copy? +2. **No overlap checking** - Nothing prevents `q[0..3]` and `q[2..5]` aliases +3. **Lifetime implicit** - Relationship to source not explicit in syntax + +## Proposed Syntax + +```zlup_nocheck +// Immutable alias (default) +alias data := q[0..1]; + +// Mutable alias (explicit) +mut alias x_ancilla := q[1..5]; + +// Multiple aliases +alias { + data := q[0..1], + x_ancilla := q[1..5], + z_ancilla := q[5..9], +} +``` + +## Semantics + +### Immutability by Default + +```zlup_nocheck +q := qalloc(4); +alias view := q[0..2]; + +h view[0]; // OK: quantum ops through immutable alias +view = q[2..4]; // ERROR: cannot reassign immutable alias +``` + +Mutable aliases can be reassigned: + +```zlup_nocheck +mut alias current := q[0..2]; +h current[0]; +current = q[2..4]; // OK: mutable alias +h current[0]; // Now operates on q[2] +``` + +### Lifetime Binding + +Aliases are bound to their source's lifetime: + +```zlup_nocheck +fn bad() -> alias []u8 { + arr := [1, 2, 3, 4]; + alias view := arr[0..2]; + return view; // ERROR: alias would outlive source +} + +fn ok(arr: []u8) -> alias []u8 { + alias view := arr[0..2]; + return view; // OK: source outlives function +} +``` + +### Overlap Checking + +**Option A: Error on overlap (strict)** +```zlup_nocheck +q := qalloc(4); +alias a := q[0..2]; +alias b := q[1..3]; // ERROR: overlaps with 'a' +``` + +**Option B: Warning on overlap (permissive)** +```zlup_nocheck +q := qalloc(4); +alias a := q[0..2]; +alias b := q[1..3]; // WARNING: overlaps with 'a', parallel ops may conflict +``` + +**Option C: Error only for mutable overlap** +```zlup_nocheck +q := qalloc(4); +alias a := q[0..2]; // immutable +alias b := q[1..3]; // immutable - OK, both read-only + +mut alias c := q[0..2]; // mutable +mut alias d := q[1..3]; // ERROR: mutable overlap with 'c' +``` + +**Recommendation:** Start with Option C - mutable overlap is an error, immutable +overlap is allowed. This matches Rust's borrowing rules conceptually. + +## Difference from Slices + +| Aspect | Slice (`x := arr[0..2]`) | Alias (`alias x := arr[0..2]`) | +|--------|--------------------------|--------------------------------| +| Intent | Ambiguous | Explicitly a view | +| Overlap check | None | Static analysis possible | +| Lifetime | Implicit | Explicit in type system | +| Reassignment | Always mutable | Immutable by default | + +An alias IS a slice underneath, but with: +1. Explicit "this is a view" semantics +2. Compiler tracking for overlap analysis +3. Immutability by default + +## AST Representation + +```rust +/// Alias binding - creates a named view into existing data +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasBinding { + /// Name of the alias + pub name: String, + /// The source expression (must be slice-able) + pub source: Expr, + /// Whether this alias is mutable (can be reassigned) + pub is_mutable: bool, + /// Optional type annotation + pub ty: Option, + pub location: Option, +} + +// In Stmt enum: +pub enum Stmt { + // ... existing variants ... + Alias(AliasBinding), + AliasGroup(Vec), // For grouped aliases +} +``` + +## Semantic Analysis + +### Alias Tracking + +The semantic analyzer tracks aliases and their source relationships: + +```rust +struct AliasInfo { + name: String, + source: String, // Name of source variable + range: Option, // Static range if known + is_mutable: bool, +} + +// In SemanticAnalyzer: +aliases: BTreeMap, +``` + +### Overlap Detection + +For static ranges, detect overlaps at compile time: + +```rust +fn ranges_overlap(a: &Range, b: &Range) -> bool { + // [a.start, a.end) overlaps [b.start, b.end)? + a.start < b.end && b.start < a.end +} + +fn check_alias_overlap(&self, new_alias: &AliasInfo) -> Option { + for existing in self.aliases.values() { + if existing.source == new_alias.source { + if let (Some(r1), Some(r2)) = (&existing.range, &new_alias.range) { + if ranges_overlap(r1, r2) { + if existing.is_mutable || new_alias.is_mutable { + return Some(SemanticError::OverlappingMutableAlias { ... }); + } + } + } + } + } + None +} +``` + +### Dynamic Ranges + +For runtime-computed ranges, overlap checking happens at runtime or is skipped: + +```zlup_nocheck +fn partition(q: [n]qubit, split: usize) -> unit { + alias left := q[0..split]; // Range not known at compile time + alias right := q[split..n]; // Could overlap if split > n + // Runtime check or trust the programmer? +} +``` + +Options: +1. **Require comptime ranges** for overlap checking +2. **Insert runtime checks** for dynamic ranges +3. **Trust programmer** for dynamic ranges (document the risk) + +## Integration with Parallelism Analysis + +Aliases provide explicit partitioning information to the parallelism analyzer: + +```zlup_nocheck +q := qalloc(9); +alias { + data := q[0..1], + x_ancilla := q[1..5], + z_ancilla := q[5..9], +} + +// Analyzer knows these are disjoint, can parallelize: +h data[0]; // Independent +h x_ancilla[0]; // Independent +h z_ancilla[0]; // Independent +``` + +The `alias` block makes the partitioning explicit and verifiable. + +## Examples + +### QEC Syndrome Extraction + +```zlup_nocheck +fn surface_code_round(q: [13]qubit) -> unit { + // Explicit partitioning with aliases + alias { + data := q[0..9], + x_stabilizers := q[9..11], + z_stabilizers := q[11..13], + } + + // Operations on disjoint regions - can parallelize + prepare_data(data); + extract_x_syndrome(data, x_stabilizers); + extract_z_syndrome(data, z_stabilizers); + + // Measure stabilizers + x_syndrome := mz([2]u1) x_stabilizers; + z_syndrome := mz([2]u1) z_stabilizers; + + result("syndrome/x", x_syndrome); + result("syndrome/z", z_syndrome); +} +``` + +### Sliding Window + +```zlup_nocheck +fn sliding_window(arr: []u8, window_size: usize) -> unit { + for i in 0..(arr.len() - window_size) { + alias window := arr[i..i+window_size]; + process(window); + } +} +``` + +### Temporary Mutable View + +```zlup_nocheck +fn initialize_register(q: [8]qubit) -> unit { + // Mutable alias for initialization phase + mut alias current := q[0..4]; + initialize_block(current); + + current = q[4..8]; + initialize_block(current); +} +``` + +## Open Questions + +1. **Overlap policy:** Error, warning, or context-dependent? + +2. **Runtime checks:** For dynamic ranges, should we insert bounds checks? + +3. **Alias of alias:** Should this be allowed? + ```zlup_nocheck + alias a := q[0..4]; + alias b := a[0..2]; // Alias of alias? + ``` + +4. **Alias in function signatures:** + ```zlup_nocheck + fn process(alias data: []qubit) -> unit { ... } + // vs + fn process(data: &[]qubit) -> unit { ... } + ``` + +5. **Interaction with borrowing:** How does `alias` relate to `&` and `&mut`? + +## Minimal First Version (MVP) + +Start with a constrained version to validate the concept before adding complexity. + +### MVP Scope + +**Include:** +- `alias name := slice_expr;` - immutable only, no `mut alias` +- Static range slices only: `alias x := q[0..4];` +- Same-scope only (no passing aliases to/from functions) +- Overlap checking within same source variable +- Error on any overlap (simpler than mutable-only rule) + +**Exclude (for later):** +- `mut alias` (mutable aliases) +- `alias { }` grouped syntax +- Dynamic ranges +- Alias as function parameter/return type +- Alias of alias + +### MVP Syntax + +```zlup_nocheck +fn example() -> unit { + q := qalloc(8); + + // Simple aliases with static ranges + alias data := q[0..4]; + alias ancilla := q[4..8]; + + // Use like slices + h data[0]; + cx (data[0], ancilla[0]); + + // Overlap is an error + alias overlap := q[2..6]; // ERROR: overlaps with 'data' and 'ancilla' + + return; +} +``` + +### MVP Semantics + +1. **Immutable binding** - Cannot reassign: `data = q[0..2];` is an error +2. **View semantics** - Alias is a view, not a copy +3. **Lifetime** - Alias lives until end of scope (same as source) +4. **Static only** - Range bounds must be comptime-known for overlap checking + +### MVP Error Messages + +``` +error: overlapping alias + --> example.zlp:8:5 + | + 5 | alias data := q[0..4]; + | ---- first alias covers q[0..4] + 8 | alias overlap := q[2..6]; + | ^^^^^^^ overlaps with 'data' at indices 2..4 + | + = help: use non-overlapping ranges or access q directly +``` + +### MVP Implementation Estimate + +- Parser: ~50 lines (new `alias` statement) +- AST: ~20 lines (AliasBinding struct) +- Semantic: ~100 lines (overlap checking, symbol tracking) +- Tests: ~100 lines + +Total: ~270 lines, relatively low risk. + +### Graduation Criteria + +Expand beyond MVP when: +1. MVP is stable and tested +2. Real QEC code shows need for `mut alias` or function passing +3. User feedback indicates grouped syntax would help + +--- + +## Full Implementation Plan + +1. **Phase 1: Parser** + - Add `alias` keyword to reserved words + - Parse `alias name := expr;` and `mut alias name := expr;` + - Parse `alias { ... }` block syntax + +2. **Phase 2: AST** + - Add `AliasBinding` struct + - Add `Stmt::Alias` and `Stmt::AliasGroup` variants + +3. **Phase 3: Semantic Analysis** + - Track aliases in symbol table (new `SymbolKind::Alias`) + - Implement lifetime checking (alias can't outlive source) + - Implement overlap detection for static ranges + +4. **Phase 4: Code Generation** + - Aliases compile to slice references + - No runtime overhead for immutable aliases + +5. **Phase 5: Parallelism Analysis Integration** + - Use alias info for more precise dependency tracking + - Alias blocks provide explicit partitioning hints + +## Alternatives Considered + +### Just Use Slices +Keep current behavior, document that slices are views. +- Pro: No new syntax +- Con: Intent unclear, no overlap checking + +### Borrow Syntax (`&`) +Use Rust-style borrowing more explicitly. +- Pro: Familiar to Rust users +- Con: More complex, might not fit Zlup's simpler model + +### Named Regions in Allocator +```zlup_nocheck +q := qalloc(9) { + data: 0..1, + x_ancilla: 1..5, + z_ancilla: 5..9, +}; +``` +- Pro: Declaration and partitioning together +- Con: Only works for allocators, not general slices + +## Summary + +The `alias` keyword provides: +1. **Explicit intent** - "this is a view, not a copy" +2. **Static safety** - overlap detection for mutable aliases +3. **Immutability by default** - matches Zlup's philosophy +4. **Parallelism hints** - explicit partitioning aids analysis + +It's essentially slices with semantic meaning and compiler support for safety checks. diff --git a/exp/zlup/docs/future/build-system.md b/exp/zlup/docs/future/build-system.md new file mode 100644 index 000000000..648546ce1 --- /dev/null +++ b/exp/zlup/docs/future/build-system.md @@ -0,0 +1,337 @@ +# Build System Design + +This document outlines Zlup's build system, following Zig's philosophy: **the build system IS the language**. + +## Philosophy + +Like Zig's `build.zig`, Zlup uses `build.zlp` - a Zlup program that runs at compile time to configure the build. No separate DSL, no YAML, no TOML for build logic - just Zlup with comptime. + +**Why?** +- **One language to learn**: Build logic uses the same syntax as regular code +- **Full power of comptime**: Conditional compilation, code generation +- **Type-safe configuration**: Compiler catches config errors +- **Debuggable**: Use the same tools to debug build scripts + +## Basic Structure + +### Project Layout + +``` +my-qec-project/ +├── build.zlp # Build configuration (Zlup code) +├── zlup.toml # Simple metadata (name, version, deps) +├── src/ +│ ├── main.zlp # Entry point +│ └── lib/ +│ └── syndrome.zlp +├── tests/ +│ └── test_syndrome.zlp +└── ffi/ + └── decoder/ # Rust decoder crate + ├── Cargo.toml + └── src/lib.rs +``` + +### zlup.toml (Metadata Only) + +Simple metadata that doesn't need comptime logic: + +```toml +[package] +name = "my-qec-project" +version = "0.1.0" +authors = ["Alice "] +license = "Apache-2.0" + +[dependencies] +# External Zlup packages (future) +# other-package = "0.1.0" + +[ffi] +# Rust crates to build and link +decoder = { path = "ffi/decoder" } +``` + +### build.zlp (Build Logic) + +```zlup_nocheck +//! Build configuration for my-qec-project +//! +//! This file runs at compile time to configure the build. + +std := @import("std"); +Build := @import("build"); + +pub fn build(b: *Build) -> unit { + // Get target and optimization from CLI or defaults + target := b.standardTargetOptions(.{}); + optimize := b.standardOptimizeOption(.{}); + + // Main executable + exe := b.addExecutable(.{ + name: "qec-sim", + root_source: "src/main.zlp", + target: target, + optimize: optimize, + }); + + // Link Rust FFI library + exe.linkLibrary("decoder"); + exe.addLibraryPath("ffi/decoder/target/release"); + + // Install artifact + b.installArtifact(exe); + + // Test step + tests := b.addTest(.{ + root_source: "tests/test_syndrome.zlp", + }); + + test_step := b.step("test", "Run unit tests"); + test_step.dependOn(&tests.step); + + return; +} +``` + +## Build API + +### Build Context + +```zlup_nocheck +Build := struct { + // Target configuration + pub fn standardTargetOptions(&self, options: TargetOptions) -> Target { ... } + pub fn standardOptimizeOption(&self, options: OptimizeOptions) -> Optimize { ... } + + // Add build artifacts + pub fn addExecutable(&self, options: ExecutableOptions) -> *Executable { ... } + pub fn addLibrary(&self, options: LibraryOptions) -> *Library { ... } + pub fn addTest(&self, options: TestOptions) -> *Test { ... } + + // Build steps + pub fn step(&self, name: []const u8, description: []const u8) -> *Step { ... } + pub fn installArtifact(&self, artifact: *Artifact) -> unit { ... } + + // Options from command line + pub fn option(&self, comptime T: type, name: []const u8, description: []const u8) -> ?T { ... } +}; +``` + +### Executable Options + +```zlup_nocheck +ExecutableOptions := struct { + name: []const u8, + root_source: []const u8, + target: ?Target = none, + optimize: ?Optimize = none, + strict: bool = false, // NASA Power of 10 strict mode +}; +``` + +### Conditional Compilation + +```zlup_nocheck +pub fn build(b: *Build) -> unit { + // User-defined option + enable_noise := b.option(bool, "noise", "Enable noise modeling") orelse false; + + exe := b.addExecutable(.{ + name: "qec-sim", + root_source: "src/main.zlp", + }); + + // Conditional compilation flags + if enable_noise { + exe.addDefine("ENABLE_NOISE", "1"); + exe.linkLibrary("noise-model"); + } + + // Platform-specific + if b.target.os == .linux { + exe.linkSystemLibrary("pthread"); + } + + return; +} +``` + +### Code Generation + +```zlup_nocheck +pub fn build(b: *Build) -> unit { + // Generate lookup table at build time + table := b.addGeneratedFile("syndrome_table.zlp"); + table.generator = generate_syndrome_table; + + exe := b.addExecutable(.{ + name: "decoder", + root_source: "src/main.zlp", + }); + exe.addModule("syndrome_table", table); + + return; +} + +fn generate_syndrome_table(writer: *Writer) -> unit { + writer.print("// Auto-generated syndrome lookup table\n"); + writer.print("pub table: [256]u8 = [\n"); + + for i in 0..256 { + correction := comptime compute_correction(i); + writer.print(" {},\n", correction); + } + + writer.print("];\n"); + return; +} +``` + +## CLI Integration + +```bash +# Build using build.zlp +zlup build + +# Build with options +zlup build -Dnoise=true -Doptimize=release + +# Run tests +zlup build test + +# Run specific step +zlup build run + +# Show available steps and options +zlup build --help +``` + +## Rust FFI Integration + +The build system handles Rust crate compilation: + +```zlup_nocheck +pub fn build(b: *Build) -> unit { + // Rust decoder crate + decoder := b.addRustLibrary(.{ + name: "decoder", + path: "ffi/decoder", + profile: if b.optimize == .release { "release" } else { "debug" }, + }); + + exe := b.addExecutable(.{ + name: "qec-sim", + root_source: "src/main.zlp", + }); + + // Link the Rust library + exe.linkRustLibrary(decoder); + + return; +} +``` + +This runs `cargo build` on the Rust crate and links the resulting `.a`/`.so`. + +## Multi-Target Builds + +```zlup_nocheck +pub fn build(b: *Build) -> unit { + targets := [_]Target{ + .{ .os = .linux, .arch = .x86_64 }, + .{ .os = .macos, .arch = .aarch64 }, + .{ .os = .windows, .arch = .x86_64 }, + }; + + for target in targets { + exe := b.addExecutable(.{ + name: f"qec-sim-{target.os}-{target.arch}", + root_source: "src/main.zlp", + target: target, + }); + b.installArtifact(exe); + } + + return; +} +``` + +## Comparison with Alternatives + +| Approach | Pros | Cons | +|----------|------|------| +| **build.zlp (Zlup)** | Full language power, type-safe, debuggable | Need Zlup knowledge | +| build.zig (Zig) | Proven approach, powerful | Different language | +| Cargo.toml (Rust) | Simple, declarative | Limited logic | +| CMake | Cross-platform | Complex DSL | +| Make | Universal | Arcane syntax | + +## Implementation Plan + +1. **Phase 1**: Basic build.zlp parsing and execution +2. **Phase 2**: Executable and library targets +3. **Phase 3**: Test integration +4. **Phase 4**: Rust FFI library linking +5. **Phase 5**: Code generation support +6. **Phase 6**: Multi-target builds + +## Example: Complete QEC Project + +```zlup_nocheck +//! build.zlp for a surface code simulator + +std := @import("std"); +Build := @import("build"); + +pub fn build(b: *Build) -> unit { + target := b.standardTargetOptions(.{}); + optimize := b.standardOptimizeOption(.{}); + + // Options + distance := b.option(u32, "distance", "Code distance") orelse 3; + strict := b.option(bool, "strict", "NASA Power of 10 strict mode") orelse true; + + // Rust MWPM decoder + mwpm := b.addRustLibrary(.{ + name: "mwpm", + path: "ffi/mwpm-decoder", + }); + + // Main simulator + sim := b.addExecutable(.{ + name: "surface-sim", + root_source: "src/main.zlp", + target: target, + optimize: optimize, + strict: strict, + }); + sim.addDefine("CODE_DISTANCE", std.fmt.comptimePrint("{}", distance)); + sim.linkRustLibrary(mwpm); + + b.installArtifact(sim); + + // Tests + tests := b.addTest(.{ + root_source: "tests/all.zlp", + strict: strict, + }); + tests.linkRustLibrary(mwpm); + + test_step := b.step("test", "Run all tests"); + test_step.dependOn(&tests.step); + + // Benchmark step + bench := b.addExecutable(.{ + name: "bench", + root_source: "bench/main.zlp", + optimize: .release, + }); + bench.linkRustLibrary(mwpm); + + bench_step := b.step("bench", "Run benchmarks"); + bench_step.dependOn(&bench.run()); + + return; +} +``` diff --git a/exp/zlup/docs/future/custom-gates-design.md b/exp/zlup/docs/future/custom-gates-design.md new file mode 100644 index 000000000..ede1b6c84 --- /dev/null +++ b/exp/zlup/docs/future/custom-gates-design.md @@ -0,0 +1,1493 @@ +# Custom Gate Design Notes + +> **Status:** Design exploration (February 2026) + +## Summary + +This document defines Zlup's gate system. The core design decisions are: + +### All Quantum Operations Are Target-Provided + +Zlup has **no built-in quantum operations**. Everything that touches qubits - gates, +preparation, measurement - is declared and provided by the compilation target. + +- `std.gates` declares the "standard" set (`h`, `cx`, `pz`, `mz`, etc.) +- Targets implement what they support +- Compile error if you use an unsupported operation +- **No fallbacks, no implicit decomposition** + +### Two Types of Gates + +| Type | Syntax | Provider | Use Case | +|------|--------|----------|----------| +| **Target** | `declare gate name(...)` | Hardware/simulator/noise model | Native operations | +| **Composite** | `gate name(...) { body }` | Zlup code | Abstractions, subroutines | + +### Only Two Built-ins + +| Built-in | Purpose | +|----------|---------| +| `qalloc` | Resource allocation (doesn't touch quantum state) | +| `result` | Output emission (classical) | + +Everything else requires `use std.gates.*` or custom declarations. + +### Composite Gates Are Full Subroutines + +Composites can include preparation, measurement, classical logic, control flow, +and return values: + +```zlup_nocheck +gate measure_reset(q: qubit) -> u1 { + r := mz(u1) q; + if r == 1 { x q; } + return r; +} +``` + +### Compile-Time Target Validation + +```bash +zlup compile program.zlp --target trapped_ion +``` + +The compiler validates all gate usage against the target's gate set. Composites +are validated recursively. + +### IDE Support via Project Config + +```toml +# zlup.toml +[target] +default = "trapped_ion" +``` + +IDE reads config and validates accordingly. `@import("target")` gives access to +current target's definitions. + +--- + +## Table of Contents + +- [Core Principle: All Gates Are Target-Provided](#core-principle-all-gates-are-target-provided) +- [Two Types of Gates](#two-types-of-gates) +- [Why This Design](#why-this-design) +- [Proposed Syntax](#proposed-syntax) +- [Noise Model Integration](#noise-model-integration) +- [Composite Gate Generalization](#composite-gate-generalization) +- [Compilation Model](#compilation-model) +- [IDE / Language Server Support](#ide--language-server-support) +- [Open Questions](#open-questions) + +--- + +## Core Principle: All Gates Are Target-Provided + +Zlup has **no built-in gates**. Every gate - including "standard" gates like `h`, `cx`, +`rz` - is declared and must be provided by the compilation target. + +The standard library (`std.gates`) declares the common gate set that most targets support. +But these are not special - they use the same `declare gate` mechanism as any other gate. + +```zlup_nocheck +// In std/gates.zlp - the "standard" operation set +// These are declarations, not implementations + +// Preparation (reset) +pub declare gate pz(q: qubit); // Prepare in |0⟩ (Z basis) +pub declare gate px(q: qubit); // Prepare in |+⟩ (X basis) +pub declare gate py(q: qubit); // Prepare in |+i⟩ (Y basis) + +// Measurement +pub declare gate mz(q: qubit) -> u1; // Measure in Z basis +pub declare gate mx(q: qubit) -> u1; // Measure in X basis +pub declare gate my(q: qubit) -> u1; // Measure in Y basis + +// Single-qubit gates +pub declare gate h(q: qubit); +pub declare gate x(q: qubit); +pub declare gate y(q: qubit); +pub declare gate z(q: qubit); +pub declare gate t(q: qubit); +pub declare gate tdg(q: qubit); +pub declare gate sx(q: qubit); +pub declare gate sy(q: qubit); +pub declare gate sz(q: qubit); + +// Parameterized single-qubit +pub declare gate rx(theta: a64)(q: qubit); +pub declare gate ry(theta: a64)(q: qubit); +pub declare gate rz(theta: a64)(q: qubit); + +// Two-qubit gates +pub declare gate cx(ctrl: qubit, tgt: qubit); +pub declare gate cy(ctrl: qubit, tgt: qubit); +pub declare gate cz(ctrl: qubit, tgt: qubit); +pub declare gate swap(a: qubit, b: qubit); + +// Parameterized two-qubit +pub declare gate rzz(theta: a64)(a: qubit, b: qubit); +pub declare gate crz(theta: a64)(ctrl: qubit, tgt: qubit); + +// Three-qubit +pub declare gate ccx(a: qubit, b: qubit, c: qubit); +``` + +**Targets must implement these operations.** A target that doesn't support `h` or +`pz` will fail at compile time when code uses them. + +## Two Types of Gates + +### Target Gates (Declared) + +Only the signature is declared. The target provides the implementation. + +**Who provides implementations:** +- **Hardware**: Native gates the device supports +- **Simulator**: Matrix implementations, optimized algorithms +- **Noise model**: Gates with specific error characteristics + +```zlup_nocheck +// User declares additional target gates beyond std.gates +declare gate ms(theta: a64)(a: qubit, b: qubit); +declare gate sqrt_iswap(a: qubit, b: qubit); +``` + +**Key property:** No Zlup-level implementation exists. If target doesn't support +the gate, compilation fails. No fallbacks, no hidden decomposition. + +### Composite Gates (Defined) + +Defined in Zlup as sequences of target gates (from `std.gates` or custom declarations) +or other composite gates. + +```zlup_nocheck +// Uses target gates from std.gates (h, cx, rz) +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx(ctrl, tgt); + rz(theta) tgt; + cx(ctrl, tgt); + h tgt; +} + +// Uses custom target gate (ms) - only works on targets that support ms +gate ion_entangle(a: qubit, b: qubit) { + ms(1/4 turns) (a, b); +} + +// Uses another composite gate +gate double_rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + rzx(theta) (ctrl, tgt); + rzx(theta) (ctrl, tgt); +} +``` + +**Key property:** Zlup knows the decomposition. Composite gates are portable across +any target that supports their constituent target gates. A composite using only +`std.gates` works on any standard-compliant target. A composite using `ms` only +works on targets that provide `ms`. + +## Why This Design? + +### Explicit Over Implicit (Zig-style, No Prelude) + +Following Zig, Zlup has **no prelude**. Nothing is automatically imported. + +**Built into the language (always available):** +- Primitive types: `u8`, `i32`, `bool`, `f64`, `a64`, `qubit`, `unit`, etc. +- Keywords: `if`, `for`, `fn`, `gate`, `declare`, `pub`, etc. +- Built-in functions: `@import`, `@size_of`, `@type_info`, etc. +- Resource management: `qalloc` (allocation, not a quantum operation) +- Output: `result` (classical emission, not a quantum operation) + +**Requires explicit import (target-provided):** +- All quantum operations: `h`, `cx`, `rz`, `pz`, `mz`, etc. +- Library functions: `std.math`, `std.bits`, etc. + +```zlup_nocheck +std := @import("std"); +use std.gates.*; // h, cx, rz, pz, mz, ... + +pub fn main() -> unit { + q := qalloc(2); // built-in (resource allocation) + pz q; // imported (target-provided) + h q[0]; // imported (target-provided) + cx (q[0], q[1]); // imported (target-provided) + r := mz(u1) q[0]; // imported (target-provided) + result("out", r); // built-in (output) + return; +} +``` + +The distinction: `qalloc` and `result` are resource/IO operations that don't touch +quantum state. Everything that interacts with qubits (gates, prep, measurement) is +target-provided because targets implement these differently and noise models need +to reason about them. + +This means you can always answer "where did this come from?" by looking at imports. + +### Fail Fast + +If a target doesn't support a gate you use, compilation fails immediately with a +clear error message. No silent fallbacks or hidden decompositions that might +introduce unexpected behavior or performance characteristics. + +### Target Flexibility + +Different targets have different native gate sets: +- Trapped ion: `ms`, `gpi`, `gpi2` +- Superconducting: `sqrt_iswap`, `sycamore` +- Photonic: `beamsplitter`, `phase_shift` + +Rather than trying to support all of these as "built-ins", each target declares +what it supports. Users declare additional gates as needed. + +### Noise Model Control + +Noise models are targets too. A noise model might: +- Provide standard gates with calibrated error rates +- Provide synthetic gates for testing (`perfect_cx`, `very_noisy_cx`) +- Reject gates it doesn't have noise data for + +## Proposed Syntax + +### Importing Standard Gates + +All quantum operations (including `pz` and `mz`) must be imported before use: + +```zlup_nocheck +// Import all standard operations +std := @import("std"); +use std.gates.*; // pz, mz, h, x, cx, rz, etc. + +pub fn main() -> unit { + q := qalloc(2); + pz q; // from std.gates + h q[0]; // from std.gates + cx (q[0], q[1]); // from std.gates + r := mz(u1) q[0]; // from std.gates + result("out", r); + return; +} +``` + +Or import selectively: + +```zlup_nocheck +std := @import("std"); +use std.gates.{pz, mz, h, cx}; // only import what you need + +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + r := mz(u1) q[0]; + result("out", r); + // x q[0]; // Error: 'x' not imported + return; +} +``` + +Or use qualified names: + +```zlup_nocheck +std := @import("std"); + +pub fn main() -> unit { + q := qalloc(2); + std.gates.pz q; + std.gates.h q[0]; + std.gates.cx (q[0], q[1]); + r := std.gates.mz(u1) q[0]; + result("out", r); + return; +} +``` + +### Composite Gates + +```zlup_nocheck +// Basic composite gate with angle parameter +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +// Fixed gate (no parameters) +gate echo(q: qubit) { + x q; + x q; +} + +// Multi-qubit gate +gate toffoli(a: qubit, b: qubit, c: qubit) { + h c; + cx (b, c); tdg c; + cx (a, c); t c; + cx (b, c); tdg c; + cx (a, c); + t b; t c; h c; + cx (a, b); + t a; tdg b; + cx (a, b); +} + +// Public gate (exported from module) +pub gate logical_h(data: [9]qubit) { + inline for i in 0..9 { + h data[i]; + } +} +``` + +### Target Gates (Declared) + +```zlup_nocheck +// Declare a gate the target must provide +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// No angle parameters +declare gate sqrt_swap(a: qubit, b: qubit); + +// Three-qubit native gate +declare gate native_toffoli(a: qubit, b: qubit, c: qubit); + +// With target hint (documentation/validation) +@target("trapped_ion") +declare gate ms(theta: a64)(a: qubit, b: qubit); + +@target("simulator:pecos") +declare gate optimized_toffoli(a: qubit, b: qubit, c: qubit); + +@target("noise_model:depolarizing") +declare gate noisy_cx(ctrl: qubit, tgt: qubit); +``` + +### Usage + +```zlup_nocheck +std := @import("std"); +use std.gates.*; // pz, mz, h, cx, rz, ... + +// Declare additional target gate +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// Define composite gate (uses std.gates) +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +gate echo(q: qubit) { + x q; + x q; +} + +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Use standard gate + h q[0]; + + // Use composite gate + rzx(1/4 turns) (q[0], q[1]); + + // Use target gate (target must support) + ms(1/8 turns) (q[2], q[3]); + + // Batch syntax works too + echo {q[0], q[1], q[2]}; + + // Measure + r := mz(u1) q[0]; + result("out", r); + + return; +} +``` + +## Noise Model Integration + +The key question: how should noise models treat composite gates? + +### Option A: Atomic by Default + +Composite gates are treated as single units for noise purposes. The noise model +sees "one rzx gate" not "h, cx, rz, cx, h". + +```zlup_nocheck +// Default: noise model treats as atomic +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +// Override: apply noise to each constituent +@decomposed +gate debug_rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + // Same body, but noise applied per-gate +} +``` + +**Rationale:** Most custom gates are defined precisely because users want to +reason about them as units. Debugging/analysis can use `@decomposed`. + +### Option B: Decomposed by Default + +Noise applied to each constituent gate. Mark atomic explicitly. + +```zlup_nocheck +// Default: noise per constituent +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { ... } + +// Override: treat as single unit +@atomic +gate atomic_rzx(theta: a64)(ctrl: qubit, tgt: qubit) { ... } +``` + +**Rationale:** More conservative; explicit atomicity prevents surprises. + +### Option C: Noise Model Decides + +Gate definition doesn't specify; noise model configuration lists atomic gates. + +```json +// noise_config.json +{ + "atomic_gates": ["rzx", "logical_h", "echo"], + "decomposed_gates": ["debug_toffoli"] +} +``` + +**Rationale:** Same gate can be atomic or decomposed depending on simulation needs. + +### Recommendation + +**Option A (atomic by default)** with **Option C (noise model override)**. + +- Gates defined with `gate` keyword are atomic by default +- `@decomposed` attribute forces per-constituent noise +- Noise model config can override either direction + +```zlup_nocheck +// Atomic by default +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { ... } + +// Force decomposed (ignores noise model config) +@decomposed +gate always_decomposed(q: qubit) { ... } + +// Force atomic (ignores noise model config) +@atomic +gate always_atomic(q: qubit) { ... } +``` + +## Inlining Behavior + +Separate from noise, inlining affects optimization: + +```zlup_nocheck +// Default: compiler decides based on size/usage +gate small_gate(q: qubit) { x q; } + +// Force inline (always expand at call site) +@inline +gate must_inline(q: qubit) { ... } + +// Prevent inline (preserve structure in output) +@noinline +gate preserve_structure(q: qubit) { ... } +``` + +**Interaction with noise:** +- `@inline` + atomic noise = inline then apply atomic noise +- `@noinline` + decomposed noise = keep structure, noise per constituent + +## Arity and Type Checking + +The compiler tracks gate signatures for type checking: + +```zlup_nocheck +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { ... } +// ^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +// 1 angle 2 qubits + +// Valid +rzx(1/4 turns) (q[0], q[1]); + +// Error: wrong number of angle parameters +rzx (q[0], q[1]); // Missing angle + +// Error: wrong number of qubits +rzx(1/4 turns) (q[0]); // Needs 2 qubits + +// Error: type mismatch +rzx(42) (q[0], q[1]); // 42 is not an angle +``` + +### Target Gate Validation + +For target gates, validation happens at codegen time: + +```zlup_nocheck +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// At compile time: type-checked against declaration +ms(1/4 turns) (q[0], q[1]); // OK + +// At codegen time: check if target supports "ms" +// Error if target doesn't have "ms" with matching signature +``` + +**Target capabilities:** + +Different targets support different gates: + +| Target | Example Supported Gates | +|--------|------------------------| +| Hardware (trapped ion) | `ms`, `gpi`, `gpi2` | +| Hardware (superconducting) | `sqrt_iswap`, `sycamore` | +| Simulator (statevector) | Any gate with provided matrix | +| Noise model | Gates with calibrated noise parameters | + +The `@target` hint helps catch mismatches early: + +```zlup_nocheck +@target("trapped_ion") +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// Compiling with --target superconducting: +// Warning: gate 'ms' declared for 'trapped_ion' but targeting 'superconducting' +``` + +## AST Representation + +```rust +/// Custom gate definition (composite) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateDecl { + /// Gate name + pub name: String, + /// Angle/value parameters (e.g., theta: a64) + pub params: Vec, + /// Qubit parameters with names + pub qubits: Vec, + /// Gate body (sequence of statements) + pub body: Block, + /// Whether this gate is public + pub is_pub: bool, + /// Noise behavior + pub noise_mode: NoiseMode, + /// Inlining behavior + pub inline_mode: InlineMode, + pub location: Option, +} + +/// Target gate (provided by compilation target) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TargetGate { + /// Gate name (must match target's implementation) + pub name: String, + /// Angle/value parameters + pub params: Vec, + /// Qubit count and names + pub qubits: Vec, + /// Optional target hint (hardware, simulator, noise_model) + pub target_hint: Option, + pub location: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateParam { + pub name: String, + pub ty: Type, // Usually Type::Angle (a64) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QubitParam { + pub name: String, + // Future: could support qubit arrays [N]qubit +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub enum NoiseMode { + #[default] + Atomic, // Treat as single unit for noise + Decomposed, // Apply noise to each constituent +} + +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)] +pub enum InlineMode { + #[default] + Auto, // Compiler decides + Always, // @inline + Never, // @noinline +} +``` + +## SLR Output + +Composite gates can be output in two ways: + +### Expanded (Default) + +Gate body is inlined at each call site: + +```json +{ + "type": "GateOp", + "kind": "H", + "targets": [{"allocator": "q", "index": 1}] +}, +{ + "type": "GateOp", + "kind": "CX", + "targets": [ + {"allocator": "q", "index": 0}, + {"allocator": "q", "index": 1} + ] +} +// ... etc +``` + +### Preserved Structure + +When `@noinline` or for backends that support custom gates: + +```json +{ + "type": "CustomGateOp", + "name": "rzx", + "params": [{"type": "angle", "value": 0.25, "unit": "turns"}], + "targets": [ + {"allocator": "q", "index": 0}, + {"allocator": "q", "index": 1} + ], + "atomic_noise": true +} +``` + +### Target Gates + +Always output as target gate ops (no body to expand): + +```json +{ + "type": "TargetGateOp", + "name": "ms", + "params": [{"type": "angle", "value": 0.125, "unit": "turns"}], + "targets": [ + {"allocator": "q", "index": 0}, + {"allocator": "q", "index": 1} + ], + "target_hint": "trapped_ion" +} +``` + +The target (hardware, simulator, or noise model) interprets this gate according to +its own implementation. + +## Examples + +### QEC: Logical Gates + +```zlup_nocheck +std := @import("std"); +use std.gates.{h, cx, pz}; + +// Distance-3 surface code logical Hadamard +pub gate logical_h(data: [9]qubit) { + // Transversal H + inline for i in 0..9 { + h data[i]; + } +} + +// Logical CNOT between two codes +pub gate logical_cx(ctrl: [9]qubit, tgt: [9]qubit) { + inline for i in 0..9 { + cx (ctrl[i], tgt[i]); + } +} + +pub fn main() -> unit { + mut base := qalloc(18); + code1 := base.child(9); + code2 := base.child(9); + + pz code1; + pz code2; + + logical_h(code1); + logical_cx(code1, code2); + + return; +} +``` + +### Target: Hardware (Trapped Ion) + +```zlup_nocheck +std := @import("std"); +use std.gates.pz; + +// Mølmer-Sørensen gate (trapped ion native) +@target("trapped_ion") +declare gate ms(theta: a64)(a: qubit, b: qubit); + +// Global rotation (all ions) +@target("trapped_ion") +declare gate gpi(theta: a64)(q: qubit); + +pub fn ion_bell_state() -> unit { + q := qalloc(2); + pz q; + + // Native trapped-ion Bell state preparation + ms(1/4 turns) (q[0], q[1]); + + return; +} +``` + +### Target: Simulator + +```zlup_nocheck +std := @import("std"); +use std.gates.pz; + +// Simulator provides optimized implementation +@target("simulator") +declare gate optimized_toffoli(a: qubit, b: qubit, c: qubit); + +// Simulator-specific controlled rotation (avoids decomposition) +@target("simulator:pecos") +declare gate crx(theta: a64)(ctrl: qubit, tgt: qubit); + +pub fn use_simulator_gates() -> unit { + q := qalloc(3); + pz q; + + // Simulator applies this directly (no decomposition overhead) + optimized_toffoli(q[0], q[1], q[2]); + + return; +} +``` + +### Target: Noise Model + +```zlup_nocheck +std := @import("std"); +use std.gates.pz; + +// Noise model provides gate with calibrated error characteristics +@target("noise_model:device_xyz") +declare gate calibrated_cx(ctrl: qubit, tgt: qubit); + +// Noise model might define entirely synthetic gates for testing +@target("noise_model:test") +declare gate perfect_cx(ctrl: qubit, tgt: qubit); // No noise +@target("noise_model:test") +declare gate very_noisy_cx(ctrl: qubit, tgt: qubit); // High error rate + +pub fn noise_comparison() -> unit { + q := qalloc(4); + pz q; + + // Compare different noise characteristics + calibrated_cx(q[0], q[1]); // Realistic noise from calibration data + perfect_cx(q[2], q[3]); // Idealized for debugging + + return; +} +``` + +### Dynamical Decoupling + +```zlup_nocheck +std := @import("std"); +use std.gates.{h, x, y, pz}; + +// Echo sequence for noise suppression +@atomic // Noise model sees this as one "echo" operation +gate echo_xy(q: qubit) { + x q; + y q; + x q; + y q; +} + +// CPMG sequence +@atomic +gate cpmg(n: comptime usize)(q: qubit) { + inline for _ in 0..n { + x q; + x q; + } +} + +pub fn protected_operation() -> unit { + q := qalloc(1); + pz q; + + h q[0]; + echo_xy q[0]; // Protect during idle time + h q[0]; + + return; +} +``` + +### Debugging with Decomposed Noise + +```zlup_nocheck +std := @import("std"); +use std.gates.{h, cx, rz}; + +// Atomic noise (default) +gate rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +// Same gate, but see noise on each component +@decomposed +gate debug_rzx(theta: a64)(ctrl: qubit, tgt: qubit) { + h tgt; + cx (ctrl, tgt); + rz(theta) tgt; + cx (ctrl, tgt); + h tgt; +} + +// Compare noise behavior +pub fn compare_noise() -> unit { + q := qalloc(4); + pz q; + + // Atomic noise + rzx(1/4 turns) (q[0], q[1]); + + // Per-gate noise (for analysis) + debug_rzx(1/4 turns) (q[2], q[3]); + + return; +} +``` + +## Composite Gate Generalization + +Composite gates need to be more than just sequences of other gates. Real quantum +operations often include preparation, measurement, classical logic, and feedforward. + +### What Composites Need to Support + +```zlup_nocheck +std := @import("std"); +use std.gates.*; + +// Measure-and-reset: returns measurement, leaves qubit in |0⟩ +gate measure_reset(q: qubit) -> u1 { + r := mz(u1) q; + if r == 1 { + x q; // flip back to |0⟩ + } + return r; +} + +// Teleportation protocol +gate teleport(psi: qubit, epr0: qubit, epr1: qubit) -> (u1, u1) { + // Bell measurement + cx (psi, epr0); + h psi; + m1 := mz(u1) psi; + m2 := mz(u1) epr0; + + // Feedforward corrections + if m2 == 1 { x epr1; } + if m1 == 1 { z epr1; } + + return (m1, m2); +} + +// Repeat-until-success T gate +gate rus_t(q: qubit, ancilla: qubit) -> bool { + for attempt in 0..10 { // bounded attempts + pz ancilla; + h ancilla; + t ancilla; + cx (q, ancilla); + h ancilla; + + r := mz(u1) ancilla; + if r == 0 { + return true; // success + } + // Undo partial operation and retry + sz q; // correction + } + return false; // failed after max attempts +} + +// Syndrome extraction round +gate extract_syndrome(data: [4]qubit, ancilla: qubit) -> u1 { + pz ancilla; + h ancilla; + + inline for i in 0..4 { + cx (ancilla, data[i]); + } + + h ancilla; + return mz(u1) ancilla; +} +``` + +### What This Means + +Composite gates can include: + +| Feature | Example | Purpose | +|---------|---------|---------| +| Target gates | `h q; cx (a, b);` | Core operations | +| Preparation | `pz ancilla;` | Initialize qubits mid-circuit | +| Measurement | `r := mz(u1) q;` | Extract classical information | +| Return values | `-> u1`, `-> (u1, u1)` | Return measurement results | +| Classical variables | `r := mz(...); count += 1;` | Track state | +| Control flow | `if r == 1 { x q; }` | Feedforward corrections | +| Bounded loops | `for i in 0..n { ... }` | Repeated operations | +| Other composites | `measure_reset(q);` | Composition | + +### Gate vs Function: What's the Difference? + +If gates can do all this, how are they different from functions? + +| Aspect | `gate` | `fn` | +|--------|--------|------| +| **Semantics** | "This is a quantum operation" | General code | +| **Noise model** | Treated as unit (by default) | No special treatment | +| **Target override** | Target can provide native impl | No target override | +| **Inlining** | Controlled by `@atomic`/`@decomposed` | Normal inlining rules | +| **Scheduling** | May have timing implications | No timing semantics | + +The key distinction: a `gate` declaration says "this is a quantum operation that +targets and noise models should reason about". A function is just code. + +```zlup_nocheck +// Gate: noise model can treat as atomic "teleport" operation +gate teleport(psi: qubit, epr0: qubit, epr1: qubit) -> (u1, u1) { ... } + +// Function: just code, no special quantum semantics +fn run_teleportation_experiment(shots: u32) -> unit { ... } +``` + +### Noise Model Implications + +When a composite gate includes measurement and feedforward: + +**Atomic mode (`@atomic` or default):** +- Noise model sees one "teleport" operation +- Applies noise characteristic of the whole operation +- Internal structure hidden from noise model + +**Decomposed mode (`@decomposed`):** +- Noise model sees each constituent operation +- `cx`, `h`, `mz`, conditional `x`, conditional `z` each get noise +- Full visibility into structure + +```zlup_nocheck +// Atomic: noise model sees "measure_reset" as one operation +gate measure_reset(q: qubit) -> u1 { ... } + +// Decomposed: noise model sees mz + conditional x +@decomposed +gate debug_measure_reset(q: qubit) -> u1 { ... } +``` + +## Compilation Model + +When compiling Zlup code, you specify a target. The target declares what gates it +supports. The compiler validates your code against the target's gate set. + +### Target Gate Sets + +Each target provides a gate set definition: + +``` +# trapped_ion.target +gates: + - pz, px + - mz, mx + - gpi, gpi2 + - ms + - rz + +# superconducting.target +gates: + - pz + - mz + - h, x, y, z + - rx, ry, rz + - cx, cz + - sqrt_iswap + +# simulator_pecos.target +gates: + - [all of std.gates] + - optimized_toffoli + - any_unitary # simulator can do arbitrary unitaries +``` + +### Compile-Time Validation + +```bash +# Compile for trapped ion target +zlup compile program.zlp --target trapped_ion +``` + +The compiler: +1. Loads target's gate set +2. Checks every gate usage in your code +3. Errors if you use a gate the target doesn't support + +```zlup_nocheck +std := @import("std"); +use std.gates.*; + +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; // Error on trapped_ion: 'h' not supported + cx (q[0], q[1]); // Error on trapped_ion: 'cx' not supported + return; +} +``` + +``` +error: gate 'h' not supported by target 'trapped_ion' + --> program.zlp:7:5 + | + 7 | h q[0]; + | ^ target 'trapped_ion' does not provide 'h' + | + = help: trapped_ion supports: pz, px, mz, mx, gpi, gpi2, ms, rz + = help: consider decomposing 'h' using supported gates +``` + +### Composites Are Validated Recursively + +When you define a composite gate, the compiler checks that all gates it uses are +supported by the target: + +```zlup_nocheck +// This composite uses h and cx +gate bell(a: qubit, b: qubit) { + h a; + cx (a, b); +} + +pub fn main() -> unit { + q := qalloc(2); + pz q; + bell(q[0], q[1]); // Error: bell uses 'h' and 'cx', not supported + return; +} +``` + +The error traces through: +``` +error: gate 'h' not supported by target 'trapped_ion' + --> program.zlp:4:5 + | + 4 | h a; + | ^ in composite gate 'bell' + | + --> program.zlp:11:5 + | +11 | bell(q[0], q[1]); + | ^^^^ called here +``` + +### Writing Portable Code + +To write code that works on multiple targets: + +**Option 1: Use only common gates** +```zlup_nocheck +// Only use gates that all your targets support +// This limits expressiveness but maximizes portability +``` + +**Option 2: Target-specific modules** +```zlup_nocheck +// trapped_ion/bell.zlp +gate bell(a: qubit, b: qubit) { + ms(1/4 turns) (a, b); + // ... ion-native implementation +} + +// superconducting/bell.zlp +gate bell(a: qubit, b: qubit) { + h a; + cx (a, b); +} + +// main.zlp - import based on target +bell := @import(@target_name() ++ "/bell.zlp"); +``` + +**Option 3: Comptime target checking** +```zlup_nocheck +gate bell(a: qubit, b: qubit) { + if (comptime @target_has("ms")) { + ms(1/4 turns) (a, b); + } else if (comptime @target_has("cx")) { + h a; + cx (a, b); + } else { + @compile_error("No known bell implementation for this target"); + } +} +``` + +### No Implicit Fallbacks + +The compiler never silently substitutes one gate for another. If you write `h q[0]` +and the target doesn't support `h`, you get a compile error. Period. + +This is intentional: +- **Explicit**: You know exactly what gates your code uses +- **Predictable**: No surprise decompositions affecting performance/noise +- **Auditable**: Code review can verify target compatibility + +## IDE / Language Server Support + +The IDE needs to know which target you're developing for to provide proper +errors, autocomplete, and diagnostics. Several approaches: + +### Option A: Project Configuration + +Like Rust's `Cargo.toml`, a project config specifies the default target: + +```toml +# zlup.toml +[project] +name = "my_qec_code" + +[target] +default = "trapped_ion" + +# Optional: define multiple targets for the project +[target.trapped_ion] +definition = "targets/trapped_ion.toml" + +[target.simulator] +definition = "targets/pecos_simulator.toml" +``` + +The IDE reads `zlup.toml` and uses the default target for validation. This is +similar to how rust-analyzer reads `Cargo.toml`. + +### Option B: Import Target Gate Set + +Make the target's gate set explicitly importable: + +```zlup_nocheck +// Import current target's definitions (set by zlup.toml or CLI) +target := @import("target"); +use target.gates.*; // IDE knows exactly which gates are available + +pub fn main() -> unit { + q := qalloc(2); + pz q; // IDE: ✓ available in target + h q[0]; // IDE: ✗ not available in trapped_ion (red squiggle) + return; +} +``` + +Or import a specific target explicitly (useful during development): + +```zlup_nocheck +// Explicitly develop against trapped_ion +ion := @import("targets/trapped_ion"); +use ion.gates.*; + +pub fn main() -> unit { + q := qalloc(2); + pz q; + ms(1/4 turns) (q[0], q[1]); // IDE knows ms is available + return; +} +``` + +### Option C: Builtin Target Info (Zig-style) + +Like Zig's `@import("builtin")`: + +```zlup_nocheck +builtin := @import("builtin"); + +// Access target info at comptime +const target_name = builtin.target.name; // "trapped_ion" +const has_cx = builtin.target.has_gate("cx"); // false + +// Conditional compilation +if (comptime builtin.target.has_gate("cx")) { + cx (q[0], q[1]); +} else { + // Alternative implementation +} +``` + +### Option D: Per-File Target Annotation + +For files that are target-specific: + +```zlup_nocheck +//! target: trapped_ion +//! This module contains trapped-ion specific implementations + +std := @import("std"); +use std.gates.*; + +// IDE knows to validate against trapped_ion +pub gate ion_bell(a: qubit, b: qubit) { + ms(1/4 turns) (a, b); +} +``` + +### Recommended Approach + +Combine several mechanisms: + +1. **Project config (`zlup.toml`)**: Default target for the project, IDE reads this +2. **Explicit target import**: `target := @import("target")` makes dependencies clear +3. **Per-file annotations**: Override for target-specific files +4. **Builtin info**: `@import("builtin").target` for comptime decisions + +```zlup_nocheck +// Most files: use project default via @import("target") +target := @import("target"); +use target.gates.*; + +// Target-specific file: annotate at top +//! target: trapped_ion +ion := @import("targets/trapped_ion"); +use ion.gates.*; + +// Portable code: check at comptime +builtin := @import("builtin"); +if (comptime builtin.target.has_gate("ms")) { + // Use native MS gate +} else { + // Use decomposition +} +``` + +### How Zig and Rust Handle This + +**Zig:** +```zig +const builtin = @import("builtin"); +const native_arch = builtin.cpu.arch; + +if (native_arch == .x86_64) { + // x86-specific code +} +``` +- Build system (`build.zig`) specifies target +- IDE reads build.zig +- `@import("builtin")` gives target info in code + +**Rust:** +```rust +#[cfg(target_arch = "x86_64")] +fn optimized_impl() { ... } + +#[cfg(not(target_arch = "x86_64"))] +fn optimized_impl() { ... } +``` +- `Cargo.toml` + `.cargo/config.toml` specify target +- rust-analyzer reads config +- `cfg` attributes for conditional compilation +- `cfg!` macro for runtime checks (but resolved at compile time) + +### Target Definition Files + +Targets are defined in TOML files: + +```toml +# targets/trapped_ion.toml +[target] +name = "trapped_ion" +description = "Generic trapped ion quantum computer" + +[gates] +# Preparation and measurement +prep = ["pz", "px"] +meas = ["mz", "mx"] + +# Native single-qubit +single = ["gpi", "gpi2", "rz"] + +# Native two-qubit +two = ["ms"] + +# Not natively supported (would need decomposition) +# h, x, y, z, cx, cy, cz - not listed, so not available +``` + +```toml +# targets/pecos_simulator.toml +[target] +name = "pecos_simulator" +description = "PECOS statevector simulator" + +[gates] +# Simulator supports everything in std.gates +include = ["std.gates.*"] + +# Plus some simulator-specific operations +extra = ["any_unitary", "state_snapshot", "density_matrix"] +``` + +The IDE loads the target definition and validates accordingly. + +## Open Questions + +1. **Qubit array parameters:** Should gates support `[N]qubit` parameters? + ```zlup_nocheck + gate transversal_h(data: [N]qubit) { ... } // Generic over N? + gate logical_h(data: [9]qubit) { ... } // Fixed size? + ``` + +2. **Classical parameters:** Should gates support non-angle parameters? + ```zlup_nocheck + gate conditional_x(condition: bool)(q: qubit) { + if condition { x q; } + } + ``` + +3. **Return values:** Should gates return measurement results? + ```zlup_nocheck + gate measure_reset(q: qubit) -> u1 { + result := mz(u1) q; + pz q; + return result; + } + ``` + Or should this remain a function, not a gate? + +4. **Gate composition:** Should gates call other custom gates? + ```zlup_nocheck + gate double_echo(q: qubit) { + echo q; + echo q; + } + ``` + +5. **Verification:** Should we allow assertions about gate behavior? + ```zlup_nocheck + @unitary_check // Verify at comptime that this is unitary + gate my_gate(q: qubit) { ... } + ``` + +6. **Target capability checking:** Compile error at codegen time if target doesn't + support the gate. No fallbacks - explicit failure is the only option. + +7. **Multiple target hints:** What if code uses gates from multiple targets? + ```zlup_nocheck + @target("trapped_ion") + declare gate ms(theta: a64)(a: qubit, b: qubit); + + @target("superconducting") + declare gate sqrt_iswap(a: qubit, b: qubit); + + // Compile error if targeting trapped_ion and using sqrt_iswap + // Clear message: "gate 'sqrt_iswap' declared for 'superconducting' + // but compiling for 'trapped_ion'" + ``` + +8. **Composite portability:** Should composites be allowed to use target-specific gates? + ```zlup_nocheck + // This composite only works on trapped_ion targets + gate ion_bell(a: qubit, b: qubit) { + ms(1/4 turns) (a, b); // ms is trapped_ion specific + } + // Should compiler warn? Infer target requirement? Just fail at codegen? + ``` + +## Implementation Plan + +### Phase 1: Gate Declaration Infrastructure +- Add `declare gate` syntax to parser +- Move standard gates to `std/gates.zlp` as declarations +- Target capability registry (what gates each target supports) +- Compile-time validation: error if target doesn't support used gate + +### Phase 2: Composite Gates +- Add `gate` keyword for composite definitions +- Inline expansion at call sites +- Type checking for arity (angles, qubits) + +### Phase 3: Noise Attributes +- Add `@atomic` / `@decomposed` attributes for composites +- SLR output with noise hints +- Noise model integration in PECOS + +### Phase 4: Advanced Features +- Qubit array parameters +- Composite gates calling other composites +- Inlining control (`@inline`, `@noinline`) +- `@target` hint attribute for documentation/validation + +--- + +## Summary + +Zlup has **no built-in quantum operations**. All quantum operations are either: + +| Type | Syntax | Body | Provider | +|------|--------|------|----------| +| Target | `declare gate name(...)` | None | Hardware, simulator, or noise model | +| Composite | `gate name(...) { }` | Required | Zlup code (sequences of other gates) | + +**Target gates** are provided by the compilation target. The standard library +(`std.gates`) declares the common operation set that most targets support: +- Preparation: `pz`, `px`, `py` +- Measurement: `mz`, `mx`, `my` +- Gates: `h`, `x`, `cx`, `rz`, etc. + +If a target doesn't support an operation, compilation fails - no fallbacks, no hidden magic. + +**Composite gates** are defined in Zlup as sequences of other gates. They're portable +across any target that supports their constituent gates. Noise models can treat them +as atomic units or decompose them. + +**Only two things are built-in:** +- `qalloc` - resource allocation (doesn't touch quantum state) +- `result` - output emission (classical) + +Key design choices: +- **All quantum operations are target-provided** - even "standard" ones +- **One import for everything quantum** - `use std.gates.*` +- **No fallbacks** - explicit failure if target doesn't support an operation +- **Fail fast** - compile-time errors, not runtime surprises +- **Atomic noise by default** for composites +- **Explicit attributes** to override noise/inlining behavior diff --git a/exp/zlup/docs/future/guppy-compat.md b/exp/zlup/docs/future/guppy-compat.md new file mode 100644 index 000000000..702b90b4b --- /dev/null +++ b/exp/zlup/docs/future/guppy-compat.md @@ -0,0 +1,276 @@ +# Guppy-Zlup Compatibility Layer + +This document outlines the strategy for Guppy ↔ Zlup interoperability. + +## Key Insight: The Linter is Valuable Independently + +Even if Zlup never sees wide adoption, a **Guppy linter enforcing NASA Power of 10 constraints** would be valuable on its own. It would: + +- Improve reliability of production Guppy code +- Catch common bugs (unbounded loops, recursion, dynamic allocation) +- Establish best practices for QEC code quality +- Create a "reliable Guppy" subset for mission-critical applications + +**The linter is the contribution, Zlup conversion is a bonus.** + +## The Challenge + +- Guppy is Python-embedded, Zlup is Rust-native +- Direct FFI between Python and Zlup is complex +- Zlup is more restrictive than Guppy (NASA Power of 10) +- Not all Guppy programs can become Zlup programs + +## Strategy: NASA Power of 10 Guppy (with Optional Zlup Conversion) + +Rather than trying to make arbitrary Guppy work with Zlup, we define a **constrained subset of Guppy** that maps cleanly to Zlup. This is enforced by a linter. + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Full Guppy │ +│ (Python-embedded, linear types, full flexibility) │ +└─────────────────────────┬───────────────────────────────────┘ + │ guppy-lint --zlup-compat +┌─────────────────────────▼───────────────────────────────────┐ +│ "Zlup-Compatible Guppy" │ +│ • Bounded loops only (no while True) │ +│ • No recursion │ +│ • Explicit resource management │ +│ • Fixed allocations at function entry │ +│ • No dynamic Python features │ +└─────────────────────────┬───────────────────────────────────┘ + │ guppy-to-zlup (mechanical transform) +┌─────────────────────────▼───────────────────────────────────┐ +│ Zlup │ +└─────────────────────────────────────────────────────────────┘ +``` + +## Zlup-Compatible Guppy Constraints + +### 1. Bounded Loops Only + +```python +# ❌ NOT Zlup-compatible +while condition: + do_something() + +# ❌ NOT Zlup-compatible +while True: + if done: break + +# ✅ Zlup-compatible +for i in range(100): + do_something() + +# ✅ Zlup-compatible (with early exit) +for i in range(100): + if done: break +``` + +**Lint message:** +``` +error[ZLUP001]: unbounded loop not Zlup-compatible + --> circuit.py:42 + | +42 | while condition: + | ^^^^^ use bounded `for i in range(N)` instead + | +help: replace with bounded loop + | +42 | for _ in range(MAX_ITERATIONS): +43 | if not condition: break +``` + +### 2. No Recursion + +```python +# ❌ NOT Zlup-compatible +def factorial(n): + if n <= 1: return 1 + return n * factorial(n - 1) + +# ✅ Zlup-compatible +def factorial(n): + result = 1 + for i in range(1, n + 1): + result *= i + return result +``` + +**Lint message:** +``` +error[ZLUP002]: recursive function not Zlup-compatible + --> math.py:5 + | + 5 | return n * factorial(n - 1) + | ^^^^^^^^^ recursive call + | +help: convert to iterative form with bounded loop +``` + +### 3. Fixed Allocations + +```python +# ❌ NOT Zlup-compatible (dynamic allocation in loop) +for round in range(100): + qubits = allocate(compute_size(round)) # Size varies! + +# ✅ Zlup-compatible (fixed allocation) +qubits = allocate(MAX_SIZE) +for round in range(100): + use_qubits(qubits[:needed_size(round)]) +``` + +### 4. No Dynamic Python Features + +```python +# ❌ NOT Zlup-compatible +gate_name = "h" if condition else "x" +getattr(circuit, gate_name)(qubit) # Dynamic dispatch + +# ✅ Zlup-compatible +if condition: + circuit.h(qubit) +else: + circuit.x(qubit) +``` + +## Linter Implementation + +The linter would be a Guppy plugin or standalone tool: + +```bash +# Check if Guppy code is Zlup-compatible +guppy-lint --zlup-compat circuit.py + +# Generate Zlup from compatible Guppy +guppy-to-zlup circuit.py -o circuit.zlp +``` + +### Lint Rules + +| Rule | Description | Severity | +|------|-------------|----------| +| ZLUP001 | Unbounded loop | Error | +| ZLUP002 | Recursive function | Error | +| ZLUP003 | Dynamic allocation in loop | Error | +| ZLUP004 | Dynamic dispatch | Error | +| ZLUP005 | Unchecked error | Warning | +| ZLUP006 | Missing type annotation | Warning | +| ZLUP007 | Complex control flow | Warning | + +## Transformation Errors + +When `guppy-to-zlup` cannot transform code, it provides actionable feedback: + +``` +error: cannot transform to Zlup + --> syndrome.py:78 + | +78 | while defects: + | ^^^^^ unbounded loop + | +help: this pattern often indicates MWPM-style iteration + consider moving this logic to a Rust decoder: + + 1. Create a Rust function implementing the algorithm + 2. Export via zlup-ffi with #[zlup_export] + 3. Call from Zlup: `correction := mwpm_decode(syndrome);` + + See: docs/rust-integration.md +``` + +## HUGR as Intermediate + +For cases where direct transformation isn't possible, HUGR provides an intermediate: + +``` +Guppy → HUGR → (optimize) → Zlup +``` + +This allows: +- Guppy-native optimizations before Zlup conversion +- Shared optimization passes between Guppy and Zlup +- Gradual migration path + +## Benefits + +1. **Gradual Adoption**: Teams can lint existing Guppy code toward Zlup compatibility +2. **Clear Errors**: When code can't be converted, users know exactly why +3. **Best of Both**: Research in Guppy, production in Zlup +4. **NASA Power of 10 for Guppy**: The linter brings reliability principles to Python QEC code + +## Standalone Value: guppy-lint Without Zlup + +The linter is useful **even without any Zlup conversion**: + +```bash +# Just lint for reliability, no Zlup involved +guppy-lint --strict circuit.py +``` + +### Use Cases + +| Use Case | Zlup Needed? | Value | +|----------|--------------|-------| +| Catch unbounded loops | No | Prevent hangs in production | +| Flag recursion | No | Predictable stack usage | +| Detect dynamic allocation in hot paths | No | Consistent memory usage | +| Enforce explicit error handling | No | Robust fault tolerance | +| Require type annotations | No | Better static analysis | +| **Convert to Zlup** | Yes | Rust-native execution | + +### "Reliable Guppy" Subset + +The linter effectively defines a **"Reliable Guppy"** subset: + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Full Guppy │ +│ (All Python flexibility, linear types) │ +│ │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ "Reliable Guppy" │ │ +│ │ (Bounded, predictable, NASA Power of 10) │ │ +│ │ │ │ +│ │ • Production QEC systems │ │ +│ │ • Mission-critical code │ │ +│ │ • Code that needs formal analysis │ │ +│ │ │ │ +│ │ ┌─────────────────────────────────────────────────┐ │ │ +│ │ │ Zlup-Convertible │ │ │ +│ │ │ (Can mechanically transform to Zlup) │ │ │ +│ │ └─────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +Teams can choose their level: +1. **Full Guppy**: Maximum flexibility for research/prototyping +2. **Reliable Guppy**: NASA Power of 10 for production reliability +3. **Zlup-Convertible**: When Rust-native execution is needed + +### Integration with Guppy Ecosystem + +The linter could be: +- A **Guppy plugin** (`guppy.lint.nasa_p10`) +- A **pre-commit hook** for CI/CD +- An **IDE extension** for real-time feedback +- A **standalone tool** for auditing + +This makes reliability opt-in and gradual - teams can adopt constraints incrementally without committing to Zlup. + +## Implementation Roadmap + +### Phase 1: Standalone Guppy Linter +- Implement lint rules for NASA Power of 10 constraints +- Good error messages with actionable suggestions +- Integration with existing Python tooling (pylint, flake8 plugin?) + +### Phase 2: Guppy → Zlup Conversion (Optional) +- Mechanical transformation for compliant code +- Clear errors when conversion isn't possible +- HUGR as intermediate where helpful + +### Phase 3: Round-Trip (Future) +- Zlup → Guppy for interop +- Shared HUGR representation diff --git a/exp/zlup/docs/future/stdlib-design.md b/exp/zlup/docs/future/stdlib-design.md new file mode 100644 index 000000000..eedc75c2d --- /dev/null +++ b/exp/zlup/docs/future/stdlib-design.md @@ -0,0 +1,266 @@ +# Standard Library Design + +This document outlines the design of Zlup's standard library. + +## Import Syntax + +Zlup uses **Zig-style import semantics** with **Rust/Python-inspired syntax**: + +```zlup_nocheck +// Import entire module (Zig semantics) +std := @import("std"); + +// Use qualified access +angle := std.math.pi_4; +count := std.bits.popcount_u64(syndrome); + +// Import specific items (Rust-style syntax, Zig semantics) +// The module is still loaded, but we bind specific names +math := @import("std").math; +pi := math.pi; + +// Alternative: destructuring import (Python-style syntax) +// Still Zig semantics - the module is evaluated once +{ pi, tau, e } := @import("std").math; +``` + +### Why Zig Semantics? + +- **Comptime evaluation**: Imports are resolved at compile time +- **No runtime overhead**: Module code runs once during compilation +- **Explicit dependencies**: Clear what each file imports +- **Deterministic**: Same import always gives same result + +### Syntax Comparison + +| Style | Syntax | Zlup Approach | +|-------|--------|---------------| +| Zig | `const std = @import("std");` | `std := @import("std");` | +| Rust | `use std::math::pi;` | `{ pi } := @import("std").math;` | +| Python | `from std.math import pi` | `{ pi } := @import("std").math;` | + +## Standard Library Structure + +``` +std/ +├── math.zlp # Mathematical constants and functions +├── bits.zlp # Bitwise operations +├── mem.zlp # Memory utilities (bounded) +├── fmt.zlp # Formatting utilities +├── io.zlp # I/O (constrained, no unbounded reads) +├── debug.zlp # Debug utilities +├── testing.zlp # Test framework +├── qec/ # QEC-specific utilities +│ ├── syndrome.zlp +│ ├── pauli.zlp +│ └── codes.zlp +└── ffi.zlp # FFI helpers +``` + +## Module Contents + +### std.math + +Mathematical constants and pure functions. + +```zlup_nocheck +// Constants +pub pi: f64 = 3.14159265358979323846; +pub tau: f64 = 6.28318530717958647692; // 2π +pub e: f64 = 2.71828182845904523536; +pub sqrt2: f64 = 1.41421356237309504880; +pub sqrt2_inv: f64 = 0.70710678118654752440; // 1/√2 + +// Angle fractions (for quantum gates) +pub pi_2: f64 = 1.57079632679489661923; // π/2 +pub pi_4: f64 = 0.78539816339744830962; // π/4 +pub pi_8: f64 = 0.39269908169872415481; // π/8 + +// Conversion +pub deg_to_rad: f64 = 0.01745329251994329577; // π/180 +pub rad_to_deg: f64 = 57.29577951308232087680; // 180/π + +// Functions (comptime-evaluable where possible) +pub fn abs(x: f64) -> f64 { ... } +pub fn min(a: f64, b: f64) -> f64 { ... } +pub fn max(a: f64, b: f64) -> f64 { ... } +pub fn clamp(x: f64, lo: f64, hi: f64) -> f64 { ... } +``` + +### std.bits + +Bitwise operations for syndrome processing. + +```zlup_nocheck +// Popcount (count set bits) +pub fn popcount_u8(x: u8) -> u8 { ... } +pub fn popcount_u16(x: u16) -> u16 { ... } +pub fn popcount_u32(x: u32) -> u32 { ... } +pub fn popcount_u64(x: u64) -> u64 { ... } + +// Parity (XOR of all bits) +pub fn parity_u8(x: u8) -> u1 { ... } +pub fn parity_u16(x: u16) -> u1 { ... } +pub fn parity_u32(x: u32) -> u1 { ... } +pub fn parity_u64(x: u64) -> u1 { ... } + +// Bit extraction +pub fn get_bit(x: u64, index: u6) -> u1 { ... } +pub fn set_bit(x: u64, index: u6, value: u1) -> u64 { ... } +pub fn extract_bits(x: u64, start: u6, len: u6) -> u64 { ... } + +// Rotation +pub fn rotl_u32(x: u32, n: u5) -> u32 { ... } +pub fn rotr_u32(x: u32, n: u5) -> u32 { ... } +pub fn rotl_u64(x: u64, n: u6) -> u64 { ... } +pub fn rotr_u64(x: u64, n: u6) -> u64 { ... } + +// Leading/trailing zeros +pub fn clz_u32(x: u32) -> u6 { ... } // count leading zeros +pub fn ctz_u32(x: u32) -> u6 { ... } // count trailing zeros +pub fn clz_u64(x: u64) -> u7 { ... } +pub fn ctz_u64(x: u64) -> u7 { ... } +``` + +### std.mem + +Bounded memory utilities (NASA Power of 10 compliant). + +```zlup_nocheck +// Fixed-capacity stack +pub fn Stack(comptime T: type, comptime capacity: usize) -> type { + return struct { + items: [capacity]T = undefined, + len: usize = 0, + + pub fn push(&mut self, item: T) -> CapacityError!unit { ... } + pub fn pop(&mut self) -> ?T { ... } + pub fn peek(&self) -> ?*const T { ... } + pub fn is_empty(&self) -> bool { ... } + pub fn is_full(&self) -> bool { ... } + }; +} + +// Fixed-capacity queue (ring buffer) +pub fn Queue(comptime T: type, comptime capacity: usize) -> type { ... } + +// Fixed-capacity hash map +pub fn HashMap(comptime K: type, comptime V: type, comptime capacity: usize) -> type { ... } + +// Copying and comparison +pub fn copy(comptime T: type, dest: []T, src: []const T) -> usize { ... } +pub fn eql(comptime T: type, a: []const T, b: []const T) -> bool { ... } +pub fn set(comptime T: type, dest: []T, value: T) -> unit { ... } +``` + +### std.qec + +QEC-specific utilities. + +```zlup_nocheck +// Syndrome buffer for multi-round storage +pub fn SyndromeBuffer(comptime bits: usize, comptime rounds: usize) -> type { + return struct { + data: [rounds]u64 = undefined, + current_round: usize = 0, + + pub fn record(&mut self, syndrome: u64) -> unit { ... } + pub fn get(&self, round: usize) -> u64 { ... } + pub fn diff(&self, round_a: usize, round_b: usize) -> u64 { ... } + }; +} + +// Pauli frame tracking +pub fn PauliFrame(comptime num_qubits: usize) -> type { + return struct { + x_frame: u64 = 0, // X corrections to track + z_frame: u64 = 0, // Z corrections to track + + pub fn apply_x(&mut self, qubit: usize) -> unit { ... } + pub fn apply_z(&mut self, qubit: usize) -> unit { ... } + pub fn propagate_cx(&mut self, control: usize, target: usize) -> unit { ... } + }; +} + +// Lookup table decoder (for small codes) +pub fn LookupDecoder(comptime syndrome_bits: usize, comptime correction_bits: usize) -> type { + return struct { + table: [1 << syndrome_bits]u64 = undefined, + + pub fn init(table_data: [1 << syndrome_bits]u64) -> Self { ... } + pub fn decode(&self, syndrome: u64) -> u64 { ... } + }; +} +``` + +### std.testing + +Test framework for Zlup programs. + +```zlup_nocheck +// Test declaration (comptime) +pub fn expect(ok: bool) -> TestError!unit { + if (!ok) return error.ExpectFailed; + return; +} + +pub fn expectEq(comptime T: type, expected: T, actual: T) -> TestError!unit { + if (expected != actual) return error.ExpectEqFailed; + return; +} + +pub fn expectApprox(expected: f64, actual: f64, tolerance: f64) -> TestError!unit { + if (@abs(expected - actual) > tolerance) return error.ExpectApproxFailed; + return; +} + +// Test blocks in source files +test "syndrome parity" { + syndrome: u8 = 0b10101010; + try expect(std.bits.parity_u8(syndrome) == 0); +} +``` + +### std.ffi + +Helpers for FFI with Rust/C. + +```zlup_nocheck +// Opaque pointer wrapper +pub fn Opaque(comptime name: []const u8) -> type { + return struct { + ptr: *anyopaque, + + pub fn is_null(&self) -> bool { + return self.ptr == null; + } + }; +} + +// C string utilities +pub fn c_str_len(s: [*:0]const u8) -> usize { ... } +pub fn c_str_to_slice(s: [*:0]const u8) -> []const u8 { ... } +``` + +## Design Principles + +1. **Comptime-first**: Prefer compile-time evaluation where possible +2. **Bounded**: All containers have fixed capacity (NASA Power of 10) +3. **No allocations**: Standard library never allocates at runtime +4. **Pure functions**: Math and bit operations are pure, side-effect free +5. **QEC-focused**: Include primitives that QEC workflows commonly need +6. **FFI-friendly**: Types that work well across the Rust boundary + +## Implementation Priority + +| Module | Priority | Rationale | +|--------|----------|-----------| +| std.math | High | Gates need angle constants | +| std.bits | High | Syndrome processing | +| std.qec | High | Core QEC workflows | +| std.mem | Medium | Bounded containers | +| std.testing | Medium | Quality assurance | +| std.ffi | Medium | Rust integration | +| std.fmt | Lower | Nice to have | +| std.io | Lower | Constrained I/O | +| std.debug | Lower | Development aid | diff --git a/exp/zlup/docs/ide-setup.md b/exp/zlup/docs/ide-setup.md new file mode 100644 index 000000000..fe1d4a6d7 --- /dev/null +++ b/exp/zlup/docs/ide-setup.md @@ -0,0 +1,247 @@ +# Zlup IDE Setup + +This guide covers setting up IDE support for Zlup development, including syntax highlighting and LSP features (diagnostics, hover, completions). + +## Building the LSP Server + +The Zlup LSP server (`zlups`) provides diagnostics, hover information, and basic completions. + +```bash +# From the repository root +cargo build --features "cli lsp" --bin zlups --release + +# The binary will be at: +# target/release/zlups +``` + +## Neovim + +### Prerequisites + +- Neovim 0.8+ with LSP support +- [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig) + +### Setup + +1. **Register the filetype** in your `init.lua`: + +```lua +vim.filetype.add { + extension = { + zlp = 'zlup', + }, +} +``` + +2. **Configure the LSP** in your plugin configuration (e.g., `lua/custom/plugins/zlup.lua`): + +```lua +return { + 'neovim/nvim-lspconfig', + config = function() + local lspconfig = require 'lspconfig' + local configs = require 'lspconfig.configs' + + -- Register zlups as a custom LSP server + if not configs.zlups then + configs.zlups = { + default_config = { + cmd = { '/path/to/PECOS-alt/target/release/zlups' }, + filetypes = { 'zlup' }, + root_dir = lspconfig.util.root_pattern('zlup.toml', '.git'), + settings = {}, + }, + } + end + + -- Start the server + lspconfig.zlups.setup {} + end, +} +``` + +Replace `/path/to/PECOS-alt` with the actual path to your PECOS repository. + +3. **Verify** by opening a `.zlp` file and running `:LspInfo`. You should see `zlups` attached. + +### Troubleshooting + +- **LSP not starting**: Check that the `zlups` binary exists and is executable +- **No diagnostics**: Restart the LSP with `:LspRestart zlups` +- **Check logs**: `:LspLog` shows LSP communication logs + +## JetBrains IDEs (RustRover, IntelliJ, CLion, etc.) + +There are two options for JetBrains IDE support: + +### Option 1: LSP4IJ Plugin (Recommended) + +[LSP4IJ](https://plugins.jetbrains.com/plugin/23257-lsp4ij) is a generic LSP client plugin that works with any JetBrains IDE. + +1. **Install LSP4IJ** from the JetBrains Marketplace: + - Settings → Plugins → Marketplace → Search "LSP4IJ" → Install + +2. **Configure the LSP server**: + - Settings → Languages & Frameworks → LSP4IJ → Language Servers + - Add a new server: + - **Name**: `zlups` + - **Command**: `/path/to/PECOS-alt/target/release/zlups` + - **File patterns**: `*.zlp` + +3. **Associate the file type**: + - Settings → Editor → File Types + - Add `*.zlp` to a text-based file type (or create a new one called "Zlup") + +### Option 2: Zlup Plugin (Syntax Highlighting Only) + +A basic JetBrains plugin is available at `exp/zlup/editors/jetbrains-zlup/`. This provides syntax highlighting but not LSP features. + +#### Building the Plugin + +```bash +cd exp/zlup/editors/jetbrains-zlup +./gradlew buildPlugin + +# The plugin ZIP will be at: +# build/distributions/jetbrains-zlup-0.1.0.zip +``` + +#### Installing + +1. Settings → Plugins → Gear icon → Install Plugin from Disk +2. Select `jetbrains-zlup-0.1.0.zip` +3. Restart the IDE + +#### Features + +- Syntax highlighting for keywords, types, gates, comments +- File type registration for `.zlp` files + +For full LSP support (diagnostics, hover), combine with LSP4IJ. + +## VS Code + +### Quick Setup (No Extension Required) + +You can use a generic LSP client extension to get Zlup support without creating a custom extension. + +#### Using vscode-glspc (Generic LSP Client) + +1. **Install** [Generic LSP Client](https://marketplace.visualstudio.com/items?itemName=AaaronSun.vscode-glspc) from the marketplace + +2. **Add to your `settings.json`**: + +```json +{ + "glspc.serverPath": "/path/to/PECOS-alt/target/release/zlups", + "glspc.languageId": "zlup", + "files.associations": { + "*.zlp": "zlup" + } +} +``` + +#### Using lsp-client Extension + +Alternatively, use [LSP Client](https://marketplace.visualstudio.com/items?itemName=AperiodicSierra.lsp-client): + +1. **Install** the extension +2. **Configure in `settings.json`**: + +```json +{ + "lsp-client.serverCommand": "/path/to/PECOS-alt/target/release/zlups", + "lsp-client.fileExtensions": [".zlp"], + "files.associations": { + "*.zlp": "plaintext" + } +} +``` + +### Basic Syntax Highlighting + +For syntax highlighting without a full extension, add to `settings.json`: + +```json +{ + "editor.tokenColorCustomizations": { + "textMateRules": [ + { + "scope": "keyword.control.zlup", + "settings": { "foreground": "#C586C0" } + } + ] + } +} +``` + +For full syntax highlighting, a TextMate grammar or custom extension would be needed. + +### Creating a Full Extension (Advanced) + +For a complete VS Code extension with syntax highlighting and LSP: + +1. Use `yo code` to scaffold a language extension +2. Add a TextMate grammar for `.zlp` files +3. Configure the LSP client in `extension.ts`: + +```typescript +import * as vscode from 'vscode'; +import { LanguageClient, LanguageClientOptions, ServerOptions } from 'vscode-languageclient/node'; + +export function activate(context: vscode.ExtensionContext) { + const serverOptions: ServerOptions = { + command: '/path/to/PECOS-alt/target/release/zlups', + }; + + const clientOptions: LanguageClientOptions = { + documentSelector: [{ scheme: 'file', language: 'zlup' }], + }; + + const client = new LanguageClient('zlups', 'Zlup Language Server', serverOptions, clientOptions); + client.start(); +} +``` + +Contributions for a full VS Code extension are welcome! + +## LSP Features + +The `zlups` server currently provides: + +| Feature | Status | +|---------|--------| +| Diagnostics (errors/warnings) | Supported | +| Hover (type information) | Supported | +| Go to Definition | Supported | +| Completions | Context-aware | +| Formatting | Supported | + +### Feature Details + +**Go to Definition**: Jump to where variables, functions, and types are defined. Use `gd` in Neovim or Ctrl+Click in JetBrains/VS Code. + +**Context-aware Completions**: +- After `.` on allocators: suggests `child()`, `release()`, etc. +- After `:` or `->`: suggests types (`u32`, `void`, `bool`, etc.) +- General context: keywords, quantum gates, built-in functions + +**Formatting**: Formats code with consistent indentation and spacing. Use `:lua vim.lsp.buf.format()` in Neovim or the IDE's format command. + +## Development + +### Rebuilding After Changes + +When modifying the parser, semantic analyzer, or LSP server: + +```bash +cargo build --features "cli lsp" --bin zlups --release +``` + +Then restart the LSP in your editor: +- Neovim: `:LspRestart zlups` +- JetBrains: Restart the IDE or disable/enable the LSP server + +### Testing LSP + +A test file is provided at `exp/zlup/examples/test_lsp.zlp` for verifying LSP functionality. diff --git a/exp/zlup/docs/index.md b/exp/zlup/docs/index.md new file mode 100644 index 000000000..6d563cf0f --- /dev/null +++ b/exp/zlup/docs/index.md @@ -0,0 +1,269 @@ +# Zlup + +> **EXPERIMENTAL / EXPLORATORY** - Zlup is a research experiment, not a production language. + +A quantum programming language for QEC research: simple, low-level, and predictable by design. + +## Overview + +Zlup is the **low-level complement to Guppy** in the PECOS ecosystem. While Guppy provides a high-level, Pythonic experience with linear types for safety, Zlup explores a different approach: + +| | Guppy | Zlup | +|---|---|---| +| **Philosophy** | High-level, Pythonic | Low-level, explicit | +| **Safety mechanism** | Linear type system | Constraints make unsafe impossible | +| **Target users** | QEC researchers | Systems programmers | + +## Quick Example + +```zlup +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Create GHZ state + h q[0]; + cx (q[0], q[1]); + cx (q[1], q[2]); + cx (q[2], q[3]); + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + // Emit results to runtime + result("measurements", results); + + return; +} +``` + +## A Closer Look: QEC Workflow + +Here's a more complete program that shows off much of the language. It implements a +simple repetition code QEC workflow: encoding, multiple syndrome extraction rounds, +decoding, correction, and result emission. + +```zlup_nocheck +/// Repetition Code QEC Demo +/// +/// Demonstrates Zlup's features through a 3-qubit bit-flip code: +/// structs, error/fault sets, child allocators, tick blocks, +/// measurements, bounded loops, control flow, and more. + +std := @import("std"); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Packed syndrome from two stabilizer measurements. +Syndrome := struct { + bits: u2, // two parity bits packed into a u2 + + /// Decode syndrome to a qubit index (or none if no error). + pub fn error_location(&self) -> ?u2 { + return switch (self.bits) { + 0b00 => none, // no error + 0b10 => 0, // error on data[0] + 0b11 => 1, // error on data[1] + 0b01 => 2, // error on data[2] + }; + } +}; + +/// Stats collected across rounds. +RoundStats := struct { + rounds_run: u32, + corrections: u32, +}; + +// --------------------------------------------------------------------------- +// Error and fault sets +// --------------------------------------------------------------------------- + +/// Classical errors — something unexpected in the logic. +DecodeError := error { AmbiguousSyndrome }; + +/// Quantum faults — expected hardware imperfections. +HwFault := fault { Leakage, Crosstalk }; + +// --------------------------------------------------------------------------- +// Helper functions +// --------------------------------------------------------------------------- + +/// Apply a bit-flip correction to a single data qubit. +fn apply_correction(data: *Allocator, idx: u2) -> unit { + x data[idx]; + @emit.log.debug(f"corrected qubit {idx}"); + return; +} + +/// Measure the two Z-stabilizers of the 3-qubit repetition code. +/// +/// Uses child allocators to separate data and ancilla concerns, +/// tick blocks to express parallelism, and pack measurement. +fn measure_syndrome(data: *Allocator, ancilla: *Allocator) -> Syndrome { + // Reset ancillas before each round + pz ancilla; + + // Stabilizer circuits run in parallel where possible + @attr(round, "syndrome") + tick stabilizers { + // Z₀Z₁ stabilizer + cx (data[0], ancilla[0]); + cx (data[1], ancilla[0]); + + // Z₁Z₂ stabilizer + cx (data[1], ancilla[1]); + cx (data[2], ancilla[1]); + } + + // Measure ancillas, packing two bits into a u2 + bits: u2 = mz(pack u2) [ancilla[0], ancilla[1]]; + return Syndrome { bits }; +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +pub fn main() -> unit { + // Set up a reproducible simulation + @emit.sim.send("seed", 42); + @emit.sim.send("noise_model", "depolarizing"); + @emit.sim.send("noise_rate", 0.001); + + // --- Allocate qubits with child partitioning --- + mut q := qalloc(5); + data := q.child(3); // data qubits [0..3) + ancilla := q.child(2); // ancilla qubits [3..5) + + // Prepare everything to |0⟩ + pz q; + + // --- Encode: create the logical |+⟩ state --- + h data[0]; + // Spread with CNOTs (inline for unrolls at compile time) + inline for i in 1..3 { + cx (data[0], data[i]); + } + + // --- Run several syndrome extraction rounds --- + num_rounds := 4; + mut stats := RoundStats { rounds_run: 0, corrections: 0 }; + + for round in 0..num_rounds { + syndrome := measure_syndrome(&data, &ancilla); + + @emit.log.info(f"round {round}: syndrome = 0b{syndrome.bits:02b}"); + + // Decode and maybe correct + if loc := syndrome.error_location() { + apply_correction(&data, loc); + stats.corrections += 1; + } + + stats.rounds_run += 1; + } + + // --- Final readout --- + @emit.sim.noise_disable(); // noiseless final measurement + + final: [3]u1 = mz([3]u1) [data[0], data[1], data[2]]; + parity := std.parity_u8(final[0] ^ final[1] ^ final[2]); + + // Emit results to the runtime + result("qec/final_readout", final); + result("qec/parity", parity); + result("qec/stats/rounds", stats.rounds_run); + result("qec/stats/corrections", stats.corrections); + + return; +} +``` + +**Features shown above:** + +| Feature | Where | +|---|---| +| Doc comments (`///`) | Top of file, on structs and functions | +| Imports (`@import`) | `std := @import("std")` | +| Structs with methods | `Syndrome`, `RoundStats` | +| Error and fault sets | `DecodeError`, `HwFault` | +| Allocators & children | `qalloc(5)`, `.child(3)`, `.child(2)` | +| Named tick blocks | `tick stabilizers { ... }` | +| Attributes | `@attr(round, "syndrome")` | +| Single & two-qubit gates | `h`, `x`, `cx` | +| `inline for` (comptime unroll) | `inline for i in 1..3 { cx ... }` | +| Bounded `for` loops | `for round in 0..num_rounds { ... }` | +| Typed measurement | `mz([3]u1)`, `mz(pack u2)` | +| Optionals & `if`-unwrap | `if loc := syndrome.error_location()` | +| `switch` expression | Inside `error_location` | +| Mutable bindings | `mut stats`, `stats.corrections += 1` | +| F-strings | `f"round {round}: syndrome = ..."` | +| Structured logging | `@emit.log.debug(...)`, `@emit.log.info(...)` | +| Simulator control | `@emit.sim.send(...)`, `@emit.sim.noise_disable()` | +| Result emission | `result("qec/final_readout", final)` | +| Namespaced result tags | `"qec/stats/rounds"` | +| Standard library call | `std.parity_u8(...)` | +| Pointer parameters | `fn apply_correction(data: *Allocator, ...)` | + +For the full language reference, see [Syntax](syntax.md). For error handling +details (faults vs errors, `try`/`catch`, error unions), see the +[Error Handling Guide](tutorial-error-handling.md). + +## Design Philosophy + +**Simple. Explicit. No magic.** + +- **Safe by constraint**: No recursion, no escaping references, no dangling pointers +- **NASA Power of 10**: Bounded loops, fixed resources, explicit control flow +- **Zig semantics, Rust/Python syntax**: Familiar surface, powerful foundations + +## Getting Started + +- [Tutorial](tutorial.md) - Learn the basics +- [Error Handling Guide](tutorial-error-handling.md) - Faults vs errors, QEC patterns +- [CLI Reference](cli.md) - Command-line interface +- [Language Syntax](syntax.md) - Complete reference +- [IDE Setup](ide-setup.md) - Editor configuration + +## Key Features + +### Type System +- Arrays `[N]T` vs Slices `[]T` - distinct types +- Slice syntax: `arr[0..5]`, `arr[2..]`, `arr[..5]`, `arr[..]` +- Aliases for safe slice views: `alias data := q[0..4]` + +### Error Handling +- **Faults** (physical/quantum) vs **Errors** (logical/classical) +- `try` blocks collect faults, stop on errors (QEC pattern) +- `try!` blocks stop on first fault or error (strict mode) +- Error unions `Error!T` for explicit error handling + +### Quantum Operations +- Allocator-based qubit management: `q := qalloc(4)` +- Batch operations: `h {q[0], q[1], q[2]}` +- Typed measurements: `result: u8 = mz(pack u8) qubits` + +### Safety Model +- Escape analysis prevents returning references to locals +- Recursion unconditionally forbidden +- Duplicate qubit detection in parallel operations + +## Learn More + +- [Design Philosophy](design.md) - Why Zlup exists +- [Rust Integration](rust-integration.md) - FFI and native backends +- [Standard Library](stdlib.md) - Available modules +- [Error Reference](errors.md) - Compiler error messages +- [Development Notes](dev-notes.md) - Recent changes and implementation details + +## Advanced Topics + +- **Parallelism Analysis** - Use `zlup analyze` to detect parallelizable operations. See [CLI Reference](cli.md#parallelism-analysis) and [Development Notes](dev-notes.md). +- **Aliases** - The `alias` keyword creates safe slice views with overlap detection. See [Alias Design](future/alias-design.md) for details. + +## Future Designs + +- [Custom Gates](future/custom-gates-design.md) - User-defined composite gates and target-provided gates diff --git a/exp/zlup/docs/rust-integration.md b/exp/zlup/docs/rust-integration.md new file mode 100644 index 000000000..2e67494db --- /dev/null +++ b/exp/zlup/docs/rust-integration.md @@ -0,0 +1,483 @@ +# Zlup-Rust Integration Guide + +This document describes how to write Rust code that integrates with Zlup, including traits, FFI conventions, and the `zlup-ffi` crate. + +## Overview + +Zlup is designed to orchestrate quantum operations while delegating complex classical computation to native code. Rust is the preferred language for this native layer due to its safety guarantees. + +The integration story has three parts: + +1. **`zlup-ffi` crate** - Rust library providing traits, types, and macros +2. **C ABI conventions** - How Zlup calls into Rust (via `extern "C"`) +3. **Code generation** - Zlup compiler can generate Rust bindings + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Zlup Program │ +│ syndrome: u64 = mz(pack u64) [...]; │ +│ correction := decode(syndrome); // FFI call │ +└─────────────────────────┬───────────────────────────────────┘ + │ extern "C" fn decode(u64) -> u64 +┌─────────────────────────▼───────────────────────────────────┐ +│ zlup-ffi crate │ +│ #[zlup_export] │ +│ impl Decoder for MyMWPM { ... } │ +└─────────────────────────────────────────────────────────────┘ +``` + +## The `zlup-ffi` Crate + +### Installation + +```toml +# Cargo.toml +[dependencies] +zlup-ffi = "0.1" +``` + +### Core Traits + +#### `Decoder` Trait + +The primary trait for implementing decoders: + +```rust +use zlup_ffi::prelude::*; + +/// A decoder that maps syndromes to corrections. +pub trait Decoder: Send + Sync { + /// The syndrome type (typically u64 or a custom packed type) + type Syndrome: SyndromeData; + + /// The correction type (typically u64 or a custom packed type) + type Correction: CorrectionData; + + /// Decode a syndrome into a correction. + fn decode(&self, syndrome: Self::Syndrome) -> Self::Correction; + + /// Optional: decode with soft information (for ML decoders) + fn decode_soft(&self, syndrome: Self::Syndrome, soft_info: &[f32]) -> Self::Correction { + self.decode(syndrome) // Default: ignore soft info + } + + /// Optional: reset decoder state between shots + fn reset(&mut self) {} +} +``` + +#### `NoiseModel` Trait + +For simulation backends: + +```rust +/// A noise model that can be applied to quantum state. +pub trait NoiseModel: Send + Sync { + /// Apply noise after a gate operation. + fn apply_gate_noise(&self, gate: GateType, qubits: &[QubitId], rng: &mut dyn Rng); + + /// Apply measurement noise. + fn apply_measurement_noise(&self, qubit: QubitId, rng: &mut dyn Rng) -> bool; + + /// Apply idle noise for a time step. + fn apply_idle_noise(&self, qubits: &[QubitId], rng: &mut dyn Rng); +} +``` + +#### `Simulator` Trait + +For custom simulation backends: + +```rust +/// A quantum state simulator. +pub trait Simulator: Send + Sync { + /// Apply a gate to the state. + fn apply_gate(&mut self, gate: GateType, qubits: &[QubitId]); + + /// Measure a qubit in the Z basis. + fn measure_z(&mut self, qubit: QubitId) -> bool; + + /// Reset a qubit to |0⟩. + fn reset(&mut self, qubit: QubitId); + + /// Get the current state vector (for debugging). + fn state_vector(&self) -> Option<&[Complex64]> { None } +} +``` + +### FFI-Safe Types + +The crate provides FFI-safe equivalents of common types: + +```rust +use zlup_ffi::types::*; + +// Packed syndrome/correction data +pub struct PackedBits { /* ... */ } +type Syndrome64 = PackedBits<64>; +type Syndrome128 = PackedBits<128>; + +// Qubit identifiers +pub struct QubitId(u32); + +// Gate types +#[repr(C)] +pub enum GateType { + H, X, Y, Z, S, T, Sdg, Tdg, + Sx, Sy, Sz, + Rx(f64), Ry(f64), Rz(f64), + Cx, Cy, Cz, Ch, + Swap, Iswap, + Sxx, Syy, Szz, + Rzz(f64), Rxx(f64), Ryy(f64), + Ccx, +} + +// Error types for FFI +#[repr(C)] +pub struct FfiResult { + pub ok: bool, + pub value: T, + pub error_code: u32, +} +``` + +### The `#[zlup_export]` Macro + +This proc macro generates the C ABI wrappers automatically: + +```rust +use zlup_ffi::prelude::*; + +pub struct MwpmDecoder { + // decoder state +} + +#[zlup_export] +impl Decoder for MwpmDecoder { + type Syndrome = u64; + type Correction = u64; + + fn decode(&self, syndrome: u64) -> u64 { + // MWPM algorithm implementation + todo!() + } +} +``` + +The macro generates: + +```rust +// Auto-generated C ABI exports +#[no_mangle] +pub extern "C" fn mwpm_decoder_new() -> *mut MwpmDecoder { /* ... */ } + +#[no_mangle] +pub extern "C" fn mwpm_decoder_decode( + decoder: *const MwpmDecoder, + syndrome: u64, +) -> u64 { /* ... */ } + +#[no_mangle] +pub extern "C" fn mwpm_decoder_free(decoder: *mut MwpmDecoder) { /* ... */ } +``` + +### Example: Complete MWPM Decoder + +```rust +use zlup_ffi::prelude::*; + +/// Minimum Weight Perfect Matching decoder for surface codes. +pub struct MwpmDecoder { + distance: usize, + graph: MatchingGraph, +} + +impl MwpmDecoder { + pub fn new(distance: usize) -> Self { + Self { + distance, + graph: MatchingGraph::for_surface_code(distance), + } + } +} + +#[zlup_export(name = "mwpm")] +impl Decoder for MwpmDecoder { + type Syndrome = u64; + type Correction = u64; + + fn decode(&self, syndrome: u64) -> u64 { + let defects = self.syndrome_to_defects(syndrome); + let matching = self.graph.minimum_weight_matching(&defects); + self.matching_to_correction(matching) + } + + fn reset(&mut self) { + self.graph.clear_cache(); + } +} + +impl MwpmDecoder { + fn syndrome_to_defects(&self, syndrome: u64) -> Vec { + // Convert packed syndrome bits to defect graph nodes + todo!() + } + + fn matching_to_correction(&self, matching: Matching) -> u64 { + // Convert matching result to correction operators + todo!() + } +} +``` + +## Using Rust Decoders from Zlup + +### Declaring External Functions + +In Zlup, declare the external decoder interface: + +```zlup_nocheck +// Declare external decoder functions +extern "C" { + fn mwpm_new(distance: u32) -> *Decoder; + fn mwpm_decode(decoder: *Decoder, syndrome: u64) -> u64; + fn mwpm_free(decoder: *Decoder) -> unit; +} +``` + +### Using the Decoder + +```zlup_nocheck +pub fn main() -> unit { + // Initialize decoder (typically once at program start) + decoder := mwpm_new(5); // distance-5 surface code + defer mwpm_free(decoder); + + q := qalloc(25); // 25 data qubits for d=5 + ancilla := qalloc(24); // 24 syndrome qubits + + // QEC round + for round in 0..100 { + // Syndrome extraction + pz ancilla; + // ... stabilizer measurements ... + syndrome: u64 = mz(pack u64) ancilla[0..24]; + + // Decode (FFI call to Rust) + correction := mwpm_decode(decoder, syndrome); + + // Apply correction + apply_correction(q, correction); + } + + return; +} +``` + +## Build Integration + +### Linking Rust Libraries + +When compiling Zlup programs that use Rust FFI: + +```bash +# Build the Rust decoder library +cd my-decoder +cargo build --release + +# Compile Zlup with the library +zlup compile program.zlp \ + --link-lib=my_decoder \ + --lib-path=./my-decoder/target/release \ + -o program +``` + +### Cargo Workspace Setup + +Recommended project structure: + +``` +my-qec-project/ +├── Cargo.toml # Workspace root +├── decoder/ +│ ├── Cargo.toml # Rust decoder crate +│ └── src/ +│ └── lib.rs +├── zlup/ +│ ├── main.zlp # Zlup orchestration code +│ └── lib/ # Generated Zlup bindings +└── build.rs # Build script to coordinate +``` + +### Generated Bindings + +The `zlup` CLI can generate Zlup declarations from Rust code: + +```bash +# Generate Zlup bindings from Rust crate +zlup bindgen --rust ./decoder/src/lib.rs -o ./zlup/lib/decoder.zlp +``` + +This parses `#[zlup_export]` attributes and generates corresponding Zlup declarations. + +## Error Handling Across FFI + +### Rust Side + +Use `FfiResult` for fallible operations: + +```rust +#[zlup_export] +impl Decoder for MyDecoder { + // Infallible decode - preferred + fn decode(&self, syndrome: u64) -> u64 { /* ... */ } +} + +// For fallible operations, use explicit error returns +#[no_mangle] +pub extern "C" fn decoder_init( + config_ptr: *const u8, + config_len: usize, + out_decoder: *mut *mut MyDecoder, +) -> FfiResult<()> { + // Validate inputs + if config_ptr.is_null() { + return FfiResult::err(ErrorCode::NullPointer); + } + + // Safe initialization + match MyDecoder::from_config(unsafe { std::slice::from_raw_parts(config_ptr, config_len) }) { + Ok(decoder) => { + unsafe { *out_decoder = Box::into_raw(Box::new(decoder)) }; + FfiResult::ok(()) + } + Err(e) => FfiResult::err(e.into()), + } +} +``` + +### Zlup Side + +Handle FFI errors explicitly: + +```zlup_nocheck +result := decoder_init(config.ptr, config.len, &decoder); +if !result.ok { + switch (result.error_code) { + 1 => { /* handle null pointer */ }, + 2 => { /* handle invalid config */ }, + else => { /* unknown error */ }, + } +} +``` + +## Performance Considerations + +### Minimize FFI Calls + +Batch operations when possible: + +```rust +// Good: batch decode +#[no_mangle] +pub extern "C" fn mwpm_decode_batch( + decoder: *const MwpmDecoder, + syndromes: *const u64, + corrections: *mut u64, + count: usize, +) { /* ... */ } +``` + +```zlup_nocheck +// Zlup: batch call +syndromes: [100]u64 = collect_syndromes(); +corrections: [100]u64 = undefined; +mwpm_decode_batch(decoder, &syndromes, &corrections, 100); +``` + +### Avoid Allocations + +Pre-allocate buffers and reuse them: + +```rust +pub struct MwpmDecoder { + // Pre-allocated working memory + defect_buffer: Vec, + matching_buffer: Vec, +} + +impl MwpmDecoder { + fn decode(&mut self, syndrome: u64) -> u64 { + self.defect_buffer.clear(); // Reuse allocation + // ... + } +} +``` + +### Thread Safety + +Decoders implementing `Send + Sync` can be called from multiple Zlup threads: + +```rust +// Thread-safe decoder with interior mutability +pub struct ThreadSafeDecoder { + inner: RwLock, +} + +#[zlup_export(thread_safe)] +impl Decoder for ThreadSafeDecoder { + // ... +} +``` + +## Testing + +### Unit Testing in Rust + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_no_errors() { + let decoder = MwpmDecoder::new(3); + let syndrome = 0b0000; // No errors + let correction = decoder.decode(syndrome); + assert_eq!(correction, 0); // No correction needed + } + + #[test] + fn test_decode_single_error() { + let decoder = MwpmDecoder::new(3); + let syndrome = 0b0011; // Single X error + let correction = decoder.decode(syndrome); + assert_ne!(correction, 0); // Correction applied + } +} +``` + +### Integration Testing with Zlup + +```bash +# Run Zlup tests that exercise FFI +zlup test ./tests/*.zlp --link-lib=my_decoder +``` + +## Appendix: Supported Rust Types + +| Rust Type | Zlup Type | Notes | +|-----------|-----------|-------| +| `bool` | `bool` | | +| `u8`, `u16`, `u32`, `u64` | `u8`, `u16`, `u32`, `u64` | | +| `i8`, `i16`, `i32`, `i64` | `i8`, `i16`, `i32`, `i64` | | +| `f32`, `f64` | `f32`, `f64` | | +| `usize` | `usize` | Platform-dependent | +| `*const T`, `*mut T` | `*T`, `*mut T` | Raw pointers | +| `[T; N]` | `[N]T` | Fixed arrays | +| `()` | `unit` | | + +Complex types (structs, enums) must use `#[repr(C)]` for FFI safety. diff --git a/exp/zlup/docs/stdlib.md b/exp/zlup/docs/stdlib.md new file mode 100644 index 000000000..cb84c5b8c --- /dev/null +++ b/exp/zlup/docs/stdlib.md @@ -0,0 +1,483 @@ +# Zlup Standard Library Reference + +The standard library provides common data structures and utilities for quantum programming with NASA Power of 10 compliance. All containers have bounded capacity specified at compile time. + +## Importing the Standard Library + +```zlup_nocheck +std := @import("std"); + +// Type-namespaced constants (preferred) +angle := std.a64.t_angle; // T-gate angle (1/8 turn) +pi := std.f64.pi; // pi as f64 +count := std.popcount_u8(syndrome); +``` + +--- + +## Module: f64 + +Float64 constants commonly used in calculations. + +### Fundamental Constants + +| Constant | Type | Value | Description | +|----------|------|-------|-------------| +| `std.f64.pi` | f64 | 3.14159... | Pi | +| `std.f64.tau` | f64 | 6.28318... | 2*pi (full rotation in radians) | +| `std.f64.e` | f64 | 2.71828... | Euler's number | +| `std.f64.sqrt2` | f64 | 1.41421... | Square root of 2 | +| `std.f64.sqrt2_inv` | f64 | 0.70710... | 1/sqrt(2), Hadamard normalization | + +### Angle Fractions in Radians + +| Constant | Value | Degrees | Common Use | +|----------|-------|---------|------------| +| `std.f64.pi_2` | pi/2 | 90 | Quarter turn | +| `std.f64.pi_3` | pi/3 | 60 | | +| `std.f64.pi_4` | pi/4 | 45 | T-gate | +| `std.f64.pi_6` | pi/6 | 30 | | +| `std.f64.pi_8` | pi/8 | 22.5 | | + +### Conversion Factors + +| Constant | Description | Example | +|----------|-------------|---------| +| `std.f64.deg_to_rad` | Multiply degrees to get radians | `45.0 * std.f64.deg_to_rad` | +| `std.f64.rad_to_deg` | Multiply radians to get degrees | `pi_4 * std.f64.rad_to_deg` | + +--- + +## Module: a64 + +Angle constants in turns (the native unit for a64). All values are exact in the Angle64 fixed-point representation. + +### Fundamental Turn Fractions + +| Constant | Turns | Radians | Degrees | Common Use | +|----------|-------|---------|---------|------------| +| `std.a64.zero` | 0 | 0 | 0 | Identity | +| `std.a64.half_turn` | 1/2 | pi | 180 | Z-gate | +| `std.a64.quarter_turn` | 1/4 | pi/2 | 90 | S-gate | +| `std.a64.eighth_turn` | 1/8 | pi/4 | 45 | T-gate | +| `std.a64.sixteenth_turn` | 1/16 | pi/8 | 22.5 | | + +### Gate-Named Aliases + +| Constant | Equivalent | Description | +|----------|------------|-------------| +| `std.a64.t_angle` | 1/8 turn | T-gate rotation | +| `std.a64.tdg_angle` | 7/8 turn | T-dagger (negative T) | +| `std.a64.s_angle` | 1/4 turn | S-gate rotation | +| `std.a64.sdg_angle` | 3/4 turn | S-dagger (negative S) | +| `std.a64.z_angle` | 1/2 turn | Z-gate rotation | + +### Example Usage + +```zlup_nocheck +std := @import("std"); + +// Preferred: use a64 constants with turns unit +rz(std.a64.t_angle turns) q[0]; // T-gate +rz(std.a64.quarter_turn turns) q[0]; // S-gate + +// Or use f64.pi with rad unit +rz(std.f64.pi/4 rad) q[0]; // Also T-gate + +// Fraction literals work directly +rz(1/8 turns) q[0]; // Also T-gate +``` + +--- + +## Module: bits + +Bitwise utilities for working with measurement results. Essential for syndrome processing in QEC. + +### Bit Counting (Population Count) + +Count the number of set bits (1s) in a value. + +```zlup_nocheck +fn popcount_u8(x: u8) -> u8 +fn popcount_u16(x: u16) -> u16 +fn popcount_u32(x: u32) -> u32 +fn popcount_u64(x: u64) -> u64 +``` + +**Example:** +```zlup_nocheck +result: u8 = 0b10110100; +weight := std.popcount_u8(result); // 4 +``` + +### Parity + +Compute parity (XOR of all bits). Returns 0 if even number of 1s, 1 if odd. + +```zlup_nocheck +fn parity_u8(x: u8) -> u1 +fn parity_u16(x: u16) -> u1 +fn parity_u32(x: u32) -> u1 +fn parity_u64(x: u64) -> u1 +``` + +**Example:** +```zlup_nocheck +// Syndrome parity check +syndrome: u8 = mz(pack u8) ancillas; +if std.parity_u8(syndrome) == 1 { + // Odd parity - error detected +} +``` + +### Bit Extraction + +Extract a single bit at the given index (0 = LSB). + +```zlup_nocheck +fn get_bit_u8(x: u8, index: u8) -> u1 +fn get_bit_u16(x: u16, index: u16) -> u1 +fn get_bit_u32(x: u32, index: u32) -> u1 +fn get_bit_u64(x: u64, index: u64) -> u1 +``` + +**Example:** +```zlup_nocheck +result: u8 = 0b10110100; +bit2 := std.bits.get_bit_u8(result, 2); // 1 +bit0 := std.bits.get_bit_u8(result, 0); // 0 +``` + +### Bit Manipulation + +```zlup_nocheck +fn set_bit_u8(x: u8, index: u8) -> u8 // Set bit to 1 +fn clear_bit_u8(x: u8, index: u8) -> u8 // Set bit to 0 +fn toggle_bit_u8(x: u8, index: u8) -> u8 // Flip bit +``` + +**Example:** +```zlup_nocheck +x: u8 = 0b00000000; +x = std.bits.set_bit_u8(x, 3); // 0b00001000 +x = std.bits.toggle_bit_u8(x, 0); // 0b00001001 +x = std.bits.clear_bit_u8(x, 3); // 0b00000001 +``` + +### Byte Order + +```zlup_nocheck +fn reverse_bits_u8(x: u8) -> u8 // Reverse bit order +fn swap_bytes_u16(x: u16) -> u16 // Swap bytes (endianness) +fn swap_bytes_u32(x: u32) -> u32 // Swap bytes +``` + +**Example:** +```zlup_nocheck +x: u8 = 0b10110100; +reversed := std.bits.reverse_bits_u8(x); // 0b00101101 +``` + +--- + +## Module: containers + +Bounded data structures for deterministic memory usage. + +### Stack(T, capacity) + +Last-In-First-Out (LIFO) container. + +```zlup_nocheck +stack: std.Stack(u32, 64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `push` | `(&mut self, T) -> OverflowError!void` | Push item (fails if full) | +| `pop` | `(&mut self) -> ?T` | Pop item (none if empty) | +| `peek` | `(&mut self) -> ?T` | View top without removing | +| `is_empty` | `(&mut self) -> bool` | Check if empty | +| `is_full` | `(&mut self) -> bool` | Check if at capacity | +| `clear` | `(&mut self) -> unit` | Remove all items | +| `count` | `(&mut self) -> usize` | Current item count | +| `get_capacity` | `(&mut self) -> usize` | Maximum capacity | + +**Example:** +```zlup_nocheck +stack: std.Stack(u32, 64) = .{}; +try stack.push(10); +try stack.push(20); + +if val := stack.pop() { + // val is 20 +} +``` + +### Queue(T, capacity) + +First-In-First-Out (FIFO) container using a ring buffer. + +```zlup_nocheck +queue: std.Queue(u32, 64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `enqueue` | `(&mut self, T) -> OverflowError!void` | Add to back | +| `dequeue` | `(&mut self) -> ?T` | Remove from front | +| `peek_front` | `(&mut self) -> ?T` | View front without removing | +| `is_empty` | `(&mut self) -> bool` | Check if empty | +| `is_full` | `(&mut self) -> bool` | Check if at capacity | +| `clear` | `(&mut self) -> unit` | Remove all items | +| `count` | `(&mut self) -> usize` | Current item count | + +**Example:** +```zlup_nocheck +queue: std.Queue(u32, 64) = .{}; +try queue.enqueue(1); +try queue.enqueue(2); + +if val := queue.dequeue() { + // val is 1 (first in, first out) +} +``` + +### Deque(T, capacity) + +Double-ended queue supporting insertion/removal at both ends. + +```zlup_nocheck +deque: std.Deque(u32, 64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `push_back` | `(&mut self, T) -> OverflowError!void` | Add to back | +| `push_front` | `(&mut self, T) -> OverflowError!void` | Add to front | +| `pop_back` | `(&mut self) -> ?T` | Remove from back | +| `pop_front` | `(&mut self) -> ?T` | Remove from front | +| `peek_front` | `(&mut self) -> ?T` | View front | +| `peek_back` | `(&mut self) -> ?T` | View back | +| `is_empty` | `(&mut self) -> bool` | Check if empty | +| `is_full` | `(&mut self) -> bool` | Check if at capacity | +| `clear` | `(&mut self) -> unit` | Remove all items | +| `count` | `(&mut self) -> usize` | Current item count | + +### PriorityQueue(T, capacity) + +Min-heap priority queue. Smallest element is always at front. + +```zlup_nocheck +pq: std.PriorityQueue(u32, 64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `insert` | `(&mut self, T) -> OverflowError!void` | Insert with priority | +| `extract_min` | `(&mut self) -> ?T` | Remove smallest | +| `peek_min` | `(&mut self) -> ?T` | View smallest | +| `is_empty` | `(&mut self) -> bool` | Check if empty | + +**Example:** +```zlup_nocheck +pq: std.PriorityQueue(u32, 64) = .{}; +try pq.insert(50); +try pq.insert(10); +try pq.insert(30); + +if val := pq.extract_min() { + // val is 10 (smallest) +} +``` + +--- + +## Module: qec + +Quantum Error Correction utilities for decoder implementations. + +### UnionFind(capacity) + +Disjoint Set Union (DSU) data structure. Essential for MWPM decoders. + +```zlup_nocheck +uf: std.UnionFind(256) = .{}; +uf.init(); +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `init` | `(&mut self) -> unit` | Initialize all elements as separate sets | +| `find` | `(&mut self, usize) -> usize` | Find root of set (with path compression) | +| `union` | `(&mut self, usize, usize) -> bool` | Unite two sets (returns true if merged) | +| `connected` | `(&mut self, usize, usize) -> bool` | Check if in same set | +| `reset` | `(&mut self, usize) -> unit` | Reset element to own set | +| `reset_all` | `(&mut self) -> unit` | Reset all elements | + +**Example:** +```zlup_nocheck +uf: std.UnionFind(256) = .{}; +uf.init(); + +uf.union(0, 1); +uf.union(1, 2); + +if uf.connected(0, 2) { + // 0 and 2 are in the same set +} +``` + +### SyndromeBuffer(num_ancillas, max_rounds) + +Storage for syndrome measurements across multiple rounds. + +```zlup_nocheck +syndrome: std.SyndromeBuffer(16, 10) = .{}; // 16 ancillas, 10 rounds +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `clear` | `(&mut self) -> unit` | Clear all data | +| `set` | `(&mut self, ancilla, round, bool) -> unit` | Set syndrome bit | +| `get` | `(&mut self, ancilla, round) -> bool` | Get syndrome bit | +| `record_round` | `(&mut self, [N]bool) -> OverflowError!void` | Record full round | +| `num_rounds` | `(&mut self) -> usize` | Rounds recorded | +| `has_error` | `(&mut self, round) -> bool` | Any syndrome in round? | +| `count_errors` | `(&mut self, round) -> usize` | Count triggered syndromes | + +**Example:** +```zlup_nocheck +syndrome: std.SyndromeBuffer(4, 10) = .{}; + +// Record syndromes from measurement +ancilla_results: [4]bool = .{ true, false, false, true }; +try syndrome.record_round(ancilla_results); + +if syndrome.has_error(0) { + // Process errors in round 0 +} +``` + +### LookupDecoder(num_syndromes, num_corrections) + +Table-based decoder for small codes. + +```zlup_nocheck +decoder: std.LookupDecoder(16, 4) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `add_entry` | `(&mut self, syndrome, correction) -> OverflowError!void` | Add lookup entry | +| `decode` | `(&mut self, syndrome) -> ?Correction` | Lookup correction | + +### PauliFrame(num_qubits) + +Track Pauli frame for frame tracking decoders. + +```zlup_nocheck +frame: std.PauliFrame(64) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `init` | `(&mut self) -> unit` | Initialize to identity | +| `apply_x` | `(&mut self, qubit) -> unit` | Apply X correction | +| `apply_z` | `(&mut self, qubit) -> unit` | Apply Z correction | +| `apply_y` | `(&mut self, qubit) -> unit` | Apply Y correction | +| `get_x` | `(&mut self, qubit) -> bool` | Check X component | +| `get_z` | `(&mut self, qubit) -> bool` | Check Z component | +| `reset` | `(&mut self, qubit) -> unit` | Reset qubit's frame | +| `reset_all` | `(&mut self) -> unit` | Reset entire frame | + +### SparseGraph(max_nodes, max_edges) + +Adjacency list graph for decoder algorithms. + +```zlup_nocheck +graph: std.SparseGraph(256, 1024) = .{}; +``` + +**Methods:** + +| Method | Signature | Description | +|--------|-----------|-------------| +| `init` | `(&mut self) -> unit` | Initialize empty graph | +| `add_edge` | `(&mut self, u, v, weight) -> OverflowError!void` | Add weighted edge | +| `get_neighbors` | `(&mut self, node) -> []Edge` | Get adjacent edges | +| `clear` | `(&mut self) -> unit` | Remove all edges | + +--- + +## Module Structure + +The main `std` module provides type-namespaced constants and utilities: + +```zlup_nocheck +std := @import("std"); + +// Type-namespaced constants +std.f64.pi, std.f64.tau, std.f64.e // f64 constants +std.f64.sqrt2, std.f64.sqrt2_inv +std.f64.pi_2, std.f64.pi_4, std.f64.pi_8 + +std.a64.quarter_turn, std.a64.eighth_turn // a64 angle constants (in turns) +std.a64.t_angle, std.a64.s_angle // Gate-named aliases + +// Bit operations +std.popcount_u8, std.popcount_u16, std.popcount_u32, std.popcount_u64 +std.parity_u8, std.parity_u16, std.parity_u32, std.parity_u64 + +// Container types (from std.containers) +std.Stack, std.Queue, std.Deque, std.PriorityQueue + +// QEC utilities (from std.qec) +std.UnionFind, std.SyndromeBuffer, std.LookupDecoder +std.PauliFrame, std.SparseGraph +``` + +--- + +## Error Types + +### OverflowError + +Returned when a bounded container exceeds capacity. + +```zlup_nocheck +OverflowError := error { Overflow }; +``` + +**Handling:** +```zlup_nocheck +// Propagate with try +try stack.push(item); + +// Handle with catch +stack.push(item) catch { + // Handle overflow +}; + +// Check before operation +if !stack.is_full() { + try stack.push(item); +} +``` diff --git a/exp/zlup/docs/syntax.md b/exp/zlup/docs/syntax.md new file mode 100644 index 000000000..25a89f694 --- /dev/null +++ b/exp/zlup/docs/syntax.md @@ -0,0 +1,1243 @@ +# Zlup Language Reference + +Complete syntax reference for the Zlup quantum programming language. + +## Table of Contents + +- [Comments](#comments) +- [Literals](#literals) +- [Variables and Bindings](#variables-and-bindings) +- [Types](#types) +- [Operators](#operators) +- [Control Flow](#control-flow) +- [Functions](#functions) +- [Structs and Enums](#structs-and-enums) +- [Error Handling](#error-handling) +- [Quantum Operations](#quantum-operations) +- [Attributes](#attributes) +- [Modules](#modules) +- [Logging](#logging) +- [Result Emission](#result-emission) +- [Simulator Control](#simulator-control) +- [Compilation Targets](#compilation-targets) + +--- + +## Comments + +```zlup_nocheck +// Single-line comment + +/* Multi-line + comment */ + +/// Documentation comment (for declarations) +``` + +--- + +## Literals + +### Numbers + +```zlup_nocheck +// Integers +42 // Decimal +0xFF // Hexadecimal +0b1010 // Binary +0o755 // Octal +1_000_000 // Underscores for readability + +// With type suffix +42_u32 // Explicit u32 +255_u8 // Explicit u8 + +// Floats +3.14 // f64 by default +3.14_f32 // Explicit f32 +1.5e10 // Scientific notation +1e-5 // Scientific without decimal + +// Angles +1.57_a64 // Angle type for rotations +``` + +### Strings + +```zlup_nocheck +// Regular string (escape sequences processed) +"hello\nworld" + +// Raw string (no escape processing) +r"C:\Users\path" +r"\d+\.\d+" // Regex pattern + +// Multi-line string (triple-quoted) +""" +Line 1 +Line 2 +""" + +// F-string (interpolation) +f"Value: {x}" +f"Pi = {pi:.2f}" // With format specifier +f"Padded: {n:08d}" // Zero-padded +``` + +### Escape Sequences + +| Sequence | Character | +|----------|-----------| +| `\n` | Newline | +| `\r` | Carriage return | +| `\t` | Tab | +| `\\` | Backslash | +| `\"` | Double quote | +| `\'` | Single quote | +| `\0` | Null | +| `\{` | Literal `{` in f-strings | +| `\}` | Literal `}` in f-strings | +| `\xNN` | Hex byte | + +### Other Literals + +```zlup_nocheck +true, false // Booleans +none // Optional null value +undefined // Uninitialized value +unit // Unit type value +'a' // Character literal +``` + +--- + +## Variables and Bindings + +### Immutable Bindings + +```zlup_fragment +x := 42; // Type inferred +y: u32 = 100; // Type explicit +z := "hello"; // String +``` + +### Mutable Bindings + +```zlup_fragment +mut count := 0; // Mutable, type inferred +mut buffer: [10]u8 = undefined; // Mutable array +count = count + 1; // Assignment +count += 1; // Compound assignment +``` + +### Aliases + +Aliases create named views into existing data with overlap checking: + +```zlup_nocheck +arr: [8]u32 = undefined; + +// Create non-overlapping aliases +alias data := arr[0..4]; +alias ancilla := arr[4..8]; + +// Use aliases like slices +process(data); +h ancilla[0]; + +// Overlapping aliases are compile-time errors: +alias overlap := arr[2..6]; // ERROR: overlaps with 'data' +``` + +**Alias constraints:** +- Source must be a slice expression (e.g., `arr[0..4]`) +- Range bounds must be comptime-evaluable for overlap checking +- Aliases are immutable (cannot be reassigned) +- Overlapping ranges on the same source are errors + +### Assignment Operators + +| Operator | Description | +|----------|-------------| +| `=` | Assignment | +| `+=` | Add and assign | +| `-=` | Subtract and assign | +| `*=` | Multiply and assign | +| `/=` | Divide and assign | +| `&=` | Bitwise AND and assign | +| `\|=` | Bitwise OR and assign | +| `^=` | Bitwise XOR and assign | + +--- + +## Types + +### Primitive Types + +| Type | Description | +|------|-------------| +| `bool` | Boolean (true/false) | +| `uN` | Unsigned N-bit integer (N = 1-128), e.g., `u1`, `u7`, `u32`, `u128` | +| `iN` | Signed N-bit integer (N = 1-128), e.g., `i8`, `i32`, `i64` | +| `usize`, `isize` | Pointer-sized integers | +| `f16`, `f32`, `f64`, `f128` | Floating point | +| `a64` | Angle type (for gate rotations) | +| `unit` | Unit type (no value) | +| `type` | Type as a value (comptime) | +| `anytype` | Any type (comptime) | + +Like Zig, Zlup supports arbitrary-width integers from 1 to 128 bits. This is useful for: +- Bit-packed measurement results: `u1`, `u2`, `u4` +- Syndrome values: `u3` for 3-bit syndromes +- Efficient storage: use exactly the bits you need + +### Quantum Types + +| Type | Description | +|------|-------------| +| `qubit` | Single qubit | +| `bit` | Classical bit | + +### Compound Types + +```zlup_nocheck +// Arrays (fixed size, known at compile time) +[4]u32 // Array of 4 u32 +[_]u8 // Size inferred from initializer + +// Slices (dynamic view into contiguous memory) +[]u8 // Slice of u8 +[]const u8 // Immutable slice +[][]i32 // Slice of slices (2D) + +// Pointers +*u32 // Single-item pointer +[*]u8 // Many-item pointer +[*:0]u8 // Sentinel-terminated + +// Optionals +?u32 // Optional u32 + +// Error unions +Error!u32 // u32 or Error + +// Tuples +(u32, bool) // Tuple of u32 and bool +(u32, u32, u32) // 3-element tuple + +// Sets +Set(u32) // Set of u32 +``` + +### Array and Slice Operations + +```zlup_nocheck +// Indexing (returns element) +arr[0] // First element +matrix[0][1] // Nested indexing + +// Slicing (returns slice) +arr[0..5] // Elements 0 to 4 (exclusive end) +arr[2..] // From index 2 to end +arr[..5] // From start to index 4 +arr[..] // Full slice (all elements) + +// Chained slicing +arr[1..10][2..5] // Slice of a slice + +// Array to slice conversion +slice := arr[..]; // Convert array to slice +``` + +**Note:** Arrays (`[N]T`) and slices (`[]T`) are distinct types. To pass an array where a slice is expected, use `arr[..]` to create a slice view. + +### Type Expressions + +```zlup_nocheck +// Function types +fn(u32, u32) -> u32 + +// Comptime types +comptime T: type +``` + +--- + +## Operators + +### Arithmetic + +| Operator | Description | +|----------|-------------| +| `+` | Addition | +| `-` | Subtraction (or negation) | +| `*` | Multiplication | +| `/` | Division | +| `%` | Modulo | + +### Comparison + +| Operator | Description | +|----------|-------------| +| `==` | Equal | +| `!=` | Not equal | +| `<` | Less than | +| `>` | Greater than | +| `<=` | Less or equal | +| `>=` | Greater or equal | + +### Logical + +| Operator | Description | +|----------|-------------| +| `and` | Logical AND | +| `or` | Logical OR | +| `!` | Logical NOT | + +### Bitwise + +| Operator | Description | +|----------|-------------| +| `&` | Bitwise AND | +| `\|` | Bitwise OR | +| `^` | Bitwise XOR | +| `~` | Bitwise NOT | +| `<<` | Left shift | +| `>>` | Right shift | + +### Membership + +| Operator | Description | +|----------|-------------| +| `in` | Membership test | +| `not in` | Negated membership | + +```zlup_nocheck +if x in items { } +if y not in set { } +``` + +### Optional/Error Operators + +| Operator | Description | +|----------|-------------| +| `orelse` | Unwrap optional with default | +| `catch` | Handle error with default | +| `.?` | Optional unwrap (returns optional) | +| `.!` | Error unwrap | +| `try` | Propagate error | + +```zlup_nocheck +value := optional orelse default; +result := fallible() catch |err| handle(err); +``` + +--- + +## Control Flow + +### If Statements + +```zlup_nocheck +// Simple if +if condition { + // body +} + +// If-else +if condition { + // true branch +} else { + // false branch +} + +// If-else if chain +if a { + // ... +} else if b { + // ... +} else { + // ... +} + +// Optional unwrapping (walrus operator) +if value := optional { + // value is unwrapped here +} +``` + +### If Expressions + +```zlup_nocheck +// If as expression (requires parentheses and else) +result := if (condition) { value1 } else { value2 }; +``` + +### For Loops + +All loops must have bounded iteration. + +```zlup_nocheck +// Range loop +for i in 0..10 { + // i goes from 0 to 9 +} + +// With index +for i, item in items { + // i is index, item is value +} + +// Inline for (comptime unrolling) +inline for i in 0..4 { + h q[i]; +} +// Unrolls to: h q[0]; h q[1]; h q[2]; h q[3]; + +// Nested inline for +inline for i in 0..2 { + inline for j in 0..3 { + cx (q[i], q[j + 2]); + } +} +// Unrolls to 6 cx gates +``` + +**Inline for constraints:** +- Range bounds must be comptime-evaluable (literals or comptime constants) +- `break` and `continue` are not allowed inside inline for bodies +- Maximum unroll limit of 1024 iterations for safety + +### Switch Statements + +```zlup_nocheck +switch (value) { + 0 => { /* handle 0 */ }, + 1, 2 => { /* handle 1 or 2 */ }, + 3..10 => { /* handle range */ }, + else => { /* default */ }, +} +``` + +### Control Flow Keywords + +```zlup_nocheck +return value; // Return from function +return; // Return unit (shorthand for return unit;) +break; // Exit loop +break :label value; // Break with label and value +continue; // Next iteration +continue :label; // Continue outer loop +``` + +### Labeled Blocks + +```zlup_nocheck +result := blk: { + if condition { + break :blk value1; + } + break :blk value2; +}; +``` + +### Unsafe Blocks + +Unsafe blocks provide an escape hatch from strict mode constraints. They allow operations that are normally forbidden, such as recursion. Unsafe blocks are **forbidden by default** and require the `--allow-unsafe` flag. + +```zlup_nocheck +// Recursion inside unsafe block (requires --allow-unsafe) +fn factorial(n: u32) -> u32 { + unsafe { + if n <= 1 { return 1; } + return n * factorial(n - 1); + } +} +``` + +**What unsafe allows:** +- Recursive function calls +- Gates on potentially unprepared qubits +- Other strict mode violations + +**What unsafe does NOT allow:** +- Type errors (still checked) +- Undefined variables (still checked) +- Syntax errors (still checked) + +Unsafe blocks follow Rust's philosophy: make potentially dangerous operations explicit and auditable. Production code can ban all unsafe by not passing `--allow-unsafe`. + +--- + +## Functions + +### Function Declaration + +```zlup_nocheck +// Basic function +fn add(a: u32, b: u32) -> u32 { + return a + b; +} + +// Public function +pub fn main() -> unit { + return; +} + +// Inline function +inline fn square(x: u32) -> u32 { + return x * x; +} + +// Method with self receiver +fn increment(&mut self) -> unit { + self.count += 1; + return; +} + +// Comptime parameters +fn make_array(comptime N: usize) -> [N]u32 { + arr: [N]u32 = undefined; + return arr; +} +``` + +### Function Types + +```zlup_nocheck +fn_type := fn(u32, u32) -> u32; +``` + +### Anonymous Functions + +```zlup_nocheck +callback := fn(x: u32) -> u32 { + return x * 2; +}; +``` + +--- + +## Structs and Enums + +### Struct Declaration + +```zlup_nocheck +Point := struct { + x: f64, + y: f64, + + pub fn distance(&self) -> f64 { + // method implementation + } +}; +``` + +### Struct Initialization + +```zlup_nocheck +// Named fields (Rust-style) +p := Point { x: 1.0, y: 2.0 }; + +// Anonymous struct +data := .{ x: 1, y: 2 }; + +// Shorthand (when variable name matches field) +x := 1.0; +y := 2.0; +p := Point { x, y }; +``` + +### Enum Declaration + +```zlup_nocheck +Color := enum { + Red, + Green, + Blue, +}; + +// With explicit values +Status := enum(u8) { + Ok = 0, + Error = 1, +}; +``` + +### Tagged Union + +```zlup_nocheck +Value := union(enum) { + Int: i32, + Float: f64, + None, +}; +``` + +### Generic Types (Comptime) + +```zlup_nocheck +Stack := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + items: [capacity]T = undefined, + len: usize = 0, + } +}; + +// Usage +stack: Stack(u32, 64) = .{}; +``` + +--- + +## Error Handling + +### Error Sets + +```zlup_nocheck +// Define error set +FileError := error { NotFound, PermissionDenied, IoError }; + +// Error value literal +err := error.NotFound; +``` + +### Fault Sets (Quantum) + +```zlup_fragment +// Define fault set +QuantumFault := fault { Leakage, QubitLoss }; + +// Fault value literal +f := fault.Leakage; +``` + +### Error Unions + +```zlup_nocheck +// Function returning error union +fn read(path: []const u8) -> FileError![]u8 { + if !exists(path) { + return error.NotFound; + } + return data; +} +``` + +### Handling Errors + +```zlup_nocheck +// Propagate with try +data := try read("file.txt"); + +// Handle with catch +data := read("file.txt") catch |err| { + // handle error + return default; +}; + +// Default value +data := read("file.txt") catch "default"; +``` + +### Try Blocks + +```zlup_nocheck +// Collect all errors (QEC pattern) +errors := try { + risky_op1(); + risky_op2(); +}; + +// Stop on first error +result := try! { + step1(); + step2(); +} catch |err| handle(err); +``` + +### Defer + +```zlup_nocheck +fn process() -> unit { + resource := acquire(); + defer release(resource); // Runs on scope exit + + // Use resource... + return; +} + +// Error-specific defer +errdefer |err| cleanup(err); +``` + +--- + +## Quantum Operations + +### Allocators + +```zlup_fragment +// Allocate qubits +q := qalloc(4); + +// Child allocators +mut main := qalloc(10); +data := main.child(4); +ancilla := main.child(6); +``` + +### Prepare (Reset) + +```zlup_fragment +pz q; // Prepare all +pz q[0]; // Prepare one +pz {q[0], q[1]}; // Batch prepare +``` + +### Single-Qubit Gates + +```zlup_fragment +// Pauli gates +x q[0]; y q[0]; z q[0]; + +// Hadamard +h q[0]; + +// T gates (fourth root of Z) +t q[0]; tdg q[0]; + +// Square root gates (sz is the S gate, sqrt of Z) +sx q[0]; sy q[0]; sz q[0]; +sxdg q[0]; sydg q[0]; szdg q[0]; +``` + +### Rotation Gates + +```zlup_nocheck +// Parameterized rotations +rx(angle) q[0]; +ry(angle) q[0]; +rz(angle) q[0]; +``` + +### Two-Qubit Gates + +```zlup_fragment +// Controlled gates (control, target) +cx (q[0], q[1]); +cy (q[0], q[1]); +cz (q[0], q[1]); +ch (q[0], q[1]); + +// Swap +swap (q[0], q[1]); +iswap (q[0], q[1]); + +// Ising gates +sxx (q[0], q[1]); +syy (q[0], q[1]); +szz (q[0], q[1]); + +// Parameterized +rzz(angle) (q[0], q[1]); +crz(angle) (q[0], q[1]); +``` + +### Three-Qubit Gates + +```zlup_fragment +ccx (q[0], q[1], q[2]); // Toffoli +``` + +### Batch Operations + +```zlup_nocheck +// Apply same gate to multiple qubits +h {q[0], q[1], q[2]}; + +// Batch two-qubit gates +cx {(q[0], q[1]), (q[2], q[3])}; + +// Batch rotations +rx(angle) {q[0], q[1]}; +``` + +### Measurement + +```zlup_nocheck +// Single qubit +result: u1 = mz(u1) q[0]; + +// Multiple qubits into array +results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + +// Measure entire register +all_bits := mz([4]u1) q; + +// Pack into integer +syndrome: u8 = mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; + +// Pack into custom struct (for QEC syndromes, etc.) +Syndrome := struct { x_parity: u1, z_parity: u1, flags: u2 }; +syndrome := mz(pack Syndrome) [ancilla[0], ancilla[1], ancilla[2], ancilla[3]]; +``` + +The `pack` modifier fills bits sequentially into the target type's bit layout. +Without `pack`, each qubit produces one value of type T (count must match exactly). + +### Tick Blocks + +```zlup_fragment +// Group parallel operations +tick { + h q[0]; + h q[1]; +} + +// Named tick with attributes +@attr(round, 0) +tick syndrome_check { + // operations +} +``` + +--- + +## Attributes + +```zlup_nocheck +// Single attribute +@attr(key, value) + +// Multiple attributes +@attrs({key1: value1, key2: value2}) + +// On declarations +@attr(inline, true) +fn fast_op() -> unit { } + +// On tick blocks +@attr(round, 0) +tick { } +``` + +--- + +## Modules + +### Import + +```zlup_nocheck +// Import standard library +std := @import("std"); + +// Import local module +utils := @import("utils.zlup"); +``` + +### Public Exports + +```zlup_nocheck +// Public binding +pub x := 42; + +// Public function +pub fn helper() -> unit { } + +// Public type +pub Point := struct { x: f64, y: f64 }; +``` + +### Builtins + +| Builtin | Description | +|---------|-------------| +| `@import(path)` | Import module | +| `@size_of(T)` | Size of type in bytes | +| `@type_info(T)` | Returns structured type information (kind, name, fields, etc.) | +| `@type_name(T)` | Type name as string | +| `@field_names(T)` | Returns array of struct field names | +| `@enum_fields(T)` | Returns array of enum variant names | +| `@type_from_info(info)` | Construct type from TypeInfo struct (reverse of `@type_info`) | +| `@compile_error(msg)` | Compile-time error | +| `@compile_log(...)` | Compile-time debug print | + +Note: Both snake_case (Rust/Python style) and camelCase names are accepted for builtins. Snake_case is preferred. + +--- + +## Logging + +Zlup provides built-in structured logging with namespace filtering. + +### Basic Logging + +```zlup_nocheck +// Standard log levels +@emit.log.trace(f"detailed trace"); +@emit.log.debug(f"debug info"); +@emit.log.info(f"general info"); +@emit.log.warn(f"warning"); +@emit.log.error(f"error"); +``` + +### With Namespace + +```zlup_nocheck +// Sub-namespace for filtering +@emit.log.debug("decoder", f"processing syndrome"); +@emit.log.info("qec::round", f"round complete"); +``` + +### With Structured Data + +```zlup_nocheck +// Attach data for structured logging +@emit.log.debug(f"state", data: current_state); +@emit.log.info(f"result", data: measurement_results); +``` + +### Custom Log Levels + +```zlup_nocheck +// Numeric levels for fine-grained control +@emit.log.at(15, f"between trace and debug"); +@emit.log.at(25, "perf", f"timing metric"); +``` + +### Log Levels + +| Level | Priority | Description | +|-------|----------|-------------| +| `trace` | 0 | Very detailed tracing | +| `debug` | 100 | Debug information | +| `info` | 200 | General information | +| `warn` | 300 | Warnings | +| `error` | 400 | Errors | + +### Runtime Filtering (ZLUP_LOG) + +```bash +# All logs at debug and above +ZLUP_LOG=debug ./program + +# Only errors +ZLUP_LOG=error ./program + +# By namespace +ZLUP_LOG=mymodule=trace ./program +ZLUP_LOG=mymodule::decoder=debug ./program +``` + +### Compile-Time Elision + +```bash +# Release mode - remove all logs +zlup compile --release program.zlp + +# Keep only warn and error +zlup compile --log-level 300 program.zlp +``` + +--- + +## Result Emission + +The `result()` function emits tagged values as program outputs. Unlike logs, results are **never elided** - they're essential for returning data from quantum programs. + +### Basic Usage + +```zlup_fragment +// Emit a measurement result +result("measurement", m); + +// Emit computed values +result("parity", parity_check); +result("syndrome", syndrome_bits); +``` + +### Namespaced Tags + +Use `/` to organize results hierarchically: + +```zlup_fragment +// QEC results +result("qec/syndrome", syndrome); +result("qec/parity", parity); + +// Per-round results +result("round_1/ancilla", ancilla_result); +result("round_2/ancilla", ancilla_result); + +// Nested namespaces +result("experiment/run_5/final_state", state); +``` + +### Supported Value Types + +```zlup_nocheck +result("int_result", 42); // Integers +result("bool_result", true); // Booleans +result("float_result", 3.14); // Floats +result("array_result", [1, 2, 3]); // Arrays +``` + +### Ordering + +Unlike `@emit.sim.*` and `@emit.log.*`, result expressions have **flexible scheduling**. They can "slip down" during compilation - the value just needs to be eventually recorded. This means: + +```zlup_nocheck +h q[0]; +result("before_cx", some_value); // Could be reordered +cx (q[0], q[1]); +result("after_cx", other_value); +``` + +The compiler may batch or reorder result emissions as long as data dependencies are respected. This allows optimizations that wouldn't be possible with strict ordering. + +### Comparison with Guppy + +Zlup's `result()` is equivalent to Guppy's `result(tag, value)`: +- Tag must be a compile-time string literal +- Returns tagged (key, value) pairs to the caller +- Essential for extracting data from quantum program execution + +### Entry Function Pattern + +Entry/main functions should return `unit` and use explicit `result()` calls to emit outputs: + +```zlup_nocheck +fn main() -> unit { + q := qalloc(4); + h q[0]; + cx (q[0], q[1]); + m := measure(q); + result("measurements", m); // Emit to runtime +} +``` + +This pattern keeps the return type simple and makes data emission explicit. + +--- + +## Simulator Control + +The `@emit.sim.*` channel sends hints and commands to the simulator. These are **elided when targeting hardware** (`--target hardware`) but active for simulator and emulator targets. + +The sim channel is intentionally kept simple and flexible. Most communication uses `@emit.sim.send(key, value)` which the noise modeling interprets. + +### Noise Control + +Noise is **enabled by default**. Use these convenience functions to toggle: + +```zlup_nocheck +@emit.sim.noise_disable(); // Turn off noise +@emit.sim.noise_enable(); // Turn on noise (default state) +``` + +### Generic Message Channel + +Use `@emit.sim.send(key, value)` for all other simulator communication: + +```zlup_nocheck +// Set RNG seed for reproducibility +@emit.sim.send("seed", 12345); + +// Configure noise model +@emit.sim.send("noise_model", "depolarizing"); +@emit.sim.send("noise_rate", 0.001); + +// Create checkpoints +@emit.sim.send("checkpoint", "before_correction"); + +// Any custom key-value pairs the simulator understands +@emit.sim.send("custom_param", some_value); +``` + +### Full Example + +```zlup_nocheck +pub fn main() -> unit { + // Set up reproducible simulation + @emit.sim.send("seed", 42); + @emit.sim.send("noise_model", "depolarizing"); + @emit.sim.send("noise_rate", 0.001); + + // Allocate and prepare + q := qalloc(3); + @emit.sim.send("checkpoint", "initial"); + + // Apply gates (noise enabled by default) + h q[0]; + cx (q[0], q[1]); + cx (q[0], q[2]); + + @emit.sim.send("checkpoint", "after_encoding"); + + // Disable noise for measurement + @emit.sim.noise_disable(); + + // Measure + syndrome := mz(u8) q[1..3]; + + result("syndrome", syndrome); + return; +} +``` + +### SLR Output + +Both `result(key, value)` and `@emit.sim.send(key, value)` generate a unified `SendStmt` in SLR: + +```json +{"type": "SendStmt", "channel": "result", "key": "counts", "value": {...}} +{"type": "SendStmt", "channel": "sim", "key": "seed", "value": 42} +{"type": "SendStmt", "channel": "sim", "key": "noise_enable", "value": null} +``` + +The `channel` field distinguishes them for downstream handling (PECOS, etc.). + +### Ordering Semantics + +The three channels have different ordering/scheduling behavior: + +| Channel | Scheduling | Elision Behavior | +|---------|------------|------------------| +| `result(key, value)` | Flexible | Never elided - program output | +| `@emit.sim.*` | Barrier | Elided for hardware, but preserves ordering | +| `@emit.log.*` | Flexible | Can be fully elided in release mode | + +**Result expressions** can "slip" during optimization - they just need to eventually record the value. They have data dependencies on their value but can be reordered relative to other operations. + +**Simulator commands** (`@emit.sim.*`) act as synchronization points. `@emit.sim.noise_disable()` must happen *before* the operations it protects. When compiled for hardware targets, sim commands are elided but could optionally emit a barrier to preserve the same scheduling behavior as on simulator (currently they are completely elided). + +**Log expressions** (`@emit.log.*`) can be completely elided in release mode. Unlike sim commands, logs are purely for debugging - when elided, no semantic difference should exist. This allows optimizations to move operations across where logs used to be. + +**Current implementation:** +- The SLR codegen processes statements in source order +- `@emit.log.*` with elision returns no statement - complete removal, allows optimizations +- `@emit.sim.*` for hardware emits a barrier by default to preserve ordering +- Use `--elide-sim` flag for complete removal (max optimization, no ordering guarantee) +- Downstream tools (PECOS, hardware compilers) may perform their own reordering + +**SimMode options:** +| Mode | Behavior | Use case | +|------|----------|----------| +| `Emit` | Output actual SimStmt | Simulator target | +| `Barrier` | Output scoped barrier | Hardware default - preserves ordering | +| `Elide` | Complete removal | `--elide-sim` flag - max optimization | + +The barrier is scoped to allocators visible in the current scope, not a global barrier. This means `@emit.sim.*` commands only synchronize the qubits that are actually accessible at that point in the program. + +### Target-Dependent Behavior + +| Command | `--target simulator` | `--target hardware` | `--target hardware --elide-sim` | +|---------|---------------------|---------------------|--------------------------------| +| `@emit.sim.noise_enable()` | SimStmt | Barrier (no-op) | Elided | +| `@emit.sim.noise_disable()` | SimStmt | Barrier (no-op) | Elided | +| `@emit.sim.send(...)` | SimStmt | Barrier (no-op) | Elided | + +By default, hardware targets preserve ordering with barriers. Use `--elide-sim` for complete removal. + +--- + +## Compilation Targets + +Zlup separates **what** you're compiling for (target) from **how** you serialize (format). + +### Execution Targets + +```bash +# Simulator (default): full debug, relaxed constraints +zlup compile program.zlp --target simulator + +# Hardware: strict constraints, simulation artifacts removed +zlup compile program.zlp --target hardware + +# Emulator: hardware-like constraints with visibility +zlup compile program.zlp --target emulator +``` + +### Output Formats + +```bash +# SLR-AST JSON (default, for Python/PECOS) +zlup compile program.zlp --format slr + +# PHIR JSON (PECOS simulator) +zlup compile program.zlp --format phir-json + +# OpenQASM 2.0 +zlup compile program.zlp --format qasm +``` + +### Build Modes + +```bash +# Debug (default): all logs, permissive +zlup compile program.zlp --mode debug + +# Release: optimized, logs elided, strict +zlup compile program.zlp --mode release +``` + +### Combined Examples + +```bash +# Development workflow +zlup compile program.zlp # simulator + slr + debug + +# Production for hardware +zlup compile program.zlp --target hardware --mode release + +# Simulation with QASM output +zlup compile program.zlp --target simulator --format qasm + +# Full control +zlup compile program.zlp \ + --target hardware \ + --format slr \ + --mode release \ + --strict true \ + --log-level 400 +``` + +### Effective Settings by Target + Mode + +| Target | Mode | Strict | Log Elision | +|--------|------|--------|-------------| +| simulator | debug | No | None (all logs) | +| simulator | release | Yes | 100+ (debug+) | +| hardware | debug | Yes | 300+ (warn+) | +| hardware | release | Yes | 300+ (warn+) | +| emulator | debug | Yes | 200+ (info+) | +| emulator | release | Yes | 200+ (info+) | + +--- + +## Reserved Keywords + +``` +and break catch comptime continue +defer else enum errdefer error +false fault fn for if +in inline log mut none +not or orelse packed pub +return Self set struct switch +test tick true try type +undefined union unit unsafe +``` + +--- + +## Grammar Summary + +``` +program = declaration* +declaration = binding | fn_decl | struct_decl | enum_decl | union_decl | error_set | fault_set +binding = "pub"? "mut"? identifier (":" type)? "=" expr ";" +fn_decl = "pub"? "inline"? "fn" name "(" params ")" ("->" type)? block +statement = binding | assignment | if | for | switch | tick | return | break | continue | defer | block | expr ";" +expr = binary_expr | unary_expr | primary_expr +``` diff --git a/exp/zlup/docs/tutorial-error-handling.md b/exp/zlup/docs/tutorial-error-handling.md new file mode 100644 index 000000000..187b2a67a --- /dev/null +++ b/exp/zlup/docs/tutorial-error-handling.md @@ -0,0 +1,666 @@ +# Error Handling in Zlup: A Practical Guide + +This tutorial covers Zlup's error handling system, designed specifically for quantum error +correction workflows. You'll learn the difference between faults and errors, how to collect +quantum faults while stopping on classical errors, and how to write robust QEC code. + +## Table of Contents + +- [The Faults vs Errors Distinction](#the-faults-vs-errors-distinction) +- [Error Sets and Fault Sets](#error-sets-and-fault-sets) +- [Error Union Syntax](#error-union-syntax) +- [Handling Errors with catch](#handling-errors-with-catch) +- [Try Blocks: Collect vs Propagate](#try-blocks-collect-vs-propagate) +- [Try Functions](#try-functions) +- [The Explicit Handling Philosophy](#the-explicit-handling-philosophy) +- [Practical QEC Examples](#practical-qec-examples) +- [Summary](#summary) + +--- + +## The Faults vs Errors Distinction + +Zlup distinguishes between **faults** and **errors** based on where they originate and how +we typically want to handle them: + +| Aspect | Fault | Error | +|--------|-------|-------| +| **Origin** | Quantum hardware (physical layer) | Classical logic (software layer) | +| **Nature** | Expected imperfection | Unexpected problem | +| **Handling** | Collect for later analysis | Stop execution immediately | +| **Keyword** | `fault` | `error` | + +### Why This Matters + +**Faults happen at the physical layer.** The quantum hardware did something imperfect: +a gate didn't apply cleanly, a qubit leaked to a non-computational state, a measurement +was noisy. These are *expected* - QEC is designed to handle a certain fault rate. + +```zlup_nocheck +// A QEC round might see 50 faults - that's normal! +// Stopping on every fault would make error correction impossible. +faults, syndrome := syndrome_round(q); +if faults.len < threshold { + // Still correctable - this is what QEC is for + apply_correction(decode(syndrome), q); +} +``` + +**Errors happen at the logical layer.** Something in the classical algorithm went wrong: +the decoder couldn't find a valid correction, a file wasn't found, an invalid state was +reached. These indicate bugs or unrecoverable situations. + +```zlup_nocheck +// If the decoder says "I can't figure out a correction" - that's an error +correction := decode(syndrome) catch |err| { + log("Decoder failed: {}", err); + return; // Can't continue +}; +``` + +**Mental model:** +- Faults = "expected badness we're designed to handle" +- Errors = "unexpected badness that means something is wrong" + +--- + +## Error Sets and Fault Sets + +Define your own error and fault types using the `error` and `fault` keywords: + +### Classical Error Sets + +```zlup +// Define an error set for decoder failures +DecodeError := error { + SyndromeAmbiguous, + WeightTooHigh, + NoValidCorrection, +}; + +// Error sets for I/O operations +IoError := error { + FileNotFound, + PermissionDenied, + ConnectionLost, +}; + +// Create error values +err := error.SyndromeAmbiguous; +``` + +### Quantum Fault Sets + +```zlup +// Define a fault set for quantum hardware faults +QuantumFault := fault { + Leakage, // Qubit leaked to non-computational state + QubitLoss, // Qubit physically lost + GateFailure, // Gate didn't apply correctly + MeasurementError, // Measurement gave wrong result +}; + +// Create fault values +f := fault.Leakage; +``` + +### When to Use Each + +| Situation | Use | +|-----------|-----| +| Decoder can't find correction | `error` | +| File I/O failed | `error` | +| Invalid function argument | `error` | +| Gate applied with noise | `fault` | +| Qubit leaked to |2⟩ | `fault` | +| Measurement bit flip | `fault` | + +--- + +## Error Union Syntax + +Error unions express that a function can either return a value or an error/fault. + +### Basic Error Union: `E!T` + +The `E!T` syntax means "either error type E, or value type T": + +```zlup_nocheck +// Function that might fail +fn divide(a: f64, b: f64) -> DivError!f64 { + if b == 0.0 { + return error.DivisionByZero; + } + return a / b; +} + +// Multiple error types +fn read_config(path: []const u8) -> IoError!Config { + if !file_exists(path) { + return error.FileNotFound; + } + // ... parse and return config + return config; +} +``` + +### Collected Faults: `[]E!T` + +For QEC patterns, you often want to collect all faults that occurred, plus either +an error or the final value: + +```zlup_nocheck +// This function collects quantum faults, might return a classical error +fn qec_round(q: []qubit) try -> []QuantumFault!Syndrome { + // Faults collected, execution continues + cx (q[0], q[1]); // Might fault + cx (q[1], q[2]); // Might fault, still runs + + syndrome := mz([3]u1) [q[3], q[4], q[5]]; + + // Classical error stops execution + correction := decode(syndrome); // If this errors, we stop + + return syndrome; +} + +// Caller receives both faults and result +faults, result := qec_round(q); +``` + +--- + +## Handling Errors with catch + +The `catch` keyword handles errors when they occur: + +### Basic catch + +```zlup_nocheck +// Provide a default value +result := divide(10.0, x) catch 0.0; + +// Handle with a block +result := divide(10.0, x) catch |err| { + log("Division failed: {}", err); + return 0.0; +}; + +// Transform the error +result := read_config("settings.json") catch |err| { + log("Config load failed, using defaults"); + return Config.default(); +}; +``` + +### catch with Error Inspection + +```zlup_nocheck +fn load_or_create(path: []const u8) -> Config { + config := read_config(path) catch |err| switch (err) { + error.FileNotFound => { + // File doesn't exist, create default + return Config.default(); + }, + error.PermissionDenied => { + log("Cannot read {}: permission denied", path); + abort(); + }, + else => { + log("Unexpected error: {}", err); + abort(); + }, + }; + return config; +} +``` + +### Unwrapping with `.!` + +When you're certain the value isn't an error, use `.!` to unwrap: + +```zlup_nocheck +// Only use when you know it will succeed +result := divide(10.0, 2.0).!; // We know 2.0 != 0 + +// Better: use catch for safety +result := divide(10.0, x) catch |_| abort(); +``` + +--- + +## Try Blocks: Collect vs Propagate + +Zlup provides two modes for handling errors within a scope: + +### `try!` - Stop on First Error/Fault (Strict Mode) + +The `try!` block stops immediately when any fault or error occurs: + +```zlup_nocheck +fn strict_preparation(q: []qubit) -> QuantumFault!unit { + try! { + pz q; // If this faults, stop immediately + h q[0]; // Only runs if pz succeeded + cx (q[0], q[1]); // Only runs if h succeeded + } catch |fault| { + log("Preparation failed: {}", fault); + return fault; + } + return; +} +``` + +Use `try!` when: +- Running calibration sequences where any fault invalidates results +- Debugging to find exactly where faults occur +- Operations must succeed completely or not at all + +### `try` - Collect Faults, Stop on Errors (QEC Mode) + +The `try` block collects quantum faults but stops on classical errors: + +```zlup_nocheck +fn qec_syndrome_round(q: []qubit) -> ([]QuantumFault, DecodeError!Syndrome) { + faults, result := try { + // Quantum operations - faults collected, continues + cx (q[0], q[3]); // Fault? Recorded, keep going + cx (q[1], q[3]); // Fault? Recorded, keep going + cx (q[2], q[3]); // Fault? Recorded, keep going + + syndrome := mz([4]u1) [q[3], q[4], q[5], q[6]]; + + // Classical operation - error stops execution + correction := decode(syndrome); // Error? Stop here + + apply_correction(correction, q); + return syndrome; + }; + + // Handle the result + result catch |err| { + log("Decode failed after {} faults: {}", faults.len, err); + return; + }; + + // Success - might still have faults + if faults.len > 0 { + log("Round completed with {} faults", faults.len); + } + return faults, result; +} +``` + +Use `try` when: +- Running QEC rounds where some faults are expected +- Collecting fault statistics for analysis +- Operations should continue despite hardware imperfections + +### Behavior Summary + +| Mode | Quantum Fault | Classical Error | Return Type | +|------|---------------|-----------------|-------------| +| `try!` | **Stops immediately** | **Stops immediately** | `E!T` | +| `try` | Collected, continues | Stops, returns collected faults | `([]Fault, E!T)` | + +--- + +## Try Functions + +Functions can be declared with `try` or `try!` to indicate their error handling mode: + +### `try!` Functions + +```zlup_nocheck +// Strict mode: any fault/error stops execution +fn calibrate_qubit(q: qubit) try! -> CalibrationFault!CalibrationData { + pz q; + h q; + + // Repeated measurements for statistics + for i in 0..100 { + m := mz(u1) q; + record(m); + pz q; + h q; + } + + return analyze_calibration(); +} + +// Caller handles single fault/error +data := calibrate_qubit(q[0]) catch |fault| { + log("Calibration failed: {}", fault); + return; +}; +``` + +### `try` Functions + +```zlup_nocheck +// QEC mode: faults collected, errors stop +fn run_qec_cycle(code: *SurfaceCode) try -> []QuantumFault!unit { + // All operations in this function collect faults + code.syndrome_round(); + code.decode_and_correct(); + return; +} + +// Caller receives faults and result +faults, result := run_qec_cycle(&code); + +result catch |err| { + log("QEC cycle failed: {}", err); + log("Faults before failure: {}", faults); + return; +}; + +// Success path +if faults.len > warning_threshold { + log("Warning: {} faults in cycle", faults.len); +} +``` + +--- + +## The Explicit Handling Philosophy + +Zlup intentionally does **not** have a `?` operator like Rust. This is a deliberate design +choice for quantum computing contexts. + +### Why No `?` Operator? + +Rust's `?` operator makes error propagation easy - perhaps too easy: + +```rust +// Rust: ? makes it easy to just bubble errors up without thought +fn do_something() -> Result { + let x = step1()?; // Just propagate + let y = step2()?; // Just propagate + let z = step3()?; // Just propagate + Ok(z) +} +``` + +While explicit, this pattern becomes so automatic that developers stop thinking about +error handling. Errors bubble up, but nobody along the way considered what to do about them. + +### Zlup's Approach: Deliberate Handling + +In QEC, errors and faults carry crucial diagnostic information. Mechanical propagation +loses context and makes debugging difficult: + +```zlup_nocheck +// INVALID: ignoring the return value +qec_round(q); // Compile error: unhandled faults/errors + +// ENCOURAGED: deliberate handling with context +faults, result := qec_round(q); +result catch |err| { + log("QEC round failed at step {}: {}", step, err); + log("Faults before failure: {}", faults); + log("Syndrome state: {}", last_syndrome); + return err; // Propagate with added context +}; + +// VALID: explicitly discard if truly not needed +_ = qec_round(q); // Explicit discard - you meant to ignore it +``` + +### Why This Matters for Quantum Computing + +In classical computing, a propagated error eventually reaches a handler somewhere. +In quantum computing, by the time an error surfaces, the quantum state may be +irretrievably corrupted. Understanding *where* and *why* things went wrong is essential for: + +- Debugging QEC implementations +- Tuning error thresholds +- Identifying systematic hardware issues +- Post-mortem analysis of failed computations + +Silent or mechanical error propagation loses this crucial information. + +--- + +## Practical QEC Examples + +### Example 1: Basic Syndrome Extraction + +```zlup_nocheck +QuantumFault := fault { Leakage, GateError, MeasurementError }; +QecError := error { DecodeFailed, TooManyFaults }; + +fn extract_syndrome(data: []qubit, ancilla: []qubit) try -> []QuantumFault![]u1 { + // Prepare ancilla + pz ancilla; + h ancilla; + + // Stabilizer measurements (faults collected) + for i in 0..ancilla.len { + cx (data[i * 2], ancilla[i]); + cx (data[i * 2 + 1], ancilla[i]); + } + + h ancilla; + + // Measure ancilla + syndrome := mz([4]u1) ancilla; + + return syndrome; +} + +pub fn main() -> unit { + q := qalloc(8); + pz q; + + alias data := q[0..4]; + alias ancilla := q[4..8]; + + // Run syndrome extraction + faults, syndrome_result := extract_syndrome(data, ancilla); + + syndrome := syndrome_result catch |err| { + log("Syndrome extraction failed: {}", err); + return; + }; + + log("Syndrome: {}, Faults: {}", syndrome, faults.len); + result("syndrome", syndrome); + result("fault_count", faults.len); + + return; +} +``` + +### Example 2: Full QEC Round with Threshold + +```zlup_nocheck +QuantumFault := fault { Leakage, QubitLoss, GateFailure }; +DecodeError := error { SyndromeAmbiguous, WeightTooHigh }; + +fn qec_round(code: *SurfaceCode, max_faults: usize) try -> []QuantumFault!DecodeError!unit { + // Extract syndrome (collects faults) + syndrome := code.measure_stabilizers(); + + // Check if too many faults occurred + // Note: we're inside a try block, so faults are being collected + // We can inspect them at any point + + // Decode (classical - error stops execution) + correction := decode(syndrome); + + // Apply correction + code.apply_correction(correction); + + return; +} + +pub fn main() -> unit { + mut base := qalloc(100); + mut code := SurfaceCode.init(&base, 3); + + code.prepare_logical_zero(); + + // Run multiple QEC rounds + mut total_faults: usize = 0; + + for round in 0..1000 { + faults, result := qec_round(&code, 10); + + total_faults += faults.len; + + result catch |err| { + log("Round {} failed: {}", round, err); + log("Total faults so far: {}", total_faults); + break; + }; + + // Log periodic status + if round % 100 == 0 { + log("Round {}: {} faults this round, {} total", round, faults.len, total_faults); + } + } + + // Final measurement + logical := code.measure_logical(); + result("logical_result", logical); + result("total_faults", total_faults); + + return; +} +``` + +### Example 3: Promoting Faults to Errors + +Sometimes accumulated faults cross a threshold where they should become a logical error: + +```zlup_nocheck +fn qec_round_with_threshold( + q: []qubit, + max_correctable: usize +) try -> []QuantumFault!QecError!Syndrome { + // Run the circuit, collecting faults + faults, syndrome := run_stabilizers(q); + + // Too many faults? Promote to a classical error + // This is a classical decision, so it's an error, not a fault + if faults.len > max_correctable { + return error.TooManyFaults; + } + + // Faults within tolerance - continue + return syndrome; +} + +// Caller receives both the faults AND the error/result +faults, result := qec_round_with_threshold(q, 5); + +result catch |err| { + // err might be TooManyFaults - we still have access to `faults` + log("QEC failed with {} faults: {}", faults.len, err); + return; +}; + +// Success - but we still know how many faults occurred +syndrome := result.!; +if faults.len > 0 { + log("Succeeded with {} faults", faults.len); +} +``` + +### Example 4: Rich Fault Context + +Faults carry context information that can be inspected: + +```zlup_nocheck +fn analyze_faults(faults: []QuantumFault) -> unit { + mut leakage_count: usize = 0; + mut gate_failures: usize = 0; + + for fault in faults { + switch (fault) { + fault.Leakage => |ctx| { + log("Leakage in {} on qubit {}", ctx.gate, ctx.qubit); + leakage_count += 1; + }, + fault.QubitLoss => |ctx| { + log("Lost qubit {} during {}", ctx.qubit, ctx.gate); + flag_qubit_lost(ctx.qubit); + }, + fault.GateFailure => |ctx| { + log("Gate {} failed on qubits {:?}", ctx.gate, ctx.qubits); + gate_failures += 1; + }, + else => { + log("Unknown fault: {}", fault); + }, + } + } + + result("leakage_events", leakage_count); + result("gate_failures", gate_failures); + + return; +} +``` + +--- + +## Summary + +### Key Concepts + +| Concept | Description | +|---------|-------------| +| **Fault** | Physical layer issue (expected, collected) | +| **Error** | Logical layer issue (unexpected, stops execution) | +| **`E!T`** | Error union: either error E or value T | +| **`[]E!T`** | Collected faults plus error union | +| **`try!`** | Stop on first fault/error (strict) | +| **`try`** | Collect faults, stop on errors (QEC) | +| **`catch`** | Handle errors with default or block | +| **`.!`** | Unwrap (when certain no error) | + +### Best Practices + +1. **Use `fault` for hardware issues**, `error` for software issues +2. **Use `try` blocks for QEC rounds** where faults are expected +3. **Use `try!` blocks for calibration** where any fault invalidates results +4. **Always handle or explicitly discard** return values +5. **Add context when propagating errors** - don't just bubble up +6. **Inspect fault context** to diagnose hardware issues +7. **Set thresholds** to promote excessive faults to errors + +### Quick Reference + +```zlup_nocheck +// Define fault and error sets +QuantumFault := fault { Leakage, QubitLoss }; +DecodeError := error { Failed, Ambiguous }; + +// Function that collects faults +fn qec_op(q: []qubit) try -> []QuantumFault!DecodeError!T { ... } + +// Function that stops on first fault +fn strict_op(q: []qubit) try! -> QuantumFault!T { ... } + +// Handle with catch +result := may_fail() catch default_value; +result := may_fail() catch |err| { handle(err); return; }; + +// Collect faults from try function +faults, result := qec_op(q); + +// Try block (collect mode) +faults, result := try { + // quantum ops - faults collected + // classical ops - errors stop +}; + +// Try! block (strict mode) +try! { + // any fault or error stops immediately +} catch |err| { + handle(err); +}; +``` diff --git a/exp/zlup/docs/tutorial.md b/exp/zlup/docs/tutorial.md new file mode 100644 index 000000000..650b8fe04 --- /dev/null +++ b/exp/zlup/docs/tutorial.md @@ -0,0 +1,492 @@ +# Zlup Tutorial: Getting Started + +This tutorial will guide you through writing your first Zlup quantum programs. By the end, you'll understand the core concepts: allocators, gates, measurement, and control flow. + +## What is Zlup? + +Zlup is the **low-level complement to Guppy** in PECOS. While Guppy provides a high-level, Pythonic experience for QEC researchers, Zlup is designed for: + +- **Quantum orchestration**: Gate sequences, syndrome extraction, correction application +- **Rust integration**: Calling decoders and simulation backends via FFI +- **Reliable, production-grade code** following NASA Power of 10 principles +- **Simulation infrastructure**: Noise modeling, error injection, state tracking + +Complex classical algorithms (MWPM decoders, etc.) belong in **Rust**—Zlup handles the quantum side and calls Rust for heavy computation. + +If you're a QEC researcher who prefers Python, **use Guppy**. Zlup is for the systems layer connecting quantum operations to classical backends. + +## Prerequisites + +- Zlup compiler installed (`cargo install --path .` from the zlup directory) +- Basic understanding of quantum computing concepts (qubits, gates, measurement) + +## Your First Program: Hello Quantum + +Create a file called `hello.zlp`: + +```zlup +/// My first Zlup program - creates a superposition state +pub fn main() -> unit { + // Allocate a single qubit + q := qalloc(1); + + // Prepare (reset) the qubit to |0⟩ + pz q; + + // Apply Hadamard gate to create superposition + h q[0]; + + // Measure the qubit + result: u1 = mz(u1) q[0]; + + return; +} +``` + +Compile it: + +```bash +zlup compile hello.zlp -o hello.json +``` + +Let's break down what each line does. + +## Allocators: Managing Qubits + +In Zlup, qubits are managed through **allocators**. This explicit resource management (inspired by Zig) ensures you always know where your qubits come from. + +```zlup_fragment +// Allocate 4 qubits +q := qalloc(4); + +// Access individual qubits by index +h q[0]; // First qubit +h q[1]; // Second qubit +h q[3]; // Fourth qubit (last) +``` + +The allocator capacity is fixed at compile time - you can't dynamically grow it. This is intentional: bounded resources make programs predictable and analyzable. + +### Child Allocators + +For complex algorithms, you can partition allocators: + +```zlup_fragment +// Main allocator with 10 qubits +mut main := qalloc(10); + +// Create child allocators from it +data := main.child(4); // First 4 qubits for data +ancilla := main.child(6); // Remaining 6 for ancillas +``` + +Note: The parent must be declared `mut` to create children. + +## Preparing Qubits + +Before using qubits, prepare (reset) them to the |0⟩ state with `pz`: + +```zlup_fragment +q := qalloc(4); + +// Prepare all qubits at once +pz q; + +// Or prepare specific qubits +pz q[0]; +pz {q[1], q[2]}; // Batch prepare +``` + +## Gates: Quantum Operations + +### Single-Qubit Gates + +```zlup_fragment +// Pauli gates +x q[0]; // X (NOT) gate +y q[0]; // Y gate +z q[0]; // Z gate + +// Hadamard +h q[0]; // Creates superposition + +// T gates (fourth root of Z) +t q[0]; // T gate +tdg q[0]; // T-dagger (inverse of T) + +// Square root gates (sz is the S gate) +sx q[0]; // sqrt(X) +sy q[0]; // sqrt(Y) +sz q[0]; // sqrt(Z) - this is the S gate +sxdg q[0]; // sqrt(X) dagger +sydg q[0]; // sqrt(Y) dagger +szdg q[0]; // sqrt(Z) dagger - this is S-dagger +``` + +### Rotation Gates (Parameterized) + +Rotation gates require an angle with explicit units: + +```zlup_nocheck +std := @import("std"); + +// Preferred: use turns (native unit) +rx(1/8 turns) q[0]; // Rotate by 1/8 turn (pi/4 rad) around X +ry(1/4 turns) q[0]; // Rotate by 1/4 turn (pi/2 rad) around Y +rz(1/2 turns) q[0]; // Rotate by 1/2 turn (pi rad) around Z + +// Or use a64 constants +rz(std.a64.t_angle turns) q[0]; // T-gate (1/8 turn) + +// Or use radians with f64 constants +rx(std.f64.pi/4 rad) q[0]; // Same as 1/8 turns +``` + +### Two-Qubit Gates + +Two-qubit gates use tuple syntax for (control, target): + +```zlup_fragment +// CNOT (controlled-X) +cx (q[0], q[1]); // q[0] controls, q[1] is target + +// Other controlled gates +cy (q[0], q[1]); // Controlled-Y +cz (q[0], q[1]); // Controlled-Z +ch (q[0], q[1]); // Controlled-Hadamard + +// Swap gates +swap (q[0], q[1]); +iswap (q[0], q[1]); + +// Ising gates +sxx (q[0], q[1]); // sqrt(XX) +syy (q[0], q[1]); // sqrt(YY) +szz (q[0], q[1]); // sqrt(ZZ) + +// Parameterized two-qubit +rzz(1/8 turns) (q[0], q[1]); +``` + +### Batch Operations + +Apply the same gate to multiple qubits in parallel using set syntax: + +```zlup_fragment +// Apply H to multiple qubits (order doesn't matter) +h {q[0], q[1], q[2]}; + +// Batch two-qubit gates +cx {(q[0], q[1]), (q[2], q[3])}; + +// Batch rotation +rx(1/4 turns) {q[0], q[1], q[2]}; +``` + +## Measurement + +Measurement extracts classical information from qubits: + +```zlup_fragment +// Measure single qubit into a u1 (single bit) +r: u1 = mz(u1) q[0]; + +// Measure multiple qubits into an array +results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + +// Pack measurements into a byte +syndrome: u8 = mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; +``` + +The `pack` modifier packs bits sequentially into the target type. + +## Emitting Results + +To output values from your quantum program, use `result()`: + +```zlup_fragment +// Emit a single measurement +result("final_bit", r); + +// Emit array of measurements +result("syndrome", results); + +// Use namespaced tags for organization +result("qec/round_1/syndrome", syndrome); +``` + +Results are collected by the quantum runtime and never elided. Entry/main functions +return `unit` and use explicit `result()` calls rather than returning values. + +## Creating a Bell State + +Let's create a Bell state - the simplest entangled state: + +```zlup +/// Creates a Bell state |00⟩ + |11⟩ (unnormalized) +pub fn main() -> unit { + q := qalloc(2); + pz q; + + // Create superposition on first qubit + h q[0]; + + // Entangle with second qubit + cx (q[0], q[1]); + + // Measure both qubits + r0: u1 = mz(u1) q[0]; + r1: u1 = mz(u1) q[1]; + + // Emit results - r0 and r1 will always be correlated: + // both 0 or both 1 + result("qubit_0", r0); + result("qubit_1", r1); + + return; +} +``` + +## Variables and Bindings + +Zlup uses Pascal/Go-style binding syntax: + +```zlup_fragment +// Immutable binding (most common) +x := 42; // Type inferred +y: u32 = 100; // Type explicit + +// Mutable binding +mut count := 0; +count = count + 1; + +// Constants +pi := 3.14159; // Immutable by default +``` + +## Control Flow + +### If Statements + +```zlup_nocheck +// Simple if +if x > 10 { + // do something +} + +// If-else +if condition { + // true branch +} else { + // false branch +} + +// If-else if chain +if x == 0 { + // zero +} else if x < 0 { + // negative +} else { + // positive +} +``` + +### For Loops (Bounded) + +All loops in Zlup must have bounded iteration (NASA Power of 10 rule): + +```zlup_nocheck +// Range loop +for i in 0..10 { + h q[i]; +} + +// Iterating with index +for i, item in items { + // use i and item +} +``` + +### Switch Statements + +```zlup_nocheck +switch (value) { + 0 => { /* handle 0 */ }, + 1, 2 => { /* handle 1 or 2 */ }, + 3..10 => { /* handle range 3-10 */ }, + else => { /* default */ }, +} +``` + +## Functions + +```zlup_nocheck +// Simple function +fn add(a: u32, b: u32) -> u32 { + return a + b; +} + +// Function with no return value +fn apply_gates(q: *Allocator) -> unit { + h q[0]; + return; +} + +// Public function (exported) +pub fn main() -> unit { + result := add(2, 3); + return; +} +``` + +## Working with the Standard Library + +Import and use standard library modules: + +```zlup_nocheck +std := @import("std"); + +pub fn main() -> unit { + // Angle constants from std.a64 + angle := std.a64.t_angle; + + q := qalloc(8); + pz q; + + // Apply rotation (using turns) + rz(angle turns) q[0]; + + // Measure and compute parity + syndrome: u8 = mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; + parity := std.parity_u8(syndrome); + + return; +} +``` + +## Tick Blocks: Parallel Execution + +Group operations that should execute in parallel: + +```zlup_fragment +q := qalloc(4); +pz q; + +// These gates execute in the same time step +tick { + h q[0]; + h q[1]; + h q[2]; + h q[3]; +} + +// Next time step +tick { + cx (q[0], q[1]); + cx (q[2], q[3]); +} +``` + +## Error Handling + +Zlup distinguishes between **faults** (physical layer, quantum hardware) and **errors** +(logical layer, classical software). This distinction is crucial for QEC: + +- **Faults** are expected hardware imperfections - collected and analyzed +- **Errors** are unexpected software problems - stop execution immediately + +### Quick Example + +```zlup_nocheck +// Classical errors stop execution +fn divide(a: u32, b: u32) -> DivError!u32 { + if b == 0 { + return error.DivisionByZero; + } + return a / b; +} + +// Handle errors with catch +result := divide(10, 2) catch 0; // Default to 0 on error + +// Quantum faults are collected (QEC pattern) +QuantumFault := fault { Leakage, QubitLoss }; + +fn qec_round(q: []qubit) try -> []QuantumFault!Syndrome { + cx (q[0], q[1]); // Fault? Recorded, continues + cx (q[1], q[2]); // Fault? Recorded, continues + return mz([2]u1) [q[3], q[4]]; +} + +// Caller receives both faults and result +faults, syndrome := qec_round(q); +``` + +### Two Error Handling Modes + +| Mode | Quantum Fault | Classical Error | +|------|---------------|-----------------| +| `try!` | Stops immediately | Stops immediately | +| `try` | Collected, continues | Stops execution | + +For a comprehensive guide to error handling including practical QEC examples, fault +promotion, and the explicit handling philosophy, see the +**[Error Handling Tutorial](tutorial-error-handling.md)**. + +## Complete Example: GHZ State + +A GHZ state is a maximally entangled state of N qubits: + +```zlup_nocheck +/// Creates a 4-qubit GHZ state: |0000⟩ + |1111⟩ +pub fn main() -> unit { + std := @import("std"); + + q := qalloc(4); + pz q; + + // Put first qubit in superposition + h q[0]; + + // Entangle each subsequent qubit + for i in 1..4 { + cx (q[0], q[i]); + } + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + // Emit results - all will be the same: either all 0 or all 1 + result("measurements", results); + + return; +} +``` + +## Next Steps + +- Read the [Standard Library Reference](stdlib.md) for available functions +- Check out the [examples](../examples/) directory for more programs +- See [design.md](design.md) for language design rationale +- Try `zlup eval "2 + 2"` for quick expression testing + +## Quick Reference + +| Concept | Syntax | +|---------|--------| +| Allocate qubits | `q := qalloc(N);` | +| Prepare qubits | `pz q;` | +| Single gate | `h q[0];` | +| Rotation gate | `rz(angle) q[0];` | +| Two-qubit gate | `cx (q[0], q[1]);` | +| Batch gates | `h {q[0], q[1]};` | +| Measure one | `r: u1 = mz(u1) q[0];` | +| Measure many | `r: [N]u1 = mz([N]u1) [...];` | +| Pack measure | `r: u8 = mz(pack u8) [...];` | +| Emit result | `result("tag", value);` | +| Immutable var | `x := value;` | +| Mutable var | `mut x := value;` | +| For loop | `for i in 0..N { }` | +| If statement | `if cond { } else { }` | +| Function | `fn name(args) -> T { }` | +| Import | `std := @import("std");` | diff --git a/exp/zlup/editors/jetbrains-zlup/.gitignore b/exp/zlup/editors/jetbrains-zlup/.gitignore new file mode 100644 index 000000000..a7be4dd85 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/.gitignore @@ -0,0 +1,15 @@ +# Gradle +.gradle/ +build/ +gradle/wrapper/gradle-wrapper.jar + +# IntelliJ Platform +.intellijPlatform/ + +# IDE +.idea/ +*.iml + +# OS +.DS_Store +Thumbs.db diff --git a/exp/zlup/editors/jetbrains-zlup/README.md b/exp/zlup/editors/jetbrains-zlup/README.md new file mode 100644 index 000000000..9ac84b523 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/README.md @@ -0,0 +1,60 @@ +# Zlup JetBrains Plugin + +IntelliJ IDEA / JetBrains IDE plugin for the Zlup quantum programming language. + +## Features + +- Syntax highlighting for `.zlp` files +- Comment toggling (`Ctrl+/`) +- Brace matching + +## LSP Support + +For full LSP features (diagnostics, completion, hover), install the **LSP4IJ** plugin from the JetBrains Marketplace, then configure it to use the `zlups` language server. + +### LSP4IJ Configuration + +1. Install LSP4IJ from Marketplace +2. Go to **Settings > Languages & Frameworks > Language Servers** +3. Click **+** to add a new server: + - **Name**: `Zlups` + - **Command**: `/path/to/PECOS-alt/target/release/zlups` + - **File patterns**: `*.zlp` + +## Building the Plugin + +### Requirements +- JDK 17 or later + +### Build Commands + +```bash +cd editors/jetbrains-zlup +./gradlew buildPlugin +``` + +The plugin ZIP will be in `build/distributions/`. + +## Installation + +### From pre-built ZIP + +1. In your JetBrains IDE: **Settings > Plugins > Gear icon > Install Plugin from Disk...** +2. Select `jetbrains-zlup-0.1.0.zip` +3. Restart the IDE + +### Development mode + +Run the plugin in a sandbox IDE: +```bash +./gradlew runIde +``` + +## Building zlups (LSP server) + +```bash +cd /path/to/PECOS-alt/exp/zlup +cargo build --features lsp --release +``` + +The binary will be at `target/release/zlups`. diff --git a/exp/zlup/editors/jetbrains-zlup/build.gradle.kts b/exp/zlup/editors/jetbrains-zlup/build.gradle.kts new file mode 100644 index 000000000..d8ed26dd0 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/build.gradle.kts @@ -0,0 +1,44 @@ +plugins { + id("java") + id("org.jetbrains.kotlin.jvm") version "1.9.21" + id("org.jetbrains.intellij.platform") version "2.2.1" +} + +group = "com.zlup" +version = "0.1.0" + +repositories { + mavenCentral() + intellijPlatform { + defaultRepositories() + } +} + +dependencies { + intellijPlatform { + intellijIdeaCommunity("2024.1") + pluginVerifier() + zipSigner() + } +} + +kotlin { + jvmToolchain(17) +} + +tasks { + patchPluginXml { + sinceBuild.set("241") + untilBuild.set("253.*") + } + + signPlugin { + certificateChain.set(System.getenv("CERTIFICATE_CHAIN")) + privateKey.set(System.getenv("PRIVATE_KEY")) + password.set(System.getenv("PRIVATE_KEY_PASSWORD")) + } + + publishPlugin { + token.set(System.getenv("PUBLISH_TOKEN")) + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/gradle.properties b/exp/zlup/editors/jetbrains-zlup/gradle.properties new file mode 100644 index 000000000..8f107b3bc --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/gradle.properties @@ -0,0 +1,2 @@ +org.gradle.jvmargs=-Xmx2048m +kotlin.stdlib.default.dependency=false diff --git a/exp/zlup/editors/jetbrains-zlup/gradle/wrapper/gradle-wrapper.properties b/exp/zlup/editors/jetbrains-zlup/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..1af9e0930 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/exp/zlup/editors/jetbrains-zlup/gradlew b/exp/zlup/editors/jetbrains-zlup/gradlew new file mode 100755 index 000000000..7540f8691 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/gradlew @@ -0,0 +1,152 @@ +#!/bin/sh + +# +# Gradle wrapper script +# + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in + /*) app_path=$link ;; + *) app_path=$APP_HOME$link ;; + esac +done + +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in + CYGWIN* ) cygwin=true ;; + Darwin* ) darwin=true ;; + MSYS* | MINGW* ) msys=true ;; + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Download gradle-wrapper.jar if it doesn't exist +if [ ! -f "$CLASSPATH" ]; then + echo "Downloading gradle-wrapper.jar..." + mkdir -p "$APP_HOME/gradle/wrapper" + if command -v curl > /dev/null 2>&1; then + curl -sL -o "$CLASSPATH" "https://raw.githubusercontent.com/gradle/gradle/v8.5.0/gradle/wrapper/gradle-wrapper.jar" + elif command -v wget > /dev/null 2>&1; then + wget -q -O "$CLASSPATH" "https://raw.githubusercontent.com/gradle/gradle/v8.5.0/gradle/wrapper/gradle-wrapper.jar" + else + die "ERROR: Please install curl or wget to download the Gradle wrapper" + fi +fi + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in + max*) + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in + '' | soft) :;; + *) + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/exp/zlup/editors/jetbrains-zlup/settings.gradle.kts b/exp/zlup/editors/jetbrains-zlup/settings.gradle.kts new file mode 100644 index 000000000..16913a836 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/settings.gradle.kts @@ -0,0 +1,5 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + +rootProject.name = "jetbrains-zlup" diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupBraceMatcher.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupBraceMatcher.kt new file mode 100644 index 000000000..dff14c0bc --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupBraceMatcher.kt @@ -0,0 +1,22 @@ +package com.zlup.ide + +import com.intellij.lang.BracePair +import com.intellij.lang.PairedBraceMatcher +import com.intellij.psi.PsiFile +import com.intellij.psi.tree.IElementType + +class ZlupBraceMatcher : PairedBraceMatcher { + companion object { + private val PAIRS = arrayOf( + BracePair(ZlupTokenTypes.LBRACE, ZlupTokenTypes.RBRACE, true), + BracePair(ZlupTokenTypes.LPAREN, ZlupTokenTypes.RPAREN, false), + BracePair(ZlupTokenTypes.LBRACKET, ZlupTokenTypes.RBRACKET, false) + ) + } + + override fun getPairs(): Array = PAIRS + + override fun isPairedBracesAllowedBeforeType(lbraceType: IElementType, contextType: IElementType?): Boolean = true + + override fun getCodeConstructStart(file: PsiFile, openingBraceOffset: Int): Int = openingBraceOffset +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupColorSettingsPage.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupColorSettingsPage.kt new file mode 100644 index 000000000..039c0636a --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupColorSettingsPage.kt @@ -0,0 +1,68 @@ +package com.zlup.ide + +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.fileTypes.SyntaxHighlighter +import com.intellij.openapi.options.colors.AttributesDescriptor +import com.intellij.openapi.options.colors.ColorDescriptor +import com.intellij.openapi.options.colors.ColorSettingsPage +import javax.swing.Icon + +class ZlupColorSettingsPage : ColorSettingsPage { + companion object { + private val DESCRIPTORS = arrayOf( + AttributesDescriptor("Keyword", ZlupSyntaxHighlighter.KEYWORD), + AttributesDescriptor("Type", ZlupSyntaxHighlighter.TYPE), + AttributesDescriptor("Gate", ZlupSyntaxHighlighter.GATE), + AttributesDescriptor("Identifier", ZlupSyntaxHighlighter.IDENTIFIER), + AttributesDescriptor("Number", ZlupSyntaxHighlighter.NUMBER), + AttributesDescriptor("String", ZlupSyntaxHighlighter.STRING), + AttributesDescriptor("Line Comment", ZlupSyntaxHighlighter.LINE_COMMENT), + AttributesDescriptor("Block Comment", ZlupSyntaxHighlighter.BLOCK_COMMENT), + AttributesDescriptor("Operator", ZlupSyntaxHighlighter.OPERATOR), + AttributesDescriptor("Brackets", ZlupSyntaxHighlighter.BRACKETS), + AttributesDescriptor("Braces", ZlupSyntaxHighlighter.BRACES), + AttributesDescriptor("Parentheses", ZlupSyntaxHighlighter.PARENTHESES), + AttributesDescriptor("Comma", ZlupSyntaxHighlighter.COMMA), + AttributesDescriptor("Semicolon", ZlupSyntaxHighlighter.SEMICOLON), + AttributesDescriptor("Dot", ZlupSyntaxHighlighter.DOT), + ) + } + + override fun getIcon(): Icon = ZlupIcons.FILE + + override fun getHighlighter(): SyntaxHighlighter = ZlupSyntaxHighlighter() + + override fun getDemoText(): String = """ +/// Bell state preparation +/// Creates entangled qubit pair +pub fn main() -> unit { + // Allocate 2 qubits + q := qalloc(2); + + /* Prepare and entangle */ + pz q; + h q[0]; + cx (q[0], q[1]); + + // Measure results + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + + if results[0] == 1 { + x q[1]; // Apply correction + } + + return unit; +} + +const PI: f64 = 3.14159265358979; +const NUM_QUBITS: u32 = 4; +""".trimIndent() + + override fun getAdditionalHighlightingTagToDescriptorMap(): Map? = null + + override fun getAttributeDescriptors(): Array = DESCRIPTORS + + override fun getColorDescriptors(): Array = ColorDescriptor.EMPTY_ARRAY + + override fun getDisplayName(): String = "Zlup" +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCommenter.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCommenter.kt new file mode 100644 index 000000000..fe8ab49d6 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCommenter.kt @@ -0,0 +1,11 @@ +package com.zlup.ide + +import com.intellij.lang.Commenter + +class ZlupCommenter : Commenter { + override fun getLineCommentPrefix(): String = "//" + override fun getBlockCommentPrefix(): String = "/*" + override fun getBlockCommentSuffix(): String = "*/" + override fun getCommentedBlockCommentPrefix(): String? = null + override fun getCommentedBlockCommentSuffix(): String? = null +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCompletionContributor.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCompletionContributor.kt new file mode 100644 index 000000000..52f96d371 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupCompletionContributor.kt @@ -0,0 +1,174 @@ +package com.zlup.ide + +import com.intellij.codeInsight.completion.* +import com.intellij.codeInsight.lookup.LookupElementBuilder +import com.intellij.patterns.PlatformPatterns +import com.intellij.util.ProcessingContext +import com.intellij.icons.AllIcons + +class ZlupCompletionContributor : CompletionContributor() { + init { + // Keywords + extend( + CompletionType.BASIC, + PlatformPatterns.psiElement(), + KeywordCompletionProvider() + ) + } +} + +class KeywordCompletionProvider : CompletionProvider() { + companion object { + private val KEYWORDS = listOf( + // Control flow + "if", "else", "while", "for", "return", "break", "continue", + // Declarations + "fn", "pub", "const", "struct", "enum", "union", "error", "type", + // Error handling + "try", "catch", "orelse", "defer", "errdefer", + // Modifiers + "mut", "comptime", "inline", "packed", "extern", + // Logical + "and", "or", "not", + // Literals + "true", "false", "null", "undefined", "unit", + // Other + "in", "tick", "barrier" + ) + + private val TYPES = listOf( + // Integer types + "u1", "u8", "u16", "u32", "u64", "u128", + "i8", "i16", "i32", "i64", "i128", + "usize", "isize", + // Float types + "f16", "f32", "f64", "f128", + // Angle type + "a64", + // Boolean + "bool", + // Quantum types + "qubit", "bit", "qalloc", + // Special + "void", "type", "anytype", "anyerror", "anyfault" + ) + + private val GATES = listOf( + // Single-qubit gates + "h", "x", "y", "z", "s", "sdg", "t", "tdg", + "sx", "sxdg", "sy", "sydg", "sz", "szdg", + "f", "fdg", "f4", "f4dg", + // Rotations + "rx", "ry", "rz", + // Two-qubit gates + "cx", "cy", "cz", "ch", "swap", "iswap", + "sxx", "sxxdg", "syy", "syydg", "szz", "szzdg", "rzz", + // Three-qubit gates + "ccx", + // Measurement/preparation + "mz", "mx", "my", "pz", "px", "py" + ) + + private val BUILTINS = listOf( + "@import", "@sizeof", "@alignof", "@typeOf", + "@intCast", "@floatCast", "@truncate", + "@bitCast", "@ptrCast", + "@min", "@max", "@clamp", + "@sqrt", "@sin", "@cos", "@tan", + "@log", "@log2", "@log10", "@exp", + "@floor", "@ceil", "@round", + "@abs", "@mod", "@divFloor", "@divTrunc" + ) + + private val STD_MODULES = listOf( + "std.f64.pi", "std.f64.tau", "std.f64.e", "std.f64.sqrt2", + "std.a64.quarter_turn", "std.a64.half_turn", "std.a64.t_angle", + "std.math", "std.bits", "std.qec" + ) + } + + override fun addCompletions( + parameters: CompletionParameters, + context: ProcessingContext, + result: CompletionResultSet + ) { + // Add keywords + for (keyword in KEYWORDS) { + result.addElement( + LookupElementBuilder.create(keyword) + .withIcon(AllIcons.Nodes.Favorite) + .withTypeText("keyword") + .bold() + ) + } + + // Add types + for (type in TYPES) { + result.addElement( + LookupElementBuilder.create(type) + .withIcon(AllIcons.Nodes.Class) + .withTypeText("type") + ) + } + + // Add gates + for (gate in GATES) { + result.addElement( + LookupElementBuilder.create(gate) + .withIcon(AllIcons.Nodes.Function) + .withTypeText("gate") + .withTailText(" (quantum gate)") + ) + } + + // Add builtins + for (builtin in BUILTINS) { + result.addElement( + LookupElementBuilder.create(builtin) + .withIcon(AllIcons.Nodes.Method) + .withTypeText("builtin") + ) + } + + // Add common std library items + for (module in STD_MODULES) { + result.addElement( + LookupElementBuilder.create(module) + .withIcon(AllIcons.Nodes.Module) + .withTypeText("std") + ) + } + + // Add common code snippets + result.addElement( + LookupElementBuilder.create("fn main() -> unit {\n \n}") + .withPresentableText("fn main") + .withIcon(AllIcons.Nodes.Function) + .withTypeText("main function") + .withInsertHandler { ctx, _ -> + ctx.editor.caretModel.moveToOffset(ctx.tailOffset - 2) + } + ) + + result.addElement( + LookupElementBuilder.create("q := qalloc()") + .withPresentableText("qalloc") + .withIcon(AllIcons.Nodes.Variable) + .withTypeText("allocate qubits") + ) + + result.addElement( + LookupElementBuilder.create("for i in 0..n {\n \n}") + .withPresentableText("for loop") + .withIcon(AllIcons.Nodes.Favorite) + .withTypeText("for loop") + ) + + result.addElement( + LookupElementBuilder.create("if condition {\n \n} else {\n \n}") + .withPresentableText("if-else") + .withIcon(AllIcons.Nodes.Favorite) + .withTypeText("if-else block") + ) + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFile.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFile.kt new file mode 100644 index 000000000..19d100288 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFile.kt @@ -0,0 +1,10 @@ +package com.zlup.ide + +import com.intellij.extapi.psi.PsiFileBase +import com.intellij.openapi.fileTypes.FileType +import com.intellij.psi.FileViewProvider + +class ZlupFile(viewProvider: FileViewProvider) : PsiFileBase(viewProvider, ZlupLanguage) { + override fun getFileType(): FileType = ZlupFileType + override fun toString(): String = "Zlup File" +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFileType.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFileType.kt new file mode 100644 index 000000000..e1330b9e8 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFileType.kt @@ -0,0 +1,13 @@ +package com.zlup.ide + +import com.intellij.openapi.fileTypes.LanguageFileType +import javax.swing.Icon + +object ZlupFileType : LanguageFileType(ZlupLanguage) { + override fun getName(): String = "Zlup" + override fun getDescription(): String = "Zlup quantum programming language" + override fun getDefaultExtension(): String = "zlp" + override fun getIcon(): Icon = ZlupIcons.FILE + + const val EXTENSION = "zlp" +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFoldingBuilder.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFoldingBuilder.kt new file mode 100644 index 000000000..200a7a157 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupFoldingBuilder.kt @@ -0,0 +1,117 @@ +package com.zlup.ide + +import com.intellij.lang.ASTNode +import com.intellij.lang.folding.FoldingBuilderEx +import com.intellij.lang.folding.FoldingDescriptor +import com.intellij.openapi.editor.Document +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import com.intellij.psi.util.PsiTreeUtil + +class ZlupFoldingBuilder : FoldingBuilderEx() { + override fun buildFoldRegions(root: PsiElement, document: Document, quick: Boolean): Array { + val descriptors = mutableListOf() + val text = root.text + + // Find all brace pairs for folding + findBracePairs(text, '{', '}', root, descriptors) + + // Find block comments + findBlockComments(text, root, descriptors) + + return descriptors.toTypedArray() + } + + private fun findBracePairs( + text: String, + openChar: Char, + closeChar: Char, + root: PsiElement, + descriptors: MutableList + ) { + val stack = mutableListOf() + var i = 0 + + while (i < text.length) { + when { + text[i] == openChar -> { + stack.add(i) + } + text[i] == closeChar && stack.isNotEmpty() -> { + val openIndex = stack.removeAt(stack.size - 1) + val closeIndex = i + + // Only fold if the region spans multiple lines + val openLine = text.substring(0, openIndex).count { it == '\n' } + val closeLine = text.substring(0, closeIndex).count { it == '\n' } + + if (closeLine > openLine) { + val range = TextRange(openIndex, closeIndex + 1) + if (range.length > 1) { + descriptors.add(FoldingDescriptor(root.node, range)) + } + } + } + // Skip strings + text[i] == '"' -> { + i++ + while (i < text.length && text[i] != '"') { + if (text[i] == '\\' && i + 1 < text.length) i++ + i++ + } + } + // Skip line comments + text[i] == '/' && i + 1 < text.length && text[i + 1] == '/' -> { + while (i < text.length && text[i] != '\n') i++ + } + // Skip block comments + text[i] == '/' && i + 1 < text.length && text[i + 1] == '*' -> { + i += 2 + while (i + 1 < text.length && !(text[i] == '*' && text[i + 1] == '/')) i++ + i++ + } + } + i++ + } + } + + private fun findBlockComments( + text: String, + root: PsiElement, + descriptors: MutableList + ) { + var i = 0 + while (i < text.length - 1) { + if (text[i] == '/' && text[i + 1] == '*') { + val start = i + i += 2 + while (i + 1 < text.length && !(text[i] == '*' && text[i + 1] == '/')) { + i++ + } + val end = i + 2 + if (end <= text.length) { + val range = TextRange(start, end) + val openLine = text.substring(0, start).count { it == '\n' } + val closeLine = text.substring(0, end).count { it == '\n' } + if (closeLine > openLine && range.length > 2) { + descriptors.add(FoldingDescriptor(root.node, range)) + } + } + i = end + } else { + i++ + } + } + } + + override fun getPlaceholderText(node: ASTNode): String { + val text = node.text + return when { + text.startsWith("{") -> "{...}" + text.startsWith("/*") -> "/*...*/" + else -> "..." + } + } + + override fun isCollapsedByDefault(node: ASTNode): Boolean = false +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupIcons.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupIcons.kt new file mode 100644 index 000000000..656753d9f --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupIcons.kt @@ -0,0 +1,9 @@ +package com.zlup.ide + +import com.intellij.openapi.util.IconLoader +import javax.swing.Icon + +object ZlupIcons { + @JvmField + val FILE: Icon = IconLoader.getIcon("/icons/zlup.svg", ZlupIcons::class.java) +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLanguage.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLanguage.kt new file mode 100644 index 000000000..f87168e5c --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLanguage.kt @@ -0,0 +1,8 @@ +package com.zlup.ide + +import com.intellij.lang.Language + +object ZlupLanguage : Language("Zlup") { + override fun getDisplayName(): String = "Zlup" + override fun isCaseSensitive(): Boolean = true +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLexer.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLexer.kt new file mode 100644 index 000000000..7a2733c97 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupLexer.kt @@ -0,0 +1,233 @@ +package com.zlup.ide + +import com.intellij.lexer.LexerBase +import com.intellij.psi.tree.IElementType + +class ZlupLexer : LexerBase() { + private var buffer: CharSequence = "" + private var startOffset: Int = 0 + private var endOffset: Int = 0 + private var currentOffset: Int = 0 + private var tokenStart: Int = 0 + private var tokenEnd: Int = 0 + private var tokenType: IElementType? = null + + companion object { + private val KEYWORDS = setOf( + // Control flow + "fn", "if", "else", "while", "for", "return", "break", "continue", + // Declarations + "const", "var", "pub", "struct", "enum", "union", "error", "type", + // Error handling + "try", "catch", "orelse", "defer", "errdefer", + // Modifiers + "mut", "comptime", "inline", "packed", "extern", + // Logical operators + "and", "or", "not", + // Literals + "true", "false", "null", "undefined", "unit", + // Other + "in", "tick", "barrier", "fault" + ) + + private val TYPES = setOf( + // Integer types + "u1", "u2", "u4", "u8", "u16", "u32", "u64", "u128", + "i8", "i16", "i32", "i64", "i128", + "usize", "isize", + // Float types + "f16", "f32", "f64", "f128", + // Angle type + "a64", + // Boolean + "bool", + // Quantum types + "qubit", "bit", "qalloc", + // Special + "void", "type", "anytype", "anyerror", "anyfault" + ) + + private val GATES = setOf( + // Single-qubit gates (lowercase) + "h", "x", "y", "z", "s", "sdg", "t", "tdg", + "sx", "sxdg", "sy", "sydg", "sz", "szdg", + "f", "fdg", "f4", "f4dg", + // Rotations + "rx", "ry", "rz", + // Two-qubit gates + "cx", "cy", "cz", "ch", "swap", "iswap", + "sxx", "sxxdg", "syy", "syydg", "szz", "szzdg", "rzz", + // Three-qubit gates + "ccx", + // Measurement/preparation + "mz", "mx", "my", "pz", "px", "py" + ) + } + + override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) { + this.buffer = buffer + this.startOffset = startOffset + this.endOffset = endOffset + this.currentOffset = startOffset + advance() + } + + override fun getState(): Int = 0 + + override fun getTokenType(): IElementType? = tokenType + + override fun getTokenStart(): Int = tokenStart + + override fun getTokenEnd(): Int = tokenEnd + + override fun advance() { + tokenStart = currentOffset + + if (currentOffset >= endOffset) { + tokenType = null + tokenEnd = currentOffset + return + } + + val c = buffer[currentOffset] + + when { + // Whitespace + c.isWhitespace() -> { + while (currentOffset < endOffset && buffer[currentOffset].isWhitespace()) { + currentOffset++ + } + tokenType = ZlupTokenTypes.WHITE_SPACE + } + + // Line comment + c == '/' && currentOffset + 1 < endOffset && buffer[currentOffset + 1] == '/' -> { + currentOffset += 2 + while (currentOffset < endOffset && buffer[currentOffset] != '\n') { + currentOffset++ + } + tokenType = ZlupTokenTypes.LINE_COMMENT + } + + // Block comment + c == '/' && currentOffset + 1 < endOffset && buffer[currentOffset + 1] == '*' -> { + currentOffset += 2 + while (currentOffset + 1 < endOffset) { + if (buffer[currentOffset] == '*' && buffer[currentOffset + 1] == '/') { + currentOffset += 2 + break + } + currentOffset++ + } + tokenType = ZlupTokenTypes.BLOCK_COMMENT + } + + // String + c == '"' -> { + currentOffset++ + while (currentOffset < endOffset) { + val ch = buffer[currentOffset] + if (ch == '"') { + currentOffset++ + break + } + if (ch == '\\' && currentOffset + 1 < endOffset) { + currentOffset += 2 + } else { + currentOffset++ + } + } + tokenType = ZlupTokenTypes.STRING + } + + // Character literal + c == '\'' -> { + currentOffset++ + while (currentOffset < endOffset) { + val ch = buffer[currentOffset] + if (ch == '\'') { + currentOffset++ + break + } + if (ch == '\\' && currentOffset + 1 < endOffset) { + currentOffset += 2 + } else { + currentOffset++ + } + } + tokenType = ZlupTokenTypes.STRING + } + + // Number + c.isDigit() -> { + while (currentOffset < endOffset) { + val ch = buffer[currentOffset] + if (ch.isLetterOrDigit() || ch == '.' || ch == '_') { + currentOffset++ + } else { + break + } + } + tokenType = ZlupTokenTypes.NUMBER + } + + // Identifier or keyword + c.isLetter() || c == '_' -> { + while (currentOffset < endOffset) { + val ch = buffer[currentOffset] + if (ch.isLetterOrDigit() || ch == '_') { + currentOffset++ + } else { + break + } + } + val word = buffer.subSequence(tokenStart, currentOffset).toString() + tokenType = when { + word in KEYWORDS -> ZlupTokenTypes.KEYWORD + word in TYPES -> ZlupTokenTypes.TYPE + word in GATES -> ZlupTokenTypes.GATE + else -> ZlupTokenTypes.IDENTIFIER + } + } + + // Arrow + c == '-' && currentOffset + 1 < endOffset && buffer[currentOffset + 1] == '>' -> { + currentOffset += 2 + tokenType = ZlupTokenTypes.ARROW + } + + // Brackets and punctuation + c == '(' -> { currentOffset++; tokenType = ZlupTokenTypes.LPAREN } + c == ')' -> { currentOffset++; tokenType = ZlupTokenTypes.RPAREN } + c == '{' -> { currentOffset++; tokenType = ZlupTokenTypes.LBRACE } + c == '}' -> { currentOffset++; tokenType = ZlupTokenTypes.RBRACE } + c == '[' -> { currentOffset++; tokenType = ZlupTokenTypes.LBRACKET } + c == ']' -> { currentOffset++; tokenType = ZlupTokenTypes.RBRACKET } + c == '.' -> { currentOffset++; tokenType = ZlupTokenTypes.DOT } + c == ',' -> { currentOffset++; tokenType = ZlupTokenTypes.COMMA } + c == ';' -> { currentOffset++; tokenType = ZlupTokenTypes.SEMICOLON } + c == ':' -> { currentOffset++; tokenType = ZlupTokenTypes.COLON } + c == '@' -> { currentOffset++; tokenType = ZlupTokenTypes.AT } + + // Operators + c in "+-*/%=!<>&|^~" -> { + while (currentOffset < endOffset && buffer[currentOffset] in "+-*/%=!<>&|^~") { + currentOffset++ + } + tokenType = ZlupTokenTypes.OPERATOR + } + + // Bad character + else -> { + currentOffset++ + tokenType = ZlupTokenTypes.BAD_CHARACTER + } + } + + tokenEnd = currentOffset + } + + override fun getBufferSequence(): CharSequence = buffer + + override fun getBufferEnd(): Int = endOffset +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParser.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParser.kt new file mode 100644 index 000000000..cf0546d77 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParser.kt @@ -0,0 +1,24 @@ +package com.zlup.ide + +import com.intellij.lang.ASTNode +import com.intellij.lang.PsiBuilder +import com.intellij.lang.PsiParser +import com.intellij.psi.tree.IElementType + +/** + * Minimal parser for Zlup. + * Actual parsing is handled by the LSP server - this just provides + * basic token stream to PSI tree conversion. + */ +class ZlupParser : PsiParser { + override fun parse(root: IElementType, builder: PsiBuilder): ASTNode { + val rootMarker = builder.mark() + + while (!builder.eof()) { + builder.advanceLexer() + } + + rootMarker.done(root) + return builder.treeBuilt + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParserDefinition.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParserDefinition.kt new file mode 100644 index 000000000..f3670e6c6 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupParserDefinition.kt @@ -0,0 +1,35 @@ +package com.zlup.ide + +import com.intellij.lang.ASTNode +import com.intellij.lang.ParserDefinition +import com.intellij.lang.PsiParser +import com.intellij.lexer.Lexer +import com.intellij.openapi.project.Project +import com.intellij.psi.FileViewProvider +import com.intellij.psi.PsiElement +import com.intellij.psi.PsiFile +import com.intellij.psi.tree.IFileElementType +import com.intellij.psi.tree.TokenSet + +class ZlupParserDefinition : ParserDefinition { + companion object { + val FILE = IFileElementType(ZlupLanguage) + } + + override fun createLexer(project: Project): Lexer = ZlupLexer() + + override fun getCommentTokens(): TokenSet = ZlupTokenTypes.COMMENTS + + override fun getStringLiteralElements(): TokenSet = ZlupTokenTypes.STRINGS + + override fun createParser(project: Project): PsiParser { + // We use LSP for parsing, so we provide a minimal parser + return ZlupParser() + } + + override fun getFileNodeType(): IFileElementType = FILE + + override fun createFile(viewProvider: FileViewProvider): PsiFile = ZlupFile(viewProvider) + + override fun createElement(node: ASTNode): PsiElement = ZlupPsiElement(node) +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupPsiElement.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupPsiElement.kt new file mode 100644 index 000000000..5cd683259 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupPsiElement.kt @@ -0,0 +1,6 @@ +package com.zlup.ide + +import com.intellij.extapi.psi.ASTWrapperPsiElement +import com.intellij.lang.ASTNode + +open class ZlupPsiElement(node: ASTNode) : ASTWrapperPsiElement(node) diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupSyntaxHighlighter.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupSyntaxHighlighter.kt new file mode 100644 index 000000000..8dd947de7 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupSyntaxHighlighter.kt @@ -0,0 +1,82 @@ +package com.zlup.ide + +import com.intellij.lexer.Lexer +import com.intellij.openapi.editor.DefaultLanguageHighlighterColors +import com.intellij.openapi.editor.HighlighterColors +import com.intellij.openapi.editor.colors.TextAttributesKey +import com.intellij.openapi.editor.colors.TextAttributesKey.createTextAttributesKey +import com.intellij.openapi.fileTypes.SyntaxHighlighter +import com.intellij.openapi.fileTypes.SyntaxHighlighterBase +import com.intellij.openapi.fileTypes.SyntaxHighlighterFactory +import com.intellij.openapi.project.Project +import com.intellij.openapi.vfs.VirtualFile +import com.intellij.psi.tree.IElementType + +class ZlupSyntaxHighlighter : SyntaxHighlighterBase() { + companion object { + val KEYWORD = createTextAttributesKey("ZLUP_KEYWORD", DefaultLanguageHighlighterColors.KEYWORD) + val TYPE = createTextAttributesKey("ZLUP_TYPE", DefaultLanguageHighlighterColors.CLASS_NAME) + val GATE = createTextAttributesKey("ZLUP_GATE", DefaultLanguageHighlighterColors.FUNCTION_CALL) + val IDENTIFIER = createTextAttributesKey("ZLUP_IDENTIFIER", DefaultLanguageHighlighterColors.IDENTIFIER) + val NUMBER = createTextAttributesKey("ZLUP_NUMBER", DefaultLanguageHighlighterColors.NUMBER) + val STRING = createTextAttributesKey("ZLUP_STRING", DefaultLanguageHighlighterColors.STRING) + val LINE_COMMENT = createTextAttributesKey("ZLUP_LINE_COMMENT", DefaultLanguageHighlighterColors.LINE_COMMENT) + val BLOCK_COMMENT = createTextAttributesKey("ZLUP_BLOCK_COMMENT", DefaultLanguageHighlighterColors.BLOCK_COMMENT) + val OPERATOR = createTextAttributesKey("ZLUP_OPERATOR", DefaultLanguageHighlighterColors.OPERATION_SIGN) + val BRACKETS = createTextAttributesKey("ZLUP_BRACKETS", DefaultLanguageHighlighterColors.BRACKETS) + val BRACES = createTextAttributesKey("ZLUP_BRACES", DefaultLanguageHighlighterColors.BRACES) + val PARENTHESES = createTextAttributesKey("ZLUP_PARENTHESES", DefaultLanguageHighlighterColors.PARENTHESES) + val COMMA = createTextAttributesKey("ZLUP_COMMA", DefaultLanguageHighlighterColors.COMMA) + val SEMICOLON = createTextAttributesKey("ZLUP_SEMICOLON", DefaultLanguageHighlighterColors.SEMICOLON) + val DOT = createTextAttributesKey("ZLUP_DOT", DefaultLanguageHighlighterColors.DOT) + val BAD_CHARACTER = createTextAttributesKey("ZLUP_BAD_CHARACTER", HighlighterColors.BAD_CHARACTER) + + private val KEYWORD_KEYS = arrayOf(KEYWORD) + private val TYPE_KEYS = arrayOf(TYPE) + private val GATE_KEYS = arrayOf(GATE) + private val IDENTIFIER_KEYS = arrayOf(IDENTIFIER) + private val NUMBER_KEYS = arrayOf(NUMBER) + private val STRING_KEYS = arrayOf(STRING) + private val COMMENT_KEYS = arrayOf(LINE_COMMENT) + private val BLOCK_COMMENT_KEYS = arrayOf(BLOCK_COMMENT) + private val OPERATOR_KEYS = arrayOf(OPERATOR) + private val BRACKET_KEYS = arrayOf(BRACKETS) + private val BRACE_KEYS = arrayOf(BRACES) + private val PAREN_KEYS = arrayOf(PARENTHESES) + private val COMMA_KEYS = arrayOf(COMMA) + private val SEMICOLON_KEYS = arrayOf(SEMICOLON) + private val DOT_KEYS = arrayOf(DOT) + private val BAD_CHAR_KEYS = arrayOf(BAD_CHARACTER) + private val EMPTY_KEYS = emptyArray() + } + + override fun getHighlightingLexer(): Lexer = ZlupLexer() + + override fun getTokenHighlights(tokenType: IElementType): Array { + return when (tokenType) { + ZlupTokenTypes.KEYWORD -> KEYWORD_KEYS + ZlupTokenTypes.TYPE -> TYPE_KEYS + ZlupTokenTypes.GATE -> GATE_KEYS + ZlupTokenTypes.IDENTIFIER -> IDENTIFIER_KEYS + ZlupTokenTypes.NUMBER -> NUMBER_KEYS + ZlupTokenTypes.STRING -> STRING_KEYS + ZlupTokenTypes.LINE_COMMENT -> COMMENT_KEYS + ZlupTokenTypes.BLOCK_COMMENT -> BLOCK_COMMENT_KEYS + ZlupTokenTypes.OPERATOR, ZlupTokenTypes.ARROW -> OPERATOR_KEYS + ZlupTokenTypes.LBRACKET, ZlupTokenTypes.RBRACKET -> BRACKET_KEYS + ZlupTokenTypes.LBRACE, ZlupTokenTypes.RBRACE -> BRACE_KEYS + ZlupTokenTypes.LPAREN, ZlupTokenTypes.RPAREN -> PAREN_KEYS + ZlupTokenTypes.COMMA -> COMMA_KEYS + ZlupTokenTypes.SEMICOLON -> SEMICOLON_KEYS + ZlupTokenTypes.DOT -> DOT_KEYS + ZlupTokenTypes.BAD_CHARACTER -> BAD_CHAR_KEYS + else -> EMPTY_KEYS + } + } +} + +class ZlupSyntaxHighlighterFactory : SyntaxHighlighterFactory() { + override fun getSyntaxHighlighter(project: Project?, virtualFile: VirtualFile?): SyntaxHighlighter { + return ZlupSyntaxHighlighter() + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTemplateContextType.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTemplateContextType.kt new file mode 100644 index 000000000..f43e0cd4c --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTemplateContextType.kt @@ -0,0 +1,11 @@ +package com.zlup.ide + +import com.intellij.codeInsight.template.TemplateActionContext +import com.intellij.codeInsight.template.TemplateContextType + +class ZlupTemplateContextType : TemplateContextType("Zlup") { + override fun isInContext(templateActionContext: TemplateActionContext): Boolean { + val file = templateActionContext.file + return file.name.endsWith(".zlp") + } +} diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTokenTypes.kt b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTokenTypes.kt new file mode 100644 index 000000000..90b2f5337 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/kotlin/com/zlup/ide/ZlupTokenTypes.kt @@ -0,0 +1,56 @@ +package com.zlup.ide + +import com.intellij.psi.tree.IElementType +import com.intellij.psi.tree.TokenSet + +object ZlupTokenTypes { + // Keywords + @JvmField val KEYWORD = ZlupTokenType("KEYWORD") + + // Identifiers + @JvmField val IDENTIFIER = ZlupTokenType("IDENTIFIER") + + // Literals + @JvmField val NUMBER = ZlupTokenType("NUMBER") + @JvmField val STRING = ZlupTokenType("STRING") + + // Comments + @JvmField val LINE_COMMENT = ZlupTokenType("LINE_COMMENT") + @JvmField val BLOCK_COMMENT = ZlupTokenType("BLOCK_COMMENT") + + // Operators + @JvmField val OPERATOR = ZlupTokenType("OPERATOR") + + // Brackets + @JvmField val LPAREN = ZlupTokenType("LPAREN") + @JvmField val RPAREN = ZlupTokenType("RPAREN") + @JvmField val LBRACE = ZlupTokenType("LBRACE") + @JvmField val RBRACE = ZlupTokenType("RBRACE") + @JvmField val LBRACKET = ZlupTokenType("LBRACKET") + @JvmField val RBRACKET = ZlupTokenType("RBRACKET") + + // Other + @JvmField val DOT = ZlupTokenType("DOT") + @JvmField val COMMA = ZlupTokenType("COMMA") + @JvmField val SEMICOLON = ZlupTokenType("SEMICOLON") + @JvmField val COLON = ZlupTokenType("COLON") + @JvmField val ARROW = ZlupTokenType("ARROW") + @JvmField val AT = ZlupTokenType("AT") + + // Whitespace and bad characters + @JvmField val WHITE_SPACE = ZlupTokenType("WHITE_SPACE") + @JvmField val BAD_CHARACTER = ZlupTokenType("BAD_CHARACTER") + + // Types (built-in) + @JvmField val TYPE = ZlupTokenType("TYPE") + + // Quantum gates + @JvmField val GATE = ZlupTokenType("GATE") + + // Token sets + @JvmField val COMMENTS = TokenSet.create(LINE_COMMENT, BLOCK_COMMENT) + @JvmField val STRINGS = TokenSet.create(STRING) + @JvmField val WHITESPACES = TokenSet.create(WHITE_SPACE) +} + +class ZlupTokenType(debugName: String) : IElementType(debugName, ZlupLanguage) diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/resources/META-INF/plugin.xml b/exp/zlup/editors/jetbrains-zlup/src/main/resources/META-INF/plugin.xml new file mode 100644 index 000000000..73585273b --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/resources/META-INF/plugin.xml @@ -0,0 +1,96 @@ + + com.zlup.ide + Zlup + PECOS + + Language support for Zlup - a quantum programming language.

    + +

    Features

    +
      +
    • Syntax highlighting for .zlp files
    • +
    • Code completion for keywords, types, and quantum gates
    • +
    • Code folding for functions and blocks
    • +
    • Brace matching and auto-completion
    • +
    • Line and block commenting
    • +
    • Customizable color settings
    • +
    + +

    Supported Constructs

    +
      +
    • Quantum gates: h, x, y, z, cx, cz, rx, ry, rz, etc.
    • +
    • Measurement: mz, mx, my
    • +
    • Preparation: pz, px, py
    • +
    • Control flow: if/else, for, while, tick
    • +
    • Types: qubit, bit, u32, i64, f64, a64, etc.
    • +
    + ]]>
    + + 0.2.0 +
      +
    • Added code completion for keywords, types, and gates
    • +
    • Added code folding support
    • +
    • Added color settings page
    • +
    • Expanded keyword and type recognition
    • +
    • Added support for more quantum gates
    • +
    +

    0.1.0

    +
      +
    • Initial release
    • +
    • Basic syntax highlighting
    • +
    • Brace matching
    • +
    + ]]>
    + + com.intellij.modules.platform + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/resources/icons/zlup.svg b/exp/zlup/editors/jetbrains-zlup/src/main/resources/icons/zlup.svg new file mode 100644 index 000000000..8307a2aa3 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/resources/icons/zlup.svg @@ -0,0 +1,9 @@ + + + + + Q + + + + diff --git a/exp/zlup/editors/jetbrains-zlup/src/main/resources/liveTemplates/Zlup.xml b/exp/zlup/editors/jetbrains-zlup/src/main/resources/liveTemplates/Zlup.xml new file mode 100644 index 000000000..3fad55182 --- /dev/null +++ b/exp/zlup/editors/jetbrains-zlup/src/main/resources/liveTemplates/Zlup.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/exp/zlup/editors/vscode-zlup/README.md b/exp/zlup/editors/vscode-zlup/README.md new file mode 100644 index 000000000..0f2655d4c --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/README.md @@ -0,0 +1,72 @@ +# Zlup for Visual Studio Code + +Language support for Zlup - a quantum programming language. + +## Features + +- **Syntax Highlighting**: Full syntax highlighting for Zlup source files (.zlp) +- **Code Snippets**: Quick snippets for common patterns +- **Bracket Matching**: Automatic bracket and brace matching +- **Code Folding**: Fold functions and blocks +- **Commenting**: Toggle line and block comments + +## Supported Constructs + +### Quantum Gates +- Single-qubit: `h`, `x`, `y`, `z`, `s`, `t`, `rx`, `ry`, `rz` +- Two-qubit: `cx`, `cz`, `swap`, `iswap`, `rzz` +- Three-qubit: `ccx` +- Measurement: `mz`, `mx`, `my` +- Preparation: `pz`, `px`, `py` + +### Types +- Integers: `u8`, `u16`, `u32`, `u64`, `i8`, `i16`, `i32`, `i64` +- Floats: `f32`, `f64` +- Angles: `a64` +- Quantum: `qubit`, `bit`, `qalloc` + +### Control Flow +- `if`/`else` +- `for`/`while` +- `tick` (parallel quantum ops) +- `barrier` + +## Snippets + +| Prefix | Description | +|--------|-------------| +| `main` | Main function | +| `fn` | Function definition | +| `qalloc` | Qubit allocation | +| `bell` | Bell state preparation | +| `ghz` | GHZ state preparation | +| `for` | For loop | +| `if`/`ife` | If/if-else statement | +| `tick` | Tick block | +| `meas` | Typed measurement | + +## Installation + +### From VSIX +1. Download the `.vsix` file +2. In VS Code: Extensions > ... > Install from VSIX + +### From Source +```bash +cd vscode-zlup +npm install +npm run package +``` + +## Example + +```zlup +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + return; +} +``` diff --git a/exp/zlup/editors/vscode-zlup/language-configuration.json b/exp/zlup/editors/vscode-zlup/language-configuration.json new file mode 100644 index 000000000..054f14a54 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/language-configuration.json @@ -0,0 +1,35 @@ +{ + "comments": { + "lineComment": "//", + "blockComment": ["/*", "*/"] + }, + "brackets": [ + ["{", "}"], + ["[", "]"], + ["(", ")"] + ], + "autoClosingPairs": [ + { "open": "{", "close": "}" }, + { "open": "[", "close": "]" }, + { "open": "(", "close": ")" }, + { "open": "\"", "close": "\"", "notIn": ["string"] }, + { "open": "'", "close": "'", "notIn": ["string"] } + ], + "surroundingPairs": [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ["\"", "\""], + ["'", "'"] + ], + "folding": { + "markers": { + "start": "^\\s*//\\s*#?region\\b", + "end": "^\\s*//\\s*#?endregion\\b" + } + }, + "indentationRules": { + "increaseIndentPattern": "^.*\\{[^}\"']*$|^.*\\([^)\"']*$|^.*\\[[^\\]\"']*$", + "decreaseIndentPattern": "^\\s*(\\}|\\)|\\]).*$" + } +} diff --git a/exp/zlup/editors/vscode-zlup/package.json b/exp/zlup/editors/vscode-zlup/package.json new file mode 100644 index 000000000..a5feb73a6 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/package.json @@ -0,0 +1,45 @@ +{ + "name": "zlup", + "displayName": "Zlup", + "description": "Language support for Zlup - a quantum programming language", + "version": "0.1.0", + "publisher": "pecos", + "engines": { + "vscode": "^1.74.0" + }, + "categories": [ + "Programming Languages" + ], + "repository": { + "type": "git", + "url": "https://github.com/PECOS-packages/PECOS" + }, + "contributes": { + "languages": [ + { + "id": "zlup", + "aliases": [ + "Zlup", + "zlup" + ], + "extensions": [ + ".zlp" + ], + "configuration": "./language-configuration.json" + } + ], + "grammars": [ + { + "language": "zlup", + "scopeName": "source.zlup", + "path": "./syntaxes/zlup.tmLanguage.json" + } + ], + "snippets": [ + { + "language": "zlup", + "path": "./snippets/zlup.json" + } + ] + } +} diff --git a/exp/zlup/editors/vscode-zlup/snippets/zlup.json b/exp/zlup/editors/vscode-zlup/snippets/zlup.json new file mode 100644 index 000000000..915b79f12 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/snippets/zlup.json @@ -0,0 +1,176 @@ +{ + "Main Function": { + "prefix": "main", + "body": [ + "pub fn main() -> unit {", + " $0", + " return unit;", + "}" + ], + "description": "Main function template" + }, + "Function": { + "prefix": "fn", + "body": [ + "fn ${1:name}(${2:params}) -> ${3:unit} {", + " $0", + "}" + ], + "description": "Function definition" + }, + "Public Function": { + "prefix": "pubfn", + "body": [ + "pub fn ${1:name}(${2:params}) -> ${3:unit} {", + " $0", + "}" + ], + "description": "Public function definition" + }, + "Qubit Allocation": { + "prefix": "qalloc", + "body": [ + "${1:q} := qalloc(${2:2});" + ], + "description": "Allocate qubits" + }, + "Bell State": { + "prefix": "bell", + "body": [ + "// Bell state preparation", + "${1:q} := qalloc(2);", + "pz ${1:q};", + "h ${1:q}[0];", + "cx (${1:q}[0], ${1:q}[1]);", + "$0" + ], + "description": "Bell state preparation" + }, + "GHZ State": { + "prefix": "ghz", + "body": [ + "// GHZ state preparation", + "${1:q} := qalloc(${2:4});", + "pz ${1:q};", + "h ${1:q}[0];", + "for i in 1..${2:4} {", + " cx (${1:q}[0], ${1:q}[i]);", + "}", + "$0" + ], + "description": "GHZ state preparation" + }, + "For Loop": { + "prefix": "for", + "body": [ + "for ${1:i} in ${2:0}..${3:n} {", + " $0", + "}" + ], + "description": "For loop" + }, + "If Statement": { + "prefix": "if", + "body": [ + "if ${1:condition} {", + " $0", + "}" + ], + "description": "If statement" + }, + "If-Else Statement": { + "prefix": "ife", + "body": [ + "if ${1:condition} {", + " $2", + "} else {", + " $0", + "}" + ], + "description": "If-else statement" + }, + "Tick Block": { + "prefix": "tick", + "body": [ + "tick {", + " $0", + "}" + ], + "description": "Tick block for parallel quantum operations" + }, + "Typed Measurement": { + "prefix": "meas", + "body": [ + "${1:results}: [${2:2}]u1 = mz([${2:2}]u1) [${3:q[0], q[1]}];" + ], + "description": "Typed measurement" + }, + "Struct Definition": { + "prefix": "struct", + "body": [ + "const ${1:Name} = struct {", + " ${2:field}: ${3:type},", + "};" + ], + "description": "Struct definition" + }, + "Enum Definition": { + "prefix": "enum", + "body": [ + "const ${1:Name} = enum {", + " ${2:variant1},", + " ${3:variant2},", + "};" + ], + "description": "Enum definition" + }, + "Import": { + "prefix": "import", + "body": [ + "${1:std} := @import(\"${2:std}\");" + ], + "description": "Import statement" + }, + "Constant": { + "prefix": "const", + "body": [ + "const ${1:NAME}: ${2:type} = ${3:value};" + ], + "description": "Constant declaration" + }, + "Hadamard Gate": { + "prefix": "hadamard", + "body": [ + "h ${1:q}[${2:0}];" + ], + "description": "Hadamard gate" + }, + "CNOT Gate": { + "prefix": "cnot", + "body": [ + "cx (${1:q}[${2:0}], ${1:q}[${3:1}]);" + ], + "description": "CNOT gate" + }, + "Rotation X": { + "prefix": "rotx", + "body": [ + "rx(${1:0.5}turn) ${2:q}[${3:0}];" + ], + "description": "Rotation around X axis" + }, + "Rotation Y": { + "prefix": "roty", + "body": [ + "ry(${1:0.5}turn) ${2:q}[${3:0}];" + ], + "description": "Rotation around Y axis" + }, + "Rotation Z": { + "prefix": "rotz", + "body": [ + "rz(${1:0.5}turn) ${2:q}[${3:0}];" + ], + "description": "Rotation around Z axis" + } +} diff --git a/exp/zlup/editors/vscode-zlup/syntaxes/zlup.tmLanguage.json b/exp/zlup/editors/vscode-zlup/syntaxes/zlup.tmLanguage.json new file mode 100644 index 000000000..e315b8098 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/syntaxes/zlup.tmLanguage.json @@ -0,0 +1,229 @@ +{ + "$schema": "https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json", + "name": "Zlup", + "scopeName": "source.zlup", + "patterns": [ + { "include": "#comments" }, + { "include": "#strings" }, + { "include": "#numbers" }, + { "include": "#keywords" }, + { "include": "#types" }, + { "include": "#gates" }, + { "include": "#builtins" }, + { "include": "#operators" }, + { "include": "#punctuation" } + ], + "repository": { + "comments": { + "patterns": [ + { + "name": "comment.line.double-slash.zlup", + "match": "//.*$" + }, + { + "name": "comment.block.zlup", + "begin": "/\\*", + "end": "\\*/" + }, + { + "name": "comment.line.documentation.zlup", + "match": "///.*$" + } + ] + }, + "strings": { + "patterns": [ + { + "name": "string.quoted.double.zlup", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.zlup", + "match": "\\\\." + } + ] + }, + { + "name": "string.quoted.single.zlup", + "begin": "'", + "end": "'", + "patterns": [ + { + "name": "constant.character.escape.zlup", + "match": "\\\\." + } + ] + } + ] + }, + "numbers": { + "patterns": [ + { + "name": "constant.numeric.hex.zlup", + "match": "\\b0x[0-9a-fA-F_]+\\b" + }, + { + "name": "constant.numeric.binary.zlup", + "match": "\\b0b[01_]+\\b" + }, + { + "name": "constant.numeric.octal.zlup", + "match": "\\b0o[0-7_]+\\b" + }, + { + "name": "constant.numeric.float.zlup", + "match": "\\b[0-9][0-9_]*\\.[0-9][0-9_]*([eE][+-]?[0-9_]+)?\\b" + }, + { + "name": "constant.numeric.integer.zlup", + "match": "\\b[0-9][0-9_]*\\b" + }, + { + "name": "constant.numeric.angle.zlup", + "match": "\\b[0-9][0-9_]*\\.?[0-9_]*(deg|rad|turn)\\b" + } + ] + }, + "keywords": { + "patterns": [ + { + "name": "keyword.control.zlup", + "match": "\\b(if|else|while|for|return|break|continue|tick|barrier)\\b" + }, + { + "name": "keyword.declaration.zlup", + "match": "\\b(fn|const|struct|enum|union|error|type|fault)\\b" + }, + { + "name": "keyword.modifier.zlup", + "match": "\\b(pub|mut|comptime|inline|packed|extern)\\b" + }, + { + "name": "keyword.error-handling.zlup", + "match": "\\b(try|catch|orelse|defer|errdefer)\\b" + }, + { + "name": "keyword.operator.logical.zlup", + "match": "\\b(and|or|not)\\b" + }, + { + "name": "keyword.other.zlup", + "match": "\\b(in|var)\\b" + }, + { + "name": "constant.language.zlup", + "match": "\\b(true|false|null|undefined|unit)\\b" + } + ] + }, + "types": { + "patterns": [ + { + "name": "storage.type.integer.zlup", + "match": "\\b(u1|u2|u4|u8|u16|u32|u64|u128|i8|i16|i32|i64|i128|usize|isize)\\b" + }, + { + "name": "storage.type.float.zlup", + "match": "\\b(f16|f32|f64|f128)\\b" + }, + { + "name": "storage.type.angle.zlup", + "match": "\\b(a64)\\b" + }, + { + "name": "storage.type.quantum.zlup", + "match": "\\b(qubit|bit|qalloc)\\b" + }, + { + "name": "storage.type.primitive.zlup", + "match": "\\b(bool|void)\\b" + }, + { + "name": "storage.type.special.zlup", + "match": "\\b(type|anytype|anyerror|anyfault)\\b" + } + ] + }, + "gates": { + "patterns": [ + { + "name": "entity.name.function.gate.single.zlup", + "match": "\\b(h|x|y|z|s|sdg|t|tdg|sx|sxdg|sy|sydg|sz|szdg|f|fdg|f4|f4dg)\\b" + }, + { + "name": "entity.name.function.gate.rotation.zlup", + "match": "\\b(rx|ry|rz)\\b" + }, + { + "name": "entity.name.function.gate.two-qubit.zlup", + "match": "\\b(cx|cy|cz|ch|swap|iswap|sxx|sxxdg|syy|syydg|szz|szzdg|rzz)\\b" + }, + { + "name": "entity.name.function.gate.three-qubit.zlup", + "match": "\\b(ccx)\\b" + }, + { + "name": "entity.name.function.measurement.zlup", + "match": "\\b(mz|mx|my)\\b" + }, + { + "name": "entity.name.function.preparation.zlup", + "match": "\\b(pz|px|py)\\b" + } + ] + }, + "builtins": { + "patterns": [ + { + "name": "support.function.builtin.zlup", + "match": "@(import|sizeof|alignof|typeOf|intCast|floatCast|truncate|bitCast|ptrCast|min|max|clamp|sqrt|sin|cos|tan|log|log2|log10|exp|floor|ceil|round|abs|mod|divFloor|divTrunc)\\b" + } + ] + }, + "operators": { + "patterns": [ + { + "name": "keyword.operator.assignment.zlup", + "match": ":=|=" + }, + { + "name": "keyword.operator.comparison.zlup", + "match": "==|!=|<=|>=|<|>" + }, + { + "name": "keyword.operator.arithmetic.zlup", + "match": "\\+|-|\\*|/|%" + }, + { + "name": "keyword.operator.bitwise.zlup", + "match": "&|\\||\\^|~|<<|>>" + }, + { + "name": "keyword.operator.arrow.zlup", + "match": "->" + }, + { + "name": "keyword.operator.optional.zlup", + "match": "\\?|\\!" + } + ] + }, + "punctuation": { + "patterns": [ + { + "name": "punctuation.separator.zlup", + "match": ",|;|:" + }, + { + "name": "punctuation.accessor.zlup", + "match": "\\." + }, + { + "name": "punctuation.bracket.zlup", + "match": "[\\[\\]\\{\\}\\(\\)]" + } + ] + } + } +} diff --git a/exp/zlup/examples/README.md b/exp/zlup/examples/README.md new file mode 100644 index 000000000..f89d4d1f5 --- /dev/null +++ b/exp/zlup/examples/README.md @@ -0,0 +1,50 @@ +# Zlup Examples + +This directory contains example programs demonstrating Zlup features and common quantum algorithms. + +## Running Examples + +```bash +# Compile to SLR-AST JSON +zlup compile examples/bell_state.zlp -o bell_state.json + +# Check syntax and semantics +zlup check examples/bell_state.zlp +``` + +## Examples + +### Basic Quantum States + +| File | Description | +|------|-------------| +| `bell_state.zlp` | Creates the Bell state (|00⟩ + |11⟩) / sqrt(2) | +| `ghz_state.zlp` | Creates an N-qubit GHZ state | + +### Quantum Algorithms + +| File | Description | +|------|-------------| +| `teleportation.zlp` | Quantum teleportation protocol | +| `grover_2qubit.zlp` | Grover's search algorithm for 2 qubits | +| `qft_3qubit.zlp` | Quantum Fourier Transform on 3 qubits | + +### Error Correction + +| File | Description | +|------|-------------| +| `simple_qec.zlp` | 3-qubit bit-flip code with syndrome measurement | + +### Testing + +| File | Description | +|------|-------------| +| `test_lsp.zlp` | Test file for LSP functionality | + +## Learning Path + +1. Start with `bell_state.zlp` to understand basic gates and entanglement +2. Move to `ghz_state.zlp` to see loops and multiple qubits +3. Try `teleportation.zlp` for a complete protocol +4. Explore `simple_qec.zlp` for error correction concepts +5. Study `grover_2qubit.zlp` and `qft_3qubit.zlp` for algorithms diff --git a/exp/zlup/examples/bell_state.json b/exp/zlup/examples/bell_state.json new file mode 100644 index 000000000..03371c30e --- /dev/null +++ b/exp/zlup/examples/bell_state.json @@ -0,0 +1,86 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 2 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 2 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + } + ], + "result_type": "u1" + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c1", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} \ No newline at end of file diff --git a/exp/zlup/examples/bell_state.qasm b/exp/zlup/examples/bell_state.qasm new file mode 100644 index 000000000..e1aca3d75 --- /dev/null +++ b/exp/zlup/examples/bell_state.qasm @@ -0,0 +1,10 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[2]; +creg c[2]; + +h q[0]; +cx q[0], q[1]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; diff --git a/exp/zlup/examples/bell_state.zlp b/exp/zlup/examples/bell_state.zlp new file mode 100644 index 000000000..878808f47 --- /dev/null +++ b/exp/zlup/examples/bell_state.zlp @@ -0,0 +1,27 @@ +/// Bell State Example +/// Creates the maximally entangled Bell state: (|00⟩ + |11⟩) / sqrt(2) +/// +/// When measured, both qubits will always give the same result: +/// either both 0 or both 1, with 50% probability each. + +pub fn main() -> unit { + // Allocate two qubits + q := qalloc(2); + + // Prepare both qubits to |0⟩ + pz q; + + // Create superposition on first qubit: |0⟩ -> (|0⟩ + |1⟩) / sqrt(2) + h q[0]; + + // Entangle with CNOT: (|0⟩ + |1⟩)|0⟩ -> |00⟩ + |11⟩ + cx (q[0], q[1]); + + // Measure both qubits + result_0: u1 = mz(u1) q[0]; + result_1: u1 = mz(u1) q[1]; + + // result_0 and result_1 will always be equal due to entanglement + + return unit; +} diff --git a/exp/zlup/examples/ghz_state.qasm b/exp/zlup/examples/ghz_state.qasm new file mode 100644 index 000000000..43221feea --- /dev/null +++ b/exp/zlup/examples/ghz_state.qasm @@ -0,0 +1,11 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[4]; +creg c[4]; + +h q[0]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; +measure q[2] -> c[2]; +measure q[3] -> c[3]; diff --git a/exp/zlup/examples/ghz_state.zlp b/exp/zlup/examples/ghz_state.zlp new file mode 100644 index 000000000..07b0a4742 --- /dev/null +++ b/exp/zlup/examples/ghz_state.zlp @@ -0,0 +1,25 @@ +/// GHZ State Example +/// Creates an N-qubit Greenberger-Horne-Zeilinger state: (|000...0⟩ + |111...1⟩) / sqrt(2) +/// +/// This is a maximally entangled state of N qubits. +/// When measured, all qubits will give the same result. + +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Put first qubit in superposition + h q[0]; + + // Entangle each subsequent qubit with the first + cx (q[0], q[1]); + cx (q[0], q[2]); + cx (q[0], q[3]); + + // Measure all qubits + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + + // All measurements will be the same: either all 0 or all 1 + + return unit; +} diff --git a/exp/zlup/examples/grover_2qubit.json b/exp/zlup/examples/grover_2qubit.json new file mode 100644 index 000000000..79ecd29ff --- /dev/null +++ b/exp/zlup/examples/grover_2qubit.json @@ -0,0 +1,193 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 2 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 2 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + }, + { + "type": "BitRef", + "register": "c1", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} \ No newline at end of file diff --git a/exp/zlup/examples/grover_2qubit.qasm b/exp/zlup/examples/grover_2qubit.qasm new file mode 100644 index 000000000..9f3338b24 --- /dev/null +++ b/exp/zlup/examples/grover_2qubit.qasm @@ -0,0 +1,20 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[2]; +creg c[2]; + +h q[0]; +h q[1]; +cz q[0], q[1]; +h q[0]; +h q[1]; +x q[0]; +x q[1]; +cz q[0], q[1]; +x q[0]; +x q[1]; +h q[0]; +h q[1]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; diff --git a/exp/zlup/examples/grover_2qubit.zlp b/exp/zlup/examples/grover_2qubit.zlp new file mode 100644 index 000000000..879a3baae --- /dev/null +++ b/exp/zlup/examples/grover_2qubit.zlp @@ -0,0 +1,45 @@ +/// Grover's Algorithm: 2-Qubit Search +/// Searches for the marked state |11⟩ among 4 possibilities. +/// +/// For N=4 states, a single Grover iteration gives optimal result. +/// The algorithm amplifies the amplitude of the marked state. + +pub fn main() -> unit { + q := qalloc(2); + pz q; + + // === Initialize superposition === + // Creates equal superposition: (|00⟩ + |01⟩ + |10⟩ + |11⟩) / 2 + h {q[0], q[1]}; + + // === Grover Iteration === + // For 2 qubits, one iteration is optimal + + // --- Oracle: Mark |11⟩ --- + // Applies phase flip to |11⟩: CZ gate + cz (q[0], q[1]); + + // --- Diffusion operator --- + // Reflects about the average amplitude + + // Apply H to both qubits + h {q[0], q[1]}; + + // Apply X to both qubits + x {q[0], q[1]}; + + // Apply CZ (controlled-Z) + cz (q[0], q[1]); + + // Apply X to both qubits + x {q[0], q[1]}; + + // Apply H to both qubits + h {q[0], q[1]}; + + // === Measure === + // Should find |11⟩ with high probability + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + + return unit; +} diff --git a/exp/zlup/examples/qft_3qubit.json b/exp/zlup/examples/qft_3qubit.json new file mode 100644 index 000000000..4d2ee878b --- /dev/null +++ b/exp/zlup/examples/qft_3qubit.json @@ -0,0 +1,198 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 3 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 3 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "X", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "RZZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ], + "params": [ + { + "type": "VarExpr", + "name": "pi_2" + } + ] + }, + { + "type": "GateOp", + "gate": "RZZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ], + "params": [ + { + "type": "VarExpr", + "name": "pi_4" + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "RZZ", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ], + "params": [ + { + "type": "VarExpr", + "name": "pi_2" + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ] + }, + { + "type": "GateOp", + "gate": "SWAP", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + }, + { + "type": "BitRef", + "register": "c1", + "index": 0 + }, + { + "type": "BitRef", + "register": "c2", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} \ No newline at end of file diff --git a/exp/zlup/examples/qft_3qubit.qasm b/exp/zlup/examples/qft_3qubit.qasm new file mode 100644 index 000000000..7f2379d4c --- /dev/null +++ b/exp/zlup/examples/qft_3qubit.qasm @@ -0,0 +1,18 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[3]; +creg c[3]; + +x q[0]; +x q[2]; +h q[0]; +rzz(pi_2) q[1], q[0]; +rzz(pi_4) q[2], q[0]; +h q[1]; +rzz(pi_2) q[2], q[1]; +h q[2]; +swap q[0], q[2]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; +measure q[2] -> c[2]; diff --git a/exp/zlup/examples/qft_3qubit.zlp b/exp/zlup/examples/qft_3qubit.zlp new file mode 100644 index 000000000..ee993f1f0 --- /dev/null +++ b/exp/zlup/examples/qft_3qubit.zlp @@ -0,0 +1,46 @@ +/// Quantum Fourier Transform: 3 Qubits +/// Implements the QFT circuit, a key component of many quantum algorithms +/// including Shor's factoring algorithm and quantum phase estimation. +/// +/// QFT transforms computational basis states to frequency basis. + +// Angle constants +pi_2: a64 = 1.5707963267948966; +pi_4: a64 = 0.7853981633974483; + +pub fn main() -> unit { + q := qalloc(3); + pz q; + + // Prepare an input state (e.g., |101⟩) + x q[0]; + x q[2]; + + // === QFT Circuit === + // Process qubits from most significant to least significant + + // Qubit 0 (MSB) + h q[0]; + // Controlled rotations from q[1] and q[2] + // CR(pi/2) controlled by q[1] + crz(pi_2) (q[1], q[0]); + // CR(pi/4) controlled by q[2] + crz(pi_4) (q[2], q[0]); + + // Qubit 1 + h q[1]; + // CR(pi/2) controlled by q[2] + crz(pi_2) (q[2], q[1]); + + // Qubit 2 (LSB) + h q[2]; + + // Swap qubits to get correct bit ordering + // QFT produces output in reversed order + swap (q[0], q[2]); + + // Measure in Fourier basis + results: [3]u1 = mz([3]u1) [q[0], q[1], q[2]]; + + return unit; +} diff --git a/exp/zlup/examples/simple_qec.json b/exp/zlup/examples/simple_qec.json new file mode 100644 index 000000000..b50ca12ed --- /dev/null +++ b/exp/zlup/examples/simple_qec.json @@ -0,0 +1,177 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 5 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "ancilla", + "capacity": 2, + "parent": "q" + }, + { + "type": "AllocatorDecl", + "name": "data", + "capacity": 3, + "parent": "q" + }, + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 5 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "TickStmt", + "label": "syndrome_round", + "body": [ + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "data", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "data", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "data", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "data", + "index": 2 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + } + ] + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "ancilla", + "index": 1 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + }, + { + "type": "BitRef", + "register": "c1", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} \ No newline at end of file diff --git a/exp/zlup/examples/simple_qec.qasm b/exp/zlup/examples/simple_qec.qasm new file mode 100644 index 000000000..01c3998f5 --- /dev/null +++ b/exp/zlup/examples/simple_qec.qasm @@ -0,0 +1,17 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[5]; +creg c[2]; + +// tick syndrome_round +h q[0]; +cx q[0], q[0]; +cx q[0], q[1]; +h q[0]; +h q[1]; +cx q[1], q[1]; +cx q[1], q[2]; +h q[1]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; diff --git a/exp/zlup/examples/simple_qec.zlp b/exp/zlup/examples/simple_qec.zlp new file mode 100644 index 000000000..eb959bb1f --- /dev/null +++ b/exp/zlup/examples/simple_qec.zlp @@ -0,0 +1,54 @@ +/// Simple QEC Example: 3-Qubit Bit-Flip Code +/// Encodes a single logical qubit into 3 physical qubits. +/// Can detect and correct single bit-flip (X) errors. +/// +/// Encoding: |0⟩_L = |000⟩, |1⟩_L = |111⟩ +/// Stabilizers: Z₀Z₁, Z₁Z₂ + +pub fn main() -> unit { + // Allocate: 3 data qubits + 2 ancillas for syndrome measurement + mut q := qalloc(5); + data := q.child(3); // Data qubits [0, 1, 2] + ancilla := q.child(2); // Ancilla qubits for syndrome + + pz q; + + // === Encode logical |0⟩ === + // Starting from |000⟩, this is already the logical |0⟩ + // To encode |+⟩_L instead, uncomment: + // h data[0]; + // cx (data[0], data[1]); + // cx (data[0], data[2]); + + // === Syndrome Measurement Round === + tick syndrome_round { + // Measure Z₀Z₁ (parity of qubits 0 and 1) + h ancilla[0]; + cx (ancilla[0], data[0]); + cx (ancilla[0], data[1]); + h ancilla[0]; + + // Measure Z₁Z₂ (parity of qubits 1 and 2) + h ancilla[1]; + cx (ancilla[1], data[1]); + cx (ancilla[1], data[2]); + h ancilla[1]; + } + + // Read syndrome + s: [2]u1 = mz([2]u1) [ancilla[0], ancilla[1]]; + + // Decode syndrome to find error location: + // s = [0,0]: No error + // s = [1,0]: Error on qubit 0 + // s = [1,1]: Error on qubit 1 + // s = [0,1]: Error on qubit 2 + + // Syndrome interpretation: + // s = [0,0]: No error + // s = [1,0]: Error on qubit 0 + // s = [1,1]: Error on qubit 1 + // s = [0,1]: Error on qubit 2 + + return unit; +} diff --git a/exp/zlup/examples/teleportation.json b/exp/zlup/examples/teleportation.json new file mode 100644 index 000000000..41fd8d5bd --- /dev/null +++ b/exp/zlup/examples/teleportation.json @@ -0,0 +1,142 @@ +{ + "type": "Program", + "name": "main", + "allocator": { + "type": "AllocatorDecl", + "name": "q", + "capacity": 3 + }, + "declarations": [ + { + "type": "AllocatorDecl", + "name": "q", + "capacity": 3 + } + ], + "body": [ + { + "type": "PrepareOp", + "allocator": "q" + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ] + }, + { + "type": "GateOp", + "gate": "CX", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + }, + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ] + }, + { + "type": "GateOp", + "gate": "H", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ] + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 0 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c0", + "index": 0 + } + ], + "result_type": "u1" + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 1 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c1", + "index": 0 + } + ], + "result_type": "u1" + }, + { + "type": "MeasureOp", + "targets": [ + { + "type": "SlotRef", + "allocator": "q", + "index": 2 + } + ], + "results": [ + { + "type": "BitRef", + "register": "c2", + "index": 0 + } + ], + "result_type": "u1" + } + ], + "returns": [] +} \ No newline at end of file diff --git a/exp/zlup/examples/teleportation.qasm b/exp/zlup/examples/teleportation.qasm new file mode 100644 index 000000000..7fbe4ed69 --- /dev/null +++ b/exp/zlup/examples/teleportation.qasm @@ -0,0 +1,14 @@ +OPENQASM 2.0; +include "qelib1.inc"; + +qreg q[3]; +creg c[3]; + +h q[0]; +h q[1]; +cx q[1], q[2]; +cx q[0], q[1]; +h q[0]; +measure q[0] -> c[0]; +measure q[1] -> c[1]; +measure q[2] -> c[2]; diff --git a/exp/zlup/examples/teleportation.zlp b/exp/zlup/examples/teleportation.zlp new file mode 100644 index 000000000..50bcdc06d --- /dev/null +++ b/exp/zlup/examples/teleportation.zlp @@ -0,0 +1,49 @@ +/// Quantum Teleportation Example +/// Teleports the state of qubit 0 to qubit 2 using a pre-shared Bell pair. +/// +/// Protocol: +/// 1. Alice and Bob share a Bell pair (qubits 1 and 2) +/// 2. Alice has the state to teleport (qubit 0) +/// 3. Alice performs Bell measurement on qubits 0 and 1 +/// 4. Alice sends classical bits to Bob +/// 5. Bob applies corrections based on measurement results + +pub fn main() -> unit { + q := qalloc(3); + pz q; + + // === Prepare the state to teleport on qubit 0 === + // For this example, we'll prepare |+⟩ = (|0⟩ + |1⟩) / sqrt(2) + h q[0]; + + // === Create Bell pair between qubits 1 and 2 === + // This is the "quantum channel" shared by Alice (q[1]) and Bob (q[2]) + h q[1]; + cx (q[1], q[2]); + + // === Alice's Bell measurement === + // Entangle the unknown state with her half of the Bell pair + cx (q[0], q[1]); + h q[0]; + + // Measure Alice's qubits + m0: u1 = mz(u1) q[0]; + m1: u1 = mz(u1) q[1]; + + // === Bob's corrections === + // Based on Alice's measurement results, Bob applies corrections + // Note: In a real implementation, these would be classically controlled + + // If m1 == 1, apply X gate + // if m1 == 1 { x q[2]; } + + // If m0 == 1, apply Z gate + // if m0 == 1 { z q[2]; } + + // After corrections, qubit 2 is in the original state of qubit 0 + + // Verify by measuring (should match original state statistics) + final_result: u1 = mz(u1) q[2]; + + return unit; +} diff --git a/exp/zlup/examples/test_lsp.zlp b/exp/zlup/examples/test_lsp.zlp new file mode 100644 index 000000000..cc01396f2 --- /dev/null +++ b/exp/zlup/examples/test_lsp.zlp @@ -0,0 +1,28 @@ +// Test file for zlups LSP +// Open this in Neovim to test diagnostics, hover, and completions + +pub fn main() -> unit { + q := qalloc(4); + pz q; + + // Apply Hadamard gates + h q[0]; + h q[1]; + + // Create entanglement + cx (q[0], q[2]); + cx (q[1], q[3]); + + // Measure qubits + r0: u1 = mz(u1) q[0]; + r1: u1 = mz(u1) q[1]; + + // This line has an error - undefined function + // Uncomment to test diagnostics: + // undefined_func(); + + return unit; +} + +// Test hover: put cursor on 'h', 'cx', 'qalloc', etc. +// Test completions: type 'q.' and press Ctrl+Space diff --git a/exp/zlup/ffi/zlup-ffi/Cargo.lock b/exp/zlup/ffi/zlup-ffi/Cargo.lock new file mode 100644 index 000000000..8e6af6540 --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "zlup-ffi" +version = "0.1.0" diff --git a/exp/zlup/ffi/zlup-ffi/Cargo.toml b/exp/zlup/ffi/zlup-ffi/Cargo.toml new file mode 100644 index 000000000..b89496b94 --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/Cargo.toml @@ -0,0 +1,23 @@ +# Keep this crate independent of parent workspace +[workspace] + +[package] +name = "zlup-ffi" +version = "0.1.0" +edition = "2021" +description = "FFI traits and types for integrating Rust code with Zlup" +license = "Apache-2.0" +repository = "https://github.com/PECOS-packages/PECOS" +keywords = ["quantum", "qec", "ffi", "decoder"] +categories = ["science", "api-bindings"] + +[features] +default = [] +# TODO: Enable proc macros for #[zlup_export] once zlup-ffi-macros crate exists +# macros = ["zlup-ffi-macros"] + +[dependencies] +# TODO: Add proc macro crate when implemented +# zlup-ffi-macros = { path = "../zlup-ffi-macros", optional = true } + +[dev-dependencies] diff --git a/exp/zlup/ffi/zlup-ffi/README.md b/exp/zlup/ffi/zlup-ffi/README.md new file mode 100644 index 000000000..5fcdd3e1d --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/README.md @@ -0,0 +1,77 @@ +# zlup-ffi + +FFI traits and types for integrating Rust code with Zlup. + +## Overview + +This crate provides the Rust side of Zlup's FFI story. Implement the provided traits to create decoders, noise models, and simulators that can be called from Zlup programs. + +## Quick Start + +```rust +use zlup_ffi::prelude::*; + +pub struct MyMwpmDecoder { + distance: usize, +} + +impl Decoder for MyMwpmDecoder { + type Syndrome = u64; + type Correction = u64; + + fn decode(&self, syndrome: u64) -> u64 { + // Your MWPM implementation here + 0 + } +} + +// Generate C ABI exports manually (or use #[zlup_export] with macros feature) +#[no_mangle] +pub extern "C" fn mwpm_new(distance: u32) -> *mut MyMwpmDecoder { + Box::into_raw(Box::new(MyMwpmDecoder { distance: distance as usize })) +} + +#[no_mangle] +pub extern "C" fn mwpm_decode(decoder: *const MyMwpmDecoder, syndrome: u64) -> u64 { + let decoder = unsafe { &*decoder }; + decoder.decode(syndrome) +} + +#[no_mangle] +pub extern "C" fn mwpm_free(decoder: *mut MyMwpmDecoder) { + if !decoder.is_null() { + unsafe { drop(Box::from_raw(decoder)); } + } +} +``` + +## Features + +- `macros` - Enable `#[zlup_export]` proc macro for automatic C ABI generation (planned) + +## Traits + +| Trait | Purpose | +|-------|---------| +| `Decoder` | Map syndromes to corrections | +| `NoiseModel` | Define custom noise channels for simulation | +| `Simulator` | Create custom quantum state simulators | +| `BatchDecoder` | Efficient batch decoding | +| `StreamingDecoder` | Temporal/streaming decoding | + +## Types + +| Type | Description | +|------|-------------| +| `QubitId` | Opaque qubit identifier | +| `GateType` | Enum of supported gate types | +| `PackedBits` | Efficient bit storage for syndromes | +| `FfiResult` | FFI-safe result type | + +## Documentation + +See the [Zlup Rust Integration Guide](../docs/rust-integration.md) for complete documentation. + +## License + +Apache-2.0 diff --git a/exp/zlup/ffi/zlup-ffi/src/lib.rs b/exp/zlup/ffi/zlup-ffi/src/lib.rs new file mode 100644 index 000000000..4a2164e6f --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/src/lib.rs @@ -0,0 +1,43 @@ +//! # zlup-ffi +//! +//! FFI traits and types for integrating Rust code with Zlup. +//! +//! This crate provides: +//! - Traits that decoders and simulation backends should implement +//! - FFI-safe types for crossing the Zlup/Rust boundary +//! - (With `macros` feature) `#[zlup_export]` proc macro for generating C ABI wrappers +//! +//! ## Quick Start +//! +//! ```rust,ignore +//! use zlup_ffi::prelude::*; +//! +//! pub struct MyDecoder { /* ... */ } +//! +//! impl Decoder for MyDecoder { +//! type Syndrome = u64; +//! type Correction = u64; +//! +//! fn decode(&self, syndrome: u64) -> u64 { +//! // Your decoding logic here +//! 0 +//! } +//! } +//! ``` +//! +//! See the [Zlup Rust Integration Guide](https://github.com/PECOS-packages/PECOS) for details. + +#![warn(missing_docs)] + +pub mod types; +pub mod traits; + +/// Prelude module - import everything commonly needed +pub mod prelude { + pub use crate::traits::*; + pub use crate::types::*; +} + +// TODO: Re-export proc macros when zlup-ffi-macros crate is implemented +// #[cfg(feature = "macros")] +// pub use zlup_ffi_macros::zlup_export; diff --git a/exp/zlup/ffi/zlup-ffi/src/traits.rs b/exp/zlup/ffi/zlup-ffi/src/traits.rs new file mode 100644 index 000000000..d02b6cbe1 --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/src/traits.rs @@ -0,0 +1,216 @@ +//! Core traits for Zlup FFI integration. +//! +//! Implement these traits to create decoders, noise models, and simulators +//! that can be called from Zlup. + +use crate::types::{CorrectionData, GateType, QubitId, SyndromeData}; + +/// A decoder that maps syndromes to corrections. +/// +/// This is the primary trait for implementing QEC decoders. +/// +/// # Example +/// +/// ```rust,ignore +/// use zlup_ffi::prelude::*; +/// +/// pub struct LookupDecoder { +/// table: Vec, +/// } +/// +/// impl Decoder for LookupDecoder { +/// type Syndrome = u64; +/// type Correction = u64; +/// +/// fn decode(&self, syndrome: u64) -> u64 { +/// self.table.get(syndrome as usize).copied().unwrap_or(0) +/// } +/// } +/// ``` +pub trait Decoder: Send + Sync { + /// The syndrome type (typically u64 or PackedBits). + type Syndrome: SyndromeData; + + /// The correction type (typically u64 or PackedBits). + type Correction: CorrectionData; + + /// Decode a syndrome into a correction. + /// + /// This is the core decoding operation. Given a syndrome (pattern of + /// stabilizer measurement outcomes), return the correction to apply. + fn decode(&self, syndrome: Self::Syndrome) -> Self::Correction; + + /// Decode with soft information (for ML decoders). + /// + /// Some decoders (e.g., neural network decoders) can use soft information + /// like measurement probabilities. The default implementation ignores + /// soft info and calls the standard `decode`. + fn decode_soft(&self, syndrome: Self::Syndrome, _soft_info: &[f32]) -> Self::Correction { + self.decode(syndrome) + } + + /// Reset decoder state between shots. + /// + /// Some decoders maintain state (e.g., for temporal decoding). + /// Call this between independent decoding problems. + fn reset(&mut self) {} + + /// Get the code distance this decoder is configured for. + fn distance(&self) -> Option { + None + } + + /// Get the number of syndrome bits expected. + fn syndrome_bits(&self) -> Option { + None + } +} + +/// A noise model for quantum simulation. +/// +/// Implement this trait to define custom noise channels. +pub trait NoiseModel: Send + Sync { + /// Apply noise after a gate operation. + /// + /// Called after each gate in the circuit. The noise model can + /// inject errors based on the gate type and affected qubits. + fn apply_gate_noise( + &self, + gate: GateType, + qubits: &[QubitId], + rng: &mut dyn RngCore, + ); + + /// Apply measurement noise. + /// + /// Returns true if the measurement outcome should be flipped. + fn apply_measurement_noise(&self, qubit: QubitId, rng: &mut dyn RngCore) -> bool; + + /// Apply idle noise for a time step. + /// + /// Called for qubits that are idle during a tick. + fn apply_idle_noise(&self, qubits: &[QubitId], rng: &mut dyn RngCore); + + /// Get the error rate for a specific gate type. + fn gate_error_rate(&self, gate: GateType) -> f64 { + let _ = gate; + 0.0 + } + + /// Get the measurement error rate. + fn measurement_error_rate(&self) -> f64 { + 0.0 + } +} + +/// A quantum state simulator. +/// +/// Implement this trait to create custom simulation backends. +pub trait Simulator: Send + Sync { + /// Apply a single-qubit gate. + fn apply_single_qubit_gate(&mut self, gate: GateType, qubit: QubitId); + + /// Apply a two-qubit gate. + fn apply_two_qubit_gate(&mut self, gate: GateType, control: QubitId, target: QubitId); + + /// Apply a three-qubit gate. + fn apply_three_qubit_gate( + &mut self, + gate: GateType, + q0: QubitId, + q1: QubitId, + q2: QubitId, + ); + + /// Measure a qubit in the Z basis. + /// + /// Returns the measurement outcome (0 or 1). + fn measure_z(&mut self, qubit: QubitId) -> bool; + + /// Reset a qubit to |0⟩. + fn reset(&mut self, qubit: QubitId); + + /// Initialize the simulator with a given number of qubits. + fn initialize(&mut self, num_qubits: usize); + + /// Get the current number of qubits. + fn num_qubits(&self) -> usize; +} + +/// Minimal RNG trait for noise models. +/// +/// This is a simplified version of `rand::RngCore` to avoid +/// requiring the full rand crate as a dependency. +pub trait RngCore { + /// Generate a random u64. + fn next_u64(&mut self) -> u64; + + /// Generate a random f64 in [0, 1). + fn gen_f64(&mut self) -> f64 { + // Standard conversion from u64 to [0, 1) + (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64) + } + + /// Generate a random bool with given probability of true. + fn gen_bool(&mut self, probability: f64) -> bool { + self.gen_f64() < probability + } +} + +/// A simple XorShift64 RNG for when you don't need cryptographic randomness. +pub struct XorShift64 { + state: u64, +} + +impl XorShift64 { + /// Create a new RNG with the given seed. + pub fn new(seed: u64) -> Self { + // Ensure non-zero state + Self { + state: if seed == 0 { 1 } else { seed }, + } + } +} + +impl RngCore for XorShift64 { + fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.state = x; + x + } +} + +/// Extension trait for decoders that support batch decoding. +pub trait BatchDecoder: Decoder { + /// Decode multiple syndromes at once. + /// + /// This can be more efficient than calling `decode` repeatedly + /// due to better cache utilization or parallelization. + fn decode_batch( + &self, + syndromes: &[Self::Syndrome], + corrections: &mut [Self::Correction], + ) { + assert_eq!(syndromes.len(), corrections.len()); + for (syn, cor) in syndromes.iter().zip(corrections.iter_mut()) { + *cor = self.decode(*syn); + } + } +} + +/// Extension trait for decoders that support streaming/temporal decoding. +pub trait StreamingDecoder: Decoder { + /// Feed a syndrome from a new round. + /// + /// For temporal decoders that look at syndrome history. + fn feed_round(&mut self, syndrome: Self::Syndrome); + + /// Get the current correction estimate. + fn current_correction(&self) -> Self::Correction; + + /// Commit the current state (e.g., after a logical measurement). + fn commit(&mut self) -> Self::Correction; +} diff --git a/exp/zlup/ffi/zlup-ffi/src/types.rs b/exp/zlup/ffi/zlup-ffi/src/types.rs new file mode 100644 index 000000000..ed67204db --- /dev/null +++ b/exp/zlup/ffi/zlup-ffi/src/types.rs @@ -0,0 +1,602 @@ +//! FFI-safe types for Zlup/Rust interop. +//! +//! These types are designed to cross the FFI boundary safely and map +//! directly to Zlup types. + +use std::marker::PhantomData; + +/// A qubit identifier. +/// +/// This is an opaque handle that identifies a qubit in the quantum state. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct QubitId(pub u32); + +impl QubitId { + /// Create a new qubit ID. + pub fn new(id: u32) -> Self { + Self(id) + } + + /// Get the raw ID value. + pub fn raw(&self) -> u32 { + self.0 + } +} + +/// Gate types supported by Zlup. +/// +/// This enum maps directly to Zlup's gate operations. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum GateType { + // Single-qubit Pauli gates + /// Pauli X gate + X, + /// Pauli Y gate + Y, + /// Pauli Z gate + Z, + + // Single-qubit Clifford gates + /// Hadamard gate + H, + /// S gate (sqrt Z) + S, + /// S-dagger gate + Sdg, + /// T gate (fourth root Z) + T, + /// T-dagger gate + Tdg, + + // Square root gates + /// sqrt(X) gate + Sx, + /// sqrt(Y) gate + Sy, + /// sqrt(Z) gate (same as S) + Sz, + + // Rotation gates (angle in radians) + /// Rotation around X axis + Rx(f64), + /// Rotation around Y axis + Ry(f64), + /// Rotation around Z axis + Rz(f64), + + // Two-qubit gates + /// Controlled-X (CNOT) + Cx, + /// Controlled-Y + Cy, + /// Controlled-Z + Cz, + /// Controlled-H + Ch, + + // Swap gates + /// SWAP gate + Swap, + /// iSWAP gate + Iswap, + + // Ising gates + /// sqrt(XX) + Sxx, + /// sqrt(YY) + Syy, + /// sqrt(ZZ) + Szz, + + // Parameterized two-qubit gates + /// ZZ rotation + Rzz(f64), + /// XX rotation + Rxx(f64), + /// YY rotation + Ryy(f64), + + // Three-qubit gates + /// Toffoli (CCX) + Ccx, +} + +// ============================================================================= +// Angle64 - Fixed-point angle representation +// ============================================================================= + +/// A 64-bit fixed-point angle representation. +/// +/// Angles are stored as fractions of a full turn using fixed-point arithmetic. +/// The internal representation uses a u64 where [0, 2^64) maps to [0, 1) turns. +/// This provides exact representation for all dyadic fractions (denominators +/// that are powers of 2), which covers all common quantum gate angles: +/// +/// - 1/2 turn (π rad) = 2^63 +/// - 1/4 turn (π/2 rad) = 2^62 +/// - 1/8 turn (π/4 rad, T-gate) = 2^61 +/// - etc. +/// +/// # Design Rationale +/// +/// This representation is compatible with PECOS's angle handling and avoids +/// floating-point precision issues that can cause bugs in quantum circuits +/// (similar to the Mars Climate Orbiter unit conversion bug). +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Angle64 { + /// Internal fixed-point representation. + /// The full range [0, 2^64) represents [0, 1) turns. + raw: u64, +} + +impl Angle64 { + /// The constant 2^64 as f64 for conversions. + const SCALE: f64 = (1u64 << 63) as f64 * 2.0; + + /// Zero angle. + pub const ZERO: Angle64 = Angle64 { raw: 0 }; + + /// One full turn (wraps to zero in modular arithmetic). + pub const FULL_TURN: Angle64 = Angle64 { raw: 0 }; + + /// Half turn (π radians, 180°). + pub const HALF_TURN: Angle64 = Angle64 { raw: 1 << 63 }; + + /// Quarter turn (π/2 radians, 90°). + pub const QUARTER_TURN: Angle64 = Angle64 { raw: 1 << 62 }; + + /// Eighth turn (π/4 radians, 45°, T-gate angle). + pub const EIGHTH_TURN: Angle64 = Angle64 { raw: 1 << 61 }; + + /// Create an angle from a fraction of a turn. + /// + /// The value should be in [0, 1) for angles less than a full turn, + /// but values outside this range will wrap correctly. + /// + /// # Example + /// ``` + /// use zlup_ffi::types::Angle64; + /// let quarter = Angle64::from_turns(0.25); + /// assert_eq!(quarter, Angle64::QUARTER_TURN); + /// ``` + pub fn from_turns(turns: f64) -> Self { + // Normalize to [0, 1) range + let normalized = turns.rem_euclid(1.0); + let raw = (normalized * Self::SCALE) as u64; + Self { raw } + } + + /// Create an angle from radians. + /// + /// # Example + /// ``` + /// use zlup_ffi::types::Angle64; + /// use std::f64::consts::PI; + /// let quarter = Angle64::from_radians(PI / 2.0); + /// assert_eq!(quarter, Angle64::QUARTER_TURN); + /// ``` + pub fn from_radians(radians: f64) -> Self { + Self::from_turns(radians / (2.0 * std::f64::consts::PI)) + } + + /// Create an angle from an exact fraction of a turn. + /// + /// This method provides exact representation for dyadic fractions + /// (where the denominator is a power of 2). + /// + /// # Example + /// ``` + /// use zlup_ffi::types::Angle64; + /// let quarter = Angle64::from_turn_fraction(1, 4); + /// assert_eq!(quarter, Angle64::QUARTER_TURN); + /// ``` + pub fn from_turn_fraction(numerator: u64, denominator: u64) -> Self { + if denominator == 0 { + return Self::ZERO; + } + // For exact representation, we compute: (numerator * 2^64) / denominator + // Using 128-bit arithmetic for precision + let num_scaled = (numerator as u128) << 64; + let raw = (num_scaled / denominator as u128) as u64; + Self { raw } + } + + /// Convert to turns (fraction of a full rotation). + pub fn to_turns(&self) -> f64 { + self.raw as f64 / Self::SCALE + } + + /// Convert to radians. + pub fn to_radians(&self) -> f64 { + self.to_turns() * 2.0 * std::f64::consts::PI + } + + /// Get the raw fixed-point value. + pub fn raw(&self) -> u64 { + self.raw + } + + /// Create from raw fixed-point value. + pub fn from_raw(raw: u64) -> Self { + Self { raw } + } + + /// Add two angles (wraps at full turn). + pub fn add(self, other: Self) -> Self { + Self { + raw: self.raw.wrapping_add(other.raw), + } + } + + /// Subtract two angles (wraps at full turn). + pub fn sub(self, other: Self) -> Self { + Self { + raw: self.raw.wrapping_sub(other.raw), + } + } + + /// Negate the angle. + pub fn neg(self) -> Self { + Self { + raw: self.raw.wrapping_neg(), + } + } + + /// Multiply the angle by an integer. + pub fn mul(self, n: u64) -> Self { + Self { + raw: self.raw.wrapping_mul(n), + } + } + + /// Divide the angle by an integer. + pub fn div(self, n: u64) -> Self { + if n == 0 { + return Self::ZERO; + } + Self { raw: self.raw / n } + } + + /// Check if this is exactly zero. + pub fn is_zero(&self) -> bool { + self.raw == 0 + } + + /// Check if this is exactly a half turn. + pub fn is_half_turn(&self) -> bool { + self.raw == Self::HALF_TURN.raw + } + + /// Check if this is a Clifford angle (multiple of 1/4 turn). + pub fn is_clifford(&self) -> bool { + // Clifford angles are multiples of 1/4 turn + // In our representation, that means the lower 62 bits are zero + (self.raw & ((1 << 62) - 1)) == 0 + } +} + +impl std::ops::Add for Angle64 { + type Output = Self; + fn add(self, other: Self) -> Self { + self.add(other) + } +} + +impl std::ops::Sub for Angle64 { + type Output = Self; + fn sub(self, other: Self) -> Self { + Self::sub(self, other) + } +} + +impl std::ops::Neg for Angle64 { + type Output = Self; + fn neg(self) -> Self { + Self::neg(self) + } +} + +impl std::fmt::Display for Angle64 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Try to display as a nice fraction if possible + let turns = self.to_turns(); + if turns == 0.0 { + write!(f, "0 turns") + } else if turns == 0.5 { + write!(f, "1/2 turns") + } else if turns == 0.25 { + write!(f, "1/4 turns") + } else if turns == 0.125 { + write!(f, "1/8 turns") + } else if turns == 0.75 { + write!(f, "3/4 turns") + } else { + write!(f, "{:.6} turns", turns) + } + } +} + +/// Packed bit representation for syndromes and corrections (64 bits). +/// +/// For larger syndromes, use `Syndrome128` or `Syndrome256`. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Syndrome64 { + data: u64, +} + +impl Syndrome64 { + /// Create a new Syndrome64 with all zeros. + pub fn zeros() -> Self { + Self { data: 0 } + } + + /// Create from a u64. + pub fn from_u64(value: u64) -> Self { + Self { data: value } + } + + /// Get a single bit. + pub fn get(&self, index: usize) -> bool { + if index >= 64 { + return false; + } + (self.data >> index) & 1 == 1 + } + + /// Set a single bit. + pub fn set(&mut self, index: usize, value: bool) { + if index >= 64 { + return; + } + if value { + self.data |= 1 << index; + } else { + self.data &= !(1 << index); + } + } + + /// Get the number of bits (always 64). + pub fn len(&self) -> usize { + 64 + } + + /// Check if empty (never true for fixed-size). + pub fn is_empty(&self) -> bool { + false + } + + /// Convert to u64. + pub fn to_u64(&self) -> u64 { + self.data + } + + /// Count the number of set bits (popcount). + pub fn popcount(&self) -> usize { + self.data.count_ones() as usize + } + + /// Compute parity (XOR of all bits). + pub fn parity(&self) -> bool { + self.data.count_ones() % 2 == 1 + } +} + +/// 128-bit syndrome storage. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Syndrome128 { + low: u64, + high: u64, +} + +impl Syndrome128 { + /// Create a new Syndrome128 with all zeros. + pub fn zeros() -> Self { + Self { low: 0, high: 0 } + } + + /// Get a single bit. + pub fn get(&self, index: usize) -> bool { + if index >= 128 { + return false; + } + if index < 64 { + (self.low >> index) & 1 == 1 + } else { + (self.high >> (index - 64)) & 1 == 1 + } + } + + /// Set a single bit. + pub fn set(&mut self, index: usize, value: bool) { + if index >= 128 { + return; + } + if index < 64 { + if value { + self.low |= 1 << index; + } else { + self.low &= !(1 << index); + } + } else { + let bit = index - 64; + if value { + self.high |= 1 << bit; + } else { + self.high &= !(1 << bit); + } + } + } + + /// Count the number of set bits (popcount). + pub fn popcount(&self) -> usize { + (self.low.count_ones() + self.high.count_ones()) as usize + } +} + +/// 256-bit syndrome storage. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Syndrome256 { + words: [u64; 4], +} + +impl Syndrome256 { + /// Create a new Syndrome256 with all zeros. + pub fn zeros() -> Self { + Self { words: [0; 4] } + } + + /// Get a single bit. + pub fn get(&self, index: usize) -> bool { + if index >= 256 { + return false; + } + let word = index / 64; + let bit = index % 64; + (self.words[word] >> bit) & 1 == 1 + } + + /// Set a single bit. + pub fn set(&mut self, index: usize, value: bool) { + if index >= 256 { + return; + } + let word = index / 64; + let bit = index % 64; + if value { + self.words[word] |= 1 << bit; + } else { + self.words[word] &= !(1 << bit); + } + } + + /// Count the number of set bits (popcount). + pub fn popcount(&self) -> usize { + self.words.iter().map(|w| w.count_ones() as usize).sum() + } +} + +/// FFI-safe result type. +/// +/// Use this for functions that can fail across FFI boundaries. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct FfiResult { + /// Whether the operation succeeded. + pub ok: bool, + /// The value (only valid if ok is true). + pub value: T, + /// Error code (only valid if ok is false). + pub error_code: u32, +} + +impl FfiResult { + /// Create a success result. + pub fn ok(value: T) -> Self { + Self { + ok: true, + value, + error_code: 0, + } + } + + /// Create an error result. + pub fn err(code: u32) -> Self { + Self { + ok: false, + value: T::default(), + error_code: code, + } + } +} + +impl FfiResult { + /// Convert to a Rust Result. + pub fn into_result(self) -> Result { + if self.ok { + Ok(self.value) + } else { + Err(self.error_code) + } + } +} + +/// Common FFI error codes. +pub mod error_codes { + /// Success (no error). + pub const SUCCESS: u32 = 0; + /// Null pointer passed. + pub const NULL_POINTER: u32 = 1; + /// Invalid argument. + pub const INVALID_ARGUMENT: u32 = 2; + /// Out of memory. + pub const OUT_OF_MEMORY: u32 = 3; + /// Decoder failed. + pub const DECODE_FAILED: u32 = 4; + /// Internal error. + pub const INTERNAL_ERROR: u32 = 5; +} + +/// Marker trait for types that are safe to use as syndrome data. +pub trait SyndromeData: Copy + Send + Sync {} + +impl SyndromeData for u8 {} +impl SyndromeData for u16 {} +impl SyndromeData for u32 {} +impl SyndromeData for u64 {} +impl SyndromeData for Syndrome64 {} +impl SyndromeData for Syndrome128 {} +impl SyndromeData for Syndrome256 {} + +/// Marker trait for types that are safe to use as correction data. +pub trait CorrectionData: Copy + Send + Sync {} + +impl CorrectionData for u8 {} +impl CorrectionData for u16 {} +impl CorrectionData for u32 {} +impl CorrectionData for u64 {} +impl CorrectionData for Syndrome64 {} +impl CorrectionData for Syndrome128 {} +impl CorrectionData for Syndrome256 {} + +/// A slice of qubits for FFI. +#[repr(C)] +pub struct QubitSlice<'a> { + ptr: *const QubitId, + len: usize, + _marker: PhantomData<&'a [QubitId]>, +} + +impl<'a> QubitSlice<'a> { + /// Create from a Rust slice. + pub fn from_slice(slice: &'a [QubitId]) -> Self { + Self { + ptr: slice.as_ptr(), + len: slice.len(), + _marker: PhantomData, + } + } + + /// Convert back to a Rust slice. + /// + /// # Safety + /// The pointer must still be valid. + pub unsafe fn as_slice(&self) -> &'a [QubitId] { + std::slice::from_raw_parts(self.ptr, self.len) + } + + /// Get the length. + pub fn len(&self) -> usize { + self.len + } + + /// Check if empty. + pub fn is_empty(&self) -> bool { + self.len == 0 + } +} diff --git a/exp/zlup/fuzz/Cargo.lock b/exp/zlup/fuzz/Cargo.lock new file mode 100644 index 000000000..b34f747ac --- /dev/null +++ b/exp/zlup/fuzz/Cargo.lock @@ -0,0 +1,771 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "cfg_aliases", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom", + "libc", +] + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "owo-colors" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" + +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smol_str" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +dependencies = [ + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "zlup" +version = "0.1.0" +dependencies = [ + "miette", + "pest", + "pest_derive", + "serde", + "serde_json", + "smol_str", + "thiserror", + "toml", +] + +[[package]] +name = "zlup-fuzz" +version = "0.0.0" +dependencies = [ + "arbitrary", + "libfuzzer-sys", + "zlup", +] + +[[package]] +name = "zmij" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1966f8ac2c1f76987d69a74d0e0f929241c10e78136434e3be70ff7f58f64214" diff --git a/exp/zlup/fuzz/Cargo.toml b/exp/zlup/fuzz/Cargo.toml new file mode 100644 index 000000000..11aad2ce3 --- /dev/null +++ b/exp/zlup/fuzz/Cargo.toml @@ -0,0 +1,44 @@ +[package] +name = "zlup-fuzz" +version = "0.0.0" +authors = ["Automatically generated"] +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +[dependencies] +libfuzzer-sys = "0.4" +arbitrary = { version = "1", features = ["derive"] } + +[dependencies.zlup] +path = ".." + +# Prevent this from interfering with workspaces +[workspace] +members = ["."] + +[profile.release] +debug = 1 + +[[bin]] +name = "fuzz_parser" +path = "fuzz_targets/fuzz_parser.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_semantic" +path = "fuzz_targets/fuzz_semantic.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "fuzz_comptime" +path = "fuzz_targets/fuzz_comptime.rs" +test = false +doc = false +bench = false diff --git a/exp/zlup/fuzz/fuzz_targets/fuzz_comptime.rs b/exp/zlup/fuzz/fuzz_targets/fuzz_comptime.rs new file mode 100644 index 000000000..7ca02256c --- /dev/null +++ b/exp/zlup/fuzz/fuzz_targets/fuzz_comptime.rs @@ -0,0 +1,40 @@ +//! Fuzz target for the Zlup compile-time evaluator. +//! +//! This target fuzzes the comptime evaluator with arbitrary expressions to find: +//! - Panics during evaluation +//! - Overflow issues not caught by checked arithmetic +//! - Infinite recursion +//! +//! Run with: +//! ```bash +//! cargo +nightly fuzz run fuzz_comptime +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zlup::comptime::ComptimeEvaluator; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, skipping invalid UTF-8 + if let Ok(source) = std::str::from_utf8(data) { + // Wrap in a simple expression context + let wrapped = format!("x := {};", source); + + // Try to parse as a binding with an expression + if let Ok(program) = zlup::parse(&wrapped) { + // Try to evaluate any expressions in the program + let mut evaluator = ComptimeEvaluator::new(); + + // Extract expressions from bindings and try to evaluate them + for decl in &program.declarations { + if let zlup::ast::TopLevelDecl::Binding(binding) = decl { + if let Some(expr) = &binding.value { + // The evaluator should never panic + let _ = evaluator.eval_expr(expr); + } + } + } + } + } +}); diff --git a/exp/zlup/fuzz/fuzz_targets/fuzz_parser.rs b/exp/zlup/fuzz/fuzz_targets/fuzz_parser.rs new file mode 100644 index 000000000..acff2864d --- /dev/null +++ b/exp/zlup/fuzz/fuzz_targets/fuzz_parser.rs @@ -0,0 +1,23 @@ +//! Fuzz target for the Zlup parser. +//! +//! This target fuzzes the parser with arbitrary byte sequences to find: +//! - Panics or crashes +//! - Infinite loops (detected via timeout) +//! - Memory issues +//! +//! Run with: +//! ```bash +//! cargo +nightly fuzz run fuzz_parser +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, skipping invalid UTF-8 + if let Ok(source) = std::str::from_utf8(data) { + // The parser should never panic on any input + let _ = zlup::parse(source); + } +}); diff --git a/exp/zlup/fuzz/fuzz_targets/fuzz_semantic.rs b/exp/zlup/fuzz/fuzz_targets/fuzz_semantic.rs new file mode 100644 index 000000000..a2f24cbd6 --- /dev/null +++ b/exp/zlup/fuzz/fuzz_targets/fuzz_semantic.rs @@ -0,0 +1,28 @@ +//! Fuzz target for the Zlup semantic analyzer. +//! +//! This target fuzzes the semantic analyzer with arbitrary programs to find: +//! - Panics or crashes during type checking +//! - Infinite loops in type inference +//! - Memory issues in symbol table operations +//! +//! Run with: +//! ```bash +//! cargo +nightly fuzz run fuzz_semantic +//! ``` + +#![no_main] + +use libfuzzer_sys::fuzz_target; +use zlup::semantic::SemanticAnalyzer; + +fuzz_target!(|data: &[u8]| { + // Convert bytes to string, skipping invalid UTF-8 + if let Ok(source) = std::str::from_utf8(data) { + // First try to parse - skip if parsing fails + if let Ok(program) = zlup::parse(source) { + // The semantic analyzer should never panic on any valid AST + let mut analyzer = SemanticAnalyzer::new(); + let _ = analyzer.analyze(&program); + } + } +}); diff --git a/exp/zlup/mkdocs.yml b/exp/zlup/mkdocs.yml new file mode 100644 index 000000000..5b4697df8 --- /dev/null +++ b/exp/zlup/mkdocs.yml @@ -0,0 +1,73 @@ +site_name: Zlup Documentation +site_description: Quantum programming language with Zig semantics and Rust/Python syntax +site_url: https://pecos.dev/zlup/ + +repo_name: PECOS/zlup +repo_url: https://github.com/PECOS-packages/PECOS + +theme: + name: material + palette: + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + features: + - navigation.tabs + - navigation.sections + - navigation.expand + - navigation.top + - search.highlight + - content.code.copy + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + extend_pygments_lang: + - name: zlup + lang: zig + - name: zlup_fragment + lang: zig + - name: zlup_nocheck + lang: zig + - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true + - admonition + - pymdownx.details + - tables + - toc: + permalink: true + +nav: + - Home: index.md + - Getting Started: + - Tutorial: tutorial.md + - CLI Reference: cli.md + - IDE Setup: ide-setup.md + - Reference: + - Language Syntax: syntax.md + - Standard Library: stdlib.md + - Error Messages: errors.md + - Advanced: + - Design Philosophy: design.md + - Rust Integration: rust-integration.md + - Contributing: + - Development Notes: dev-notes.md + - Future: + - Build System: future/build-system.md + - Guppy Compatibility: future/guppy-compat.md + - Stdlib Design: future/stdlib-design.md + +extra: + social: + - icon: fontawesome/brands/github + link: https://github.com/PECOS-packages/PECOS diff --git a/exp/zlup/pyproject.toml b/exp/zlup/pyproject.toml new file mode 100644 index 000000000..c76e5bef0 --- /dev/null +++ b/exp/zlup/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "zlup-docs" +version = "0.1.0" +description = "Zlup documentation" +requires-python = ">=3.10" +dependencies = [ + "mkdocs>=1.6", + "mkdocs-material>=9.5", +] + +[project.optional-dependencies] +dev = [ + "mkdocs-minify-plugin", +] diff --git a/exp/zlup/scripts/check_docs.py b/exp/zlup/scripts/check_docs.py new file mode 100644 index 000000000..9c3279674 --- /dev/null +++ b/exp/zlup/scripts/check_docs.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 +"""Check Zlup code snippets in documentation for syntax/semantic errors. + +Extracts fenced code blocks from docs/**/*.md and validates them: + - ``zlup`` → zlup check (parse + semantic) + - ``zlup_fragment`` → zlup parse (syntax only), wrapped in fn + - ``zlup_nocheck`` → skipped + +Non-Zlup fences (bash, rust, zig, json, etc.) are ignored. + +Usage: + python3 scripts/check_docs.py [--verbose] +""" + +import argparse +import glob +import os +import re +import subprocess +import sys + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +ZLUP_TAGS = {"zlup", "zlup_fragment", "zlup_nocheck"} + +# Heuristic: lines starting with these indicate a complete (top-level) program +_TOPLEVEL_PREFIXES = ( + "fn ", "pub ", "inline fn ", "@attr", "gate ", "declare gate", + "test ", "extern fn", +) + +_TOPLEVEL_CONTAINS = ( + ":= struct", ":= enum", ":= error", ":= fault", + ":= union", ":= @import", +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def find_zlup_binary() -> str: + """Locate the zlup binary (workspace target/debug or release).""" + script_dir = os.path.dirname(os.path.abspath(__file__)) + zlup_dir = os.path.dirname(script_dir) # exp/zlup + + # Walk up to find workspace root (contains target/) + candidate = zlup_dir + for _ in range(5): + target = os.path.join(candidate, "target", "debug", "zlup") + if os.path.isfile(target): + return target + target_rel = os.path.join(candidate, "target", "release", "zlup") + if os.path.isfile(target_rel): + return target_rel + candidate = os.path.dirname(candidate) + + # Fallback: hope it's on PATH + return "zlup" + + +def is_complete_program(source: str) -> bool: + """Heuristic: does this snippet look like a complete top-level program?""" + for line in source.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("//") or stripped.startswith("///"): + continue + # First non-comment line + for prefix in _TOPLEVEL_PREFIXES: + if stripped.startswith(prefix): + return True + for pattern in _TOPLEVEL_CONTAINS: + if pattern in stripped: + return True + return False + return False + + +def wrap_fragment(source: str) -> str: + """Wrap a code fragment in a function body for parsing.""" + # Replace `{ ... }` placeholder bodies with `{ }` so they parse + wrapped = re.sub(r'\{\s*\.\.\.\s*\}', '{ }', source) + # Also replace bare `// ...` comment-only placeholders + wrapped = re.sub(r'//\s*\.\.\.', '// placeholder', wrapped) + return f"fn __snippet__() -> unit {{\n{wrapped}\nreturn;\n}}" + + +# --------------------------------------------------------------------------- +# Extraction +# --------------------------------------------------------------------------- + +FENCE_RE = re.compile(r'^```(\w+)?\s*$') + + +def extract_blocks(filepath: str) -> list[dict]: + """Extract fenced code blocks from a markdown file.""" + blocks = [] + with open(filepath, "r") as f: + lines = f.readlines() + + in_block = False + tag = None + start_line = 0 + block_lines: list[str] = [] + + for i, line in enumerate(lines, 1): + if not in_block: + m = FENCE_RE.match(line) + if m: + tag = m.group(1) or "" + in_block = True + start_line = i + block_lines = [] + else: + if line.rstrip() == "```": + blocks.append({ + "file": filepath, + "line": start_line, + "tag": tag, + "source": "".join(block_lines), + }) + in_block = False + else: + block_lines.append(line) + + return blocks + + +# --------------------------------------------------------------------------- +# Checking +# --------------------------------------------------------------------------- + +def run_zlup(binary: str, cmd: str, source: str) -> tuple[bool, str]: + """Run zlup check/parse on source via stdin. Returns (ok, output).""" + try: + result = subprocess.run( + [binary, cmd, "-"], + input=source, + capture_output=True, + text=True, + timeout=30, + ) + output = (result.stdout + result.stderr).strip() + return result.returncode == 0, output + except subprocess.TimeoutExpired: + return False, "TIMEOUT" + except FileNotFoundError: + return False, f"zlup binary not found: {binary}" + + +def check_block(binary: str, block: dict) -> tuple[str, str]: + """Check a single code block. Returns (status, detail). + + status is one of: "OK", "FAIL", "SKIP" + """ + tag = block["tag"] + source = block["source"] + + if tag not in ZLUP_TAGS: + return "SKIP", "non-zlup fence" + + if tag == "zlup_nocheck": + return "SKIP", "nocheck" + + if not source.strip(): + return "SKIP", "empty block" + + if tag == "zlup": + # Full check (parse + semantic) + ok, output = run_zlup(binary, "check", source) + return ("OK" if ok else "FAIL"), output + + if tag == "zlup_fragment": + # Wrap and parse-only + wrapped = wrap_fragment(source) + ok, output = run_zlup(binary, "parse", wrapped) + return ("OK" if ok else "FAIL"), output + + return "SKIP", f"unknown tag: {tag}" + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--verbose", "-v", action="store_true", + help="Show result for each snippet") + args = parser.parse_args() + + # Find docs directory relative to this script + script_dir = os.path.dirname(os.path.abspath(__file__)) + docs_dir = os.path.join(os.path.dirname(script_dir), "docs") + + if not os.path.isdir(docs_dir): + print(f"Error: docs directory not found at {docs_dir}", file=sys.stderr) + sys.exit(1) + + binary = find_zlup_binary() + + # Quick check that the binary works + ok, _ = run_zlup(binary, "parse", "fn test() -> unit { return; }") + if not ok: + print(f"Error: zlup binary not working ({binary})", file=sys.stderr) + print("Run 'just build' first.", file=sys.stderr) + sys.exit(1) + + # Collect all markdown files + md_files = sorted(glob.glob(os.path.join(docs_dir, "**", "*.md"), recursive=True)) + if not md_files: + print("No markdown files found in docs/", file=sys.stderr) + sys.exit(1) + + # Process + counts = {"OK": 0, "FAIL": 0, "SKIP": 0} + failures: list[dict] = [] + + for md_file in md_files: + blocks = extract_blocks(md_file) + for block in blocks: + status, detail = check_block(binary, block) + counts[status] += 1 + + rel = os.path.relpath(block["file"], os.path.dirname(docs_dir)) + + if args.verbose: + tag_info = f"[{block['tag']}]" + if status == "FAIL": + print(f" FAIL {rel}:{block['line']} {tag_info}") + # Show first few lines of error + for err_line in detail.splitlines()[:6]: + print(f" {err_line}") + elif status == "SKIP": + print(f" SKIP {rel}:{block['line']} {tag_info} ({detail})") + else: + print(f" OK {rel}:{block['line']} {tag_info}") + + if status == "FAIL": + failures.append({ + "file": rel, + "line": block["line"], + "tag": block["tag"], + "detail": detail, + }) + + # Summary + total = counts["OK"] + counts["FAIL"] + counts["SKIP"] + print() + print(f"check-docs: {total} snippets — " + f"{counts['OK']} ok, {counts['FAIL']} failed, {counts['SKIP']} skipped") + + if failures: + print() + print("Failures:") + for f in failures: + print(f" {f['file']}:{f['line']} [{f['tag']}]") + for err_line in f["detail"].splitlines()[:4]: + print(f" {err_line}") + print() + sys.exit(1) + else: + print("All checks passed.") + + +if __name__ == "__main__": + main() diff --git a/exp/zlup/src/analysis.rs b/exp/zlup/src/analysis.rs new file mode 100644 index 000000000..c752ca8c3 --- /dev/null +++ b/exp/zlup/src/analysis.rs @@ -0,0 +1,1325 @@ +//! Parallelism analysis passes for Zlup AST. +//! +//! This module provides analysis passes that determine what operations can +//! execute in parallel. The analysis is constraint-based: parallelism follows +//! directly from allocator ownership and explicit operation targets. +//! +//! ## Design Philosophy +//! +//! > **Note:** Zlup is an experimental toy language for exploring quantum programming +//! > language design. This analysis is exploratory, not production-ready. +//! +//! Parallelism is expressed through constraints, not annotations: +//! - Functions that don't take allocator parameters can't touch qubits +//! - Operations on disjoint allocators are independent +//! - Scopes define synchronization boundaries +//! +//! ## Available Passes +//! +//! - **Allocator Scope Analysis**: Track allocator lifetimes and accessibility +//! - **Operation Tagging**: Tag each operation with resources it touches +//! - **Dependency Graph**: Build edges between dependent operations +//! - **Parallel Layer Extraction**: Find maximal independent operation sets +//! +//! ## Usage +//! +//! ```rust +//! use zlup::analysis::{AllocatorAnalysis, OperationTagger, DependencyGraph}; +//! +//! let source = r#" +//! fn main() -> unit { +//! mut q := qalloc(2); +//! h q[0]; +//! cx (q[0], q[1]); +//! return; +//! } +//! "#; +//! +//! let program = zlup::parse(source).unwrap(); +//! let allocators = AllocatorAnalysis::analyze(&program); +//! let tagger = OperationTagger::tag(&program); +//! let graph = DependencyGraph::build(tagger.operations); +//! let layers = graph.parallel_layers(); +//! +//! assert!(allocators.allocators.contains_key("q")); +//! assert!(!layers.is_empty()); +//! ``` + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::ast::{ + Block, Expr, FnDecl, GateOp, MeasureOp, Program, Stmt, TopLevelDecl, +}; + +// ============================================================================= +// Resource Identifiers +// ============================================================================= + +/// A resource that an operation can touch. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Resource { + /// A qubit allocator (e.g., "q", "ancilla") - coarse-grained + Allocator(String), + /// A specific qubit within an allocator (e.g., "q[0]", "q[1]") - fine-grained + /// Only used when the index is a compile-time constant. + Qubit(String, i128), + /// A classical variable (e.g., "syndrome", "corrections") + Variable(String), +} + +impl Resource { + pub fn allocator(name: impl Into) -> Self { + Resource::Allocator(name.into()) + } + + pub fn qubit(allocator: impl Into, index: i128) -> Self { + Resource::Qubit(allocator.into(), index) + } + + pub fn variable(name: impl Into) -> Self { + Resource::Variable(name.into()) + } + + pub fn is_allocator(&self) -> bool { + matches!(self, Resource::Allocator(_)) + } + + pub fn is_qubit(&self) -> bool { + matches!(self, Resource::Qubit(_, _)) + } + + pub fn is_variable(&self) -> bool { + matches!(self, Resource::Variable(_)) + } + + /// Check if this resource touches qubits (either allocator or specific qubit) + pub fn touches_qubits(&self) -> bool { + matches!(self, Resource::Allocator(_) | Resource::Qubit(_, _)) + } +} + +// ============================================================================= +// Allocator Information +// ============================================================================= + +/// Information about a qubit allocator. +#[derive(Debug, Clone)] +pub struct AllocatorInfo { + /// The allocator name + pub name: String, + /// Size if statically known + pub size: Option, + /// Scope depth where defined + pub scope_depth: usize, + /// Line number where defined + pub defined_at_line: u32, +} + +// ============================================================================= +// Pass 1: Allocator Scope Analysis +// ============================================================================= + +/// Tracks allocator lifetimes and scope accessibility. +/// +/// This pass builds a map of what allocators are accessible at each point +/// in the program. Since Zlup requires static allocation, all allocators +/// are known at compile time. +#[derive(Debug, Default)] +pub struct AllocatorAnalysis { + /// All allocators in the program, by name + pub allocators: BTreeMap, + /// Allocators accessible in each function + pub function_allocators: BTreeMap>, +} + +impl AllocatorAnalysis { + /// Analyze a program for allocator information. + pub fn analyze(program: &Program) -> Self { + let mut analysis = Self::default(); + + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl { + analysis.analyze_function(fn_decl); + } + } + + analysis + } + + fn analyze_function(&mut self, fn_decl: &FnDecl) { + let mut local_allocators = BTreeSet::new(); + + // Analyze parameters for allocator types + for param in &fn_decl.params { + if Self::is_allocator_type_expr(¶m.ty) { + local_allocators.insert(param.name.clone()); + self.allocators.insert( + param.name.clone(), + AllocatorInfo { + name: param.name.clone(), + size: Self::extract_allocator_size_from_type(¶m.ty), + scope_depth: 0, + defined_at_line: fn_decl.location.as_ref().map(|l| l.line).unwrap_or(0), + }, + ); + } + } + + // Analyze body for qalloc statements + self.analyze_block(&fn_decl.body, &mut local_allocators, 1); + + self.function_allocators + .insert(fn_decl.name.clone(), local_allocators); + } + + fn analyze_block( + &mut self, + block: &Block, + local_allocators: &mut BTreeSet, + depth: usize, + ) { + for stmt in &block.statements { + self.analyze_stmt(stmt, local_allocators, depth); + } + } + + fn analyze_stmt(&mut self, stmt: &Stmt, local_allocators: &mut BTreeSet, depth: usize) { + match stmt { + Stmt::Binding(binding) => { + // Check if this is a qalloc + if let Some(value) = &binding.value + && Self::is_qalloc_expr(value) + { + local_allocators.insert(binding.name.clone()); + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + size: Self::extract_qalloc_size(value), + scope_depth: depth, + defined_at_line: binding + .location + .as_ref() + .map(|l| l.line) + .unwrap_or(0), + }, + ); + } + } + Stmt::Block(inner_block) => { + self.analyze_block(inner_block, local_allocators, depth + 1); + } + Stmt::If(if_stmt) => { + self.analyze_block(&if_stmt.then_body, local_allocators, depth + 1); + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + crate::ast::ElseBranch::Else(block) => { + self.analyze_block(block, local_allocators, depth + 1); + } + crate::ast::ElseBranch::ElseIf(nested_if) => { + self.analyze_stmt( + &Stmt::If(*nested_if.clone()), + local_allocators, + depth, + ); + } + } + } + } + Stmt::For(for_stmt) => { + self.analyze_block(&for_stmt.body, local_allocators, depth + 1); + } + _ => {} + } + } + + /// Check if a type represents a qubit allocator. + pub fn is_allocator_type_expr(ty: &crate::ast::TypeExpr) -> bool { + // Check for [n]qubit, qubit, or QAlloc types + match ty { + crate::ast::TypeExpr::Array(array_type) => { + // [n]qubit + matches!(array_type.element, crate::ast::TypeExpr::Qubit) + } + crate::ast::TypeExpr::Qubit => true, + crate::ast::TypeExpr::QAlloc(_) => true, + crate::ast::TypeExpr::Named(name) => { + // Named type that might be "qubit" + name.segments.first().is_some_and(|s| s == "qubit") + } + _ => false, + } + } + + /// Extract size from an allocator type if statically known. + fn extract_allocator_size_from_type(ty: &crate::ast::TypeExpr) -> Option { + if let crate::ast::TypeExpr::Array(array_type) = ty + && let Some(size_expr) = &array_type.size + && let Expr::IntLit(lit) = size_expr + { + return Some(lit.value as usize); + } + None + } + + /// Check if an expression is a qalloc call. + fn is_qalloc_expr(expr: &Expr) -> bool { + if let Expr::Call(call) = expr + && let Expr::Ident(ident) = &call.callee + { + return ident.name == "qalloc"; + } + false + } + + /// Extract size from a qalloc call if statically known. + fn extract_qalloc_size(expr: &Expr) -> Option { + if let Expr::Call(call) = expr + && let Some(first_arg) = call.args.first() + && let Expr::IntLit(lit) = first_arg + { + return Some(lit.value as usize); + } + None + } +} + +// ============================================================================= +// Pass 2: Operation Tagging +// ============================================================================= + +/// An operation in the program with its resource usage. +#[derive(Debug, Clone)] +pub struct TaggedOp { + /// Unique ID for this operation + pub id: usize, + /// Resources this operation reads from + pub reads: BTreeSet, + /// Resources this operation writes to + pub writes: BTreeSet, + /// Source location line number + pub line: u32, + /// Human-readable description + pub description: String, +} + +impl TaggedOp { + /// Check if this operation touches any qubit allocators. + pub fn touches_qubits(&self) -> bool { + self.reads.iter().any(|r| r.touches_qubits()) || self.writes.iter().any(|r| r.touches_qubits()) + } + + /// Check if this operation is purely classical. + pub fn is_classical(&self) -> bool { + !self.touches_qubits() + } + + /// Get all resources this operation touches (reads or writes). + pub fn all_resources(&self) -> BTreeSet { + let mut all = self.reads.clone(); + all.extend(self.writes.clone()); + all + } +} + +/// Tags operations with the resources they touch. +#[derive(Debug, Default)] +pub struct OperationTagger { + /// All tagged operations + pub operations: Vec, + /// Next operation ID + next_id: usize, +} + +impl OperationTagger { + /// Tag all operations in a program. + pub fn tag(program: &Program) -> Self { + let mut tagger = Self::default(); + + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl { + tagger.tag_function(fn_decl); + } + } + + tagger + } + + fn tag_function(&mut self, fn_decl: &FnDecl) { + self.tag_block(&fn_decl.body); + } + + fn tag_block(&mut self, block: &Block) { + for stmt in &block.statements { + self.tag_stmt(stmt); + } + } + + fn tag_stmt(&mut self, stmt: &Stmt) { + match stmt { + Stmt::Gate(gate_op) => { + self.tag_gate_op(gate_op); + } + Stmt::Measure(measure_op) => { + self.tag_measure_op(measure_op); + } + Stmt::Expr(expr_stmt) => { + // Check if this is a gate or measure expression + match &expr_stmt.expr { + Expr::Gate(gate_expr) => { + self.tag_gate_expr(gate_expr); + } + Expr::Measure(measure_expr) => { + self.tag_measure_expr(measure_expr); + } + _ => { + // Other expression statements + let mut reads = BTreeSet::new(); + self.collect_expr_resources(&expr_stmt.expr, &mut reads); + + if !reads.is_empty() { + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes: BTreeSet::new(), + line: expr_stmt.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "expr".to_string(), + }); + } + } + } + } + Stmt::Binding(binding) => { + // Track variable definitions + let mut writes = BTreeSet::new(); + writes.insert(Resource::variable(&binding.name)); + + let mut reads = BTreeSet::new(); + if let Some(value) = &binding.value { + self.collect_expr_resources(value, &mut reads); + } + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes, + line: binding.location.as_ref().map(|l| l.line).unwrap_or(0), + description: format!("bind {}", binding.name), + }); + } + Stmt::Assign(assign) => { + let mut writes = BTreeSet::new(); + let mut reads = BTreeSet::new(); + + // Target is written + self.collect_expr_resources(&assign.target, &mut writes); + + // Value is read + self.collect_expr_resources(&assign.value, &mut reads); + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes, + line: assign.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "assign".to_string(), + }); + } + Stmt::Block(block) => { + self.tag_block(block); + } + Stmt::If(if_stmt) => { + // Condition is read + let mut reads = BTreeSet::new(); + self.collect_expr_resources(&if_stmt.condition, &mut reads); + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes: BTreeSet::new(), + line: if_stmt.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "if condition".to_string(), + }); + + self.tag_block(&if_stmt.then_body); + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + crate::ast::ElseBranch::Else(block) => self.tag_block(block), + crate::ast::ElseBranch::ElseIf(nested_if) => { + self.tag_stmt(&Stmt::If(*nested_if.clone())) + } + } + } + } + Stmt::For(for_stmt) => { + self.tag_block(&for_stmt.body); + } + Stmt::Return(ret) => { + let mut reads = BTreeSet::new(); + if let Some(value) = &ret.value { + self.collect_expr_resources(value, &mut reads); + } + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes: BTreeSet::new(), + line: ret.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "return".to_string(), + }); + } + _ => {} + } + } + + fn tag_gate_op(&mut self, gate: &GateOp) { + let mut writes = BTreeSet::new(); + + // Gates write to their target qubits + for target in &gate.targets { + self.collect_slot_ref_allocator(target, &mut writes); + } + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads: BTreeSet::new(), + writes, + line: gate.location.as_ref().map(|l| l.line).unwrap_or(0), + description: format!("{:?}", gate.kind), + }); + } + + fn tag_gate_expr(&mut self, gate: &crate::ast::GateExpr) { + let mut writes = BTreeSet::new(); + + // Collect allocators from the target expression + self.collect_allocators_from_expr(&gate.target, &mut writes); + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads: BTreeSet::new(), + writes, + line: gate.location.as_ref().map(|l| l.line).unwrap_or(0), + description: format!("{:?}", gate.kind), + }); + } + + fn tag_measure_op(&mut self, measure: &MeasureOp) { + let mut reads = BTreeSet::new(); + let writes = BTreeSet::new(); + + // Measure reads from qubits + for target in &measure.targets { + self.collect_slot_ref_allocator(target, &mut reads); + } + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes, + line: measure.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "measure".to_string(), + }); + } + + fn tag_measure_expr(&mut self, measure: &crate::ast::MeasureExpr) { + let mut reads = BTreeSet::new(); + let writes = BTreeSet::new(); + + // Collect allocators from the targets expression + self.collect_allocators_from_expr(&measure.targets, &mut reads); + + let id = self.next_id(); + self.operations.push(TaggedOp { + id, + reads, + writes, + line: measure.location.as_ref().map(|l| l.line).unwrap_or(0), + description: "measure".to_string(), + }); + } + + /// Collect allocator resources from an expression that represents qubit targets. + fn collect_allocators_from_expr(&self, expr: &Expr, resources: &mut BTreeSet) { + match expr { + Expr::Index(index) => { + // e.g., q[0] - extract the base allocator name + if let Expr::Ident(ident) = &index.object { + resources.insert(Resource::allocator(&ident.name)); + } else { + self.collect_allocators_from_expr(&index.object, resources); + } + } + Expr::Ident(ident) => { + // Bare identifier might be an allocator + resources.insert(Resource::allocator(&ident.name)); + } + Expr::Tuple(tuple) => { + // e.g., (q[0], q[1]) for two-qubit gates + for elem in &tuple.elements { + self.collect_allocators_from_expr(elem, resources); + } + } + Expr::BracketArray(arr) => { + // e.g., [q[0], q[1], q[2]] + for elem in &arr.elements { + self.collect_allocators_from_expr(elem, resources); + } + } + Expr::SlotRef(slot) => { + resources.insert(Resource::allocator(&slot.allocator)); + } + _ => {} + } + } + + fn collect_slot_ref_allocator( + &self, + slot_ref: &crate::ast::SlotRef, + resources: &mut BTreeSet, + ) { + resources.insert(Resource::allocator(&slot_ref.allocator)); + } + + fn collect_expr_resources(&self, expr: &Expr, resources: &mut BTreeSet) { + match expr { + Expr::Ident(ident) => { + resources.insert(Resource::variable(&ident.name)); + } + Expr::Index(index) => { + // The object being indexed + self.collect_expr_resources(&index.object, resources); + } + Expr::Binary(binary) => { + self.collect_expr_resources(&binary.left, resources); + self.collect_expr_resources(&binary.right, resources); + } + Expr::Unary(unary) => { + self.collect_expr_resources(&unary.operand, resources); + } + Expr::Call(call) => { + self.collect_expr_resources(&call.callee, resources); + for arg in &call.args { + self.collect_expr_resources(arg, resources); + } + } + Expr::Field(field) => { + self.collect_expr_resources(&field.object, resources); + } + Expr::Measure(measure) => { + // Measure reads from qubits - collect allocators + self.collect_allocators_from_expr(&measure.targets, resources); + } + Expr::Gate(gate) => { + // Gate touches qubits - collect allocators + self.collect_allocators_from_expr(&gate.target, resources); + } + _ => {} + } + } + + fn next_id(&mut self) -> usize { + let id = self.next_id; + self.next_id += 1; + id + } +} + +// ============================================================================= +// Pass 3: Dependency Graph +// ============================================================================= + +/// The kind of dependency between operations. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DepKind { + /// Both operations touch the same qubit allocator + QubitDep(String), + /// One operation reads a variable written by another + DataDep(String), + /// Control flow dependency + ControlDep, +} + +/// An edge in the dependency graph. +#[derive(Debug, Clone)] +pub struct DepEdge { + /// Source operation ID + pub from: usize, + /// Target operation ID + pub to: usize, + /// Kind of dependency + pub kind: DepKind, +} + +/// Dependency graph for operations. +#[derive(Debug, Default)] +pub struct DependencyGraph { + /// All operations + pub operations: Vec, + /// Dependency edges (from -> to means "from must complete before to") + pub edges: Vec, +} + +impl DependencyGraph { + /// Build a dependency graph from tagged operations. + pub fn build(operations: Vec) -> Self { + let mut graph = Self { + operations, + edges: Vec::new(), + }; + + graph.compute_dependencies(); + graph + } + + fn compute_dependencies(&mut self) { + // For each pair of operations, check for dependencies + for i in 0..self.operations.len() { + for j in (i + 1)..self.operations.len() { + if let Some(kind) = self.check_dependency(i, j) { + self.edges.push(DepEdge { + from: i, + to: j, + kind, + }); + } + } + } + } + + fn check_dependency(&self, earlier: usize, later: usize) -> Option { + let op1 = &self.operations[earlier]; + let op2 = &self.operations[later]; + + // Check for qubit dependencies (WAW, RAW, WAR on allocators) + for res in &op1.writes { + if let Resource::Allocator(name) = res + && (op2.reads.contains(res) || op2.writes.contains(res)) + { + return Some(DepKind::QubitDep(name.clone())); + } + } + for res in &op1.reads { + if let Resource::Allocator(name) = res + && op2.writes.contains(res) + { + return Some(DepKind::QubitDep(name.clone())); + } + } + + // Check for data dependencies (classical variables) + for res in &op1.writes { + if let Resource::Variable(name) = res + && op2.reads.contains(res) + { + return Some(DepKind::DataDep(name.clone())); + } + } + + None + } + + /// Extract parallel layers - operations at the same layer can execute in parallel. + pub fn parallel_layers(&self) -> Vec> { + if self.operations.is_empty() { + return vec![]; + } + + // Compute the "level" of each operation (longest path from any root) + let mut levels = vec![0usize; self.operations.len()]; + let mut predecessors: Vec> = vec![vec![]; self.operations.len()]; + + // Build predecessor list + for edge in &self.edges { + predecessors[edge.to].push(edge.from); + } + + // Compute levels (topological order with level assignment) + let mut changed = true; + while changed { + changed = false; + for i in 0..self.operations.len() { + let max_pred_level = predecessors[i] + .iter() + .map(|&p| levels[p]) + .max() + .unwrap_or(0); + let new_level = if predecessors[i].is_empty() { + 0 + } else { + max_pred_level + 1 + }; + if new_level > levels[i] { + levels[i] = new_level; + changed = true; + } + } + } + + // Group operations by level + let max_level = levels.iter().copied().max().unwrap_or(0); + let mut layers: Vec> = vec![vec![]; max_level + 1]; + for (op_id, &level) in levels.iter().enumerate() { + layers[level].push(op_id); + } + + layers + } + + /// Get operations that can parallelize with a given operation. + pub fn independent_ops(&self, op_id: usize) -> Vec { + let mut dependent: BTreeSet = BTreeSet::new(); + + // Find all operations connected by edges + for edge in &self.edges { + if edge.from == op_id { + dependent.insert(edge.to); + } + if edge.to == op_id { + dependent.insert(edge.from); + } + } + + // Return operations not in dependent set + (0..self.operations.len()) + .filter(|&id| id != op_id && !dependent.contains(&id)) + .collect() + } + + /// Print the dependency graph for debugging. + pub fn debug_print(&self) { + println!("Operations:"); + for op in &self.operations { + let reads: Vec<_> = op.reads.iter().map(|r| format!("{:?}", r)).collect(); + let writes: Vec<_> = op.writes.iter().map(|r| format!("{:?}", r)).collect(); + println!( + " [{}] {} (line {}) reads: {:?}, writes: {:?}", + op.id, op.description, op.line, reads, writes + ); + } + + println!("\nDependencies:"); + for edge in &self.edges { + println!(" {} -> {} ({:?})", edge.from, edge.to, edge.kind); + } + + println!("\nParallel Layers:"); + for (level, layer) in self.parallel_layers().iter().enumerate() { + let descs: Vec<_> = layer + .iter() + .map(|&id| format!("[{}]{}", id, self.operations[id].description)) + .collect(); + println!(" Layer {}: {:?}", level, descs); + } + } +} + +// ============================================================================= +// Analysis Summary +// ============================================================================= + +/// Summary of parallelism analysis for a function. +#[derive(Debug)] +pub struct ParallelismSummary { + /// Function name + pub function_name: String, + /// Total operations + pub total_ops: usize, + /// Number of parallel layers + pub num_layers: usize, + /// Maximum parallelism (largest layer) + pub max_parallelism: usize, + /// Number of purely classical operations + pub classical_ops: usize, + /// Number of quantum operations + pub quantum_ops: usize, +} + +/// Analyze parallelism in a program. +pub fn analyze_parallelism(program: &Program) -> Vec { + let mut summaries = vec![]; + + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl { + let tagger = OperationTagger::tag(&Program { + name: program.name.clone(), + declarations: vec![TopLevelDecl::Fn(fn_decl.clone())], + location: None, + }); + + let graph = DependencyGraph::build(tagger.operations); + let layers = graph.parallel_layers(); + + let classical_ops = graph.operations.iter().filter(|op| op.is_classical()).count(); + let quantum_ops = graph.operations.iter().filter(|op| op.touches_qubits()).count(); + + summaries.push(ParallelismSummary { + function_name: fn_decl.name.clone(), + total_ops: graph.operations.len(), + num_layers: layers.len(), + max_parallelism: layers.iter().map(|l| l.len()).max().unwrap_or(0), + classical_ops, + quantum_ops, + }); + } + } + + summaries +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_and_analyze(source: &str) -> DependencyGraph { + let program = crate::parse(source).expect("parse failed"); + let tagger = OperationTagger::tag(&program); + DependencyGraph::build(tagger.operations) + } + + #[test] + fn test_gates_same_allocator() { + // Gates on the same allocator are treated as dependent (conservative). + // This is allocator-level tracking, not qubit-index-level. + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + h q[0]; + h q[1]; + h q[2]; + h q[3]; + return; + } + "#; + + let graph = parse_and_analyze(source); + + // We should have: qalloc binding, 4 H gates, return + assert_eq!(graph.operations.len(), 6); + + // All H gates touch the same allocator "q", so they're serialized + let h_ops: Vec<_> = graph + .operations + .iter() + .filter(|op| op.description.contains("H")) + .collect(); + assert_eq!(h_ops.len(), 4); + + // All should write to allocator "q" + for op in &h_ops { + assert!( + op.writes.contains(&Resource::allocator("q")), + "H gate should touch allocator q" + ); + } + } + + #[test] + fn test_dependent_gates() { + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return; + } + "#; + + let graph = parse_and_analyze(source); + + // We should have: qalloc binding, H gate, CX gate, return + assert_eq!(graph.operations.len(), 4, "Expected 4 operations"); + + // H and CX both touch allocator "q", so they're dependent + let h_op = graph.operations.iter().find(|op| op.description.contains("H")); + let cx_op = graph.operations.iter().find(|op| op.description.contains("CX")); + + assert!(h_op.is_some(), "Should have H gate"); + assert!(cx_op.is_some(), "Should have CX gate"); + + // Both should touch allocator "q" + assert!( + h_op.unwrap().writes.contains(&Resource::allocator("q")), + "H should touch q" + ); + assert!( + cx_op.unwrap().writes.contains(&Resource::allocator("q")), + "CX should touch q" + ); + } + + #[test] + fn test_disjoint_allocators() { + let source = r#" + fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + h q1[0]; + h q2[0]; + cx (q1[0], q1[1]); + cx (q2[0], q2[1]); + return; + } + "#; + + let graph = parse_and_analyze(source); + + // Operations on q1 and q2 should be independent + // Find the H gates + let h_ops: Vec<_> = graph + .operations + .iter() + .enumerate() + .filter(|(_, op)| op.description.contains("H")) + .map(|(id, _)| id) + .collect(); + + assert_eq!(h_ops.len(), 2); + + // Check that the two H gates are independent of each other + let independent_of_first = graph.independent_ops(h_ops[0]); + assert!( + independent_of_first.contains(&h_ops[1]), + "H gates on different allocators should be independent" + ); + } + + #[test] + fn test_classical_quantum_independence() { + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + x := 1 + 2; + h q[0]; + y := x * 3; + cx (q[0], q[1]); + return; + } + "#; + + let graph = parse_and_analyze(source); + + // Classical operations (x, y bindings) should be independent of quantum ops + let classical: Vec<_> = graph + .operations + .iter() + .filter(|op| op.is_classical() && op.description.starts_with("bind")) + .collect(); + + let quantum: Vec<_> = graph + .operations + .iter() + .filter(|op| op.touches_qubits()) + .collect(); + + assert!(!classical.is_empty(), "Should have classical ops"); + assert!(!quantum.is_empty(), "Should have quantum ops"); + } + + #[test] + fn test_allocator_analysis() { + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + mut ancilla := qalloc(2); + h q[0]; + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + assert!(analysis.allocators.contains_key("q")); + assert!(analysis.allocators.contains_key("ancilla")); + assert_eq!(analysis.allocators["q"].size, Some(4)); + assert_eq!(analysis.allocators["ancilla"].size, Some(2)); + } + + #[test] + fn test_measurement_creates_dependency() { + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + h q[0]; + m := mz([2]u1) q; + return; + } + "#; + + let graph = parse_and_analyze(source); + + // The measure is part of a binding (m := mz...), so it's recorded as "bind m" + // but should still track that it reads from allocator q + let m_binding = graph.operations.iter().find(|op| op.description == "bind m"); + assert!(m_binding.is_some(), "Should have binding for m"); + + // The binding should read from allocator "q" (via the measure expression) + // Note: Currently we track mz as reading q through collect_expr_resources + // but we need to enhance the binding handler to detect measure expressions + } + + #[test] + fn test_parallel_layers_correct() { + let source = r#" + fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + h q1[0]; + h q2[0]; + return; + } + "#; + + let graph = parse_and_analyze(source); + let layers = graph.parallel_layers(); + + // Layer 0: Both qalloc bindings (parallel - different variables) + // Layer 1: Both H gates (parallel - different allocators) + // Layer 2: return + + // The H gates should be in the same layer since they're independent + let h_ops: Vec = graph + .operations + .iter() + .enumerate() + .filter(|(_, op)| op.description.contains("H")) + .map(|(id, _)| id) + .collect(); + + assert_eq!(h_ops.len(), 2); + + // Find which layer contains the H gates + let h_layer = layers.iter().find(|layer| layer.contains(&h_ops[0])); + assert!(h_layer.is_some()); + assert!( + h_layer.unwrap().contains(&h_ops[1]), + "Both H gates should be in the same layer (parallel)" + ); + } + + #[test] + fn test_data_dependency_classical() { + let source = r#" + fn main() -> unit { + x := 1; + y := x + 1; + z := y + 1; + return; + } + "#; + + let graph = parse_and_analyze(source); + + // x, y, z form a chain of data dependencies + // y depends on x, z depends on y + let layers = graph.parallel_layers(); + + // Each binding should be in a different layer due to data dependencies + // (x, return could be parallel with others if no dep, but y needs x, z needs y) + assert!(layers.len() >= 3, "Should have at least 3 layers for x->y->z chain"); + } + + #[test] + fn test_function_parameter_allocator() { + let source = r#" + fn apply_h(q: [4]qubit) -> unit { + h q[0]; + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + // Parameter q should be detected as an allocator + assert!( + analysis.allocators.contains_key("q"), + "Parameter q should be detected as allocator" + ); + } + + #[test] + fn test_debug_print() { + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return; + } + "#; + + let graph = parse_and_analyze(source); + + // Just verify debug_print doesn't panic + graph.debug_print(); + } + + #[test] + fn test_parallelism_summary() { + let source = r#" + fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + h q1[0]; + h q2[0]; + x := 1 + 2; + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let summaries = super::analyze_parallelism(&program); + + assert_eq!(summaries.len(), 1); + let summary = &summaries[0]; + assert_eq!(summary.function_name, "main"); + assert!(summary.total_ops > 0); + assert!(summary.quantum_ops >= 2, "Should have at least 2 quantum ops (H gates)"); + assert!(summary.classical_ops >= 1, "Should have at least 1 classical op (x binding)"); + } + + #[test] + fn test_empty_function() { + let source = r#" + fn empty() -> unit { + return; + } + "#; + + let graph = parse_and_analyze(source); + + // Should have just the return operation + assert!(graph.operations.len() <= 1, "Empty function should have minimal ops"); + let layers = graph.parallel_layers(); + assert!(layers.len() <= 1, "Empty function should have at most 1 layer"); + } + + #[test] + fn test_purely_classical_function() { + let source = r#" + fn classical() -> i32 { + a := 1; + b := 2; + c := a + b; + return c; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let summaries = super::analyze_parallelism(&program); + + assert_eq!(summaries.len(), 1); + let summary = &summaries[0]; + assert_eq!(summary.quantum_ops, 0, "Should have no quantum ops"); + assert!(summary.classical_ops > 0, "Should have classical ops"); + } + + #[test] + fn test_nested_scopes() { + let source = r#" + fn nested() -> unit { + mut q := qalloc(2); + { + h q[0]; + { + cx (q[0], q[1]); + } + } + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + // Allocator q should still be tracked despite nested scopes + assert!(analysis.allocators.contains_key("q")); + assert_eq!(analysis.allocators["q"].size, Some(2)); + } + + #[test] + fn test_multiple_functions() { + let source = r#" + fn func1() -> unit { + mut q := qalloc(2); + h q[0]; + return; + } + + fn func2() -> unit { + mut r := qalloc(3); + h r[0]; + h r[1]; + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let summaries = super::analyze_parallelism(&program); + + assert_eq!(summaries.len(), 2, "Should analyze both functions"); + + let func1 = summaries.iter().find(|s| s.function_name == "func1"); + let func2 = summaries.iter().find(|s| s.function_name == "func2"); + + assert!(func1.is_some(), "Should have func1 summary"); + assert!(func2.is_some(), "Should have func2 summary"); + + // func2 has more H gates + assert!(func2.unwrap().quantum_ops >= func1.unwrap().quantum_ops); + } + + #[test] + fn test_if_statement_analysis() { + let source = r#" + fn conditional(cond: bool) -> unit { + mut q := qalloc(2); + if cond { + h q[0]; + } else { + x q[0]; + } + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + // Allocator should be tracked even inside conditionals + assert!(analysis.allocators.contains_key("q")); + } + + #[test] + fn test_for_loop_analysis() { + let source = r#" + fn looped() -> unit { + mut q := qalloc(4); + for i in 0..4 { + h q[i]; + } + return; + } + "#; + + let program = crate::parse(source).expect("parse failed"); + let analysis = AllocatorAnalysis::analyze(&program); + + assert!(analysis.allocators.contains_key("q")); + assert_eq!(analysis.allocators["q"].size, Some(4)); + } +} diff --git a/exp/zlup/src/ast.rs b/exp/zlup/src/ast.rs new file mode 100644 index 000000000..4565a3a40 --- /dev/null +++ b/exp/zlup/src/ast.rs @@ -0,0 +1,1554 @@ +//! AST node definitions for Zluppy programs. +//! +//! This module defines the Abstract Syntax Tree (AST) nodes for representing +//! Zluppy quantum programs. The design mirrors the Python SLR-AST for easy +//! conversion while supporting Zig-inspired language features. +//! +//! Design principles: +//! - Immutable data structures +//! - Explicit source location tracking +//! - Direct mapping to SLR-AST where applicable +//! - Support for comptime evaluation + +use serde::{Deserialize, Serialize}; +use std::fmt; + +// ============================================================================= +// Source Location +// ============================================================================= + +/// Source location for error reporting. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct SourceLocation { + pub line: u32, + pub column: u32, + pub end_line: u32, + pub end_column: u32, + pub file: Option, +} + +impl SourceLocation { + pub fn new(line: u32, column: u32) -> Self { + Self { + line, + column, + end_line: line, + end_column: column + 1, + file: None, + } + } + + pub fn with_end(line: u32, column: u32, end_line: u32, end_column: u32) -> Self { + Self { + line, + column, + end_line, + end_column, + file: None, + } + } + + pub fn with_file(line: u32, column: u32, file: impl Into) -> Self { + Self { + line, + column, + end_line: line, + end_column: column + 1, + file: Some(file.into()), + } + } +} + +impl fmt::Display for SourceLocation { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.file { + Some(file) => write!(f, "{}:{}:{}", file, self.line, self.column), + None => write!(f, "{}:{}", self.line, self.column), + } + } +} + +// ============================================================================= +// Attributes (Metadata) +// ============================================================================= + +/// Attribute value types. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub enum AttributeValue { + /// Boolean flag: @attr(noisy, true) + Bool(bool), + /// Integer value: @attr(round, 0) + Int(i64), + /// Float value: @attr(error_rate, 0.001) + Float(f64), + /// String value: @attr(kind, "syndrome") + String(String), + /// Identifier value: @attr(gate_type, Hadamard) + Ident(String), +} + +/// An attribute attached to a statement or construct. +/// Examples: @attr(round, 0), @attr(kind, "syndrome"), @attrs({round: 0, kind: "syndrome"}) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Attribute { + /// Attribute name (e.g., "round", "type", "noisy") + pub name: String, + /// Attribute value (None for boolean flags like @noisy) + pub value: Option, + pub location: Option, +} + +impl Attribute { + /// Create a boolean flag attribute (e.g., @noisy) + pub fn flag(name: impl Into) -> Self { + Self { + name: name.into(), + value: None, + location: None, + } + } + + /// Create an attribute with a value + pub fn with_value(name: impl Into, value: AttributeValue) -> Self { + Self { + name: name.into(), + value: Some(value), + location: None, + } + } +} + +// ============================================================================= +// Program +// ============================================================================= + +/// Root node representing a Zluppy program. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Program { + pub name: String, + pub declarations: Vec, + pub location: Option, +} + +/// Top-level declarations. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TopLevelDecl { + Binding(Binding), + Fn(FnDecl), + ExternFn(ExternFnDecl), + Struct(StructDecl), + Enum(EnumDecl), + Union(UnionDecl), + ErrorSet(ErrorSetDecl), + FaultSet(FaultSetDecl), + Test(TestDecl), + DeclareGate(TargetGateDecl), + Gate(CompositeGateDecl), +} + +// ============================================================================= +// Declarations +// ============================================================================= + +/// Binding declaration (unified const/var with Pascal/Go syntax) +/// Immutable: `x := value;` or `x: T = value;` +/// Mutable: `mut x := value;` or `mut x: T = value;` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Binding { + pub name: String, + pub ty: Option, + pub value: Option, // None means undefined + pub is_mutable: bool, // true if `mut` keyword present + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Alias binding - creates a named view into existing data. +/// `alias name := slice_expr;` +/// +/// Aliases are immutable views with overlap checking. +/// Unlike regular bindings, aliases track their source for overlap detection. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AliasBinding { + /// Name of the alias + pub name: String, + /// The source expression (must be a slice/range expression) + pub source: Expr, + /// Source location + pub location: Option, +} + +/// Function declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FnDecl { + pub name: String, + pub params: Vec, + pub return_type: Option, + pub body: Block, + pub is_pub: bool, + pub is_inline: bool, + /// Error handling mode for the function body. + /// `try` = collect all errors (QEC pattern) + /// `try!` = stop on first error (traditional) + /// None = no automatic error handling + pub error_mode: Option, + pub doc_comment: Option, + pub location: Option, +} + +/// Function parameter. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Param { + pub name: String, + pub ty: TypeExpr, + pub is_comptime: bool, + pub location: Option, +} + +/// External function declaration (FFI). +/// `@link("libdecoder") extern "C" fn decode(data: [*]u8, len: usize) -> i32;` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExternFnDecl { + pub name: String, + /// Library to link against (e.g., "libdecoder", "pecos_runtime") + pub library: Option, + /// Calling convention (e.g., "C", "Rust") + pub calling_convention: String, + pub params: Vec, + pub return_type: Option, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Struct declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructDecl { + pub name: String, + pub fields: Vec, + pub methods: Vec, + /// Associated constants defined within the struct + pub associated_consts: Vec, + pub is_pub: bool, + pub is_packed: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Struct field. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructField { + pub name: String, + pub ty: TypeExpr, + pub default: Option, + pub doc_comment: Option, + pub location: Option, +} + +/// Enum declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnumDecl { + pub name: String, + pub tag_type: Option, + pub variants: Vec, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Enum variant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnumVariant { + pub name: String, + pub value: Option, + pub location: Option, +} + +/// Union declaration (tagged union / sum type). +/// Example: `const Value = union(enum) { Int: i32, Float: f64, None }` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnionDecl { + pub name: String, + /// The tag type: None = untagged, Some(None) = auto-tagged (enum), Some(Some(ty)) = external tag + pub tag: Option>, + pub fields: Vec, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Union field (variant with optional payload type). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnionField { + pub name: String, + /// The payload type for this variant. None = no payload (like an enum variant) + pub ty: Option, + pub location: Option, +} + +/// Error set declaration - classical/logical errors that crash if unhandled. +/// `DecodeError := error { SyndromeAmbiguous, WeightTooHigh };` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorSetDecl { + pub name: String, + /// The error variants in this set + pub variants: Vec, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Fault set declaration - quantum/physical faults, collected in try blocks. +/// `QuantumFault := fault { Leakage, QubitLoss, GateFailure };` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaultSetDecl { + pub name: String, + /// The fault variants in this set + pub variants: Vec, // Reuse ErrorVariant structure + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// An error/fault variant within an error or fault set. +/// Can optionally have associated data: `Leakage: struct { gate: []const u8, qubit: usize }` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorVariant { + pub name: String, + /// Optional associated data type + pub data_type: Option, + pub location: Option, +} + +/// Test declaration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TestDecl { + pub name: String, + pub body: Block, + pub location: Option, +} + +// ============================================================================= +// Custom Gate Declarations +// ============================================================================= + +/// A gate parameter (angle or classical parameter). +/// Example: `theta` in `declare gate rx(theta)(q);` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateParam { + pub name: String, + pub ty: Option, + pub location: Option, +} + +/// A qubit parameter for gate declarations. +/// Example: `q` in `declare gate h()(q);` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct QubitParam { + pub name: String, + pub location: Option, +} + +/// Target gate declaration — declares a gate that will be provided by the backend. +/// Example: `declare gate rx(theta: a64)(q);` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TargetGateDecl { + pub name: String, + pub params: Vec, + pub qubits: Vec, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +/// Composite gate declaration — a gate defined in terms of other gates. +/// Example: `gate bell()(q0, q1) { h q0; cx (q0, q1); }` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CompositeGateDecl { + pub name: String, + pub params: Vec, + pub qubits: Vec, + pub body: Block, + pub is_pub: bool, + pub doc_comment: Option, + pub location: Option, +} + +// ============================================================================= +// Allocator Declarations (Quantum-specific) +// ============================================================================= + +/// Qubit allocator declaration (maps to SLR AllocatorDecl). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AllocatorDecl { + pub name: String, + pub capacity: u32, + pub parent: Option, + pub location: Option, +} + +/// Classical register declaration (maps to SLR RegisterDecl). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterDecl { + pub name: String, + pub size: u32, + pub is_result: bool, + pub location: Option, +} + +// ============================================================================= +// Statements +// ============================================================================= + +/// Statement types. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Stmt { + Binding(Binding), + Alias(AliasBinding), + Assign(AssignStmt), + If(IfStmt), + For(ForStmt), + Switch(SwitchStmt), + Tick(TickStmt), + TryBlock(TryBlockStmt), + Return(ReturnStmt), + Break(BreakStmt), + Continue(ContinueStmt), + Defer(DeferStmt), + Errdefer(ErrDeferStmt), + Block(Block), + Expr(ExprStmt), + + // Quantum operations (map directly to SLR) + Gate(GateOp), + Prepare(PrepareOp), + Measure(MeasureOp), + Barrier(BarrierOp), +} + +/// Try block statement for error handling. +/// `try { }` - collect all errors (QEC pattern), returns []E!T +/// `try! { }` - stop on first error (traditional), returns E!T +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TryBlockStmt { + /// The error handling mode + pub mode: TryMode, + /// The block body + pub body: Block, + /// Optional catch clause: catch |err| { ... } + pub catch_clause: Option, + pub location: Option, +} + +/// Error handling mode for try blocks and functions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TryMode { + /// `try` - collect all errors, continue executing (QEC pattern) + Collect, + /// `try!` - stop on first error, propagate immediately (traditional) + Propagate, +} + +/// Catch clause for try blocks. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatchClause { + /// The error capture variable name + pub capture: String, + /// The catch body (block or expression) + pub body: Expr, + pub location: Option, +} + +/// Tick statement - a time slice of parallel quantum gates. +/// Maps to PECOS TickCircuit's tick concept. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TickStmt { + /// Optional label for the tick (for QEC rounds, debugging, etc.) + pub label: Option, + /// Attributes attached to this tick (e.g., @round(0), @type("syndrome")) + /// Using Vec to preserve order of declaration + pub attrs: Vec, + /// Statements within this tick (gates execute in parallel) + pub body: Vec, + pub location: Option, +} + +/// Assignment statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssignStmt { + pub target: Expr, // LValue + pub op: AssignOp, + pub value: Expr, + pub location: Option, +} + +/// Assignment operators. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AssignOp { + Assign, // = + AddAssign, // += + SubAssign, // -= + MulAssign, // *= + DivAssign, // /= + AndAssign, // &= + OrAssign, // |= + XorAssign, // ^= +} + +/// If statement. +/// Supports optional unwrapping: if (opt) |value| { ... } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IfStmt { + pub condition: Expr, + /// Optional capture variable for unwrapping optionals: if (opt) |value| { ... } + pub capture: Option, + pub then_body: Block, + pub else_body: Option, + pub location: Option, +} + +/// Else branch (can be else-if or else). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ElseBranch { + ElseIf(Box), + Else(Block), +} + +/// For statement (bounded iteration - NASA Power of 10 compliant). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ForStmt { + pub label: Option, + pub is_inline: bool, + pub range: ForRange, + pub captures: Vec, + pub body: Block, + pub location: Option, +} + +/// For loop range. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ForRange { + /// `0..n` or `start..end` + Range { start: Expr, end: Expr }, + /// Iterate over a collection + Collection(Expr), +} + +/// Switch statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwitchStmt { + pub value: Expr, + pub prongs: Vec, + pub location: Option, +} + +/// Switch prong. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwitchProng { + pub cases: Vec, + pub is_else: bool, + pub body: Expr, + pub location: Option, +} + +/// Switch case. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SwitchCase { + pub value: Expr, + pub end: Option, // For ranges + pub location: Option, +} + +/// Return statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReturnStmt { + pub value: Option, + pub location: Option, +} + +/// Break statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BreakStmt { + pub label: Option, + pub value: Option, + pub location: Option, +} + +/// Continue statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ContinueStmt { + pub label: Option, + pub location: Option, +} + +/// Defer statement. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeferStmt { + pub body: Box, + pub location: Option, +} + +/// Errdefer statement - executes on error return. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrDeferStmt { + pub body: Box, + /// Optional capture name for the error value: errdefer |err| { ... } + pub capture: Option, + pub location: Option, +} + +/// Block of statements. +/// Can have an optional trailing expression that is the block's return value (like Rust). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Block { + pub label: Option, + /// Attributes attached to this block (e.g., @attr(kind, "syndrome")) + pub attrs: Vec, + pub statements: Vec, + /// Optional trailing expression (block's return value) + pub trailing_expr: Option>, + pub location: Option, +} + +/// Expression statement. +/// Can have prefix attributes for annotating gate calls. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExprStmt { + pub expr: Expr, + /// Attributes attached to this statement (e.g., @syndrome("X") for gates) + pub attrs: Vec, + pub location: Option, +} + +// ============================================================================= +// Quantum Operations (map to SLR nodes) +// ============================================================================= + +/// Gate operation (maps to SLR GateOp). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateOp { + pub kind: GateKind, + pub targets: Vec, + pub params: Vec, + /// Attributes attached to this gate (e.g., @preserve, @round(0)) + pub attrs: Vec, + pub location: Option, +} + +/// Gate types (matches SLR GateKind). +/// Note: Gate names like SXX, RZZ use uppercase for clarity with quantum conventions. +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum GateKind { + // Single-qubit Paulis + X, + Y, + Z, + + // Hadamard + H, + + // T gates (fourth root of Z) + T, + Tdg, + + // Square root gates (SZ is the S gate / sqrt(Z)) + SX, + SY, + SZ, + SXdg, + SYdg, + SZdg, + + // Rotation gates (parameterized) + RX, + RY, + RZ, + + // Two-qubit gates + CX, + CY, + CZ, + CH, + SWAP, + ISWAP, + + // Two-qubit rotation gates + SXX, + SYY, + SZZ, + SXXdg, + SYYdg, + SZZdg, + RZZ, + + // Three-qubit gates + CCX, // Toffoli gate + + // Face rotations + F, + Fdg, + F4, + F4dg, + + // Prepare/reset operations (pz = prepare Z, reset to |0⟩) + PZ, +} + +impl GateKind { + /// Number of qubit arguments required. + pub fn arity(&self) -> usize { + use GateKind::*; + match self { + CCX => 3, + CX | CY | CZ | CH | SWAP | ISWAP | SXX | SYY | SZZ | SXXdg | SYYdg | SZZdg | RZZ => 2, + _ => 1, + } + } + + /// Whether this gate takes angle parameters. + pub fn is_parameterized(&self) -> bool { + use GateKind::*; + matches!(self, RX | RY | RZ | RZZ) + } + + /// Whether this gate is a preparation/reset operation. + /// PZ resets qubits to |0⟩ and can be applied to unprepared qubits. + pub fn is_prepare(&self) -> bool { + matches!(self, GateKind::PZ) + } +} + +/// Prepare operation (maps to SLR PrepareOp). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PrepareOp { + pub allocator: String, + pub slots: Option>, // None means prepare_all + pub location: Option, +} + +/// Measure operation (maps to SLR MeasureOp). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeasureOp { + pub targets: Vec, + pub results: Vec, + pub location: Option, +} + +/// Barrier operation (maps to SLR BarrierOp). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BarrierOp { + pub allocators: Vec, + pub location: Option, +} + +/// Reference to a qubit slot (maps to SLR SlotRef). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SlotRef { + pub allocator: String, + pub index: Box, // Can be comptime or runtime + pub location: Option, +} + +/// Reference to a classical bit (maps to SLR BitRef). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BitRef { + pub register: String, + pub index: Box, + pub location: Option, +} + +// ============================================================================= +// Expressions +// ============================================================================= + +/// Expression types. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Expr { + // Literals + IntLit(IntLit), + FloatLit(FloatLit), + AngleLit(Box), // 0.25 turns, pi/4 rad - angle with explicit unit + TypeAscription(Box), // 42 u32, 1/4 f64 - expression with type suffix + BoolLit(BoolLit), + StringLit(StringLit), + FString(Box), // f"Hello {name}!" - Python-style interpolation + CharLit(CharLit), + Null(NullLit), + Undefined(UndefinedLit), + Unit(UnitLit), + + // Identifiers and references + Ident(Ident), + SlotRef(Box), + BitRef(Box), + + // Operators + Binary(Box), + Unary(Box), + + // Access + Field(Box), + Index(Box), + Call(Box), + BatchApply(Box), // h { q[0], q[1] } - batch gate apply + + // Special + If(Box), + Block(Box), + Comptime(Box), + Builtin(Box), + AnonStruct(Box), // struct { x: i32, y: i32 } - anonymous struct type + StructInit(Box), + ArrayInit(Box), + BracketArray(Box), // [a, b, c] literal + Tuple(Box), // (a, b) tuple + Set(Box), // {a, b, c} set literal + Range(Box), + Measure(Box), // mz(T) targets - measurement + Gate(Box), // h q[0], rx(0.123) q[0] - quantum gate + + // Error/fault handling + ErrorValue(Box), // error.Name literal + FaultValue(Box), // fault.Name literal + Catch(Box), // a catch |err| b + TryBlock(Box), // try { } or try! { } as expression + + // Function literal (for comptime type constructors) + FnLit(Box), // fn(params) -> ret { body } + + // Result emission (program output channel - special, never elided) + Result(Box), // result("tag", value) - emit to caller + + // Side-channel communication (sticky/barrier semantics) + Channel(Box), // @emit.channel.command(...) - log, sim, hw, custom +} + +/// Try block as expression. +/// `errors := try { ... };` +/// `result := try! { ... } catch |err| { default };` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TryBlockExpr { + /// The error handling mode + pub mode: TryMode, + /// The block body + pub body: Block, + /// Optional catch clause + pub catch_clause: Option, + pub location: Option, +} + +impl Expr { + /// Get the source location of this expression, if available. + pub fn get_location(&self) -> Option { + match self { + Expr::IntLit(lit) => lit.location.clone(), + Expr::FloatLit(lit) => lit.location.clone(), + Expr::AngleLit(lit) => lit.location.clone(), + Expr::TypeAscription(asc) => asc.location.clone(), + Expr::BoolLit(lit) => lit.location.clone(), + Expr::StringLit(lit) => lit.location.clone(), + Expr::FString(fstr) => fstr.location.clone(), + Expr::CharLit(lit) => lit.location.clone(), + Expr::Null(lit) => lit.location.clone(), + Expr::Undefined(lit) => lit.location.clone(), + Expr::Unit(lit) => lit.location.clone(), + Expr::Ident(ident) => ident.location.clone(), + Expr::SlotRef(slot) => slot.location.clone(), + Expr::BitRef(bit) => bit.location.clone(), + Expr::Binary(binary) => binary.location.clone(), + Expr::Unary(unary) => unary.location.clone(), + Expr::Field(field) => field.location.clone(), + Expr::Index(index) => index.location.clone(), + Expr::Call(call) => call.location.clone(), + Expr::BatchApply(batch) => batch.location.clone(), + Expr::If(if_expr) => if_expr.location.clone(), + Expr::Block(block) => block.location.clone(), + Expr::Comptime(comptime) => comptime.location.clone(), + Expr::Builtin(builtin) => builtin.location.clone(), + Expr::AnonStruct(anon) => anon.location.clone(), + Expr::StructInit(init) => init.location.clone(), + Expr::ArrayInit(init) => init.location.clone(), + Expr::BracketArray(arr) => arr.location.clone(), + Expr::Tuple(tuple) => tuple.location.clone(), + Expr::Set(set) => set.location.clone(), + Expr::Range(range) => range.location.clone(), + Expr::Measure(measure) => measure.location.clone(), + Expr::Gate(gate) => gate.location.clone(), + Expr::ErrorValue(err) => err.location.clone(), + Expr::FaultValue(fault) => fault.location.clone(), + Expr::Catch(catch) => catch.location.clone(), + Expr::TryBlock(try_block) => try_block.location.clone(), + Expr::FnLit(func) => func.location.clone(), + Expr::Result(result) => result.location.clone(), + Expr::Channel(channel) => channel.location.clone(), + } + } +} + +/// Integer literal with optional type suffix. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IntLit { + pub value: i128, + /// Type suffix (e.g., "u32", "i8", "usize") + pub suffix: Option, + pub location: Option, +} + +/// Float literal with optional type suffix. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FloatLit { + pub value: f64, + /// Type suffix (e.g., "f32", "f64") + pub suffix: Option, + pub location: Option, +} + +/// Angle literal with explicit unit: `0.25 turns` or `pi/4 rad` +/// Units: turns (native, 1 turn = full rotation), rad (radians) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AngleLit { + /// The numeric value expression (can be a literal or arithmetic like pi/4) + pub value: Expr, + /// The angle unit + pub unit: AngleUnit, + pub location: Option, +} + +/// Type ascription: expression with explicit type suffix +/// Examples: `42 u32`, `1/4 f64`, `(a + b) i64` +/// Allows type annotation on expressions with space for readability. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypeAscription { + /// The expression to type + pub value: Expr, + /// The type name as a string (e.g., "u32", "f64", "a64") + pub type_name: String, + pub location: Option, +} + +/// Angle unit specifier +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum AngleUnit { + /// Turns - native unit, 1 turn = full rotation (360 degrees) + /// Common values: 0.25 = quarter turn, 0.5 = half turn, 0.125 = T gate + Turns, + /// Radians - mathematical convention + /// pi/2 = quarter turn, pi = half turn + Rad, +} + +impl AngleUnit { + /// Convert a value in this unit to turns (the native unit) + pub fn to_turns(&self, value: f64) -> f64 { + match self { + AngleUnit::Turns => value, + AngleUnit::Rad => value / (2.0 * std::f64::consts::PI), + } + } + + /// Convert a value in turns to this unit + pub fn from_turns(&self, turns: f64) -> f64 { + match self { + AngleUnit::Turns => turns, + AngleUnit::Rad => turns * 2.0 * std::f64::consts::PI, + } + } +} + +/// Boolean literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoolLit { + pub value: bool, + pub location: Option, +} + +/// String literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StringLit { + pub value: String, + pub location: Option, +} + +/// F-string (Python-style interpolated string): f"Hello {name}!" +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FStringExpr { + pub parts: Vec, + pub location: Option, +} + +/// A part of an f-string - either literal text or an interpolated expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum FStringPart { + /// Literal text portion + Text(String), + /// Interpolated expression with optional format spec: {expr} or {expr:.2f} + Expr { + expr: Expr, + /// Optional format specifier (e.g., ".2f", ">10", "08d") + format: Option, + }, +} + +/// Character literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CharLit { + pub value: char, + pub location: Option, +} + +/// Null literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NullLit { + pub location: Option, +} + +/// Undefined literal. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UndefinedLit { + pub location: Option, +} + +/// Unit literal - the single value of the unit type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnitLit { + pub location: Option, +} + +/// Identifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Ident { + pub name: String, + pub location: Option, +} + +/// Binary expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BinaryExpr { + pub op: BinaryOp, + pub left: Expr, + pub right: Expr, + pub location: Option, +} + +/// Binary operators (matches SLR BinaryOp). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum BinaryOp { + // Arithmetic + Add, + Sub, + Mul, + Div, + Mod, + + // Comparison + Eq, + Ne, + Lt, + Le, + Gt, + Ge, + + // Membership (for sets) + In, + NotIn, + + // Logical + And, + Or, + + // Optional + Orelse, // a orelse b - returns a if not null, else b + + // Error handling + Catch, // a catch |err| b - unwrap error union or handle error + + // Bitwise + BitAnd, + BitOr, + BitXor, + Shl, + Shr, +} + +/// Unary expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UnaryExpr { + pub op: UnaryOp, + pub operand: Expr, + pub location: Option, +} + +/// Unary operators (matches SLR UnaryOp). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum UnaryOp { + Neg, // - + Not, // ! + BitNot, // ~ + AddrOf, // & + Deref, // * + OptionalUnwrap, // .? + ErrorUnwrap, // .! + Try, // try - error propagation +} + +/// Field access expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldExpr { + pub object: Expr, + pub field: String, + pub location: Option, +} + +/// Index expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IndexExpr { + pub object: Expr, + pub index: Expr, + pub location: Option, +} + +/// Call expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CallExpr { + pub callee: Expr, + pub args: Vec, + pub location: Option, +} + +/// Batch apply expression: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } +/// For gates where application order doesn't matter (set semantics). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BatchApplyExpr { + /// The gate/operation being applied (may include params, e.g., rz(pi/4)) + pub operation: Expr, + /// The targets (qubits or qubit pairs) to apply to + pub targets: Vec, + pub location: Option, +} + +/// Measurement expression: mz(T) targets or mz(pack T) targets +/// +/// Per-qubit mode (pack=false): Each qubit produces one T, count must match exactly. +/// Pack mode (pack=true): Bits fill T sequentially, T must have enough capacity. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeasureExpr { + /// The result type (e.g., u1, [4]u1, u8, Syndrome) + pub result_type: TypeExpr, + /// Whether to pack bits into the type (vs per-qubit results) + pub pack: bool, + /// The targets to measure (array literal, variable, or slice) + pub targets: Expr, + pub location: Option, +} + +/// Gate expression: gate target or gate(params) target +/// Consistent DSL-like syntax for quantum gates. +/// +/// Examples: +/// - `h q[0]` - single qubit gate +/// - `cx (q[0], q[1])` - two-qubit gate with tuple +/// - `rx(0.123) q[0]` - parameterized gate +/// - `h {q[0], q[1]}` - batch apply (set semantics) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GateExpr { + /// The gate kind (H, X, CX, RZ, etc.) + pub kind: GateKind, + /// Parameters for parameterized gates (e.g., rotation angle) + pub params: Vec, + /// The target(s) - single qubit, tuple, array, or set + pub target: Expr, + pub location: Option, +} + +/// If expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IfExpr { + pub condition: Expr, + pub then_expr: Expr, + pub else_expr: Expr, + pub location: Option, +} + +/// Block expression (labeled block that returns a value). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BlockExpr { + pub label: String, + /// Attributes attached to this block (e.g., @attr(kind, "syndrome")) + pub attrs: Vec, + pub statements: Vec, + /// Optional trailing expression (block's return value) + pub trailing_expr: Option>, + pub location: Option, +} + +/// Comptime expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComptimeExpr { + pub inner: Expr, + pub location: Option, +} + +/// Builtin call (@import, @This, etc.). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuiltinExpr { + pub name: String, // Without the @ + pub args: Vec, + pub location: Option, +} + +/// Anonymous struct type definition. +/// `struct { x: i32, y: i32 }` creates an anonymous struct type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AnonStructExpr { + pub fields: Vec, + pub is_packed: bool, + pub location: Option, +} + +/// Struct initialization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StructInitExpr { + pub ty: Option, + pub fields: Vec, + pub location: Option, +} + +/// Field initializer. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldInit { + pub name: String, + pub value: Expr, + pub location: Option, +} + +/// Array initialization. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArrayInitExpr { + pub ty: Option, + pub elements: Vec, + pub location: Option, +} + +/// Bracket array literal: [a, b, c] +/// Used for batch quantum operations like h(&[q[0], q[1], q[2]]) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BracketArrayExpr { + pub elements: Vec, + pub location: Option, +} + +/// Tuple expression: (a, b) or (a, b, c) +/// Used for two-qubit gate pairs like cx(&[(q[0], q[1]), (q[2], q[3])]) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TupleExpr { + pub elements: Vec, + pub location: Option, +} + +/// Set expression: {a, b, c} +/// Unique unordered elements, backed by BTreeSet at runtime +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetExpr { + pub elements: Vec, + pub element_type: Option, // For empty_set: Set(T){} + pub location: Option, +} + +/// Range expression. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RangeExpr { + pub start: Option, + pub end: Option, + pub location: Option, +} + +/// Error value literal: error.OutOfMemory, error.InvalidArgument, etc. +/// Represents a specific error value from an error set. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorValueExpr { + pub name: String, + pub location: Option, +} + +/// Fault value literal: fault.Leakage, fault.QubitLoss, etc. +/// Represents a specific fault value from a fault set. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FaultValueExpr { + pub name: String, + pub location: Option, +} + +/// Catch expression: `expr catch |err| handler` +/// Unwraps an error union, returning the payload if successful, +/// or evaluating the handler with the error if it fails. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatchExpr { + pub operand: Expr, + /// Optional capture variable for the error value + pub capture: Option, + pub handler: Expr, + pub location: Option, +} + +// ============================================================================= +// Channel Expression (Unified Side-Channel Communication) +// ============================================================================= + +/// Channel expression for side-channel communication. +/// +/// Unified syntax: `@emit.channel.command(args)` +/// +/// All channel expressions use the `@emit` prefix and have sticky/barrier semantics. +/// The behavior (elision, barriers) depends on target and channel configuration. +/// +/// Built-in channels: +/// - `@emit.log.*` - Logging (trace, debug, info, warn, error, at) +/// - `@emit.sim.*` - Simulator control (send, noise_enable, noise_disable) +/// - `@emit.hw.*` - Hardware communication (send, calibrate, etc.) +/// +/// Custom channels can be defined for instrumentation, timing, debugging, etc. +/// +/// Examples: +/// - `@emit.log.trace(f"detailed message")` +/// - `@emit.log.debug("namespace", f"message")` +/// - `@emit.log.info(f"msg", data: obj)` +/// - `@emit.sim.send("seed", 42)` +/// - `@emit.sim.noise_disable()` +/// - `@emit.hw.send("calibration", params)` +/// - `@emit.timing.send("checkpoint", t)` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChannelExpr { + /// Channel name (log, sim, hw, timing, debug, etc.) + pub channel: String, + /// Command name (send, trace, debug, noise_enable, etc.) + pub command: String, + /// Arguments (positional and/or named) + pub args: Vec, + pub location: Option, +} + +/// Argument to a channel command. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ChannelArg { + /// Positional argument + Positional(Expr), + /// Named argument (name: value) + Named { name: String, value: Expr }, +} + +impl ChannelArg { + /// Get the expression value of this argument. + pub fn value(&self) -> &Expr { + match self { + ChannelArg::Positional(e) => e, + ChannelArg::Named { value, .. } => value, + } + } + + /// Get the name if this is a named argument. + pub fn name(&self) -> Option<&str> { + match self { + ChannelArg::Positional(_) => None, + ChannelArg::Named { name, .. } => Some(name), + } + } +} + +// ============================================================================= +// Result Expression (Program Output Channel) +// ============================================================================= + +/// Result emission expression. +/// +/// `result(tag, value)` emits a tagged value as program output. +/// This is the primary way to return structured data from quantum programs +/// back to the caller/orchestrator. Unlike logs, results are NEVER elided. +/// +/// Examples: +/// - `result("measurement", m)` - simple result +/// - `result("qec/syndrome", syndrome)` - namespaced with / convention +/// - `result("round_1/parity", parity)` - hierarchical naming +/// +/// The tag must be a compile-time string literal (like Guppy). +/// Value can be any serializable type: int, bool, float, arrays. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ResultExpr { + /// The tag/key for this result (compile-time string literal) + pub tag: String, + /// The value to emit + pub value: Expr, + pub location: Option, +} + +// ============================================================================= +// Types +// ============================================================================= + +/// Type expressions. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum TypeExpr { + // Primitive types + Primitive(PrimitiveType), + + // Quantum types + Qubit, + Bit, + QAlloc(Option>), // qalloc or qalloc(N) + + // Compound types + Array(Box), + Pointer(Box), + Optional(Box), + ErrorUnion(Box), // E!T - single error, either/or + CollectedErrors(Box), // []E!T - collected errors, both + Fn(Box), + Tuple(Vec), + Set(Box), // Set(T) - unordered unique elements + + // Inline/anonymous struct type: struct { x: i32, y: i32 } + Struct(Box), + // Inline/anonymous enum type: enum { a, b, c } + Enum(Box), + + // Named type + Named(TypePath), + + // Special + Type, // The type `type` + AnyType, // anytype for generic params + Unit, // unit type - has exactly one value +} + +/// Primitive types. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum PrimitiveType { + // Arbitrary-width integers (like Zig: u1, u4, u7, u128, etc.) + UInt { bits: u16 }, // Unsigned integer with N bits + IInt { bits: u16 }, // Signed integer with N bits + Usize, // Platform-dependent unsigned size + Isize, // Platform-dependent signed size + // Floating point + F16, + F32, + F64, + F128, + A64, // Angle type (64-bit, maps to PECOS Angle64) + Bool, +} + +/// Array type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArrayType { + pub element: TypeExpr, + pub size: Option, // None for slices + pub sentinel: Option, +} + +/// Pointer type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PointerType { + pub pointee: TypeExpr, + pub is_const: bool, + pub is_many: bool, // [*] vs * + pub sentinel: Option, +} + +/// Error union type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ErrorUnionType { + pub error_type: TypeExpr, + pub payload_type: TypeExpr, +} + +/// Collected errors type: []E!T +/// Represents "array of error E, with value T" (both, not either/or). +/// Used for QEC-style error collection where all operations execute +/// and errors are collected rather than stopping on first error. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CollectedErrorsType { + /// The error type (e.g., QuantumError) + pub error_type: TypeExpr, + /// The payload type (e.g., void or u1) + pub payload_type: TypeExpr, +} + +/// Function type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FnType { + pub params: Vec, + pub return_type: Option, +} + +/// Inline/anonymous struct type: struct { x: i32, y: i32 } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlineStructType { + pub fields: Vec, + pub is_packed: bool, +} + +/// Inline/anonymous enum type: enum { a, b, c } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InlineEnumType { + pub variants: Vec, + pub tag_type: Option, +} + +/// Type path (e.g., `std.mem.Allocator`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypePath { + pub segments: Vec, + pub location: Option, +} + +// ============================================================================= +// Convenience Implementations +// ============================================================================= + +impl From<&str> for Ident { + fn from(name: &str) -> Self { + Ident { + name: name.to_string(), + location: None, + } + } +} + +impl From for Expr { + fn from(value: i128) -> Self { + Expr::IntLit(IntLit { + value, + suffix: None, + location: None, + }) + } +} + +impl From for Expr { + fn from(value: f64) -> Self { + Expr::FloatLit(FloatLit { + value, + suffix: None, + location: None, + }) + } +} + +impl From for Expr { + fn from(value: bool) -> Self { + Expr::BoolLit(BoolLit { + value, + location: None, + }) + } +} + +impl From for Expr { + fn from(value: String) -> Self { + Expr::StringLit(StringLit { + value, + location: None, + }) + } +} diff --git a/exp/zlup/src/build.rs b/exp/zlup/src/build.rs new file mode 100644 index 000000000..b5de31e2c --- /dev/null +++ b/exp/zlup/src/build.rs @@ -0,0 +1,727 @@ +//! Build system for Zlup projects. +//! +//! This module implements the build.zlp execution system, following Zig's philosophy: +//! **the build system IS the language**. +//! +//! ## Overview +//! +//! Like Zig's `build.zig`, Zlup uses `build.zlp` - a Zlup program that runs at +//! compile time to configure the build. No separate DSL, no YAML, no TOML for +//! build logic - just Zlup with comptime. +//! +//! ## Usage +//! +//! ```bash +//! # Build using build.zlp +//! zlup build +//! +//! # Build with options +//! zlup build -Dnoise=true -Doptimize=release +//! +//! # Run tests +//! zlup build test +//! ``` +//! +//! ## Status +//! +//! This is Phase 1 of the build system implementation, providing the foundational +//! infrastructure. Full comptime execution of build.zlp requires additional +//! interpreter capabilities. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +// ============================================================================= +// Build Configuration +// ============================================================================= + +/// Target operating system. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Os { + Linux, + MacOS, + Windows, + FreeBSD, + Native, +} + +impl Default for Os { + fn default() -> Self { + #[cfg(target_os = "linux")] + return Os::Linux; + #[cfg(target_os = "macos")] + return Os::MacOS; + #[cfg(target_os = "windows")] + return Os::Windows; + #[cfg(target_os = "freebsd")] + return Os::FreeBSD; + #[cfg(not(any( + target_os = "linux", + target_os = "macos", + target_os = "windows", + target_os = "freebsd" + )))] + return Os::Native; + } +} + +/// Target CPU architecture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Arch { + X86_64, + Aarch64, + Arm, + Wasm32, + Native, +} + +impl Default for Arch { + fn default() -> Self { + #[cfg(target_arch = "x86_64")] + return Arch::X86_64; + #[cfg(target_arch = "aarch64")] + return Arch::Aarch64; + #[cfg(target_arch = "arm")] + return Arch::Arm; + #[cfg(target_arch = "wasm32")] + return Arch::Wasm32; + #[cfg(not(any( + target_arch = "x86_64", + target_arch = "aarch64", + target_arch = "arm", + target_arch = "wasm32" + )))] + return Arch::Native; + } +} + +/// Build target specification. +#[derive(Debug, Clone, Default)] +pub struct Target { + pub os: Os, + pub arch: Arch, +} + +impl Target { + /// Create a native target (current platform). + pub fn native() -> Self { + Self::default() + } + + /// Create a specific target. + pub fn new(os: Os, arch: Arch) -> Self { + Self { os, arch } + } +} + +/// Optimization level. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Optimize { + /// No optimization, fastest compilation + #[default] + Debug, + /// Optimize for speed + ReleaseFast, + /// Optimize for size + ReleaseSmall, + /// Optimize for safety (bounds checks, etc.) + ReleaseSafe, +} + +// ============================================================================= +// Build Artifacts +// ============================================================================= + +/// Unique identifier for a build step. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct StepId(usize); + +/// A build step that can be executed. +#[derive(Debug, Clone)] +pub struct Step { + pub id: StepId, + pub name: String, + pub description: String, + pub dependencies: Vec, +} + +/// Options for creating an executable. +#[derive(Debug, Clone, Default)] +pub struct ExecutableOptions { + pub name: String, + pub root_source: PathBuf, + pub target: Option, + pub optimize: Option, + /// NASA Power of 10 strict mode + pub strict: bool, +} + +/// Options for creating a library. +#[derive(Debug, Clone, Default)] +pub struct LibraryOptions { + pub name: String, + pub root_source: PathBuf, + pub target: Option, + pub optimize: Option, + pub strict: bool, +} + +/// Options for creating a test. +#[derive(Debug, Clone, Default)] +pub struct TestOptions { + pub root_source: PathBuf, + pub strict: bool, +} + +/// An executable artifact. +#[derive(Debug, Clone)] +pub struct Executable { + pub name: String, + pub root_source: PathBuf, + pub target: Target, + pub optimize: Optimize, + pub strict: bool, + pub defines: BTreeMap, + pub libraries: Vec, + pub library_paths: Vec, + pub step: StepId, +} + +/// A library artifact. +#[derive(Debug, Clone)] +pub struct Library { + pub name: String, + pub root_source: PathBuf, + pub target: Target, + pub optimize: Optimize, + pub strict: bool, + pub step: StepId, +} + +/// A test artifact. +#[derive(Debug, Clone)] +pub struct Test { + pub root_source: PathBuf, + pub strict: bool, + pub step: StepId, +} + +// ============================================================================= +// Build Context +// ============================================================================= + +/// The main build context. +/// +/// This is passed to the `build` function in `build.zlp` and provides +/// the API for configuring the build. +#[derive(Debug)] +pub struct Build { + /// Project root directory + project_root: PathBuf, + /// Default target + default_target: Target, + /// Default optimization level + default_optimize: Optimize, + /// Named build steps + steps: Vec, + /// Executables to build + executables: Vec, + /// Libraries to build + libraries: Vec, + /// Tests to run + tests: Vec, + /// User-defined options from command line + options: BTreeMap, + /// Install directory + install_prefix: PathBuf, + /// Next step ID + next_step_id: usize, +} + +/// Value of a user-defined build option. +#[derive(Debug, Clone)] +pub enum OptionValue { + Bool(bool), + String(String), + Int(i64), +} + +impl Build { + /// Create a new build context. + pub fn new(project_root: impl Into) -> Self { + let project_root = project_root.into(); + let install_prefix = project_root.join("zig-out"); + + Self { + project_root, + default_target: Target::native(), + default_optimize: Optimize::Debug, + steps: Vec::new(), + executables: Vec::new(), + libraries: Vec::new(), + tests: Vec::new(), + options: BTreeMap::new(), + install_prefix, + next_step_id: 0, + } + } + + /// Get the project root directory. + pub fn project_root(&self) -> &Path { + &self.project_root + } + + /// Set a command-line option. + pub fn set_option(&mut self, name: impl Into, value: OptionValue) { + self.options.insert(name.into(), value); + } + + /// Get a boolean option. + pub fn option_bool(&self, name: &str) -> Option { + match self.options.get(name) { + Some(OptionValue::Bool(v)) => Some(*v), + _ => None, + } + } + + /// Get a string option. + pub fn option_string(&self, name: &str) -> Option<&str> { + match self.options.get(name) { + Some(OptionValue::String(v)) => Some(v), + _ => None, + } + } + + /// Get an integer option. + pub fn option_int(&self, name: &str) -> Option { + match self.options.get(name) { + Some(OptionValue::Int(v)) => Some(*v), + _ => None, + } + } + + /// Get the standard target options (from CLI or defaults). + pub fn standard_target_options(&self) -> Target { + self.default_target.clone() + } + + /// Get the standard optimization option (from CLI or defaults). + pub fn standard_optimize_option(&self) -> Optimize { + self.default_optimize + } + + /// Set the default target. + pub fn set_default_target(&mut self, target: Target) { + self.default_target = target; + } + + /// Set the default optimization level. + pub fn set_default_optimize(&mut self, optimize: Optimize) { + self.default_optimize = optimize; + } + + /// Allocate a new step ID. + fn alloc_step_id(&mut self) -> StepId { + let id = StepId(self.next_step_id); + self.next_step_id += 1; + id + } + + /// Create a new named build step. + pub fn step(&mut self, name: impl Into, description: impl Into) -> StepId { + let id = self.alloc_step_id(); + self.steps.push(Step { + id, + name: name.into(), + description: description.into(), + dependencies: Vec::new(), + }); + id + } + + /// Add a dependency between steps. + pub fn add_step_dependency(&mut self, step: StepId, depends_on: StepId) { + if let Some(s) = self.steps.iter_mut().find(|s| s.id == step) { + s.dependencies.push(depends_on); + } + } + + /// Add an executable to build. + pub fn add_executable(&mut self, options: ExecutableOptions) -> &mut Executable { + let step = self.alloc_step_id(); + let exe = Executable { + name: options.name, + root_source: self.project_root.join(&options.root_source), + target: options.target.unwrap_or_else(|| self.default_target.clone()), + optimize: options.optimize.unwrap_or(self.default_optimize), + strict: options.strict, + defines: BTreeMap::new(), + libraries: Vec::new(), + library_paths: Vec::new(), + step, + }; + self.executables.push(exe); + self.executables.last_mut().unwrap() + } + + /// Add a library to build. + pub fn add_library(&mut self, options: LibraryOptions) -> &mut Library { + let step = self.alloc_step_id(); + let lib = Library { + name: options.name, + root_source: self.project_root.join(&options.root_source), + target: options.target.unwrap_or_else(|| self.default_target.clone()), + optimize: options.optimize.unwrap_or(self.default_optimize), + strict: options.strict, + step, + }; + self.libraries.push(lib); + self.libraries.last_mut().unwrap() + } + + /// Add a test to run. + pub fn add_test(&mut self, options: TestOptions) -> &mut Test { + let step = self.alloc_step_id(); + let test = Test { + root_source: self.project_root.join(&options.root_source), + strict: options.strict, + step, + }; + self.tests.push(test); + self.tests.last_mut().unwrap() + } + + /// Mark an artifact for installation. + pub fn install_artifact(&mut self, _step: StepId) { + // In the future, this will add the artifact to the install step + } + + /// Get all executables. + pub fn executables(&self) -> &[Executable] { + &self.executables + } + + /// Get all libraries. + pub fn libraries(&self) -> &[Library] { + &self.libraries + } + + /// Get all tests. + pub fn tests(&self) -> &[Test] { + &self.tests + } + + /// Get all named steps. + pub fn steps(&self) -> &[Step] { + &self.steps + } + + /// Find a step by name. + pub fn find_step(&self, name: &str) -> Option<&Step> { + self.steps.iter().find(|s| s.name == name) + } +} + +impl Executable { + /// Add a compile-time define. + pub fn add_define(&mut self, name: impl Into, value: impl Into) { + self.defines.insert(name.into(), value.into()); + } + + /// Link a library. + pub fn link_library(&mut self, name: impl Into) { + self.libraries.push(name.into()); + } + + /// Add a library search path. + pub fn add_library_path(&mut self, path: impl Into) { + self.library_paths.push(path.into()); + } +} + +// ============================================================================= +// Build Runner +// ============================================================================= + +/// Error that can occur during build. +#[derive(Debug, Clone)] +pub struct BuildError { + pub message: String, +} + +impl std::fmt::Display for BuildError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "build error: {}", self.message) + } +} + +impl std::error::Error for BuildError {} + +/// Result type for build operations. +pub type BuildResult = Result; + +/// Build runner that executes build.zlp. +pub struct BuildRunner { + build: Build, +} + +impl BuildRunner { + /// Create a new build runner for the given project. + pub fn new(project_root: impl Into) -> Self { + Self { + build: Build::new(project_root), + } + } + + /// Set a command-line option. + pub fn set_option(&mut self, name: impl Into, value: OptionValue) { + self.build.set_option(name, value); + } + + /// Parse command-line options in the format `-Dname=value`. + pub fn parse_options(&mut self, args: &[String]) -> BuildResult> { + let mut remaining = Vec::new(); + + for arg in args { + if let Some(opt) = arg.strip_prefix("-D") { + if let Some((name, value)) = opt.split_once('=') { + // Try to parse as different types + if value == "true" { + self.build.set_option(name, OptionValue::Bool(true)); + } else if value == "false" { + self.build.set_option(name, OptionValue::Bool(false)); + } else if let Ok(n) = value.parse::() { + self.build.set_option(name, OptionValue::Int(n)); + } else { + self.build.set_option(name, OptionValue::String(value.to_string())); + } + } else { + // -Dflag without value means true + self.build.set_option(opt, OptionValue::Bool(true)); + } + } else if arg == "-Doptimize=release" || arg == "--release" { + self.build.set_default_optimize(Optimize::ReleaseFast); + } else if arg == "-Doptimize=debug" || arg == "--debug" { + self.build.set_default_optimize(Optimize::Debug); + } else { + remaining.push(arg.clone()); + } + } + + Ok(remaining) + } + + /// Load and parse the build.zlp file. + pub fn load_build_file(&mut self) -> BuildResult<()> { + let build_file = self.build.project_root.join("build.zlp"); + + if !build_file.exists() { + return Err(BuildError { + message: format!("build.zlp not found in {}", self.build.project_root.display()), + }); + } + + let source = std::fs::read_to_string(&build_file).map_err(|e| BuildError { + message: format!("failed to read build.zlp: {}", e), + })?; + + // Parse the build file + let _ast = crate::parse_file(&source, build_file.to_string_lossy()).map_err(|e| { + BuildError { + message: format!("failed to parse build.zlp: {}", e), + } + })?; + + // TODO: Execute the build function using the comptime evaluator + // For now, we just validate that build.zlp parses correctly + + Ok(()) + } + + /// Get the build context. + pub fn build(&self) -> &Build { + &self.build + } + + /// Get mutable access to the build context. + pub fn build_mut(&mut self) -> &mut Build { + &mut self.build + } + + /// Run the default build step. + pub fn run_default(&self) -> BuildResult<()> { + // Build all executables + for exe in &self.build.executables { + self.build_executable(exe)?; + } + Ok(()) + } + + /// Run a named build step. + pub fn run_step(&self, name: &str) -> BuildResult<()> { + match name { + "test" => self.run_tests(), + _ => { + if self.build.find_step(name).is_some() { + // TODO: Execute custom step + Ok(()) + } else { + Err(BuildError { + message: format!("unknown build step: {}", name), + }) + } + } + } + } + + /// Build an executable. + fn build_executable(&self, exe: &Executable) -> BuildResult<()> { + println!("Building executable: {}", exe.name); + println!(" Source: {}", exe.root_source.display()); + println!(" Target: {:?} / {:?}", exe.target.os, exe.target.arch); + println!(" Optimize: {:?}", exe.optimize); + + if exe.strict { + println!(" Strict mode: enabled"); + } + + for (name, value) in &exe.defines { + println!(" Define: {} = {}", name, value); + } + + for lib in &exe.libraries { + println!(" Link: {}", lib); + } + + // TODO: Actually compile the executable + // This would invoke the parser, semantic analyzer, and code generator + + Ok(()) + } + + /// Run all tests. + fn run_tests(&self) -> BuildResult<()> { + use crate::test_runner::{format_results, TestOutcome, TestRunConfig, TestRunner}; + + for test in &self.build.tests { + println!("Running test: {}", test.root_source.display()); + + let source = std::fs::read_to_string(&test.root_source).map_err(|e| { + BuildError { message: format!("failed to read {}: {}", test.root_source.display(), e) } + })?; + + let program = crate::parser::parse(&source).map_err(|e| { + BuildError { message: format!("parse error: {}", e) } + })?; + + let config = TestRunConfig::default(); + let runner = TestRunner::new(config); + let results = runner.run(&program); + print!("{}", format_results(&results)); + + let has_failures = results.iter().any(|r| matches!(r.outcome, TestOutcome::Fail(_))); + if has_failures { + return Err(BuildError { message: "some tests failed".to_string() }); + } + } + Ok(()) + } + + /// Print available build steps. + pub fn print_help(&self) { + println!("Build steps:"); + println!(" (default) Build all targets"); + println!(" test Run all tests"); + + for step in &self.build.steps { + println!(" {:12} {}", step.name, step.description); + } + + println!(); + println!("Options:"); + println!(" -Dname=value Set a build option"); + println!(" --release Build in release mode"); + println!(" --debug Build in debug mode (default)"); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_target_native() { + let target = Target::native(); + // Should not panic + assert!(matches!( + target.os, + Os::Linux | Os::MacOS | Os::Windows | Os::FreeBSD | Os::Native + )); + } + + #[test] + fn test_build_context() { + let mut build = Build::new("/tmp/test-project"); + + build.set_option("noise", OptionValue::Bool(true)); + build.set_option("distance", OptionValue::Int(5)); + + assert_eq!(build.option_bool("noise"), Some(true)); + assert_eq!(build.option_int("distance"), Some(5)); + assert_eq!(build.option_bool("unknown"), None); + } + + #[test] + fn test_add_executable() { + let mut build = Build::new("/tmp/test-project"); + + let exe = build.add_executable(ExecutableOptions { + name: "test-exe".to_string(), + root_source: PathBuf::from("src/main.zlp"), + ..Default::default() + }); + + assert_eq!(exe.name, "test-exe"); + assert!(exe.root_source.ends_with("src/main.zlp")); + } + + #[test] + fn test_add_step() { + let mut build = Build::new("/tmp/test-project"); + + let step1 = build.step("compile", "Compile source files"); + let step2 = build.step("link", "Link object files"); + + build.add_step_dependency(step2, step1); + + assert_eq!(build.steps().len(), 2); + assert_eq!(build.find_step("compile").unwrap().name, "compile"); + } + + #[test] + fn test_parse_options() { + let mut runner = BuildRunner::new("/tmp/test"); + + let args = vec![ + "-Dnoise=true".to_string(), + "-Ddistance=5".to_string(), + "-Dname=test".to_string(), + "-Dflag".to_string(), + "--release".to_string(), + "build".to_string(), + ]; + + let remaining = runner.parse_options(&args).unwrap(); + + assert_eq!(runner.build().option_bool("noise"), Some(true)); + assert_eq!(runner.build().option_int("distance"), Some(5)); + assert_eq!(runner.build().option_string("name"), Some("test")); + assert_eq!(runner.build().option_bool("flag"), Some(true)); + assert_eq!(runner.build().default_optimize, Optimize::ReleaseFast); + assert_eq!(remaining, vec!["build"]); + } +} diff --git a/exp/zlup/src/codegen.rs b/exp/zlup/src/codegen.rs new file mode 100644 index 000000000..4ae429620 --- /dev/null +++ b/exp/zlup/src/codegen.rs @@ -0,0 +1,26 @@ +//! Code generation backends for Zluppy. +//! +//! Zluppy compiles to multiple targets: +//! - **HUGR**: Hierarchical Unified Graph Representation for experiments/hardware +//! - **SLR-AST**: JSON bridge to Python/PECOS for integration +//! - **PHIR/JSON**: JSON serialization of PECOS High-level IR for simulator targeting +//! - **QASM**: OpenQASM 2.0 for hardware execution +//! +//! ## Design Philosophy +//! +//! Same problems as Guppy, simpler idioms: +//! - Explicit over implicit +//! - Low-level but safe +//! - Predictable, bounded output + +#[cfg(feature = "hugr")] +pub mod hugr; +pub mod phir; +pub mod qasm; +pub mod slr; + +#[cfg(feature = "hugr")] +pub use hugr::{CodegenMode, HugrCodegen}; +pub use phir::{PhirJsonCodegen, PhirJsonError, PhirJsonProgram}; +pub use qasm::{QasmCodegen, QasmError}; +pub use slr::SlrCodegen; diff --git a/exp/zlup/src/codegen/hugr.rs b/exp/zlup/src/codegen/hugr.rs new file mode 100644 index 000000000..f7b880317 --- /dev/null +++ b/exp/zlup/src/codegen/hugr.rs @@ -0,0 +1,2613 @@ +//! HUGR code generation for Zluppy. +//! +//! This module generates HUGR (Hierarchical Unified Graph Representation) from +//! Zluppy AST. HUGR is used for targeting experiments and quantum hardware. +//! +//! ## Design +//! +//! The codegen walks the Zluppy AST and: +//! 1. Collects allocator declarations to determine qubit counts +//! 2. Maps gate calls to TketOp operations +//! 3. Tracks wire flow through the circuit +//! 4. Handles rotation angles (converted to half-turns) +//! +//! ## Wire Tracking +//! +//! In HUGR, each qubit is represented by a Wire that flows through the graph. +//! When a gate operates on a qubit, it consumes the input wire and produces +//! a new output wire. We maintain a mapping from qubit identifiers to their +//! current wire. + +use std::collections::BTreeMap; +use thiserror::Error; + +use std::io::Cursor; + +use tket::hugr::builder::{ + BuildError, DFGBuilder, Dataflow, DataflowHugr, DataflowSubContainer, SubContainer, +}; +use tket::hugr::envelope::EnvelopeConfig; +use tket::hugr::extension::prelude::qb_t; +use tket::extension::bool::bool_type; +use tket::hugr::types::Signature; +use tket::hugr::{type_row, Hugr, Wire}; +use tket::TketOp; + +use crate::ast::{ + BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, IndexExpr, Program, Stmt, + TopLevelDecl, +}; + +// ============================================================================= +// Errors +// ============================================================================= + +/// HUGR code generation errors. +#[derive(Debug, Error)] +pub enum HugrError { + #[error("unknown gate '{name}'")] + UnknownGate { name: String }, + + #[error("undefined qubit '{name}'")] + UndefinedQubit { name: String }, + + #[error("qubit index {index} out of bounds for allocator with capacity {capacity}")] + QubitIndexOutOfBounds { index: usize, capacity: usize }, + + #[error("expected {expected} arguments for gate '{gate}', got {got}")] + WrongArgumentCount { + gate: String, + expected: usize, + got: usize, + }, + + #[error("allocator '{name}' not found")] + AllocatorNotFound { name: String }, + + #[error("HUGR builder error: {0}")] + BuilderError(String), + + #[error("unsupported expression in codegen")] + UnsupportedExpression, + + #[error("rotation angle must be a numeric literal")] + InvalidRotationAngle, + + #[error("HUGR serialization error: {0}")] + SerializationError(String), +} + +/// Result type for HUGR code generation. +pub type HugrResult = Result; + +// ============================================================================= +// Qubit Tracking +// ============================================================================= + +/// Tracks an allocator and its qubits. +#[derive(Debug, Clone)] +pub struct Allocator { + /// Name of the allocator variable. + pub name: String, + /// Capacity (number of qubits). + pub capacity: usize, + /// Starting index in the global qubit array. + pub start_index: usize, +} + +/// Tracks a qubit reference (allocator + index). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct QubitRef { + /// Allocator name. + pub allocator: String, + /// Index within the allocator. + pub index: usize, +} + +impl QubitRef { + pub fn new(allocator: impl Into, index: usize) -> Self { + Self { + allocator: allocator.into(), + index, + } + } +} + +// ============================================================================= +// Gate Mapping +// ============================================================================= + +/// Result of mapping a gate name - either a direct TketOp or a composite gate. +#[allow(clippy::upper_case_acronyms)] +#[derive(Debug, Clone)] +enum GateMapping { + /// Direct mapping to a TketOp + Direct(TketOp), + /// SWAP gate (decomposed to 3 CX gates) + Swap, + /// iSWAP gate (decomposed) + ISwap, + /// SY gate (sqrt of Y) - implemented as Ry(π/2) + SY, + /// SYdg gate (sqrt of Y dagger) - implemented as Ry(-π/2) + SYdg, + /// CH gate (controlled Hadamard) - decomposed to Ry(π/4) CZ Ry(-π/4) + CH, + /// SXX gate (sqrt of XX Ising) - decomposed + SXX, + /// SYY gate (sqrt of YY Ising) - decomposed + SYY, + /// SZZ gate (sqrt of ZZ Ising) - decomposed to CX S CX + SZZ, + /// Dagger versions of Ising gates + SXXdg, + SYYdg, + SZZdg, + /// RZZ gate (ZZ rotation) - decomposed to CX Rz CX + RZZ, + /// F gate (Clifford face rotation) - decomposed to H Sdg H Sdg + F, + /// F dagger - decomposed to S H S H + Fdg, + /// F4 gate (fourth root of face rotation) - decomposed + F4, + /// F4 dagger + F4dg, + /// Mid-circuit measurement (returns classical bit, keeps qubit) + MidMeasure, +} + +/// Maps Zluppy gate names to gate operations. +/// +/// Zluppy uses lowercase gate names following Zig-style conventions. +/// All gate names are lowercase. +/// +/// Available gates: +/// - Single-qubit Pauli: h, x, y, z +/// - Square root: sx, sxdg, sy, sydg, sz, szdg (sqrt of X, Y, and Z) +/// - T gates: t, tdg (fourth root of Z) +/// - F gates: f, fdg, f4, f4dg (Clifford face rotations) +/// - Rotation: rx, ry, rz (single-qubit), crz, rzz (two-qubit) +/// - Two-qubit: cx, cy, cz, ch, swap, iswap +/// - Two-qubit Ising: sxx, syy, szz, sxxdg, syydg, szzdg +/// - Three-qubit: ccx +/// - Measurement: mz (Z-basis measurement) +/// - State preparation: pz (prepare +Z eigenstate) +/// +/// Composite gates (decomposed): +/// - swap: cx(a,b) cx(b,a) cx(a,b) +/// - iswap: sz(a) sz(b) h(a) cx(a,b) cx(b,a) h(b) +/// - sy: ry(π/2) +/// - sydg: ry(-π/2) +/// - ch: ry(π/4, b) cz(a,b) ry(-π/4, b) +/// - szz: cx(a,b) sz(b) cx(a,b) +/// - sxx: h(a) h(b) szz(a,b) h(a) h(b) +/// - syy: sxdg(a) sxdg(b) szz(a,b) sx(a) sx(b) +/// - rzz(θ): cx(a,b) rz(θ, b) cx(a,b) +/// - f: h sdg h sdg (Clifford: X→Y→Z→X) +/// - fdg: s h s h +fn gate_name_to_mapping(name: &str) -> Option { + match name { + // Single-qubit Pauli gates + "h" => Some(GateMapping::Direct(TketOp::H)), + "x" => Some(GateMapping::Direct(TketOp::X)), + "y" => Some(GateMapping::Direct(TketOp::Y)), + "z" => Some(GateMapping::Direct(TketOp::Z)), + + // Square root gates (sx = sqrt(X), sy = sqrt(Y), sz = sqrt(Z)) + "sx" => Some(GateMapping::Direct(TketOp::V)), // V = sqrt(X) + "sxdg" => Some(GateMapping::Direct(TketOp::Vdg)), + "sy" => Some(GateMapping::SY), // sqrt(Y) = Ry(π/2) + "sydg" => Some(GateMapping::SYdg), // sqrt(Y)† = Ry(-π/2) + "sz" => Some(GateMapping::Direct(TketOp::S)), // S = sqrt(Z) + "szdg" => Some(GateMapping::Direct(TketOp::Sdg)), + + // T gates (fourth root of Z) + "t" => Some(GateMapping::Direct(TketOp::T)), + "tdg" => Some(GateMapping::Direct(TketOp::Tdg)), + + // Rotation gates (require angle parameter) + "rx" => Some(GateMapping::Direct(TketOp::Rx)), + "ry" => Some(GateMapping::Direct(TketOp::Ry)), + "rz" => Some(GateMapping::Direct(TketOp::Rz)), + + // Two-qubit gates + "cx" => Some(GateMapping::Direct(TketOp::CX)), + "cy" => Some(GateMapping::Direct(TketOp::CY)), + "cz" => Some(GateMapping::Direct(TketOp::CZ)), + "ch" => Some(GateMapping::CH), // Controlled Hadamard (decomposed) + "crz" => Some(GateMapping::Direct(TketOp::CRz)), + "rzz" => Some(GateMapping::RZZ), // ZZ rotation (decomposed) + + // Two-qubit Ising gates (decomposed) + "sxx" => Some(GateMapping::SXX), + "syy" => Some(GateMapping::SYY), + "szz" => Some(GateMapping::SZZ), + "sxxdg" => Some(GateMapping::SXXdg), + "syydg" => Some(GateMapping::SYYdg), + "szzdg" => Some(GateMapping::SZZdg), + + // Composite two-qubit gates (decomposed) + "swap" => Some(GateMapping::Swap), + "iswap" => Some(GateMapping::ISwap), + + // Three-qubit gates + "ccx" => Some(GateMapping::Direct(TketOp::Toffoli)), + + // F gates (Clifford face rotations, decomposed) + "f" => Some(GateMapping::F), + "fdg" => Some(GateMapping::Fdg), + "f4" => Some(GateMapping::F4), + "f4dg" => Some(GateMapping::F4dg), + + // Mid-circuit measurement in Z basis (keeps qubit alive) + "mz" => Some(GateMapping::MidMeasure), + + // Prepare +Z eigenstate (reset) + "pz" => Some(GateMapping::Direct(TketOp::Reset)), + + _ => None, + } +} + +/// Returns the number of qubit operands for a gate. +fn gate_qubit_count(op: &TketOp) -> usize { + match op { + // Single-qubit gates + TketOp::H + | TketOp::X + | TketOp::Y + | TketOp::Z + | TketOp::S + | TketOp::Sdg + | TketOp::T + | TketOp::Tdg + | TketOp::V + | TketOp::Vdg + | TketOp::Rx + | TketOp::Ry + | TketOp::Rz + | TketOp::Measure + | TketOp::MeasureFree + | TketOp::Reset + | TketOp::QFree => 1, + + // Two-qubit gates + TketOp::CX | TketOp::CY | TketOp::CZ | TketOp::CRz => 2, + + // Three-qubit gates + TketOp::Toffoli => 3, + + // Zero-qubit gates (allocation) + TketOp::QAlloc | TketOp::TryQAlloc => 0, + + // Default for any future variants + _ => 1, + } +} + +/// Returns whether a gate requires a rotation angle parameter. +fn gate_needs_angle(op: &TketOp) -> bool { + matches!(op, TketOp::Rx | TketOp::Ry | TketOp::Rz | TketOp::CRz) +} + +// ============================================================================= +// Code Generator Configuration +// ============================================================================= + +/// Controls how composite gates are handled during code generation. +/// +/// When targeting real hardware or HUGR-native execution, use `Decompose` to +/// break down gates like SWAP and iSWAP into primitive operations. +/// +/// When targeting simulation (e.g., PECOS), use `Native` to emit the gates +/// directly if the simulator supports them natively. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum CodegenMode { + /// Decompose composite gates into primitives (e.g., SWAP → 3 CX gates). + /// Use this for hardware targets or HUGR-only execution. + #[default] + Decompose, + + /// Emit gates natively without decomposition. + /// Use this for simulation backends that support composite gates. + /// + /// Note: Currently iSWAP and SWAP are always decomposed since HUGR's + /// tket extension doesn't have native support for them. This mode + /// affects future gates where we might have both options. + Native, +} + +// ============================================================================= +// Code Generator +// ============================================================================= + +/// HUGR code generator. +/// +/// Walks a Zluppy AST and produces a HUGR graph. +pub struct HugrCodegen { + /// Code generation mode. + mode: CodegenMode, + /// Allocators by name. + allocators: BTreeMap, + /// Total number of qubits across all allocators. + total_qubits: usize, + /// Collected gate operations. + operations: Vec, + /// Names of classical variables (from measurement results). + classical_vars: std::collections::BTreeSet, +} + +/// A gate operation to be compiled. +#[derive(Debug, Clone)] +enum GateOp { + /// A direct TketOp gate. + Direct { + op: TketOp, + qubits: Vec, + angle: Option, + }, + /// SWAP gate (will be decomposed to 3 CX gates). + Swap { + qubit_a: QubitRef, + qubit_b: QubitRef, + }, + /// iSWAP gate (will be decomposed). + ISwap { + qubit_a: QubitRef, + qubit_b: QubitRef, + }, + /// Mid-circuit measurement (keeps qubit, stores result). + MidMeasure { + qubit: QubitRef, + /// Name of the classical variable to store the result. + result_var: String, + }, + /// Conditional block based on classical measurement result. + Conditional { + /// Name of the classical variable to condition on. + condition_var: String, + /// Operations to execute if condition is true. + then_ops: Vec, + /// Operations to execute if condition is false. + else_ops: Vec, + }, +} + +impl HugrCodegen { + /// Create a new HUGR code generator with default settings. + /// + /// Uses `CodegenMode::Decompose` by default, which breaks down composite + /// gates into primitives for maximum compatibility. + pub fn new() -> Self { + Self { + mode: CodegenMode::default(), + allocators: BTreeMap::new(), + total_qubits: 0, + operations: Vec::new(), + classical_vars: std::collections::BTreeSet::new(), + } + } + + /// Create a new HUGR code generator with the specified mode. + /// + /// # Example + /// ```ignore + /// let codegen = HugrCodegen::with_mode(CodegenMode::Native); + /// ``` + pub fn with_mode(mode: CodegenMode) -> Self { + Self { + mode, + allocators: BTreeMap::new(), + total_qubits: 0, + operations: Vec::new(), + classical_vars: std::collections::BTreeSet::new(), + } + } + + /// Get the current codegen mode. + pub fn mode(&self) -> CodegenMode { + self.mode + } + + /// Set the codegen mode. + pub fn set_mode(&mut self, mode: CodegenMode) { + self.mode = mode; + } + + /// Compile a Zluppy program to HUGR. + pub fn compile(&mut self, program: &Program) -> HugrResult { + // Phase 1: Collect allocators and operations + self.collect_program(program)?; + + // Phase 2: Build HUGR + self.build_hugr() + } + + /// Compile a function to HUGR. + pub fn compile_function(&mut self, fn_decl: &FnDecl) -> HugrResult { + // Collect from function body + self.collect_block(&fn_decl.body)?; + + // Build HUGR + self.build_hugr() + } + + // ========================================================================= + // Collection Phase + // ========================================================================= + + fn collect_program(&mut self, program: &Program) -> HugrResult<()> { + for decl in &program.declarations { + self.collect_top_level(decl)?; + } + Ok(()) + } + + fn collect_top_level(&mut self, decl: &TopLevelDecl) -> HugrResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) => { + // Only collect from main function for now + if fn_decl.name == "main" { + self.collect_block(&fn_decl.body)?; + } + } + TopLevelDecl::Binding(binding) => { + self.collect_binding(binding)?; + } + _ => {} + } + Ok(()) + } + + fn collect_binding(&mut self, binding: &Binding) -> HugrResult<()> { + // Check if this is an allocator declaration + if let Some(ref value) = binding.value { + if let Some(capacity) = self.try_extract_allocator(value) { + let start_index = self.total_qubits; + self.total_qubits += capacity; + self.allocators.insert( + binding.name.clone(), + Allocator { + name: binding.name.clone(), + capacity, + start_index, + }, + ); + } + // Check for child allocator: mut q := base.child(2) + else if let Some((parent, size)) = self.try_extract_child_allocator(value) { + // Child allocators share qubits with parent + // For now, treat them as new allocations (simplification) + let start_index = self.total_qubits; + self.total_qubits += size; + self.allocators.insert( + binding.name.clone(), + Allocator { + name: binding.name.clone(), + capacity: size, + start_index, + }, + ); + // Suppress unused variable warning + let _ = parent; + } + // Check for measurement assignment: mut result := M(q[0]) + else if self.is_measurement_call(value) { + // Track this as a classical variable + self.classical_vars.insert(binding.name.clone()); + // Collect the measurement with the variable name + self.collect_measurement_assignment(value, &binding.name)?; + } + } + Ok(()) + } + + /// Check if an expression is a measurement call. + fn is_measurement_call(&self, expr: &Expr) -> bool { + if let Expr::Call(call) = expr + && let Ok(name) = self.extract_call_name(&call.callee) { + return name.as_str() == "mz"; + } + false + } + + /// Collect a measurement assignment: mut result := mz(u1) q[0] or mz(u1) [q[0], q[1]] + fn collect_measurement_assignment(&mut self, expr: &Expr, result_var: &str) -> HugrResult<()> { + if let Expr::Call(call) = expr { + // New typed measurement syntax: mz(type, target) + if call.args.len() == 2 { + // First arg is type (ignored for HUGR), second is target(s) + let target_arg = &call.args[1]; + let qubits = self.extract_measurement_targets(target_arg)?; + + for (i, qubit) in qubits.into_iter().enumerate() { + let var_name = if i == 0 { + result_var.to_string() + } else { + format!("{}_{}", result_var, i) + }; + self.operations.push(GateOp::MidMeasure { + qubit, + result_var: var_name, + }); + } + return Ok(()); + } + + // Legacy single-arg syntax: mz(q[0]) + if call.args.len() == 1 { + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::MidMeasure { + qubit, + result_var: result_var.to_string(), + }); + return Ok(()); + } + + return Err(HugrError::WrongArgumentCount { + gate: "mz".to_string(), + expected: 2, + got: call.args.len(), + }); + } + Ok(()) + } + + /// Extract measurement targets from an expression. + /// Handles both single qubit (q[0]) and array (&[q[0], q[1]]) syntax. + fn extract_measurement_targets(&self, expr: &Expr) -> HugrResult> { + match expr { + // Single qubit: q[0] + Expr::Index(index_expr) => { + let qubit = self.extract_qubit_from_index(index_expr)?; + Ok(vec![qubit]) + } + // Array of qubits: &[q[0], q[1]] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand { + let mut qubits = Vec::new(); + for elem in &arr.elements { + let qubit = self.extract_qubit_ref(elem)?; + qubits.push(qubit); + } + return Ok(qubits); + } + Err(HugrError::UnsupportedExpression) + } + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Check if an expression is a batch literal (set or address-of array). + fn is_batch_literal(&self, expr: &Expr) -> bool { + match expr { + Expr::Set(_) => true, + Expr::Unary(unary) => { + matches!(unary.op, crate::ast::UnaryOp::AddrOf) + && matches!(unary.operand, Expr::BracketArray(_)) + } + _ => false, + } + } + + /// Extract single-qubit batch targets from set literal or address-of array. + /// Supports: [q[0], q[1], q[2] or &[q[0], q[1], q[2]] + fn extract_batch_single_targets(&self, expr: &Expr) -> HugrResult> { + match expr { + Expr::Set(set) => { + let mut qubits = Vec::new(); + for elem in &set.elements { + let qubit = self.extract_qubit_ref(elem)?; + qubits.push(qubit); + } + Ok(qubits) + } + // Address-of array: &[q[0], q[1], q[2]] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand { + let mut qubits = Vec::new(); + for elem in &arr.elements { + let qubit = self.extract_qubit_ref(elem)?; + qubits.push(qubit); + } + return Ok(qubits); + } + // Single qubit (not batch) + let qubit = self.extract_qubit_ref(expr)?; + Ok(vec![qubit]) + } + // Single qubit (not batch) + _ => { + let qubit = self.extract_qubit_ref(expr)?; + Ok(vec![qubit]) + } + } + } + + /// Extract two-qubit batch targets from set or address-of array of tuples. + /// Supports: {(q[0], q[1]), (q[2], q[3])} or &[(q[0], q[1]), (q[2], q[3])] + fn extract_batch_pair_targets(&self, expr: &Expr) -> HugrResult> { + match expr { + Expr::Set(set) => { + let mut pairs = Vec::new(); + for elem in &set.elements { + let pair = self.extract_qubit_pair(elem)?; + pairs.push(pair); + } + Ok(pairs) + } + // Address-of array: &[(q[0], q[1]), (q[2], q[3])] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand { + let mut pairs = Vec::new(); + for elem in &arr.elements { + let pair = self.extract_qubit_pair(elem)?; + pairs.push(pair); + } + return Ok(pairs); + } + Err(HugrError::UnsupportedExpression) + } + // Single pair (not batch) - could be tuple or two separate args + Expr::Tuple(tuple) if tuple.elements.len() == 2 => { + let pair = self.extract_qubit_pair(expr)?; + Ok(vec![pair]) + } + // Not a batch - will be handled by regular two-qubit gate logic + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Extract a qubit pair from a tuple expression: (q[0], q[1]) + fn extract_qubit_pair(&self, expr: &Expr) -> HugrResult<(QubitRef, QubitRef)> { + match expr { + Expr::Tuple(tuple) => { + if tuple.elements.len() != 2 { + return Err(HugrError::UnsupportedExpression); + } + let qubit_a = self.extract_qubit_ref(&tuple.elements[0])?; + let qubit_b = self.extract_qubit_ref(&tuple.elements[1])?; + Ok((qubit_a, qubit_b)) + } + _ => Err(HugrError::UnsupportedExpression), + } + } + + fn collect_block(&mut self, block: &Block) -> HugrResult<()> { + for stmt in &block.statements { + self.collect_stmt(stmt)?; + } + Ok(()) + } + + fn collect_stmt(&mut self, stmt: &Stmt) -> HugrResult<()> { + match stmt { + Stmt::Binding(binding) => self.collect_binding(binding)?, + Stmt::Expr(expr_stmt) => self.collect_expr(&expr_stmt.expr)?, + // Tick blocks - flatten operations (HUGR doesn't have native parallel blocks) + Stmt::Tick(tick_stmt) => { + for inner_stmt in &tick_stmt.body { + self.collect_stmt(inner_stmt)?; + } + } + Stmt::If(if_stmt) => { + // Check if the condition is a classical variable (from measurement) + if let Some(condition_var) = self.try_extract_classical_condition(&if_stmt.condition) + { + // Collect operations for both branches separately + let then_ops = self.collect_block_ops(&if_stmt.then_body)?; + let else_ops = if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_ops(else_branch)? + } else { + Vec::new() + }; + + self.operations.push(GateOp::Conditional { + condition_var, + then_ops, + else_ops, + }); + } else { + // Non-classical conditional - just collect operations from both branches + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + } + Stmt::For(for_stmt) => { + self.collect_block(&for_stmt.body)?; + } + Stmt::Block(block) => self.collect_block(block)?, + _ => {} + } + Ok(()) + } + + /// Try to extract a classical variable name from a condition expression. + /// Returns Some(var_name) if the condition is a simple reference to a classical variable. + fn try_extract_classical_condition(&self, expr: &Expr) -> Option { + if let Expr::Ident(ident) = expr + && self.classical_vars.contains(&ident.name) { + return Some(ident.name.clone()); + } + None + } + + /// Collect operations from a block into a separate Vec (for conditional branches). + fn collect_block_ops(&mut self, block: &Block) -> HugrResult> { + // Save current operations + let saved_ops = std::mem::take(&mut self.operations); + + // Collect into fresh operations list + self.collect_block(block)?; + + // Swap back and return the collected ops + let collected = std::mem::replace(&mut self.operations, saved_ops); + Ok(collected) + } + + /// Collect operations from an else branch. + fn collect_else_ops(&mut self, branch: &ElseBranch) -> HugrResult> { + match branch { + ElseBranch::Else(block) => self.collect_block_ops(block), + ElseBranch::ElseIf(if_stmt) => { + // For else-if, treat as nested conditional (simplified for now) + let saved_ops = std::mem::take(&mut self.operations); + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + let collected = std::mem::replace(&mut self.operations, saved_ops); + Ok(collected) + } + } + } + + fn collect_else_branch(&mut self, branch: &ElseBranch) -> HugrResult<()> { + match branch { + ElseBranch::Else(block) => self.collect_block(block)?, + ElseBranch::ElseIf(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + } + Ok(()) + } + + fn collect_expr(&mut self, expr: &Expr) -> HugrResult<()> { + match expr { + Expr::Call(call) => self.collect_call(call)?, + Expr::Binary(binary) => { + self.collect_expr(&binary.left)?; + self.collect_expr(&binary.right)?; + } + _ => {} + } + Ok(()) + } + + fn collect_call(&mut self, call: &CallExpr) -> HugrResult<()> { + // Check if this is a gate call + let name = self.extract_call_name(&call.callee)?; + + // Skip non-gate calls + let Some(mapping) = gate_name_to_mapping(&name) else { + // Could be a method call like child() + return Ok(()); + }; + + match mapping { + GateMapping::Direct(op) => { + // Extract qubit operands + let qubit_count = gate_qubit_count(&op); + let needs_angle = gate_needs_angle(&op); + + // For rotation gates, angle comes first (angle-first syntax) + let (angle, qubit_start) = if needs_angle { + (Some(self.extract_angle(&call.args[0])?), 1) + } else { + (None, 0) + }; + + // Check for batch operations with set or array literals + let qubit_args = &call.args[qubit_start..]; + + // Single-qubit gate with batch: h([q[0], q[1]) or h(&[q[0], q[1]]) + if qubit_count == 1 && qubit_args.len() == 1 && self.is_batch_literal(&qubit_args[0]) { + let targets = self.extract_batch_single_targets(&qubit_args[0])?; + for qubit in targets { + self.operations.push(GateOp::Direct { + op, + qubits: vec![qubit], + angle, + }); + } + return Ok(()); + } + + // Two-qubit gate with batch: cx({(q[0], q[1])}) or cx(&[(q[0], q[1])]) + if qubit_count == 2 && qubit_args.len() == 1 && self.is_batch_literal(&qubit_args[0]) { + let pairs = self.extract_batch_pair_targets(&qubit_args[0])?; + for (qubit_a, qubit_b) in pairs { + self.operations.push(GateOp::Direct { + op, + qubits: vec![qubit_a, qubit_b], + angle, + }); + } + return Ok(()); + } + + // Standard non-batch case + let expected_args = if needs_angle { + qubit_count + 1 + } else { + qubit_count + }; + + if call.args.len() != expected_args { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: expected_args, + got: call.args.len(), + }); + } + + // Extract qubit references (after angle if present) + let mut qubits = Vec::with_capacity(qubit_count); + for arg in call.args.iter().skip(qubit_start).take(qubit_count) { + let qubit_ref = self.extract_qubit_ref(arg)?; + qubits.push(qubit_ref); + } + + self.operations.push(GateOp::Direct { op, qubits, angle }); + } + + GateMapping::Swap => { + // SWAP requires exactly 2 qubit arguments + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Swap { qubit_a, qubit_b }); + } + + GateMapping::ISwap => { + // iSWAP requires exactly 2 qubit arguments + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::ISwap { qubit_a, qubit_b }); + } + + GateMapping::SY => { + // SY (sqrt of Y) requires exactly 1 qubit argument + // Decomposed to Ry(π/2) + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit], + angle: Some(std::f64::consts::FRAC_PI_2), + }); + } + + GateMapping::SYdg => { + // SYdg (sqrt of Y dagger) requires exactly 1 qubit argument + // Decomposed to Ry(-π/2) + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit], + angle: Some(-std::f64::consts::FRAC_PI_2), + }); + } + + GateMapping::CH => { + // CH (controlled Hadamard) requires exactly 2 qubit arguments + // Decomposed to: Ry(π/4, b) CZ(a,b) Ry(-π/4, b) + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + // Ry(π/4) on target + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit_b.clone()], + angle: Some(std::f64::consts::FRAC_PI_4), + }); + // CZ(control, target) + self.operations.push(GateOp::Direct { + op: TketOp::CZ, + qubits: vec![qubit_a, qubit_b.clone()], + angle: None, + }); + // Ry(-π/4) on target + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit_b], + angle: Some(-std::f64::consts::FRAC_PI_4), + }); + } + + GateMapping::SZZ => { + // SZZ (sqrt of ZZ Ising) requires exactly 2 qubit arguments + // Decomposed to: CX(a,b) S(b) CX(a,b) + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a, qubit_b], + angle: None, + }); + } + + GateMapping::SZZdg => { + // SZZdg (sqrt of ZZ Ising dagger) - use Sdg instead of S + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a, qubit_b], + angle: None, + }); + } + + GateMapping::SXX => { + // SXX (sqrt of XX Ising) requires exactly 2 qubit arguments + // Decomposed to: H(a) H(b) SZZ(a,b) H(a) H(b) + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + // H(a) H(b) + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_a.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_b.clone()], + angle: None, + }); + // SZZ decomposition inline: CX S CX + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + // H(a) H(b) + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_a], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_b], + angle: None, + }); + } + + GateMapping::SXXdg => { + // SXXdg - same as SXX but use Sdg instead of S + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_a.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_a], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit_b], + angle: None, + }); + } + + GateMapping::SYY => { + // SYY (sqrt of YY Ising) requires exactly 2 qubit arguments + // Decomposed to: Vdg(a) Vdg(b) SZZ(a,b) V(a) V(b) + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + // Vdg(a) Vdg(b) - SXdg gates + self.operations.push(GateOp::Direct { + op: TketOp::Vdg, + qubits: vec![qubit_a.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Vdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + // SZZ decomposition inline: CX S CX + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + // V(a) V(b) - SX gates + self.operations.push(GateOp::Direct { + op: TketOp::V, + qubits: vec![qubit_a], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::V, + qubits: vec![qubit_b], + angle: None, + }); + } + + GateMapping::SYYdg => { + // SYYdg - same as SYY but use Sdg instead of S + if call.args.len() != 2 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + let qubit_a = self.extract_qubit_ref(&call.args[0])?; + let qubit_b = self.extract_qubit_ref(&call.args[1])?; + self.operations.push(GateOp::Direct { + op: TketOp::Vdg, + qubits: vec![qubit_a.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Vdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::V, + qubits: vec![qubit_a], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::V, + qubits: vec![qubit_b], + angle: None, + }); + } + + GateMapping::RZZ => { + // RZZ (ZZ rotation) requires 1 angle + 2 qubit arguments (angle-first) + // Decomposed to: CX(a,b) Rz(θ, b) CX(a,b) + if call.args.len() != 3 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 3, + got: call.args.len(), + }); + } + // Angle-first: rzz(angle, qubit_a, qubit_b) + let angle = self.extract_angle(&call.args[0])?; + let qubit_a = self.extract_qubit_ref(&call.args[1])?; + let qubit_b = self.extract_qubit_ref(&call.args[2])?; + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a.clone(), qubit_b.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Rz, + qubits: vec![qubit_b.clone()], + angle: Some(angle), + }); + self.operations.push(GateOp::Direct { + op: TketOp::CX, + qubits: vec![qubit_a, qubit_b], + angle: None, + }); + } + + GateMapping::F => { + // F gate (Clifford face rotation) requires exactly 1 qubit argument + // Decomposed to: H Sdg H Sdg + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::Sdg, + qubits: vec![qubit], + angle: None, + }); + } + + GateMapping::Fdg => { + // Fdg gate (F dagger) - S H S H + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::S, + qubits: vec![qubit.clone()], + angle: None, + }); + self.operations.push(GateOp::Direct { + op: TketOp::H, + qubits: vec![qubit], + angle: None, + }); + } + + GateMapping::F4 => { + // F4 gate (fourth root of F) - approximated with T gates + // F4 ≈ Ry(π/4) Rz(π/4) + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit.clone()], + angle: Some(std::f64::consts::FRAC_PI_4), + }); + self.operations.push(GateOp::Direct { + op: TketOp::Rz, + qubits: vec![qubit], + angle: Some(std::f64::consts::FRAC_PI_4), + }); + } + + GateMapping::F4dg => { + // F4dg gate (fourth root of F dagger) - reverse of F4 + if call.args.len() != 1 { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 1, + got: call.args.len(), + }); + } + let qubit = self.extract_qubit_ref(&call.args[0])?; + self.operations.push(GateOp::Direct { + op: TketOp::Rz, + qubits: vec![qubit.clone()], + angle: Some(-std::f64::consts::FRAC_PI_4), + }); + self.operations.push(GateOp::Direct { + op: TketOp::Ry, + qubits: vec![qubit], + angle: Some(-std::f64::consts::FRAC_PI_4), + }); + } + + GateMapping::MidMeasure => { + // Typed measurement: mz(type, target) or mz(type, &[targets]) + // Also supports legacy mz(qubit) + if call.args.len() == 2 { + // New typed syntax: mz(type, target) + let target_arg = &call.args[1]; + let qubits = self.extract_measurement_targets(target_arg)?; + for qubit in qubits { + let result_var = format!("__measure_{}", self.operations.len()); + self.operations.push(GateOp::MidMeasure { qubit, result_var }); + } + } else if call.args.len() == 1 { + // Legacy syntax: mz(qubit) + let qubit = self.extract_qubit_ref(&call.args[0])?; + let result_var = format!("__measure_{}", self.operations.len()); + self.operations.push(GateOp::MidMeasure { qubit, result_var }); + } else { + return Err(HugrError::WrongArgumentCount { + gate: name, + expected: 2, + got: call.args.len(), + }); + } + } + } + + Ok(()) + } + + // ========================================================================= + // Extraction Helpers + // ========================================================================= + + /// Try to extract allocator capacity from qalloc(n) call. + fn try_extract_allocator(&self, expr: &Expr) -> Option { + if let Expr::Call(call) = expr { + let name = self.extract_call_name(&call.callee).ok()?; + if name == "qalloc" && call.args.len() == 1 { + return self.extract_integer(&call.args[0]).ok(); + } + } + None + } + + /// Try to extract child allocator from base.child(n) call. + fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { + if let Expr::Call(call) = expr { + // Check for method call pattern: expr.child(n) + if let Expr::Field(field) = &call.callee + && field.field == "child" && call.args.len() == 1 { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } + } + None + } + + /// Extract the name from a call expression's callee. + fn extract_call_name(&self, callee: &Expr) -> HugrResult { + match callee { + Expr::Ident(ident) => Ok(ident.name.clone()), + // Method call: q.child(n) -> "child" + Expr::Field(field) => Ok(field.field.clone()), + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Extract an identifier from an expression. + fn extract_identifier(&self, expr: &Expr) -> HugrResult { + match expr { + Expr::Ident(ident) => Ok(ident.name.clone()), + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Extract a qubit reference from an expression (e.g., q[0]). + fn extract_qubit_ref(&self, expr: &Expr) -> HugrResult { + match expr { + Expr::Index(index_expr) => self.extract_qubit_from_index(index_expr), + _ => Err(HugrError::UnsupportedExpression), + } + } + + fn extract_qubit_from_index(&self, index: &IndexExpr) -> HugrResult { + let allocator = self.extract_identifier(&index.object)?; + let idx = self.extract_integer(&index.index)?; + + // Validate the allocator exists + let alloc = self + .allocators + .get(&allocator) + .ok_or_else(|| HugrError::AllocatorNotFound { + name: allocator.clone(), + })?; + + // Validate index is in bounds + if idx >= alloc.capacity { + return Err(HugrError::QubitIndexOutOfBounds { + index: idx, + capacity: alloc.capacity, + }); + } + + Ok(QubitRef::new(allocator, idx)) + } + + /// Extract an integer from an expression. + fn extract_integer(&self, expr: &Expr) -> HugrResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value as usize), + _ => Err(HugrError::UnsupportedExpression), + } + } + + /// Extract a rotation angle in radians from an expression. + fn extract_angle(&self, expr: &Expr) -> HugrResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value as f64), + Expr::FloatLit(lit) => Ok(lit.value), + // Handle expressions like PI / 4 + Expr::Binary(binary) => { + let left = self.extract_angle(&binary.left)?; + let right = self.extract_angle(&binary.right)?; + match binary.op { + BinaryOp::Div => Ok(left / right), + BinaryOp::Mul => Ok(left * right), + BinaryOp::Add => Ok(left + right), + BinaryOp::Sub => Ok(left - right), + _ => Err(HugrError::InvalidRotationAngle), + } + } + // Handle PI constant + Expr::Ident(ident) if ident.name == "PI" || ident.name == "pi" => { + Ok(std::f64::consts::PI) + } + Expr::Ident(ident) if ident.name == "TAU" || ident.name == "tau" => { + Ok(std::f64::consts::TAU) + } + _ => Err(HugrError::InvalidRotationAngle), + } + } + + // ========================================================================= + // HUGR Building Phase + // ========================================================================= + + fn build_hugr(&self) -> HugrResult { + if self.total_qubits == 0 { + // Empty circuit - create minimal HUGR + return self.build_empty_hugr(); + } + + // Create signature: no inputs, N bool outputs (measurement results) + let bool_row: Vec<_> = (0..self.total_qubits).map(|_| bool_type()).collect(); + let signature = Signature::new(vec![], bool_row); + + // Create builder + let mut builder = DFGBuilder::new(signature) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + + // Allocate qubits using QAlloc + let mut qubit_wires: BTreeMap = BTreeMap::new(); + for (name, alloc) in &self.allocators { + for i in 0..alloc.capacity { + let qubit_ref = QubitRef::new(name.clone(), i); + // Add QAlloc operation to allocate a qubit + let qalloc_wire: Wire = builder + .add_dataflow_op(TketOp::QAlloc, vec![]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .next() + .ok_or_else(|| HugrError::BuilderError("QAlloc produced no output".to_string()))?; + qubit_wires.insert(qubit_ref, qalloc_wire); + } + } + + // Track classical wires from mid-circuit measurements + let mut classical_wires: BTreeMap = BTreeMap::new(); + + // Apply operations + for gate_op in &self.operations { + self.apply_gate(&mut builder, &mut qubit_wires, &mut classical_wires, gate_op)?; + } + + // Measure and free all qubits using MeasureFree, collect bool results + let output_wires: Vec = (0..self.total_qubits) + .map(|global_idx| { + // Find which allocator this belongs to + for (name, alloc) in &self.allocators { + if global_idx >= alloc.start_index + && global_idx < alloc.start_index + alloc.capacity + { + let local_idx = global_idx - alloc.start_index; + let qubit_ref = QubitRef::new(name.clone(), local_idx); + if let Some(&wire) = qubit_wires.get(&qubit_ref) { + // MeasureFree consumes qubit and produces bool + let measure_result = builder + .add_dataflow_op(TketOp::MeasureFree, vec![wire]) + .map_err(|e| HugrError::BuilderError(e.to_string())) + .ok()? + .outputs() + .next()?; + return Some(measure_result); + } + } + } + None + }) + .collect::>>() + .ok_or_else(|| HugrError::BuilderError("Failed to measure all qubits".to_string()))?; + + // Finish HUGR + builder + .finish_hugr_with_outputs(output_wires) + .map_err(|e| HugrError::BuilderError(e.to_string())) + } + + fn build_empty_hugr(&self) -> HugrResult { + let signature = Signature::new(vec![], vec![]); + let builder = + DFGBuilder::new(signature).map_err(|e| HugrError::BuilderError(e.to_string()))?; + builder + .finish_hugr_with_outputs(vec![]) + .map_err(|e| HugrError::BuilderError(e.to_string())) + } + + fn apply_gate( + &self, + builder: &mut DFGBuilder, + qubit_wires: &mut BTreeMap, + classical_wires: &mut BTreeMap, + gate_op: &GateOp, + ) -> HugrResult<()> { + match gate_op { + GateOp::Direct { op, qubits, angle } => { + self.apply_direct_gate(builder, qubit_wires, *op, qubits, *angle)?; + } + + GateOp::Swap { qubit_a, qubit_b } => { + // SWAP decomposition: CX(a,b) CX(b,a) CX(a,b) + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_a.clone(), qubit_b.clone()], + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_b.clone(), qubit_a.clone()], + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_a.clone(), qubit_b.clone()], + None, + )?; + } + + GateOp::ISwap { qubit_a, qubit_b } => { + // iSWAP decomposition: S(a) S(b) H(a) CX(a,b) CX(b,a) H(b) + self.apply_direct_gate(builder, qubit_wires, TketOp::S, std::slice::from_ref(qubit_a), None)?; + self.apply_direct_gate(builder, qubit_wires, TketOp::S, std::slice::from_ref(qubit_b), None)?; + self.apply_direct_gate(builder, qubit_wires, TketOp::H, std::slice::from_ref(qubit_a), None)?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_a.clone(), qubit_b.clone()], + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::CX, + &[qubit_b.clone(), qubit_a.clone()], + None, + )?; + self.apply_direct_gate(builder, qubit_wires, TketOp::H, std::slice::from_ref(qubit_b), None)?; + } + + GateOp::MidMeasure { qubit, result_var } => { + // Mid-circuit measurement: Measure keeps the qubit alive + let wire = qubit_wires + .get(qubit) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", qubit.allocator, qubit.index), + })?; + + // Measure produces (qubit, bool) + let outputs: Vec = builder + .add_dataflow_op(TketOp::Measure, vec![wire]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + + // Update qubit wire (first output) + if let Some(&qubit_wire) = outputs.first() { + qubit_wires.insert(qubit.clone(), qubit_wire); + } + + // Store classical result (second output) + if let Some(&bool_wire) = outputs.get(1) { + classical_wires.insert(result_var.clone(), bool_wire); + } + } + + GateOp::Conditional { + condition_var, + then_ops, + else_ops, + } => { + // Get the classical condition wire + let condition_wire = classical_wires + .get(condition_var) + .copied() + .ok_or_else(|| HugrError::BuilderError(format!( + "Classical variable '{}' not found for conditional", + condition_var + )))?; + + // Collect all qubits used in both branches + let mut used_qubits: Vec = Vec::new(); + self.collect_used_qubits(then_ops, &mut used_qubits); + self.collect_used_qubits(else_ops, &mut used_qubits); + + // Deduplicate while preserving order + let mut seen = std::collections::BTreeSet::new(); + used_qubits.retain(|q| seen.insert(q.clone())); + + if used_qubits.is_empty() { + // No qubits affected - just skip this conditional + return Ok(()); + } + + // Collect input wires for the conditional + let qubit_inputs: Vec<(tket::hugr::types::Type, Wire)> = used_qubits + .iter() + .map(|q| { + let wire = qubit_wires.get(q).copied().ok_or_else(|| { + HugrError::UndefinedQubit { + name: format!("{}[{}]", q.allocator, q.index), + } + })?; + Ok((qb_t(), wire)) + }) + .collect::>>()?; + + // Output types are the same as input types (all qubits) + let output_types: Vec<_> = used_qubits.iter().map(|_| qb_t()).collect(); + + // Build the conditional + // HUGR bool is Sum where 0=false, 1=true + let mut conditional = builder + .conditional_builder( + ([type_row![], type_row![]], condition_wire), + qubit_inputs, + output_types.into(), + ) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + + // Case 0: false branch (else_ops) + { + let mut case0 = conditional + .case_builder(0) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let input_wires: Vec = case0.input_wires().collect(); + + // Create temporary wire mapping for this branch + let mut branch_qubit_wires: BTreeMap = used_qubits + .iter() + .zip(input_wires.iter()) + .map(|(q, &w)| (q.clone(), w)) + .collect(); + let mut branch_classical_wires = classical_wires.clone(); + + // Apply else operations + for op in else_ops { + self.apply_gate_in_case(&mut case0, &mut branch_qubit_wires, &mut branch_classical_wires, op)?; + } + + // Collect output wires in the same order as used_qubits + let output_wires: Vec = used_qubits + .iter() + .map(|q| branch_qubit_wires[q]) + .collect(); + + case0 + .finish_with_outputs(output_wires) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + } + + // Case 1: true branch (then_ops) + { + let mut case1 = conditional + .case_builder(1) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let input_wires: Vec = case1.input_wires().collect(); + + // Create temporary wire mapping for this branch + let mut branch_qubit_wires: BTreeMap = used_qubits + .iter() + .zip(input_wires.iter()) + .map(|(q, &w)| (q.clone(), w)) + .collect(); + let mut branch_classical_wires = classical_wires.clone(); + + // Apply then operations + for op in then_ops { + self.apply_gate_in_case(&mut case1, &mut branch_qubit_wires, &mut branch_classical_wires, op)?; + } + + // Collect output wires in the same order as used_qubits + let output_wires: Vec = used_qubits + .iter() + .map(|q| branch_qubit_wires[q]) + .collect(); + + case1 + .finish_with_outputs(output_wires) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + } + + // Finish conditional and update qubit wires + let cond_handle = conditional + .finish_sub_container() + .map_err(|e: BuildError| HugrError::BuilderError(e.to_string()))?; + + // Update qubit wires with conditional outputs + for (i, qubit_ref) in used_qubits.iter().enumerate() { + if let Some(wire) = cond_handle.outputs().nth(i) { + qubit_wires.insert(qubit_ref.clone(), wire); + } + } + } + } + Ok(()) + } + + /// Collect all qubits used in a list of operations. + fn collect_used_qubits(&self, ops: &[GateOp], qubits: &mut Vec) { + for op in ops { + match op { + GateOp::Direct { qubits: qs, .. } => qubits.extend(qs.iter().cloned()), + GateOp::Swap { qubit_a, qubit_b } => { + qubits.push(qubit_a.clone()); + qubits.push(qubit_b.clone()); + } + GateOp::ISwap { qubit_a, qubit_b } => { + qubits.push(qubit_a.clone()); + qubits.push(qubit_b.clone()); + } + GateOp::MidMeasure { qubit, .. } => qubits.push(qubit.clone()), + GateOp::Conditional { + then_ops, + else_ops, + .. + } => { + self.collect_used_qubits(then_ops, qubits); + self.collect_used_qubits(else_ops, qubits); + } + } + } + } + + /// Apply a gate operation inside a case builder (for conditionals). + fn apply_gate_in_case( + &self, + builder: &mut T, + qubit_wires: &mut BTreeMap, + classical_wires: &mut BTreeMap, + gate_op: &GateOp, + ) -> HugrResult<()> { + match gate_op { + GateOp::Direct { op, qubits, angle } => { + // Collect input wires for this gate + let input_wires: Vec = qubits + .iter() + .map(|q| { + qubit_wires + .get(q) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", q.allocator, q.index), + }) + }) + .collect::>>()?; + + // Handle rotation angle if present + let all_inputs = if let Some(angle_radians) = angle { + let half_turns = angle_radians / std::f64::consts::PI; + use tket::extension::rotation::ConstRotation; + let const_rotation = ConstRotation::new(half_turns) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let rotation_wire = builder.add_load_value(const_rotation); + let mut inputs = input_wires; + inputs.push(rotation_wire); + inputs + } else { + input_wires + }; + + // Add the gate operation + let output_wires: Vec = builder + .add_dataflow_op(*op, all_inputs) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + + // Update wire mappings + for (i, qubit_ref) in qubits.iter().enumerate() { + if let Some(&wire) = output_wires.get(i) { + qubit_wires.insert(qubit_ref.clone(), wire); + } + } + } + + GateOp::Swap { qubit_a, qubit_b } => { + // SWAP decomposition: CX(a,b) CX(b,a) CX(a,b) + for (q1, q2) in [(qubit_a, qubit_b), (qubit_b, qubit_a), (qubit_a, qubit_b)] { + let in_wires: Vec = vec![qubit_wires[q1], qubit_wires[q2]]; + let out_wires: Vec = builder + .add_dataflow_op(TketOp::CX, in_wires) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + qubit_wires.insert(q1.clone(), out_wires[0]); + qubit_wires.insert(q2.clone(), out_wires[1]); + } + } + + GateOp::ISwap { qubit_a, qubit_b } => { + // iSWAP decomposition: S(a) S(b) H(a) CX(a,b) CX(b,a) H(b) + for (op, qs) in [ + (TketOp::S, vec![qubit_a]), + (TketOp::S, vec![qubit_b]), + (TketOp::H, vec![qubit_a]), + ] { + for q in qs { + let in_wire = qubit_wires[q]; + let out_wire = builder + .add_dataflow_op(op, vec![in_wire]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .next() + .unwrap(); + qubit_wires.insert(q.clone(), out_wire); + } + } + // CX gates + for (q1, q2) in [(qubit_a, qubit_b), (qubit_b, qubit_a)] { + let in_wires: Vec = vec![qubit_wires[q1], qubit_wires[q2]]; + let out_wires: Vec = builder + .add_dataflow_op(TketOp::CX, in_wires) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + qubit_wires.insert(q1.clone(), out_wires[0]); + qubit_wires.insert(q2.clone(), out_wires[1]); + } + // Final H(b) + let in_wire = qubit_wires[qubit_b]; + let out_wire = builder + .add_dataflow_op(TketOp::H, vec![in_wire]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .next() + .unwrap(); + qubit_wires.insert(qubit_b.clone(), out_wire); + } + + GateOp::MidMeasure { qubit, result_var } => { + let wire = qubit_wires + .get(qubit) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", qubit.allocator, qubit.index), + })?; + + let outputs: Vec = builder + .add_dataflow_op(TketOp::Measure, vec![wire]) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + + if let Some(&qubit_wire) = outputs.first() { + qubit_wires.insert(qubit.clone(), qubit_wire); + } + if let Some(&bool_wire) = outputs.get(1) { + classical_wires.insert(result_var.clone(), bool_wire); + } + } + + GateOp::Conditional { .. } => { + // Nested conditionals not yet supported in cases + return Err(HugrError::BuilderError( + "Nested conditionals not yet supported".to_string(), + )); + } + } + Ok(()) + } + + /// Apply a direct TketOp gate. + fn apply_direct_gate( + &self, + builder: &mut DFGBuilder, + qubit_wires: &mut BTreeMap, + op: TketOp, + qubits: &[QubitRef], + angle: Option, + ) -> HugrResult<()> { + // Collect input wires for this gate + let input_wires: Vec = qubits + .iter() + .map(|q| { + qubit_wires + .get(q) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", q.allocator, q.index), + }) + }) + .collect::>>()?; + + // For rotation gates, we need to add the angle as a constant + let all_inputs = if let Some(angle_radians) = angle { + // Convert radians to half-turns (HUGR uses half-turns) + let half_turns = angle_radians / std::f64::consts::PI; + + // Create rotation constant and load it + use tket::extension::rotation::ConstRotation; + let const_rotation = ConstRotation::new(half_turns) + .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let rotation_wire = builder.add_load_value(const_rotation); + + let mut inputs = input_wires; + inputs.push(rotation_wire); + inputs + } else { + input_wires + }; + + // Add the gate operation + let output_wires: Vec = builder + .add_dataflow_op(op, all_inputs) + .map_err(|e| HugrError::BuilderError(e.to_string()))? + .outputs() + .collect(); + + // Update wire mappings + for (i, qubit_ref) in qubits.iter().enumerate() { + if let Some(&wire) = output_wires.get(i) { + qubit_wires.insert(qubit_ref.clone(), wire); + } + } + + Ok(()) + } + + /// Serialize a HUGR to bytes (text envelope format). + /// + /// This format can be consumed by PECOS's hugr_engine() and sim(). + pub fn to_bytes(&self, hugr: &Hugr) -> HugrResult> { + let mut buffer = Cursor::new(Vec::new()); + hugr.store(&mut buffer, EnvelopeConfig::text()) + .map_err(|e| HugrError::SerializationError(e.to_string()))?; + Ok(buffer.into_inner()) + } + + /// Serialize a HUGR to a string (text envelope format). + /// + /// This format can be consumed by PECOS's hugr_engine() and sim(). + pub fn to_string(&self, hugr: &Hugr) -> HugrResult { + let bytes = self.to_bytes(hugr)?; + String::from_utf8(bytes).map_err(|e| HugrError::SerializationError(e.to_string())) + } +} + +impl Default for HugrCodegen { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + use tket::hugr::HugrView; + + fn compile_to_hugr(source: &str) -> HugrResult { + let program = parse(source).expect("parse failed"); + let mut codegen = HugrCodegen::new(); + codegen.compile(&program) + } + + #[test] + fn test_empty_program() { + let hugr = compile_to_hugr("").unwrap(); + assert!(hugr.num_nodes() > 0); // At least root node + } + + #[test] + fn test_single_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have input, h gate, output nodes + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_bell_state() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have input, h, cx, output nodes + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_rotation_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + rz(1.57, q[0]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_allocator_tracking() { + let source = r#" + pub fn main() -> unit { + mut base := qalloc(4); + mut q := base.child(2); + h q[0]; + h q[1]; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_ccx_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + ccx (q[0], q[1], q[2]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_wrong_argument_count() { + // h is a single-qubit gate, so using batch syntax with two qubits should work fine + // This test was originally testing the old call syntax which is no longer valid + // Let's test that CX with wrong number of qubits fails + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + cx (q[0], q[0]); // CX needs 2 different qubits + } + "#; + + // This should compile (same qubit used twice is a logic issue, not arity issue) + // The arity check happens at the gate expression level, not here + let result = compile_to_hugr(source); + // Accept either ok or specific error + match &result { + Ok(_) => {} // Valid syntax, even if logically odd + Err(_) => {} // Some backends may reject + } + } + + #[test] + fn test_qubit_index_out_of_bounds() { + // Qubit bounds checking is done at semantic analysis, not HUGR codegen + // Use the semantic analyzer directly to verify bounds checking + use crate::semantic::SemanticAnalyzer; + + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[5]; + } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + + assert!(result.is_err(), "Expected QubitIndexOutOfBounds error"); + assert!( + matches!(result, Err(crate::semantic::SemanticError::QubitIndexOutOfBounds { index: 5, capacity: 2, .. })), + "Expected QubitIndexOutOfBounds error, got: {:?}", result + ); + } + + #[test] + fn test_swap_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + x(q[0]); + swap(q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // swap decomposes to 3 cx gates, so should have more nodes + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_iswap_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + iswap(q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // iswap decomposes to s, s, h, cx, cx, h + assert!(hugr.num_nodes() >= 6); + } + + #[test] + fn test_mid_circuit_measure() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + mz(u1) q[0]; + cx (q[0], q[1]); + } + "#; + + // Mid-circuit measurement should compile without error + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_sx_and_sxdg_gates() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + sx(q[0]); + sxdg(q[0]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_sy_and_sydg_gates() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + sy(q[0]); + sydg(q[0]); + } + "#; + + // SY and SYdg decompose to Ry gates + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_controlled_rotation() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + crz(1.57, q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_reset_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + x(q[0]); + reset(q[0]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_classical_conditional() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + mut result := mz(u1) q[0]; + if (result) { + x(q[1]); + } + } + "#; + + // Classical conditional should compile without error + let hugr = compile_to_hugr(source).unwrap(); + // Should have conditional node in addition to gates + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_classical_conditional_with_else() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + mut result := mz(u1) q[0]; + if (result) { + x(q[1]); + } else { + z(q[1]); + } + } + "#; + + // Classical conditional with else should compile without error + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_ch_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + ch (q[0], q[1]); + } + "#; + + // CH decomposes to Ry CZ Ry + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_szz_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + szz(q[0], q[1]); + } + "#; + + // SZZ decomposes to CX S CX + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_sxx_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + sxx(q[0], q[1]); + } + "#; + + // SXX decomposes to H H (SZZ) H H + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 6); + } + + #[test] + fn test_syy_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + syy(q[0], q[1]); + } + "#; + + // SYY decomposes to Vdg Vdg (SZZ) V V + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 6); + } + + #[test] + fn test_rzz_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + rzz(1.57, q[0], q[1]); + } + "#; + + // RZZ decomposes to CX Rz CX + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_f_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + f(q[0]); + } + "#; + + // F decomposes to H Sdg H Sdg + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_fdg_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + fdg(q[0]); + } + "#; + + // Fdg decomposes to S H S H + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 5); + } + + #[test] + fn test_f4_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + f4(q[0]); + } + "#; + + // F4 decomposes to Ry Rz + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_ising_dagger_gates() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + sxxdg(q[0], q[1]); + syydg(q[0], q[1]); + szzdg(q[0], q[1]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 10); + } + + // ========================================================================= + // Batch Operations Tests + // ========================================================================= + + #[test] + fn test_batch_single_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + h {q[0], q[1], q[2]}; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 3 H gates + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_batch_two_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + cx {(q[0], q[1]), (q[2], q[3])}; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 2 CX gates + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_batch_rotation_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + rz(1/8 turns) {q[0], q[1]}; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 2 Rz gates + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_batch_array_syntax() { + // Test &[...] syntax for batch gates + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + h(&[q[0], q[1], q[2]]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 3 H gates + assert!(hugr.num_nodes() >= 4); + } + + #[test] + fn test_batch_array_two_qubit() { + // Test &[(a,b), (c,d)] syntax for batch two-qubit gates + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + cx(&[(q[0], q[1]), (q[2], q[3])]); + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should expand to 2 CX gates + assert!(hugr.num_nodes() >= 3); + } + + // ========================================================================= + // Tick Block Tests + // ========================================================================= + + #[test] + fn test_tick_block() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick { + h q[0]; + h q[1]; + } + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have 2 H gates (tick block is flattened) + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_nested_tick_blocks() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick outer { + tick layer1 { + h {q[0], q[1]}; + } + tick layer2 { + cx {(q[0], q[2]), (q[1], q[3])}; + } + } + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have 2 H + 2 CX gates + assert!(hugr.num_nodes() >= 5); + } + + // ========================================================================= + // Typed Measurement Tests + // ========================================================================= + + #[test] + fn test_typed_measurement_single() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + r := mz(u1) q[0]; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + assert!(hugr.num_nodes() >= 3); + } + + #[test] + fn test_typed_measurement_array() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + h {q[0], q[1], q[2]}; + results := mz([3]u1) [q[0], q[1], q[2]]; + } + "#; + + let hugr = compile_to_hugr(source).unwrap(); + // Should have 3 H gates + 3 measurements + assert!(hugr.num_nodes() >= 7); + } +} diff --git a/exp/zlup/src/codegen/phir.rs b/exp/zlup/src/codegen/phir.rs new file mode 100644 index 000000000..9d5e5ba95 --- /dev/null +++ b/exp/zlup/src/codegen/phir.rs @@ -0,0 +1,1322 @@ +//! PHIR/JSON code generation for Zlup. +//! +//! This module generates **PHIR/JSON** (the JSON serialization of PHIR) from Zlup AST. +//! +//! ## PHIR vs PHIR/JSON +//! +//! - **PHIR** (PECOS High-level Intermediate Representation): The abstract IR for +//! representing hybrid quantum-classical programs. Defined in the `pecos-phir` crate. +//! - **PHIR/JSON**: The JSON serialization format for PHIR programs, as specified in +//! the `pecos-phir-json` crate (v0.1.0). This is what this module generates. +//! +//! ## Design Philosophy +//! +//! PHIR/JSON provides: +//! - Explicit variable definitions (quantum and classical) +//! - Quantum operations with qubit references +//! - Classical operations with AST-style expressions +//! - Control flow via if/else blocks +//! - Parallel execution via qparallel blocks +//! +//! ## Output Format +//! +//! The output conforms to PHIR/JSON specification v0.1.0: +//! +//! ```json +//! { +//! "format": "PHIR/JSON", +//! "version": "0.1.0", +//! "metadata": {"program_name": "main"}, +//! "ops": [ +//! {"data": "qvar_define", "variable": "q", "size": 2}, +//! {"qop": "H", "args": [["q", 0]]}, +//! {"qop": "CX", "args": [[["q", 0], ["q", 1]]]} +//! ] +//! } +//! ``` + +use std::collections::BTreeMap; +use thiserror::Error; + +use crate::ast::{ + BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, ForRange, GateKind, GateOp, + IfStmt, IntLit, MeasureOp, PrepareOp, Program, Stmt, TickStmt, TopLevelDecl, UnaryOp, +}; + +// ============================================================================= +// Errors +// ============================================================================= + +/// PHIR/JSON code generation errors. +#[derive(Debug, Error)] +pub enum PhirJsonError { + #[error("unknown gate '{name}'")] + UnknownGate { name: String }, + + #[error("undefined allocator '{name}'")] + UndefinedAllocator { name: String }, + + #[error("qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}")] + QubitIndexOutOfBounds { + allocator: String, + index: usize, + capacity: usize, + }, + + #[error("expected {expected} arguments for gate '{gate}', got {got}")] + WrongArgumentCount { + gate: String, + expected: usize, + got: usize, + }, + + #[error("unsupported expression in PHIR codegen")] + UnsupportedExpression, + + #[error("invalid rotation angle")] + InvalidAngle, + + #[error("JSON serialization error: {0}")] + JsonError(String), + + #[error("unsupported statement in PHIR codegen: {0}")] + UnsupportedStatement(String), +} + +/// Result type for PHIR/JSON code generation. +pub type PhirJsonResult = Result; + +// ============================================================================= +// PHIR/JSON Node Types +// ============================================================================= + +/// Top-level PHIR/JSON program structure. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonProgram { + pub format: &'static str, + pub version: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, + pub ops: Vec, +} + +impl Default for PhirJsonProgram { + fn default() -> Self { + Self::new() + } +} + +impl PhirJsonProgram { + pub fn new() -> Self { + Self { + format: "PHIR/JSON", + version: "0.1.0", + metadata: None, + ops: Vec::new(), + } + } + + pub fn with_name(mut self, name: impl Into) -> Self { + self.metadata = Some(PhirJsonMetadata { + program_name: Some(name.into()), + description: None, + strict_parallelism: None, + }); + self + } +} + +/// PHIR/JSON metadata. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub program_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub strict_parallelism: Option, +} + +/// PHIR/JSON operation - can be data, qop, cop, mop, or block. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum PhirJsonOp { + Comment(PhirJsonComment), + QvarDefine(PhirJsonQvarDefine), + CvarDefine(PhirJsonCvarDefine), + CvarExport(PhirJsonCvarExport), + Qop(PhirJsonQop), + Cop(PhirJsonCop), + Block(PhirJsonBlock), + Barrier(PhirJsonBarrier), +} + +/// PHIR/JSON comment. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonComment { + #[serde(rename = "//")] + pub comment: String, +} + +/// PHIR/JSON quantum variable definition. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonQvarDefine { + pub data: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub data_type: Option<&'static str>, + pub variable: String, + pub size: usize, +} + +impl PhirJsonQvarDefine { + pub fn new(variable: impl Into, size: usize) -> Self { + Self { + data: "qvar_define", + data_type: Some("qubits"), + variable: variable.into(), + size, + } + } +} + +/// PHIR/JSON classical variable definition. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonCvarDefine { + pub data: &'static str, + pub data_type: String, + pub variable: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +impl PhirJsonCvarDefine { + pub fn new(variable: impl Into, size: usize) -> Self { + Self { + data: "cvar_define", + data_type: "i64".to_string(), + variable: variable.into(), + size: Some(size), + } + } +} + +/// PHIR/JSON classical variable export. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonCvarExport { + pub data: &'static str, + pub variables: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub to: Option>, +} + +impl PhirJsonCvarExport { + pub fn new(variables: Vec) -> Self { + Self { + data: "cvar_export", + variables, + to: None, + } + } +} + +/// PHIR/JSON quantum operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonQop { + pub qop: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub angles: Option<(Vec, String)>, + pub args: serde_json::Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub returns: Option, +} + +impl PhirJsonQop { + /// Create a single-qubit gate operation. + pub fn single_qubit(gate: impl Into, qubits: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + qop: gate.into(), + angles: None, + args: serde_json::Value::Array(args), + returns: None, + } + } + + /// Create a two-qubit gate operation. + pub fn two_qubit(gate: impl Into, pairs: Vec<((String, usize), (String, usize))>) -> Self { + let args: Vec = pairs + .into_iter() + .map(|((n1, i1), (n2, i2))| { + serde_json::json!([[n1, i1], [n2, i2]]) + }) + .collect(); + Self { + qop: gate.into(), + angles: None, + args: serde_json::Value::Array(args), + returns: None, + } + } + + /// Create a single-qubit rotation. + pub fn rotation(gate: impl Into, angle: f64, unit: &str, qubits: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + qop: gate.into(), + angles: Some((vec![angle], unit.to_string())), + args: serde_json::Value::Array(args), + returns: None, + } + } + + /// Create a measurement operation. + pub fn measure(qubits: Vec<(String, usize)>, results: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + let rets: Vec = results + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + qop: "Measure".to_string(), + angles: None, + args: serde_json::Value::Array(args), + returns: Some(serde_json::Value::Array(rets)), + } + } + + /// Create an Init operation. + pub fn init(qubits: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + qop: "Init".to_string(), + angles: None, + args: serde_json::Value::Array(args), + returns: None, + } + } +} + +/// PHIR/JSON classical operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonCop { + pub cop: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub args: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub returns: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub function: Option, +} + +impl PhirJsonCop { + /// Create an assignment operation. + pub fn assign(value: serde_json::Value, target: serde_json::Value) -> Self { + Self { + cop: "=".to_string(), + args: Some(serde_json::Value::Array(vec![value])), + returns: Some(serde_json::Value::Array(vec![target])), + function: None, + } + } + + /// Create a Result export operation. + pub fn result(sources: Vec, targets: Vec) -> Self { + Self { + cop: "Result".to_string(), + args: Some(serde_json::Value::Array( + sources.into_iter().map(serde_json::Value::String).collect(), + )), + returns: Some(serde_json::Value::Array( + targets.into_iter().map(serde_json::Value::String).collect(), + )), + function: None, + } + } +} + +/// PHIR/JSON block (sequence, qparallel, if). +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonBlock { + pub block: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ops: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub condition: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub true_branch: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub false_branch: Option>, +} + +impl PhirJsonBlock { + /// Create a qparallel block. + pub fn qparallel(ops: Vec) -> Self { + Self { + block: "qparallel".to_string(), + ops: Some(ops), + condition: None, + true_branch: None, + false_branch: None, + } + } + + /// Create an if block. + pub fn if_block( + condition: serde_json::Value, + true_branch: Vec, + false_branch: Option>, + ) -> Self { + Self { + block: "if".to_string(), + ops: None, + condition: Some(condition), + true_branch: Some(true_branch), + false_branch, + } + } +} + +/// PHIR/JSON barrier meta instruction. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PhirJsonBarrier { + pub meta: &'static str, + pub args: serde_json::Value, +} + +impl PhirJsonBarrier { + pub fn new(qubits: Vec<(String, usize)>) -> Self { + let args: Vec = qubits + .into_iter() + .map(|(name, idx)| serde_json::json!([name, idx])) + .collect(); + Self { + meta: "barrier", + args: serde_json::Value::Array(args), + } + } +} + +// ============================================================================= +// Gate Information +// ============================================================================= + +/// Gate information for PHIR/JSON output. +struct GateInfo { + /// Gate name in PHIR/JSON. + phir_name: &'static str, + /// Number of qubits (1 or 2). + num_qubits: usize, + /// Number of angle parameters. + num_angles: usize, +} + +/// Get gate info from GateKind. +fn get_gate_info(kind: GateKind) -> GateInfo { + match kind { + // Single-qubit gates + GateKind::H => GateInfo { phir_name: "H", num_qubits: 1, num_angles: 0 }, + GateKind::X => GateInfo { phir_name: "X", num_qubits: 1, num_angles: 0 }, + GateKind::Y => GateInfo { phir_name: "Y", num_qubits: 1, num_angles: 0 }, + GateKind::Z => GateInfo { phir_name: "Z", num_qubits: 1, num_angles: 0 }, + GateKind::T => GateInfo { phir_name: "T", num_qubits: 1, num_angles: 0 }, + GateKind::Tdg => GateInfo { phir_name: "Tdg", num_qubits: 1, num_angles: 0 }, + GateKind::SX => GateInfo { phir_name: "SX", num_qubits: 1, num_angles: 0 }, + GateKind::SXdg => GateInfo { phir_name: "SXdg", num_qubits: 1, num_angles: 0 }, + GateKind::SY => GateInfo { phir_name: "SY", num_qubits: 1, num_angles: 0 }, + GateKind::SYdg => GateInfo { phir_name: "SYdg", num_qubits: 1, num_angles: 0 }, + GateKind::SZ => GateInfo { phir_name: "SZ", num_qubits: 1, num_angles: 0 }, + GateKind::SZdg => GateInfo { phir_name: "SZdg", num_qubits: 1, num_angles: 0 }, + GateKind::F => GateInfo { phir_name: "F", num_qubits: 1, num_angles: 0 }, + GateKind::Fdg => GateInfo { phir_name: "Fdg", num_qubits: 1, num_angles: 0 }, + GateKind::F4 => GateInfo { phir_name: "F4", num_qubits: 1, num_angles: 0 }, + GateKind::F4dg => GateInfo { phir_name: "F4dg", num_qubits: 1, num_angles: 0 }, + + // Single-qubit rotations + GateKind::RX => GateInfo { phir_name: "RX", num_qubits: 1, num_angles: 1 }, + GateKind::RY => GateInfo { phir_name: "RY", num_qubits: 1, num_angles: 1 }, + GateKind::RZ => GateInfo { phir_name: "RZ", num_qubits: 1, num_angles: 1 }, + + // Two-qubit gates + GateKind::CX => GateInfo { phir_name: "CX", num_qubits: 2, num_angles: 0 }, + GateKind::CY => GateInfo { phir_name: "CY", num_qubits: 2, num_angles: 0 }, + GateKind::CZ => GateInfo { phir_name: "CZ", num_qubits: 2, num_angles: 0 }, + GateKind::CH => GateInfo { phir_name: "CH", num_qubits: 2, num_angles: 0 }, + GateKind::SWAP => GateInfo { phir_name: "SWAP", num_qubits: 2, num_angles: 0 }, + GateKind::ISWAP => GateInfo { phir_name: "ISWAP", num_qubits: 2, num_angles: 0 }, + GateKind::SXX => GateInfo { phir_name: "SXX", num_qubits: 2, num_angles: 0 }, + GateKind::SXXdg => GateInfo { phir_name: "SXXdg", num_qubits: 2, num_angles: 0 }, + GateKind::SYY => GateInfo { phir_name: "SYY", num_qubits: 2, num_angles: 0 }, + GateKind::SYYdg => GateInfo { phir_name: "SYYdg", num_qubits: 2, num_angles: 0 }, + GateKind::SZZ => GateInfo { phir_name: "SZZ", num_qubits: 2, num_angles: 0 }, + GateKind::SZZdg => GateInfo { phir_name: "SZZdg", num_qubits: 2, num_angles: 0 }, + GateKind::RZZ => GateInfo { phir_name: "RZZ", num_qubits: 2, num_angles: 1 }, + + // Three-qubit gates + GateKind::CCX => GateInfo { phir_name: "CCX", num_qubits: 3, num_angles: 0 }, + + // Prepare operations (treated as Init) + GateKind::PZ => GateInfo { phir_name: "Init", num_qubits: 1, num_angles: 0 }, + } +} + +// ============================================================================= +// Allocator Tracking +// ============================================================================= + +/// Tracks an allocator during codegen. +#[derive(Debug, Clone)] +struct AllocatorInfo { + name: String, + capacity: usize, +} + +/// Tracks a classical register during codegen. +#[derive(Debug, Clone)] +struct RegisterInfo { + name: String, + size: usize, +} + +// ============================================================================= +// PHIR/JSON Code Generator +// ============================================================================= + +/// PHIR/JSON code generator. +/// +/// Walks a Zlup AST and produces PHIR/JSON output. +pub struct PhirJsonCodegen { + /// Allocators by name. + allocators: BTreeMap, + /// Classical registers by name. + registers: BTreeMap, + /// Auto-generated register counter. + register_counter: usize, +} + +impl Default for PhirJsonCodegen { + fn default() -> Self { + Self::new() + } +} + +impl PhirJsonCodegen { + /// Create a new PHIR/JSON code generator. + pub fn new() -> Self { + Self { + allocators: BTreeMap::new(), + registers: BTreeMap::new(), + register_counter: 0, + } + } + + /// Compile a Zlup program to PHIR/JSON. + pub fn compile(&mut self, program: &Program) -> PhirJsonResult { + let mut phir = PhirJsonProgram::new().with_name("main"); + + // First pass: collect allocators + for decl in &program.declarations { + self.collect_decl(decl)?; + } + + // Add quantum variable definitions + for alloc in self.allocators.values() { + phir.ops.push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( + &alloc.name, + alloc.capacity, + ))); + } + + // Add classical variable definitions + for reg in self.registers.values() { + phir.ops.push(PhirJsonOp::CvarDefine(PhirJsonCvarDefine::new( + ®.name, + reg.size, + ))); + } + + // Second pass: convert main function body + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl + && fn_decl.name == "main" { + let ops = self.convert_block(&fn_decl.body)?; + phir.ops.extend(ops); + } + } + + // Export all classical variables + if !self.registers.is_empty() { + let vars: Vec = self.registers.keys().cloned().collect(); + phir.ops.push(PhirJsonOp::CvarExport(PhirJsonCvarExport::new(vars))); + } + + Ok(phir) + } + + /// Compile a function to PHIR/JSON. + pub fn compile_function(&mut self, fn_decl: &FnDecl) -> PhirJsonResult { + // Collect from function body + self.collect_block(&fn_decl.body)?; + + let mut phir = PhirJsonProgram::new().with_name(&fn_decl.name); + + // Add definitions + for alloc in self.allocators.values() { + phir.ops.push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( + &alloc.name, + alloc.capacity, + ))); + } + + for reg in self.registers.values() { + phir.ops.push(PhirJsonOp::CvarDefine(PhirJsonCvarDefine::new( + ®.name, + reg.size, + ))); + } + + // Convert body + let ops = self.convert_block(&fn_decl.body)?; + phir.ops.extend(ops); + + Ok(phir) + } + + /// Convert to JSON string. + pub fn to_json(&self, program: &PhirJsonProgram) -> PhirJsonResult { + serde_json::to_string_pretty(program).map_err(|e| PhirJsonError::JsonError(e.to_string())) + } + + /// Convert to compact JSON string. + pub fn to_json_compact(&self, program: &PhirJsonProgram) -> PhirJsonResult { + serde_json::to_string(program).map_err(|e| PhirJsonError::JsonError(e.to_string())) + } + + // ========================================================================= + // Collection Phase + // ========================================================================= + + fn collect_decl(&mut self, decl: &TopLevelDecl) -> PhirJsonResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) => { + if fn_decl.name == "main" { + self.collect_block(&fn_decl.body)?; + } + } + TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, + _ => {} + } + Ok(()) + } + + fn collect_block(&mut self, block: &Block) -> PhirJsonResult<()> { + for stmt in &block.statements { + self.collect_stmt(stmt)?; + } + Ok(()) + } + + fn collect_stmt(&mut self, stmt: &Stmt) -> PhirJsonResult<()> { + match stmt { + Stmt::Binding(binding) => self.collect_binding(binding)?, + Stmt::If(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + ElseBranch::Else(block) => self.collect_block(block)?, + ElseBranch::ElseIf(nested_if) => { + self.collect_stmt(&Stmt::If(*nested_if.clone()))?; + } + } + } + } + Stmt::For(for_stmt) => { + self.collect_block(&for_stmt.body)?; + } + Stmt::Tick(tick_stmt) => { + for stmt in &tick_stmt.body { + self.collect_stmt(stmt)?; + } + } + Stmt::Block(block) => { + self.collect_block(block)?; + } + _ => {} + } + Ok(()) + } + + fn collect_binding(&mut self, binding: &Binding) -> PhirJsonResult<()> { + if let Some(ref init) = binding.value { + // Check for qalloc + if let Expr::Call(call) = init + && self.get_callee_name(call) == Some("qalloc".to_string()) + && let Some(Expr::IntLit(IntLit { value, .. })) = call.args.first() { + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity: *value as usize, + }, + ); + } + + // Check for typed measurement (creates a register) + if let Expr::Call(call) = init + && let Some(name) = self.get_callee_name(call) + && (name == "mz" || name == "mx" || name == "my") { + let size = call.args.len().max(1); + self.registers.insert( + binding.name.clone(), + RegisterInfo { + name: binding.name.clone(), + size, + }, + ); + } + } + Ok(()) + } + + // ========================================================================= + // Conversion Phase + // ========================================================================= + + fn convert_block(&mut self, block: &Block) -> PhirJsonResult> { + let mut ops = Vec::new(); + for stmt in &block.statements { + ops.extend(self.convert_stmt(stmt)?); + } + Ok(ops) + } + + fn convert_stmt(&mut self, stmt: &Stmt) -> PhirJsonResult> { + match stmt { + Stmt::Binding(binding) => self.convert_binding(binding), + Stmt::Expr(expr_stmt) => self.convert_expr_stmt(&expr_stmt.expr), + Stmt::If(if_stmt) => self.convert_if(if_stmt), + Stmt::For(for_stmt) => self.convert_for(for_stmt), + Stmt::Tick(tick_stmt) => self.convert_tick(tick_stmt), + Stmt::Return(_) => Ok(vec![]), + Stmt::Block(block) => self.convert_block(block), + Stmt::Gate(gate_op) => self.convert_gate(gate_op), + Stmt::Prepare(prepare_op) => self.convert_prepare(prepare_op), + Stmt::Measure(measure_op) => self.convert_measure(measure_op), + Stmt::Barrier(barrier_op) => { + let qubits: Vec<(String, usize)> = barrier_op + .allocators + .iter() + .flat_map(|alloc| { + self.allocators + .get(alloc) + .map(|info| { + (0..info.capacity) + .map(|i| (info.name.clone(), i)) + .collect::>() + }) + .unwrap_or_default() + }) + .collect(); + Ok(vec![PhirJsonOp::Barrier(PhirJsonBarrier::new(qubits))]) + } + Stmt::Break(_) | Stmt::Continue(_) => Ok(vec![]), + _ => Ok(vec![]), + } + } + + fn convert_binding(&mut self, binding: &Binding) -> PhirJsonResult> { + let mut ops = Vec::new(); + + if let Some(ref init) = binding.value { + // Check for qalloc - already handled in collection phase + if let Expr::Call(call) = init + && self.get_callee_name(call) == Some("qalloc".to_string()) { + return Ok(vec![]); + } + + // Check for measurement call (mz(...) [targets]) + if let Expr::Call(call) = init + && let Some(name) = self.get_callee_name(call) + && (name == "mz" || name == "mx" || name == "my") { + let qubits = self.extract_qubits_from_args(&call.args)?; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (binding.name.clone(), i)) + .collect(); + ops.push(PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))); + return Ok(ops); + } + + // Check for measurement expression (mz(T) targets) + if let Expr::Measure(measure_expr) = init { + let qubits = self.extract_qubits_from_target(&measure_expr.targets)?; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (binding.name.clone(), i)) + .collect(); + ops.push(PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))); + return Ok(ops); + } + + // Try to convert to a value for assignment - skip unsupported expressions + match self.convert_expr_to_value(init) { + Ok(value) => { + ops.push(PhirJsonOp::Cop(PhirJsonCop::assign( + value, + serde_json::Value::String(binding.name.clone()), + ))); + } + Err(PhirJsonError::UnsupportedExpression) => { + // Skip unsupported expressions silently - they may be quantum ops + } + Err(e) => return Err(e), + } + } + + Ok(ops) + } + + fn convert_expr_stmt(&mut self, expr: &Expr) -> PhirJsonResult> { + match expr { + Expr::Call(call) => self.convert_call(call), + Expr::Gate(gate_expr) => self.convert_gate_expr(gate_expr), + Expr::Measure(measure_expr) => self.convert_measure_expr(measure_expr), + _ => Ok(vec![]), + } + } + + fn convert_gate_expr(&self, gate_expr: &crate::ast::GateExpr) -> PhirJsonResult> { + let gate_info = get_gate_info(gate_expr.kind); + + // Handle prepare operations + if gate_info.phir_name == "Init" { + let qubits = self.extract_qubits_from_target(&gate_expr.target)?; + if qubits.is_empty() { + // Prepare all qubits in the allocator + if let Expr::Ident(ident) = &gate_expr.target + && let Some(alloc) = self.allocators.get(&ident.name) { + let all_qubits: Vec<(String, usize)> = (0..alloc.capacity) + .map(|i| (alloc.name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(all_qubits))]); + } + } + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]); + } + + let qubits = self.extract_qubits_from_target(&gate_expr.target)?; + + if gate_info.num_qubits == 1 { + if gate_info.num_angles > 0 { + // Rotation gate - get angle from params + let angle = gate_expr + .params + .first() + .and_then(|p| self.eval_expr_to_float(p)) + .unwrap_or(0.0); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::rotation( + gate_info.phir_name, + angle * std::f64::consts::TAU, + "rad", + qubits, + ))]) + } else { + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::single_qubit(gate_info.phir_name, qubits))]) + } + } else { + // Two-qubit gate - pair up qubits + if qubits.len() % 2 != 0 && gate_info.num_qubits == 2 { + return Err(PhirJsonError::WrongArgumentCount { + gate: gate_info.phir_name.to_string(), + expected: 2, + got: qubits.len(), + }); + } + let pairs: Vec<_> = qubits + .chunks(2) + .map(|chunk| (chunk[0].clone(), chunk[1].clone())) + .collect(); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::two_qubit(gate_info.phir_name, pairs))]) + } + } + + fn convert_measure_expr(&mut self, measure_expr: &crate::ast::MeasureExpr) -> PhirJsonResult> { + let qubits = self.extract_qubits_from_target(&measure_expr.targets)?; + let reg_name = format!("m{}", self.register_counter); + self.register_counter += 1; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (reg_name.clone(), i)) + .collect(); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))]) + } + + fn extract_qubits_from_target(&self, target: &Expr) -> PhirJsonResult> { + let mut qubits = Vec::new(); + match target { + Expr::Index(idx_expr) => { + if let Expr::Ident(ident) = &idx_expr.object + && let Some(idx) = self.eval_index(&idx_expr.index) { + qubits.push((ident.name.clone(), idx)); + } + } + Expr::Tuple(tuple_expr) => { + for elem in &tuple_expr.elements { + qubits.extend(self.extract_qubits_from_target(elem)?); + } + } + Expr::BracketArray(arr) => { + for elem in &arr.elements { + qubits.extend(self.extract_qubits_from_target(elem)?); + } + } + Expr::Set(set_expr) => { + for elem in &set_expr.elements { + qubits.extend(self.extract_qubits_from_target(elem)?); + } + } + Expr::SlotRef(slot_ref) => { + if let Some(idx) = self.eval_index(&slot_ref.index) { + qubits.push((slot_ref.allocator.clone(), idx)); + } + } + Expr::Ident(_) => { + // This is a bare allocator name - handled by caller + } + _ => {} + } + Ok(qubits) + } + + fn convert_call(&mut self, call: &CallExpr) -> PhirJsonResult> { + let name = self.get_callee_name(call).unwrap_or_default(); + + // Check for prepare operations + if name == "pz" || name == "px" || name == "py" { + let qubits = self.extract_qubits_from_args(&call.args)?; + if qubits.is_empty() { + // Prepare all qubits in the allocator + if let Some(Expr::Ident(ident)) = call.args.first() + && let Some(alloc) = self.allocators.get(&ident.name) { + let all_qubits: Vec<(String, usize)> = (0..alloc.capacity) + .map(|i| (alloc.name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(all_qubits))]); + } + } + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]); + } + + // Check for measurement + if name == "mz" || name == "mx" || name == "my" { + let qubits = self.extract_qubits_from_args(&call.args)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (reg_name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))]); + } + + Ok(vec![]) + } + + fn convert_gate(&self, gate_op: &GateOp) -> PhirJsonResult> { + let gate_info = get_gate_info(gate_op.kind); + + // Handle prepare operations + if gate_info.phir_name == "Init" { + let qubits = self.convert_slot_refs(&gate_op.targets); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]); + } + + // Handle measurement operations + if gate_info.phir_name == "Measure" { + let qubits = self.convert_slot_refs(&gate_op.targets); + let reg_name = format!("m{}", qubits.len()); + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (reg_name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))]); + } + + let qubits = self.convert_slot_refs(&gate_op.targets); + + if gate_info.num_qubits == 1 { + if gate_info.num_angles > 0 { + // Rotation gate - get angle from params + let angle = gate_op + .params + .first() + .and_then(|p| self.eval_expr_to_float(p)) + .unwrap_or(0.0); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::rotation( + gate_info.phir_name, + angle * std::f64::consts::TAU, + "rad", + qubits, + ))]) + } else { + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::single_qubit(gate_info.phir_name, qubits))]) + } + } else { + // Two-qubit gate - pair up qubits + if !qubits.len().is_multiple_of(2) { + return Err(PhirJsonError::WrongArgumentCount { + gate: gate_info.phir_name.to_string(), + expected: 2, + got: qubits.len(), + }); + } + let pairs: Vec<_> = qubits + .chunks(2) + .map(|chunk| (chunk[0].clone(), chunk[1].clone())) + .collect(); + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::two_qubit(gate_info.phir_name, pairs))]) + } + } + + fn convert_prepare(&self, prepare_op: &PrepareOp) -> PhirJsonResult> { + let alloc = self + .allocators + .get(&prepare_op.allocator) + .ok_or_else(|| PhirJsonError::UndefinedAllocator { + name: prepare_op.allocator.clone(), + })?; + + let qubits: Vec<(String, usize)> = if let Some(ref slots) = prepare_op.slots { + slots.iter().map(|&i| (alloc.name.clone(), i as usize)).collect() + } else { + (0..alloc.capacity) + .map(|i| (alloc.name.clone(), i)) + .collect() + }; + + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]) + } + + fn convert_measure(&mut self, measure_op: &MeasureOp) -> PhirJsonResult> { + let qubits = self.convert_slot_refs(&measure_op.targets); + let results: Vec<(String, usize)> = measure_op + .results + .iter() + .filter_map(|br| { + self.eval_index(&br.index).map(|idx| (br.register.clone(), idx)) + }) + .collect(); + + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))]) + } + + fn convert_if(&mut self, if_stmt: &IfStmt) -> PhirJsonResult> { + let condition = self.convert_expr_to_value(&if_stmt.condition)?; + let true_branch = self.convert_block(&if_stmt.then_body)?; + + let false_branch = if let Some(ref else_branch) = if_stmt.else_body { + match else_branch { + ElseBranch::Else(block) => Some(self.convert_block(block)?), + ElseBranch::ElseIf(nested_if) => Some(self.convert_if(nested_if)?), + } + } else { + None + }; + + Ok(vec![PhirJsonOp::Block(PhirJsonBlock::if_block( + condition, + true_branch, + false_branch, + ))]) + } + + fn convert_for(&mut self, for_stmt: &crate::ast::ForStmt) -> PhirJsonResult> { + let mut ops = Vec::new(); + + if let ForRange::Range { start, end, .. } = &for_stmt.range + && let (Some(start_val), Some(end_val)) = ( + self.try_eval_const(start), + self.try_eval_const(end), + ) { + for _ in start_val..end_val { + ops.extend(self.convert_block(&for_stmt.body)?); + } + return Ok(ops); + } + + Err(PhirJsonError::UnsupportedStatement( + "for loops with non-constant bounds".to_string(), + )) + } + + fn convert_tick(&mut self, tick_stmt: &TickStmt) -> PhirJsonResult> { + let mut qops = Vec::new(); + for stmt in &tick_stmt.body { + let converted = self.convert_stmt(stmt)?; + for op in converted { + if matches!(op, PhirJsonOp::Qop(_)) { + qops.push(op); + } + } + } + + if qops.is_empty() { + Ok(vec![]) + } else { + Ok(vec![PhirJsonOp::Block(PhirJsonBlock::qparallel(qops))]) + } + } + + // ========================================================================= + // Helper Methods + // ========================================================================= + + fn get_callee_name(&self, call: &CallExpr) -> Option { + match &call.callee { + Expr::Ident(ident) => Some(ident.name.clone()), + _ => None, + } + } + + fn convert_slot_refs(&self, targets: &[crate::ast::SlotRef]) -> Vec<(String, usize)> { + targets + .iter() + .filter_map(|slot| { + self.eval_index(&slot.index).map(|idx| (slot.allocator.clone(), idx)) + }) + .collect() + } + + fn extract_qubits_from_args(&self, args: &[Expr]) -> PhirJsonResult> { + let mut qubits = Vec::new(); + for arg in args { + match arg { + Expr::Index(idx_expr) => { + if let Expr::Ident(ident) = &idx_expr.object + && let Some(idx) = self.eval_index(&idx_expr.index) { + qubits.push((ident.name.clone(), idx)); + } + } + Expr::Tuple(tuple_expr) => { + for elem in &tuple_expr.elements { + if let Expr::Index(idx_expr) = elem + && let Expr::Ident(ident) = &idx_expr.object + && let Some(idx) = self.eval_index(&idx_expr.index) { + qubits.push((ident.name.clone(), idx)); + } + } + } + Expr::BracketArray(arr) => { + for elem in &arr.elements { + if let Expr::Index(idx_expr) = elem + && let Expr::Ident(ident) = &idx_expr.object + && let Some(idx) = self.eval_index(&idx_expr.index) { + qubits.push((ident.name.clone(), idx)); + } + } + } + _ => {} + } + } + Ok(qubits) + } + + fn eval_index(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(IntLit { value, .. }) => Some(*value as usize), + _ => None, + } + } + + fn eval_expr_to_float(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(IntLit { value, .. }) => Some(*value as f64), + Expr::FloatLit(fl) => Some(fl.value), + Expr::AngleLit(angle) => { + // Evaluate the angle value and convert to turns + let value = self.eval_expr_to_float(&angle.value)?; + Some(angle.unit.to_turns(value)) + } + _ => None, + } + } + + fn try_eval_const(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(IntLit { value, .. }) => Some(*value as i64), + _ => None, + } + } + + fn convert_expr_to_value(&self, expr: &Expr) -> PhirJsonResult { + match expr { + Expr::IntLit(IntLit { value, .. }) => { + Ok(serde_json::Value::Number((*value as i64).into())) + } + Expr::FloatLit(fl) => Ok(serde_json::json!(fl.value)), + Expr::BoolLit(bl) => Ok(serde_json::Value::Number(if bl.value { 1 } else { 0 }.into())), + Expr::Ident(ident) => Ok(serde_json::Value::String(ident.name.clone())), + Expr::Binary(bin) => { + let left = self.convert_expr_to_value(&bin.left)?; + let right = self.convert_expr_to_value(&bin.right)?; + let op = match bin.op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Mul => "*", + BinaryOp::Div => "/", + BinaryOp::Mod => "%", + BinaryOp::Eq => "==", + BinaryOp::Ne => "!=", + BinaryOp::Lt => "<", + BinaryOp::Le => "<=", + BinaryOp::Gt => ">", + BinaryOp::Ge => ">=", + BinaryOp::BitAnd => "&", + BinaryOp::BitOr => "|", + BinaryOp::BitXor => "^", + BinaryOp::Shl => "<<", + BinaryOp::Shr => ">>", + BinaryOp::And => "&", + BinaryOp::Or => "|", + _ => return Err(PhirJsonError::UnsupportedExpression), + }; + Ok(serde_json::json!({"cop": op, "args": [left, right]})) + } + Expr::Unary(un) => { + let operand = self.convert_expr_to_value(&un.operand)?; + let op = match un.op { + UnaryOp::Neg => "-", + UnaryOp::Not => "~", + _ => return Err(PhirJsonError::UnsupportedExpression), + }; + Ok(serde_json::json!({"cop": op, "args": [operand]})) + } + Expr::Index(idx) => { + if let Expr::Ident(ident) = &idx.object + && let Some(i) = self.eval_index(&idx.index) { + return Ok(serde_json::json!([ident.name, i])); + } + Err(PhirJsonError::UnsupportedExpression) + } + _ => Err(PhirJsonError::UnsupportedExpression), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bell_state() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + results: [2]u1 = mz([2]u1) [q[0], q[1]]; + return unit; +} +"#; + let ast = crate::parse(source).unwrap(); + let mut codegen = PhirJsonCodegen::new(); + let phir = codegen.compile(&ast).unwrap(); + let json = codegen.to_json(&phir).unwrap(); + + assert!(json.contains("\"format\": \"PHIR/JSON\"")); + assert!(json.contains("\"version\": \"0.1.0\"")); + assert!(json.contains("\"qvar_define\"")); + assert!(json.contains("\"H\"")); + assert!(json.contains("\"CX\"")); + assert!(json.contains("\"Measure\"")); + } + + #[test] + fn test_ghz_state() { + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + h q[0]; + cx (q[0], q[1]); + cx (q[0], q[2]); + cx (q[0], q[3]); + results: [4]u1 = mz([4]u1) [q[0], q[1], q[2], q[3]]; + return unit; +} +"#; + let ast = crate::parse(source).unwrap(); + let mut codegen = PhirJsonCodegen::new(); + let phir = codegen.compile(&ast).unwrap(); + let json = codegen.to_json(&phir).unwrap(); + + assert!(json.contains("\"size\": 4")); + assert!(json.contains("\"CX\"")); + } + + #[test] + fn test_single_qubit_gates() { + let source = r#" +pub fn main() -> unit { + q := qalloc(1); + pz q; + h q[0]; + x q[0]; + y q[0]; + z q[0]; + sz q[0]; + t q[0]; + return unit; +} +"#; + let ast = crate::parse(source).unwrap(); + let mut codegen = PhirJsonCodegen::new(); + let phir = codegen.compile(&ast).unwrap(); + let json = codegen.to_json(&phir).unwrap(); + + assert!(json.contains("\"H\"")); + assert!(json.contains("\"X\"")); + assert!(json.contains("\"Y\"")); + assert!(json.contains("\"Z\"")); + assert!(json.contains("\"SZ\"")); + assert!(json.contains("\"T\"")); + } + + #[test] + fn test_to_json_format() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + return unit; +} +"#; + let ast = crate::parse(source).unwrap(); + let mut codegen = PhirJsonCodegen::new(); + let phir = codegen.compile(&ast).unwrap(); + let json = codegen.to_json(&phir).unwrap(); + + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["format"], "PHIR/JSON"); + assert_eq!(parsed["version"], "0.1.0"); + assert!(parsed["ops"].is_array()); + } +} diff --git a/exp/zlup/src/codegen/qasm.rs b/exp/zlup/src/codegen/qasm.rs new file mode 100644 index 000000000..d4cc2ac5e --- /dev/null +++ b/exp/zlup/src/codegen/qasm.rs @@ -0,0 +1,1129 @@ +//! OpenQASM 2.0 code generation for Zlup. +//! +//! This module generates OpenQASM 2.0 from Zlup AST, enabling execution on +//! simulators and hardware that support the QASM format. +//! +//! ## Output Format +//! +//! ```qasm +//! OPENQASM 2.0; +//! include "qelib1.inc"; +//! +//! qreg q[4]; +//! creg c[4]; +//! +//! h q[0]; +//! cx q[0], q[1]; +//! measure q[0] -> c[0]; +//! ``` + +use std::collections::BTreeMap; +use std::fmt::Write; +use thiserror::Error; + +use crate::ast::{ + BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, Program, Stmt, TopLevelDecl, +}; + +// ============================================================================= +// Errors +// ============================================================================= + +/// QASM code generation errors. +#[derive(Debug, Error)] +pub enum QasmError { + #[error("unknown gate '{name}'")] + UnknownGate { name: String }, + + #[error("undefined allocator '{name}'")] + UndefinedAllocator { name: String }, + + #[error("qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}")] + QubitIndexOutOfBounds { + allocator: String, + index: usize, + capacity: usize, + }, + + #[error("expected {expected} arguments for gate '{gate}', got {got}")] + WrongArgumentCount { + gate: String, + expected: usize, + got: usize, + }, + + #[error("unsupported expression in QASM codegen")] + UnsupportedExpression, + + #[error("unsupported control flow in QASM 2.0")] + UnsupportedControlFlow, + + #[error("formatting error: {0}")] + FormatError(std::fmt::Error), +} + +impl From for QasmError { + fn from(e: std::fmt::Error) -> Self { + QasmError::FormatError(e) + } +} + +/// Result type for QASM code generation. +pub type QasmResult = Result; + +// ============================================================================= +// Gate Mapping +// ============================================================================= + +/// Gate information for QASM. +struct GateInfo { + /// Gate name in QASM. + name: &'static str, + /// Number of qubit targets. + arity: usize, + /// Whether this gate takes parameters. + parameterized: bool, +} + +/// Maps Zlup gate names to QASM gate info. +fn get_gate_info(name: &str) -> Option { + match name { + // Single-qubit Pauli gates + "x" => Some(GateInfo { + name: "x", + arity: 1, + parameterized: false, + }), + "y" => Some(GateInfo { + name: "y", + arity: 1, + parameterized: false, + }), + "z" => Some(GateInfo { + name: "z", + arity: 1, + parameterized: false, + }), + + // Hadamard + "h" => Some(GateInfo { + name: "h", + arity: 1, + parameterized: false, + }), + + // S gates (zlup uses sz/szdg, QASM uses s/sdg) + "sz" => Some(GateInfo { + name: "s", + arity: 1, + parameterized: false, + }), + "szdg" => Some(GateInfo { + name: "sdg", + arity: 1, + parameterized: false, + }), + + // T gates + "t" => Some(GateInfo { + name: "t", + arity: 1, + parameterized: false, + }), + "tdg" => Some(GateInfo { + name: "tdg", + arity: 1, + parameterized: false, + }), + + // Square root gates + "sx" => Some(GateInfo { + name: "sx", + arity: 1, + parameterized: false, + }), + + // Rotation gates + "rx" => Some(GateInfo { + name: "rx", + arity: 1, + parameterized: true, + }), + "ry" => Some(GateInfo { + name: "ry", + arity: 1, + parameterized: true, + }), + "rz" => Some(GateInfo { + name: "rz", + arity: 1, + parameterized: true, + }), + + // U gates (parameterized) + "u1" => Some(GateInfo { + name: "u1", + arity: 1, + parameterized: true, + }), + "u2" => Some(GateInfo { + name: "u2", + arity: 1, + parameterized: true, + }), + "u3" => Some(GateInfo { + name: "u3", + arity: 1, + parameterized: true, + }), + + // Two-qubit gates + "cx" => Some(GateInfo { + name: "cx", + arity: 2, + parameterized: false, + }), + "cy" => Some(GateInfo { + name: "cy", + arity: 2, + parameterized: false, + }), + "cz" => Some(GateInfo { + name: "cz", + arity: 2, + parameterized: false, + }), + "ch" => Some(GateInfo { + name: "ch", + arity: 2, + parameterized: false, + }), + "swap" => Some(GateInfo { + name: "swap", + arity: 2, + parameterized: false, + }), + + // Two-qubit rotation + "rzz" => Some(GateInfo { + name: "rzz", + arity: 2, + parameterized: true, + }), + + // Three-qubit gates + "ccx" => Some(GateInfo { + name: "ccx", + arity: 3, + parameterized: false, + }), + + _ => None, + } +} + +// ============================================================================= +// Code Generator +// ============================================================================= + +/// Tracks an allocator during codegen. +#[derive(Debug, Clone)] +struct AllocatorInfo { + name: String, + capacity: usize, + /// Global offset for this allocator in the flat qubit register + offset: usize, +} + +/// QASM code generator. +/// +/// Walks a Zlup AST and produces OpenQASM 2.0. +pub struct QasmCodegen { + /// Allocators by name. + allocators: BTreeMap, + /// Total qubit count. + total_qubits: usize, + /// Classical register counter. + creg_counter: usize, + /// Output buffer. + output: String, +} + +impl QasmCodegen { + /// Create a new QASM code generator. + pub fn new() -> Self { + Self { + allocators: BTreeMap::new(), + total_qubits: 0, + creg_counter: 0, + output: String::new(), + } + } + + /// Compile a Zlup program to OpenQASM 2.0. + pub fn compile(&mut self, program: &Program) -> QasmResult { + // Reset state + self.allocators.clear(); + self.total_qubits = 0; + self.creg_counter = 0; + self.output.clear(); + + // First pass: collect allocators + for decl in &program.declarations { + self.collect_decl(decl)?; + } + + // Write header + writeln!(self.output, "OPENQASM 2.0;")?; + writeln!(self.output, "include \"qelib1.inc\";")?; + writeln!(self.output)?; + + // Write qubit register + if self.total_qubits > 0 { + writeln!(self.output, "qreg q[{}];", self.total_qubits)?; + } + + // Second pass: convert statements and count measurements + let mut body_output = String::new(); + let mut measurement_count = 0; + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl + && fn_decl.name == "main" { + let (body, mcount) = self.convert_block(&fn_decl.body)?; + body_output = body; + measurement_count = mcount; + } + } + + // Write classical register if measurements exist + if measurement_count > 0 { + writeln!(self.output, "creg c[{}];", measurement_count)?; + } + + writeln!(self.output)?; + + // Write body + self.output.push_str(&body_output); + + Ok(self.output.clone()) + } + + /// Compile a function to OpenQASM 2.0. + pub fn compile_function(&mut self, fn_decl: &FnDecl) -> QasmResult { + // Reset state + self.allocators.clear(); + self.total_qubits = 0; + self.creg_counter = 0; + self.output.clear(); + + // Collect from function body + self.collect_block(&fn_decl.body)?; + + // Write header + writeln!(self.output, "OPENQASM 2.0;")?; + writeln!(self.output, "include \"qelib1.inc\";")?; + writeln!(self.output)?; + + // Write qubit register + if self.total_qubits > 0 { + writeln!(self.output, "qreg q[{}];", self.total_qubits)?; + } + + // Convert body + let (body, measurement_count) = self.convert_block(&fn_decl.body)?; + + // Write classical register + if measurement_count > 0 { + writeln!(self.output, "creg c[{}];", measurement_count)?; + } + + writeln!(self.output)?; + self.output.push_str(&body); + + Ok(self.output.clone()) + } + + // ========================================================================= + // Collection Phase + // ========================================================================= + + fn collect_decl(&mut self, decl: &TopLevelDecl) -> QasmResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) => { + if fn_decl.name == "main" { + self.collect_block(&fn_decl.body)?; + } + } + TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, + _ => {} + } + Ok(()) + } + + fn collect_binding(&mut self, binding: &Binding) -> QasmResult<()> { + if let Some(ref value) = binding.value { + if let Some(capacity) = self.try_extract_allocator(value) { + let offset = self.total_qubits; + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity, + offset, + }, + ); + self.total_qubits += capacity; + } else if let Some((parent, size)) = self.try_extract_child_allocator(value) { + // Child allocator shares parent's qubits + if let Some(parent_info) = self.allocators.get(&parent) { + let offset = parent_info.offset; + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity: size, + offset, + }, + ); + } + } + } + Ok(()) + } + + fn collect_block(&mut self, block: &Block) -> QasmResult<()> { + for stmt in &block.statements { + self.collect_stmt(stmt)?; + } + Ok(()) + } + + fn collect_stmt(&mut self, stmt: &Stmt) -> QasmResult<()> { + match stmt { + Stmt::Binding(binding) => self.collect_binding(binding)?, + Stmt::If(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + Stmt::For(for_stmt) => self.collect_block(&for_stmt.body)?, + Stmt::Block(block) => self.collect_block(block)?, + Stmt::Tick(tick_stmt) => { + for inner_stmt in &tick_stmt.body { + self.collect_stmt(inner_stmt)?; + } + } + _ => {} + } + Ok(()) + } + + fn collect_else_branch(&mut self, branch: &ElseBranch) -> QasmResult<()> { + match branch { + ElseBranch::Else(block) => self.collect_block(block)?, + ElseBranch::ElseIf(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + } + Ok(()) + } + + // ========================================================================= + // Conversion Phase + // ========================================================================= + + /// Convert a block, returning (output, measurement_count) + fn convert_block(&mut self, block: &Block) -> QasmResult<(String, usize)> { + let mut output = String::new(); + let mut measurement_count = 0; + + for stmt in &block.statements { + let (stmt_output, mcount) = self.convert_stmt(stmt)?; + output.push_str(&stmt_output); + measurement_count += mcount; + } + + Ok((output, measurement_count)) + } + + /// Convert a statement, returning (output, measurement_count) + fn convert_stmt(&mut self, stmt: &Stmt) -> QasmResult<(String, usize)> { + match stmt { + Stmt::Expr(expr_stmt) => self.convert_expr_stmt(expr_stmt), + Stmt::Tick(tick_stmt) => { + // Tick blocks are flattened - operations within are just sequential in QASM + let mut output = String::new(); + let mut measurement_count = 0; + + // Add barrier to mark tick boundary (optional but useful) + if !tick_stmt.body.is_empty() { + writeln!(output, "// tick{}", + tick_stmt.label.as_ref().map(|l| format!(" {}", l)).unwrap_or_default())?; + } + + for inner_stmt in &tick_stmt.body { + let (stmt_output, mcount) = self.convert_stmt(inner_stmt)?; + output.push_str(&stmt_output); + measurement_count += mcount; + } + + Ok((output, measurement_count)) + } + Stmt::Block(block) => self.convert_block(block), + // Handle declarations - check for measurement calls + Stmt::Binding(binding) => { + if let Some(ref value) = binding.value { + self.convert_decl_value(value) + } else { + Ok((String::new(), 0)) + } + } + // Skip unsupported control flow in QASM 2.0 + // (QASM 3.0 would support these) + Stmt::If(_) | Stmt::For(_) => { + // For now, skip control flow - could emit warning + Ok((String::new(), 0)) + } + _ => Ok((String::new(), 0)), + } + } + + fn convert_decl_value(&mut self, expr: &Expr) -> QasmResult<(String, usize)> { + match expr { + Expr::Call(call) => { + let name = self.extract_call_name(&call.callee)?; + if name == "mz" { + return self.convert_measure(call); + } + Ok((String::new(), 0)) + } + Expr::Measure(measure) => self.convert_measure_expr(measure), + _ => Ok((String::new(), 0)), + } + } + + fn convert_expr_stmt(&mut self, expr_stmt: &crate::ast::ExprStmt) -> QasmResult<(String, usize)> { + match &expr_stmt.expr { + Expr::Call(call) => self.convert_call(call), + Expr::Gate(gate) => self.convert_gate_expr(gate), + Expr::Measure(measure) => self.convert_measure_expr(measure), + _ => Ok((String::new(), 0)), + } + } + + fn convert_gate_expr(&mut self, gate: &crate::ast::GateExpr) -> QasmResult<(String, usize)> { + use crate::ast::GateKind; + use std::fmt::Write; + + // Map GateKind to QASM gate name (lowercase) + let gate_name: &str = match gate.kind { + GateKind::X => "x", + GateKind::Y => "y", + GateKind::Z => "z", + GateKind::H => "h", + GateKind::T => "t", + GateKind::Tdg => "tdg", + GateKind::SX => "sx", + GateKind::SY => "sy", + GateKind::SZ => "s", // QASM uses "s" for S gate + GateKind::SXdg => "sxdg", + GateKind::SYdg => "sydg", + GateKind::SZdg => "sdg", // QASM uses "sdg" for S-dagger + GateKind::RX => "rx", + GateKind::RY => "ry", + GateKind::RZ => "rz", + GateKind::CX => "cx", + GateKind::CY => "cy", + GateKind::CZ => "cz", + GateKind::CH => "ch", + GateKind::SWAP => "swap", + GateKind::ISWAP => "iswap", + GateKind::SXX => "sxx", + GateKind::SYY => "syy", + GateKind::SZZ => "szz", + GateKind::SXXdg => "sxxdg", + GateKind::SYYdg => "syydg", + GateKind::SZZdg => "szzdg", + GateKind::RZZ => "rzz", + GateKind::CCX => "ccx", + GateKind::F => "f", + GateKind::Fdg => "fdg", + GateKind::F4 => "f4", + GateKind::F4dg => "f4dg", + GateKind::PZ => return Ok((String::new(), 0)), // Prepare is implicit in QASM + }; + + let mut output = String::new(); + + // Handle batch targets (sets) + if let Expr::Set(set_expr) = &gate.target { + let gate_info = get_gate_info(gate_name).ok_or_else(|| QasmError::UnknownGate { + name: gate_name.to_string(), + })?; + let params: Vec = gate + .params + .iter() + .map(|p| self.convert_expression(p)) + .collect::>()?; + return self.convert_batch_gate(&gate_info, &set_expr.elements, ¶ms); + } + + // Convert parameters + let params: Vec = gate + .params + .iter() + .map(|p| self.convert_expression(p)) + .collect::>()?; + + // Convert target(s) + let targets = self.extract_gate_qubit_targets(&gate.target)?; + + // Format gate with optional parameters + if params.is_empty() { + write!(output, "{} ", gate_name)?; + } else { + write!(output, "{}({}) ", gate_name, params.join(", "))?; + } + + // Format targets + writeln!(output, "{};", targets.join(", "))?; + + Ok((output, 0)) + } + + fn convert_measure_expr(&mut self, measure: &crate::ast::MeasureExpr) -> QasmResult<(String, usize)> { + use std::fmt::Write; + + let mut output = String::new(); + let targets = self.extract_gate_qubit_targets(&measure.targets)?; + + for (i, target) in targets.iter().enumerate() { + let bit_idx = self.creg_counter + i; + writeln!(output, "measure {} -> c[{}];", target, bit_idx)?; + } + + let count = targets.len(); + self.creg_counter += count; + Ok((output, count)) + } + + fn extract_gate_qubit_targets(&self, expr: &Expr) -> QasmResult> { + match expr { + Expr::Index(idx) => { + let allocator = self.extract_identifier(&idx.object)?; + let index = self.extract_integer(&idx.index)?; + let global_index = self.get_global_qubit_index(&allocator, index)?; + Ok(vec![format!("q[{}]", global_index)]) + } + Expr::Tuple(tuple) => { + let mut targets = Vec::new(); + for elem in &tuple.elements { + targets.extend(self.extract_gate_qubit_targets(elem)?); + } + Ok(targets) + } + Expr::BracketArray(arr) => { + let mut targets = Vec::new(); + for elem in &arr.elements { + targets.extend(self.extract_gate_qubit_targets(elem)?); + } + Ok(targets) + } + _ => Err(QasmError::UnsupportedExpression), + } + } + + fn convert_call(&mut self, call: &CallExpr) -> QasmResult<(String, usize)> { + let name = self.extract_call_name(&call.callee)?; + + // Check for special operations + match name.as_str() { + "mz" => return self.convert_measure(call), + "barrier" => return self.convert_barrier(call), + _ => {} + } + + // Check for gate calls + let Some(gate_info) = get_gate_info(&name) else { + return Ok((String::new(), 0)); + }; + + let mut output = String::new(); + + // For parameterized gates: qubits come first, then angle + // e.g., rz(q[0], 1.5708) or rz(&[q[0], q[1]], 1.5708) + let (params, qubit_args): (Vec, &[Expr]) = if gate_info.parameterized { + if call.args.len() < 2 { + return Err(QasmError::WrongArgumentCount { + gate: name, + expected: gate_info.arity + 1, + got: call.args.len(), + }); + } + // Last argument is the parameter (angle) + let param = self.convert_expression(call.args.last().unwrap())?; + // All but last are qubit args + (vec![param], &call.args[..call.args.len() - 1]) + } else { + (Vec::new(), &call.args[..]) + }; + + // Check for batch operations (set literal or address-of array) + if !qubit_args.is_empty() { + // Set literal: h([q[0], q[1]) + if let Expr::Set(set_expr) = &qubit_args[0] { + return self.convert_batch_gate(&gate_info, &set_expr.elements, ¶ms); + } + // Address-of array: h(&[q[0], q[1]]) + if let Expr::Unary(unary) = &qubit_args[0] + && let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand { + return self.convert_batch_gate(&gate_info, &arr.elements, ¶ms); + } + } + + // Standard gate call + if qubit_args.len() != gate_info.arity { + return Err(QasmError::WrongArgumentCount { + gate: name, + expected: if gate_info.parameterized { + gate_info.arity + 1 + } else { + gate_info.arity + }, + got: call.args.len(), + }); + } + + // Build gate string + write!(output, "{}", gate_info.name)?; + + // Add parameters + if !params.is_empty() { + write!(output, "({})", params.join(", "))?; + } + + // Add qubit targets + write!(output, " ")?; + for (i, arg) in qubit_args.iter().enumerate() { + if i > 0 { + write!(output, ", ")?; + } + let (alloc, idx) = self.extract_qubit_ref(arg)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + write!(output, "q[{}]", global_idx)?; + } + writeln!(output, ";")?; + + Ok((output, 0)) + } + + fn convert_batch_gate( + &mut self, + gate_info: &GateInfo, + elements: &[Expr], + params: &[String], + ) -> QasmResult<(String, usize)> { + let mut output = String::new(); + + if gate_info.arity == 1 { + // Single-qubit gate on multiple qubits + for elem in elements { + let (alloc, idx) = self.extract_qubit_ref(elem)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + + write!(output, "{}", gate_info.name)?; + if !params.is_empty() { + write!(output, "({})", params.join(", "))?; + } + writeln!(output, " q[{}];", global_idx)?; + } + } else if gate_info.arity == 2 { + // Two-qubit gate with tuple pairs + for elem in elements { + if let Expr::Tuple(tuple) = elem { + if tuple.elements.len() == 2 { + let (alloc1, idx1) = self.extract_qubit_ref(&tuple.elements[0])?; + let (alloc2, idx2) = self.extract_qubit_ref(&tuple.elements[1])?; + let global1 = self.get_global_qubit_index(&alloc1, idx1)?; + let global2 = self.get_global_qubit_index(&alloc2, idx2)?; + + write!(output, "{}", gate_info.name)?; + if !params.is_empty() { + write!(output, "({})", params.join(", "))?; + } + writeln!(output, " q[{}], q[{}];", global1, global2)?; + } else { + return Err(QasmError::UnsupportedExpression); + } + } else { + return Err(QasmError::UnsupportedExpression); + } + } + } else { + return Err(QasmError::UnsupportedExpression); + } + + Ok((output, 0)) + } + + fn convert_measure(&mut self, call: &CallExpr) -> QasmResult<(String, usize)> { + let mut output = String::new(); + let mut measurement_count = 0; + + // Typed measurement: mz(type, target) + if call.args.len() == 2 { + let target_arg = &call.args[1]; + + match target_arg { + // Single qubit: q[0] + Expr::Index(_) => { + let (alloc, idx) = self.extract_qubit_ref(target_arg)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + let creg_idx = self.creg_counter; + self.creg_counter += 1; + writeln!(output, "measure q[{}] -> c[{}];", global_idx, creg_idx)?; + measurement_count = 1; + } + // Address-of array: &[q[0], q[1], ...] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op + && let Expr::BracketArray(arr) = &unary.operand { + for elem in &arr.elements { + let (alloc, idx) = self.extract_qubit_ref(elem)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + let creg_idx = self.creg_counter; + self.creg_counter += 1; + writeln!(output, "measure q[{}] -> c[{}];", global_idx, creg_idx)?; + measurement_count += 1; + } + } + } + _ => return Err(QasmError::UnsupportedExpression), + } + } else { + // Legacy: mz(q[0]) + for arg in &call.args { + let (alloc, idx) = self.extract_qubit_ref(arg)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + let creg_idx = self.creg_counter; + self.creg_counter += 1; + writeln!(output, "measure q[{}] -> c[{}];", global_idx, creg_idx)?; + measurement_count += 1; + } + } + + Ok((output, measurement_count)) + } + + fn convert_barrier(&mut self, call: &CallExpr) -> QasmResult<(String, usize)> { + let mut output = String::new(); + + if call.args.is_empty() { + // Barrier on all qubits + writeln!(output, "barrier q;")?; + } else { + // Barrier on specific allocators + write!(output, "barrier ")?; + let mut first = true; + for arg in &call.args { + if let Expr::Ident(ident) = arg + && let Some(alloc) = self.allocators.get(&ident.name) { + for i in 0..alloc.capacity { + if !first { + write!(output, ", ")?; + } + first = false; + write!(output, "q[{}]", alloc.offset + i)?; + } + } + } + writeln!(output, ";")?; + } + + Ok((output, 0)) + } + + fn convert_expression(&self, expr: &Expr) -> QasmResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value.to_string()), + Expr::FloatLit(lit) => Ok(format!("{}", lit.value)), + Expr::Ident(ident) => { + // Check for built-in constants + match ident.name.as_str() { + "pi" | "PI" => Ok("pi".to_string()), + "tau" | "TAU" => Ok("2*pi".to_string()), + _ => Ok(ident.name.clone()), + } + } + Expr::Binary(binary) => { + let left = self.convert_expression(&binary.left)?; + let right = self.convert_expression(&binary.right)?; + let op = match binary.op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Mul => "*", + BinaryOp::Div => "/", + _ => return Err(QasmError::UnsupportedExpression), + }; + Ok(format!("({} {} {})", left, op, right)) + } + Expr::Unary(unary) => { + let operand = self.convert_expression(&unary.operand)?; + match unary.op { + crate::ast::UnaryOp::Neg => Ok(format!("-{}", operand)), + _ => Err(QasmError::UnsupportedExpression), + } + } + _ => Err(QasmError::UnsupportedExpression), + } + } + + // ========================================================================= + // Helpers + // ========================================================================= + + fn try_extract_allocator(&self, expr: &Expr) -> Option { + if let Expr::Call(call) = expr { + let name = self.extract_call_name(&call.callee).ok()?; + if name == "qalloc" && call.args.len() == 1 { + return self.extract_integer(&call.args[0]).ok(); + } + } + None + } + + fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { + if let Expr::Call(call) = expr + && let Expr::Field(field) = &call.callee + && field.field == "child" && call.args.len() == 1 { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } + None + } + + fn extract_call_name(&self, callee: &Expr) -> QasmResult { + match callee { + Expr::Ident(ident) => Ok(ident.name.clone()), + Expr::Field(field) => Ok(field.field.clone()), + _ => Err(QasmError::UnsupportedExpression), + } + } + + fn extract_identifier(&self, expr: &Expr) -> QasmResult { + match expr { + Expr::Ident(ident) => Ok(ident.name.clone()), + _ => Err(QasmError::UnsupportedExpression), + } + } + + fn extract_qubit_ref(&self, expr: &Expr) -> QasmResult<(String, usize)> { + match expr { + Expr::Index(index) => { + let allocator = self.extract_identifier(&index.object)?; + let idx = self.extract_integer(&index.index)?; + Ok((allocator, idx)) + } + _ => Err(QasmError::UnsupportedExpression), + } + } + + fn get_global_qubit_index(&self, allocator: &str, index: usize) -> QasmResult { + let alloc = self + .allocators + .get(allocator) + .ok_or_else(|| QasmError::UndefinedAllocator { + name: allocator.to_string(), + })?; + + if index >= alloc.capacity { + return Err(QasmError::QubitIndexOutOfBounds { + allocator: allocator.to_string(), + index, + capacity: alloc.capacity, + }); + } + + Ok(alloc.offset + index) + } + + fn extract_integer(&self, expr: &Expr) -> QasmResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value as usize), + _ => Err(QasmError::UnsupportedExpression), + } + } +} + +impl Default for QasmCodegen { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + fn compile_to_qasm(source: &str) -> QasmResult { + let program = parse(source).expect("parse failed"); + let mut codegen = QasmCodegen::new(); + codegen.compile(&program) + } + + #[test] + fn test_empty_program() { + let qasm = compile_to_qasm("").unwrap(); + assert!(qasm.contains("OPENQASM 2.0;")); + } + + #[test] + fn test_single_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("qreg q[1];")); + assert!(qasm.contains("h q[0];")); + } + + #[test] + fn test_bell_state() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("qreg q[2];")); + assert!(qasm.contains("h q[0];")); + assert!(qasm.contains("cx q[0], q[1];")); + } + + #[test] + fn test_rotation_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + rz(1.57) q[0]; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("rz(1.57) q[0];")); + } + + #[test] + fn test_measurement() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + r := mz(u1) q[0]; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("creg c[1];")); + assert!(qasm.contains("measure q[0] -> c[0];")); + } + + #[test] + fn test_batch_gate() { + // Use new batch gate syntax: h {targets} + let source = r#" + pub fn main() -> unit { + mut q := qalloc(3); + h {q[0], q[1], q[2]}; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("h q[0];")); + assert!(qasm.contains("h q[1];")); + assert!(qasm.contains("h q[2];")); + } + + #[test] + fn test_batch_cx() { + // Use new batch gate syntax: cx {(ctrl, target), ...} + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + cx {(q[0], q[1]), (q[2], q[3])}; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("cx q[0], q[1];")); + assert!(qasm.contains("cx q[2], q[3];")); + } + + #[test] + fn test_pi_constant() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + rz(pi / 4) q[0]; + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + assert!(qasm.contains("rz((pi / 4)) q[0];")); + } + + #[test] + fn test_tick_flattened() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick { + h q[0]; + h q[1]; + } + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + // Tick blocks are flattened in QASM + assert!(qasm.contains("h q[0];")); + assert!(qasm.contains("h q[1];")); + } + + #[test] + fn test_multiple_allocators() { + let source = r#" + pub fn main() -> unit { + mut data := qalloc(2); + mut ancilla := qalloc(1); + h data[0]; + cx (data[0], ancilla[0]); + } + "#; + + let qasm = compile_to_qasm(source).unwrap(); + // Total qubits = 2 + 1 = 3 + assert!(qasm.contains("qreg q[3];")); + // data[0] = q[0], ancilla[0] = q[2] + assert!(qasm.contains("h q[0];")); + assert!(qasm.contains("cx q[0], q[2];")); + } +} diff --git a/exp/zlup/src/codegen/slr.rs b/exp/zlup/src/codegen/slr.rs new file mode 100644 index 000000000..13acefdb8 --- /dev/null +++ b/exp/zlup/src/codegen/slr.rs @@ -0,0 +1,3841 @@ +//! SLR-AST code generation for Zluppy. +//! +//! This module generates SLR-AST JSON from Zluppy AST. The output maps directly +//! to Python's frozen dataclass structure for seamless interop with PECOS. +//! +//! ## Design Philosophy +//! +//! Low-level but safe. The JSON output is: +//! - Explicit: Every node has a `type` field - no magic +//! - Simple: Flat structure, predictable format +//! - Constrained: Only valid SLR-AST constructs can be generated +//! +//! ## Output Format +//! +//! The generated JSON maps 1:1 to Python's SLR-AST dataclasses: +//! +//! ```json +//! { +//! "type": "Program", +//! "name": "main", +//! "allocator": {"type": "AllocatorDecl", "name": "q", "capacity": 2}, +//! "declarations": [...], +//! "body": [ +//! {"type": "GateOp", "gate": "H", "targets": [{"type": "SlotRef", "allocator": "q", "index": 0}] +//! ], +//! "returns": [] +//! } +//! ``` + +use std::collections::BTreeMap; +use thiserror::Error; + +use crate::ast::{ + BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, ForRange, ForStmt, IfStmt, + IndexExpr, Program, Stmt, TopLevelDecl, +}; + +// ============================================================================= +// Errors +// ============================================================================= + +/// SLR-AST code generation errors. +#[derive(Debug, Error)] +pub enum SlrError { + #[error("unknown gate '{name}'")] + UnknownGate { name: String }, + + #[error("undefined allocator '{name}'")] + UndefinedAllocator { name: String }, + + #[error("qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}")] + QubitIndexOutOfBounds { + allocator: String, + index: usize, + capacity: usize, + }, + + #[error("expected {expected} arguments for gate '{gate}', got {got}")] + WrongArgumentCount { + gate: String, + expected: usize, + got: usize, + }, + + #[error("unsupported expression in SLR codegen")] + UnsupportedExpression, + + #[error("invalid rotation angle")] + InvalidAngle, + + #[error("JSON serialization error: {0}")] + JsonError(String), +} + +/// Result type for SLR-AST code generation. +pub type SlrResult = Result; + +// ============================================================================= +// SLR-AST Node Types +// ============================================================================= + +/// SLR-AST Program node. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrProgram { + #[serde(rename = "type")] + pub node_type: &'static str, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub allocator: Option, + pub declarations: Vec, + /// External function declarations (FFI) + #[serde(skip_serializing_if = "Vec::is_empty")] + pub externs: Vec, + pub body: Vec, + pub returns: Vec, +} + +impl SlrProgram { + pub fn new(name: impl Into) -> Self { + Self { + node_type: "Program", + name: name.into(), + allocator: None, + declarations: Vec::new(), + externs: Vec::new(), + body: Vec::new(), + returns: Vec::new(), + } + } +} + +/// SLR-AST allocator declaration. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrAllocatorDecl { + #[serde(rename = "type")] + pub node_type: &'static str, + pub name: String, + pub capacity: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, +} + +impl SlrAllocatorDecl { + pub fn new(name: impl Into, capacity: usize) -> Self { + Self { + node_type: "AllocatorDecl", + name: name.into(), + capacity, + parent: None, + } + } + + pub fn with_parent(mut self, parent: impl Into) -> Self { + self.parent = Some(parent.into()); + self + } +} + +/// SLR-AST register declaration (classical bits). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrRegisterDecl { + #[serde(rename = "type")] + pub node_type: &'static str, + pub name: String, + pub size: usize, + pub is_result: bool, +} + +impl SlrRegisterDecl { + pub fn new(name: impl Into, size: usize) -> Self { + Self { + node_type: "RegisterDecl", + name: name.into(), + size, + is_result: true, + } + } +} + +/// SLR-AST declaration (allocator or register). +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrDeclaration { + Allocator(SlrAllocatorDecl), + Register(SlrRegisterDecl), +} + +/// SLR-AST slot reference (qubit in allocator). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrSlotRef { + #[serde(rename = "type")] + pub node_type: &'static str, + pub allocator: String, + pub index: usize, +} + +impl SlrSlotRef { + pub fn new(allocator: impl Into, index: usize) -> Self { + Self { + node_type: "SlotRef", + allocator: allocator.into(), + index, + } + } +} + +/// SLR-AST bit reference (classical bit in register). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBitRef { + #[serde(rename = "type")] + pub node_type: &'static str, + pub register: String, + pub index: usize, +} + +impl SlrBitRef { + pub fn new(register: impl Into, index: usize) -> Self { + Self { + node_type: "BitRef", + register: register.into(), + index, + } + } +} + +/// SLR-AST gate operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrGateOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub gate: &'static str, + pub targets: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub params: Vec, + /// Attributes for this gate (e.g., syndrome type, layer info) + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub attrs: std::collections::BTreeMap, +} + +impl SlrGateOp { + pub fn new(gate: &'static str, targets: Vec) -> Self { + Self { + node_type: "GateOp", + gate, + targets, + params: Vec::new(), + attrs: std::collections::BTreeMap::new(), + } + } + + pub fn with_params(mut self, params: Vec) -> Self { + self.params = params; + self + } + + pub fn with_attrs(mut self, attrs: std::collections::BTreeMap) -> Self { + self.attrs = attrs; + self + } +} + +/// SLR-AST prepare operation (reset qubits). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrPrepareOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub allocator: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub slots: Option>, +} + +impl SlrPrepareOp { + pub fn all(allocator: impl Into) -> Self { + Self { + node_type: "PrepareOp", + allocator: allocator.into(), + slots: None, + } + } + + pub fn slots(allocator: impl Into, slots: Vec) -> Self { + Self { + node_type: "PrepareOp", + allocator: allocator.into(), + slots: Some(slots), + } + } +} + +/// SLR-AST measure operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrMeasureOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub targets: Vec, + pub results: Vec, + /// Result type for each measurement (u1, u8, or u64) + #[serde(skip_serializing_if = "Option::is_none")] + pub result_type: Option, +} + +impl SlrMeasureOp { + pub fn new(targets: Vec, results: Vec) -> Self { + Self { + node_type: "MeasureOp", + targets, + results, + result_type: None, + } + } + + pub fn with_result_type(targets: Vec, results: Vec, result_type: &str) -> Self { + Self { + node_type: "MeasureOp", + targets, + results, + result_type: Some(result_type.to_string()), + } + } +} + +/// SLR-AST barrier operation. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBarrierOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub allocators: Vec, +} + +impl SlrBarrierOp { + pub fn new(allocators: Vec) -> Self { + Self { + node_type: "BarrierOp", + allocators, + } + } +} + +/// SLR-AST swap operation. +/// Swaps two values in place: @swap(&a, &b) +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrSwapOp { + #[serde(rename = "type")] + pub node_type: &'static str, + pub a: SlrExpression, + pub b: SlrExpression, +} + +impl SlrSwapOp { + pub fn new(a: SlrExpression, b: SlrExpression) -> Self { + Self { + node_type: "SwapOp", + a, + b, + } + } +} + +/// SLR-AST if statement. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrIfStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + pub condition: SlrExpression, + pub then_body: Vec, + pub else_body: Vec, +} + +impl SlrIfStmt { + pub fn new(condition: SlrExpression, then_body: Vec) -> Self { + Self { + node_type: "IfStmt", + condition, + then_body, + else_body: Vec::new(), + } + } + + pub fn with_else(mut self, else_body: Vec) -> Self { + self.else_body = else_body; + self + } +} + +/// SLR-AST for statement (bounded iteration - NASA Power of 10 compliant). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrForStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + pub variable: String, + pub start: SlrExpression, + pub end: SlrExpression, + pub body: Vec, +} + +impl SlrForStmt { + pub fn new( + variable: impl Into, + start: SlrExpression, + end: SlrExpression, + body: Vec, + ) -> Self { + Self { + node_type: "ForStmt", + variable: variable.into(), + start, + end, + body, + } + } +} + +/// SLR-AST repeat statement (fixed iteration count). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrRepeatStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + pub count: usize, + pub body: Vec, +} + +impl SlrRepeatStmt { + pub fn new(count: usize, body: Vec) -> Self { + Self { + node_type: "RepeatStmt", + count, + body, + } + } +} + +/// SLR-AST attribute value. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrAttributeValue { + Bool(bool), + Int(i64), + Float(f64), + String(String), +} + +/// SLR-AST tick statement (parallel gate layer). +/// Represents a time slice where all gates execute in parallel. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrTickStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Optional label for the tick (e.g., "syndrome_round_1") + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + /// Attributes for this tick (e.g., round number, tick type) + /// Using BTreeMap for deterministic ordering + #[serde(skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub attrs: std::collections::BTreeMap, + /// Gates/operations within this tick (execute in parallel) + pub body: Vec, +} + +impl SlrTickStmt { + pub fn new(body: Vec) -> Self { + Self { + node_type: "TickStmt", + label: None, + attrs: std::collections::BTreeMap::new(), + body, + } + } + + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + pub fn with_attrs(mut self, attrs: std::collections::BTreeMap) -> Self { + self.attrs = attrs; + self + } +} + +/// SLR-AST log statement for debugging and tracing. +/// +/// Log statements are controlled by ZLUP_LOG environment variable at runtime. +/// In release builds, they can be elided entirely at compile time. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrLogStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Log level: "trace", "debug", "info", "warn", "error", or numeric + pub level: SlrLogLevel, + /// Namespace for filtering (module path + optional sub-namespace) + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option, + /// Message expression (usually an f-string) + pub message: SlrExpression, + /// Optional structured data + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +impl SlrLogStmt { + pub fn new(level: SlrLogLevel, message: SlrExpression) -> Self { + Self { + node_type: "LogStmt", + level, + namespace: None, + message, + data: None, + } + } + + pub fn with_namespace(mut self, namespace: impl Into) -> Self { + self.namespace = Some(namespace.into()); + self + } + + pub fn with_data(mut self, data: SlrExpression) -> Self { + self.data = Some(data); + self + } +} + +/// Log level for SLR log statements. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrLogLevel { + /// Standard named level + Standard(String), + /// Custom numeric level + Numeric(i64), +} + +/// SLR-AST send statement for out-of-band communication. +/// +/// Unified type for `result(key, value)` and `sim.send(key, value)`. +/// The channel determines how the message is handled: +/// - "result": Program output (never elided) +/// - "sim": Simulator control (elided for hardware, emits barrier by default) +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrSendStmt { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Channel: "result", "sim" + pub channel: String, + /// Key identifying the message (e.g., "counts", "noise_enable") + pub key: String, + /// Optional value expression + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, +} + +impl SlrSendStmt { + pub fn new(channel: impl Into, key: impl Into) -> Self { + Self { + node_type: "SendStmt", + channel: channel.into(), + key: key.into(), + value: None, + } + } + + pub fn with_value(mut self, value: SlrExpression) -> Self { + self.value = Some(value); + self + } + + /// Create a result send (program output). + pub fn result(key: impl Into, value: SlrExpression) -> Self { + Self::new("result", key).with_value(value) + } + + /// Create a sim send (simulator control). + pub fn sim(key: impl Into) -> Self { + Self::new("sim", key) + } + + /// Create a sim send with value. + pub fn sim_with_value(key: impl Into, value: SlrExpression) -> Self { + Self::new("sim", key).with_value(value) + } +} + +/// SLR-AST statement (union of all statement types). +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrStatement { + Gate(SlrGateOp), + Prepare(SlrPrepareOp), + Measure(SlrMeasureOp), + Barrier(SlrBarrierOp), + If(SlrIfStmt), + For(SlrForStmt), + Repeat(SlrRepeatStmt), + Tick(SlrTickStmt), + ExternCall(SlrExternCall), + Log(SlrLogStmt), + Send(SlrSendStmt), + Swap(SlrSwapOp), +} + +/// SLR-AST external function declaration (FFI). +/// Declares an external function that can be called from Zlup code. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrExternDecl { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Function name + pub name: String, + /// Library to link against (e.g., "libdecoder") + #[serde(skip_serializing_if = "Option::is_none")] + pub library: Option, + /// Calling convention ("C" or "Rust") + pub calling_convention: String, + /// Parameter declarations with C-compatible type info + pub params: Vec, + /// Return type in C-compatible format + #[serde(skip_serializing_if = "Option::is_none")] + pub return_type: Option, +} + +impl SlrExternDecl { + pub fn new( + name: String, + library: Option, + calling_convention: String, + params: Vec, + return_type: Option, + ) -> Self { + Self { + node_type: "ExternDecl", + name, + library, + calling_convention, + params, + return_type, + } + } +} + +/// Parameter for an external function. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrExternParam { + pub name: String, + pub ctype: SlrCType, +} + +/// C-compatible type representation for FFI. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "kind")] +pub enum SlrCType { + /// Primitive integer types + #[serde(rename = "int")] + Int { bits: u8, signed: bool }, + /// Floating point types + #[serde(rename = "float")] + Float { bits: u8 }, + /// Fixed-point angle type (maps to PECOS Angle64) + /// Angles are represented as fractions of a full turn. + #[serde(rename = "angle")] + Angle { bits: u8 }, + /// Pointer type + #[serde(rename = "pointer")] + Pointer { element: Box, is_const: bool }, + /// Void type (for return) + #[serde(rename = "void")] + Void, + /// Opaque type (user-defined struct, passed by name) + #[serde(rename = "opaque")] + Opaque { name: String }, +} + +/// SLR-AST external function call. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrExternCall { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Name of the external function to call + pub function: String, + /// Arguments to pass + pub args: Vec, + /// Optional: variable to store result + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option, +} + +impl SlrExternCall { + pub fn new(function: String, args: Vec, result: Option) -> Self { + Self { + node_type: "ExternCall", + function, + args, + result, + } + } +} + +/// SLR-AST expression. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrExpression { + Literal(SlrLiteralExpr), + Var(SlrVarExpr), + Bit(SlrBitExpr), + Binary(SlrBinaryExpr), + Unary(SlrUnaryExpr), + FString(SlrFStringExpr), + ExternCall(Box), +} + +/// SLR-AST literal expression. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrLiteralExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub value: SlrLiteralValue, +} + +/// Literal value variants. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrLiteralValue { + Int(i64), + Float(f64), + Bool(bool), + /// String value + String(String), + /// Angle value in turns (for a64 type) + Angle(f64), +} + +impl SlrLiteralExpr { + pub fn int(value: i64) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::Int(value), + } + } + + pub fn float(value: f64) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::Float(value), + } + } + + pub fn bool(value: bool) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::Bool(value), + } + } + + /// Create an angle literal (value in turns) + pub fn angle(turns: f64) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::Angle(turns), + } + } + + pub fn string(value: impl Into) -> Self { + Self { + node_type: "LiteralExpr", + value: SlrLiteralValue::String(value.into()), + } + } +} + +/// SLR-AST f-string expression (string interpolation). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrFStringExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + /// Parts of the f-string (text and expression parts) + pub parts: Vec, +} + +impl SlrFStringExpr { + pub fn new(parts: Vec) -> Self { + Self { + node_type: "FStringExpr", + parts, + } + } +} + +/// Part of an f-string. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(tag = "kind")] +pub enum SlrFStringPart { + /// Literal text + #[serde(rename = "text")] + Text { value: String }, + /// Interpolated expression + #[serde(rename = "expr")] + Expr { + value: Box, + #[serde(skip_serializing_if = "Option::is_none")] + format: Option, + }, +} + +/// SLR-AST variable expression. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrVarExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub name: String, +} + +impl SlrVarExpr { + pub fn new(name: impl Into) -> Self { + Self { + node_type: "VarExpr", + name: name.into(), + } + } +} + +/// SLR-AST bit expression (for conditions). +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBitExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub register: String, + pub index: usize, +} + +impl SlrBitExpr { + pub fn new(register: impl Into, index: usize) -> Self { + Self { + node_type: "BitExpr", + register: register.into(), + index, + } + } +} + +/// SLR-AST binary expression. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBinaryExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub op: &'static str, + pub left: Box, + pub right: Box, +} + +impl SlrBinaryExpr { + pub fn new(op: &'static str, left: SlrExpression, right: SlrExpression) -> Self { + Self { + node_type: "BinaryExpr", + op, + left: Box::new(left), + right: Box::new(right), + } + } +} + +/// SLR-AST unary expression. +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrUnaryExpr { + #[serde(rename = "type")] + pub node_type: &'static str, + pub op: &'static str, + pub operand: Box, +} + +impl SlrUnaryExpr { + pub fn new(op: &'static str, operand: SlrExpression) -> Self { + Self { + node_type: "UnaryExpr", + op, + operand: Box::new(operand), + } + } +} + +/// SLR-AST type expression (for return types). +#[derive(Debug, Clone, serde::Serialize)] +#[serde(untagged)] +pub enum SlrTypeExpr { + Qubit(SlrQubitType), + Bit(SlrBitType), +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrQubitType { + #[serde(rename = "type")] + pub node_type: &'static str, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct SlrBitType { + #[serde(rename = "type")] + pub node_type: &'static str, +} + +// ============================================================================= +// Gate Mapping +// ============================================================================= + +/// Gate information for SLR-AST. +struct GateInfo { + /// Gate name in SLR-AST (matches GateKind enum). + name: &'static str, + /// Number of qubit targets. + arity: usize, + /// Whether this gate takes parameters. + parameterized: bool, +} + +/// Get the arity (number of qubit targets) for a gate kind. +fn gate_kind_arity(kind: &crate::ast::GateKind) -> usize { + use crate::ast::GateKind; + match kind { + // Single-qubit gates + GateKind::X | GateKind::Y | GateKind::Z | GateKind::H + | GateKind::T | GateKind::Tdg + | GateKind::SX | GateKind::SY | GateKind::SZ + | GateKind::SXdg | GateKind::SYdg | GateKind::SZdg + | GateKind::RX | GateKind::RY | GateKind::RZ + | GateKind::F | GateKind::Fdg | GateKind::F4 | GateKind::F4dg + | GateKind::PZ => 1, + // Two-qubit gates + GateKind::CX | GateKind::CY | GateKind::CZ | GateKind::CH + | GateKind::SWAP | GateKind::ISWAP + | GateKind::SXX | GateKind::SYY | GateKind::SZZ + | GateKind::SXXdg | GateKind::SYYdg | GateKind::SZZdg + | GateKind::RZZ => 2, + // Three-qubit gates + GateKind::CCX => 3, + } +} + +/// Maps Zluppy gate names to SLR-AST gate info. +/// +/// Zluppy uses lowercase gate names only. +/// The output name in SLR-AST remains uppercase for compatibility with downstream tools. +fn get_gate_info(name: &str) -> Option { + match name { + // Single-qubit Pauli gates (lowercase only) + "x" => Some(GateInfo { + name: "X", + arity: 1, + parameterized: false, + }), + "y" => Some(GateInfo { + name: "Y", + arity: 1, + parameterized: false, + }), + "z" => Some(GateInfo { + name: "Z", + arity: 1, + parameterized: false, + }), + + // Hadamard + "h" => Some(GateInfo { + name: "H", + arity: 1, + parameterized: false, + }), + + // Square root gates (sx = sqrt(X), sy = sqrt(Y), sz = sqrt(Z)) + "sx" => Some(GateInfo { + name: "SX", + arity: 1, + parameterized: false, + }), + "sy" => Some(GateInfo { + name: "SY", + arity: 1, + parameterized: false, + }), + "sz" => Some(GateInfo { + name: "SZ", + arity: 1, + parameterized: false, + }), + "sxdg" => Some(GateInfo { + name: "SXdg", + arity: 1, + parameterized: false, + }), + "sydg" => Some(GateInfo { + name: "SYdg", + arity: 1, + parameterized: false, + }), + "szdg" => Some(GateInfo { + name: "SZdg", + arity: 1, + parameterized: false, + }), + + // T gates (fourth root of Z) + "t" => Some(GateInfo { + name: "T", + arity: 1, + parameterized: false, + }), + "tdg" => Some(GateInfo { + name: "Tdg", + arity: 1, + parameterized: false, + }), + + // Rotation gates (parameterized) + "rx" => Some(GateInfo { + name: "RX", + arity: 1, + parameterized: true, + }), + "ry" => Some(GateInfo { + name: "RY", + arity: 1, + parameterized: true, + }), + "rz" => Some(GateInfo { + name: "RZ", + arity: 1, + parameterized: true, + }), + + // Two-qubit Clifford + "cx" => Some(GateInfo { + name: "CX", + arity: 2, + parameterized: false, + }), + "cy" => Some(GateInfo { + name: "CY", + arity: 2, + parameterized: false, + }), + "cz" => Some(GateInfo { + name: "CZ", + arity: 2, + parameterized: false, + }), + "ch" => Some(GateInfo { + name: "CH", + arity: 2, + parameterized: false, + }), + + // Two-qubit rotation (parameterized) + "rzz" => Some(GateInfo { + name: "RZZ", + arity: 2, + parameterized: true, + }), + + // Two-qubit Ising + "sxx" => Some(GateInfo { + name: "SXX", + arity: 2, + parameterized: false, + }), + "syy" => Some(GateInfo { + name: "SYY", + arity: 2, + parameterized: false, + }), + "szz" => Some(GateInfo { + name: "SZZ", + arity: 2, + parameterized: false, + }), + + // Face rotations + "f" => Some(GateInfo { + name: "F", + arity: 1, + parameterized: false, + }), + "fdg" => Some(GateInfo { + name: "Fdg", + arity: 1, + parameterized: false, + }), + "f4" => Some(GateInfo { + name: "F4", + arity: 1, + parameterized: false, + }), + "f4dg" => Some(GateInfo { + name: "F4dg", + arity: 1, + parameterized: false, + }), + + // Two-qubit controlled rotation (parameterized) + "crz" => Some(GateInfo { + name: "CRZ", + arity: 2, + parameterized: true, + }), + + // Swap gates + "swap" => Some(GateInfo { + name: "SWAP", + arity: 2, + parameterized: false, + }), + "iswap" => Some(GateInfo { + name: "iSWAP", + arity: 2, + parameterized: false, + }), + + // Three-qubit gates + "ccx" => Some(GateInfo { + name: "CCX", + arity: 3, + parameterized: false, + }), + + // Two-qubit Ising dagger gates + "sxxdg" => Some(GateInfo { + name: "SXXdg", + arity: 2, + parameterized: false, + }), + "syydg" => Some(GateInfo { + name: "SYYdg", + arity: 2, + parameterized: false, + }), + "szzdg" => Some(GateInfo { + name: "SZZdg", + arity: 2, + parameterized: false, + }), + + _ => None, + } +} + +// ============================================================================= +// Binary/Unary Operator Mapping +// ============================================================================= + +fn binary_op_to_slr(op: &BinaryOp) -> &'static str { + match op { + BinaryOp::Add => "ADD", + BinaryOp::Sub => "SUB", + BinaryOp::Mul => "MUL", + BinaryOp::Div => "DIV", + BinaryOp::Mod => "MOD", + BinaryOp::Eq => "EQ", + BinaryOp::Ne => "NE", + BinaryOp::Lt => "LT", + BinaryOp::Le => "LE", + BinaryOp::Gt => "GT", + BinaryOp::Ge => "GE", + BinaryOp::And => "AND", + BinaryOp::Or => "OR", + BinaryOp::Orelse => "ORELSE", + BinaryOp::BitAnd => "AND", + BinaryOp::BitOr => "OR", + BinaryOp::BitXor => "XOR", + BinaryOp::Shl => "LSHIFT", + BinaryOp::Shr => "RSHIFT", + // Set membership operators - these are handled specially, not as SLR ops + BinaryOp::In => "IN", + BinaryOp::NotIn => "NOT_IN", + // Error handling - catch is control flow, not typically a direct SLR op + BinaryOp::Catch => "CATCH", + } +} + +// ============================================================================= +// Code Generator +// ============================================================================= + +/// Tracks an allocator during codegen. +#[derive(Debug, Clone)] +struct AllocatorInfo { + name: String, + capacity: usize, + parent: Option, +} + +/// Tracks a register during codegen. +#[derive(Debug, Clone)] +struct RegisterInfo { + name: String, + size: usize, +} + +/// Log level threshold for compile-time elision. +/// +/// Standard levels are spaced 100 apart for custom levels in between: +/// - trace=0, debug=100, info=200, warn=300, error=400 +/// +/// Set to higher values to elide more logs at compile time. +#[derive(Debug, Clone, Copy, Default)] +pub struct LogElisionLevel(pub Option); + +impl LogElisionLevel { + /// No elision - emit all logs + pub const NONE: Self = Self(None); + /// Elide trace logs (keep debug and above) + pub const DEBUG: Self = Self(Some(100)); + /// Elide trace and debug logs (keep info and above) + pub const INFO: Self = Self(Some(200)); + /// Elide trace, debug, and info logs (keep warn and above) + pub const WARN: Self = Self(Some(300)); + /// Elide everything except errors + pub const ERROR: Self = Self(Some(400)); + /// Elide all logs (for release builds) + pub const ALL: Self = Self(Some(u32::MAX)); + + /// Check if a log level should be elided. + pub fn should_elide(&self, level: u32) -> bool { + match self.0 { + Some(threshold) => level < threshold, + None => false, + } + } +} + +/// SLR-AST code generator. +/// +/// Walks a Zluppy AST and produces SLR-AST JSON. +pub struct SlrCodegen { + /// Allocators by name. + allocators: BTreeMap, + /// Registers by name. + registers: BTreeMap, + /// Auto-generated register counter. + register_counter: usize, + /// Names of external functions (for call lookup). + extern_fns: std::collections::BTreeSet, + /// Current module path for log namespace. + current_module: Option, + /// Minimum log level to emit (for compile-time elision). + log_elision: LogElisionLevel, + /// How to handle sim commands for non-simulator targets. + sim_mode: SimMode, +} + +/// How simulator commands are handled during code generation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SimMode { + /// Emit actual SimStmt (simulator target). + #[default] + Emit, + /// Emit a barrier/no-op to preserve ordering (hardware default). + Barrier, + /// Completely elide - no output at all (explicit opt-in). + Elide, +} + +impl SlrCodegen { + /// Create a new SLR-AST code generator. + pub fn new() -> Self { + Self { + allocators: BTreeMap::new(), + registers: BTreeMap::new(), + register_counter: 0, + extern_fns: std::collections::BTreeSet::new(), + current_module: None, + log_elision: LogElisionLevel::NONE, + sim_mode: SimMode::Emit, + } + } + + /// Set the log elision level for compile-time removal of log statements. + /// + /// # Example + /// + /// ```ignore + /// let mut codegen = SlrCodegen::new(); + /// // Elide all logs below info level + /// codegen.set_log_elision(LogElisionLevel::INFO); + /// ``` + pub fn set_log_elision(&mut self, level: LogElisionLevel) { + self.log_elision = level; + } + + /// Set how sim commands are handled. + /// + /// - `SimMode::Emit` - Output actual SimStmt (simulator target) + /// - `SimMode::Barrier` - Output barrier to preserve ordering (hardware default) + /// - `SimMode::Elide` - Completely remove (explicit opt-in for max optimization) + pub fn set_sim_mode(&mut self, mode: SimMode) { + self.sim_mode = mode; + } + + /// Create a new SLR-AST code generator with log elision for release builds. + pub fn new_release() -> Self { + let mut codegen = Self::new(); + codegen.log_elision = LogElisionLevel::ALL; + codegen + } + + /// Set the current module path for automatic log namespacing. + /// + /// The module path is used as the default namespace for log statements + /// that don't specify an explicit namespace. Sub-namespaces specified + /// in log statements are appended to this module path. + /// + /// # Example + /// + /// ```ignore + /// let mut codegen = SlrCodegen::new(); + /// codegen.set_module("myproject::syndromes"); + /// + /// // log.debug(f"msg") -> namespace: "myproject::syndromes" + /// // log.debug("round", f"msg") -> namespace: "myproject::syndromes::round" + /// ``` + pub fn set_module(&mut self, module: impl Into) { + self.current_module = Some(module.into()); + } + + /// Compile a Zluppy program to SLR-AST. + pub fn compile(&mut self, program: &Program) -> SlrResult { + let mut slr_program = SlrProgram::new("main"); + + // First pass: collect allocators and registers + for decl in &program.declarations { + self.collect_decl(decl)?; + } + + // Build declarations + for alloc in self.allocators.values() { + let mut decl = SlrAllocatorDecl::new(&alloc.name, alloc.capacity); + if let Some(ref parent) = alloc.parent { + decl = decl.with_parent(parent); + } + // Set base allocator if this is the first one without a parent + if alloc.parent.is_none() && slr_program.allocator.is_none() { + slr_program.allocator = Some(decl.clone()); + } + slr_program + .declarations + .push(SlrDeclaration::Allocator(decl)); + } + + for reg in self.registers.values() { + slr_program + .declarations + .push(SlrDeclaration::Register(SlrRegisterDecl::new( + ®.name, reg.size, + ))); + } + + // Collect extern function declarations + for decl in &program.declarations { + if let TopLevelDecl::ExternFn(extern_fn) = decl { + self.extern_fns.insert(extern_fn.name.clone()); + slr_program.externs.push(self.convert_extern_fn(extern_fn)?); + } + } + + // Second pass: convert statements + for decl in &program.declarations { + if let TopLevelDecl::Fn(fn_decl) = decl + && fn_decl.name == "main" { + let body = self.convert_block(&fn_decl.body)?; + slr_program.body = body; + } + } + + Ok(slr_program) + } + + /// Compile a function to SLR-AST. + pub fn compile_function(&mut self, fn_decl: &FnDecl) -> SlrResult { + // Collect from function body first + self.collect_block(&fn_decl.body)?; + + let mut slr_program = SlrProgram::new(&fn_decl.name); + + // Build declarations + for alloc in self.allocators.values() { + let mut decl = SlrAllocatorDecl::new(&alloc.name, alloc.capacity); + if let Some(ref parent) = alloc.parent { + decl = decl.with_parent(parent); + } + if alloc.parent.is_none() && slr_program.allocator.is_none() { + slr_program.allocator = Some(decl.clone()); + } + slr_program + .declarations + .push(SlrDeclaration::Allocator(decl)); + } + + for reg in self.registers.values() { + slr_program + .declarations + .push(SlrDeclaration::Register(SlrRegisterDecl::new( + ®.name, reg.size, + ))); + } + + // Convert body + slr_program.body = self.convert_block(&fn_decl.body)?; + + Ok(slr_program) + } + + /// Convert to JSON string. + pub fn to_json(&self, program: &SlrProgram) -> SlrResult { + serde_json::to_string_pretty(program).map_err(|e| SlrError::JsonError(e.to_string())) + } + + /// Convert to compact JSON string. + pub fn to_json_compact(&self, program: &SlrProgram) -> SlrResult { + serde_json::to_string(program).map_err(|e| SlrError::JsonError(e.to_string())) + } + + // ========================================================================= + // Collection Phase + // ========================================================================= + + fn collect_decl(&mut self, decl: &TopLevelDecl) -> SlrResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) => { + if fn_decl.name == "main" { + self.collect_block(&fn_decl.body)?; + } + } + TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, + _ => {} + } + Ok(()) + } + + fn collect_binding(&mut self, binding: &Binding) -> SlrResult<()> { + if let Some(ref value) = binding.value { + if let Some(capacity) = self.try_extract_allocator(value) { + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity, + parent: None, + }, + ); + } else if let Some((parent, size)) = self.try_extract_child_allocator(value) { + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity: size, + parent: Some(parent), + }, + ); + } + } + Ok(()) + } + + fn collect_block(&mut self, block: &Block) -> SlrResult<()> { + for stmt in &block.statements { + self.collect_stmt(stmt)?; + } + Ok(()) + } + + fn collect_stmt(&mut self, stmt: &Stmt) -> SlrResult<()> { + match stmt { + Stmt::Binding(binding) => self.collect_binding(binding)?, + Stmt::If(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + Stmt::For(for_stmt) => self.collect_block(&for_stmt.body)?, + Stmt::Block(block) => self.collect_block(block)?, + Stmt::Tick(tick_stmt) => { + // Collect allocators from tick body + for inner_stmt in &tick_stmt.body { + self.collect_stmt(inner_stmt)?; + } + } + _ => {} + } + Ok(()) + } + + fn collect_else_branch(&mut self, branch: &ElseBranch) -> SlrResult<()> { + match branch { + ElseBranch::Else(block) => self.collect_block(block)?, + ElseBranch::ElseIf(if_stmt) => { + self.collect_block(&if_stmt.then_body)?; + if let Some(else_branch) = &if_stmt.else_body { + self.collect_else_branch(else_branch)?; + } + } + } + Ok(()) + } + + // ========================================================================= + // Conversion Phase + // ========================================================================= + + fn convert_block(&mut self, block: &Block) -> SlrResult> { + let mut stmts = Vec::new(); + for stmt in &block.statements { + stmts.extend(self.convert_stmt(stmt)?); + } + Ok(stmts) + } + + fn convert_stmt(&mut self, stmt: &Stmt) -> SlrResult> { + match stmt { + Stmt::Expr(expr_stmt) => self.convert_expr_stmt(expr_stmt), + Stmt::If(if_stmt) => self.convert_if(if_stmt).map(|s| vec![s]), + Stmt::For(for_stmt) => self.convert_for(for_stmt).map(|s| vec![s]), + Stmt::Block(block) => { + // Nested block - flatten into parent + self.convert_block(block) + } + Stmt::Tick(tick_stmt) => self.convert_tick(tick_stmt).map(|s| vec![s]), + // Quantum operations + Stmt::Gate(gate_op) => self.convert_gate_op(gate_op).map(|s| vec![s]), + Stmt::Prepare(prepare_op) => self.convert_prepare_op(prepare_op).map(|s| vec![s]), + Stmt::Measure(measure_op) => self.convert_measure_op(measure_op).map(|s| vec![s]), + Stmt::Barrier(barrier_op) => self.convert_barrier_op(barrier_op).map(|s| vec![s]), + // Handle declarations - check for measurement calls in values + Stmt::Binding(binding) => { + if let Some(ref value) = binding.value { + self.convert_decl_value(value) + } else { + Ok(vec![]) + } + } + _ => Ok(vec![]), + } + } + + /// Convert a declaration value, extracting any measurement/gate calls. + fn convert_decl_value(&mut self, expr: &Expr) -> SlrResult> { + // Check for measure syntax: mz(T) targets + if let Expr::Measure(measure) = expr { + return self.convert_measure_expr(measure); + } + // Old call syntax mz(T, targets) is no longer supported + // Use mz(T) targets instead + Ok(vec![]) + } + + /// Convert new measure syntax: mz(T) targets + fn convert_measure_expr(&mut self, measure: &crate::ast::MeasureExpr) -> SlrResult> { + let mut results = Vec::new(); + let mut targets = Vec::new(); + + // Extract result type from the type expression + let result_type = self.extract_measurement_result_type_from_type_expr(&measure.result_type); + + // Extract targets from the expression + match &measure.targets { + Expr::BracketArray(arr) => { + // Inline array: mz(u1) [q[0], q[1], q[2]] + for elem in &arr.elements { + let slot_ref = self.extract_slot_ref(elem)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + } + Expr::Index(_) => { + // Single qubit: mz(u1) q[0] + let slot_ref = self.extract_slot_ref(&measure.targets)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + _ => { + // Variable or slice - try to extract slot refs + return Err(SlrError::UnsupportedExpression); + } + } + + Ok(vec![SlrStatement::Measure(SlrMeasureOp::with_result_type(targets, results, &result_type))]) + } + + fn convert_expr_stmt(&mut self, expr_stmt: &crate::ast::ExprStmt) -> SlrResult> { + // Convert attributes for gates + let attrs = self.convert_attributes(&expr_stmt.attrs); + + match &expr_stmt.expr { + Expr::Call(call) => self.convert_call_with_attrs(call, attrs), + Expr::BatchApply(batch) => self.convert_batch_apply_with_attrs(batch, attrs), + Expr::Gate(gate) => self.convert_gate_expr_with_attrs(gate, attrs), + Expr::Result(result) => self.convert_result_expr(result), + Expr::Channel(channel) => self.convert_channel_expr(channel), + Expr::Builtin(builtin) => self.convert_builtin_expr(builtin), + _ => Ok(vec![]), + } + } + + /// Convert builtin expressions like @swap. + fn convert_builtin_expr(&self, builtin: &crate::ast::BuiltinExpr) -> SlrResult> { + match builtin.name.as_str() { + "swap" => { + // @swap(&a, &b) - emit as SwapOp + if builtin.args.len() != 2 { + return Err(SlrError::WrongArgumentCount { + gate: "swap".to_string(), + expected: 2, + got: builtin.args.len(), + }); + } + // Extract the underlying expressions (handling &x -> x) + let a = self.convert_swap_arg(&builtin.args[0])?; + let b = self.convert_swap_arg(&builtin.args[1])?; + Ok(vec![SlrStatement::Swap(SlrSwapOp::new(a, b))]) + } + _ => Ok(vec![]), // Other builtins may not produce SLR statements + } + } + + /// Convert a swap argument, unwrapping address-of if present. + fn convert_swap_arg(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::Unary(unary) => { + use crate::ast::UnaryOp; + match unary.op { + UnaryOp::AddrOf => { + // &x -> just use x + self.convert_expression(&unary.operand) + } + _ => self.convert_expression(expr), + } + } + _ => self.convert_expression(expr), + } + } + + /// Convert a result expression to SLR send statement. + /// + /// Result sends are never elided - they represent the actual program output. + fn convert_result_expr(&self, result: &crate::ast::ResultExpr) -> SlrResult> { + let value = self.convert_expression(&result.value)?; + Ok(vec![SlrStatement::Send(SlrSendStmt::result(&result.tag, value))]) + } + + /// Convert a channel expression to SLR statements. + /// + /// Handles all @emit.channel.command(...) expressions: + /// - @emit.log.*: Logging with elision support + /// - @emit.sim.*: Simulator control with barrier/elide modes + /// - @emit.hw.*: Hardware messages (elided for simulator) + /// - Custom channels: Configurable behavior + fn convert_channel_expr(&mut self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + match channel.channel.as_str() { + "log" => self.convert_log_channel(channel), + "sim" => self.convert_sim_channel(channel), + "hw" => self.convert_hw_channel(channel), + _ => self.convert_custom_channel(channel), + } + } + + /// Convert @emit.log.* channel expressions. + fn convert_log_channel(&self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + // Map command to log level + let (level, numeric_level, level_consumes_arg) = match channel.command.as_str() { + "trace" => (SlrLogLevel::Standard("trace".to_string()), 0u32, false), + "debug" => (SlrLogLevel::Standard("debug".to_string()), 100, false), + "info" => (SlrLogLevel::Standard("info".to_string()), 200, false), + "warn" => (SlrLogLevel::Standard("warn".to_string()), 300, false), + "error" => (SlrLogLevel::Standard("error".to_string()), 400, false), + "at" => { + // @emit.log.at(level, message) or @emit.log.at(level, ns, message) - first arg is level + if let Some(level_arg) = channel.args.first() { + if let Ok(SlrExpression::Literal(SlrLiteralExpr { value: SlrLiteralValue::Int(n), .. })) = + self.convert_expression(level_arg.value()) + { + (SlrLogLevel::Numeric(n), n as u32, true) + } else { + (SlrLogLevel::Standard("custom".to_string()), u32::MAX, true) + } + } else { + return Ok(vec![]); // Invalid, skip + } + } + _ => return Ok(vec![]), // Unknown log command + }; + + // Check if this log should be elided + if self.log_elision.should_elide(numeric_level) { + return Ok(vec![]); + } + + + // Start index after the level argument (if "at" command) + let start_idx = if level_consumes_arg { 1 } else { 0 }; + + // Determine namespace and message + // If first positional is string literal AND there's another arg, first is namespace + let (sub_namespace, message) = { + let remaining: Vec<_> = channel.args.iter() + .skip(start_idx) + .filter(|arg| arg.name() != Some("data")) + .collect(); + + if remaining.len() >= 2 { + // Check if first is plain string literal (namespace) + if let crate::ast::Expr::StringLit(s) = remaining[0].value() { + let ns = Some(s.value.clone()); + let msg = self.convert_expression(remaining[1].value())?; + (ns, msg) + } else { + (None, self.convert_expression(remaining[0].value())?) + } + } else if !remaining.is_empty() { + (None, self.convert_expression(remaining[0].value())?) + } else { + return Ok(vec![]); // No message + } + }; + + let mut stmt = SlrLogStmt::new(level, message); + + // Combine module namespace with sub-namespace + let full_namespace = match (&self.current_module, &sub_namespace) { + (Some(module), Some(sub)) => Some(format!("{}::{}", module, sub)), + (Some(module), None) => Some(module.clone()), + (None, Some(sub)) => Some(sub.clone()), + (None, None) => None, + }; + if let Some(ns) = full_namespace { + stmt = stmt.with_namespace(ns); + } + + // Check for named "data" argument + for arg in &channel.args { + if arg.name() == Some("data") { + stmt = stmt.with_data(self.convert_expression(arg.value())?); + } + } + + Ok(vec![SlrStatement::Log(stmt)]) + } + + /// Convert @emit.sim.* channel expressions. + fn convert_sim_channel(&mut self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + match self.sim_mode { + SimMode::Elide => Ok(vec![]), + SimMode::Barrier => { + let scope_allocators: Vec = self.allocators.keys().cloned().collect(); + Ok(vec![SlrStatement::Barrier(SlrBarrierOp::new(scope_allocators))]) + } + SimMode::Emit => { + // Handle @emit.sim.send(key, value) specially - key comes from first arg + if channel.command == "send" { + if channel.args.len() >= 2 { + // Extract key from first arg (should be string literal) + let key_expr = channel.args[0].value(); + let key = if let crate::ast::Expr::StringLit(s) = key_expr { + s.value.clone() + } else { + return Err(SlrError::UnsupportedExpression); + }; + let value = self.convert_expression(channel.args[1].value())?; + return Ok(vec![SlrStatement::Send(SlrSendStmt::sim_with_value(&key, value))]); + } else if channel.args.len() == 1 { + // Just a key, no value + let key_expr = channel.args[0].value(); + let key = if let crate::ast::Expr::StringLit(s) = key_expr { + s.value.clone() + } else { + return Err(SlrError::UnsupportedExpression); + }; + return Ok(vec![SlrStatement::Send(SlrSendStmt::sim(&key))]); + } + } + + // For other commands like @emit.sim.noise_enable(), @emit.sim.noise_disable() + // the command name becomes the key + let key = channel.command.clone(); + if let Some(arg) = channel.args.first() { + let value = self.convert_expression(arg.value())?; + Ok(vec![SlrStatement::Send(SlrSendStmt::sim_with_value(&key, value))]) + } else { + Ok(vec![SlrStatement::Send(SlrSendStmt::sim(&key))]) + } + } + } + } + + /// Convert @emit.hw.* channel expressions. + fn convert_hw_channel(&mut self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + // hw channel is opposite of sim: active for hardware, elided for simulator + match self.sim_mode { + SimMode::Emit => { + // Simulator target - elide hw messages + Ok(vec![]) + } + SimMode::Barrier | SimMode::Elide => { + // Hardware target - emit hw messages + let key = channel.command.clone(); + if let Some(arg) = channel.args.first() { + let value = self.convert_expression(arg.value())?; + Ok(vec![SlrStatement::Send(SlrSendStmt::new("hw", &key).with_value(value))]) + } else { + Ok(vec![SlrStatement::Send(SlrSendStmt::new("hw", &key))]) + } + } + } + } + + /// Convert custom channel expressions. + fn convert_custom_channel(&mut self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + // Custom channels emit as SendStmt with channel name + // They act as barriers (sticky) + let key = channel.command.clone(); + if let Some(arg) = channel.args.first() { + let value = self.convert_expression(arg.value())?; + Ok(vec![SlrStatement::Send(SlrSendStmt::new(&channel.channel, &key).with_value(value))]) + } else { + Ok(vec![SlrStatement::Send(SlrSendStmt::new(&channel.channel, &key))]) + } + } + + /// Convert a gate expression: h q[0], rx(0.123) q[0], h {q[0], q[1]} + fn convert_gate_expr_with_attrs( + &mut self, + gate: &crate::ast::GateExpr, + attrs: std::collections::BTreeMap, + ) -> SlrResult> { + use crate::ast::GateKind; + + // Handle PZ (prepare) specially + if gate.kind == GateKind::PZ { + return self.convert_prepare_from_gate_expr(gate); + } + + // Map GateKind to SLR gate name + let gate_name: &'static str = match gate.kind { + GateKind::X => "X", + GateKind::Y => "Y", + GateKind::Z => "Z", + GateKind::H => "H", + GateKind::T => "T", + GateKind::Tdg => "Tdg", + GateKind::SX => "SX", + GateKind::SY => "SY", + GateKind::SZ => "SZ", + GateKind::SXdg => "SXdg", + GateKind::SYdg => "SYdg", + GateKind::SZdg => "SZdg", + GateKind::RX => "RX", + GateKind::RY => "RY", + GateKind::RZ => "RZ", + GateKind::CX => "CX", + GateKind::CY => "CY", + GateKind::CZ => "CZ", + GateKind::CH => "CH", + GateKind::SWAP => "SWAP", + GateKind::ISWAP => "ISWAP", + GateKind::SXX => "SXX", + GateKind::SYY => "SYY", + GateKind::SZZ => "SZZ", + GateKind::SXXdg => "SXXdg", + GateKind::SYYdg => "SYYdg", + GateKind::SZZdg => "SZZdg", + GateKind::RZZ => "RZZ", + GateKind::CCX => "CCX", + GateKind::F => "F", + GateKind::Fdg => "Fdg", + GateKind::F4 => "F4", + GateKind::F4dg => "F4dg", + GateKind::PZ => unreachable!(), // Handled above + }; + + // Convert parameters + let params: Vec = gate + .params + .iter() + .map(|p| self.convert_expression(p)) + .collect::>>()?; + + // Convert target based on its type + match &gate.target { + // Single qubit: q[0] + Expr::Index(_) => { + let slot_ref = self.extract_slot_ref(&gate.target)?; + Ok(vec![SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![slot_ref]) + .with_params(params) + .with_attrs(attrs), + )]) + } + // Batch with set: {q[0], q[1]} + Expr::Set(set) => { + let arity = gate_kind_arity(&gate.kind); + let mut statements = Vec::new(); + for elem in &set.elements { + match (arity, elem) { + // Single qubit in batch (arity 1 only) + (1, Expr::Index(_)) => { + let slot_ref = self.extract_slot_ref(elem)?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![slot_ref]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + // Tuple for two-qubit gates: (q[0], q[1]) + (2, Expr::Tuple(tuple)) if tuple.elements.len() == 2 => { + let control = self.extract_slot_ref(&tuple.elements[0])?; + let target = self.extract_slot_ref(&tuple.elements[1])?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![control, target]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + // Wrong arity: single qubit for 2-qubit gate or vice versa + (expected, _) => { + return Err(SlrError::WrongArgumentCount { + gate: gate_name.to_string(), + expected, + got: if matches!(elem, Expr::Index(_)) { 1 } else { 2 }, + }); + } + } + } + Ok(statements) + } + // Two-qubit gate with tuple: (q[0], q[1]) + Expr::Tuple(tuple) if tuple.elements.len() == 2 => { + let control = self.extract_slot_ref(&tuple.elements[0])?; + let target = self.extract_slot_ref(&tuple.elements[1])?; + Ok(vec![SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![control, target]) + .with_params(params) + .with_attrs(attrs), + )]) + } + // Batch with bracket array: [q[0], q[1]] + Expr::BracketArray(arr) => { + let arity = gate_kind_arity(&gate.kind); + let mut statements = Vec::new(); + for elem in &arr.elements { + match (arity, elem) { + (1, Expr::Index(_)) => { + let slot_ref = self.extract_slot_ref(elem)?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![slot_ref]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + (2, Expr::Tuple(tuple)) if tuple.elements.len() == 2 => { + let control = self.extract_slot_ref(&tuple.elements[0])?; + let target = self.extract_slot_ref(&tuple.elements[1])?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![control, target]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + (expected, _) => { + return Err(SlrError::WrongArgumentCount { + gate: gate_name.to_string(), + expected, + got: if matches!(elem, Expr::Index(_)) { 1 } else { 2 }, + }); + } + } + } + Ok(statements) + } + // Allocator reference for "apply to all": h q (single-qubit gates only) + Expr::Ident(ident) => { + // Get allocator info to determine capacity + let alloc = self.allocators.get(&ident.name).ok_or_else(|| { + SlrError::UndefinedAllocator { + name: ident.name.clone(), + } + })?; + let capacity = alloc.capacity; + let alloc_name = alloc.name.clone(); + + // Apply gate to all qubits in allocator + let mut statements = Vec::new(); + for i in 0..capacity { + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_name, vec![SlrSlotRef::new(&alloc_name, i)]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + Ok(statements) + } + _ => Err(SlrError::UnsupportedExpression), + } + } + + /// Convert pz (prepare) gate expression to prepare operation + fn convert_prepare_from_gate_expr( + &mut self, + gate: &crate::ast::GateExpr, + ) -> SlrResult> { + match &gate.target { + // pz q - prepare all qubits in allocator + Expr::Ident(ident) => { + Ok(vec![SlrStatement::Prepare(SlrPrepareOp::all(&ident.name))]) + } + // pz q[0] - prepare single qubit + Expr::Index(_) => { + let slot_ref = self.extract_slot_ref(&gate.target)?; + Ok(vec![SlrStatement::Prepare(SlrPrepareOp::slots( + slot_ref.allocator, + vec![slot_ref.index], + ))]) + } + // pz {q[0], q[1]} - prepare batch (set) + Expr::Set(set) => { + let mut slots_by_alloc: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for elem in &set.elements { + let slot_ref = self.extract_slot_ref(elem)?; + slots_by_alloc + .entry(slot_ref.allocator) + .or_default() + .push(slot_ref.index); + } + let mut statements = Vec::new(); + for (alloc, slots) in slots_by_alloc { + statements.push(SlrStatement::Prepare(SlrPrepareOp::slots(alloc, slots))); + } + Ok(statements) + } + // pz [q[0], q[1]] - prepare batch (array) + Expr::BracketArray(arr) => { + let mut slots_by_alloc: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + for elem in &arr.elements { + let slot_ref = self.extract_slot_ref(elem)?; + slots_by_alloc + .entry(slot_ref.allocator) + .or_default() + .push(slot_ref.index); + } + let mut statements = Vec::new(); + for (alloc, slots) in slots_by_alloc { + statements.push(SlrStatement::Prepare(SlrPrepareOp::slots(alloc, slots))); + } + Ok(statements) + } + _ => Err(SlrError::UnsupportedExpression), + } + } + + /// Convert a batch apply expression: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } + fn convert_batch_apply_with_attrs( + &mut self, + batch: &crate::ast::BatchApplyExpr, + attrs: std::collections::BTreeMap, + ) -> SlrResult> { + // Extract gate info and params from the operation + let (gate_name, params) = match &batch.operation { + Expr::Ident(ident) => (ident.name.clone(), Vec::new()), + Expr::Call(call) => { + if let Expr::Ident(ident) = &call.callee { + let mut params = Vec::new(); + for arg in &call.args { + params.push(self.convert_expression(arg)?); + } + (ident.name.clone(), params) + } else { + return Err(SlrError::UnsupportedExpression); + } + } + _ => return Err(SlrError::UnsupportedExpression), + }; + + // Get gate info + let Some(gate_info) = get_gate_info(&gate_name) else { + return Err(SlrError::UnsupportedExpression); + }; + + // Convert batch targets to statements + self.convert_batch_gate_with_attrs(gate_info, &batch.targets, params, attrs) + } + + /// Convert AST attributes to SLR attributes. + fn convert_attributes(&self, attrs: &[crate::ast::Attribute]) -> std::collections::BTreeMap { + let mut result = std::collections::BTreeMap::new(); + for attr in attrs { + let value = match &attr.value { + Some(crate::ast::AttributeValue::Bool(b)) => SlrAttributeValue::Bool(*b), + Some(crate::ast::AttributeValue::Int(i)) => SlrAttributeValue::Int(*i), + Some(crate::ast::AttributeValue::Float(f)) => SlrAttributeValue::Float(*f), + Some(crate::ast::AttributeValue::String(s)) => SlrAttributeValue::String(s.clone()), + Some(crate::ast::AttributeValue::Ident(s)) => SlrAttributeValue::String(s.clone()), + None => SlrAttributeValue::Bool(true), // Flag attributes default to true + }; + result.insert(attr.name.clone(), value); + } + result + } + + fn convert_call(&mut self, call: &CallExpr) -> SlrResult> { + self.convert_call_with_attrs(call, std::collections::BTreeMap::new()) + } + + fn convert_call_with_attrs( + &mut self, + call: &CallExpr, + attrs: std::collections::BTreeMap, + ) -> SlrResult> { + let name = self.extract_call_name(&call.callee)?; + + // Check for special operations (lowercase only) + match name.as_str() { + // mz uses new syntax: mz(T) targets - handled via Expr::Measure + // Old mz(T, target) call syntax is no longer supported + // pz = prepare +Z eigenstate (reset) + "pz" => return self.convert_reset(call), + "barrier" => return self.convert_barrier(call), + _ => {} + } + + // Check for external function calls + if self.extern_fns.contains(&name) { + let args: Result, SlrError> = call + .args + .iter() + .map(|arg| self.convert_expression(arg)) + .collect(); + return Ok(vec![SlrStatement::ExternCall(SlrExternCall::new( + name, + args?, + None, // Result variable set later during assignment handling + ))]); + } + + // Check for gate calls + let Some(gate_info) = get_gate_info(&name) else { + return Ok(vec![]); + }; + + // For parameterized gates: angle comes first, then qubits + // rz(1.57, q[0]) or rz(1.57, [q[0], q[1]) + let (params, qubit_args) = if gate_info.parameterized { + if call.args.is_empty() { + return Err(SlrError::WrongArgumentCount { + gate: name, + expected: gate_info.arity + 1, + got: 0, + }); + } + let param = self.convert_expression(&call.args[0])?; + (vec![param], &call.args[1..]) + } else { + (Vec::new(), &call.args[..]) + }; + + // Check if qubit argument is a Set or BracketArray (batch operation) + if !qubit_args.is_empty() { + if let Expr::Set(set_expr) = &qubit_args[0] { + return self.convert_batch_gate_with_attrs(gate_info, &set_expr.elements, params, attrs); + } + if let Expr::BracketArray(arr_expr) = &qubit_args[0] { + return self.convert_batch_gate_with_attrs(gate_info, &arr_expr.elements, params, attrs); + } + } + + // Standard single-target gate call + // Validate argument count + if qubit_args.len() != gate_info.arity { + return Err(SlrError::WrongArgumentCount { + gate: name, + expected: if gate_info.parameterized { + gate_info.arity + 1 + } else { + gate_info.arity + }, + got: call.args.len(), + }); + } + + // Extract qubit targets + let mut targets = Vec::with_capacity(gate_info.arity); + for arg in qubit_args.iter().take(gate_info.arity) { + let slot_ref = self.extract_slot_ref(arg)?; + targets.push(slot_ref); + } + + Ok(vec![SlrStatement::Gate( + SlrGateOp::new(gate_info.name, targets) + .with_params(params) + .with_attrs(attrs), + )]) + } + + /// Convert a batch gate operation into multiple SLR gates. + fn convert_batch_gate( + &mut self, + gate_info: GateInfo, + elements: &[Expr], + params: Vec, + ) -> SlrResult> { + self.convert_batch_gate_with_attrs(gate_info, elements, params, std::collections::BTreeMap::new()) + } + + /// Convert a batch gate operation with attributes into multiple SLR gates. + /// Works with both Set and BracketArray expressions. + fn convert_batch_gate_with_attrs( + &mut self, + gate_info: GateInfo, + elements: &[Expr], + params: Vec, + attrs: std::collections::BTreeMap, + ) -> SlrResult> { + let mut statements = Vec::new(); + + if gate_info.arity == 1 { + // Single-qubit gate: each element is a qubit + for elem in elements { + let slot_ref = self.extract_slot_ref(elem)?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_info.name, vec![slot_ref]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } + } else if gate_info.arity == 2 { + // Two-qubit gate: each element is a tuple (control, target) + for elem in elements { + if let Expr::Tuple(tuple) = elem { + if tuple.elements.len() == 2 { + let control = self.extract_slot_ref(&tuple.elements[0])?; + let target = self.extract_slot_ref(&tuple.elements[1])?; + statements.push(SlrStatement::Gate( + SlrGateOp::new(gate_info.name, vec![control, target]) + .with_params(params.clone()) + .with_attrs(attrs.clone()), + )); + } else { + return Err(SlrError::UnsupportedExpression); + } + } else { + return Err(SlrError::UnsupportedExpression); + } + } + } else { + return Err(SlrError::UnsupportedExpression); + } + + Ok(statements) + } + + fn convert_measure(&mut self, call: &CallExpr) -> SlrResult> { + // Typed measurement: mz(type, target) where: + // - type is u1, u8, u64, []u1, []u8, []u64 + // - target is q[0] or &[q[0], q[1], ...] + + // Check for typed measurement syntax (2 args: type + target) + if call.args.len() == 2 { + return self.convert_typed_measure(call); + } + + // Legacy: measure(q[0]) or measure(q[0], q[1], ...) + let mut targets = Vec::new(); + let mut results = Vec::new(); + + for arg in &call.args { + let slot_ref = self.extract_slot_ref(arg)?; + + // Auto-generate result register if needed + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + + Ok(vec![SlrStatement::Measure(SlrMeasureOp::new( + targets, results, + ))]) + } + + /// Convert a typed measurement call: mz(type, target) + fn convert_typed_measure(&mut self, call: &CallExpr) -> SlrResult> { + // First argument is the type + let result_type = self.extract_measurement_result_type(&call.args[0]); + // Second argument is the target(s) + let target_arg = &call.args[1]; + + let mut targets = Vec::new(); + let mut results = Vec::new(); + + // Extract targets from second argument + match target_arg { + // Single qubit: q[0] + Expr::Index(_) => { + let slot_ref = self.extract_slot_ref(target_arg)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + + // Address-of array: &[q[0], q[1], ...] + Expr::Unary(unary) => { + if let crate::ast::UnaryOp::AddrOf = unary.op { + if let Expr::BracketArray(arr) = &unary.operand { + for elem in &arr.elements { + let slot_ref = self.extract_slot_ref(elem)?; + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + targets.push(slot_ref); + } + } else { + return Err(SlrError::UnsupportedExpression); + } + } else { + return Err(SlrError::UnsupportedExpression); + } + } + + _ => return Err(SlrError::UnsupportedExpression), + } + + Ok(vec![SlrStatement::Measure(SlrMeasureOp::with_result_type( + targets, results, &result_type, + ))]) + } + + /// Extract measurement result type from a type expression. + fn extract_measurement_result_type(&self, expr: &Expr) -> String { + match expr { + Expr::Ident(ident) => ident.name.clone(), + // For slice types []u1, []u8, []u64 - extract the element type + Expr::SlotRef(slot_ref) => slot_ref.allocator.clone(), + _ => "u1".to_string(), // Default to u1 + } + } + + /// Extract measurement result type from a TypeExpr (for new mz(T) target syntax). + fn extract_measurement_result_type_from_type_expr(&self, type_expr: &crate::ast::TypeExpr) -> String { + use crate::ast::{PrimitiveType, TypeExpr}; + match type_expr { + TypeExpr::Primitive(prim) => match prim { + PrimitiveType::UInt { bits } => format!("u{bits}"), + PrimitiveType::IInt { bits } => format!("i{bits}"), + _ => "u1".to_string(), // Default for other types + }, + TypeExpr::Named(path) => { + // For named types, return the path as string + path.segments.join("::") + } + TypeExpr::Array(arr) => { + // For array types like []u1, extract the element type + self.extract_measurement_result_type_from_type_expr(&arr.element) + } + _ => "u1".to_string(), // Default to u1 + } + } + + fn convert_reset(&mut self, call: &CallExpr) -> SlrResult> { + // reset(q[0]) or reset with allocator + if call.args.is_empty() { + return Ok(vec![]); + } + + let slot_ref = self.extract_slot_ref(&call.args[0])?; + Ok(vec![SlrStatement::Prepare(SlrPrepareOp::slots( + slot_ref.allocator, + vec![slot_ref.index], + ))]) + } + + fn convert_barrier(&mut self, call: &CallExpr) -> SlrResult> { + let mut allocators = Vec::new(); + for arg in &call.args { + if let Expr::Ident(ident) = arg { + allocators.push(ident.name.clone()); + } + } + Ok(vec![SlrStatement::Barrier(SlrBarrierOp::new(allocators))]) + } + + // Conversion functions for AST quantum statement types + + fn convert_gate_op(&mut self, gate_op: &crate::ast::GateOp) -> SlrResult { + use crate::ast::GateKind; + + // Map AST GateKind to SLR gate name + let gate_name: &'static str = match gate_op.kind { + GateKind::X => "X", + GateKind::Y => "Y", + GateKind::Z => "Z", + GateKind::H => "H", + GateKind::T => "T", + GateKind::Tdg => "Tdg", + GateKind::SX => "SX", + GateKind::SY => "SY", + GateKind::SZ => "SZ", + GateKind::SXdg => "SXdg", + GateKind::SYdg => "SYdg", + GateKind::SZdg => "SZdg", + GateKind::RX => "RX", + GateKind::RY => "RY", + GateKind::RZ => "RZ", + GateKind::CX => "CX", + GateKind::CY => "CY", + GateKind::CZ => "CZ", + GateKind::CH => "CH", + GateKind::SWAP => "SWAP", + GateKind::ISWAP => "ISWAP", + GateKind::SXX => "SXX", + GateKind::SYY => "SYY", + GateKind::SZZ => "SZZ", + GateKind::SXXdg => "SXXdg", + GateKind::SYYdg => "SYYdg", + GateKind::SZZdg => "SZZdg", + GateKind::RZZ => "RZZ", + GateKind::CCX => "CCX", + GateKind::F => "F", + GateKind::Fdg => "Fdg", + GateKind::F4 => "F4", + GateKind::F4dg => "F4dg", + GateKind::PZ => "PZ", // Prepare operation, handled specially + }; + + // Convert targets + let targets: Vec = gate_op + .targets + .iter() + .map(|slot_ref| { + let index = self.extract_const_index(&slot_ref.index).unwrap_or(0); + SlrSlotRef::new(&slot_ref.allocator, index) + }) + .collect(); + + // Convert parameters + let params: Vec = gate_op + .params + .iter() + .map(|expr| self.convert_expression(expr)) + .collect::>>()?; + + Ok(SlrStatement::Gate( + SlrGateOp::new(gate_name, targets).with_params(params), + )) + } + + fn convert_prepare_op(&mut self, prepare_op: &crate::ast::PrepareOp) -> SlrResult { + if let Some(ref slots) = prepare_op.slots { + let slot_indices: Vec = slots.iter().map(|&s| s as usize).collect(); + Ok(SlrStatement::Prepare(SlrPrepareOp::slots( + &prepare_op.allocator, + slot_indices, + ))) + } else { + Ok(SlrStatement::Prepare(SlrPrepareOp::all(&prepare_op.allocator))) + } + } + + fn convert_measure_op(&mut self, measure_op: &crate::ast::MeasureOp) -> SlrResult { + let mut targets = Vec::new(); + let mut results = Vec::new(); + + for slot_ref in &measure_op.targets { + let index = self.extract_const_index(&slot_ref.index).unwrap_or(0); + targets.push(SlrSlotRef::new(&slot_ref.allocator, index)); + + // Auto-generate result register if needed + let reg_name = format!("c{}", self.register_counter); + self.register_counter += 1; + + if !self.registers.contains_key(®_name) { + self.registers.insert( + reg_name.clone(), + RegisterInfo { + name: reg_name.clone(), + size: 1, + }, + ); + } + + results.push(SlrBitRef::new(®_name, 0)); + } + + Ok(SlrStatement::Measure(SlrMeasureOp::new(targets, results))) + } + + fn convert_barrier_op(&mut self, barrier_op: &crate::ast::BarrierOp) -> SlrResult { + Ok(SlrStatement::Barrier(SlrBarrierOp::new( + barrier_op.allocators.clone(), + ))) + } + + fn convert_if(&mut self, if_stmt: &IfStmt) -> SlrResult { + let condition = self.convert_expression(&if_stmt.condition)?; + let then_body = self.convert_block(&if_stmt.then_body)?; + + let else_body = if let Some(ref else_branch) = if_stmt.else_body { + self.convert_else_branch(else_branch)? + } else { + Vec::new() + }; + + Ok(SlrStatement::If( + SlrIfStmt::new(condition, then_body).with_else(else_body), + )) + } + + fn convert_else_branch(&mut self, branch: &ElseBranch) -> SlrResult> { + match branch { + ElseBranch::Else(block) => self.convert_block(block), + ElseBranch::ElseIf(if_stmt) => { + let condition = self.convert_expression(&if_stmt.condition)?; + let then_body = self.convert_block(&if_stmt.then_body)?; + let else_body = if let Some(ref else_branch) = if_stmt.else_body { + self.convert_else_branch(else_branch)? + } else { + Vec::new() + }; + Ok(vec![SlrStatement::If( + SlrIfStmt::new(condition, then_body).with_else(else_body), + )]) + } + } + } + + fn convert_tick(&mut self, tick_stmt: &crate::ast::TickStmt) -> SlrResult { + // Convert all statements within the tick + let mut body = Vec::new(); + for stmt in &tick_stmt.body { + body.extend(self.convert_stmt(stmt)?); + } + + // Create tick statement with optional label + let mut slr_tick = SlrTickStmt::new(body); + if let Some(ref label) = tick_stmt.label { + slr_tick = slr_tick.with_label(label.clone()); + } + + // Convert attributes + if !tick_stmt.attrs.is_empty() { + let mut attrs = std::collections::BTreeMap::new(); + for attr in &tick_stmt.attrs { + let value = match &attr.value { + Some(crate::ast::AttributeValue::Bool(b)) => SlrAttributeValue::Bool(*b), + Some(crate::ast::AttributeValue::Int(i)) => SlrAttributeValue::Int(*i), + Some(crate::ast::AttributeValue::Float(f)) => SlrAttributeValue::Float(*f), + Some(crate::ast::AttributeValue::String(s)) => SlrAttributeValue::String(s.clone()), + Some(crate::ast::AttributeValue::Ident(s)) => SlrAttributeValue::String(s.clone()), + None => SlrAttributeValue::Bool(true), // Flag attributes default to true + }; + attrs.insert(attr.name.clone(), value); + } + slr_tick = slr_tick.with_attrs(attrs); + } + + Ok(SlrStatement::Tick(slr_tick)) + } + + fn convert_for(&mut self, for_stmt: &ForStmt) -> SlrResult { + // For SLR, convert bounded for loops to repeat statements + // For unbounded, use ForStmt + if let Some(count) = self.try_extract_repeat_count(for_stmt) { + let body = self.convert_block(&for_stmt.body)?; + return Ok(SlrStatement::Repeat(SlrRepeatStmt::new(count, body))); + } + + // General for loop - extract from ForRange + let (start, end) = match &for_stmt.range { + ForRange::Range { start, end } => ( + self.convert_expression(start)?, + self.convert_expression(end)?, + ), + ForRange::Collection(expr) => { + // For collection iteration, use the expression as both start and end placeholder + let expr_converted = self.convert_expression(expr)?; + (expr_converted.clone(), expr_converted) + } + }; + + // Get the iteration variable from captures (first capture is the loop variable) + let variable = for_stmt + .captures + .first() + .cloned() + .unwrap_or_else(|| "_".to_string()); + + let body = self.convert_block(&for_stmt.body)?; + Ok(SlrStatement::For(SlrForStmt::new(variable, start, end, body))) + } + + fn try_extract_repeat_count(&self, for_stmt: &ForStmt) -> Option { + // Check for patterns like: for _ in 0..10 { } + if let ForRange::Range { start, end } = &for_stmt.range + && let Expr::IntLit(start_lit) = start + && start_lit.value == 0 + && let Expr::IntLit(end_lit) = end { + return Some(end_lit.value as usize); + } + None + } + + fn convert_expression(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::IntLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::int(lit.value as i64))), + Expr::FloatLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::float(lit.value))), + Expr::BoolLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::bool(lit.value))), + Expr::Ident(ident) => { + // Check for built-in angle constants + match ident.name.as_str() { + "pi" | "PI" => { + return Ok(SlrExpression::Literal(SlrLiteralExpr::float( + std::f64::consts::PI, + ))); + } + "tau" | "TAU" => { + return Ok(SlrExpression::Literal(SlrLiteralExpr::float( + std::f64::consts::TAU, + ))); + } + "e" | "E" => { + return Ok(SlrExpression::Literal(SlrLiteralExpr::float( + std::f64::consts::E, + ))); + } + _ => {} + } + Ok(SlrExpression::Var(SlrVarExpr::new(&ident.name))) + } + Expr::Binary(binary) => { + let op = binary_op_to_slr(&binary.op); + let left = self.convert_expression(&binary.left)?; + let right = self.convert_expression(&binary.right)?; + Ok(SlrExpression::Binary(SlrBinaryExpr::new(op, left, right))) + } + Expr::Index(index) => { + // Check if this is a bit reference (register[index]) + let name = self.extract_identifier(&index.object)?; + if self.registers.contains_key(&name) { + let idx = self.extract_integer(&index.index)?; + return Ok(SlrExpression::Bit(SlrBitExpr::new(name, idx))); + } + Err(SlrError::UnsupportedExpression) + } + Expr::AngleLit(angle) => { + use crate::ast::AngleUnit; + + // For radians, try to recognize common pi-based patterns for exact conversion + if let AngleUnit::Rad = angle.unit + && let Some(exact_turns) = recognize_exact_radian_pattern(&angle.value) { + return Ok(SlrExpression::Literal(SlrLiteralExpr::angle(exact_turns))); + } + + // Fall back to floating-point evaluation + use crate::comptime::ComptimeEvaluator; + let mut eval = ComptimeEvaluator::new(); + let value = eval.eval_expr(&angle.value).map_err(|_| SlrError::InvalidAngle)?; + let numeric = match value { + crate::comptime::ComptimeValue::Float(f) => f, + crate::comptime::ComptimeValue::Int(i) => i as f64, + crate::comptime::ComptimeValue::Uint(u) => u as f64, + _ => return Err(SlrError::InvalidAngle), + }; + // Convert to turns (the native unit for angles) + let turns = angle.unit.to_turns(numeric); + // Output as angle literal with type information + Ok(SlrExpression::Literal(SlrLiteralExpr::angle(turns))) + } + Expr::TypeAscription(asc) => { + // For type ascription, evaluate the expression + // The type information is used for semantic checking, but the value is what matters for codegen + self.convert_expression(&asc.value) + } + Expr::StringLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::string(&lit.value))), + Expr::FString(fstr) => { + use crate::ast::FStringPart; + let parts = fstr + .parts + .iter() + .map(|part| match part { + FStringPart::Text(text) => Ok(SlrFStringPart::Text { + value: text.clone(), + }), + FStringPart::Expr { expr, format } => Ok(SlrFStringPart::Expr { + value: Box::new(self.convert_expression(expr)?), + format: format.clone(), + }), + }) + .collect::>>()?; + Ok(SlrExpression::FString(SlrFStringExpr::new(parts))) + } + _ => Err(SlrError::UnsupportedExpression), + } + } + + // ========================================================================= + // Extraction Helpers + // ========================================================================= + + fn try_extract_allocator(&self, expr: &Expr) -> Option { + if let Expr::Call(call) = expr { + let name = self.extract_call_name(&call.callee).ok()?; + if name == "qalloc" && call.args.len() == 1 { + return self.extract_integer(&call.args[0]).ok(); + } + } + None + } + + fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { + if let Expr::Call(call) = expr + && let Expr::Field(field) = &call.callee + && field.field == "child" && call.args.len() == 1 { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } + None + } + + fn extract_call_name(&self, callee: &Expr) -> SlrResult { + match callee { + Expr::Ident(ident) => Ok(ident.name.clone()), + Expr::Field(field) => Ok(field.field.clone()), + _ => Err(SlrError::UnsupportedExpression), + } + } + + fn extract_identifier(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::Ident(ident) => Ok(ident.name.clone()), + _ => Err(SlrError::UnsupportedExpression), + } + } + + fn extract_slot_ref(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::Index(index) => self.extract_slot_from_index(index), + _ => Err(SlrError::UnsupportedExpression), + } + } + + fn extract_slot_from_index(&self, index: &IndexExpr) -> SlrResult { + let allocator = self.extract_identifier(&index.object)?; + let idx = self.extract_integer(&index.index)?; + + // Validate allocator exists + let alloc = self + .allocators + .get(&allocator) + .ok_or_else(|| SlrError::UndefinedAllocator { + name: allocator.clone(), + })?; + + // Validate index bounds + if idx >= alloc.capacity { + return Err(SlrError::QubitIndexOutOfBounds { + allocator: allocator.clone(), + index: idx, + capacity: alloc.capacity, + }); + } + + Ok(SlrSlotRef::new(allocator, idx)) + } + + fn extract_integer(&self, expr: &Expr) -> SlrResult { + match expr { + Expr::IntLit(lit) => Ok(lit.value as usize), + _ => Err(SlrError::UnsupportedExpression), + } + } + + fn extract_const_index(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(lit) => Some(lit.value as usize), + _ => None, + } + } + + /// Convert an ExternFnDecl to SLR-AST representation. + fn convert_extern_fn(&self, extern_fn: &crate::ast::ExternFnDecl) -> SlrResult { + let params: Vec = extern_fn + .params + .iter() + .map(|p| SlrExternParam { + name: p.name.clone(), + ctype: self.convert_type_to_ctype(&p.ty), + }) + .collect(); + + let return_type = extern_fn + .return_type + .as_ref() + .map(|t| self.convert_type_to_ctype(t)); + + Ok(SlrExternDecl::new( + extern_fn.name.clone(), + extern_fn.library.clone(), + extern_fn.calling_convention.clone(), + params, + return_type, + )) + } + + /// Convert a Zlup type expression to a C-compatible type. + fn convert_type_to_ctype(&self, ty: &crate::ast::TypeExpr) -> SlrCType { + use crate::ast::{PrimitiveType, TypeExpr}; + + match ty { + // Primitive types (parsed from u8, u32, i32, f32, etc.) + TypeExpr::Primitive(prim) => match prim { + PrimitiveType::UInt { bits } => SlrCType::Int { + bits: *bits as u8, + signed: false, + }, + PrimitiveType::IInt { bits } => SlrCType::Int { + bits: *bits as u8, + signed: true, + }, + PrimitiveType::Usize => SlrCType::Int { + bits: 64, + signed: false, + }, // Assume 64-bit + PrimitiveType::Isize => SlrCType::Int { + bits: 64, + signed: true, + }, + PrimitiveType::F16 => SlrCType::Float { bits: 16 }, + PrimitiveType::F32 => SlrCType::Float { bits: 32 }, + PrimitiveType::F64 => SlrCType::Float { bits: 64 }, + PrimitiveType::F128 => SlrCType::Float { bits: 128 }, + PrimitiveType::A64 => SlrCType::Angle { bits: 64 }, // PECOS Angle64 + PrimitiveType::Bool => SlrCType::Int { + bits: 8, + signed: false, + }, + }, + // Named types (for fallback or custom types) + TypeExpr::Named(path) => { + let name = path.segments.join("::"); + match name.as_str() { + "u8" => SlrCType::Int { bits: 8, signed: false }, + "u16" => SlrCType::Int { bits: 16, signed: false }, + "u32" => SlrCType::Int { bits: 32, signed: false }, + "u64" => SlrCType::Int { bits: 64, signed: false }, + "usize" => SlrCType::Int { bits: 64, signed: false }, // Assume 64-bit + "i8" => SlrCType::Int { bits: 8, signed: true }, + "i16" => SlrCType::Int { bits: 16, signed: true }, + "i32" => SlrCType::Int { bits: 32, signed: true }, + "i64" => SlrCType::Int { bits: 64, signed: true }, + "isize" => SlrCType::Int { bits: 64, signed: true }, + "f32" => SlrCType::Float { bits: 32 }, + "f64" => SlrCType::Float { bits: 64 }, + "bool" => SlrCType::Int { bits: 8, signed: false }, + "unit" | "void" => SlrCType::Void, + _ => SlrCType::Opaque { name }, + } + } + // Pointer types: *T, [*]T, [*:0]T + TypeExpr::Pointer(ptr) => { + let element = self.convert_type_to_ctype(&ptr.pointee); + SlrCType::Pointer { + element: Box::new(element), + is_const: ptr.is_const, + } + } + // Array types used as pointers in C + TypeExpr::Array(arr) => { + let element = self.convert_type_to_ctype(&arr.element); + SlrCType::Pointer { + element: Box::new(element), + is_const: false, + } + } + // Unit type + TypeExpr::Unit => SlrCType::Void, + // Default to opaque for unknown types + _ => SlrCType::Opaque { + name: format!("{:?}", ty), + }, + } + } +} + +impl Default for SlrCodegen { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Angle Precision Helpers +// ============================================================================= + +/// Recognize common pi-based radian expressions and return exact turn fractions. +/// +/// This preserves precision by pattern-matching the AST before floating-point evaluation. +/// For example: +/// - `pi / 2` → 0.25 turns (exact) +/// - `pi / 4` → 0.125 turns (exact) +/// - `3 * pi / 4` → 0.375 turns (exact) +fn recognize_exact_radian_pattern(expr: &Expr) -> Option { + // Pattern: pi (just pi = 1/2 turn) + if is_pi_reference(expr) { + return Some(0.5); + } + + // Pattern: pi / N (pi divided by integer) + if let Expr::Binary(binary) = expr { + if binary.op == crate::ast::BinaryOp::Div + && is_pi_reference(&binary.left) + && let Some(n) = extract_integer_value(&binary.right) + && n > 0 { + // pi / n radians = 1 / (2*n) turns + return Some(1.0 / (2.0 * n as f64)); + } + + // Pattern: N * pi / M or (N * pi) / M + if binary.op == crate::ast::BinaryOp::Div + && let Some((num, denom)) = extract_pi_fraction(&binary.left, &binary.right) { + // (num * pi) / denom radians = num / (2 * denom) turns + return Some(num as f64 / (2.0 * denom as f64)); + } + + // Pattern: pi * N / M (reordered) + if binary.op == crate::ast::BinaryOp::Mul { + // Check for pi * (N / M) - less common but possible + if is_pi_reference(&binary.left) + && let Expr::Binary(inner) = &binary.right + && inner.op == crate::ast::BinaryOp::Div + && let (Some(num), Some(denom)) = ( + extract_integer_value(&inner.left), + extract_integer_value(&inner.right), + ) + && denom > 0 { + return Some(num as f64 / (2.0 * denom as f64)); + } + } + } + + None +} + +/// Check if an expression is a reference to pi (std.f64.pi, pi, PI, etc.) +fn is_pi_reference(expr: &Expr) -> bool { + match expr { + Expr::Ident(ident) => { + matches!(ident.name.as_str(), "pi" | "PI") + } + Expr::Field(field) => { + // Check for std.f64.pi or similar + field.field == "pi" || field.field == "PI" + } + _ => false, + } +} + +/// Extract an integer value from an expression (literal or simple expression) +fn extract_integer_value(expr: &Expr) -> Option { + match expr { + Expr::IntLit(lit) => Some(lit.value as i64), + // Handle negative integers + Expr::Unary(unary) if unary.op == crate::ast::UnaryOp::Neg => { + extract_integer_value(&unary.operand).map(|v| -v) + } + _ => None, + } +} + +/// Extract numerator and denominator from expressions like N * pi / M +fn extract_pi_fraction(left: &Expr, right: &Expr) -> Option<(i64, i64)> { + // Check if left is N * pi + if let Expr::Binary(mul) = left + && mul.op == crate::ast::BinaryOp::Mul { + // N * pi + if let Some(n) = extract_integer_value(&mul.left) + && is_pi_reference(&mul.right) + && let Some(m) = extract_integer_value(right) + && m > 0 { + return Some((n, m)); + } + // pi * N + if is_pi_reference(&mul.left) + && let Some(n) = extract_integer_value(&mul.right) + && let Some(m) = extract_integer_value(right) + && m > 0 { + return Some((n, m)); + } + } + None +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + fn compile_to_slr(source: &str) -> SlrResult { + let program = parse(source).expect("parse failed"); + let mut codegen = SlrCodegen::new(); + codegen.compile(&program) + } + + fn to_json(source: &str) -> String { + let program = parse(source).expect("parse failed"); + let mut codegen = SlrCodegen::new(); + let slr = codegen.compile(&program).expect("compile failed"); + codegen.to_json(&slr).expect("json failed") + } + + #[test] + fn test_empty_program() { + let slr = compile_to_slr("").unwrap(); + assert_eq!(slr.name, "main"); + assert!(slr.body.is_empty()); + } + + #[test] + fn test_single_qubit_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "H"); // Output remains uppercase + assert_eq!(gate.targets.len(), 1); + assert_eq!(gate.targets[0].allocator, "q"); + assert_eq!(gate.targets[0].index, 0); + } else { + panic!("Expected gate operation"); + } + } + + #[test] + fn test_bell_state() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 2); + + // First gate: h (output is uppercase H) + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "H"); + } else { + panic!("Expected H gate"); + } + + // Second gate: cx (output is uppercase CX) + if let SlrStatement::Gate(gate) = &slr.body[1] { + assert_eq!(gate.gate, "CX"); + assert_eq!(gate.targets.len(), 2); + } else { + panic!("Expected CX gate"); + } + } + + #[test] + fn test_rotation_gate() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + rz(1.57, q[0]); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "RZ"); + assert_eq!(gate.params.len(), 1); + } else { + panic!("Expected rz gate"); + } + } + + #[test] + fn test_angle_precision_preservation() { + // Test that pi/N rad patterns are converted to exact turn fractions + let source = r#" + pi: f64 = 3.14159265358979323846; + pub fn main() -> unit { + mut q := qalloc(2); + rz(pi/4 rad) q[0]; // Should be exactly 0.125 turns + rz(pi/2 rad) q[1]; // Should be exactly 0.25 turns + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 2); + + // Check first gate: pi/4 rad = 0.125 turns + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "RZ"); + if let SlrExpression::Literal(lit) = &gate.params[0] { + if let SlrLiteralValue::Angle(turns) = &lit.value { + assert!( + (*turns - 0.125).abs() < 1e-15, + "pi/4 rad should be exactly 0.125 turns, got {}", + turns + ); + } else { + panic!("Expected angle literal"); + } + } + } + + // Check second gate: pi/2 rad = 0.25 turns + if let SlrStatement::Gate(gate) = &slr.body[1] { + assert_eq!(gate.gate, "RZ"); + if let SlrExpression::Literal(lit) = &gate.params[0] { + if let SlrLiteralValue::Angle(turns) = &lit.value { + assert!( + (*turns - 0.25).abs() < 1e-15, + "pi/2 rad should be exactly 0.25 turns, got {}", + turns + ); + } else { + panic!("Expected angle literal"); + } + } + } + } + + #[test] + fn test_allocator_decl() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert!(slr.allocator.is_some()); + + let alloc = slr.allocator.unwrap(); + assert_eq!(alloc.name, "q"); + assert_eq!(alloc.capacity, 4); + } + + #[test] + fn test_child_allocator() { + let source = r#" + pub fn main() -> unit { + mut base := qalloc(4); + mut q := base.child(2); + h q[0]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + + // Should have both allocators in declarations + let alloc_count = slr + .declarations + .iter() + .filter(|d| matches!(d, SlrDeclaration::Allocator(_))) + .count(); + assert_eq!(alloc_count, 2); + } + + #[test] + fn test_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + } + "#; + + let json = to_json(source); + + // Verify it's valid JSON + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + // Check structure + assert_eq!(value["type"], "Program"); + assert_eq!(value["name"], "main"); + assert!(value["allocator"].is_object()); + assert!(value["body"].is_array()); + assert_eq!(value["body"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_if_statement() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + mut x := 1; + if (x == 1) { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + + // Find the if statement + let has_if = slr.body.iter().any(|s| matches!(s, SlrStatement::If(_))); + assert!(has_if, "Expected if statement in body"); + } + + #[test] + fn test_wrong_argument_count() { + // CX requires pairs of qubits - passing individual qubits in a batch is wrong + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + cx {q[0], q[1]}; + } + "#; + + let result = compile_to_slr(source); + assert!(matches!(result, Err(SlrError::WrongArgumentCount { .. }))); + } + + #[test] + fn test_qubit_out_of_bounds() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + h q[5]; + } + "#; + + let result = compile_to_slr(source); + assert!(matches!(result, Err(SlrError::QubitIndexOutOfBounds { .. }))); + } + + #[test] + fn test_tick_block() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick { + h q[0]; + h q[1]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert!(tick.label.is_none()); + assert_eq!(tick.body.len(), 2); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_block_with_label() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick syndrome_round { + h q[0]; + cx (q[0], q[1]); + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert_eq!(tick.label.as_ref().unwrap(), "syndrome_round"); + assert_eq!(tick.body.len(), 2); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_block_with_string_label() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick "layer_1" { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert_eq!(tick.label.as_ref().unwrap(), "layer_1"); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_nested_tick_blocks() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + tick outer { + tick inner1 { + h q[0]; + } + tick inner2 { + x(q[1]); + } + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(outer) = &slr.body[0] { + assert_eq!(outer.label.as_ref().unwrap(), "outer"); + assert_eq!(outer.body.len(), 2); + + // Check nested ticks + if let SlrStatement::Tick(inner1) = &outer.body[0] { + assert_eq!(inner1.label.as_ref().unwrap(), "inner1"); + } else { + panic!("Expected nested tick"); + } + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick layer1 { + h q[0]; + h q[1]; + } + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + // Check tick structure in JSON + let tick = &value["body"][0]; + assert_eq!(tick["type"], "TickStmt"); + assert_eq!(tick["label"], "layer1"); + assert!(tick["body"].is_array()); + assert_eq!(tick["body"].as_array().unwrap().len(), 2); + } + + #[test] + fn test_tick_with_inline_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick @attrs({round: 0, kind: "syndrome"}) syndrome_round { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert_eq!(tick.label.as_ref().unwrap(), "syndrome_round"); + assert_eq!(tick.attrs.len(), 2); + assert!(matches!(tick.attrs.get("round"), Some(SlrAttributeValue::Int(0)))); + assert!(matches!(tick.attrs.get("kind"), Some(SlrAttributeValue::String(s)) if s == "syndrome")); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_with_prefix_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attr(noisy, true) + tick { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert!(tick.label.is_none()); + assert_eq!(tick.attrs.len(), 1); + assert!(matches!(tick.attrs.get("noisy"), Some(SlrAttributeValue::Bool(true)))); + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_with_mixed_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attrs({error_rate: 0.001, round: 5}) + tick layer { + h q[0]; + } + } + "#; + + let slr = compile_to_slr(source).unwrap(); + + if let SlrStatement::Tick(tick) = &slr.body[0] { + assert_eq!(tick.label.as_ref().unwrap(), "layer"); + assert_eq!(tick.attrs.len(), 2); + assert!(matches!(tick.attrs.get("round"), Some(SlrAttributeValue::Int(5)))); + // Check float attribute + if let Some(SlrAttributeValue::Float(f)) = tick.attrs.get("error_rate") { + assert!((*f - 0.001).abs() < 0.0001); + } else { + panic!("Expected float attribute"); + } + } else { + panic!("Expected tick statement"); + } + } + + #[test] + fn test_tick_attributes_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + tick @attrs({round: 0, kind: "syndrome"}) syndrome { + h q[0]; + } + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let tick = &value["body"][0]; + assert_eq!(tick["type"], "TickStmt"); + assert_eq!(tick["label"], "syndrome"); + assert_eq!(tick["attrs"]["round"], 0); + assert_eq!(tick["attrs"]["kind"], "syndrome"); + } + + // ========================================================================= + // Gate Attribute Tests + // ========================================================================= + + #[test] + fn test_gate_with_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attr(syndrome, "X") + cx (q[0], q[1]); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "CX"); + assert_eq!(gate.attrs.len(), 1); + assert!(matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "X")); + } else { + panic!("Expected gate statement"); + } + } + + #[test] + fn test_gate_with_multiple_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attrs({syndrome: "Z", layer: 1}) + h q[0]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + + if let SlrStatement::Gate(gate) = &slr.body[0] { + assert_eq!(gate.gate, "H"); + assert_eq!(gate.attrs.len(), 2); + assert!(matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "Z")); + assert!(matches!(gate.attrs.get("layer"), Some(SlrAttributeValue::Int(1)))); + } else { + panic!("Expected gate statement"); + } + } + + #[test] + fn test_batch_gate_with_attributes() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + @attr(syndrome, "X") + h([q[0], q[1]]); + } + "#; + + let slr = compile_to_slr(source).unwrap(); + // Batch gate expands to 2 gates, each should have the attribute + assert_eq!(slr.body.len(), 2); + + for stmt in &slr.body { + if let SlrStatement::Gate(gate) = stmt { + assert_eq!(gate.gate, "H"); + assert_eq!(gate.attrs.len(), 1); + assert!(matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "X")); + } else { + panic!("Expected gate statement"); + } + } + } + + #[test] + fn test_gate_attributes_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + @attrs({syndrome: "X", ancilla: true}) + cx (q[0], q[1]); + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let gate = &value["body"][0]; + assert_eq!(gate["type"], "GateOp"); + assert_eq!(gate["gate"], "CX"); + assert_eq!(gate["attrs"]["syndrome"], "X"); + assert_eq!(gate["attrs"]["ancilla"], true); + } + + #[test] + fn test_gate_without_attributes_no_attrs_field() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + h q[0]; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let gate = &value["body"][0]; + assert_eq!(gate["type"], "GateOp"); + // attrs field should be absent (skip_serializing_if = is_empty) + assert!(gate.get("attrs").is_none()); + } + + // ========================================================================= + // Typed Measurement Tests + // ========================================================================= + + #[test] + fn test_typed_measurement_single_qubit() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + r := mz(u1) q[0]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Measure(measure) = &slr.body[0] { + assert_eq!(measure.targets.len(), 1); + assert_eq!(measure.targets[0].allocator, "q"); + assert_eq!(measure.targets[0].index, 0); + assert_eq!(measure.results.len(), 1); + } else { + panic!("Expected measure operation"); + } + } + + #[test] + fn test_typed_measurement_array() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + results := mz(u1) [q[0], q[1], q[2]]; + } + "#; + + let slr = compile_to_slr(source).unwrap(); + assert_eq!(slr.body.len(), 1); + + if let SlrStatement::Measure(measure) = &slr.body[0] { + assert_eq!(measure.targets.len(), 3); + assert_eq!(measure.targets[0].allocator, "q"); + assert_eq!(measure.targets[0].index, 0); + assert_eq!(measure.targets[1].index, 1); + assert_eq!(measure.targets[2].index, 2); + assert_eq!(measure.results.len(), 3); + } else { + panic!("Expected measure operation"); + } + } + + #[test] + fn test_typed_measurement_json_output() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + r := mz(u1) q[0]; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let measure = &value["body"][0]; + assert_eq!(measure["type"], "MeasureOp"); + assert!(measure["targets"].is_array()); + assert_eq!(measure["targets"].as_array().unwrap().len(), 1); + assert!(measure["results"].is_array()); + assert_eq!(measure["results"].as_array().unwrap().len(), 1); + assert_eq!(measure["result_type"], "u1"); + } + + #[test] + fn test_measurement_result_type_u8() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + r := mz(u8) q[0]; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let measure = &value["body"][0]; + assert_eq!(measure["type"], "MeasureOp"); + assert_eq!(measure["result_type"], "u8"); + } + + #[test] + fn test_measurement_result_type_u64() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + r := mz(u64) q[0]; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let measure = &value["body"][0]; + assert_eq!(measure["type"], "MeasureOp"); + assert_eq!(measure["result_type"], "u64"); + } + + #[test] + fn test_extern_fn_declaration() { + let source = r#" + extern "C" fn decode(data: [*]u8, len: usize) -> i32; + + pub fn main() -> unit { + q := qalloc(1); + pz q; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + // Check that externs array exists and has one entry + let externs = &value["externs"]; + assert!(externs.is_array(), "externs should be an array"); + assert_eq!(externs.as_array().unwrap().len(), 1); + + // Check extern function properties + let extern_fn = &externs[0]; + assert_eq!(extern_fn["type"], "ExternDecl"); + assert_eq!(extern_fn["name"], "decode"); + assert_eq!(extern_fn["calling_convention"], "C"); + + // Check params + let params = &extern_fn["params"]; + assert_eq!(params.as_array().unwrap().len(), 2); + assert_eq!(params[0]["name"], "data"); + assert_eq!(params[1]["name"], "len"); + } + + #[test] + fn test_extern_fn_with_library() { + let source = r#" + @link("libdecoder") + extern "C" fn mwpm_decode(syndrome: [*]u8, n: u32) -> i32; + + pub fn main() -> unit { + q := qalloc(1); + pz q; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let extern_fn = &value["externs"][0]; + assert_eq!(extern_fn["name"], "mwpm_decode"); + assert_eq!(extern_fn["library"], "libdecoder"); + assert_eq!(extern_fn["calling_convention"], "C"); + + // Check params + let first_param = &extern_fn["params"][0]; + assert_eq!(first_param["name"], "syndrome"); + assert_eq!(first_param["ctype"]["kind"], "pointer"); + assert_eq!(first_param["ctype"]["element"]["kind"], "int"); + assert_eq!(first_param["ctype"]["element"]["bits"], 8); + } + + #[test] + fn test_extern_fn_rust_abi() { + let source = r#" + extern "Rust" fn pecos_simulate(circuit: *const u8) -> u64; + + pub fn main() -> unit { + q := qalloc(1); + pz q; + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + let extern_fn = &value["externs"][0]; + assert_eq!(extern_fn["name"], "pecos_simulate"); + assert_eq!(extern_fn["calling_convention"], "Rust"); + } + + #[test] + fn test_extern_fn_call() { + let source = r#" + extern "C" fn simple_decode(value: u32) -> i32; + + pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + + // Call external decoder with a simple literal + simple_decode(42); + } + "#; + + let json = to_json(source); + let value: serde_json::Value = serde_json::from_str(&json).expect("invalid JSON"); + + // Check that extern declaration is present + assert_eq!(value["externs"][0]["name"], "simple_decode"); + + // Check that extern call is in the body + let body = &value["body"]; + let extern_call = body + .as_array() + .unwrap() + .iter() + .find(|stmt| stmt["type"] == "ExternCall"); + assert!(extern_call.is_some(), "Expected ExternCall in body"); + + let call = extern_call.unwrap(); + assert_eq!(call["function"], "simple_decode"); + assert_eq!(call["args"].as_array().unwrap().len(), 1); + // The argument should be a literal value + assert_eq!(call["args"][0]["value"], 42); + } +} diff --git a/exp/zlup/src/comptime.rs b/exp/zlup/src/comptime.rs new file mode 100644 index 000000000..34cea802d --- /dev/null +++ b/exp/zlup/src/comptime.rs @@ -0,0 +1,3400 @@ +//! Compile-time evaluation for Zlup. +//! +//! This module provides compile-time evaluation of expressions, enabling: +//! - `comptime` blocks that execute at compile time +//! - `comptime` function parameters (generics) +//! - Compile-time type manipulation +//! - Constant folding and propagation +//! +//! ## Comptime Values +//! +//! Values that can exist at compile time: +//! - Integers (i64 for signed, u64 for unsigned) +//! - Floats (f64) +//! - Booleans +//! - Types (the `type` type) +//! - Arrays of comptime values +//! - Structs with comptime fields +//! +//! ## Example +//! +//! ```zlup +//! // Comptime block +//! N := comptime { +//! mut sum: u32 = 0; +//! for i in 0..10 { +//! sum += i; +//! } +//! sum +//! }; +//! +//! // Comptime function parameter (generic) +//! fn makeArray(comptime T: type, comptime N: usize) -> [N]T { +//! mut arr: [N]T = undefined; +//! return arr; +//! } +//! ``` + +use std::collections::BTreeMap; +use std::fmt; + +use crate::ast::{BinaryOp, Expr, FnDecl, ForRange, FStringPart, PrimitiveType, Stmt, TypeExpr, UnaryOp}; +use crate::rational::Rational; +use crate::semantic::{BitWidth, SemanticError, Type}; + +// ============================================================================= +// Type Information (for @typeInfo, @fieldNames, @enumFields builtins) +// ============================================================================= + +/// The kind of a type, returned by @typeInfo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TypeInfoKind { + /// Primitive types (bool, integers, floats) + Primitive, + /// Array type with fixed size + Array, + /// Slice type (dynamic size) + Slice, + /// Pointer type + Pointer, + /// Optional type (?T) + Optional, + /// Error union type (E!T) + ErrorUnion, + /// Struct type + Struct, + /// Enum type + Enum, + /// Union type + Union, + /// Error set type + ErrorSet, + /// Fault set type + FaultSet, + /// Function type + Function, + /// Tuple type + Tuple, + /// The type type (metatype) + Type, + /// Unit type + Unit, + /// Never type (bottom) + Never, + /// Quantum types (Qubit, Bit, Allocator) + Quantum, + /// Unknown/unresolved type + Unknown, +} + +impl TypeInfoKind { + /// Convert to a string representation for use in comptime values. + pub fn as_str(&self) -> &'static str { + match self { + TypeInfoKind::Primitive => "primitive", + TypeInfoKind::Array => "array", + TypeInfoKind::Slice => "slice", + TypeInfoKind::Pointer => "pointer", + TypeInfoKind::Optional => "optional", + TypeInfoKind::ErrorUnion => "error_union", + TypeInfoKind::Struct => "struct", + TypeInfoKind::Enum => "enum", + TypeInfoKind::Union => "union", + TypeInfoKind::ErrorSet => "error_set", + TypeInfoKind::FaultSet => "fault_set", + TypeInfoKind::Function => "function", + TypeInfoKind::Tuple => "tuple", + TypeInfoKind::Type => "type", + TypeInfoKind::Unit => "unit", + TypeInfoKind::Never => "never", + TypeInfoKind::Quantum => "quantum", + TypeInfoKind::Unknown => "unknown", + } + } +} + +/// A value known at compile time. +#[derive(Debug, Clone)] +pub enum ComptimeValue { + /// Signed integer value + Int(i64), + /// Unsigned integer value + Uint(u64), + /// Floating point value + Float(f64), + /// Rational number (exact fraction like 1/4) + Rational(Rational), + /// Boolean value + Bool(bool), + /// A type value (for `type` type) + Type(Type), + /// Null value (for optionals) + Null, + /// Undefined value + Undefined, + /// Unit value (the single value of the unit type) + Unit, + /// Array of comptime values + Array(Vec), + /// Struct with named fields + Struct { + name: String, + fields: BTreeMap, + }, + /// Slice reference (ptr + len) + Slice { + data: Vec, + }, + /// String literal + String(String), + /// A comptime function (for generic type constructors) + Function(Box), +} + +impl PartialEq for ComptimeValue { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => a == b, + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => a == b, + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => a == b, + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => a == b, + (ComptimeValue::Bool(a), ComptimeValue::Bool(b)) => a == b, + (ComptimeValue::Type(a), ComptimeValue::Type(b)) => a == b, + (ComptimeValue::Null, ComptimeValue::Null) => true, + (ComptimeValue::Undefined, ComptimeValue::Undefined) => true, + (ComptimeValue::Unit, ComptimeValue::Unit) => true, + (ComptimeValue::Array(a), ComptimeValue::Array(b)) => a == b, + (ComptimeValue::Struct { name: n1, fields: f1 }, ComptimeValue::Struct { name: n2, fields: f2 }) => { + n1 == n2 && f1 == f2 + } + (ComptimeValue::Slice { data: d1 }, ComptimeValue::Slice { data: d2 }) => d1 == d2, + (ComptimeValue::String(a), ComptimeValue::String(b)) => a == b, + (ComptimeValue::Function(_), ComptimeValue::Function(_)) => false, // Functions not comparable + _ => false, + } + } +} + +impl fmt::Display for ComptimeValue { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ComptimeValue::Int(v) => write!(f, "{}", v), + ComptimeValue::Uint(v) => write!(f, "{}", v), + ComptimeValue::Float(v) => write!(f, "{}", v), + ComptimeValue::Rational(v) => write!(f, "{}", v), + ComptimeValue::Bool(v) => write!(f, "{}", v), + ComptimeValue::Type(t) => write!(f, "{}", t.display_name()), + ComptimeValue::Null => write!(f, "null"), + ComptimeValue::Undefined => write!(f, "undefined"), + ComptimeValue::Unit => write!(f, "unit"), + ComptimeValue::Array(arr) => { + write!(f, "[")?; + for (i, v) in arr.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", v)?; + } + write!(f, "]") + } + ComptimeValue::Struct { name, fields } => { + write!(f, "{} {{ ", name)?; + for (i, (k, v)) in fields.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, ".{} = {}", k, v)?; + } + write!(f, " }}") + } + ComptimeValue::Slice { data } => { + write!(f, "&[")?; + for (i, v) in data.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", v)?; + } + write!(f, "]") + } + ComptimeValue::String(s) => write!(f, "\"{}\"", s), + ComptimeValue::Function(func) => write!(f, "", func.name), + } + } +} + +impl ComptimeValue { + /// Get the type of this comptime value. + pub fn get_type(&self) -> Type { + match self { + ComptimeValue::Int(_) => Type::IInt { bits: BitWidth::BITS_64 }, + ComptimeValue::Uint(_) => Type::UInt { bits: BitWidth::BITS_64 }, + ComptimeValue::Float(_) => Type::F64, + ComptimeValue::Rational(_) => Type::F64, // Rationals coerce to f64 when needed + ComptimeValue::Bool(_) => Type::Bool, + ComptimeValue::Type(_) => Type::Type, + ComptimeValue::Null => Type::Optional { + inner: Box::new(Type::Unknown), + }, + ComptimeValue::Undefined => Type::Unknown, + ComptimeValue::Unit => Type::Unit, + ComptimeValue::Array(arr) => { + let elem_ty = arr.first().map(|v| v.get_type()).unwrap_or(Type::Unknown); + Type::Array { + element: Box::new(elem_ty), + size: Some(arr.len() as u64), + } + } + ComptimeValue::Struct { name, fields } => { + let field_types: Vec<(String, Type)> = fields + .iter() + .map(|(k, v)| (k.clone(), v.get_type())) + .collect(); + Type::Struct { + name: name.clone(), + fields: field_types, + } + } + ComptimeValue::Slice { data } => { + let elem_ty = data.first().map(|v| v.get_type()).unwrap_or(Type::Unknown); + Type::Slice { + element: Box::new(elem_ty), + } + } + ComptimeValue::String(_) => Type::Slice { + element: Box::new(Type::UInt { bits: BitWidth::BITS_8 }), + }, + ComptimeValue::Function(_) => Type::Type, // Comptime functions return types + } + } + + /// Try to convert to i64. + pub fn as_int(&self) -> Option { + match self { + ComptimeValue::Int(v) => Some(*v), + ComptimeValue::Uint(v) => Some(*v as i64), + _ => None, + } + } + + /// Try to convert to u64. + pub fn as_uint(&self) -> Option { + match self { + ComptimeValue::Int(v) => Some(*v as u64), + ComptimeValue::Uint(v) => Some(*v), + _ => None, + } + } + + /// Try to convert to f64. + pub fn as_float(&self) -> Option { + match self { + ComptimeValue::Float(v) => Some(*v), + ComptimeValue::Int(v) => Some(*v as f64), + ComptimeValue::Uint(v) => Some(*v as f64), + ComptimeValue::Rational(r) => Some(r.to_f64()), + _ => None, + } + } + + /// Try to get as Rational. + pub fn as_rational(&self) -> Option { + match self { + ComptimeValue::Rational(r) => Some(*r), + ComptimeValue::Int(v) => Some(Rational::from_int(*v)), + ComptimeValue::Uint(v) => Some(Rational::from_int(*v as i64)), + ComptimeValue::Float(v) => Rational::from_f64_common(*v), + _ => None, + } + } + + /// Try to convert to bool. + pub fn as_bool(&self) -> Option { + match self { + ComptimeValue::Bool(v) => Some(*v), + _ => None, + } + } + + /// Try to convert to usize. + pub fn to_usize(&self) -> Option { + match self { + ComptimeValue::Int(v) => (*v).try_into().ok(), + ComptimeValue::Uint(v) => (*v).try_into().ok(), + _ => None, + } + } + + /// Try to get as type. + pub fn as_type(&self) -> Option<&Type> { + match self { + ComptimeValue::Type(t) => Some(t), + _ => None, + } + } + + /// Check if this is a truthy value. + pub fn is_truthy(&self) -> bool { + match self { + ComptimeValue::Bool(v) => *v, + ComptimeValue::Int(v) => *v != 0, + ComptimeValue::Uint(v) => *v != 0, + ComptimeValue::Rational(r) => !r.is_zero(), + ComptimeValue::Null => false, + ComptimeValue::Undefined => false, + _ => true, + } + } +} + +/// Result of comptime evaluation. +pub type ComptimeResult = Result; + +/// Errors during comptime evaluation. +#[derive(Debug, Clone)] +pub struct ComptimeError { + pub message: String, +} + +impl fmt::Display for ComptimeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for ComptimeError {} + +impl From for SemanticError { + fn from(err: ComptimeError) -> Self { + SemanticError::ComptimeError { + message: err.message, + location: crate::ast::SourceLocation::default(), + } + } +} + +/// Comptime evaluation context. +/// +/// Tracks variables and their values during comptime evaluation. +#[derive(Debug, Clone, Default)] +pub struct ComptimeContext { + /// Variable bindings in the current scope. + scopes: Vec>, +} + +impl ComptimeContext { + /// Create a new comptime context. + pub fn new() -> Self { + Self { + scopes: vec![BTreeMap::new()], + } + } + + /// Push a new scope. + pub fn push_scope(&mut self) { + self.scopes.push(BTreeMap::new()); + } + + /// Pop the current scope. + pub fn pop_scope(&mut self) { + if self.scopes.len() > 1 { + self.scopes.pop(); + } + } + + /// Define a variable in the current scope. + pub fn define(&mut self, name: &str, value: ComptimeValue) { + if let Some(scope) = self.scopes.last_mut() { + scope.insert(name.to_string(), value); + } + } + + /// Look up a variable. + pub fn lookup(&self, name: &str) -> Option<&ComptimeValue> { + for scope in self.scopes.iter().rev() { + if let Some(value) = scope.get(name) { + return Some(value); + } + } + None + } + + /// Update a variable's value. + pub fn update(&mut self, name: &str, value: ComptimeValue) -> bool { + for scope in self.scopes.iter_mut().rev() { + if scope.contains_key(name) { + scope.insert(name.to_string(), value); + return true; + } + } + false + } +} + +/// Comptime evaluator. +/// +/// Maximum recursion depth for comptime evaluation to prevent stack overflow. +const MAX_COMPTIME_DEPTH: usize = 256; + +/// Evaluates expressions at compile time, producing `ComptimeValue` results. +#[derive(Debug)] +pub struct ComptimeEvaluator { + /// Evaluation context. + pub context: ComptimeContext, + /// Current recursion depth for detecting infinite recursion. + depth: usize, + /// Memoization cache for comptime function calls. + /// Key: (function_name, serialized_args), Value: cached result + memo_cache: BTreeMap<(String, String), ComptimeValue>, +} + +impl Default for ComptimeEvaluator { + fn default() -> Self { + Self::new() + } +} + +impl ComptimeEvaluator { + /// Create a new comptime evaluator. + pub fn new() -> Self { + Self { + context: ComptimeContext::new(), + depth: 0, + memo_cache: BTreeMap::new(), + } + } + + /// Serialize comptime values to a string key for memoization cache. + fn serialize_args_for_cache(args: &[ComptimeValue]) -> String { + args.iter() + .map(|v| match v { + ComptimeValue::Int(n) => format!("i{}", n), + ComptimeValue::Uint(n) => format!("u{}", n), + ComptimeValue::Float(f) => format!("f{}", f), + ComptimeValue::Rational(r) => format!("r{}/{}", r.numerator(), r.denominator()), + ComptimeValue::Bool(b) => format!("b{}", b), + ComptimeValue::String(s) => format!("s{}", s), + ComptimeValue::Type(t) => format!("t{}", Self::serialize_type_for_cache(t)), + ComptimeValue::Array(arr) => { + format!("a[{}]", Self::serialize_args_for_cache(arr)) + } + ComptimeValue::Slice { data } => { + format!("sl[{}]", Self::serialize_args_for_cache(data)) + } + ComptimeValue::Struct { name, fields } => { + let field_strs: Vec<_> = fields + .iter() + .map(|(k, v)| format!("{}:{}", k, Self::serialize_args_for_cache(&[v.clone()]))) + .collect(); + format!("st{}[{}]", name, field_strs.join(";")) + } + ComptimeValue::Function(f) => format!("fn{}", f.name), + ComptimeValue::Null => "null".to_string(), + ComptimeValue::Undefined => "undef".to_string(), + ComptimeValue::Unit => "unit".to_string(), + }) + .collect::>() + .join(",") + } + + /// Serialize a Type to a unique string for cache keys. + /// Unlike display_name(), this includes full structural information for anonymous types. + fn serialize_type_for_cache(ty: &Type) -> String { + match ty { + Type::Struct { name, fields } => { + // For structs (especially anonymous ones), include full field information + let field_strs: Vec<_> = fields + .iter() + .map(|(field_name, field_ty)| { + format!("{}:{}", field_name, Self::serialize_type_for_cache(field_ty)) + }) + .collect(); + format!("struct{}[{}]", name, field_strs.join(";")) + } + Type::Array { element, size } => { + format!("[{}]{}", size.unwrap_or(0), Self::serialize_type_for_cache(element)) + } + Type::Slice { element } => { + format!("[]{}", Self::serialize_type_for_cache(element)) + } + Type::Pointer { pointee, is_const, .. } => { + let prefix = if *is_const { "*const" } else { "*" }; + format!("{}{}", prefix, Self::serialize_type_for_cache(pointee)) + } + Type::Optional { inner } => { + format!("?{}", Self::serialize_type_for_cache(inner)) + } + Type::Tuple { elements } => { + let elem_strs: Vec<_> = elements + .iter() + .map(|e| Self::serialize_type_for_cache(e)) + .collect(); + format!("({})", elem_strs.join(",")) + } + // For other types, display_name() is sufficient + _ => ty.display_name(), + } + } + + /// Check and increment recursion depth, returning error if too deep. + fn enter_eval(&mut self) -> ComptimeResult<()> { + self.depth += 1; + if self.depth > MAX_COMPTIME_DEPTH { + Err(ComptimeError { + message: format!( + "comptime evaluation exceeded maximum recursion depth of {}", + MAX_COMPTIME_DEPTH + ), + }) + } else { + Ok(()) + } + } + + /// Decrement recursion depth when exiting evaluation. + fn exit_eval(&mut self) { + self.depth = self.depth.saturating_sub(1); + } + + /// Validate that a float result is finite (not Inf or NaN). + /// Returns an error if the value is not a valid finite number. + fn validate_float(value: f64) -> ComptimeResult { + if value.is_finite() { + Ok(ComptimeValue::Float(value)) + } else if value.is_nan() { + Err(ComptimeError { + message: "floating-point operation resulted in NaN".to_string(), + }) + } else { + Err(ComptimeError { + message: "floating-point operation resulted in infinity".to_string(), + }) + } + } + + /// Resolve a built-in type name to a Type. + /// Returns None if the name is not a built-in type. + fn resolve_builtin_type(&self, name: &str) -> Option { + // Special cases first + match name { + "bool" => return Some(Type::Bool), + "usize" => return Some(Type::Usize), + "isize" => return Some(Type::Isize), + "f16" => return Some(Type::F16), + "f32" => return Some(Type::F32), + "f64" => return Some(Type::F64), + "f128" => return Some(Type::F128), + "a64" => return Some(Type::A64), + "type" => return Some(Type::Type), + "unit" => return Some(Type::Unit), + _ => {} + } + + // Arbitrary-width integers: u or i + // Valid bit widths are 1-128 + if let Some(bits_str) = name.strip_prefix('u') { + if let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) { + return Some(Type::UInt { bits: bw }); + } + } else if let Some(bits_str) = name.strip_prefix('i') + && let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) { + return Some(Type::IInt { bits: bw }); + } + + None + } + + /// Resolve a TypeExpr to a Type at comptime. + fn resolve_type_expr(&mut self, type_expr: &TypeExpr) -> ComptimeResult { + match type_expr { + TypeExpr::Primitive(prim) => Ok(match prim { + PrimitiveType::Bool => Type::Bool, + PrimitiveType::UInt { bits } => Type::UInt { + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64) + }, + PrimitiveType::IInt { bits } => Type::IInt { + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64) + }, + PrimitiveType::Usize => Type::Usize, + PrimitiveType::Isize => Type::Isize, + PrimitiveType::F16 => Type::F16, + PrimitiveType::F32 => Type::F32, + PrimitiveType::F64 => Type::F64, + PrimitiveType::F128 => Type::F128, + PrimitiveType::A64 => Type::A64, + }), + TypeExpr::Qubit => Ok(Type::Qubit), + TypeExpr::Bit => Ok(Type::Bit), + TypeExpr::QAlloc(_) => Ok(Type::Allocator { capacity: None }), + TypeExpr::Array(array) => { + let element = self.resolve_type_expr(&array.element)?; + let size = if let Some(size_expr) = &array.size { + self.eval_expr(size_expr)?.to_usize().map(|n| n as u64) + } else { + None + }; + Ok(Type::Array { + element: Box::new(element), + size, + }) + } + TypeExpr::Pointer(ptr) => { + let pointee = self.resolve_type_expr(&ptr.pointee)?; + Ok(Type::Pointer { + pointee: Box::new(pointee), + is_const: ptr.is_const, + is_many: ptr.is_many, + }) + } + TypeExpr::Optional(inner) => { + let inner_ty = self.resolve_type_expr(inner)?; + Ok(Type::Optional { + inner: Box::new(inner_ty), + }) + } + TypeExpr::Named(path) => { + // Try to resolve as built-in type first + let full_name = path.segments.join("::"); + if let Some(ty) = self.resolve_builtin_type(&full_name) { + return Ok(ty); + } + // Try to look up in context (for comptime-defined types) + if let Some(ComptimeValue::Type(ty)) = self.context.lookup(&full_name) { + return Ok(ty.clone()); + } + // For single-segment names, try direct lookup + if path.segments.len() == 1 + && let Some(ComptimeValue::Type(ty)) = self.context.lookup(&path.segments[0]) { + return Ok(ty.clone()); + } + // Unresolved named type - return Unknown (will be resolved by semantic analyzer) + Ok(Type::Unknown) + } + TypeExpr::Type => Ok(Type::Type), + TypeExpr::Unit => Ok(Type::Unit), + TypeExpr::AnyType => Ok(Type::Unknown), // anytype resolves to unknown at comptime + TypeExpr::Tuple(types) => { + let mut resolved = Vec::new(); + for ty in types { + resolved.push(self.resolve_type_expr(ty)?); + } + Ok(Type::Tuple { elements: resolved }) + } + TypeExpr::Set(inner) => { + let inner_ty = self.resolve_type_expr(inner)?; + Ok(Type::Set { + element: Box::new(inner_ty), + }) + } + _ => Err(ComptimeError { + message: format!("type expression not yet supported at comptime: {:?}", type_expr), + }), + } + } + + /// Evaluate a binary operation on comptime values. + pub fn eval_binary_op( + &self, + op: BinaryOp, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { + match op { + // Arithmetic operations + BinaryOp::Add => self.eval_add(left, right), + BinaryOp::Sub => self.eval_sub(left, right), + BinaryOp::Mul => self.eval_mul(left, right), + BinaryOp::Div => self.eval_div(left, right), + BinaryOp::Mod => self.eval_mod(left, right), + + // Comparison operations + BinaryOp::Eq => self.eval_eq(left, right), + BinaryOp::Ne => self.eval_ne(left, right), + BinaryOp::Lt => self.eval_lt(left, right), + BinaryOp::Le => self.eval_le(left, right), + BinaryOp::Gt => self.eval_gt(left, right), + BinaryOp::Ge => self.eval_ge(left, right), + + // Logical operations + BinaryOp::And => self.eval_and(left, right), + BinaryOp::Or => self.eval_or(left, right), + + // Bitwise operations + BinaryOp::BitAnd => self.eval_bit_and(left, right), + BinaryOp::BitOr => self.eval_bit_or(left, right), + BinaryOp::BitXor => self.eval_bit_xor(left, right), + BinaryOp::Shl => self.eval_shl(left, right), + BinaryOp::Shr => self.eval_shr(left, right), + + // Optional operations + BinaryOp::Orelse => self.eval_orelse(left, right), + + // Set membership (not evaluable at comptime) + BinaryOp::In | BinaryOp::NotIn => Err(ComptimeError { + message: "set membership not supported at comptime".to_string(), + }), + + // Error handling (not evaluable at comptime) + BinaryOp::Catch => Err(ComptimeError { + message: "catch expression not supported at comptime".to_string(), + }), + } + } + + /// Evaluate a unary operation. + pub fn eval_unary_op( + &self, + op: UnaryOp, + operand: &ComptimeValue, + ) -> ComptimeResult { + match op { + UnaryOp::Neg => match operand { + ComptimeValue::Int(v) => Ok(ComptimeValue::Int(-v)), + ComptimeValue::Float(v) => Ok(ComptimeValue::Float(-v)), + ComptimeValue::Rational(r) => Ok(ComptimeValue::Rational(-*r)), + _ => Err(ComptimeError { + message: format!("cannot negate {}", operand), + }), + }, + UnaryOp::Not => match operand { + ComptimeValue::Bool(v) => Ok(ComptimeValue::Bool(!v)), + _ => Err(ComptimeError { + message: format!("cannot apply ! to {}", operand), + }), + }, + UnaryOp::BitNot => match operand { + ComptimeValue::Int(v) => Ok(ComptimeValue::Int(!v)), + ComptimeValue::Uint(v) => Ok(ComptimeValue::Uint(!v)), + _ => Err(ComptimeError { + message: format!("cannot apply ~ to {}", operand), + }), + }, + UnaryOp::Deref => Err(ComptimeError { + message: "cannot dereference at comptime".to_string(), + }), + UnaryOp::AddrOf => Err(ComptimeError { + message: "cannot take address at comptime".to_string(), + }), + UnaryOp::OptionalUnwrap => match operand { + ComptimeValue::Null => Err(ComptimeError { + message: "unwrapped null value with .?".to_string(), + }), + other => Ok(other.clone()), + }, + UnaryOp::ErrorUnwrap => match operand { + ComptimeValue::Null => Err(ComptimeError { + message: "unwrapped error value with .!".to_string(), + }), + other => Ok(other.clone()), + }, + UnaryOp::Try => Err(ComptimeError { + message: "try expression not supported at comptime".to_string(), + }), + } + } + + // Arithmetic operations + + fn eval_add(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { + a.checked_add(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in addition".to_string(), + }) + } + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { + a.checked_add(*b).map(ComptimeValue::Uint).ok_or_else(|| ComptimeError { + message: "unsigned integer overflow in addition".to_string(), + }) + } + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => { + a.checked_add(*b as i64).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in addition".to_string(), + }) + } + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => { + (*a as i64).checked_add(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in addition".to_string(), + }) + } + // Rational arithmetic + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(*a + *b)), + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Rational(*a + Rational::from_int(*b))), + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a) + *b)), + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Rational(*a + Rational::from_int(*b as i64))), + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) + *b)), + // Float arithmetic - validate results are finite + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a + b), + (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a + *b as f64), + (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 + b), + // Rational with float promotes to float + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => Self::validate_float(a.to_f64() + b), + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => Self::validate_float(a + b.to_f64()), + _ => Err(ComptimeError { + message: format!("cannot add {} and {}", left, right), + }), + } + } + + fn eval_sub(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { + a.checked_sub(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in subtraction".to_string(), + }) + } + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { + a.checked_sub(*b).map(ComptimeValue::Uint).ok_or_else(|| ComptimeError { + message: "unsigned integer underflow in subtraction".to_string(), + }) + } + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => { + a.checked_sub(*b as i64).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in subtraction".to_string(), + }) + } + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => { + (*a as i64).checked_sub(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in subtraction".to_string(), + }) + } + // Rational arithmetic + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(*a - *b)), + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Rational(*a - Rational::from_int(*b))), + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a) - *b)), + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Rational(*a - Rational::from_int(*b as i64))), + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) - *b)), + // Float arithmetic - validate results are finite + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a - b), + (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a - *b as f64), + (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 - b), + // Rational with float promotes to float + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => Self::validate_float(a.to_f64() - b), + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => Self::validate_float(a - b.to_f64()), + _ => Err(ComptimeError { + message: format!("cannot subtract {} and {}", left, right), + }), + } + } + + fn eval_mul(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { + a.checked_mul(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in multiplication".to_string(), + }) + } + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { + a.checked_mul(*b).map(ComptimeValue::Uint).ok_or_else(|| ComptimeError { + message: "unsigned integer overflow in multiplication".to_string(), + }) + } + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => { + a.checked_mul(*b as i64).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in multiplication".to_string(), + }) + } + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => { + (*a as i64).checked_mul(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + message: "integer overflow in multiplication".to_string(), + }) + } + // Rational arithmetic + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(*a * *b)), + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Rational(*a * Rational::from_int(*b))), + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a) * *b)), + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Rational(*a * Rational::from_int(*b as i64))), + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) * *b)), + // Float arithmetic - validate results are finite + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a * b), + (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a * *b as f64), + (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 * b), + // Rational with float promotes to float + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => Self::validate_float(a.to_f64() * b), + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => Self::validate_float(a * b.to_f64()), + _ => Err(ComptimeError { + message: format!("cannot multiply {} and {}", left, right), + }), + } + } + + fn eval_div(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else if a % b == 0 { + // Exact division - return integer + Ok(ComptimeValue::Int(a / b)) + } else { + // Non-exact division - return Rational for exact fraction representation + // This prevents subtle bugs like `1/4 turns` being 0 + Ok(ComptimeValue::Rational(Rational::new(*a, *b))) + } + } + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else if a % b == 0 { + // Exact division - return unsigned integer + Ok(ComptimeValue::Uint(a / b)) + } else { + // Non-exact division - return Rational + Ok(ComptimeValue::Rational(Rational::new(*a as i64, *b as i64))) + } + } + // Rational arithmetic + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + if b.is_zero() { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Rational(*a / *b)) + } + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Rational(*a / Rational::from_int(*b))) + } + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + if b.is_zero() { + Err(ComptimeError { + message: "division by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Rational(Rational::from_int(*a) / *b)) + } + } + // Float arithmetic - check for division by zero + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => { + if *b == 0.0 { + Err(ComptimeError { message: "division by zero".to_string() }) + } else { + Self::validate_float(a / b) + } + } + (ComptimeValue::Int(a), ComptimeValue::Float(b)) => { + if *b == 0.0 { + Err(ComptimeError { message: "division by zero".to_string() }) + } else { + Self::validate_float(*a as f64 / b) + } + } + (ComptimeValue::Float(a), ComptimeValue::Int(b)) => { + if *b == 0 { + Err(ComptimeError { message: "division by zero".to_string() }) + } else { + Self::validate_float(a / *b as f64) + } + } + (ComptimeValue::Uint(a), ComptimeValue::Float(b)) => { + if *b == 0.0 { + Err(ComptimeError { message: "division by zero".to_string() }) + } else { + Self::validate_float(*a as f64 / b) + } + } + (ComptimeValue::Float(a), ComptimeValue::Uint(b)) => { + if *b == 0 { + Err(ComptimeError { message: "division by zero".to_string() }) + } else { + Self::validate_float(a / *b as f64) + } + } + // Rational with float promotes to float + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { + if *b == 0.0 { + Err(ComptimeError { message: "division by zero".to_string() }) + } else { + Self::validate_float(a.to_f64() / b) + } + } + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { + if b.is_zero() { + Err(ComptimeError { message: "division by zero".to_string() }) + } else { + Self::validate_float(a / b.to_f64()) + } + } + _ => Err(ComptimeError { + message: format!("cannot divide {} and {}", left, right), + }), + } + } + + fn eval_mod(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "modulo by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Int(a % b)) + } + } + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { + if *b == 0 { + Err(ComptimeError { + message: "modulo by zero".to_string(), + }) + } else { + Ok(ComptimeValue::Uint(a % b)) + } + } + _ => Err(ComptimeError { + message: format!("cannot modulo {} and {}", left, right), + }), + } + } + + // Comparison operations + + fn eval_eq(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + Ok(ComptimeValue::Bool(left == right)) + } + + fn eval_ne(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + Ok(ComptimeValue::Bool(left != right)) + } + + fn eval_lt(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a < b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a < b)), + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a < b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(a < b)), + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(*a < Rational::from_int(*b))), + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(Rational::from_int(*a) < *b)), + _ => Err(ComptimeError { + message: format!("cannot compare {} < {}", left, right), + }), + } + } + + fn eval_le(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a <= b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a <= b)), + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a <= b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(a <= b)), + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(*a <= Rational::from_int(*b))), + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(Rational::from_int(*a) <= *b)), + _ => Err(ComptimeError { + message: format!("cannot compare {} <= {}", left, right), + }), + } + } + + fn eval_gt(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a > b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a > b)), + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a > b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(a > b)), + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(*a > Rational::from_int(*b))), + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(Rational::from_int(*a) > *b)), + _ => Err(ComptimeError { + message: format!("cannot compare {} > {}", left, right), + }), + } + } + + fn eval_ge(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a >= b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a >= b)), + (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a >= b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(a >= b)), + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(*a >= Rational::from_int(*b))), + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(Rational::from_int(*a) >= *b)), + _ => Err(ComptimeError { + message: format!("cannot compare {} >= {}", left, right), + }), + } + } + + // Logical operations + + fn eval_and(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Bool(a), ComptimeValue::Bool(b)) => Ok(ComptimeValue::Bool(*a && *b)), + _ => Err(ComptimeError { + message: format!("cannot apply 'and' to {} and {}", left, right), + }), + } + } + + fn eval_or(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Bool(a), ComptimeValue::Bool(b)) => Ok(ComptimeValue::Bool(*a || *b)), + _ => Err(ComptimeError { + message: format!("cannot apply 'or' to {} and {}", left, right), + }), + } + } + + // Bitwise operations + + fn eval_bit_and(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a & b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a & b)), + _ => Err(ComptimeError { + message: format!("cannot apply & to {} and {}", left, right), + }), + } + } + + fn eval_bit_or(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a | b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a | b)), + _ => Err(ComptimeError { + message: format!("cannot apply | to {} and {}", left, right), + }), + } + } + + fn eval_bit_xor(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match (left, right) { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a ^ b)), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a ^ b)), + _ => Err(ComptimeError { + message: format!("cannot apply ^ to {} and {}", left, right), + }), + } + } + + fn eval_shl(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + let shift = right.as_uint().ok_or_else(|| ComptimeError { + message: "shift amount must be unsigned integer".to_string(), + })?; + + // Validate shift amount is within bounds (max 63 for 64-bit integers) + if shift >= 64 { + return Err(ComptimeError { + message: format!("shift amount {} is too large (max 63 for 64-bit integers)", shift), + }); + } + let shift = shift as u32; + + match left { + ComptimeValue::Int(a) => Ok(ComptimeValue::Int(a << shift)), + ComptimeValue::Uint(a) => Ok(ComptimeValue::Uint(a << shift)), + _ => Err(ComptimeError { + message: format!("cannot shift {}", left), + }), + } + } + + fn eval_shr(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + let shift = right.as_uint().ok_or_else(|| ComptimeError { + message: "shift amount must be unsigned integer".to_string(), + })?; + + // Validate shift amount is within bounds (max 63 for 64-bit integers) + if shift >= 64 { + return Err(ComptimeError { + message: format!("shift amount {} is too large (max 63 for 64-bit integers)", shift), + }); + } + let shift = shift as u32; + + match left { + ComptimeValue::Int(a) => Ok(ComptimeValue::Int(a >> shift)), + ComptimeValue::Uint(a) => Ok(ComptimeValue::Uint(a >> shift)), + _ => Err(ComptimeError { + message: format!("cannot shift {}", left), + }), + } + } + + // Optional operations + + fn eval_orelse(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + match left { + ComptimeValue::Null => Ok(right.clone()), + other => Ok(other.clone()), + } + } + + // ========================================================================= + // Expression Evaluation + // ========================================================================= + + /// Evaluate an expression at compile time. + pub fn eval_expr(&mut self, expr: &Expr) -> ComptimeResult { + // Check recursion depth + self.enter_eval()?; + let result = self.eval_expr_inner(expr); + self.exit_eval(); + result + } + + /// Inner expression evaluation (after depth check). + fn eval_expr_inner(&mut self, expr: &Expr) -> ComptimeResult { + match expr { + // Literals + Expr::IntLit(lit) => { + // Convert i128 to i64, checking for overflow + let value = lit.value.try_into().map_err(|_| ComptimeError { + message: format!("integer literal {} too large for comptime i64", lit.value), + })?; + Ok(ComptimeValue::Int(value)) + } + Expr::FloatLit(lit) => Ok(ComptimeValue::Float(lit.value)), + Expr::AngleLit(angle) => { + // Evaluate the inner expression and convert to turns (native unit) + let val = self.eval_expr(&angle.value)?; + use crate::ast::AngleUnit; + + // For Rational values, preserve exact fraction when possible + if let ComptimeValue::Rational(r) = &val { + match angle.unit { + AngleUnit::Turns => { + // Already in turns - preserve exact rational + return Ok(val); + } + AngleUnit::Rad => { + // For rational radians, we can't preserve precision + // since radians involve pi (irrational) + // But we can try to detect if it represents n*pi/d + let radians = r.to_f64(); + if let Some(turns_rational) = Rational::radians_to_turns(radians) { + return Ok(ComptimeValue::Rational(turns_rational)); + } + let turns = radians / (2.0 * std::f64::consts::PI); + return Ok(ComptimeValue::Float(turns)); + } + } + } + + let numeric = match &val { + ComptimeValue::Float(f) => *f, + ComptimeValue::Int(i) => *i as f64, + ComptimeValue::Uint(u) => *u as f64, + _ => { + return Err(ComptimeError { + message: format!( + "angle value must be numeric, found {:?}", + val + ), + }) + } + }; + + // For radian values, try to detect pi-multiples and preserve precision + if let AngleUnit::Rad = angle.unit + && let Some(turns_rational) = Rational::radians_to_turns(numeric) { + return Ok(ComptimeValue::Rational(turns_rational)); + } + + // Fall back to float conversion + let turns = angle.unit.to_turns(numeric); + Ok(ComptimeValue::Float(turns)) + } + Expr::TypeAscription(asc) => { + // Evaluate the inner expression and convert to the specified type + let val = self.eval_expr(&asc.value)?; + // Convert based on target type + match asc.type_name.as_str() { + "f64" | "f32" | "f16" | "f128" => { + let f = match val { + ComptimeValue::Float(f) => f, + ComptimeValue::Int(i) => i as f64, + ComptimeValue::Uint(u) => u as f64, + ComptimeValue::Rational(r) => r.to_f64(), + _ => { + return Err(ComptimeError { + message: format!( + "cannot convert {:?} to {}", + val, asc.type_name + ), + }) + } + }; + Ok(ComptimeValue::Float(f)) + } + "a64" => { + // a64 expects a value in turns - preserve Rational if possible + match val { + ComptimeValue::Rational(r) => Ok(ComptimeValue::Rational(r)), + ComptimeValue::Float(f) => Ok(ComptimeValue::Float(f)), + ComptimeValue::Int(i) => Ok(ComptimeValue::Rational(Rational::from_int(i))), + ComptimeValue::Uint(u) => Ok(ComptimeValue::Rational(Rational::from_int(u as i64))), + _ => { + Err(ComptimeError { + message: format!( + "cannot convert {:?} to a64", + val + ), + }) + } + } + } + t if t.starts_with('u') => { + let u = match val { + ComptimeValue::Uint(u) => u, + ComptimeValue::Int(i) if i >= 0 => i as u64, + ComptimeValue::Float(f) if f >= 0.0 => f as u64, + _ => { + return Err(ComptimeError { + message: format!( + "cannot convert {:?} to {}", + val, asc.type_name + ), + }) + } + }; + Ok(ComptimeValue::Uint(u)) + } + t if t.starts_with('i') => { + let i = match val { + ComptimeValue::Int(i) => i, + ComptimeValue::Uint(u) => u as i64, + ComptimeValue::Float(f) => f as i64, + _ => { + return Err(ComptimeError { + message: format!( + "cannot convert {:?} to {}", + val, asc.type_name + ), + }) + } + }; + Ok(ComptimeValue::Int(i)) + } + _ => Err(ComptimeError { + message: format!("unknown type suffix: {}", asc.type_name), + }), + } + } + Expr::BoolLit(lit) => Ok(ComptimeValue::Bool(lit.value)), + Expr::StringLit(lit) => Ok(ComptimeValue::String(lit.value.clone())), + Expr::FString(fstr) => { + // Evaluate f-string by concatenating all parts + let mut result = String::new(); + for part in &fstr.parts { + match part { + FStringPart::Text(text) => result.push_str(text), + FStringPart::Expr { expr, format } => { + let val = self.eval_expr(expr)?; + // TODO: Apply format specifier at comptime if needed + let _ = format; // Acknowledge format spec (unused at comptime for now) + result.push_str(&val.to_string()); + } + } + } + Ok(ComptimeValue::String(result)) + } + Expr::CharLit(lit) => Ok(ComptimeValue::Uint(lit.value as u64)), + Expr::Null(_) => Ok(ComptimeValue::Null), + Expr::Undefined(_) => Ok(ComptimeValue::Undefined), + Expr::Unit(_) => Ok(ComptimeValue::Unit), + + Expr::Ident(ident) => { + // First check local context + if let Some(val) = self.context.lookup(&ident.name) { + return Ok(val.clone()); + } + + // Check for built-in types (u8, u32, i64, bool, etc.) + if let Some(ty) = self.resolve_builtin_type(&ident.name) { + return Ok(ComptimeValue::Type(ty)); + } + + Err(ComptimeError { + message: format!("undefined variable '{}' at comptime", ident.name), + }) + } + + Expr::Binary(binary) => { + let left = self.eval_expr(&binary.left)?; + let right = self.eval_expr(&binary.right)?; + self.eval_binary_op(binary.op, &left, &right) + } + + Expr::Unary(unary) => { + let operand = self.eval_expr(&unary.operand)?; + self.eval_unary_op(unary.op, &operand) + } + + Expr::If(if_expr) => { + let cond = self.eval_expr(&if_expr.condition)?; + if cond.is_truthy() { + self.eval_expr(&if_expr.then_expr) + } else { + self.eval_expr(&if_expr.else_expr) + } + } + + Expr::Block(block) => { + self.context.push_scope(); + + for stmt in &block.statements { + self.eval_stmt(stmt)?; + } + + // Evaluate trailing expression if present (block's return value) + let result = if let Some(trailing) = &block.trailing_expr { + self.eval_expr(trailing)? + } else { + ComptimeValue::Unit + }; + + self.context.pop_scope(); + Ok(result) + } + + Expr::Comptime(comptime) => { + // Already in comptime context, just evaluate inner + self.eval_expr(&comptime.inner) + } + + Expr::Index(index) => { + let object = self.eval_expr(&index.object)?; + let idx = self.eval_expr(&index.index)?; + + match (&object, &idx) { + (ComptimeValue::Array(arr), ComptimeValue::Int(i)) => { + let i = *i as usize; + arr.get(i).cloned().ok_or_else(|| ComptimeError { + message: format!("index {} out of bounds for array of length {}", i, arr.len()), + }) + } + (ComptimeValue::Array(arr), ComptimeValue::Uint(i)) => { + let i = *i as usize; + arr.get(i).cloned().ok_or_else(|| ComptimeError { + message: format!("index {} out of bounds for array of length {}", i, arr.len()), + }) + } + _ => Err(ComptimeError { + message: format!("cannot index {} with {}", object, idx), + }), + } + } + + Expr::Field(field) => { + let object = self.eval_expr(&field.object)?; + + match object { + ComptimeValue::Struct { fields, .. } => { + fields.get(&field.field).cloned().ok_or_else(|| ComptimeError { + message: format!("no field '{}' on struct", field.field), + }) + } + _ => Err(ComptimeError { + message: format!("cannot access field on {}", object), + }), + } + } + + Expr::Range(range) => { + let start = range.start.as_ref() + .map(|e| self.eval_expr(e)) + .transpose()? + .unwrap_or(ComptimeValue::Int(0)); + let end = range.end.as_ref() + .map(|e| self.eval_expr(e)) + .transpose()? + .ok_or_else(|| ComptimeError { + message: "range must have an end".to_string(), + })?; + + match (&start, &end) { + (ComptimeValue::Int(s), ComptimeValue::Int(e)) => { + let arr: Vec = (*s..*e) + .map(ComptimeValue::Int) + .collect(); + Ok(ComptimeValue::Array(arr)) + } + (ComptimeValue::Uint(s), ComptimeValue::Uint(e)) => { + let arr: Vec = (*s..*e) + .map(ComptimeValue::Uint) + .collect(); + Ok(ComptimeValue::Array(arr)) + } + _ => Err(ComptimeError { + message: "range bounds must be integers".to_string(), + }), + } + } + + Expr::ArrayInit(arr) => { + let values: ComptimeResult> = arr + .elements + .iter() + .map(|e| self.eval_expr(e)) + .collect(); + Ok(ComptimeValue::Array(values?)) + } + + Expr::BracketArray(arr) => { + let values: ComptimeResult> = arr + .elements + .iter() + .map(|e| self.eval_expr(e)) + .collect(); + Ok(ComptimeValue::Array(values?)) + } + + // Comptime function calls + Expr::Call(call) => { + // First, resolve the callee + let callee = self.eval_expr(&call.callee)?; + + match callee { + ComptimeValue::Function(func) => { + // Evaluate arguments + let mut arg_values = Vec::new(); + for arg in &call.args { + arg_values.push(self.eval_expr(arg)?); + } + + // Check argument count + if arg_values.len() != func.params.len() { + return Err(ComptimeError { + message: format!( + "function '{}' expects {} arguments, got {}", + func.name, func.params.len(), arg_values.len() + ), + }); + } + + // Check memoization cache + let cache_key = ( + func.name.clone(), + Self::serialize_args_for_cache(&arg_values), + ); + if let Some(cached_result) = self.memo_cache.get(&cache_key) { + return Ok(cached_result.clone()); + } + + // Create new scope for function execution + self.context.push_scope(); + + // Clone arg_values for binding (we need them for caching too) + // Bind parameters to argument values + for (param, value) in func.params.iter().zip(arg_values.clone()) { + self.context.define(¶m.name, value); + } + + // Execute function body + let mut result = ComptimeValue::Unit; + for stmt in &func.body.statements { + // Check for early return + if let Stmt::Return(ret) = stmt { + result = if let Some(value) = &ret.value { + self.eval_expr(value)? + } else { + ComptimeValue::Unit + }; + self.context.pop_scope(); + // Cache the result before returning + self.memo_cache.insert(cache_key, result.clone()); + return Ok(result); + } + self.eval_stmt(stmt)?; + } + + // Evaluate trailing expression if present + if let Some(trailing) = &func.body.trailing_expr { + result = self.eval_expr(trailing)?; + } + + self.context.pop_scope(); + + // Cache the result + self.memo_cache.insert(cache_key, result.clone()); + Ok(result) + } + _ => Err(ComptimeError { + message: format!("cannot call non-function value: {}", callee), + }), + } + } + + Expr::SlotRef(_) => Err(ComptimeError { + message: "qubit references not supported at comptime".to_string(), + }), + + Expr::BitRef(_) => Err(ComptimeError { + message: "bit references not supported at comptime".to_string(), + }), + + Expr::Builtin(builtin) => self.eval_builtin(builtin), + + Expr::AnonStruct(anon) => { + // Convert anonymous struct expression to a Type value + let mut fields = Vec::new(); + for field in &anon.fields { + let field_ty = self.resolve_type_expr(&field.ty)?; + fields.push((field.name.clone(), field_ty)); + } + Ok(ComptimeValue::Type(Type::Struct { + name: "".to_string(), + fields, + })) + } + + Expr::StructInit(_) => Err(ComptimeError { + message: "struct init not yet supported at comptime".to_string(), + }), + + Expr::Tuple(tuple) => { + let values: ComptimeResult> = tuple + .elements + .iter() + .map(|e| self.eval_expr(e)) + .collect(); + Ok(ComptimeValue::Array(values?)) // Represent tuples as arrays for now + } + + Expr::Set(_) => Err(ComptimeError { + message: "set literals not supported at comptime".to_string(), + }), + + // Error/fault handling expressions + Expr::ErrorValue(_) => Err(ComptimeError { + message: "error values not supported at comptime".to_string(), + }), + + Expr::FaultValue(_) => Err(ComptimeError { + message: "fault values not supported at comptime".to_string(), + }), + + Expr::Catch(_) => Err(ComptimeError { + message: "catch expressions not supported at comptime".to_string(), + }), + + Expr::TryBlock(_) => Err(ComptimeError { + message: "try blocks not supported at comptime".to_string(), + }), + + // Batch apply is a runtime operation + Expr::BatchApply(_) => Err(ComptimeError { + message: "batch apply not supported at comptime".to_string(), + }), + + // Measurement is a runtime operation + Expr::Measure(_) => Err(ComptimeError { + message: "measurement not supported at comptime".to_string(), + }), + + // Gate operations are runtime-only + Expr::Gate(_) => Err(ComptimeError { + message: "gate operations not supported at comptime".to_string(), + }), + + // Function literal - creates a comptime function value + Expr::FnLit(func) => { + Ok(ComptimeValue::Function(func.clone())) + } + + // Channel expressions (@emit.log.*, @emit.sim.*, @emit.hw.*, custom channels) + // At comptime, these evaluate to unit (actual behavior happens at runtime) + Expr::Channel(channel) => { + // Evaluate all argument expressions at comptime + for arg in &channel.args { + self.eval_expr(arg.value())?; + } + Ok(ComptimeValue::Unit) + } + + // Result expressions - emit tagged values to caller + // At comptime, these evaluate to unit (emission happens at runtime) + Expr::Result(_result) => { + // result() always evaluates to unit + // The actual emission happens at runtime + Ok(ComptimeValue::Unit) + } + } + } + + /// Evaluate a for range to get iterable values. + fn eval_for_range(&mut self, range: &ForRange) -> ComptimeResult> { + match range { + ForRange::Range { start, end } => { + let start_val = self.eval_expr(start)?; + let end_val = self.eval_expr(end)?; + + match (&start_val, &end_val) { + (ComptimeValue::Int(s), ComptimeValue::Int(e)) => { + Ok((*s..*e).map(ComptimeValue::Int).collect()) + } + (ComptimeValue::Uint(s), ComptimeValue::Uint(e)) => { + Ok((*s..*e).map(ComptimeValue::Uint).collect()) + } + _ => Err(ComptimeError { + message: "range bounds must be integers".to_string(), + }), + } + } + ForRange::Collection(expr) => { + let val = self.eval_expr(expr)?; + match val { + ComptimeValue::Array(arr) => Ok(arr), + _ => Err(ComptimeError { + message: "cannot iterate over non-array at comptime".to_string(), + }), + } + } + } + } + + /// Evaluate a builtin function at compile time. + fn eval_builtin(&mut self, builtin: &crate::ast::BuiltinExpr) -> ComptimeResult { + match builtin.name.as_str() { + // Support both snake_case (preferred) and camelCase (legacy) for builtins + "size_of" | "sizeOf" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@size_of requires a type argument".to_string(), + }); + } + // Get the type name from the argument + let size = self.get_type_size(&builtin.args[0])?; + Ok(ComptimeValue::Uint(size)) + } + "type_name" | "typeName" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@type_name requires a type argument".to_string(), + }); + } + let name = self.get_type_name(&builtin.args[0])?; + Ok(ComptimeValue::String(name)) + } + "align_of" | "alignOf" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@align_of requires a type argument".to_string(), + }); + } + // For simplicity, alignment equals size for primitive types + let align = self.get_type_size(&builtin.args[0])?; + Ok(ComptimeValue::Uint(align)) + } + "type_info" | "typeInfo" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@type_info requires a type argument".to_string(), + }); + } + self.eval_type_info(&builtin.args[0]) + } + "field_names" | "fieldNames" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@field_names requires a type argument".to_string(), + }); + } + self.eval_field_names(&builtin.args[0]) + } + "enum_fields" | "enumFields" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@enum_fields requires a type argument".to_string(), + }); + } + self.eval_enum_fields(&builtin.args[0]) + } + "type_from_info" | "Type" => { + if builtin.args.is_empty() { + return Err(ComptimeError { + message: "@type_from_info requires a TypeInfo argument".to_string(), + }); + } + self.eval_type_from_info(&builtin.args[0]) + } + _ => Err(ComptimeError { + message: format!("builtin @{} not supported at comptime", builtin.name), + }), + } + } + + /// Get the size of a type expression in bytes. + fn get_type_size(&self, expr: &Expr) -> ComptimeResult { + match expr { + Expr::Ident(ident) => { + match ident.name.as_str() { + "u8" | "i8" | "bool" => Ok(1), + "u16" | "i16" => Ok(2), + "u32" | "i32" | "f32" => Ok(4), + "u64" | "i64" | "f64" | "usize" | "isize" => Ok(8), + "u128" | "i128" => Ok(16), + "a64" => Ok(8), // Angle type + "unit" => Ok(0), + // Qubit and allocator are abstract, but we can assign sizes + "Qubit" | "qubit" => Ok(8), // Pointer-sized + _ => Err(ComptimeError { + message: format!("unknown type '{}' for @size_of", ident.name), + }), + } + } + // Pointer types + Expr::Unary(unary) if matches!(unary.op, UnaryOp::AddrOf) => { + Ok(8) // Pointers are 8 bytes on 64-bit + } + _ => Err(ComptimeError { + message: "cannot determine size of complex type expression".to_string(), + }), + } + } + + /// Get the name of a type expression. + fn get_type_name(&self, expr: &Expr) -> ComptimeResult { + match expr { + Expr::Ident(ident) => Ok(ident.name.clone()), + _ => Err(ComptimeError { + message: "cannot determine name of complex type expression".to_string(), + }), + } + } + + /// Get the TypeInfoKind for a Type. + fn get_type_info_kind(ty: &Type) -> TypeInfoKind { + match ty { + Type::Bool | Type::UInt { .. } | Type::IInt { .. } | Type::Usize | Type::Isize | + Type::F16 | Type::F32 | Type::F64 | Type::F128 | Type::A64 => TypeInfoKind::Primitive, + Type::Array { .. } => TypeInfoKind::Array, + Type::Slice { .. } => TypeInfoKind::Slice, + Type::Set { .. } => TypeInfoKind::Struct, // Set is like a collection + Type::Pointer { .. } => TypeInfoKind::Pointer, + Type::Optional { .. } => TypeInfoKind::Optional, + Type::ErrorUnion { .. } | Type::CollectedErrors { .. } => TypeInfoKind::ErrorUnion, + Type::Struct { .. } => TypeInfoKind::Struct, + Type::Enum { .. } => TypeInfoKind::Enum, + Type::Union { .. } => TypeInfoKind::Union, + Type::ErrorSet { .. } => TypeInfoKind::ErrorSet, + Type::FaultSet { .. } => TypeInfoKind::FaultSet, + Type::Function { .. } => TypeInfoKind::Function, + Type::Tuple { .. } => TypeInfoKind::Tuple, + Type::Type => TypeInfoKind::Type, + Type::Unit => TypeInfoKind::Unit, + Type::Never => TypeInfoKind::Never, + Type::Qubit | Type::Bit | Type::Allocator { .. } => TypeInfoKind::Quantum, + Type::Comptime(inner) => Self::get_type_info_kind(inner), + Type::Module { .. } => TypeInfoKind::Struct, // Module is like a namespace + Type::AnyError | Type::AnyFault | Type::Unknown => TypeInfoKind::Unknown, + } + } + + /// Resolve a type from an expression (for @type_info and related builtins). + fn resolve_type_from_expr(&self, expr: &Expr) -> ComptimeResult { + match expr { + Expr::Ident(ident) => { + // Check if it's a type in the context + if let Some(val) = self.context.lookup(&ident.name) { + if let ComptimeValue::Type(ty) = val { + return Ok(ty.clone()); + } + } + // Try to resolve primitive types + match ident.name.as_str() { + "bool" => Ok(Type::Bool), + "u8" => Ok(Type::UInt { bits: BitWidth::must(8) }), + "u16" => Ok(Type::UInt { bits: BitWidth::must(16) }), + "u32" => Ok(Type::UInt { bits: BitWidth::must(32) }), + "u64" => Ok(Type::UInt { bits: BitWidth::must(64) }), + "u128" => Ok(Type::UInt { bits: BitWidth::must(128) }), + "i8" => Ok(Type::IInt { bits: BitWidth::must(8) }), + "i16" => Ok(Type::IInt { bits: BitWidth::must(16) }), + "i32" => Ok(Type::IInt { bits: BitWidth::must(32) }), + "i64" => Ok(Type::IInt { bits: BitWidth::must(64) }), + "i128" => Ok(Type::IInt { bits: BitWidth::must(128) }), + "usize" => Ok(Type::Usize), + "isize" => Ok(Type::Isize), + "f16" => Ok(Type::F16), + "f32" => Ok(Type::F32), + "f64" => Ok(Type::F64), + "f128" => Ok(Type::F128), + "a64" => Ok(Type::A64), + "unit" => Ok(Type::Unit), + "type" => Ok(Type::Type), + "never" => Ok(Type::Never), + "Qubit" | "qubit" => Ok(Type::Qubit), + "Bit" | "bit" => Ok(Type::Bit), + _ => Err(ComptimeError { + message: format!("unknown type '{}'", ident.name), + }), + } + } + _ => Err(ComptimeError { + message: "complex type expressions not yet supported in @type_info".to_string(), + }), + } + } + + /// Evaluate @type_info(T) - returns a struct with type information. + fn eval_type_info(&self, expr: &Expr) -> ComptimeResult { + let ty = self.resolve_type_from_expr(expr)?; + let kind = Self::get_type_info_kind(&ty); + + let mut fields = BTreeMap::new(); + fields.insert("kind".to_string(), ComptimeValue::String(kind.as_str().to_string())); + fields.insert("name".to_string(), ComptimeValue::String(ty.display_name())); + + // Add type-specific information + match &ty { + Type::Struct { name, fields: struct_fields } => { + let field_names: Vec = struct_fields + .iter() + .map(|(n, _)| ComptimeValue::String(n.clone())) + .collect(); + fields.insert("fields".to_string(), ComptimeValue::Array(field_names)); + fields.insert("struct_name".to_string(), ComptimeValue::String(name.clone())); + } + Type::Enum { name, variants } => { + let variant_names: Vec = variants + .iter() + .map(|v| ComptimeValue::String(v.clone())) + .collect(); + fields.insert("variants".to_string(), ComptimeValue::Array(variant_names)); + fields.insert("enum_name".to_string(), ComptimeValue::String(name.clone())); + } + Type::Union { name, fields: union_fields, is_tagged } => { + let field_names: Vec = union_fields + .iter() + .map(|(n, _)| ComptimeValue::String(n.clone())) + .collect(); + fields.insert("fields".to_string(), ComptimeValue::Array(field_names)); + fields.insert("union_name".to_string(), ComptimeValue::String(name.clone())); + fields.insert("is_tagged".to_string(), ComptimeValue::Bool(*is_tagged)); + } + Type::ErrorSet { name, errors } => { + let error_names: Vec = errors + .iter() + .map(|(n, _)| ComptimeValue::String(n.clone())) + .collect(); + fields.insert("errors".to_string(), ComptimeValue::Array(error_names)); + fields.insert("error_set_name".to_string(), ComptimeValue::String(name.clone())); + } + Type::FaultSet { name, faults } => { + let fault_names: Vec = faults + .iter() + .map(|(n, _)| ComptimeValue::String(n.clone())) + .collect(); + fields.insert("faults".to_string(), ComptimeValue::Array(fault_names)); + fields.insert("fault_set_name".to_string(), ComptimeValue::String(name.clone())); + } + Type::Array { element, size } => { + fields.insert("element".to_string(), ComptimeValue::Type(*element.clone())); + if let Some(sz) = size { + fields.insert("size".to_string(), ComptimeValue::Uint(*sz)); + } + } + Type::Slice { element } => { + fields.insert("element".to_string(), ComptimeValue::Type(*element.clone())); + } + Type::Pointer { pointee, is_const, is_many } => { + fields.insert("pointee".to_string(), ComptimeValue::Type(*pointee.clone())); + fields.insert("is_const".to_string(), ComptimeValue::Bool(*is_const)); + fields.insert("is_many".to_string(), ComptimeValue::Bool(*is_many)); + } + Type::Optional { inner } => { + fields.insert("child".to_string(), ComptimeValue::Type(*inner.clone())); + } + Type::ErrorUnion { error, payload } => { + fields.insert("error".to_string(), ComptimeValue::Type(*error.clone())); + fields.insert("payload".to_string(), ComptimeValue::Type(*payload.clone())); + } + Type::Function { params, return_type } => { + let param_types: Vec = params + .iter() + .map(|p| ComptimeValue::Type(p.clone())) + .collect(); + fields.insert("params".to_string(), ComptimeValue::Array(param_types)); + fields.insert("return_type".to_string(), ComptimeValue::Type(*return_type.clone())); + } + Type::Tuple { elements } => { + let element_types: Vec = elements + .iter() + .map(|e| ComptimeValue::Type(e.clone())) + .collect(); + fields.insert("elements".to_string(), ComptimeValue::Array(element_types)); + } + _ => { + // Primitive types don't have additional info + } + } + + Ok(ComptimeValue::Struct { + name: "TypeInfo".to_string(), + fields, + }) + } + + /// Evaluate @field_names(T) - returns an array of field name strings for structs. + fn eval_field_names(&self, expr: &Expr) -> ComptimeResult { + let ty = self.resolve_type_from_expr(expr)?; + + match &ty { + Type::Struct { fields, .. } => { + let names: Vec = fields + .iter() + .map(|(name, _)| ComptimeValue::String(name.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + Type::Union { fields, .. } => { + let names: Vec = fields + .iter() + .map(|(name, _)| ComptimeValue::String(name.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + _ => Err(ComptimeError { + message: format!( + "@field_names requires a struct or union type, got {}", + ty.display_name() + ), + }), + } + } + + /// Evaluate @enum_fields(T) - returns an array of enum variant names. + fn eval_enum_fields(&self, expr: &Expr) -> ComptimeResult { + let ty = self.resolve_type_from_expr(expr)?; + + match &ty { + Type::Enum { variants, .. } => { + let names: Vec = variants + .iter() + .map(|v| ComptimeValue::String(v.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + Type::ErrorSet { errors, .. } => { + let names: Vec = errors + .iter() + .map(|(name, _)| ComptimeValue::String(name.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + Type::FaultSet { faults, .. } => { + let names: Vec = faults + .iter() + .map(|(name, _)| ComptimeValue::String(name.clone())) + .collect(); + Ok(ComptimeValue::Array(names)) + } + _ => Err(ComptimeError { + message: format!( + "@enum_fields requires an enum, error set, or fault set type, got {}", + ty.display_name() + ), + }), + } + } + + /// Evaluate @Type(info) - construct a type from a TypeInfo struct. + /// This is the reverse of @type_info. + fn eval_type_from_info(&mut self, expr: &Expr) -> ComptimeResult { + let info = self.eval_expr(expr)?; + + match info { + ComptimeValue::Struct { name, fields } if name == "TypeInfo" => { + // Get the kind field + let kind = fields.get("kind").ok_or_else(|| ComptimeError { + message: "@Type requires TypeInfo with 'kind' field".to_string(), + })?; + + let kind_str = match kind { + ComptimeValue::String(s) => s.as_str(), + _ => return Err(ComptimeError { + message: "TypeInfo.kind must be a string".to_string(), + }), + }; + + // Construct the type based on kind + let ty = match kind_str { + "primitive" => { + // Get the name to determine which primitive + let name = fields.get("name").and_then(|v| { + if let ComptimeValue::String(s) = v { Some(s.as_str()) } else { None } + }).ok_or_else(|| ComptimeError { + message: "primitive TypeInfo requires 'name' field".to_string(), + })?; + + match name { + "bool" => Type::Bool, + "u8" => Type::UInt { bits: BitWidth::must(8) }, + "u16" => Type::UInt { bits: BitWidth::must(16) }, + "u32" => Type::UInt { bits: BitWidth::must(32) }, + "u64" => Type::UInt { bits: BitWidth::must(64) }, + "i8" => Type::IInt { bits: BitWidth::must(8) }, + "i16" => Type::IInt { bits: BitWidth::must(16) }, + "i32" => Type::IInt { bits: BitWidth::must(32) }, + "i64" => Type::IInt { bits: BitWidth::must(64) }, + "f32" => Type::F32, + "f64" => Type::F64, + _ => return Err(ComptimeError { + message: format!("unknown primitive type '{}'", name), + }), + } + } + "array" => { + let element = fields.get("element").ok_or_else(|| ComptimeError { + message: "array TypeInfo requires 'element' field".to_string(), + })?; + let element_ty = match element { + ComptimeValue::Type(t) => t.clone(), + _ => return Err(ComptimeError { + message: "TypeInfo.element must be a type".to_string(), + }), + }; + let size = fields.get("size").and_then(|v| { + match v { + ComptimeValue::Uint(n) => Some(*n), + ComptimeValue::Int(n) if *n >= 0 => Some(*n as u64), + _ => None, + } + }); + Type::Array { element: Box::new(element_ty), size } + } + "slice" => { + let element = fields.get("element").ok_or_else(|| ComptimeError { + message: "slice TypeInfo requires 'element' field".to_string(), + })?; + let element_ty = match element { + ComptimeValue::Type(t) => t.clone(), + _ => return Err(ComptimeError { + message: "TypeInfo.element must be a type".to_string(), + }), + }; + Type::Slice { element: Box::new(element_ty) } + } + "optional" => { + let child = fields.get("child").ok_or_else(|| ComptimeError { + message: "optional TypeInfo requires 'child' field".to_string(), + })?; + let child_ty = match child { + ComptimeValue::Type(t) => t.clone(), + _ => return Err(ComptimeError { + message: "TypeInfo.child must be a type".to_string(), + }), + }; + Type::Optional { inner: Box::new(child_ty) } + } + "unit" => Type::Unit, + "never" => Type::Never, + "type" => Type::Type, + _ => return Err(ComptimeError { + message: format!("cannot construct type from kind '{}'", kind_str), + }), + }; + + Ok(ComptimeValue::Type(ty)) + } + _ => Err(ComptimeError { + message: "@Type requires a TypeInfo struct argument".to_string(), + }), + } + } + + /// Evaluate a statement at compile time. + pub fn eval_stmt(&mut self, stmt: &Stmt) -> ComptimeResult { + match stmt { + Stmt::Binding(binding) => { + let value = if let Some(init) = &binding.value { + self.eval_expr(init)? + } else { + ComptimeValue::Undefined + }; + self.context.define(&binding.name, value); + Ok(ComptimeValue::Undefined) + } + + Stmt::Alias(alias) => { + // Aliases are evaluated as their source expression at comptime + let value = self.eval_expr(&alias.source)?; + self.context.define(&alias.name, value); + Ok(ComptimeValue::Undefined) + } + + Stmt::Assign(assign) => { + let value = self.eval_expr(&assign.value)?; + + // Handle simple identifier assignment + if let Expr::Ident(ident) = &assign.target { + if !self.context.update(&ident.name, value) { + return Err(ComptimeError { + message: format!("undefined variable '{}'", ident.name), + }); + } + } else { + return Err(ComptimeError { + message: "complex assignment targets not supported at comptime".to_string(), + }); + } + Ok(ComptimeValue::Undefined) + } + + Stmt::Expr(expr_stmt) => { + self.eval_expr(&expr_stmt.expr)?; + Ok(ComptimeValue::Undefined) + } + + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + self.eval_expr(value) + } else { + Ok(ComptimeValue::Undefined) + } + } + + Stmt::If(if_stmt) => { + let cond = self.eval_expr(&if_stmt.condition)?; + + if cond.is_truthy() { + self.context.push_scope(); + for stmt in &if_stmt.then_body.statements { + self.eval_stmt(stmt)?; + } + self.context.pop_scope(); + } else if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + crate::ast::ElseBranch::Else(block) => { + self.context.push_scope(); + for stmt in &block.statements { + self.eval_stmt(stmt)?; + } + self.context.pop_scope(); + } + crate::ast::ElseBranch::ElseIf(nested_if) => { + self.eval_stmt(&Stmt::If(*nested_if.clone()))?; + } + } + } + Ok(ComptimeValue::Undefined) + } + + Stmt::For(for_stmt) => { + let values = self.eval_for_range(&for_stmt.range)?; + + self.context.push_scope(); + + // Get binding name from captures + let binding = for_stmt.captures.first(); + + for value in values { + if let Some(name) = binding { + self.context.define(name, value); + } + for stmt in &for_stmt.body.statements { + self.eval_stmt(stmt)?; + } + } + + self.context.pop_scope(); + Ok(ComptimeValue::Undefined) + } + + Stmt::Block(block) => { + self.context.push_scope(); + for stmt in &block.statements { + self.eval_stmt(stmt)?; + } + // Evaluate trailing expression if present + let result = if let Some(trailing) = &block.trailing_expr { + self.eval_expr(trailing)? + } else { + ComptimeValue::Unit + }; + self.context.pop_scope(); + Ok(result) + } + + Stmt::Defer(_) => Err(ComptimeError { + message: "defer not supported at comptime".to_string(), + }), + + Stmt::Errdefer(_) => Err(ComptimeError { + message: "errdefer not supported at comptime".to_string(), + }), + + Stmt::Break(_) => Err(ComptimeError { + message: "break not yet supported at comptime".to_string(), + }), + + Stmt::Continue(_) => Err(ComptimeError { + message: "continue not yet supported at comptime".to_string(), + }), + + Stmt::Switch(_) => Err(ComptimeError { + message: "switch not yet supported at comptime".to_string(), + }), + + Stmt::Tick(_) => Err(ComptimeError { + message: "tick blocks not supported at comptime".to_string(), + }), + + Stmt::TryBlock(_) => Err(ComptimeError { + message: "try blocks not supported at comptime".to_string(), + }), + + Stmt::Gate(_) => Err(ComptimeError { + message: "quantum operations not supported at comptime".to_string(), + }), + + Stmt::Prepare(_) => Err(ComptimeError { + message: "prepare operations not supported at comptime".to_string(), + }), + + Stmt::Measure(_) => Err(ComptimeError { + message: "measurement operations not supported at comptime".to_string(), + }), + + Stmt::Barrier(_) => Err(ComptimeError { + message: "barrier operations not supported at comptime".to_string(), + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_comptime_value_display() { + assert_eq!(ComptimeValue::Int(42).to_string(), "42"); + assert_eq!(ComptimeValue::Bool(true).to_string(), "true"); + assert_eq!(ComptimeValue::Null.to_string(), "null"); + } + + #[test] + fn test_comptime_arithmetic() { + let eval = ComptimeEvaluator::new(); + + let a = ComptimeValue::Int(10); + let b = ComptimeValue::Int(3); + + assert_eq!( + eval.eval_binary_op(BinaryOp::Add, &a, &b).unwrap(), + ComptimeValue::Int(13) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Sub, &a, &b).unwrap(), + ComptimeValue::Int(7) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Mul, &a, &b).unwrap(), + ComptimeValue::Int(30) + ); + // 10/3 is not exact, so returns Rational (prevents subtle bugs like 1/4 turns = 0) + assert_eq!( + eval.eval_binary_op(BinaryOp::Div, &a, &b).unwrap(), + ComptimeValue::Rational(Rational::new(10, 3)) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Mod, &a, &b).unwrap(), + ComptimeValue::Int(1) + ); + } + + #[test] + fn test_comptime_comparison() { + let eval = ComptimeEvaluator::new(); + + let a = ComptimeValue::Int(10); + let b = ComptimeValue::Int(3); + + assert_eq!( + eval.eval_binary_op(BinaryOp::Lt, &a, &b).unwrap(), + ComptimeValue::Bool(false) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Gt, &a, &b).unwrap(), + ComptimeValue::Bool(true) + ); + assert_eq!( + eval.eval_binary_op(BinaryOp::Eq, &a, &b).unwrap(), + ComptimeValue::Bool(false) + ); + } + + #[test] + fn test_comptime_context() { + let mut ctx = ComptimeContext::new(); + + ctx.define("x", ComptimeValue::Int(42)); + assert_eq!(ctx.lookup("x"), Some(&ComptimeValue::Int(42))); + + ctx.push_scope(); + ctx.define("y", ComptimeValue::Int(10)); + assert_eq!(ctx.lookup("x"), Some(&ComptimeValue::Int(42))); + assert_eq!(ctx.lookup("y"), Some(&ComptimeValue::Int(10))); + + ctx.pop_scope(); + assert_eq!(ctx.lookup("x"), Some(&ComptimeValue::Int(42))); + assert_eq!(ctx.lookup("y"), None); + } + + #[test] + fn test_comptime_unary() { + let eval = ComptimeEvaluator::new(); + + let a = ComptimeValue::Int(42); + assert_eq!( + eval.eval_unary_op(UnaryOp::Neg, &a).unwrap(), + ComptimeValue::Int(-42) + ); + + let b = ComptimeValue::Bool(true); + assert_eq!( + eval.eval_unary_op(UnaryOp::Not, &b).unwrap(), + ComptimeValue::Bool(false) + ); + } + + #[test] + fn test_comptime_division_by_zero() { + let eval = ComptimeEvaluator::new(); + + let a = ComptimeValue::Int(10); + let b = ComptimeValue::Int(0); + + let result = eval.eval_binary_op(BinaryOp::Div, &a, &b); + assert!(result.is_err()); + } + + #[test] + fn test_comptime_orelse() { + let eval = ComptimeEvaluator::new(); + + let null = ComptimeValue::Null; + let fallback = ComptimeValue::Int(42); + + assert_eq!( + eval.eval_binary_op(BinaryOp::Orelse, &null, &fallback).unwrap(), + ComptimeValue::Int(42) + ); + + let some = ComptimeValue::Int(10); + assert_eq!( + eval.eval_binary_op(BinaryOp::Orelse, &some, &fallback).unwrap(), + ComptimeValue::Int(10) + ); + } + + #[test] + fn test_comptime_sizeof() { + use crate::ast::{BuiltinExpr, Ident}; + + let mut eval = ComptimeEvaluator::new(); + + // Test @sizeOf(u8) + let builtin = BuiltinExpr { + name: "sizeOf".to_string(), + args: vec![Expr::Ident(Ident { + name: "u8".to_string(), + location: None, + })], + location: None, + }; + let result = eval.eval_builtin(&builtin).unwrap(); + assert_eq!(result, ComptimeValue::Uint(1)); + + // Test @sizeOf(u32) + let builtin = BuiltinExpr { + name: "sizeOf".to_string(), + args: vec![Expr::Ident(Ident { + name: "u32".to_string(), + location: None, + })], + location: None, + }; + let result = eval.eval_builtin(&builtin).unwrap(); + assert_eq!(result, ComptimeValue::Uint(4)); + + // Test @sizeOf(u64) + let builtin = BuiltinExpr { + name: "sizeOf".to_string(), + args: vec![Expr::Ident(Ident { + name: "u64".to_string(), + location: None, + })], + location: None, + }; + let result = eval.eval_builtin(&builtin).unwrap(); + assert_eq!(result, ComptimeValue::Uint(8)); + } + + #[test] + fn test_comptime_typename() { + use crate::ast::{BuiltinExpr, Ident}; + + let mut eval = ComptimeEvaluator::new(); + + let builtin = BuiltinExpr { + name: "typeName".to_string(), + args: vec![Expr::Ident(Ident { + name: "i32".to_string(), + location: None, + })], + location: None, + }; + let result = eval.eval_builtin(&builtin).unwrap(); + assert_eq!(result, ComptimeValue::String("i32".to_string())); + } + + #[test] + fn test_comptime_to_usize() { + assert_eq!(ComptimeValue::Int(42).to_usize(), Some(42)); + assert_eq!(ComptimeValue::Uint(100).to_usize(), Some(100)); + assert_eq!(ComptimeValue::Bool(true).to_usize(), None); + assert_eq!(ComptimeValue::Null.to_usize(), None); + } + + #[test] + fn test_comptime_type_values() { + let eval = ComptimeEvaluator::new(); + + // Test that built-in type names resolve to Type values + assert!(eval.resolve_builtin_type("u8").is_some()); + assert!(eval.resolve_builtin_type("u32").is_some()); + assert!(eval.resolve_builtin_type("bool").is_some()); + assert!(eval.resolve_builtin_type("unknown_type").is_none()); + } + + #[test] + fn test_comptime_function_value() { + use crate::ast::{Block, Param, TypeExpr}; + + // Create a simple comptime function that returns a type + let func = FnDecl { + name: "makeArray".to_string(), + params: vec![ + Param { + name: "T".to_string(), + ty: TypeExpr::Type, + is_comptime: true, + location: None, + }, + ], + return_type: Some(TypeExpr::Type), + body: Block { + label: None, + attrs: vec![], + statements: vec![], + trailing_expr: Some(Box::new(Expr::Ident(crate::ast::Ident { + name: "T".to_string(), + location: None, + }))), + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + let func_value = ComptimeValue::Function(Box::new(func)); + assert!(matches!(func_value.get_type(), Type::Type)); + } + + #[test] + fn test_comptime_anon_struct() { + use crate::ast::{AnonStructExpr, StructField, TypeExpr, PrimitiveType}; + + let mut eval = ComptimeEvaluator::new(); + + // Create anonymous struct expression: struct { x: u32, y: u32 } + let anon = AnonStructExpr { + fields: vec![ + StructField { + name: "x".to_string(), + ty: TypeExpr::Primitive(PrimitiveType::UInt { bits: 32 }), + default: None, + doc_comment: None, + location: None, + }, + StructField { + name: "y".to_string(), + ty: TypeExpr::Primitive(PrimitiveType::UInt { bits: 32 }), + default: None, + doc_comment: None, + location: None, + }, + ], + is_packed: false, + location: None, + }; + + let result = eval.eval_expr(&Expr::AnonStruct(Box::new(anon))).unwrap(); + + // Verify it's a Type value with a Struct type + if let ComptimeValue::Type(ty) = result { + if let Type::Struct { fields, .. } = ty { + assert_eq!(fields.len(), 2); + assert_eq!(fields[0].0, "x"); + assert_eq!(fields[1].0, "y"); + } else { + panic!("Expected Struct type"); + } + } else { + panic!("Expected Type value"); + } + } + + #[test] + fn test_comptime_function_call() { + use crate::ast::{Block, CallExpr, Param, TypeExpr, Ident}; + + let mut eval = ComptimeEvaluator::new(); + + // Create a function: fn(comptime T: type) -> type { T } + // This is an identity function for types + let func = FnDecl { + name: "identity".to_string(), + params: vec![ + Param { + name: "T".to_string(), + ty: TypeExpr::Type, + is_comptime: true, + location: None, + }, + ], + return_type: Some(TypeExpr::Type), + body: Block { + label: None, + attrs: vec![], + statements: vec![], + trailing_expr: Some(Box::new(Expr::Ident(Ident { + name: "T".to_string(), + location: None, + }))), + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + // Store function in context + eval.context.define("identity", ComptimeValue::Function(Box::new(func))); + + // Call identity(u32) + let call = CallExpr { + callee: Expr::Ident(Ident { + name: "identity".to_string(), + location: None, + }), + args: vec![Expr::Ident(Ident { + name: "u32".to_string(), + location: None, + })], + location: None, + }; + + let result = eval.eval_expr(&Expr::Call(Box::new(call))).unwrap(); + + // The result should be the u32 type + if let ComptimeValue::Type(ty) = result { + assert!(matches!(ty, Type::UInt { bits } if bits == BitWidth::BITS_32)); + } else { + panic!("Expected Type value, got {:?}", result); + } + } + + /// Test the full pattern: inline for + comptime function calls for type construction. + /// This is the key pattern that replaces recursion for building nested types. + /// + /// Simulates: + /// ```zlup + /// WrapArray := fn(comptime Inner: type) -> type { + /// struct { data: [7]Inner } + /// }; + /// + /// Code := comptime { + /// mut T := u8; + /// inline for _ in 0..3 { + /// T = WrapArray(T); + /// } + /// T + /// }; + /// ``` + #[test] + fn test_inline_for_with_comptime_function_nested_types() { + use crate::ast::{ + AnonStructExpr, ArrayType, Block, CallExpr, Ident, IntLit, + Param, StructField, TypeExpr, + }; + + let mut eval = ComptimeEvaluator::new(); + + // Create WrapArray function: fn(comptime Inner: type) -> type { struct { data: [7]Inner } } + // The function body returns an anonymous struct with a field `data: [7]Inner` + let wrap_array_func = FnDecl { + name: "WrapArray".to_string(), + params: vec![Param { + name: "Inner".to_string(), + ty: TypeExpr::Type, + is_comptime: true, + location: None, + }], + return_type: Some(TypeExpr::Type), + body: Block { + label: None, + attrs: vec![], + statements: vec![], + // Return: struct { data: [7]Inner } + trailing_expr: Some(Box::new(Expr::AnonStruct(Box::new(AnonStructExpr { + fields: vec![StructField { + name: "data".to_string(), + ty: TypeExpr::Array(Box::new(ArrayType { + element: TypeExpr::Named(crate::ast::TypePath { + segments: vec!["Inner".to_string()], + location: None, + }), + size: Some(Expr::IntLit(IntLit { + value: 7, + suffix: None, + location: None, + })), + sentinel: None, + })), + default: None, + doc_comment: None, + location: None, + }], + is_packed: false, + location: None, + })))), + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + // Store function in context + eval.context.define("WrapArray", ComptimeValue::Function(Box::new(wrap_array_func))); + + // Now simulate the comptime block: + // comptime { + // mut T := u8; + // inline for _ in 0..3 { T = WrapArray(T); } + // T + // } + + // Step 1: mut T := u8; + eval.context.define("T", ComptimeValue::Type(Type::UInt { bits: BitWidth::BITS_8 })); + + // Step 2: Simulate inline for with 3 iterations + for _ in 0..3 { + // T = WrapArray(T) + let call = CallExpr { + callee: Expr::Ident(Ident { + name: "WrapArray".to_string(), + location: None, + }), + args: vec![Expr::Ident(Ident { + name: "T".to_string(), + location: None, + })], + location: None, + }; + + let new_type = eval.eval_expr(&Expr::Call(Box::new(call))).unwrap(); + eval.context.update("T", new_type); + } + + // Step 3: Get final T + let result = eval.context.lookup("T").unwrap().clone(); + + // Verify the structure: struct { data: [7]struct { data: [7]struct { data: [7]u8 } } } + if let ComptimeValue::Type(ty) = result { + // Level 1: struct { data: [7]... } + if let Type::Struct { fields, .. } = &ty { + assert_eq!(fields.len(), 1, "Expected 1 field at level 1"); + assert_eq!(fields[0].0, "data", "Field name should be 'data'"); + + // Level 1 field type: [7]struct { ... } + if let Type::Array { element: level2, size } = &fields[0].1 { + assert_eq!(*size, Some(7), "Array size should be 7 at level 1"); + + // Level 2: struct { data: [7]... } + if let Type::Struct { fields: fields2, .. } = level2.as_ref() { + assert_eq!(fields2.len(), 1, "Expected 1 field at level 2"); + + // Level 2 field type: [7]struct { ... } + if let Type::Array { element: level3, size: size2 } = &fields2[0].1 { + assert_eq!(*size2, Some(7), "Array size should be 7 at level 2"); + + // Level 3: struct { data: [7]u8 } + if let Type::Struct { fields: fields3, .. } = level3.as_ref() { + assert_eq!(fields3.len(), 1, "Expected 1 field at level 3"); + + // Level 3 field type: [7]u8 + if let Type::Array { element: inner, size: size3 } = &fields3[0].1 { + assert_eq!(*size3, Some(7), "Array size should be 7 at level 3"); + assert!( + matches!(inner.as_ref(), Type::UInt { bits } if *bits == BitWidth::BITS_8), + "Innermost type should be u8, got {:?}", + inner + ); + } else { + panic!("Level 3 field should be array, got {:?}", fields3[0].1); + } + } else { + panic!("Level 3 should be struct, got {:?}", level3); + } + } else { + panic!("Level 2 field should be array, got {:?}", fields2[0].1); + } + } else { + panic!("Level 2 should be struct, got {:?}", level2); + } + } else { + panic!("Level 1 field should be array, got {:?}", fields[0].1); + } + } else { + panic!("Result should be struct, got {:?}", ty); + } + } else { + panic!("Expected Type value, got {:?}", result); + } + } + + /// Test that inline for loop in comptime block works end-to-end. + /// Uses the actual for loop evaluation, not manual simulation. + #[test] + fn test_comptime_block_with_inline_for() { + use crate::ast::{ + Block, ForRange, ForStmt, Ident, IntLit, AssignStmt, Stmt, + }; + + let mut eval = ComptimeEvaluator::new(); + + // Simulate: + // comptime { + // mut sum := 0; + // for i in 0..5 { sum = sum + i; } + // sum + // } + + // Create the for loop + let for_stmt = ForStmt { + captures: vec!["i".to_string()], + range: ForRange::Range { + start: Expr::IntLit(IntLit { value: 0, suffix: None, location: None }), + end: Expr::IntLit(IntLit { value: 5, suffix: None, location: None }), + }, + body: Block { + label: None, + attrs: vec![], + statements: vec![ + // sum = sum + i + Stmt::Assign(AssignStmt { + target: Expr::Ident(Ident { name: "sum".to_string(), location: None }), + op: crate::ast::AssignOp::Assign, + value: Expr::Binary(Box::new(crate::ast::BinaryExpr { + left: Expr::Ident(Ident { name: "sum".to_string(), location: None }), + op: BinaryOp::Add, + right: Expr::Ident(Ident { name: "i".to_string(), location: None }), + location: None, + })), + location: None, + }), + ], + trailing_expr: None, + location: None, + }, + is_inline: true, + label: None, + location: None, + }; + + // Initialize sum + eval.context.define("sum", ComptimeValue::Int(0)); + + // Execute the for loop + eval.eval_stmt(&Stmt::For(for_stmt)).unwrap(); + + // Check result: 0 + 1 + 2 + 3 + 4 = 10 + let result = eval.context.lookup("sum").unwrap(); + assert_eq!(*result, ComptimeValue::Int(10), "Sum should be 10"); + } + + #[test] + fn test_fraction_division_returns_rational() { + // Integer division that's not exact should return Rational + // This is critical for angle expressions like `1/4 turns` + let eval = ComptimeEvaluator::new(); + + // 1/4 should be Rational(1/4), not 0 or Float + let one = ComptimeValue::Int(1); + let four = ComptimeValue::Int(4); + let result = eval.eval_div(&one, &four).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 4)), "1/4 should be Rational(1/4)"); + // Verify it converts to correct float + assert_eq!(result.as_float(), Some(0.25), "1/4 as float should be 0.25"); + + // 1/8 should be Rational(1/8) + let eight = ComptimeValue::Int(8); + let result = eval.eval_div(&one, &eight).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 8)), "1/8 should be Rational(1/8)"); + assert_eq!(result.as_float(), Some(0.125), "1/8 as float should be 0.125"); + + // 4/2 is exact, should return Int + let two = ComptimeValue::Int(2); + let result = eval.eval_div(&four, &two).unwrap(); + assert_eq!(result, ComptimeValue::Int(2), "4/2 should be Int(2)"); + + // 10/5 is exact, should return Int + let ten = ComptimeValue::Int(10); + let five = ComptimeValue::Int(5); + let result = eval.eval_div(&ten, &five).unwrap(); + assert_eq!(result, ComptimeValue::Int(2), "10/5 should be Int(2)"); + } + + #[test] + fn test_rational_arithmetic() { + let eval = ComptimeEvaluator::new(); + + // 1/4 + 1/4 = 1/2 + let quarter = ComptimeValue::Rational(Rational::new(1, 4)); + let result = eval.eval_add(&quarter, &quarter).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 2))); + + // 1/2 - 1/4 = 1/4 + let half = ComptimeValue::Rational(Rational::new(1, 2)); + let result = eval.eval_sub(&half, &quarter).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 4))); + + // 1/4 * 2 = 1/2 + let two = ComptimeValue::Int(2); + let result = eval.eval_mul(&quarter, &two).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 2))); + + // 1/2 / 2 = 1/4 + let result = eval.eval_div(&half, &two).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 4))); + + // 1/3 + 1/3 + 1/3 = 1 + let third = ComptimeValue::Rational(Rational::new(1, 3)); + let two_thirds = eval.eval_add(&third, &third).unwrap(); + let result = eval.eval_add(&two_thirds, &third).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 1))); + } + + #[test] + fn test_rational_comparison() { + let eval = ComptimeEvaluator::new(); + + let quarter = ComptimeValue::Rational(Rational::new(1, 4)); + let half = ComptimeValue::Rational(Rational::new(1, 2)); + let one = ComptimeValue::Int(1); + + // 1/4 < 1/2 + assert_eq!( + eval.eval_lt(&quarter, &half).unwrap(), + ComptimeValue::Bool(true) + ); + + // 1/4 < 1 + assert_eq!( + eval.eval_lt(&quarter, &one).unwrap(), + ComptimeValue::Bool(true) + ); + + // 1/2 > 1/4 + assert_eq!( + eval.eval_gt(&half, &quarter).unwrap(), + ComptimeValue::Bool(true) + ); + } + + #[test] + fn test_rational_negation() { + let eval = ComptimeEvaluator::new(); + + let quarter = ComptimeValue::Rational(Rational::new(1, 4)); + let result = eval.eval_unary_op(UnaryOp::Neg, &quarter).unwrap(); + assert_eq!(result, ComptimeValue::Rational(Rational::new(-1, 4))); + } + + // ========================================================================= + // Advanced Builtin Tests (@type_info, @field_names, @enum_fields) + // ========================================================================= + + #[test] + fn test_type_info_kind() { + use crate::semantic::Type; + + // Test primitive types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Bool), + TypeInfoKind::Primitive + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::UInt { bits: BitWidth::must(32) }), + TypeInfoKind::Primitive + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::F64), + TypeInfoKind::Primitive + ); + + // Test compound types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Array { + element: Box::new(Type::Bool), + size: Some(4) + }), + TypeInfoKind::Array + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Slice { + element: Box::new(Type::Bool) + }), + TypeInfoKind::Slice + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Optional { + inner: Box::new(Type::Bool) + }), + TypeInfoKind::Optional + ); + + // Test user-defined types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Struct { + name: "Point".to_string(), + fields: vec![ + ("x".to_string(), Type::F64), + ("y".to_string(), Type::F64), + ], + }), + TypeInfoKind::Struct + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Enum { + name: "Color".to_string(), + variants: vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()], + }), + TypeInfoKind::Enum + ); + + // Test special types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Unit), + TypeInfoKind::Unit + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Never), + TypeInfoKind::Never + ); + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Type), + TypeInfoKind::Type + ); + + // Test quantum types + assert_eq!( + ComptimeEvaluator::get_type_info_kind(&Type::Qubit), + TypeInfoKind::Quantum + ); + } + + #[test] + fn test_type_info_kind_as_str() { + assert_eq!(TypeInfoKind::Primitive.as_str(), "primitive"); + assert_eq!(TypeInfoKind::Array.as_str(), "array"); + assert_eq!(TypeInfoKind::Struct.as_str(), "struct"); + assert_eq!(TypeInfoKind::Enum.as_str(), "enum"); + assert_eq!(TypeInfoKind::Optional.as_str(), "optional"); + assert_eq!(TypeInfoKind::Quantum.as_str(), "quantum"); + } + + #[test] + fn test_resolve_type_from_expr_primitives() { + use crate::ast::{Ident, SourceLocation}; + + let eval = ComptimeEvaluator::new(); + + // Test primitive type resolution + let bool_expr = Expr::Ident(Ident { + name: "bool".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.resolve_type_from_expr(&bool_expr).unwrap(); + assert_eq!(result, Type::Bool); + + let u32_expr = Expr::Ident(Ident { + name: "u32".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.resolve_type_from_expr(&u32_expr).unwrap(); + assert_eq!(result, Type::UInt { bits: BitWidth::must(32) }); + + let f64_expr = Expr::Ident(Ident { + name: "f64".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.resolve_type_from_expr(&f64_expr).unwrap(); + assert_eq!(result, Type::F64); + } + + #[test] + fn test_eval_type_info_primitive() { + use crate::ast::{Ident, SourceLocation}; + + let eval = ComptimeEvaluator::new(); + + let u32_expr = Expr::Ident(Ident { + name: "u32".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.eval_type_info(&u32_expr).unwrap(); + + // Should be a struct with kind and name + if let ComptimeValue::Struct { name, fields } = result { + assert_eq!(name, "TypeInfo"); + assert_eq!( + fields.get("kind"), + Some(&ComptimeValue::String("primitive".to_string())) + ); + // Name should be "u32" + if let Some(ComptimeValue::String(type_name)) = fields.get("name") { + assert!(type_name.contains("u32") || type_name.contains("UInt")); + } + } else { + panic!("Expected TypeInfo struct"); + } + } + + #[test] + fn test_eval_field_names_struct() { + use crate::ast::{Ident, SourceLocation}; + + let mut eval = ComptimeEvaluator::new(); + + // Register a struct type in context + eval.context.define( + "Point", + ComptimeValue::Type(Type::Struct { + name: "Point".to_string(), + fields: vec![ + ("x".to_string(), Type::F64), + ("y".to_string(), Type::F64), + ("z".to_string(), Type::F64), + ], + }), + ); + + let point_expr = Expr::Ident(Ident { + name: "Point".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.eval_field_names(&point_expr).unwrap(); + + // Should be an array of strings + if let ComptimeValue::Array(names) = result { + assert_eq!(names.len(), 3); + assert_eq!(names[0], ComptimeValue::String("x".to_string())); + assert_eq!(names[1], ComptimeValue::String("y".to_string())); + assert_eq!(names[2], ComptimeValue::String("z".to_string())); + } else { + panic!("Expected array of field names"); + } + } + + #[test] + fn test_eval_enum_fields() { + use crate::ast::{Ident, SourceLocation}; + + let mut eval = ComptimeEvaluator::new(); + + // Register an enum type in context + eval.context.define( + "Color", + ComptimeValue::Type(Type::Enum { + name: "Color".to_string(), + variants: vec![ + "Red".to_string(), + "Green".to_string(), + "Blue".to_string(), + ], + }), + ); + + let color_expr = Expr::Ident(Ident { + name: "Color".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.eval_enum_fields(&color_expr).unwrap(); + + // Should be an array of strings + if let ComptimeValue::Array(variants) = result { + assert_eq!(variants.len(), 3); + assert_eq!(variants[0], ComptimeValue::String("Red".to_string())); + assert_eq!(variants[1], ComptimeValue::String("Green".to_string())); + assert_eq!(variants[2], ComptimeValue::String("Blue".to_string())); + } else { + panic!("Expected array of enum variants"); + } + } + + #[test] + fn test_eval_type_from_info() { + use crate::ast::{Ident, SourceLocation}; + + let mut eval = ComptimeEvaluator::new(); + + // Create a TypeInfo struct for an array type + let mut fields = BTreeMap::new(); + fields.insert("kind".to_string(), ComptimeValue::String("array".to_string())); + fields.insert("name".to_string(), ComptimeValue::String("[4]u32".to_string())); + fields.insert("element".to_string(), ComptimeValue::Type(Type::UInt { bits: BitWidth::must(32) })); + fields.insert("size".to_string(), ComptimeValue::Uint(4)); + + // Store the TypeInfo in context + eval.context.define( + "my_info", + ComptimeValue::Struct { + name: "TypeInfo".to_string(), + fields, + }, + ); + + let info_expr = Expr::Ident(Ident { + name: "my_info".to_string(), + location: Some(SourceLocation::default()), + }); + let result = eval.eval_type_from_info(&info_expr).unwrap(); + + // Should be an array type + if let ComptimeValue::Type(Type::Array { element, size }) = result { + assert_eq!(*element, Type::UInt { bits: BitWidth::must(32) }); + assert_eq!(size, Some(4)); + } else { + panic!("Expected array type, got {:?}", result); + } + } + + /// Test that comptime function memoization works correctly. + /// Calling the same function with the same args should return cached result. + #[test] + fn test_comptime_memoization() { + use crate::ast::{Block, FnDecl, Ident, IntLit, Param}; + + let mut eval = ComptimeEvaluator::new(); + + // Define a simple function: fn add_10(n: i32) -> i32 { n + 10 } + let add_10_func = FnDecl { + name: "add_10".to_string(), + params: vec![Param { + name: "n".to_string(), + ty: TypeExpr::Named(crate::ast::TypePath { + segments: vec!["i32".to_string()], + location: None, + }), + is_comptime: true, + location: None, + }], + return_type: Some(TypeExpr::Named(crate::ast::TypePath { + segments: vec!["i32".to_string()], + location: None, + })), + body: Block { + label: None, + attrs: vec![], + statements: vec![], + trailing_expr: Some(Box::new(Expr::Binary(Box::new(crate::ast::BinaryExpr { + left: Expr::Ident(Ident { + name: "n".to_string(), + location: None, + }), + op: BinaryOp::Add, + right: Expr::IntLit(IntLit { + value: 10, + suffix: None, + location: None, + }), + location: None, + })))), + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + eval.context.define("add_10", ComptimeValue::Function(Box::new(add_10_func))); + + // Call the function with argument 5 + let call1 = crate::ast::CallExpr { + callee: Expr::Ident(Ident { + name: "add_10".to_string(), + location: None, + }), + args: vec![Expr::IntLit(IntLit { + value: 5, + suffix: None, + location: None, + })], + location: None, + }; + + // First call - should compute and cache + let result1 = eval.eval_expr(&Expr::Call(Box::new(call1.clone()))).unwrap(); + assert_eq!(result1, ComptimeValue::Int(15)); + + // Verify it's in the cache + let cache_key = ( + "add_10".to_string(), + ComptimeEvaluator::serialize_args_for_cache(&[ComptimeValue::Int(5)]), + ); + assert!( + eval.memo_cache.contains_key(&cache_key), + "Result should be cached after first call" + ); + + // Second call with same args - should return cached value + let result2 = eval.eval_expr(&Expr::Call(Box::new(call1))).unwrap(); + assert_eq!(result2, ComptimeValue::Int(15)); + + // Call with different args - should compute new result + let call2 = crate::ast::CallExpr { + callee: Expr::Ident(Ident { + name: "add_10".to_string(), + location: None, + }), + args: vec![Expr::IntLit(IntLit { + value: 20, + suffix: None, + location: None, + })], + location: None, + }; + let result3 = eval.eval_expr(&Expr::Call(Box::new(call2))).unwrap(); + assert_eq!(result3, ComptimeValue::Int(30)); + + // Both entries should be in cache + assert_eq!(eval.memo_cache.len(), 2); + } +} diff --git a/exp/zlup/src/config.rs b/exp/zlup/src/config.rs new file mode 100644 index 000000000..dc7b8cf66 --- /dev/null +++ b/exp/zlup/src/config.rs @@ -0,0 +1,321 @@ +//! Build configuration for Zlup projects. +//! +//! Zlup uses a `zlup.toml` file to configure project settings: +//! +//! ```toml +//! [package] +//! name = "my-quantum-program" +//! version = "0.1.0" +//! entry = "src/main.zlp" +//! +//! [build] +//! strict = false +//! target = "slr" +//! ``` +//! +//! ## Package Section +//! +//! - `name`: Project name (required) +//! - `version`: Semantic version (required) +//! - `entry`: Entry point file, relative to config file (default: "main.zlp") +//! - `description`: Optional project description +//! - `authors`: Optional list of authors +//! +//! ## Build Section +//! +//! - `strict`: Enable strict mode / NASA Power of 10 checks (default: false) +//! - `target`: Default compilation target: "slr" or "hugr" (default: "slr") +//! - `output_dir`: Output directory for compiled files (default: "build") + +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +/// Configuration file name. +pub const CONFIG_FILE_NAME: &str = "zlup.toml"; + +/// Errors that can occur when loading configuration. +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("failed to read config file '{path}': {source}")] + ReadError { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse config file '{path}': {source}")] + ParseError { + path: String, + #[source] + source: toml::de::Error, + }, + + #[error("config file not found: searched from '{start_dir}' to filesystem root")] + NotFound { start_dir: String }, + + #[error("invalid target '{target}' in config - expected 'slr' or 'hugr'")] + InvalidTarget { target: String }, +} + +/// Complete project configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + /// Package metadata. + pub package: PackageConfig, + + /// Build settings. + #[serde(default)] + pub build: BuildConfig, +} + +/// Package metadata section. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PackageConfig { + /// Project name. + pub name: String, + + /// Project version (semver). + pub version: String, + + /// Entry point file, relative to config file. + #[serde(default = "default_entry")] + pub entry: PathBuf, + + /// Optional project description. + pub description: Option, + + /// Optional list of authors. + #[serde(default)] + pub authors: Vec, +} + +fn default_entry() -> PathBuf { + PathBuf::from("main.zlp") +} + +/// Build settings section. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildConfig { + /// Enable strict mode (NASA Power of 10 checks). + #[serde(default)] + pub strict: bool, + + /// Default compilation target. + #[serde(default)] + pub target: TargetConfig, + + /// Output directory for compiled files. + #[serde(default = "default_output_dir")] + pub output_dir: PathBuf, +} + +fn default_output_dir() -> PathBuf { + PathBuf::from("build") +} + +impl Default for BuildConfig { + fn default() -> Self { + Self { + strict: false, + target: TargetConfig::default(), + output_dir: default_output_dir(), + } + } +} + +/// Compilation target configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TargetConfig { + /// SLR-AST JSON (Python/PECOS bridge). + #[default] + Slr, + /// HUGR (hardware/experiments). + Hugr, +} + +impl std::fmt::Display for TargetConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TargetConfig::Slr => write!(f, "slr"), + TargetConfig::Hugr => write!(f, "hugr"), + } + } +} + +impl Config { + /// Load configuration from a specific file path. + pub fn from_file(path: &Path) -> Result { + let content = fs::read_to_string(path).map_err(|e| ConfigError::ReadError { + path: path.display().to_string(), + source: e, + })?; + + toml::from_str(&content).map_err(|e| ConfigError::ParseError { + path: path.display().to_string(), + source: e, + }) + } + + /// Find and load configuration by searching upward from the given directory. + /// + /// Searches for `zlup.toml` starting from `start_dir` and moving up to + /// parent directories until found or reaching the filesystem root. + pub fn find_and_load(start_dir: &Path) -> Result<(Self, PathBuf), ConfigError> { + let config_path = Self::find_config_file(start_dir)?; + let config = Self::from_file(&config_path)?; + Ok((config, config_path)) + } + + /// Find the configuration file by searching upward from the given directory. + pub fn find_config_file(start_dir: &Path) -> Result { + let mut current = start_dir.to_path_buf(); + + loop { + let config_path = current.join(CONFIG_FILE_NAME); + if config_path.exists() { + return Ok(config_path); + } + + if !current.pop() { + return Err(ConfigError::NotFound { + start_dir: start_dir.display().to_string(), + }); + } + } + } + + /// Get the project root directory (directory containing zlup.toml). + pub fn project_root(config_path: &Path) -> PathBuf { + config_path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) + } + + /// Get the absolute path to the entry file. + pub fn entry_path(&self, config_path: &Path) -> PathBuf { + let root = Self::project_root(config_path); + root.join(&self.package.entry) + } + + /// Get the absolute path to the output directory. + pub fn output_path(&self, config_path: &Path) -> PathBuf { + let root = Self::project_root(config_path); + root.join(&self.build.output_dir) + } + + /// Create a minimal configuration for a new project. + pub fn new(name: &str) -> Self { + Self { + package: PackageConfig { + name: name.to_string(), + version: "0.1.0".to_string(), + entry: default_entry(), + description: None, + authors: Vec::new(), + }, + build: BuildConfig::default(), + } + } + + /// Serialize the configuration to TOML string. + pub fn to_toml(&self) -> Result { + toml::to_string_pretty(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_minimal_config() { + let toml = r#" + [package] + name = "test-project" + version = "0.1.0" + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.package.name, "test-project"); + assert_eq!(config.package.version, "0.1.0"); + assert_eq!(config.package.entry, PathBuf::from("main.zlp")); + assert!(!config.build.strict); + assert_eq!(config.build.target, TargetConfig::Slr); + } + + #[test] + fn test_parse_full_config() { + let toml = r#" + [package] + name = "quantum-app" + version = "1.2.3" + entry = "src/main.zlp" + description = "A quantum application" + authors = ["Alice", "Bob"] + + [build] + strict = true + target = "hugr" + output_dir = "out" + "#; + + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.package.name, "quantum-app"); + assert_eq!(config.package.version, "1.2.3"); + assert_eq!(config.package.entry, PathBuf::from("src/main.zlp")); + assert_eq!( + config.package.description, + Some("A quantum application".to_string()) + ); + assert_eq!(config.package.authors, vec!["Alice", "Bob"]); + assert!(config.build.strict); + assert_eq!(config.build.target, TargetConfig::Hugr); + assert_eq!(config.build.output_dir, PathBuf::from("out")); + } + + #[test] + fn test_target_config_serialization() { + // Test roundtrip through full config + let toml = r#" + [package] + name = "test" + version = "0.1.0" + [build] + target = "slr" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.build.target, TargetConfig::Slr); + + let toml = r#" + [package] + name = "test" + version = "0.1.0" + [build] + target = "hugr" + "#; + let config: Config = toml::from_str(toml).unwrap(); + assert_eq!(config.build.target, TargetConfig::Hugr); + } + + #[test] + fn test_new_config() { + let config = Config::new("my-project"); + assert_eq!(config.package.name, "my-project"); + assert_eq!(config.package.version, "0.1.0"); + assert_eq!(config.package.entry, PathBuf::from("main.zlp")); + assert!(!config.build.strict); + } + + #[test] + fn test_config_to_toml() { + let config = Config::new("test"); + let toml_str = config.to_toml().unwrap(); + assert!(toml_str.contains("name = \"test\"")); + assert!(toml_str.contains("version = \"0.1.0\"")); + } +} diff --git a/exp/zlup/src/docgen.rs b/exp/zlup/src/docgen.rs new file mode 100644 index 000000000..832e0f8e0 --- /dev/null +++ b/exp/zlup/src/docgen.rs @@ -0,0 +1,534 @@ +//! Documentation generator for Zluppy programs. +//! +//! Extracts doc comments from AST nodes and produces Markdown documentation. + +use crate::ast::*; + +/// Configuration for documentation generation. +#[derive(Debug, Clone)] +pub struct DocConfig { + /// Include private (non-pub) items + pub include_private: bool, + /// Show source locations in output + pub show_locations: bool, +} + +impl Default for DocConfig { + fn default() -> Self { + Self { + include_private: false, + show_locations: false, + } + } +} + +/// Kind of documented item. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum DocItemKind { + Function, + ExternFunction, + Struct, + Enum, + Union, + ErrorSet, + FaultSet, + Constant, + Test, +} + +impl DocItemKind { + /// Section heading for this kind. + pub fn section_heading(&self) -> &'static str { + match self { + DocItemKind::Function => "Functions", + DocItemKind::ExternFunction => "Extern Functions", + DocItemKind::Struct => "Structs", + DocItemKind::Enum => "Enums", + DocItemKind::Union => "Unions", + DocItemKind::ErrorSet => "Error Sets", + DocItemKind::FaultSet => "Fault Sets", + DocItemKind::Constant => "Constants", + DocItemKind::Test => "Tests", + } + } +} + +/// A documented item extracted from the AST. +#[derive(Debug, Clone)] +pub struct DocItem { + /// Kind of item + pub kind: DocItemKind, + /// Item name + pub name: String, + /// Signature or declaration line + pub signature: String, + /// Doc comment text (may be multi-line) + pub doc: Option, + /// Child items (struct fields, enum variants, etc.) + pub children: Vec, + /// Whether this item is public + pub is_pub: bool, + /// Source location + pub location: Option, +} + +/// A child of a documented item (field, variant, etc.). +#[derive(Debug, Clone)] +pub struct DocChild { + /// Child name + pub name: String, + /// Type or value description + pub description: String, + /// Doc comment + pub doc: Option, +} + +/// Extract documented items from a program AST. +pub fn extract_doc_items(program: &Program, config: &DocConfig) -> Vec { + let mut items = Vec::new(); + + for decl in &program.declarations { + match decl { + TopLevelDecl::Fn(f) => { + if !config.include_private && !f.is_pub { + continue; + } + items.push(extract_fn_doc(f)); + } + TopLevelDecl::ExternFn(f) => { + if !config.include_private && !f.is_pub { + continue; + } + items.push(extract_extern_fn_doc(f)); + } + TopLevelDecl::Struct(s) => { + if !config.include_private && !s.is_pub { + continue; + } + items.push(extract_struct_doc(s)); + } + TopLevelDecl::Enum(e) => { + if !config.include_private && !e.is_pub { + continue; + } + items.push(extract_enum_doc(e)); + } + TopLevelDecl::Union(u) => { + if !config.include_private && !u.is_pub { + continue; + } + items.push(extract_union_doc(u)); + } + TopLevelDecl::ErrorSet(e) => { + if !config.include_private && !e.is_pub { + continue; + } + items.push(extract_error_set_doc(e)); + } + TopLevelDecl::FaultSet(f) => { + if !config.include_private && !f.is_pub { + continue; + } + items.push(extract_fault_set_doc(f)); + } + TopLevelDecl::Binding(b) => { + if !config.include_private && !b.is_pub { + continue; + } + items.push(extract_binding_doc(b)); + } + TopLevelDecl::Test(t) => { + if config.include_private { + items.push(extract_test_doc(t)); + } + } + TopLevelDecl::DeclareGate(_) | TopLevelDecl::Gate(_) => { + // Custom gate declarations are not yet included in documentation + } + } + } + + items +} + +fn format_params(params: &[Param]) -> String { + params + .iter() + .map(|p| format!("{}: {}", p.name, format_type_expr(&p.ty))) + .collect::>() + .join(", ") +} + +fn format_type_expr(ty: &TypeExpr) -> String { + // Simplified type expression formatting + format!("{:?}", ty) + .chars() + .take(80) + .collect() +} + +fn extract_fn_doc(f: &FnDecl) -> DocItem { + let ret = f.return_type.as_ref() + .map(|t| format!(" -> {}", format_type_expr(t))) + .unwrap_or_default(); + let sig = format!("fn {}({}){}", f.name, format_params(&f.params), ret); + + DocItem { + kind: DocItemKind::Function, + name: f.name.clone(), + signature: sig, + doc: f.doc_comment.clone(), + children: Vec::new(), + is_pub: f.is_pub, + location: f.location.clone(), + } +} + +fn extract_extern_fn_doc(f: &ExternFnDecl) -> DocItem { + let ret = f.return_type.as_ref() + .map(|t| format!(" -> {}", format_type_expr(t))) + .unwrap_or_default(); + let sig = format!( + "extern \"{}\" fn {}({}){}", + f.calling_convention, f.name, format_params(&f.params), ret + ); + + DocItem { + kind: DocItemKind::ExternFunction, + name: f.name.clone(), + signature: sig, + doc: f.doc_comment.clone(), + children: Vec::new(), + is_pub: f.is_pub, + location: f.location.clone(), + } +} + +fn extract_struct_doc(s: &StructDecl) -> DocItem { + let children = s.fields.iter().map(|f| { + DocChild { + name: f.name.clone(), + description: format_type_expr(&f.ty), + doc: f.doc_comment.clone(), + } + }).collect(); + + DocItem { + kind: DocItemKind::Struct, + name: s.name.clone(), + signature: format!("struct {}", s.name), + doc: s.doc_comment.clone(), + children, + is_pub: s.is_pub, + location: s.location.clone(), + } +} + +fn extract_enum_doc(e: &EnumDecl) -> DocItem { + let children = e.variants.iter().map(|v| { + DocChild { + name: v.name.clone(), + description: String::new(), + doc: None, + } + }).collect(); + + DocItem { + kind: DocItemKind::Enum, + name: e.name.clone(), + signature: format!("enum {}", e.name), + doc: e.doc_comment.clone(), + children, + is_pub: e.is_pub, + location: e.location.clone(), + } +} + +fn extract_union_doc(u: &UnionDecl) -> DocItem { + let children = u.fields.iter().map(|f| { + let desc = f.ty.as_ref() + .map(|t| format_type_expr(t)) + .unwrap_or_default(); + DocChild { + name: f.name.clone(), + description: desc, + doc: None, + } + }).collect(); + + DocItem { + kind: DocItemKind::Union, + name: u.name.clone(), + signature: format!("union {}", u.name), + doc: u.doc_comment.clone(), + children, + is_pub: u.is_pub, + location: u.location.clone(), + } +} + +fn extract_error_set_doc(e: &ErrorSetDecl) -> DocItem { + let children = e.variants.iter().map(|v| { + DocChild { + name: v.name.clone(), + description: String::new(), + doc: None, + } + }).collect(); + + DocItem { + kind: DocItemKind::ErrorSet, + name: e.name.clone(), + signature: format!("{} := error {{ ... }}", e.name), + doc: e.doc_comment.clone(), + children, + is_pub: e.is_pub, + location: e.location.clone(), + } +} + +fn extract_fault_set_doc(f: &FaultSetDecl) -> DocItem { + let children = f.variants.iter().map(|v| { + DocChild { + name: v.name.clone(), + description: String::new(), + doc: None, + } + }).collect(); + + DocItem { + kind: DocItemKind::FaultSet, + name: f.name.clone(), + signature: format!("{} := fault {{ ... }}", f.name), + doc: f.doc_comment.clone(), + children, + is_pub: f.is_pub, + location: f.location.clone(), + } +} + +fn extract_binding_doc(b: &Binding) -> DocItem { + let sig = if b.is_mutable { + format!("mut {}", b.name) + } else { + b.name.clone() + }; + + DocItem { + kind: DocItemKind::Constant, + name: b.name.clone(), + signature: sig, + doc: b.doc_comment.clone(), + children: Vec::new(), + is_pub: b.is_pub, + location: b.location.clone(), + } +} + +fn extract_test_doc(t: &TestDecl) -> DocItem { + DocItem { + kind: DocItemKind::Test, + name: t.name.clone(), + signature: format!("test \"{}\"", t.name), + doc: None, + children: Vec::new(), + is_pub: false, + location: t.location.clone(), + } +} + +/// Generate Markdown documentation from extracted doc items. +pub fn generate_markdown(items: &[DocItem], module_name: &str) -> String { + let mut out = String::new(); + + out.push_str(&format!("# {}\n\n", module_name)); + + // Group by kind, in order + let mut kinds: Vec = items.iter().map(|i| i.kind.clone()).collect(); + kinds.sort(); + kinds.dedup(); + + for kind in kinds { + let kind_items: Vec<_> = items.iter().filter(|i| i.kind == kind).collect(); + if kind_items.is_empty() { + continue; + } + + out.push_str(&format!("## {}\n\n", kind.section_heading())); + + for item in kind_items { + out.push_str(&format!("### {}\n\n", item.name)); + out.push_str(&format!("```zluppy\n{}\n```\n\n", item.signature)); + + if let Some(ref doc) = item.doc { + out.push_str(doc.trim()); + out.push_str("\n\n"); + } + + if !item.children.is_empty() { + out.push_str("| Name | Type | Description |\n"); + out.push_str("|------|------|-------------|\n"); + for child in &item.children { + let doc_str = child.doc.as_deref().unwrap_or(""); + out.push_str(&format!( + "| `{}` | `{}` | {} |\n", + child.name, + child.description, + doc_str + )); + } + out.push('\n'); + } + } + } + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + // NOTE: The pest grammar currently treats `///` as a regular line comment + // (consumed by the implicit COMMENT rule), so doc_comment fields in AST + // nodes are always None. The docgen infrastructure is ready to use them + // once the grammar is fixed to distinguish doc comments from regular comments. + + #[test] + fn test_extract_pub_function() { + let source = r#" + pub fn add(a: u32, b: u32) -> u32 { + return a + b; + } + "#; + let program = parse(source).unwrap(); + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, DocItemKind::Function); + assert_eq!(items[0].name, "add"); + } + + #[test] + fn test_extract_constant_binding() { + let source = r#" + pub Point := struct { + x: f64, + y: f64, + }; + "#; + let program = parse(source).unwrap(); + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, DocItemKind::Constant); + assert_eq!(items[0].name, "Point"); + } + + #[test] + fn test_private_items_excluded() { + let source = r#" + pub fn visible() -> unit { return; } + fn hidden() -> unit { return; } + "#; + let program = parse(source).unwrap(); + + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + assert_eq!(items[0].name, "visible"); + } + + #[test] + fn test_private_items_included_with_flag() { + let source = r#" + pub fn visible() -> unit { return; } + fn hidden() -> unit { return; } + "#; + let program = parse(source).unwrap(); + + let config = DocConfig { + include_private: true, + ..Default::default() + }; + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 2); + } + + #[test] + fn test_no_doc_comment() { + let source = r#" + pub fn no_doc() -> unit { return; } + "#; + let program = parse(source).unwrap(); + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + // Doc comment is None since the grammar eats /// as regular comments + assert!(items[0].doc.is_none()); + } + + #[test] + fn test_extract_error_set() { + let source = r#" + pub MyError := error { Timeout, InvalidInput }; + "#; + let program = parse(source).unwrap(); + let config = DocConfig::default(); + let items = extract_doc_items(&program, &config); + assert_eq!(items.len(), 1); + assert_eq!(items[0].kind, DocItemKind::ErrorSet); + assert_eq!(items[0].children.len(), 2); + } + + #[test] + fn test_generate_markdown_basic() { + let items = vec![ + DocItem { + kind: DocItemKind::Function, + name: "main".to_string(), + signature: "fn main() -> unit".to_string(), + doc: Some("Entry point.".to_string()), + children: Vec::new(), + is_pub: true, + location: None, + }, + ]; + let md = generate_markdown(&items, "my_module"); + assert!(md.contains("# my_module")); + assert!(md.contains("## Functions")); + assert!(md.contains("### main")); + assert!(md.contains("Entry point.")); + } + + #[test] + fn test_generate_markdown_with_children() { + let items = vec![ + DocItem { + kind: DocItemKind::Struct, + name: "Point".to_string(), + signature: "struct Point".to_string(), + doc: None, + children: vec![ + DocChild { + name: "x".to_string(), + description: "f64".to_string(), + doc: None, + }, + DocChild { + name: "y".to_string(), + description: "f64".to_string(), + doc: None, + }, + ], + is_pub: true, + location: None, + }, + ]; + let md = generate_markdown(&items, "geometry"); + assert!(md.contains("## Structs")); + assert!(md.contains("| `x` |")); + assert!(md.contains("| `y` |")); + } +} diff --git a/exp/zlup/src/formatter.rs b/exp/zlup/src/formatter.rs new file mode 100644 index 000000000..9938ac94e --- /dev/null +++ b/exp/zlup/src/formatter.rs @@ -0,0 +1,536 @@ +//! Code formatter for Zlup. +//! +//! Provides canonical formatting for Zlup source code. +//! +//! ## Usage +//! +//! ```rust +//! use zlup::formatter::{format, FormatOptions}; +//! +//! let source = "fn main()->unit{var x=1;}"; +//! let formatted = format(source, &FormatOptions::default()); +//! ``` +//! +//! ## Implementation +//! +//! The formatter uses an AST-based approach when the source is valid Zlup code. +//! For code that fails to parse, it falls back to a text-based formatter that +//! handles basic indentation and spacing. + +use crate::pretty::{self, PrettyOptions}; + +/// Formatting options. +#[derive(Debug, Clone)] +pub struct FormatOptions { + /// Use spaces instead of tabs. + pub use_spaces: bool, + /// Number of spaces per indent level (if using spaces). + pub indent_size: usize, + /// Maximum line length (for future line wrapping). + pub max_line_length: usize, + /// Use AST-based formatting (falls back to text-based if parse fails). + pub use_ast: bool, +} + +impl Default for FormatOptions { + fn default() -> Self { + Self { + use_spaces: true, + indent_size: 4, + max_line_length: 100, + use_ast: true, + } + } +} + +impl From<&FormatOptions> for PrettyOptions { + fn from(opts: &FormatOptions) -> Self { + PrettyOptions { + use_spaces: opts.use_spaces, + indent_size: opts.indent_size, + max_line_length: opts.max_line_length, + } + } +} + +/// Format Zlup source code. +/// +/// By default, this uses AST-based formatting for accurate results. +/// If the source fails to parse, it falls back to text-based formatting. +pub fn format(source: &str, options: &FormatOptions) -> String { + // Try AST-based formatting first if enabled + if options.use_ast { + let pretty_opts = PrettyOptions::from(options); + if let Some(formatted) = pretty::format_source(source, &pretty_opts) { + return formatted; + } + } + + // Fall back to text-based formatting + format_text_based(source, options) +} + +/// Text-based formatter (fallback for unparseable code). +fn format_text_based(source: &str, options: &FormatOptions) -> String { + let indent_str = if options.use_spaces { + " ".repeat(options.indent_size) + } else { + "\t".to_string() + }; + + let mut result = String::new(); + let mut indent_level: i32 = 0; + + for line in source.lines() { + let trimmed = line.trim(); + + // Skip empty lines but preserve one blank line + if trimmed.is_empty() { + if !result.ends_with("\n\n") { + result.push('\n'); + } + continue; + } + + // Adjust indent for closing braces at start of line + let starts_with_close = trimmed.starts_with('}') || trimmed.starts_with(')'); + if starts_with_close && indent_level > 0 { + indent_level -= 1; + } + + // Write indentation + for _ in 0..indent_level { + result.push_str(&indent_str); + } + + // Format the line content + let formatted_line = format_line(trimmed); + result.push_str(&formatted_line); + result.push('\n'); + + // Adjust indent for next line based on braces in this line + let mut in_string = false; + let mut prev_char = '\0'; + for ch in trimmed.chars() { + match ch { + '"' if prev_char != '\\' => in_string = !in_string, + '{' | '(' if !in_string => indent_level += 1, + '}' | ')' if !in_string && !starts_with_close => { + indent_level = (indent_level - 1).max(0); + } + _ => {} + } + prev_char = ch; + } + } + + // Ensure file ends with newline + if !result.ends_with('\n') { + result.push('\n'); + } + + result +} + +/// Format a single line (handles spacing around operators). +fn format_line(line: &str) -> String { + let mut result = String::new(); + let mut chars = line.chars().peekable(); + let mut in_string = false; + let mut prev_char = '\0'; + + while let Some(ch) = chars.next() { + // Track string state + if ch == '"' && prev_char != '\\' { + in_string = !in_string; + } + + if in_string { + result.push(ch); + prev_char = ch; + continue; + } + + match ch { + // Ensure space after comma + ',' => { + result.push(','); + if chars.peek() != Some(&' ') && chars.peek() != Some(&'\n') { + result.push(' '); + } + } + // Ensure space around '=' (but not ==, !=, <=, >=, =>) + '=' => { + let next = chars.peek().copied(); + if next == Some('=') || next == Some('>') { + // Part of ==, =>, don't add space before + if !result.ends_with(' ') + && !result.ends_with('!') + && !result.ends_with('<') + && !result.ends_with('>') + { + result.push(' '); + } + result.push('='); + } else if prev_char == '!' || prev_char == '<' || prev_char == '>' || prev_char == '=' + { + // Part of !=, <=, >=, == + result.push('='); + if chars.peek() != Some(&' ') { + result.push(' '); + } + } else { + // Standalone = + if !result.ends_with(' ') { + result.push(' '); + } + result.push('='); + if chars.peek() != Some(&' ') && chars.peek().is_some() { + result.push(' '); + } + } + } + // Ensure space after colon in type annotations + ':' => { + result.push(':'); + if chars.peek() != Some(&' ') && chars.peek() != Some(&':') { + result.push(' '); + } + } + // Ensure space around -> for return types + '-' => { + if chars.peek() == Some(&'>') { + if !result.ends_with(' ') { + result.push(' '); + } + result.push('-'); + result.push(chars.next().unwrap()); + if chars.peek() != Some(&' ') { + result.push(' '); + } + } else { + result.push(ch); + } + } + // Opening brace: ensure space before + '{' => { + if !result.ends_with(' ') && !result.is_empty() { + result.push(' '); + } + result.push('{'); + } + // Other characters pass through + _ => result.push(ch), + } + + prev_char = ch; + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_formatting() { + let source = "fn main()->unit{var x=1;}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("fn main() -> unit")); + assert!(formatted.contains("var x = 1;")); + } + + #[test] + fn test_indentation() { + let source = "fn main() -> unit {\nvar x = 1;\n}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains(" var x = 1;")); + } + + #[test] + fn test_preserve_strings() { + let source = r#"s := "hello, world";"#; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains(r#""hello, world""#)); + } + + #[test] + fn test_comma_spacing() { + let source = "fn foo(a:u32,b:u32) -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("a: u32, b: u32")); + } + + #[test] + fn test_arrow_spacing() { + let source = "fn foo()->void{}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains(" -> ")); + } + + #[test] + fn test_nested_braces() { + let source = "fn main() -> unit {\nif (true) {\nx = 1;\n}\n}"; + let formatted = format(source, &FormatOptions::default()); + // Should have proper nested indentation + let lines: Vec<&str> = formatted.lines().collect(); + assert!(lines.iter().any(|l| l.starts_with(" x = 1;"))); + } + + #[test] + fn test_blank_line_preservation() { + let source = "fn a() -> unit {}\n\n\n\nfn b() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + // Should collapse multiple blank lines to one + assert!(!formatted.contains("\n\n\n")); + } + + #[test] + fn test_tabs_option() { + let source = "fn main() -> unit {\nvar x = 1;\n}"; + let options = FormatOptions { + use_spaces: false, + ..Default::default() + }; + let formatted = format(source, &options); + assert!(formatted.contains("\tvar x = 1;")); + } + + #[test] + fn test_gate_expression_spacing() { + let source = "h q[0];"; + let formatted = format(source, &FormatOptions::default()); + // Should normalize spacing + assert!(formatted.contains("h")); + assert!(formatted.contains("q[0]")); + } + + #[test] + fn test_tick_block_formatting() { + let source = "tick{\nh q[0];\ncx (q[0],q[1]);\n}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("tick {")); + // Inner statements should be indented + let lines: Vec<&str> = formatted.lines().collect(); + assert!(lines.iter().any(|l| l.starts_with(" h") || l.starts_with(" cx"))); + } + + #[test] + fn test_type_annotation_spacing() { + let source = "x:u32 := 5;"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("x: u32")); + } + + #[test] + fn test_comparison_operators() { + let source = "if (x==1 && y!=2) {}"; + let formatted = format(source, &FormatOptions::default()); + // The formatter preserves comparison operators + assert!(formatted.contains("x") && formatted.contains("1")); + assert!(formatted.contains("y") && formatted.contains("2")); + } + + #[test] + fn test_walrus_operator() { + let source = "x := 5;"; + let formatted = format(source, &FormatOptions::default()); + eprintln!("Formatted: {:?}", formatted); + // The formatter handles := - just verify content is preserved + assert!(formatted.contains("x")); + assert!(formatted.contains("5")); + } + + #[test] + fn test_tuple_formatting() { + let source = "t := (1,2,3);"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("(1, 2, 3)")); + } + + #[test] + fn test_for_loop_formatting() { + let source = "for i in 0..10 {\nx := i;\n}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("for")); + let lines: Vec<&str> = formatted.lines().collect(); + assert!(lines.iter().any(|l| l.contains("x") && l.contains("i"))); + } + + #[test] + fn test_return_statement() { + let source = "return unit;"; + let formatted = format(source, &FormatOptions::default()); + // Should normalize whitespace + assert!(formatted.contains("return")); + assert!(formatted.contains("unit")); + } + + #[test] + fn test_function_with_attributes() { + let source = "@inline\nfn foo() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("@inline")); + assert!(formatted.contains("fn foo()")); + } + + #[test] + fn test_multiline_function_params() { + let source = "fn foo(a: u32,\nb: u32,\nc: u32) -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + // Should format each param + assert!(formatted.contains("a: u32")); + assert!(formatted.contains("b: u32")); + assert!(formatted.contains("c: u32")); + } + + #[test] + fn test_else_if_chain() { + let source = "if (a) {\nx := 1;\n} else if (b) {\nx := 2;\n} else {\nx := 3;\n}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("if (a)")); + assert!(formatted.contains("else if (b)")); + assert!(formatted.contains("else {")); + } + + #[test] + fn test_empty_block() { + let source = "fn empty() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("{}")); + } + + #[test] + fn test_trailing_newline() { + let source = "fn foo() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.ends_with('\n')); + } + + #[test] + fn test_no_triple_trailing_newline() { + let source = "fn foo() -> unit {}\n\n\n"; + let formatted = format(source, &FormatOptions::default()); + // Should not have 3+ consecutive newlines + assert!(!formatted.ends_with("\n\n\n")); + } + + // ========================================================================= + // Critical Edge Cases + // ========================================================================= + + #[test] + fn test_deeply_nested_structure() { + let source = "fn main() -> unit {\nif (a) {\nif (b) {\nif (c) {\nx := 1;\n}\n}\n}\n}"; + let formatted = format(source, &FormatOptions::default()); + // Should have increasing indentation + assert!(formatted.contains("if (a)") || formatted.contains("if(a)")); + // Innermost should have 12 spaces (3 levels) + let lines: Vec<&str> = formatted.lines().collect(); + assert!(lines.iter().any(|l| l.starts_with(" "))); + } + + #[test] + fn test_string_with_special_chars() { + let source = r#"s := "hello\nworld\t!";"#; + let formatted = format(source, &FormatOptions::default()); + // Escape sequences should be preserved + assert!(formatted.contains(r#"\n"#)); + assert!(formatted.contains(r#"\t"#)); + } + + #[test] + fn test_string_with_braces() { + let source = r#"s := "{ not a block }";"#; + let formatted = format(source, &FormatOptions::default()); + // Braces in strings should not affect indentation + assert!(formatted.contains(r#"{ not a block }"#)); + // Should still be at base indentation + assert!(formatted.starts_with("s") || formatted.starts_with("\n")); + } + + #[test] + fn test_comment_preservation() { + // Comments are not preserved in AST-based formatting + // Use text-based formatting to preserve comments + let source = "// comment\nfn main() -> unit {}"; + let options = FormatOptions { + use_ast: false, + ..Default::default() + }; + let formatted = format(source, &options); + assert!(formatted.contains("// comment")); + } + + #[test] + fn test_multiple_functions() { + let source = "fn a() -> unit {}\nfn b() -> unit {}\nfn c() -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("fn a()")); + assert!(formatted.contains("fn b()")); + assert!(formatted.contains("fn c()")); + } + + #[test] + fn test_custom_indent_size() { + let source = "fn main() -> unit {\nx := 1;\n}"; + let options = FormatOptions { + indent_size: 2, + ..Default::default() + }; + let formatted = format(source, &options); + assert!(formatted.contains(" x")); // 2 spaces + assert!(!formatted.contains(" x")); // not 4 spaces + } + + #[test] + fn test_binary_operators() { + let source = "x := a+b*c-d/e;"; + let formatted = format(source, &FormatOptions::default()); + // Should preserve the expression + assert!(formatted.contains("a")); + assert!(formatted.contains("b")); + assert!(formatted.contains("c")); + } + + #[test] + fn test_array_literal() { + let source = "arr := [1,2,3,4,5];"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("[1, 2, 3, 4, 5]") || formatted.contains("[1,2,3,4,5]")); + } + + #[test] + fn test_method_chain() { + let source = "x := obj.method1().method2().method3();"; + let formatted = format(source, &FormatOptions::default()); + assert!(formatted.contains("method1")); + assert!(formatted.contains("method2")); + assert!(formatted.contains("method3")); + } + + #[test] + fn test_long_parameter_list() { + let source = "fn foo(a:u32,b:u32,c:u32,d:u32,e:u32) -> unit {}"; + let formatted = format(source, &FormatOptions::default()); + // Should add spaces after commas + assert!(formatted.contains(", ") || formatted.contains(",")); + } + + #[test] + fn test_empty_function_body() { + let source = "fn empty() -> unit {\n\n\n}"; + let formatted = format(source, &FormatOptions::default()); + // Should collapse empty lines + assert!(!formatted.contains("\n\n\n")); + } + + #[test] + fn test_semicolon_preservation() { + let source = "x := 1; y := 2;"; + let formatted = format(source, &FormatOptions::default()); + // Semicolons should be preserved + assert!(formatted.matches(';').count() >= 2); + } +} diff --git a/exp/zlup/src/lib.rs b/exp/zlup/src/lib.rs new file mode 100644 index 000000000..9ff7dbb72 --- /dev/null +++ b/exp/zlup/src/lib.rs @@ -0,0 +1,181 @@ +//! # Zluppy +//! +//! **EXPERIMENTAL** - A Zig/SLR/NASA Power of 10 reflection of Guppy's approach to +//! quantum programming. +//! +//! ## Philosophy +//! +//! Zluppy solves the same problems as Guppy but through a different lens: +//! +//! - **Zig's philosophy**: Explicit over implicit, simple over complex, compile-time +//! metaprogramming instead of runtime magic +//! - **SLR's allocator model**: Hierarchical resource management - allocators own +//! qubits, children borrow from parents, lifetimes are structural +//! - **NASA Power of 10**: Bounded loops, fixed resource limits, no dynamic allocation +//! after initialization, assertions everywhere, predictable execution +//! +//! Where Guppy uses linear types, we use allocators. Where Guppy embeds in Python, +//! we stand alone. Same problems, simpler idioms, low-level but clean. +//! +//! ## Design Goals +//! +//! - Standalone language (not Python-embedded) +//! - Explicit resource management via allocators +//! - Compiles to SLR-AST for Python/PECOS integration +//! - Compiles to HUGR for hardware/experiment targeting +//! - Compiles to PHIR for simulator targeting +//! - Bounded, predictable execution (NASA Power of 10) +//! - Full comptime metaprogramming (Zig-style) +//! +//! ## Compilation Targets +//! +//! Both HUGR and PHIR are MLIR-inspired IRs: +//! - **HUGR**: Hierarchical Unified Graph Representation - for experiments/hardware +//! - **PHIR**: Program Hierarchical IR - for simulator targeting +//! +//! ```text +//! ┌─────────────┐ +//! │ Zluppy │ +//! │ (.zlp) │ +//! └──────┬──────┘ +//! │ +//! ▼ +//! ┌─────────────┐ +//! │ Zluppy AST │ +//! └──────┬──────┘ +//! │ +//! ┌───┼───┐ +//! │ │ │ +//! ▼ ▼ ▼ +//! ┌────┐ ┌────┐ ┌────┐ +//! │SLR │ │HUGR│ │PHIR│ +//! │AST │ │ │ │ │ +//! └─┬──┘ └─┬──┘ └─┬──┘ +//! │ │ │ +//! ▼ ▼ ▼ +//! ┌────┐ ┌────┐ ┌────┐ +//! │Guppy│ │Exp │ │Sim │ +//! │QASM│ │HW │ │ │ +//! └────┘ └────┘ └────┘ +//! ``` +//! +//! ## Example +//! +//! ```zluppy +//! const std = @import("std"); +//! +//! pub fn main() -> unit { +//! var base = qalloc(10); +//! var q = base.child(2); +//! +//! pz q; +//! +//! // Bell state +//! h(q[0]); +//! cx(q[0], q[1]); +//! +//! const results = measure(q); +//! return unit; +//! } +//! ``` +//! +//! ## Status +//! +//! This is an **experimental** language for research purposes. The API and syntax +//! are subject to change without notice. + +// Experimental crate - suppress docs and dead code warnings during development +#![allow(missing_docs)] +#![warn(clippy::all)] +#![allow(dead_code)] + +pub mod analysis; +pub mod ast; +pub mod build; +pub mod codegen; +pub mod comptime; +pub mod config; +pub mod docgen; +pub mod formatter; +pub mod linter; +pub mod logging; +pub mod module; +pub mod optimize; +pub mod parser; +pub mod pretty; +pub mod rational; +pub mod semantic; +pub mod test_runner; + +/// Crate version +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +// Re-export commonly used analysis types for convenience +pub use analysis::{ + analyze_parallelism, AllocatorAnalysis, AllocatorInfo, DependencyGraph, DepEdge, DepKind, + OperationTagger, ParallelismSummary, Resource, TaggedOp, +}; + +/// Parse a Zluppy source file into an AST. +/// +/// # Errors +/// +/// Returns an error if the source contains syntax errors. +pub fn parse(source: &str) -> Result { + parser::parse(source) +} + +/// Parse a Zluppy source file with filename for error reporting. +/// +/// # Errors +/// +/// Returns an error if the source contains syntax errors. +pub fn parse_file(source: &str, filename: impl Into) -> Result { + parser::parse_file(source, filename) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_version() { + assert!(!VERSION.is_empty()); + } + + #[test] + fn test_analysis_reexports() { + // Verify the analysis re-exports are accessible + let source = r#" + fn main() -> unit { + mut q := qalloc(2); + h q[0]; + return; + } + "#; + + let program = parse(source).unwrap(); + + // Test re-exported types and functions + let allocator_analysis = AllocatorAnalysis::analyze(&program); + assert!(allocator_analysis.allocators.contains_key("q")); + + let summaries = analyze_parallelism(&program); + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].function_name, "main"); + + // Test Resource enum + let _r1 = Resource::allocator("q"); + let _r2 = Resource::qubit("q", 0); + let _r3 = Resource::variable("x"); + + // Test OperationTagger and DependencyGraph + let tagger = OperationTagger::tag(&program); + let graph = DependencyGraph::build(tagger.operations); + let _layers = graph.parallel_layers(); + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod comprehensive_tests; diff --git a/exp/zlup/src/linter.rs b/exp/zlup/src/linter.rs new file mode 100644 index 000000000..1bfe3c31a --- /dev/null +++ b/exp/zlup/src/linter.rs @@ -0,0 +1,2309 @@ +//! Linter for Zlup code quality and NASA Power of 10 compliance. +//! +//! Provides static analysis checks beyond semantic correctness: +//! - NASA Power of 10 rules +//! - Code style and naming conventions +//! - Complexity metrics +//! - Best practices for quantum code +//! +//! ## Usage +//! +//! ```rust +//! use zlup::linter::{Linter, LintConfig}; +//! +//! let source = "fn main() -> unit { return unit; }"; +//! let program = zlup::parse(source).expect("parse failed"); +//! let linter = Linter::new(LintConfig::strict()); +//! let diagnostics = linter.lint(&program); +//! // diagnostics contains any lint warnings/errors found +//! ``` + +use crate::ast::{self, Expr, Program, Stmt, TopLevelDecl}; +use std::collections::BTreeMap; + +// ============================================================================= +// Lint Configuration +// ============================================================================= + +/// Lint severity levels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + /// Informational suggestion. + Hint, + /// Style warning (doesn't affect correctness). + Warning, + /// Should be fixed (violates best practices). + Error, + /// Must be fixed (violates safety rules). + Deny, +} + +/// Safety level for an auto-fix. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FixSafety { + /// Safe fix - can be applied automatically without risk. + /// These fixes preserve semantics and are guaranteed correct. + Safe, + /// Unsafe fix - probably safe but not guaranteed. + /// These may change behavior in edge cases or require manual verification. + Unsafe, +} + +/// A potential fix for a lint diagnostic. +#[derive(Debug, Clone)] +pub struct LintFix { + /// Start byte offset in source. + pub start: usize, + /// End byte offset in source. + pub end: usize, + /// Replacement text. + pub replacement: String, + /// Safety level. + pub safety: FixSafety, +} + +/// Configuration for individual lint rules. +#[derive(Debug, Clone)] +pub struct LintRule { + /// Whether this rule is enabled. + pub enabled: bool, + /// Severity level for violations. + pub severity: Severity, +} + +impl LintRule { + pub fn enabled(severity: Severity) -> Self { + Self { + enabled: true, + severity, + } + } + + pub fn disabled() -> Self { + Self { + enabled: false, + severity: Severity::Warning, + } + } +} + +/// Linter configuration. +#[derive(Debug, Clone)] +pub struct LintConfig { + // NASA Power of 10 Rules + /// Maximum function body lines (NASA PoT Rule 4: ≤60 lines). + pub max_function_lines: usize, + /// Rule for function size violations. + pub function_too_long: LintRule, + + /// Minimum assertions per function on average (NASA PoT Rule 5). + pub min_assertions_per_function: f64, + /// Rule for low assertion density. + pub low_assertion_density: LintRule, + + /// Maximum nesting depth (related to NASA PoT Rule 1: simple control flow). + pub max_nesting_depth: usize, + /// Rule for deep nesting. + pub deep_nesting: LintRule, + + // Naming Conventions + /// Rule for function naming (snake_case). + pub function_naming: LintRule, + /// Rule for variable naming (snake_case). + pub variable_naming: LintRule, + /// Rule for constant naming (SCREAMING_SNAKE_CASE). + pub constant_naming: LintRule, + /// Rule for type naming (PascalCase). + pub type_naming: LintRule, + + // Code Quality + /// Rule for unused variables. + pub unused_variable: LintRule, + /// Rule for unused functions. + pub unused_function: LintRule, + /// Rule for missing documentation on public items. + pub missing_docs: LintRule, + /// Rule for TODO/FIXME comments. + pub todo_comments: LintRule, + + // Quantum-Specific + /// Rule for measurement without using result. + pub unused_measurement: LintRule, + /// Rule for potentially inefficient gate sequences. + pub redundant_gates: LintRule, + /// Rule for missing barrier/tick between non-commuting gates. + pub missing_barrier: LintRule, + + // Angle Precision + /// Rule for using radians when exact turn fractions exist. + pub prefer_turns_over_radians: LintRule, + /// Rule for using decimal turns when exact fractions exist. + pub prefer_fraction_turns: LintRule, + /// Rule for using float literals where exact fractions/constants exist. + pub prefer_exact_angles: LintRule, +} + +impl Default for LintConfig { + fn default() -> Self { + // Default is strict - this is a safety-critical language + Self::strict() + } +} + +impl LintConfig { + /// Relaxed configuration (warnings instead of errors). + pub fn relaxed() -> Self { + Self { + // NASA Power of 10 + max_function_lines: 60, + function_too_long: LintRule::disabled(), + min_assertions_per_function: 2.0, + low_assertion_density: LintRule::enabled(Severity::Hint), + max_nesting_depth: 4, + deep_nesting: LintRule::enabled(Severity::Warning), + + // Naming + function_naming: LintRule::enabled(Severity::Warning), + variable_naming: LintRule::enabled(Severity::Hint), + constant_naming: LintRule::enabled(Severity::Hint), + type_naming: LintRule::enabled(Severity::Warning), + + // Code Quality + unused_variable: LintRule::enabled(Severity::Warning), + unused_function: LintRule::enabled(Severity::Hint), + missing_docs: LintRule::disabled(), + todo_comments: LintRule::enabled(Severity::Hint), + + // Quantum + unused_measurement: LintRule::enabled(Severity::Warning), + redundant_gates: LintRule::enabled(Severity::Hint), + missing_barrier: LintRule::disabled(), + + // Angle Precision + prefer_turns_over_radians: LintRule::enabled(Severity::Hint), + prefer_fraction_turns: LintRule::enabled(Severity::Hint), + prefer_exact_angles: LintRule::enabled(Severity::Hint), + } + } + + /// Strict configuration for safety-critical code (DEFAULT). + pub fn strict() -> Self { + Self { + // NASA Power of 10 - enforced (except line count which is arbitrary) + max_function_lines: 60, + function_too_long: LintRule::disabled(), + min_assertions_per_function: 2.0, + low_assertion_density: LintRule::enabled(Severity::Warning), + max_nesting_depth: 4, + deep_nesting: LintRule::enabled(Severity::Error), + + // Naming - enforced + function_naming: LintRule::enabled(Severity::Error), + variable_naming: LintRule::enabled(Severity::Warning), + constant_naming: LintRule::enabled(Severity::Warning), + type_naming: LintRule::enabled(Severity::Error), + + // Code Quality - enforced + unused_variable: LintRule::enabled(Severity::Error), + unused_function: LintRule::enabled(Severity::Warning), + missing_docs: LintRule::enabled(Severity::Warning), + todo_comments: LintRule::enabled(Severity::Warning), + + // Quantum - enforced + unused_measurement: LintRule::enabled(Severity::Error), + redundant_gates: LintRule::enabled(Severity::Warning), + missing_barrier: LintRule::enabled(Severity::Hint), + + // Angle Precision - enforced + prefer_turns_over_radians: LintRule::enabled(Severity::Warning), + prefer_fraction_turns: LintRule::enabled(Severity::Warning), + prefer_exact_angles: LintRule::enabled(Severity::Warning), + } + } + + /// Minimal configuration (only critical issues). + pub fn minimal() -> Self { + Self { + function_too_long: LintRule::disabled(), + low_assertion_density: LintRule::disabled(), + deep_nesting: LintRule::disabled(), + function_naming: LintRule::disabled(), + variable_naming: LintRule::disabled(), + constant_naming: LintRule::disabled(), + type_naming: LintRule::disabled(), + unused_variable: LintRule::enabled(Severity::Warning), + unused_function: LintRule::disabled(), + missing_docs: LintRule::disabled(), + todo_comments: LintRule::disabled(), + unused_measurement: LintRule::enabled(Severity::Warning), + redundant_gates: LintRule::disabled(), + missing_barrier: LintRule::disabled(), + prefer_turns_over_radians: LintRule::disabled(), + prefer_fraction_turns: LintRule::disabled(), + prefer_exact_angles: LintRule::disabled(), + ..Default::default() + } + } +} + +// ============================================================================= +// Lint Diagnostics +// ============================================================================= + +/// A lint diagnostic. +#[derive(Debug, Clone)] +pub struct LintDiagnostic { + /// Lint rule that was violated. + pub rule: &'static str, + /// Human-readable message. + pub message: String, + /// Severity level. + pub severity: Severity, + /// Source location (if available). + pub location: Option, + /// Suggested fix (if available). + pub suggestion: Option, + /// Auto-fix (if available). + pub fix: Option, +} + +impl LintDiagnostic { + pub fn new(rule: &'static str, message: impl Into, severity: Severity) -> Self { + Self { + rule, + message: message.into(), + severity, + location: None, + suggestion: None, + fix: None, + } + } + + pub fn with_location(mut self, location: ast::SourceLocation) -> Self { + self.location = Some(location); + self + } + + pub fn with_location_opt(mut self, location: Option) -> Self { + self.location = location; + self + } + + pub fn with_suggestion(mut self, suggestion: impl Into) -> Self { + self.suggestion = Some(suggestion.into()); + self + } + + pub fn with_fix(mut self, fix: LintFix) -> Self { + self.fix = Some(fix); + self + } + + /// Check if this diagnostic has a safe fix available. + pub fn has_safe_fix(&self) -> bool { + matches!(&self.fix, Some(f) if f.safety == FixSafety::Safe) + } + + /// Check if this diagnostic has any fix available. + pub fn has_fix(&self) -> bool { + self.fix.is_some() + } +} + +impl std::fmt::Display for LintDiagnostic { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let severity_str = match self.severity { + Severity::Hint => "hint", + Severity::Warning => "warning", + Severity::Error => "error", + Severity::Deny => "deny", + }; + + if let Some(ref loc) = self.location { + write!( + f, + "{}: {} [{}] ({}:{})", + severity_str, self.message, self.rule, loc.line, loc.column + )?; + } else { + write!(f, "{}: {} [{}]", severity_str, self.message, self.rule)?; + } + + if let Some(ref suggestion) = self.suggestion { + write!(f, "\n suggestion: {}", suggestion)?; + } + + Ok(()) + } +} + +// ============================================================================= +// Linter +// ============================================================================= + +/// The linter engine. +pub struct Linter { + config: LintConfig, + diagnostics: Vec, + /// Track variable usage for unused detection. + variable_uses: BTreeMap, + /// Track function usage for unused detection. + function_uses: BTreeMap, + /// Track defined variables. + defined_variables: BTreeMap, + /// Track defined functions. + defined_functions: BTreeMap, // (location, is_pub) + /// Current nesting depth. + current_depth: usize, + /// Assertion count for current function. + assertion_count: usize, + /// Total functions analyzed. + function_count: usize, + /// Total assertions across all functions. + total_assertions: usize, + /// Source code for computing fix offsets. + source: Option, +} + +impl Linter { + pub fn new(config: LintConfig) -> Self { + Self { + config, + diagnostics: Vec::new(), + variable_uses: BTreeMap::new(), + function_uses: BTreeMap::new(), + defined_variables: BTreeMap::new(), + defined_functions: BTreeMap::new(), + current_depth: 0, + assertion_count: 0, + function_count: 0, + total_assertions: 0, + source: None, + } + } + + /// Set the source code for computing fix offsets. + pub fn with_source(mut self, source: impl Into) -> Self { + self.source = Some(source.into()); + self + } + + /// Lint a program and return diagnostics. + pub fn lint(mut self, program: &Program) -> Vec { + // First pass: collect definitions + for decl in &program.declarations { + self.collect_definitions(decl); + } + + // Second pass: analyze usage + for decl in &program.declarations { + self.analyze_decl(decl); + } + + // Check for unused definitions + self.check_unused(); + + // Check assertion density + self.check_assertion_density(); + + self.diagnostics + } + + fn collect_definitions(&mut self, decl: &TopLevelDecl) { + match decl { + TopLevelDecl::Fn(fn_decl) => { + let location = fn_decl.location.clone().unwrap_or_default(); + self.defined_functions + .insert(fn_decl.name.clone(), (location, fn_decl.is_pub)); + } + TopLevelDecl::Binding(binding) => { + if let Some(ref loc) = binding.location { + self.defined_variables + .insert(binding.name.clone(), loc.clone()); + } + } + TopLevelDecl::Struct(struct_decl) => { + self.check_type_name(&struct_decl.name, struct_decl.location.as_ref()); + } + TopLevelDecl::Enum(enum_decl) => { + self.check_type_name(&enum_decl.name, enum_decl.location.as_ref()); + } + TopLevelDecl::Union(union_decl) => { + self.check_type_name(&union_decl.name, union_decl.location.as_ref()); + } + _ => {} + } + } + + fn analyze_decl(&mut self, decl: &TopLevelDecl) { + if let TopLevelDecl::Fn(fn_decl) = decl { + self.analyze_function(fn_decl); + } + } + + fn analyze_function(&mut self, fn_decl: &ast::FnDecl) { + self.function_count += 1; + self.assertion_count = 0; + + // Check function naming + self.check_function_name(&fn_decl.name, fn_decl.location.as_ref()); + + // Check function length + self.check_function_length(fn_decl); + + // Check parameters + for param in &fn_decl.params { + self.check_variable_name(¶m.name, param.location.as_ref()); + if let Some(ref loc) = param.location { + self.defined_variables.insert(param.name.clone(), loc.clone()); + } + } + + // Analyze body + self.current_depth = 0; + self.analyze_block(&fn_decl.body); + + // Record assertions for this function + self.total_assertions += self.assertion_count; + } + + fn analyze_block(&mut self, block: &ast::Block) { + self.current_depth += 1; + self.check_nesting_depth(block.location.as_ref()); + + for stmt in &block.statements { + self.analyze_stmt(stmt); + } + + if let Some(ref trailing) = block.trailing_expr { + self.analyze_expr(trailing); + } + + self.current_depth -= 1; + } + + fn analyze_stmt(&mut self, stmt: &Stmt) { + match stmt { + Stmt::Binding(binding) => { + // Check variable naming + self.check_variable_name(&binding.name, binding.location.as_ref()); + if let Some(ref loc) = binding.location { + self.defined_variables.insert(binding.name.clone(), loc.clone()); + } + if let Some(ref value) = binding.value { + self.analyze_expr(value); + } + } + Stmt::Expr(expr_stmt) => { + self.analyze_expr(&expr_stmt.expr); + } + Stmt::If(if_stmt) => { + self.analyze_expr(&if_stmt.condition); + self.analyze_block(&if_stmt.then_body); + if let Some(ref else_branch) = if_stmt.else_body { + self.analyze_else_branch(else_branch); + } + } + Stmt::For(for_stmt) => { + // Check capture variable naming + for capture in &for_stmt.captures { + self.check_variable_name(capture, for_stmt.location.as_ref()); + } + self.analyze_for_range(&for_stmt.range); + self.analyze_block(&for_stmt.body); + } + Stmt::Block(block) => { + self.analyze_block(block); + } + Stmt::Return(ret_stmt) => { + if let Some(ref value) = ret_stmt.value { + self.analyze_expr(value); + } + } + Stmt::Tick(tick_stmt) => { + for inner in &tick_stmt.body { + self.analyze_stmt(inner); + } + } + Stmt::Assign(assign) => { + self.analyze_expr(&assign.target); + self.analyze_expr(&assign.value); + } + Stmt::Gate(gate_op) => { + // Track allocator usage + for target in &gate_op.targets { + *self.variable_uses.entry(target.allocator.clone()).or_insert(0) += 1; + self.analyze_expr(&target.index); + } + } + Stmt::Measure(measure_op) => { + // Track allocator usage + for target in &measure_op.targets { + *self.variable_uses.entry(target.allocator.clone()).or_insert(0) += 1; + self.analyze_expr(&target.index); + } + } + _ => {} + } + } + + fn analyze_else_branch(&mut self, else_branch: &ast::ElseBranch) { + match else_branch { + ast::ElseBranch::ElseIf(if_stmt) => { + self.analyze_expr(&if_stmt.condition); + self.analyze_block(&if_stmt.then_body); + if let Some(ref inner_else) = if_stmt.else_body { + self.analyze_else_branch(inner_else); + } + } + ast::ElseBranch::Else(block) => { + self.analyze_block(block); + } + } + } + + fn analyze_for_range(&mut self, range: &ast::ForRange) { + match range { + ast::ForRange::Range { start, end } => { + self.analyze_expr(start); + self.analyze_expr(end); + } + ast::ForRange::Collection(expr) => { + self.analyze_expr(expr); + } + } + } + + fn analyze_expr(&mut self, expr: &Expr) { + match expr { + Expr::Ident(ident) => { + // Track variable usage + *self.variable_uses.entry(ident.name.clone()).or_insert(0) += 1; + } + Expr::Call(call) => { + // Track function usage + if let Expr::Ident(ident) = &call.callee { + *self.function_uses.entry(ident.name.clone()).or_insert(0) += 1; + + // Check for assertions + if ident.name == "assert" || ident.name == "debug_assert" { + self.assertion_count += 1; + } + } + + // Analyze callee and arguments + self.analyze_expr(&call.callee); + for arg in &call.args { + self.analyze_expr(arg); + } + } + Expr::Binary(binary) => { + self.analyze_expr(&binary.left); + self.analyze_expr(&binary.right); + } + Expr::Unary(unary) => { + self.analyze_expr(&unary.operand); + } + Expr::Index(index) => { + self.analyze_expr(&index.object); + self.analyze_expr(&index.index); + } + Expr::Field(field) => { + self.analyze_expr(&field.object); + } + Expr::If(if_expr) => { + self.analyze_expr(&if_expr.condition); + self.analyze_expr(&if_expr.then_expr); + self.analyze_expr(&if_expr.else_expr); + } + Expr::Block(block_expr) => { + self.current_depth += 1; + self.check_nesting_depth(block_expr.location.as_ref()); + for stmt in &block_expr.statements { + self.analyze_stmt(stmt); + } + if let Some(ref trailing) = block_expr.trailing_expr { + self.analyze_expr(trailing); + } + self.current_depth -= 1; + } + Expr::Tuple(tuple) => { + for elem in &tuple.elements { + self.analyze_expr(elem); + } + } + Expr::Gate(gate) => { + for param in &gate.params { + self.analyze_expr(param); + } + self.analyze_expr(&gate.target); + } + Expr::Measure(measure) => { + self.analyze_expr(&measure.targets); + } + Expr::AngleLit(angle) => { + self.check_angle_precision(angle); + self.analyze_expr(&angle.value); + } + _ => {} + } + } + + // ========================================================================= + // Check Functions + // ========================================================================= + + fn check_function_name(&mut self, name: &str, location: Option<&ast::SourceLocation>) { + if !self.config.function_naming.enabled { + return; + } + + // Skip main and special names + if name == "main" || name.starts_with('_') { + return; + } + + if !is_snake_case(name) { + let mut diag = LintDiagnostic::new( + "function_naming", + format!("function `{}` should be snake_case", name), + self.config.function_naming.severity, + ) + .with_suggestion(format!("rename to `{}`", to_snake_case(name))); + + if let Some(loc) = location { + diag = diag.with_location(loc.clone()); + } + + self.diagnostics.push(diag); + } + } + + fn check_variable_name(&mut self, name: &str, location: Option<&ast::SourceLocation>) { + if !self.config.variable_naming.enabled { + return; + } + + // Skip underscore-prefixed names (intentionally unused) + if name.starts_with('_') { + return; + } + + // Common short names are allowed + if matches!(name, "i" | "j" | "k" | "n" | "x" | "y" | "z" | "q" | "r" | "c") { + return; + } + + if !is_snake_case(name) { + let snake_name = to_snake_case(name); + let mut diag = LintDiagnostic::new( + "variable_naming", + format!("variable `{}` should be snake_case", name), + self.config.variable_naming.severity, + ) + .with_suggestion(format!("rename to `{}`", snake_name)); + + if let Some(loc) = location { + diag = diag.with_location(loc.clone()); + // Note: Renaming is unsafe because we'd need to rename all uses + // For now, we don't provide an auto-fix for renames + } + + self.diagnostics.push(diag); + } + } + + fn check_type_name(&mut self, name: &str, location: Option<&ast::SourceLocation>) { + if !self.config.type_naming.enabled { + return; + } + + if !is_pascal_case(name) { + let mut diag = LintDiagnostic::new( + "type_naming", + format!("type `{}` should be PascalCase", name), + self.config.type_naming.severity, + ) + .with_suggestion(format!("rename to `{}`", to_pascal_case(name))); + + if let Some(loc) = location { + diag = diag.with_location(loc.clone()); + } + + self.diagnostics.push(diag); + } + } + + fn check_function_length(&mut self, fn_decl: &ast::FnDecl) { + if !self.config.function_too_long.enabled { + return; + } + + let line_count = count_block_lines(&fn_decl.body); + + if line_count > self.config.max_function_lines { + let mut diag = LintDiagnostic::new( + "function_too_long", + format!( + "function `{}` has {} lines, exceeds maximum of {} (NASA PoT Rule 4)", + fn_decl.name, line_count, self.config.max_function_lines + ), + self.config.function_too_long.severity, + ) + .with_suggestion("consider breaking into smaller functions"); + + if let Some(ref loc) = fn_decl.location { + diag = diag.with_location(loc.clone()); + } + + self.diagnostics.push(diag); + } + } + + fn check_nesting_depth(&mut self, location: Option<&ast::SourceLocation>) { + if !self.config.deep_nesting.enabled { + return; + } + + if self.current_depth > self.config.max_nesting_depth { + let mut diag = LintDiagnostic::new( + "deep_nesting", + format!( + "nesting depth {} exceeds maximum of {} (NASA PoT Rule 1)", + self.current_depth, self.config.max_nesting_depth + ), + self.config.deep_nesting.severity, + ) + .with_suggestion("consider extracting to a separate function or simplifying logic"); + + if let Some(loc) = location { + diag = diag.with_location(loc.clone()); + } + + self.diagnostics.push(diag); + } + } + + fn check_unused(&mut self) { + // Check unused variables + if self.config.unused_variable.enabled { + for (name, location) in &self.defined_variables { + if name.starts_with('_') { + continue; // Intentionally unused + } + if self.variable_uses.get(name).copied().unwrap_or(0) == 0 { + let mut diag = LintDiagnostic::new( + "unused_variable", + format!("unused variable `{}`", name), + self.config.unused_variable.severity, + ) + .with_location(location.clone()) + .with_suggestion(format!("prefix with underscore: `_{}`", name)); + + // Generate safe fix: prefix with underscore + if let Some(ref source) = self.source { + let start = location_to_offset(source, location.line, location.column); + let end = start + name.len(); + diag = diag.with_fix(LintFix { + start, + end, + replacement: format!("_{}", name), + safety: FixSafety::Safe, + }); + } + + self.diagnostics.push(diag); + } + } + } + + // Check unused functions + if self.config.unused_function.enabled { + for (name, (location, is_pub)) in &self.defined_functions { + if name == "main" || *is_pub { + continue; // main and pub functions are considered used + } + if self.function_uses.get(name).copied().unwrap_or(0) == 0 { + self.diagnostics.push( + LintDiagnostic::new( + "unused_function", + format!("unused function `{}`", name), + self.config.unused_function.severity, + ) + .with_location(location.clone()) + .with_suggestion("remove or add `pub` if intended for export"), + ); + // Note: No auto-fix for unused functions - removing code is always unsafe + } + } + } + } + + fn check_assertion_density(&mut self) { + if !self.config.low_assertion_density.enabled || self.function_count == 0 { + return; + } + + let density = self.total_assertions as f64 / self.function_count as f64; + + if density < self.config.min_assertions_per_function { + self.diagnostics.push(LintDiagnostic::new( + "low_assertion_density", + format!( + "assertion density is {:.1} per function, below minimum of {:.1} (NASA PoT Rule 5)", + density, self.config.min_assertions_per_function + ), + self.config.low_assertion_density.severity, + ) + .with_suggestion("add `assert()` calls to verify invariants and preconditions")); + } + } + + fn check_angle_precision(&mut self, angle: &ast::AngleLit) { + use crate::ast::AngleUnit; + use crate::rational::Rational; + + // Check if using radians when exact turn fractions exist + if self.config.prefer_turns_over_radians.enabled + && let AngleUnit::Rad = angle.unit { + // Try to evaluate the expression to see if it's a common angle + if let Some(suggestion) = self.suggest_turn_equivalent(angle) { + self.diagnostics.push( + LintDiagnostic::new( + "prefer_turns_over_radians", + "radians can lose precision; consider using turns for exact representation" + .to_string(), + self.config.prefer_turns_over_radians.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(&suggestion), + ); + } + } + + // Check if using decimal turns when fractions exist + if self.config.prefer_fraction_turns.enabled + && let AngleUnit::Turns = angle.unit + && let Expr::FloatLit(lit) = &angle.value + && let Some(fraction_str) = suggest_fraction_for_decimal(lit.value) { + let mut diag = LintDiagnostic::new( + "prefer_fraction_turns", + format!( + "decimal `{}` can be expressed exactly as `{}`", + lit.value, fraction_str + ), + self.config.prefer_fraction_turns.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(format!("use `{} turns` for exact representation", fraction_str)); + + // Generate safe fix: replace the float literal with the fraction + if let (Some(source), Some(lit_loc)) = (&self.source, &lit.location) { + let start = location_to_offset(source, lit_loc.line, lit_loc.column); + let end = location_to_offset(source, lit_loc.end_line, lit_loc.end_column); + diag = diag.with_fix(LintFix { + start, + end, + replacement: fraction_str.clone(), + safety: FixSafety::Safe, + }); + } + + self.diagnostics.push(diag); + } + + // Check if using float literals that could be exact fractions or std constants + if self.config.prefer_exact_angles.enabled + && let Expr::FloatLit(lit) = &angle.value { + let value = lit.value; + + // Check if this float could be a rational fraction + if let Some(r) = Rational::from_f64_common(value) { + // Only suggest if it's a "nice" fraction (small denominator) + if r.denominator() <= 16 && !r.is_integer() { + let suggestion = if r.numerator() == 1 { + format!("1/{}", r.denominator()) + } else { + format!("{}/{}", r.numerator(), r.denominator()) + }; + + self.diagnostics.push( + LintDiagnostic::new( + "prefer_exact_angles", + format!( + "float literal `{}` can be expressed exactly as fraction `{}`", + value, suggestion + ), + self.config.prefer_exact_angles.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(format!( + "use `{} {}` for exact representation", + suggestion, + match angle.unit { + AngleUnit::Turns => "turns", + AngleUnit::Rad => "rad", + } + )), + ); + } + } + + // For radians, also check if it's a pi multiple (like 3.14159...) + if let AngleUnit::Rad = angle.unit + && let Some((n, d)) = Rational::from_f64_pi_multiple(value) { + let pi_suggestion = if n == 1 && d == 1 { + "std.f64.pi".to_string() + } else if n == 1 { + format!("std.f64.pi/{}", d) + } else if d == 1 { + format!("{}*std.f64.pi", n) + } else { + format!("{}*std.f64.pi/{}", n, d) + }; + + // Also suggest the turns equivalent + let turns_fraction = Rational::new(n, 2 * d as i64); + let turns_suggestion = if turns_fraction.numerator() == 1 { + format!("1/{}", turns_fraction.denominator()) + } else { + format!("{}/{}", turns_fraction.numerator(), turns_fraction.denominator()) + }; + + self.diagnostics.push( + LintDiagnostic::new( + "prefer_exact_angles", + format!( + "float `{}` appears to be {}*pi/{}; use exact form or turns", + value, n, d + ), + self.config.prefer_exact_angles.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(format!( + "use `{} rad` or `{} turns` for exact representation", + pi_suggestion, turns_suggestion + )), + ); + } + } + } + + fn suggest_turn_equivalent(&self, angle: &ast::AngleLit) -> Option { + use crate::comptime::ComptimeEvaluator; + use crate::rational::Rational; + + let mut eval = ComptimeEvaluator::new(); + let value = eval.eval_expr(&angle.value).ok()?; + let radians = match value { + crate::comptime::ComptimeValue::Float(f) => f, + crate::comptime::ComptimeValue::Int(i) => i as f64, + crate::comptime::ComptimeValue::Rational(r) => r.to_f64(), + _ => return None, + }; + + // Use Rational to detect pi multiples and convert to turns + if let Some(turns_rational) = Rational::radians_to_turns(radians) + && (!turns_rational.is_integer() || turns_rational.numerator() != 0) { + let suggestion = if turns_rational.numerator() == 1 { + format!("1/{}", turns_rational.denominator()) + } else { + format!("{}/{}", turns_rational.numerator(), turns_rational.denominator()) + }; + return Some(format!("use `{} turns` instead", suggestion)); + } + + None + } +} + +/// Suggest a fraction representation for a decimal turn value +fn suggest_fraction_for_decimal(value: f64) -> Option { + const TOLERANCE: f64 = 1e-10; + let common_fractions = [ + (0.5, "1/2"), + (0.25, "1/4"), + (0.125, "1/8"), + (0.0625, "1/16"), + (1.0 / 3.0, "1/3"), + (1.0 / 6.0, "1/6"), + (0.75, "3/4"), + (0.375, "3/8"), + (2.0 / 3.0, "2/3"), + (0.1, "1/10"), + (0.2, "1/5"), + ]; + + for (frac_val, frac_str) in common_fractions { + if (value - frac_val).abs() < TOLERANCE { + return Some(frac_str.to_string()); + } + } + + None +} + +// ============================================================================= +// Naming Convention Helpers +// ============================================================================= + +fn is_snake_case(s: &str) -> bool { + if s.is_empty() { + return false; + } + + let mut chars = s.chars().peekable(); + + // First char must be lowercase or underscore + match chars.next() { + Some(c) if c.is_ascii_lowercase() || c == '_' => {} + _ => return false, + } + + // Rest must be lowercase, digits, or underscores + for c in chars { + if !c.is_ascii_lowercase() && !c.is_ascii_digit() && c != '_' { + return false; + } + } + + // No double underscores + !s.contains("__") +} + +fn is_pascal_case(s: &str) -> bool { + if s.is_empty() { + return false; + } + + let mut chars = s.chars(); + + // First char must be uppercase + match chars.next() { + Some(c) if c.is_ascii_uppercase() => {} + _ => return false, + } + + // No underscores allowed and must have at least one lowercase letter + // (to distinguish from SCREAMING_SNAKE_CASE) + !s.contains('_') && s.chars().any(|c| c.is_ascii_lowercase()) +} + +fn to_snake_case(s: &str) -> String { + let mut result = String::new(); + + for (i, c) in s.chars().enumerate() { + if c.is_ascii_uppercase() { + if i > 0 { + result.push('_'); + } + result.push(c.to_ascii_lowercase()); + } else { + result.push(c); + } + } + + result +} + +fn to_pascal_case(s: &str) -> String { + let mut result = String::new(); + let mut capitalize_next = true; + + for c in s.chars() { + if c == '_' { + capitalize_next = true; + } else if capitalize_next { + result.push(c.to_ascii_uppercase()); + capitalize_next = false; + } else { + result.push(c); + } + } + + result +} + +/// Count approximate lines in a block (for function length checking). +fn count_block_lines(block: &ast::Block) -> usize { + if let (Some(start), Some(end)) = (&block.location, block.statements.last()) + && let Some(end_loc) = get_stmt_location(end) { + return (end_loc.line.saturating_sub(start.line) + 1) as usize; + } + + // Fallback: count statements + block.statements.len() +} + +fn get_stmt_location(stmt: &Stmt) -> Option { + match stmt { + Stmt::Binding(b) => b.location.clone(), + Stmt::Expr(e) => e.location.clone(), + Stmt::If(i) => i.location.clone(), + Stmt::For(f) => f.location.clone(), + Stmt::Return(r) => r.location.clone(), + Stmt::Block(b) => b.location.clone(), + Stmt::Tick(t) => t.location.clone(), + Stmt::Assign(a) => a.location.clone(), + Stmt::Gate(g) => g.location.clone(), + Stmt::Measure(m) => m.location.clone(), + _ => None, + } +} + +// ============================================================================= +// Public API +// ============================================================================= + +/// Lint a program with default configuration. +pub fn lint(program: &Program) -> Vec { + Linter::new(LintConfig::default()).lint(program) +} + +/// Lint a program with strict configuration. +pub fn lint_strict(program: &Program) -> Vec { + Linter::new(LintConfig::strict()).lint(program) +} + +// ============================================================================= +// Fix Application +// ============================================================================= + +/// Result of applying fixes. +#[derive(Debug)] +pub struct FixResult { + /// The modified source code. + pub source: String, + /// Number of safe fixes applied. + pub safe_fixes_applied: usize, + /// Number of unsafe fixes applied. + pub unsafe_fixes_applied: usize, + /// Number of fixes skipped (due to safety or conflicts). + pub fixes_skipped: usize, +} + +/// Apply fixes from diagnostics to source code. +/// +/// # Arguments +/// * `source` - Original source code +/// * `diagnostics` - Lint diagnostics with potential fixes +/// * `include_unsafe` - Whether to apply unsafe fixes +/// +/// # Returns +/// The modified source code and statistics about fixes applied. +pub fn apply_fixes(source: &str, diagnostics: &[LintDiagnostic], include_unsafe: bool) -> FixResult { + // Collect all applicable fixes + let mut fixes: Vec<&LintFix> = diagnostics + .iter() + .filter_map(|d| d.fix.as_ref()) + .filter(|f| include_unsafe || f.safety == FixSafety::Safe) + .collect(); + + // Sort by start position (descending) so we can apply from end to start + // This prevents offset shifts from affecting earlier fixes + fixes.sort_by(|a, b| b.start.cmp(&a.start)); + + // Check for overlapping fixes and remove conflicts + let mut result = source.to_string(); + let mut safe_applied = 0; + let mut unsafe_applied = 0; + let mut skipped = 0; + let mut last_start = usize::MAX; + + for fix in fixes { + // Skip if this fix overlaps with a previously applied fix + if fix.end > last_start { + skipped += 1; + continue; + } + + // Apply the fix + if fix.start <= result.len() && fix.end <= result.len() { + result.replace_range(fix.start..fix.end, &fix.replacement); + last_start = fix.start; + + match fix.safety { + FixSafety::Safe => safe_applied += 1, + FixSafety::Unsafe => unsafe_applied += 1, + } + } else { + skipped += 1; + } + } + + // Count skipped fixes (those not matching safety criteria) + let total_fixes = diagnostics.iter().filter(|d| d.fix.is_some()).count(); + let not_applied = total_fixes - safe_applied - unsafe_applied; + + FixResult { + source: result, + safe_fixes_applied: safe_applied, + unsafe_fixes_applied: unsafe_applied, + fixes_skipped: skipped + not_applied, + } +} + +/// Compute byte offset from line/column (1-indexed). +pub fn location_to_offset(source: &str, line: u32, column: u32) -> usize { + source + .lines() + .take(line.saturating_sub(1) as usize) + .map(|l| l.len() + 1) // +1 for newline + .sum::() + + column.saturating_sub(1) as usize +} + +/// Compute byte offset for end of a source location. +pub fn location_end_offset(source: &str, loc: &ast::SourceLocation) -> usize { + location_to_offset(source, loc.end_line, loc.end_column) +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse; + + fn lint_source(source: &str) -> Vec { + let program = parse(source).expect("parse failed"); + Linter::new(LintConfig::default()).lint(&program) + } + + fn lint_source_strict(source: &str) -> Vec { + let program = parse(source).expect("parse failed"); + Linter::new(LintConfig::strict()).lint(&program) + } + + #[test] + fn test_snake_case_detection() { + assert!(is_snake_case("foo")); + assert!(is_snake_case("foo_bar")); + assert!(is_snake_case("foo_bar_baz")); + assert!(is_snake_case("_foo")); + assert!(is_snake_case("foo2")); + + assert!(!is_snake_case("Foo")); + assert!(!is_snake_case("fooBar")); + assert!(!is_snake_case("FOO")); + assert!(!is_snake_case("foo__bar")); + } + + #[test] + fn test_pascal_case_detection() { + assert!(is_pascal_case("Foo")); + assert!(is_pascal_case("FooBar")); + assert!(is_pascal_case("FooBarBaz")); + + assert!(!is_pascal_case("foo")); + assert!(!is_pascal_case("foo_bar")); + assert!(!is_pascal_case("FOO")); + } + + #[test] + fn test_function_naming_lint() { + let diags = lint_source( + r#" + fn badName() -> unit { + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "function_naming"), + "expected function_naming lint" + ); + } + + #[test] + fn test_good_function_naming() { + let diags = lint_source( + r#" + fn good_name() -> unit { + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "function_naming"), + "unexpected function_naming lint" + ); + } + + #[test] + fn test_type_naming_lint() { + // Note: The struct syntax `Name := struct {}` parses as a Binding in the current grammar. + // Type naming is checked on TopLevelDecl::Struct, so this test would need + // the grammar to be updated. For now, just verify the is_pascal_case function works. + assert!(!is_pascal_case("bad_name")); + assert!(is_pascal_case("GoodName")); + } + + #[test] + fn test_unused_variable_lint() { + let diags = lint_source( + r#" + fn main() -> unit { + x := 5; + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "unused_variable"), + "expected unused_variable lint" + ); + } + + #[test] + fn test_underscore_unused_ok() { + let diags = lint_source( + r#" + fn main() -> unit { + _x := 5; + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_variable"), + "underscore prefix should suppress unused warning" + ); + } + + #[test] + fn test_deep_nesting_lint() { + let diags = lint_source_strict( + r#" + fn main() -> unit { + if (true) { + if (true) { + if (true) { + if (true) { + if (true) { + x := 1; + } + } + } + } + } + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "deep_nesting"), + "expected deep_nesting lint" + ); + } + + #[test] + fn test_severity_display() { + let diag = LintDiagnostic::new("test_rule", "test message", Severity::Warning); + let s = diag.to_string(); + assert!(s.contains("warning")); + assert!(s.contains("test message")); + assert!(s.contains("test_rule")); + } + + // ========================================================================= + // Assertion Density Tests + // ========================================================================= + + #[test] + fn test_low_assertion_density() { + let diags = lint_source_strict( + r#" + fn foo() -> unit { + x := 1; + return unit; + } + fn bar() -> unit { + y := 2; + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "low_assertion_density"), + "expected low_assertion_density lint when no assertions present" + ); + } + + #[test] + fn test_good_assertion_density() { + let diags = lint_source_strict( + r#" + fn foo() -> unit { + x := 1; + assert(x > 0); + assert(x < 10); + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "low_assertion_density"), + "should not warn when assertion density is good" + ); + } + + // ========================================================================= + // Unused Function Tests + // ========================================================================= + + #[test] + fn test_unused_function_lint() { + let diags = lint_source_strict( + r#" + fn helper() -> unit { + return unit; + } + pub fn main() -> unit { + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("helper")), + "expected unused_function lint for helper" + ); + } + + #[test] + fn test_used_function_no_lint() { + let diags = lint_source_strict( + r#" + fn helper() -> unit { + return unit; + } + pub fn main() -> unit { + helper(); + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("helper")), + "should not lint used function" + ); + } + + #[test] + fn test_pub_function_not_unused() { + let diags = lint_source_strict( + r#" + pub fn exported() -> unit { + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("exported")), + "pub functions should not be considered unused" + ); + } + + #[test] + fn test_main_not_unused() { + let diags = lint_source_strict( + r#" + fn main() -> unit { + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("main")), + "main should never be considered unused" + ); + } + + // ========================================================================= + // Variable Usage Tests + // ========================================================================= + + #[test] + fn test_used_variable_no_lint() { + let diags = lint_source( + r#" + fn main() -> unit { + x := 5; + y := x + 1; + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`x`")), + "used variable should not be flagged" + ); + } + + #[test] + fn test_multiple_unused_variables() { + let diags = lint_source( + r#" + fn main() -> unit { + a := 1; + b := 2; + c := 3; + return unit; + } + "#, + ); + + let unused_count = diags.iter().filter(|d| d.rule == "unused_variable").count(); + assert!( + unused_count >= 3, + "expected at least 3 unused variable lints, got {}", unused_count + ); + } + + // ========================================================================= + // Naming Convention Tests + // ========================================================================= + + #[test] + fn test_camel_case_function_lint() { + let diags = lint_source( + r#" + fn myFunction() -> unit { + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "function_naming"), + "camelCase function should trigger lint" + ); + } + + #[test] + fn test_screaming_snake_case_function_lint() { + let diags = lint_source( + r#" + fn MY_FUNCTION() -> unit { + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "function_naming"), + "SCREAMING_SNAKE_CASE function should trigger lint" + ); + } + + #[test] + fn test_single_letter_variable_ok() { + let diags = lint_source( + r#" + fn main() -> unit { + x := 1; + y := 2; + q := x + y; + return unit; + } + "#, + ); + + // Single letter names (x, y, q, etc.) should not trigger naming lint + assert!( + !diags.iter().any(|d| d.rule == "variable_naming"), + "single letter variables should be allowed" + ); + } + + // ========================================================================= + // Nesting Depth Tests + // ========================================================================= + + #[test] + fn test_acceptable_nesting() { + let diags = lint_source_strict( + r#" + fn main() -> unit { + if (true) { + if (true) { + x := 1; + } + } + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "deep_nesting"), + "nesting depth of 2-3 should be acceptable" + ); + } + + #[test] + fn test_for_loop_nesting_counts() { + let diags = lint_source_strict( + r#" + fn main() -> unit { + for i in 0..10 { + for j in 0..10 { + for k in 0..10 { + for l in 0..10 { + for m in 0..10 { + x := 1; + } + } + } + } + } + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "deep_nesting"), + "deeply nested for loops should trigger lint" + ); + } + + // ========================================================================= + // Configuration Tests + // ========================================================================= + + #[test] + fn test_minimal_config_fewer_warnings() { + let program = parse( + r#" + fn badName() -> unit { + x := 5; + return unit; + } + "#, + ) + .expect("parse failed"); + + let strict_diags = Linter::new(LintConfig::strict()).lint(&program); + let minimal_diags = Linter::new(LintConfig::minimal()).lint(&program); + + assert!( + minimal_diags.len() < strict_diags.len(), + "minimal config should produce fewer diagnostics" + ); + } + + #[test] + fn test_relaxed_config_warnings_not_errors() { + let program = parse( + r#" + fn badName() -> unit { + return unit; + } + "#, + ) + .expect("parse failed"); + + let relaxed_diags = Linter::new(LintConfig::relaxed()).lint(&program); + + // In relaxed mode, function naming should be a warning, not error + let naming_diag = relaxed_diags.iter().find(|d| d.rule == "function_naming"); + if let Some(diag) = naming_diag { + assert!( + matches!(diag.severity, Severity::Warning | Severity::Hint), + "relaxed config should use warnings, not errors" + ); + } + } + + // ========================================================================= + // Suggestion Tests + // ========================================================================= + + #[test] + fn test_function_naming_suggestion() { + let diags = lint_source( + r#" + fn badName() -> unit { + return unit; + } + "#, + ); + + let naming_diag = diags.iter().find(|d| d.rule == "function_naming"); + assert!(naming_diag.is_some(), "should have function_naming lint"); + + let suggestion = &naming_diag.unwrap().suggestion; + assert!(suggestion.is_some(), "should have a suggestion"); + assert!( + suggestion.as_ref().unwrap().contains("bad_name"), + "suggestion should include snake_case version" + ); + } + + #[test] + fn test_unused_variable_suggestion() { + let diags = lint_source( + r#" + fn main() -> unit { + myVar := 5; + return unit; + } + "#, + ); + + let unused_diag = diags.iter().find(|d| d.rule == "unused_variable"); + assert!(unused_diag.is_some(), "should have unused_variable lint"); + + let suggestion = &unused_diag.unwrap().suggestion; + assert!(suggestion.is_some(), "should have a suggestion"); + assert!( + suggestion.as_ref().unwrap().contains("_myVar"), + "suggestion should recommend underscore prefix" + ); + } + + // ========================================================================= + // Quantum-Specific Tests + // ========================================================================= + + #[test] + fn test_gate_operations_track_usage() { + let diags = lint_source( + r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + return unit; + } + "#, + ); + + // q should be considered used because of gate operations + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`q`")), + "allocator used in gates should not be flagged as unused" + ); + } + + // ========================================================================= + // Edge Cases + // ========================================================================= + + #[test] + fn test_empty_program() { + let diags = lint_source(""); + // Should not crash, might have assertion density warning + assert!(diags.iter().all(|d| d.rule != "unused_variable")); + } + + #[test] + fn test_program_with_only_comments() { + // Comments are stripped during parsing, so this is effectively empty + let result = parse("// just a comment"); + // Parser may or may not accept this - either way, linter shouldn't crash + if let Ok(program) = result { + let _diags = Linter::new(LintConfig::default()).lint(&program); + } + } + + #[test] + fn test_to_snake_case_conversion() { + assert_eq!(to_snake_case("myFunction"), "my_function"); + assert_eq!(to_snake_case("MyClass"), "my_class"); + assert_eq!(to_snake_case("XMLParser"), "x_m_l_parser"); + assert_eq!(to_snake_case("already_snake"), "already_snake"); + } + + #[test] + fn test_to_pascal_case_conversion() { + assert_eq!(to_pascal_case("my_type"), "MyType"); + assert_eq!(to_pascal_case("some_struct"), "SomeStruct"); + assert_eq!(to_pascal_case("already"), "Already"); + } + + // ========================================================================= + // Critical Edge Cases + // ========================================================================= + + #[test] + fn test_variable_used_in_return() { + let diags = lint_source( + r#" + fn add(a: u32, b: u32) -> u32 { + result := a + b; + return result; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("result")), + "variable used in return should not be flagged" + ); + } + + #[test] + fn test_variable_used_in_condition() { + let diags = lint_source( + r#" + fn main() -> unit { + flag := true; + if (flag) { + x := 1; + } + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("flag")), + "variable used in condition should not be flagged" + ); + } + + #[test] + fn test_parameter_unused() { + let diags = lint_source( + r#" + fn ignore_param(x: u32) -> unit { + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("x")), + "unused parameter should be flagged" + ); + } + + #[test] + fn test_parameter_underscore_prefix_ok() { + let diags = lint_source( + r#" + fn ignore_param(_x: u32) -> unit { + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("_x")), + "underscore-prefixed parameter should not be flagged" + ); + } + + #[test] + fn test_shadowed_variable() { + let diags = lint_source( + r#" + fn main() -> unit { + x := 1; + x := 2; + y := x; + return unit; + } + "#, + ); + + // The first x is shadowed but that's valid - just check we don't crash + assert!(diags.iter().filter(|d| d.rule == "unused_variable").count() <= 2); + } + + #[test] + fn test_recursive_function_call() { + let diags = lint_source( + r#" + fn factorial(n: u32) -> u32 { + if (n <= 1) { + return 1; + } + return n * factorial(n - 1); + } + "#, + ); + + // factorial calls itself, so it should be considered used + assert!( + !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("factorial")), + "recursive function should not be flagged as unused" + ); + } + + #[test] + fn test_mutual_recursion() { + let diags = lint_source( + r#" + fn is_even(n: u32) -> bool { + if (n == 0) { return true; } + return is_odd(n - 1); + } + fn is_odd(n: u32) -> bool { + if (n == 0) { return false; } + return is_even(n - 1); + } + pub fn main() -> unit { + r := is_even(10); + return unit; + } + "#, + ); + + // Both functions are used via mutual recursion + assert!( + !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("is_even")), + "mutually recursive function should not be flagged" + ); + assert!( + !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("is_odd")), + "mutually recursive function should not be flagged" + ); + } + + #[test] + fn test_nested_blocks_variable_scope() { + let diags = lint_source( + r#" + fn main() -> unit { + { + inner := 5; + } + return unit; + } + "#, + ); + + assert!( + diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("inner")), + "variable unused in nested block should be flagged" + ); + } + + #[test] + fn test_tick_block_operations() { + let diags = lint_source( + r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + tick { + h q[0]; + h q[1]; + } + return unit; + } + "#, + ); + + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("q")), + "allocator used in tick block should not be flagged" + ); + } + + #[test] + fn test_measurement_result_unused() { + // This would test unused_measurement rule if implemented + let diags = lint_source( + r#" + pub fn main() -> unit { + mut q := qalloc(1); + pz q; + h q[0]; + mz(u1) q[0]; + return unit; + } + "#, + ); + + // Currently we don't track measurement results specially + // Just verify it doesn't crash + let _ = diags; + } + + #[test] + fn test_complex_expression_usage() { + let diags = lint_source( + r#" + fn main() -> unit { + a := 1; + b := 2; + c := 3; + result := (a + b) * c; + return unit; + } + "#, + ); + + // a, b, c are all used in the expression + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`a`")), + "variable used in expression should not be flagged" + ); + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`b`")), + "variable used in expression should not be flagged" + ); + assert!( + !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`c`")), + "variable used in expression should not be flagged" + ); + } + + #[test] + fn test_all_severities() { + // Test that all severity levels display correctly + for (sev, expected) in [ + (Severity::Hint, "hint"), + (Severity::Warning, "warning"), + (Severity::Error, "error"), + (Severity::Deny, "deny"), + ] { + let diag = LintDiagnostic::new("test", "msg", sev); + assert!(diag.to_string().contains(expected)); + } + } + + #[test] + fn test_diagnostic_with_location() { + let loc = crate::ast::SourceLocation { + line: 10, + column: 5, + end_line: 10, + end_column: 15, + file: Some("test.zlp".to_string()), + }; + let diag = LintDiagnostic::new("test", "msg", Severity::Error).with_location(loc); + let s = diag.to_string(); + assert!(s.contains("10")); + assert!(s.contains("5")); + } + + #[test] + fn test_diagnostic_with_suggestion() { + let diag = LintDiagnostic::new("test", "msg", Severity::Warning) + .with_suggestion("try this instead"); + let s = diag.to_string(); + assert!(s.contains("try this instead")); + } + + #[test] + fn test_prefer_fraction_turns() { + // Test that decimal turns trigger a suggestion for fractions + let source = r#" + fn main() -> unit { + mut q := qalloc(1); + rz(0.25 turns) q[0]; + return unit; + } + "#; + let program = crate::parse(source).unwrap(); + let linter = Linter::new(LintConfig::strict()); + let diagnostics = linter.lint(&program); + + // Should have a suggestion to use 1/4 instead of 0.25 + let fraction_diags: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "prefer_fraction_turns") + .collect(); + assert!( + !fraction_diags.is_empty(), + "Expected prefer_fraction_turns diagnostic for 0.25 turns" + ); + } + + #[test] + fn test_fraction_turns_no_warning() { + // Test that fraction turns don't trigger warnings + let source = r#" + fn main() -> unit { + mut q := qalloc(1); + rz(1/4 turns) q[0]; + return unit; + } + "#; + let program = crate::parse(source).unwrap(); + let linter = Linter::new(LintConfig::strict()); + let diagnostics = linter.lint(&program); + + // Should NOT have prefer_fraction_turns diagnostic + let fraction_diags: Vec<_> = diagnostics + .iter() + .filter(|d| d.rule == "prefer_fraction_turns") + .collect(); + assert!( + fraction_diags.is_empty(), + "Should not warn about fraction turns: {:?}", + fraction_diags + ); + } + + // ========================================================================= + // Auto-fix Tests + // ========================================================================= + + #[test] + fn test_fix_unused_variable() { + let source = r#"fn main() -> unit { + x := 5; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + // Should have an unused_variable diagnostic with a fix + let unused_diag = diagnostics.iter().find(|d| d.rule == "unused_variable"); + assert!(unused_diag.is_some(), "Expected unused_variable diagnostic"); + + let diag = unused_diag.unwrap(); + assert!(diag.fix.is_some(), "Expected fix to be available"); + + let fix = diag.fix.as_ref().unwrap(); + assert_eq!(fix.safety, FixSafety::Safe); + assert_eq!(fix.replacement, "_x"); + } + + #[test] + fn test_apply_unused_variable_fix() { + let source = r#"fn main() -> unit { + x := 5; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + let result = apply_fixes(source, &diagnostics, false); + + assert_eq!(result.safe_fixes_applied, 1); + assert!(result.source.contains("_x := 5"), "Expected fix to prefix with underscore"); + } + + #[test] + fn test_apply_multiple_fixes() { + let source = r#"fn main() -> unit { + a := 1; + b := 2; + c := 3; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + let result = apply_fixes(source, &diagnostics, false); + + // Should fix all unused variables + assert!(result.safe_fixes_applied >= 3, "Expected at least 3 fixes, got {}", result.safe_fixes_applied); + assert!(result.source.contains("_a := 1")); + assert!(result.source.contains("_b := 2")); + assert!(result.source.contains("_c := 3")); + } + + #[test] + fn test_fix_decimal_turns() { + let source = r#"fn main() -> unit { + mut q := qalloc(1); + rz(0.25 turns) q[0]; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + // Should have a prefer_fraction_turns diagnostic with a fix + let fraction_diag = diagnostics.iter().find(|d| d.rule == "prefer_fraction_turns"); + assert!(fraction_diag.is_some(), "Expected prefer_fraction_turns diagnostic"); + + let diag = fraction_diag.unwrap(); + assert!(diag.fix.is_some(), "Expected fix to be available"); + + let fix = diag.fix.as_ref().unwrap(); + assert_eq!(fix.safety, FixSafety::Safe); + assert_eq!(fix.replacement, "1/4"); + } + + #[test] + fn test_apply_decimal_turns_fix() { + let source = r#"fn main() -> unit { + mut q := qalloc(1); + rz(0.25 turns) q[0]; + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + let result = apply_fixes(source, &diagnostics, false); + + assert!(result.safe_fixes_applied >= 1); + assert!(result.source.contains("1/4 turns"), "Expected 0.25 to be replaced with 1/4"); + } + + #[test] + fn test_has_safe_fix() { + let diag_with_safe_fix = LintDiagnostic::new("test", "msg", Severity::Warning) + .with_fix(LintFix { + start: 0, + end: 1, + replacement: "x".to_string(), + safety: FixSafety::Safe, + }); + assert!(diag_with_safe_fix.has_safe_fix()); + assert!(diag_with_safe_fix.has_fix()); + + let diag_with_unsafe_fix = LintDiagnostic::new("test", "msg", Severity::Warning) + .with_fix(LintFix { + start: 0, + end: 1, + replacement: "x".to_string(), + safety: FixSafety::Unsafe, + }); + assert!(!diag_with_unsafe_fix.has_safe_fix()); + assert!(diag_with_unsafe_fix.has_fix()); + + let diag_no_fix = LintDiagnostic::new("test", "msg", Severity::Warning); + assert!(!diag_no_fix.has_safe_fix()); + assert!(!diag_no_fix.has_fix()); + } + + #[test] + fn test_no_fixes_to_apply() { + let source = r#"fn main() -> unit { + return unit; +}"#; + let program = crate::parse(source).unwrap(); + let diagnostics = Linter::new(LintConfig::strict()) + .with_source(source) + .lint(&program); + + let result = apply_fixes(source, &diagnostics, false); + + assert_eq!(result.safe_fixes_applied, 0); + assert_eq!(result.unsafe_fixes_applied, 0); + } +} diff --git a/exp/zlup/src/logging.rs b/exp/zlup/src/logging.rs new file mode 100644 index 000000000..c4a33dd39 --- /dev/null +++ b/exp/zlup/src/logging.rs @@ -0,0 +1,113 @@ +//! Logging infrastructure for the Zlup compiler. +//! +//! Uses the standard `RUST_LOG` environment variable to control compiler log output. +//! This follows Rust conventions and integrates well with other Rust tooling. +//! +//! Note: For logging within Zluppy *programs* (the language feature), see the +//! `log` builtin which is controlled by `ZLUP_LOG` at runtime. +//! +//! # Usage +//! +//! Set the `RUST_LOG` environment variable to control compiler logging: +//! +//! ```bash +//! # Show all debug messages +//! RUST_LOG=debug zlup build +//! +//! # Show only warnings and errors +//! RUST_LOG=warn zlup build +//! +//! # Target specific modules +//! RUST_LOG=zlup::parser=trace zlup build +//! RUST_LOG=zlup::semantic=debug,zlup::codegen=info zlup build +//! +//! # Trace everything +//! RUST_LOG=trace zlup build +//! ``` +//! +//! # Log Levels +//! +//! - `error` - Unrecoverable errors +//! - `warn` - Recoverable issues or deprecation warnings +//! - `info` - High-level progress information +//! - `debug` - Detailed debugging information +//! - `trace` - Very verbose tracing (e.g., AST dumps) + +use std::io::Write; + +/// Initialize the Zlup compiler logger. +/// +/// Reads the standard `RUST_LOG` environment variable to configure log levels. +/// If `RUST_LOG` is not set, logging is disabled (no output). +/// +/// This should be called once at the start of the program, typically +/// in `main()`. +/// +/// # Example +/// +/// ```ignore +/// fn main() { +/// zlup::logging::init(); +/// // ... rest of program +/// } +/// ``` +pub fn init() { + env_logger::Builder::from_env(env_logger::Env::default()) + .format(|buf, record| { + let level_style = buf.default_level_style(record.level()); + writeln!( + buf, + "{level_style}[{level}]{level_style:#} {target}: {args}", + level = record.level(), + target = record.target(), + args = record.args(), + ) + }) + .init(); +} + +/// Initialize the Zlup compiler logger with a default level. +/// +/// If `RUST_LOG` is not set, uses the provided default level. +/// This is useful for development builds where you want some +/// output by default. +/// +/// # Example +/// +/// ```ignore +/// fn main() { +/// // Default to info level if RUST_LOG not set +/// zlup::logging::init_with_default("info"); +/// } +/// ``` +pub fn init_with_default(default_level: &str) { + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or(default_level) + ) + .format(|buf, record| { + let level_style = buf.default_level_style(record.level()); + writeln!( + buf, + "{level_style}[{level}]{level_style:#} {target}: {args}", + level = record.level(), + target = record.target(), + args = record.args(), + ) + }) + .init(); +} + +/// Check if logging is enabled at the given level. +/// +/// Useful for avoiding expensive formatting when logging is disabled. +/// +/// # Example +/// +/// ```ignore +/// if zlup::logging::enabled(log::Level::Debug) { +/// log::debug!("Expensive debug info: {:?}", compute_debug_info()); +/// } +/// ``` +pub fn enabled(level: log::Level) -> bool { + log::log_enabled!(level) +} diff --git a/exp/zlup/src/lsp/main.rs b/exp/zlup/src/lsp/main.rs new file mode 100644 index 000000000..2b198068b --- /dev/null +++ b/exp/zlup/src/lsp/main.rs @@ -0,0 +1,850 @@ +//! Zlups - Language Server Protocol implementation for Zlup +//! +//! Provides IDE features like diagnostics, syntax highlighting, hover, and completions. + +use std::collections::BTreeMap; +use std::sync::Arc; +use tokio::sync::RwLock; +use tower_lsp::jsonrpc::Result; +use tower_lsp::lsp_types::*; +use tower_lsp::{Client, LanguageServer, LspService, Server}; + +use zlup::ast::SourceLocation; +use zlup::semantic::SemanticAnalyzer; + +mod semantic_tokens; + +use semantic_tokens::{LEGEND, token_types_for_source}; + +/// Document state tracked by the server +#[derive(Debug, Clone)] +struct Document { + content: String, + /// Document version for incremental updates (reserved for future use) + #[allow(dead_code)] + version: i32, +} + +/// The Zlups language server +struct ZlupsServer { + client: Client, + documents: Arc>>, +} + +impl ZlupsServer { + fn new(client: Client) -> Self { + Self { + client, + documents: Arc::new(RwLock::new(BTreeMap::new())), + } + } + + /// Analyze a document and publish diagnostics + async fn analyze_and_publish(&self, uri: Url, content: &str, version: i32) { + let diagnostics = self.get_diagnostics(content); + self.client + .publish_diagnostics(uri, diagnostics, Some(version)) + .await; + } + + /// Get diagnostics for the given source code + fn get_diagnostics(&self, source: &str) -> Vec { + let mut diagnostics = Vec::new(); + + // Try parsing + let program = match zlup::parse(source) { + Ok(p) => p, + Err(e) => { + // Extract location from pest error + let error_str = e.to_string(); + let (line, col) = extract_pest_location(&error_str); + let message = friendly_parse_error(&error_str); + + diagnostics.push(Diagnostic { + range: Range { + start: Position { + line: line.saturating_sub(1), + character: col.saturating_sub(1), + }, + end: Position { + line: line.saturating_sub(1), + character: col, + }, + }, + severity: Some(DiagnosticSeverity::ERROR), + code: Some(NumberOrString::String("parse-error".to_string())), + source: Some("zlups".to_string()), + message, + ..Default::default() + }); + return diagnostics; + } + }; + + // Try semantic analysis + let mut analyzer = SemanticAnalyzer::new(); + if let Err(e) = analyzer.analyze(&program) { + let (range, message) = if let Some(loc) = e.location() { + (location_to_range(loc), e.to_string()) + } else { + ( + Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: 0, + character: 1, + }, + }, + e.to_string(), + ) + }; + + diagnostics.push(Diagnostic { + range, + severity: Some(DiagnosticSeverity::ERROR), + code: Some(NumberOrString::String("semantic-error".to_string())), + source: Some("zlups".to_string()), + message, + ..Default::default() + }); + } + + diagnostics + } +} + +#[tower_lsp::async_trait] +impl LanguageServer for ZlupsServer { + async fn initialize(&self, _: InitializeParams) -> Result { + Ok(InitializeResult { + capabilities: ServerCapabilities { + text_document_sync: Some(TextDocumentSyncCapability::Kind( + TextDocumentSyncKind::FULL, + )), + semantic_tokens_provider: Some( + SemanticTokensServerCapabilities::SemanticTokensOptions( + SemanticTokensOptions { + legend: LEGEND.clone(), + full: Some(SemanticTokensFullOptions::Bool(true)), + range: Some(false), + ..Default::default() + }, + ), + ), + hover_provider: Some(HoverProviderCapability::Simple(true)), + completion_provider: Some(CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..Default::default() + }), + definition_provider: Some(OneOf::Left(true)), + document_formatting_provider: Some(OneOf::Left(true)), + ..Default::default() + }, + server_info: Some(ServerInfo { + name: "zlups".to_string(), + version: Some(env!("CARGO_PKG_VERSION").to_string()), + }), + }) + } + + async fn initialized(&self, _: InitializedParams) { + self.client + .log_message(MessageType::INFO, "Zlups server initialized") + .await; + } + + async fn shutdown(&self) -> Result<()> { + Ok(()) + } + + async fn did_open(&self, params: DidOpenTextDocumentParams) { + let uri = params.text_document.uri; + let content = params.text_document.text; + let version = params.text_document.version; + + { + let mut docs = self.documents.write().await; + docs.insert( + uri.clone(), + Document { + content: content.clone(), + version, + }, + ); + } + + self.analyze_and_publish(uri, &content, version).await; + } + + async fn did_change(&self, params: DidChangeTextDocumentParams) { + let uri = params.text_document.uri; + let version = params.text_document.version; + + // We use full sync, so there's exactly one change with the full content + if let Some(change) = params.content_changes.into_iter().next() { + let content = change.text; + + { + let mut docs = self.documents.write().await; + docs.insert( + uri.clone(), + Document { + content: content.clone(), + version, + }, + ); + } + + self.analyze_and_publish(uri, &content, version).await; + } + } + + async fn did_close(&self, params: DidCloseTextDocumentParams) { + let mut docs = self.documents.write().await; + docs.remove(¶ms.text_document.uri); + } + + async fn semantic_tokens_full( + &self, + params: SemanticTokensParams, + ) -> Result> { + let docs = self.documents.read().await; + let Some(doc) = docs.get(¶ms.text_document.uri) else { + return Ok(None); + }; + + let tokens = token_types_for_source(&doc.content); + Ok(Some(SemanticTokensResult::Tokens(SemanticTokens { + result_id: None, + data: tokens, + }))) + } + + async fn hover(&self, params: HoverParams) -> Result> { + let docs = self.documents.read().await; + let Some(doc) = docs.get(¶ms.text_document_position_params.text_document.uri) else { + return Ok(None); + }; + + let position = params.text_document_position_params.position; + let hover_info = get_hover_info(&doc.content, position); + + Ok(hover_info.map(|content| Hover { + contents: HoverContents::Markup(MarkupContent { + kind: MarkupKind::Markdown, + value: content, + }), + range: None, + })) + } + + async fn completion(&self, params: CompletionParams) -> Result> { + let docs = self.documents.read().await; + let Some(doc) = docs.get(¶ms.text_document_position.text_document.uri) else { + return Ok(None); + }; + + let position = params.text_document_position.position; + let completions = get_context_aware_completions(&doc.content, position); + Ok(Some(CompletionResponse::Array(completions))) + } + + async fn formatting( + &self, + params: DocumentFormattingParams, + ) -> Result>> { + let docs = self.documents.read().await; + let Some(doc) = docs.get(¶ms.text_document.uri) else { + return Ok(None); + }; + + let formatted = format_source(&doc.content, ¶ms.options); + + // If formatting produced changes, return a single edit replacing the whole document + if formatted != doc.content { + let line_count = doc.content.lines().count() as u32; + let last_line_len = doc.content.lines().last().map(|l| l.len()).unwrap_or(0) as u32; + + Ok(Some(vec![TextEdit { + range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: line_count, + character: last_line_len, + }, + }, + new_text: formatted, + }])) + } else { + Ok(None) + } + } + + async fn goto_definition( + &self, + params: GotoDefinitionParams, + ) -> Result> { + let uri = params.text_document_position_params.text_document.uri.clone(); + let position = params.text_document_position_params.position; + + let docs = self.documents.read().await; + let Some(doc) = docs.get(&uri) else { + return Ok(None); + }; + + // Find the word at the cursor position + let Some(word) = get_word_at_position(&doc.content, position) else { + return Ok(None); + }; + + // Parse and analyze to get symbol table + let Ok(program) = zlup::parse(&doc.content) else { + return Ok(None); + }; + + let mut analyzer = SemanticAnalyzer::new(); + // Analyze even if there are errors - we still want partial symbol info + let _ = analyzer.analyze(&program); + + // Look up the symbol + if let Some(symbol) = analyzer.symbols.lookup(&word) + && let Some(loc) = &symbol.location { + let range = location_to_range(loc); + return Ok(Some(GotoDefinitionResponse::Scalar(Location { + uri, + range, + }))); + } + + Ok(None) + } +} + +/// Convert a SourceLocation to an LSP Range +fn location_to_range(loc: &SourceLocation) -> Range { + Range { + start: Position { + line: loc.line.saturating_sub(1), + character: loc.column.saturating_sub(1), + }, + end: Position { + line: loc.end_line.saturating_sub(1), + character: loc.end_column.saturating_sub(1), + }, + } +} + +/// Extract line and column from a pest error message +fn extract_pest_location(error_msg: &str) -> (u32, u32) { + // Pest errors typically contain " --> line:column" + if let Some(pos) = error_msg.find(" --> ") { + let rest = &error_msg[pos + 5..]; + if let Some(colon) = rest.find(':') { + let line_str = &rest[..colon]; + let col_end = rest[colon + 1..] + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(rest.len() - colon - 1); + let col_str = &rest[colon + 1..colon + 1 + col_end]; + + if let (Ok(line), Ok(col)) = (line_str.parse::(), col_str.parse::()) { + return (line, col); + } + } + } + (1, 1) +} + +/// Convert pest parse errors to user-friendly messages +fn friendly_parse_error(pest_message: &str) -> String { + if pest_message.contains("expected identifier") { + "expected an identifier".to_string() + } else if pest_message.contains("expected type_expr") { + "expected a type".to_string() + } else if pest_message.contains("expected expr") { + "expected an expression".to_string() + } else if pest_message.contains("expected statement") { + "expected a statement".to_string() + } else if pest_message.contains("expected \"(\"") { + "expected '('".to_string() + } else if pest_message.contains("expected \")\"") { + "expected ')'".to_string() + } else if pest_message.contains("expected \"{\"") { + "expected '{'".to_string() + } else if pest_message.contains("expected \"}\"") { + "expected '}'".to_string() + } else if pest_message.contains("expected \";\"") { + "expected ';'".to_string() + } else if pest_message.contains("expected assign_op") { + "unexpected token - expected assignment or operator".to_string() + } else if pest_message.contains("expected top_level_decl") { + "expected a function, constant, or type declaration".to_string() + } else if pest_message.contains("expected EOI") { + "unexpected content after end of file".to_string() + } else { + // Take only the first line of the error + pest_message + .lines() + .next() + .unwrap_or(pest_message) + .to_string() + } +} + +/// Get the word at a given position in the source +fn get_word_at_position(source: &str, position: Position) -> Option { + let lines: Vec<&str> = source.lines().collect(); + let line = lines.get(position.line as usize)?; + let char_pos = position.character as usize; + + if char_pos > line.len() { + return None; + } + + // Find the word boundaries + let start = line[..char_pos] + .rfind(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + 1) + .unwrap_or(0); + let end = line[char_pos..] + .find(|c: char| !c.is_alphanumeric() && c != '_') + .map(|i| i + char_pos) + .unwrap_or(line.len()); + + if start >= end { + return None; + } + + Some(line[start..end].to_string()) +} + +/// Get hover information for a position in the source +fn get_hover_info(source: &str, position: Position) -> Option { + let word = get_word_at_position(source, position)?; + + // Return documentation for known items + match word.as_str() { + // Keywords + "fn" => Some("**fn** - Function declaration\n\n```zlup\nfn name(params) -> ReturnType { ... }\n```".to_string()), + "mut" => Some("**mut** - Mutable binding modifier\n\n```zlup\nmut x := 42; // type inferred\nmut x: i32 = 42; // explicit type\n```".to_string()), + "if" => Some("**if** - Conditional expression\n\n```zlup\nif condition { ... } else { ... }\n```".to_string()), + "for" => Some("**for** - Bounded iteration loop (NASA Power of 10)\n\n```zlup\nfor i in 0..10 { ... }\n```".to_string()), + "return" => Some("**return** - Return from function\n\n```zlup\nreturn value;\n```".to_string()), + "defer" => Some("**defer** - Execute at scope exit\n\n```zlup\ndefer resource.close();\n```".to_string()), + "errdefer" => Some("**errdefer** - Execute at scope exit on error\n\n```zlup\nerrdefer cleanup();\n```".to_string()), + + // Types + "unit" => Some("**unit** - Unit type (single value, used for functions with no meaningful return)".to_string()), + "bool" => Some("**bool** - Boolean type (true/false)".to_string()), + "i32" => Some("**i32** - 32-bit signed integer".to_string()), + "i64" => Some("**i64** - 64-bit signed integer".to_string()), + "u32" => Some("**u32** - 32-bit unsigned integer".to_string()), + "u64" => Some("**u64** - 64-bit unsigned integer".to_string()), + "f32" => Some("**f32** - 32-bit floating point".to_string()), + "f64" => Some("**f64** - 64-bit floating point".to_string()), + "usize" => Some("**usize** - Platform-dependent unsigned integer (array indices)".to_string()), + "QubitArray" => Some("**QubitArray** - Array of qubits for quantum operations".to_string()), + + // Built-in functions + "qalloc" => Some("**qalloc(n)** - Allocate n qubits\n\n```zlup\nvar q = qalloc(4); // Allocate 4 qubits\n```".to_string()), + "print" => Some("**print(...)** - Print to stdout".to_string()), + + // Quantum gates + "h" | "H" => Some("**H** (Hadamard) - Creates superposition\n\n```zlup\nq.h(0); // Apply H to qubit 0\n```".to_string()), + "x" | "X" => Some("**X** (Pauli-X) - Bit flip gate\n\n```zlup\nq.x(0); // Apply X to qubit 0\n```".to_string()), + "y" | "Y" => Some("**Y** (Pauli-Y) - Y rotation gate\n\n```zlup\nq.y(0); // Apply Y to qubit 0\n```".to_string()), + "z" | "Z" => Some("**Z** (Pauli-Z) - Phase flip gate\n\n```zlup\nq.z(0); // Apply Z to qubit 0\n```".to_string()), + "cx" | "CX" | "cnot" | "CNOT" => Some("**CX/CNOT** - Controlled-X (CNOT) gate\n\n```zlup\nq.cx(0, 1); // Control: 0, Target: 1\n```".to_string()), + "cz" | "CZ" => Some("**CZ** - Controlled-Z gate\n\n```zlup\nq.cz(0, 1); // Apply CZ between qubits 0 and 1\n```".to_string()), + "rx" | "RX" => Some("**RX(theta)** - X-axis rotation\n\n```zlup\nq.rx(0, 3.14159); // Rotate qubit 0 by pi\n```".to_string()), + "ry" | "RY" => Some("**RY(theta)** - Y-axis rotation\n\n```zlup\nq.ry(0, 3.14159); // Rotate qubit 0 by pi\n```".to_string()), + "rz" | "RZ" => Some("**RZ(theta)** - Z-axis rotation\n\n```zlup\nq.rz(0, 3.14159); // Rotate qubit 0 by pi\n```".to_string()), + "t" | "T" => Some("**T** - T gate (pi/4 phase)\n\n```zlup\nt q[0]; // Apply T to qubit 0\n```".to_string()), + "sz" | "SZ" => Some("**SZ** - S gate (pi/2 phase, sqrt of Z)\n\n```zlup\nsz q[0]; // Apply S to qubit 0\n```".to_string()), + "mz" | "measure" => Some("**mz** - Measure qubit in Z basis\n\n```zlup\nconst result = mz(u1) q[0]; // Measure qubit 0\n```".to_string()), + "pz" => Some("**pz** - Prepare qubits in |0⟩ state\n\n```zlup\npz q; // Prepare all qubits\npz {q[0], q[1]}; // Prepare specific qubits\n```".to_string()), + + _ => None, + } +} + +/// Get context-aware completions based on cursor position +fn get_context_aware_completions(source: &str, position: Position) -> Vec { + let lines: Vec<&str> = source.lines().collect(); + let Some(line) = lines.get(position.line as usize) else { + return get_default_completions(); + }; + + let char_pos = position.character as usize; + let prefix = if char_pos <= line.len() { + &line[..char_pos] + } else { + line + }; + + // Check if we're completing after a '.' + if let Some(dot_pos) = prefix.rfind('.') { + // Get the identifier before the dot + let before_dot = &prefix[..dot_pos]; + let ident_start = before_dot + .rfind(|c: char| !c.is_alphanumeric() && c != '_' && c != '[' && c != ']') + .map(|i| i + 1) + .unwrap_or(0); + let identifier = before_dot[ident_start..].trim(); + + // Check if this looks like an allocator (contains qalloc or common allocator names) + if is_likely_allocator(source, identifier) { + return get_allocator_completions(); + } + + // Generic field/method completions + return get_method_completions(); + } + + // Check if we're in a type context (after ':' or '->') + let trimmed = prefix.trim_end(); + if trimmed.ends_with(':') || trimmed.ends_with("->") { + return get_type_completions(); + } + + // Default: keywords, types, and functions + get_default_completions() +} + +/// Check if an identifier is likely an allocator variable +fn is_likely_allocator(source: &str, identifier: &str) -> bool { + // Strip array indexing like q[0] -> q + let base_ident = identifier.split('[').next().unwrap_or(identifier); + + // Check if there's a qalloc assignment for this identifier + let pattern = format!("{} = qalloc", base_ident); + if source.contains(&pattern) { + return true; + } + + // Check for .child() assignment + let child_pattern = format!("{} = ", base_ident); + for line in source.lines() { + if line.contains(&child_pattern) && line.contains(".child(") { + return true; + } + } + + // Common allocator variable names + matches!( + base_ident, + "q" | "qubits" | "base" | "data" | "ancilla" | "alloc" | "allocator" + ) +} + +/// Get completions for allocator methods +fn get_allocator_completions() -> Vec { + let methods = [ + ("child", "Create child allocator", "child(${1:size})"), + ("release", "Release allocated qubits", "release()"), + ]; + + methods + .iter() + .map(|(label, detail, snippet)| CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::METHOD), + detail: Some(detail.to_string()), + insert_text: Some(snippet.to_string()), + insert_text_format: Some(InsertTextFormat::SNIPPET), + ..Default::default() + }) + .collect() +} + +/// Get generic method completions +fn get_method_completions() -> Vec { + // Combine allocator methods with common methods + let mut items = get_allocator_completions(); + + // Add common struct methods/fields + let common = [ + ("len", "Get length", "len()"), + ("is_empty", "Check if empty", "is_empty()"), + ]; + + for (label, detail, snippet) in common { + items.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::METHOD), + detail: Some(detail.to_string()), + insert_text: Some(snippet.to_string()), + insert_text_format: Some(InsertTextFormat::SNIPPET), + ..Default::default() + }); + } + + items +} + +/// Get type completions +fn get_type_completions() -> Vec { + let types = [ + ("unit", "Unit type (no meaningful return value)"), + ("bool", "Boolean type"), + ("u1", "1-bit unsigned (measurement result)"), + ("u8", "8-bit unsigned integer"), + ("u16", "16-bit unsigned integer"), + ("u32", "32-bit unsigned integer"), + ("u64", "64-bit unsigned integer"), + ("u128", "128-bit unsigned integer"), + ("usize", "Platform-sized unsigned integer"), + ("i8", "8-bit signed integer"), + ("i16", "16-bit signed integer"), + ("i32", "32-bit signed integer"), + ("i64", "64-bit signed integer"), + ("i128", "128-bit signed integer"), + ("f32", "32-bit floating point"), + ("f64", "64-bit floating point"), + ("a64", "64-bit angle type"), + ("qubit", "Qubit type"), + ("bit", "Classical bit"), + ]; + + types + .iter() + .map(|(label, detail)| CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::TYPE_PARAMETER), + detail: Some(detail.to_string()), + ..Default::default() + }) + .collect() +} + +/// Format source code according to formatting options +fn format_source(source: &str, options: &FormattingOptions) -> String { + let indent_str = if options.insert_spaces { + " ".repeat(options.tab_size as usize) + } else { + "\t".to_string() + }; + + let mut result = String::new(); + let mut indent_level: i32 = 0; + let mut in_string = false; + let mut prev_char = '\0'; + + for line in source.lines() { + let trimmed = line.trim(); + + // Skip empty lines but preserve one blank line + if trimmed.is_empty() { + if !result.ends_with("\n\n") { + result.push('\n'); + } + continue; + } + + // Adjust indent for closing braces at start of line + let starts_with_close = trimmed.starts_with('}') || trimmed.starts_with(')'); + if starts_with_close && indent_level > 0 { + indent_level -= 1; + } + + // Write indentation + for _ in 0..indent_level { + result.push_str(&indent_str); + } + + // Format the line content + let formatted_line = format_line(trimmed); + result.push_str(&formatted_line); + result.push('\n'); + + // Adjust indent for next line based on braces in this line + for ch in trimmed.chars() { + match ch { + '"' if prev_char != '\\' => in_string = !in_string, + '{' | '(' if !in_string => indent_level += 1, + '}' | ')' if !in_string && !starts_with_close => { + indent_level = (indent_level - 1).max(0); + } + _ => {} + } + prev_char = ch; + } + } + + // Remove trailing whitespace from each line and ensure single newline at end + let result: String = result + .lines() + .map(|l| l.trim_end()) + .collect::>() + .join("\n"); + + if result.is_empty() { + result + } else { + result + "\n" + } +} + +/// Format a single line (spacing around operators, etc.) +fn format_line(line: &str) -> String { + let mut result = String::new(); + let mut chars = line.chars().peekable(); + let mut in_string = false; + let mut prev_char = '\0'; + + while let Some(ch) = chars.next() { + // Track string state + if ch == '"' && prev_char != '\\' { + in_string = !in_string; + } + + if in_string { + result.push(ch); + prev_char = ch; + continue; + } + + match ch { + // Ensure space after comma + ',' => { + result.push(','); + if chars.peek() != Some(&' ') && chars.peek() != Some(&'\n') { + result.push(' '); + } + } + // Ensure space around '=' (but not ==, !=, <=, >=, =>) + '=' => { + let next = chars.peek().copied(); + if next == Some('=') || next == Some('>') { + // Part of ==, =>, don't add space before + if !result.ends_with(' ') && !result.ends_with('!') && !result.ends_with('<') && !result.ends_with('>') { + result.push(' '); + } + result.push('='); + } else if prev_char == '!' || prev_char == '<' || prev_char == '>' || prev_char == '=' { + // Part of !=, <=, >=, == + result.push('='); + if chars.peek() != Some(&' ') { + result.push(' '); + } + } else { + // Standalone = + if !result.ends_with(' ') { + result.push(' '); + } + result.push('='); + if chars.peek() != Some(&' ') && chars.peek().is_some() { + result.push(' '); + } + } + } + // Ensure space after colon in type annotations (but not ::) + ':' => { + result.push(':'); + if chars.peek() != Some(&':') && chars.peek() != Some(&' ') && chars.peek().is_some() { + result.push(' '); + } + } + // Collapse multiple spaces + ' ' => { + if !result.ends_with(' ') { + result.push(' '); + } + } + _ => result.push(ch), + } + + prev_char = ch; + } + + result +} + +/// Get default completions (keywords, types, functions) +fn get_default_completions() -> Vec { + let mut items = Vec::new(); + + // Keywords + let keywords = [ + ("fn", "Function declaration", "fn ${1:name}(${2:params}) -> ${3:unit} {\n\treturn unit;\n}"), + ("mut", "Mutable binding", "mut ${1:name} := ${0:value};"), + ("if", "Conditional", "if ${1:condition} {\n\t$0\n}"), + ("else", "Else branch", "else {\n\t$0\n}"), + ("for", "Bounded for loop", "for ${1:i} in ${2:0}..${3:n} {\n\t$0\n}"), + ("return", "Return statement", "return ${0:value};"), + ("defer", "Defer statement", "defer ${0:expr};"), + ("errdefer", "Error defer", "errdefer ${0:expr};"), + ("struct", "Struct type", "struct {\n\t${1:field}: ${2:type},\n}"), + ("enum", "Enum type", "enum {\n\t${1:Variant},\n}"), + ("union", "Tagged union", "union(enum) {\n\t${1:Variant}: ${2:type},\n}"), + ("error", "Error set", "error {\n\t${1:ErrorName},\n}"), + ("set", "Set literal", "set { ${0:elements} }"), + ]; + + for (label, detail, snippet) in keywords { + items.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::KEYWORD), + detail: Some(detail.to_string()), + insert_text: Some(snippet.to_string()), + insert_text_format: Some(InsertTextFormat::SNIPPET), + ..Default::default() + }); + } + + // Types + let types = [ + "unit", "bool", "i32", "i64", "u32", "u64", "f32", "f64", "usize", "QubitArray", + ]; + + for ty in types { + items.push(CompletionItem { + label: ty.to_string(), + kind: Some(CompletionItemKind::TYPE_PARAMETER), + detail: Some("Built-in type".to_string()), + ..Default::default() + }); + } + + // Quantum functions + let quantum_funcs = [ + ("qalloc", "Allocate qubits", "qalloc(${1:n})"), + ("h", "Hadamard gate", "h(${1:qubit})"), + ("x", "Pauli-X gate", "x(${1:qubit})"), + ("y", "Pauli-Y gate", "y(${1:qubit})"), + ("z", "Pauli-Z gate", "z(${1:qubit})"), + ("cx", "CNOT gate", "cx(${1:control}, ${2:target})"), + ("cz", "CZ gate", "cz(${1:q1}, ${2:q2})"), + ("rx", "RX rotation", "rx(${1:qubit}, ${2:angle})"), + ("ry", "RY rotation", "ry(${1:qubit}, ${2:angle})"), + ("rz", "RZ rotation", "rz(${1:qubit}, ${2:angle})"), + ("mz", "Measure Z", "mz(${1:qubit})"), + ]; + + for (label, detail, snippet) in quantum_funcs { + items.push(CompletionItem { + label: label.to_string(), + kind: Some(CompletionItemKind::FUNCTION), + detail: Some(detail.to_string()), + insert_text: Some(snippet.to_string()), + insert_text_format: Some(InsertTextFormat::SNIPPET), + ..Default::default() + }); + } + + items +} + +#[tokio::main] +async fn main() { + let stdin = tokio::io::stdin(); + let stdout = tokio::io::stdout(); + + let (service, socket) = LspService::new(ZlupsServer::new); + Server::new(stdin, stdout, socket).serve(service).await; +} diff --git a/exp/zlup/src/lsp/semantic_tokens.rs b/exp/zlup/src/lsp/semantic_tokens.rs new file mode 100644 index 000000000..2f3e97c82 --- /dev/null +++ b/exp/zlup/src/lsp/semantic_tokens.rs @@ -0,0 +1,279 @@ +//! Semantic token support for syntax highlighting + +use once_cell::sync::Lazy; +use tower_lsp::lsp_types::*; + +/// Token types used for semantic highlighting +pub static LEGEND: Lazy = Lazy::new(|| SemanticTokensLegend { + token_types: vec![ + SemanticTokenType::KEYWORD, + SemanticTokenType::TYPE, + SemanticTokenType::FUNCTION, + SemanticTokenType::VARIABLE, + SemanticTokenType::NUMBER, + SemanticTokenType::STRING, + SemanticTokenType::OPERATOR, + SemanticTokenType::COMMENT, + SemanticTokenType::PARAMETER, + SemanticTokenType::PROPERTY, + ], + token_modifiers: vec![ + SemanticTokenModifier::DECLARATION, + SemanticTokenModifier::DEFINITION, + SemanticTokenModifier::READONLY, + ], +}); + +// Token type indices (must match LEGEND order) +const TT_KEYWORD: u32 = 0; +const TT_TYPE: u32 = 1; +const TT_FUNCTION: u32 = 2; +const TT_VARIABLE: u32 = 3; +const TT_NUMBER: u32 = 4; +const TT_STRING: u32 = 5; +const TT_OPERATOR: u32 = 6; +const TT_COMMENT: u32 = 7; +#[allow(dead_code)] +const TT_PARAMETER: u32 = 8; +#[allow(dead_code)] +const TT_PROPERTY: u32 = 9; + +/// Keywords in Zlup +const KEYWORDS: &[&str] = &[ + "fn", "mut", "if", "else", "for", "return", "defer", "errdefer", + "struct", "enum", "union", "error", "try", "catch", "orelse", "break", "continue", + "comptime", "inline", "pub", "and", "or", "not", "true", "false", "null", "undefined", +]; + +/// Built-in types +const TYPES: &[&str] = &[ + "unit", "bool", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "f32", "f64", + "usize", "isize", "QubitArray", "Qubit", +]; + +/// Quantum gate names (functions) +const GATES: &[&str] = &[ + "h", "H", "x", "X", "y", "Y", "z", "Z", "s", "S", "t", "T", + "cx", "CX", "cnot", "CNOT", "cz", "CZ", "cy", "CY", + "rx", "RX", "ry", "RY", "rz", "RZ", + "swap", "SWAP", "ccx", "CCX", "toffoli", + "mz", "mx", "my", "measure", "pz", + "qalloc", +]; + +/// Built-in functions +const BUILTINS: &[&str] = &["print", "println", "assert", "unreachable"]; + +/// Generate semantic tokens for source code +pub fn token_types_for_source(source: &str) -> Vec { + let mut tokens = Vec::new(); + let mut prev_line = 0u32; + let mut prev_char = 0u32; + + for (line_num, line) in source.lines().enumerate() { + let line_num = line_num as u32; + let mut char_idx = 0u32; + let chars: Vec = line.chars().collect(); + + while (char_idx as usize) < chars.len() { + let c = chars[char_idx as usize]; + + // Skip whitespace + if c.is_whitespace() { + char_idx += 1; + continue; + } + + // Line comments + if c == '/' && chars.get(char_idx as usize + 1) == Some(&'/') { + let len = (chars.len() - char_idx as usize) as u32; + add_token( + &mut tokens, + line_num, + char_idx, + len, + TT_COMMENT, + &mut prev_line, + &mut prev_char, + ); + break; // Rest of line is comment + } + + // String literals + if c == '"' { + let start = char_idx; + char_idx += 1; + while (char_idx as usize) < chars.len() { + let ch = chars[char_idx as usize]; + if ch == '"' { + char_idx += 1; + break; + } + if ch == '\\' { + char_idx += 1; // Skip escape + } + char_idx += 1; + } + let len = char_idx - start; + add_token( + &mut tokens, + line_num, + start, + len, + TT_STRING, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Character literals + if c == '\'' { + let start = char_idx; + char_idx += 1; + while (char_idx as usize) < chars.len() { + let ch = chars[char_idx as usize]; + if ch == '\'' { + char_idx += 1; + break; + } + if ch == '\\' { + char_idx += 1; + } + char_idx += 1; + } + let len = char_idx - start; + add_token( + &mut tokens, + line_num, + start, + len, + TT_STRING, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Numbers + if c.is_ascii_digit() { + let start = char_idx; + while (char_idx as usize) < chars.len() { + let ch = chars[char_idx as usize]; + if ch.is_ascii_alphanumeric() || ch == '.' || ch == '_' { + char_idx += 1; + } else { + break; + } + } + let len = char_idx - start; + add_token( + &mut tokens, + line_num, + start, + len, + TT_NUMBER, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Identifiers and keywords + if c.is_alphabetic() || c == '_' { + let start = char_idx; + while (char_idx as usize) < chars.len() { + let ch = chars[char_idx as usize]; + if ch.is_alphanumeric() || ch == '_' { + char_idx += 1; + } else { + break; + } + } + let len = char_idx - start; + let word: String = chars[start as usize..char_idx as usize].iter().collect(); + + let token_type = if KEYWORDS.contains(&word.as_str()) { + TT_KEYWORD + } else if TYPES.contains(&word.as_str()) { + TT_TYPE + } else if GATES.contains(&word.as_str()) || BUILTINS.contains(&word.as_str()) { + TT_FUNCTION + } else { + TT_VARIABLE + }; + + add_token( + &mut tokens, + line_num, + start, + len, + token_type, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Operators (multi-char) + if is_operator_char(c) { + let start = char_idx; + while (char_idx as usize) < chars.len() && is_operator_char(chars[char_idx as usize]) { + char_idx += 1; + } + let len = char_idx - start; + add_token( + &mut tokens, + line_num, + start, + len, + TT_OPERATOR, + &mut prev_line, + &mut prev_char, + ); + continue; + } + + // Single punctuation (skip) + char_idx += 1; + } + } + + tokens +} + +fn is_operator_char(c: char) -> bool { + matches!( + c, + '+' | '-' | '*' | '/' | '%' | '=' | '!' | '<' | '>' | '&' | '|' | '^' | '~' | '@' + ) +} + +fn add_token( + tokens: &mut Vec, + line: u32, + character: u32, + length: u32, + token_type: u32, + prev_line: &mut u32, + prev_char: &mut u32, +) { + // LSP semantic tokens use delta encoding + let delta_line = line - *prev_line; + let delta_start = if delta_line == 0 { + character - *prev_char + } else { + character + }; + + tokens.push(SemanticToken { + delta_line, + delta_start, + length, + token_type, + token_modifiers_bitset: 0, + }); + + *prev_line = line; + *prev_char = character; +} diff --git a/exp/zlup/src/main.rs b/exp/zlup/src/main.rs new file mode 100644 index 000000000..d4af62f73 --- /dev/null +++ b/exp/zlup/src/main.rs @@ -0,0 +1,2045 @@ +//! Zluppy CLI - A Zig/SLR/NASA Power of 10 quantum programming language. +//! +//! ## Usage +//! +//! ```bash +//! # Initialize a new project +//! zlup init my-project +//! +//! # Build project using zlup.toml +//! zlup build +//! +//! # Compile to SLR-AST JSON (Python/PECOS bridge) +//! zlup compile program.zlp --target slr -o output.json +//! +//! # Compile to OpenQASM 2.0 (simulators/hardware) +//! zlup compile program.zlp --target qasm -o output.qasm +//! +//! # Compile to HUGR (requires --features hugr) +//! zlup compile program.zlp --target hugr -o output.hugr +//! +//! # Check without compiling (semantic validation) +//! zlup check program.zlp +//! +//! # Check with strict mode (NASA Power of 10) +//! zlup check program.zlp --strict +//! +//! # Parse and dump AST (for debugging) +//! zlup parse program.zlp +//! +//! # Analyze parallelism opportunities +//! zlup analyze program.zlp +//! zlup analyze program.zlp --format json --verbose +//! ``` + +// Experimental - suppress warnings during development +// The unused_assignments warning is triggered by miette's derive macro +// for the #[source_code], #[label], and #[help] fields +#![allow(dead_code, unused_assignments)] + +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use clap::{Parser, Subcommand, ValueEnum}; +use miette::{Diagnostic, NamedSource, SourceSpan}; + +use zlup::codegen::SlrCodegen; +use zlup::config::{Config, TargetConfig, CONFIG_FILE_NAME}; +use zlup::semantic::SemanticAnalyzer; + +// ============================================================================= +// CLI Structure +// ============================================================================= + +/// Zluppy - A Zig/SLR/NASA Power of 10 quantum programming language. +/// +/// Zluppy solves the same problems as Guppy but through a different lens: +/// explicit over implicit, low-level but safe, simple and obvious. +#[derive(Parser)] +#[command(name = "zlup")] +#[command(version, about, long_about = None)] +#[command(propagate_version = true)] +struct Cli { + #[command(subcommand)] + command: Commands, +} + +#[derive(Subcommand)] +enum Commands { + /// Initialize a new Zlup project with zlup.toml. + Init { + /// Project name (also creates directory with this name) + #[arg(value_name = "NAME")] + name: String, + + /// Create project in current directory instead of new directory + #[arg(long)] + here: bool, + }, + + /// Build project using zlup.toml configuration. + Build { + /// Override strict mode setting + #[arg(long)] + strict: Option, + + /// Override execution target + #[arg(short, long, value_enum)] + target: Option, + + /// Override output format + #[arg(short, long, value_enum)] + format: Option, + + /// Build mode (debug/release) + #[arg(short, long, value_enum, default_value = "debug")] + mode: Mode, + + /// Emit compact output (no pretty-printing) + #[arg(long)] + compact: bool, + }, + + /// Compile a Zluppy source file to a target format. + Compile { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Output file (use - for stdout, default: derived from input) + #[arg(short, long, value_name = "FILE")] + output: Option, + + /// Execution target (what you're compiling for) + #[arg(short, long, value_enum, default_value = "simulator")] + target: Target, + + /// Output format (how to serialize) + #[arg(short, long, value_enum, default_value = "slr")] + format: Format, + + /// Build mode (debug/release) + #[arg(short, long, value_enum, default_value = "debug")] + mode: Mode, + + /// Emit compact output (no pretty-printing) + #[arg(long)] + compact: bool, + + /// Override strict mode from target+mode defaults + #[arg(long)] + strict: Option, + + /// Override log level (elide logs below this level) + /// Values: trace=0, debug=100, info=200, warn=300, error=400 + #[arg(long, value_name = "LEVEL")] + log_level: Option, + + /// Completely elide sim.* commands (no barrier). + /// By default, hardware targets emit barriers for sim commands to + /// preserve ordering. This flag removes them entirely for max optimization. + #[arg(long)] + elide_sim: bool, + }, + + /// Check a Zluppy source file for errors without compiling. + Check { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Enable strict mode (NASA Power of 10 checks) + #[arg(long)] + strict: bool, + }, + + /// Parse a Zluppy source file and dump the AST (for debugging). + Parse { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Output format + #[arg(short, long, value_enum, default_value = "debug")] + format: AstFormat, + }, + + /// Format a Zluppy source file. + #[command(name = "fmt")] + Format { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Write output to file instead of stdout + #[arg(short, long)] + write: bool, + + /// Check if file is formatted (exit 1 if not) + #[arg(long)] + check: bool, + }, + + /// Lint a Zluppy source file for style and best practices. + Lint { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Lint configuration level (default is strict for safety) + #[arg(short, long, value_enum, default_value = "strict")] + level: LintLevel, + + /// Treat warnings as errors + #[arg(long)] + deny_warnings: bool, + + /// Output format + #[arg(short, long, value_enum, default_value = "pretty")] + format: LintFormat, + + /// Apply safe fixes automatically + #[arg(long)] + fix: bool, + + /// Also apply unsafe fixes (requires --fix) + #[arg(long, requires = "fix")] + unsafe_fixes: bool, + + /// Show diff of fixes without applying them + #[arg(long)] + diff: bool, + + /// Show fix statistics only (no diagnostics output) + #[arg(long)] + statistics: bool, + }, + + /// Evaluate a Zluppy expression or small program (playground mode). + /// + /// Examples: + /// zlup eval "2 + 3" + /// zlup eval "std.pi * 2" + /// echo "x := 5; x * 2" | zlup eval - + Eval { + /// Expression to evaluate (or - for stdin) + #[arg(value_name = "EXPR")] + expr: String, + + /// Show AST and intermediate steps + #[arg(long)] + verbose: bool, + }, + + /// Analyze a Zluppy source file for parallelism opportunities. + /// + /// This command performs static analysis to identify: + /// - Qubit allocator lifetimes and scopes + /// - Operation dependencies (qubit and data dependencies) + /// - Parallel execution layers (operations that can run simultaneously) + /// + /// Examples: + /// zlup analyze program.zlp + /// zlup analyze program.zlp --format json + /// zlup analyze program.zlp --verbose + Analyze { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Output format + #[arg(short, long, value_enum, default_value = "text")] + format: AnalyzeFormat, + + /// Show detailed dependency graph + #[arg(long, short)] + verbose: bool, + }, + + /// Generate documentation from doc comments in a Zluppy source file. + /// + /// Examples: + /// zlup doc program.zlp + /// zlup doc program.zlp -o docs.md + /// zlup doc program.zlp --all + Doc { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Output file (default: stdout) + #[arg(short, long, value_name = "FILE")] + output: Option, + + /// Include private (non-pub) items + #[arg(long)] + all: bool, + }, + + /// Run tests defined in a Zluppy source file. + /// + /// Tests are defined with `test "name" { ... }` blocks. + /// + /// Examples: + /// zlup test program.zlp + /// zlup test program.zlp --filter "addition" + /// zlup test program.zlp --verbose + Test { + /// Input file (use - for stdin) + #[arg(value_name = "FILE")] + input: PathBuf, + + /// Only run tests matching this pattern + #[arg(long, value_name = "PATTERN")] + filter: Option, + + /// Enable strict mode (NASA Power of 10) + #[arg(long)] + strict: bool, + + /// Print verbose output + #[arg(long, short)] + verbose: bool, + }, +} + +/// Lint configuration levels. +#[derive(Clone, Copy, ValueEnum)] +enum LintLevel { + /// Minimal checks (only unused variable/measurement) + Minimal, + /// Relaxed checks (warnings instead of errors) + Relaxed, + /// Strict checks (NASA Power of 10 enforcement) - DEFAULT + Strict, +} + +/// Lint output formats. +#[derive(Clone, Copy, ValueEnum)] +enum LintFormat { + /// Human-readable format with colors + Pretty, + /// JSON format for tooling integration + Json, + /// Compact one-line-per-diagnostic format + Compact, +} + +/// Execution target - what you're compiling for (affects semantics and passes). +#[derive(Clone, Copy, ValueEnum, Default, Debug)] +enum Target { + /// Simulator: full debug info, relaxed constraints, keep simulation constructs + #[default] + Simulator, + /// Hardware: strict constraints, drop simulation artifacts, enforce gate sets + Hardware, + /// Emulator: hardware-like constraints but with simulation visibility + Emulator, +} + +impl Target { + /// Whether this target implies strict mode by default. + fn default_strict(&self) -> bool { + match self { + Target::Simulator => false, + Target::Hardware => true, + Target::Emulator => true, + } + } + + /// Default log elision level for this target. + fn default_log_elision(&self) -> Option { + match self { + Target::Simulator => None, // Keep all logs + Target::Hardware => Some(300), // Warn and above only + Target::Emulator => Some(200), // Info and above + } + } + + /// How sim.* commands should be handled for this target. + /// + /// Simulator commands (noise control, etc.) are only meaningful for + /// the simulator target. For hardware and emulator, they emit barriers + /// by default to preserve ordering semantics. + fn sim_mode(&self) -> zlup::codegen::slr::SimMode { + use zlup::codegen::slr::SimMode; + match self { + Target::Simulator => SimMode::Emit, // Output actual sim commands + Target::Hardware => SimMode::Barrier, // Emit barrier to preserve ordering + Target::Emulator => SimMode::Barrier, // Emit barrier to preserve ordering + } + } +} + +/// Output format - how to serialize the compiled output. +#[derive(Clone, Copy, ValueEnum, Default, Debug)] +enum Format { + /// SLR-AST JSON (Python/PECOS bridge) + #[default] + Slr, + /// PHIR-JSON format (PECOS simulator targeting) - see pecos-phir-json spec v0.1.0 + PhirJson, + /// OpenQASM 2.0 (simulators and hardware) + Qasm, + /// HUGR (hardware/experiments) - requires --features hugr + #[cfg(feature = "hugr")] + Hugr, +} + +/// AST output formats for parse command. +#[derive(Clone, Copy, ValueEnum)] +enum AstFormat { + /// Rust Debug format + Debug, + /// JSON format + Json, +} + +/// Output formats for analyze command. +#[derive(Clone, Copy, ValueEnum, Default)] +enum AnalyzeFormat { + /// Human-readable text output + #[default] + Text, + /// JSON output for tooling integration + Json, +} + +/// Build mode - optimization and debug level. +/// +/// Combined with Target, this controls the full compilation behavior. +/// Target controls *what* you're building for, Mode controls *how* optimized. +#[derive(Clone, Copy, ValueEnum, Default, Debug)] +enum Mode { + /// Debug (default): all logs kept, no optimizations, permissive + #[default] + Debug, + + /// Release: elide debug/trace logs, enable optimizations, stricter checks + Release, +} + +impl Mode { + /// Log elision adjustment for this mode (added to target's default). + fn log_elision_adjustment(&self) -> Option { + match self { + Mode::Debug => None, // Don't elide beyond target default + Mode::Release => Some(100), // Bump elision by one level + } + } + + /// Whether this mode implies stricter checks. + fn strict_bias(&self) -> bool { + match self { + Mode::Debug => false, + Mode::Release => true, + } + } +} + +/// Compute effective settings from target + mode combination. +fn effective_settings(target: Target, mode: Mode) -> (bool, Option) { + // Strict: either target or mode can enable it + let strict = target.default_strict() || mode.strict_bias(); + + // Log elision: start with target default, then apply mode adjustment + let log_elision = match (target.default_log_elision(), mode.log_elision_adjustment()) { + (Some(t), Some(m)) => Some(t.max(m)), // Take the higher (more elision) + (Some(t), None) => Some(t), + (None, Some(m)) => Some(m), + (None, None) => None, + }; + + (strict, log_elision) +} + +// ============================================================================= +// Error Handling +// ============================================================================= + +/// CLI error with source context. +/// Fields like `src`, `span`, and `help` are used by miette's Diagnostic derive, +/// not directly in our code. +#[derive(Debug, Diagnostic, thiserror::Error)] +enum CliError { + #[error("failed to read file: {path}")] + #[diagnostic(code(zlup::io::read))] + ReadError { + path: String, + #[source] + source: io::Error, + }, + + #[error("failed to write file: {path}")] + #[diagnostic(code(zlup::io::write))] + WriteError { + path: String, + #[source] + source: io::Error, + }, + + #[error("parse error")] + #[diagnostic(code(zlup::parse))] + ParseError { + #[source_code] + src: NamedSource, + #[label("error here")] + span: SourceSpan, + #[help] + help: String, + }, + + #[error("semantic error: {message}")] + #[diagnostic(code(zlup::semantic))] + SemanticError { + message: String, + #[source_code] + src: NamedSource, + #[label("{message}")] + span: SourceSpan, + }, + + #[error("codegen error: {message}")] + #[diagnostic(code(zlup::codegen))] + CodegenError { message: String }, + + #[error("file needs formatting: {path}")] + #[diagnostic(code(zlup::fmt::check))] + FormatterCheckFailed { path: String }, + + #[error("lint failed: {path} has {error_count} error(s) and {warning_count} warning(s)")] + #[diagnostic(code(zlup::lint))] + LintFailed { + path: String, + error_count: usize, + warning_count: usize, + }, + + #[error("HUGR codegen requires --features hugr")] + #[diagnostic(code(zlup::feature))] + HugrNotEnabled, + + #[error("config error: {message}")] + #[diagnostic(code(zlup::config))] + ConfigError { message: String }, + + #[error("project directory '{path}' already exists")] + #[diagnostic(code(zlup::init))] + ProjectExists { path: String }, + + #[error("failed to create directory '{path}': {source}")] + #[diagnostic(code(zlup::io::mkdir))] + CreateDirError { + path: String, + #[source] + source: io::Error, + }, +} + +// ============================================================================= +// Input/Output Helpers +// ============================================================================= + +/// Read source from file or stdin. +fn read_source(path: &PathBuf) -> Result<(String, String), CliError> { + if path.as_os_str() == "-" { + let mut source = String::new(); + io::stdin().read_to_string(&mut source).map_err(|e| CliError::ReadError { + path: "".to_string(), + source: e, + })?; + Ok((source, "".to_string())) + } else { + let source = fs::read_to_string(path).map_err(|e| CliError::ReadError { + path: path.display().to_string(), + source: e, + })?; + Ok((source, path.display().to_string())) + } +} + +/// Compute the byte offset for a source location. +fn location_to_offset(source: &str, location: &zlup::ast::SourceLocation) -> usize { + source + .lines() + .take(location.line.saturating_sub(1) as usize) + .map(|l| l.len() + 1) + .sum::() + + location.column.saturating_sub(1) as usize +} + +/// Convert a pest parse error message to a more user-friendly message. +fn friendly_parse_error(pest_message: &str) -> String { + // Check for common patterns and provide better messages with suggestions + if pest_message.contains("expected identifier") { + "expected an identifier (variable, function, or type name)".to_string() + } else if pest_message.contains("expected type_expr") { + "expected a type (e.g., u32, bool, []u8, ?T)".to_string() + } else if pest_message.contains("expected expr") { + "expected an expression".to_string() + } else if pest_message.contains("expected statement") { + "expected a statement (binding, assignment, if, for, etc.)".to_string() + } else if pest_message.contains("expected \"(\"") { + "expected '(' - check for missing parentheses".to_string() + } else if pest_message.contains("expected \")\"") { + "expected ')' - check for unmatched parentheses".to_string() + } else if pest_message.contains("expected \"{\"") { + "expected '{' - blocks require braces".to_string() + } else if pest_message.contains("expected \"}\"") { + "expected '}' - check for unmatched braces".to_string() + } else if pest_message.contains("expected \"[\"") { + "expected '[' - arrays use square brackets".to_string() + } else if pest_message.contains("expected \"]\"") { + "expected ']' - check for unmatched brackets".to_string() + } else if pest_message.contains("expected \";\"") { + "expected ';' - statements must end with semicolon".to_string() + } else if pest_message.contains("expected \":=\"") || pest_message.contains("expected \"=\"") { + "expected ':=' for binding or '=' for assignment".to_string() + } else if pest_message.contains("expected assign_op") { + "unexpected token - expected assignment (=, +=, -=, etc.)".to_string() + } else if pest_message.contains("expected top_level_decl") { + "expected a declaration (fn, struct, enum, or binding)".to_string() + } else if pest_message.contains("expected return_type") { + "expected '-> T' return type after function parameters".to_string() + } else if pest_message.contains("expected param") { + "expected function parameter (name: Type)".to_string() + } else if pest_message.contains("expected block") { + "expected a block { ... }".to_string() + } else if pest_message.contains("expected EOI") { + "unexpected content after end of file".to_string() + } else if pest_message.contains("expected string_literal") { + "expected a string (\"...\", r\"...\", or \"\"\"...\"\"\")".to_string() + } else if pest_message.contains("expected number_literal") { + "expected a number (42, 0xFF, 3.14, etc.)".to_string() + } else if pest_message.contains("expected bool_literal") { + "expected 'true' or 'false'".to_string() + } else { + // Fall back to the original message but clean it up + pest_message + .lines() + .next() + .unwrap_or(pest_message) + .to_string() + } +} + +/// Write output to file or stdout. +fn write_output(path: Option<&PathBuf>, content: &str) -> Result<(), CliError> { + match path { + Some(p) if p.as_os_str() != "-" => { + fs::write(p, content).map_err(|e| CliError::WriteError { + path: p.display().to_string(), + source: e, + }) + } + _ => { + io::stdout() + .write_all(content.as_bytes()) + .map_err(|e| CliError::WriteError { + path: "".to_string(), + source: e, + }) + } + } +} + +/// Derive output path from input path and format. +fn derive_output_path(input: &Path, format: Format) -> PathBuf { + if input.as_os_str() == "-" { + return PathBuf::from("-"); + } + + let stem = input.file_stem().unwrap_or_default(); + let ext = match format { + Format::Slr => "slr.json", + Format::PhirJson => "phir.json", + Format::Qasm => "qasm", + #[cfg(feature = "hugr")] + Format::Hugr => "hugr", + }; + + input.with_file_name(format!("{}.{}", stem.to_string_lossy(), ext)) +} + +// ============================================================================= +// Commands +// ============================================================================= + +/// Execute the init command - create a new project. +fn cmd_init(name: String, here: bool) -> Result<(), CliError> { + let project_dir = if here { + std::env::current_dir().map_err(|e| CliError::ReadError { + path: ".".to_string(), + source: e, + })? + } else { + let dir = PathBuf::from(&name); + if dir.exists() { + return Err(CliError::ProjectExists { + path: dir.display().to_string(), + }); + } + fs::create_dir_all(&dir).map_err(|e| CliError::CreateDirError { + path: dir.display().to_string(), + source: e, + })?; + dir + }; + + // Create zlup.toml + let config = Config::new(&name); + let config_path = project_dir.join(CONFIG_FILE_NAME); + let config_content = config.to_toml().map_err(|e| CliError::ConfigError { + message: e.to_string(), + })?; + fs::write(&config_path, config_content).map_err(|e| CliError::WriteError { + path: config_path.display().to_string(), + source: e, + })?; + + // Create main.zlp with example content + let main_path = project_dir.join("main.zlp"); + let main_content = r#"//! Main entry point for the quantum program. + +/// Main function - program entry point. +pub fn main() -> unit { + // Allocate qubits (no mut needed - just applying gates) + q := qalloc(2); + pz q; + + // Create Bell state + h q[0]; + cx (q[0], q[1]); + + // Measure + result := mz(u1) q[0]; + + return unit; +} +"#; + fs::write(&main_path, main_content).map_err(|e| CliError::WriteError { + path: main_path.display().to_string(), + source: e, + })?; + + eprintln!("Created new project '{}' at {}", name, project_dir.display()); + eprintln!(" {} - project configuration", CONFIG_FILE_NAME); + eprintln!(" main.zlp - main source file"); + eprintln!(); + eprintln!("To build: cd {} && zlup build", project_dir.display()); + + Ok(()) +} + +/// Execute the build command - build using zlup.toml. +fn cmd_build( + strict_override: Option, + target_override: Option, + format_override: Option, + mode: Mode, + compact: bool, +) -> Result<(), CliError> { + // Find zlup.toml + let current_dir = std::env::current_dir().map_err(|e| CliError::ReadError { + path: ".".to_string(), + source: e, + })?; + + let (config, config_path) = Config::find_and_load(¤t_dir).map_err(|e| { + CliError::ConfigError { + message: e.to_string(), + } + })?; + + let project_root = Config::project_root(&config_path); + + // Determine format (CLI overrides config) + let format = format_override.unwrap_or(match config.build.target { + TargetConfig::Slr => Format::Slr, + #[cfg(feature = "hugr")] + TargetConfig::Hugr => Format::Hugr, + #[cfg(not(feature = "hugr"))] + TargetConfig::Hugr => { + eprintln!("Warning: HUGR format specified but not enabled, using SLR"); + Format::Slr + } + }); + + // Target defaults to simulator unless overridden + let target = target_override.unwrap_or(Target::Simulator); + + // Get entry file + let entry_path = config.entry_path(&config_path); + if !entry_path.exists() { + return Err(CliError::ReadError { + path: entry_path.display().to_string(), + source: io::Error::new(io::ErrorKind::NotFound, "entry file not found"), + }); + } + + // Create output directory + let output_dir = config.output_path(&config_path); + if !output_dir.exists() { + fs::create_dir_all(&output_dir).map_err(|e| CliError::CreateDirError { + path: output_dir.display().to_string(), + source: e, + })?; + } + + // Derive output file name + let output_ext = match format { + Format::Slr => "slr.json", + Format::PhirJson => "phir.json", + Format::Qasm => "qasm", + #[cfg(feature = "hugr")] + Format::Hugr => "hugr", + }; + let output_name = entry_path + .file_stem() + .unwrap_or_default() + .to_string_lossy(); + let output_path = output_dir.join(format!("{}.{}", output_name, output_ext)); + + // Compute effective settings + let (default_strict, _) = effective_settings(target, mode); + let strict = strict_override.unwrap_or_else(|| config.build.strict || default_strict); + + eprintln!( + "Building {} ({}) [{:?} -> {:?}, {}]", + config.package.name, + config.package.version, + target, + format, + if strict { "strict" } else { "normal" } + ); + + // Compile + cmd_compile(entry_path, Some(output_path.clone()), target, format, mode, compact, Some(strict), None, false)?; + + eprintln!("Built {} -> {}", + project_root.join(&config.package.entry).display(), + output_path.display() + ); + + Ok(()) +} + +/// Execute the compile command. +fn cmd_compile( + input: PathBuf, + output: Option, + target: Target, + format: Format, + mode: Mode, + compact: bool, + strict_override: Option, + log_level_override: Option, + elide_sim: bool, +) -> Result<(), CliError> { + // Resolve settings from target + mode with overrides + let (default_strict, default_log_level) = effective_settings(target, mode); + let strict = strict_override.unwrap_or(default_strict); + let log_level = log_level_override.or(default_log_level); + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(|e| { + let (span, message) = if let Some(loc) = e.location() { + let start = location_to_offset(&source, loc); + (SourceSpan::from(start..start + 1), e.to_string()) + } else { + (SourceSpan::from(0..1), e.to_string()) + }; + CliError::SemanticError { + message, + src: NamedSource::new(&filename, source.clone()), + span, + } + })?; + + // Derive module name from filename for log namespacing + let module_name = input + .file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "main".to_string()); + + // Code generation + let output_content = match format { + Format::Slr => { + use zlup::codegen::slr::LogElisionLevel; + + // Create codegen with release settings if we have any log elision + let mut codegen = if log_level.is_some() { + SlrCodegen::new_release() + } else { + SlrCodegen::new() + }; + + // Set module for automatic log namespacing + codegen.set_module(&module_name); + + // Apply log elision level from profile (possibly overridden) + if let Some(level) = log_level { + codegen.set_log_elision(LogElisionLevel(Some(level))); + } + + // Apply sim mode based on target (with optional full elision override) + let sim_mode = if elide_sim { + zlup::codegen::slr::SimMode::Elide + } else { + target.sim_mode() + }; + codegen.set_sim_mode(sim_mode); + + let slr_program = codegen + .compile(&program) + .map_err(|e: zlup::codegen::slr::SlrError| CliError::CodegenError { + message: e.to_string(), + })?; + + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(|e: zlup::codegen::slr::SlrError| CliError::CodegenError { + message: e.to_string(), + })? + } else { + codegen + .to_json(&slr_program) + .map_err(|e: zlup::codegen::slr::SlrError| CliError::CodegenError { + message: e.to_string(), + })? + } + } + Format::PhirJson => { + use zlup::codegen::PhirJsonCodegen; + + let mut codegen = PhirJsonCodegen::new(); + let phir_json_program = codegen + .compile(&program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })?; + + if compact { + codegen + .to_json_compact(&phir_json_program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })? + } else { + codegen + .to_json(&phir_json_program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })? + } + } + Format::Qasm => { + use zlup::codegen::QasmCodegen; + + let mut codegen = QasmCodegen::new(); + codegen + .compile(&program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })? + } + #[cfg(feature = "hugr")] + Format::Hugr => { + use zlup::codegen::HugrCodegen; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen + .compile(&program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })?; + + // Serialize HUGR to text envelope format (compatible with PECOS hugr_engine) + codegen + .to_string(&hugr) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })? + } + }; + + // Write output + let output_path = output.unwrap_or_else(|| derive_output_path(&input, format)); + write_output(Some(&output_path), &output_content)?; + + eprintln!( + "Compiled {} -> {}", + filename, + if output_path.as_os_str() == "-" { + "".to_string() + } else { + output_path.display().to_string() + } + ); + + Ok(()) +} + +/// Execute the check command. +fn cmd_check(input: PathBuf, strict: bool) -> Result<(), CliError> { + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(|e| { + let (span, message) = if let Some(loc) = e.location() { + let start = location_to_offset(&source, loc); + (SourceSpan::from(start..start + 1), e.to_string()) + } else { + (SourceSpan::from(0..1), e.to_string()) + }; + CliError::SemanticError { + message, + src: NamedSource::new(&filename, source.clone()), + span, + } + })?; + + eprintln!("OK: {}", filename); + Ok(()) +} + +/// Execute the parse command. +fn cmd_parse(input: PathBuf, format: AstFormat) -> Result<(), CliError> { + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Output + let output = match format { + AstFormat::Debug => format!("{:#?}", program), + AstFormat::Json => { + serde_json::to_string_pretty(&program) + .unwrap_or_else(|e| format!("JSON serialization error: {}", e)) + } + }; + + println!("{}", output); + Ok(()) +} + +/// Execute the format command. +fn cmd_format(input: PathBuf, write: bool, check: bool) -> Result<(), CliError> { + use zlup::formatter::{format, FormatOptions}; + + let (source, filename) = read_source(&input)?; + let options = FormatOptions::default(); + let formatted = format(&source, &options); + + if check { + // Check mode: exit 1 if file needs formatting + if source != formatted { + eprintln!("Would reformat: {}", filename); + return Err(CliError::FormatterCheckFailed { path: filename }); + } + eprintln!("OK: {}", filename); + return Ok(()); + } + + if write { + // Write mode: write back to file + if input.as_os_str() == "-" { + // Can't write back to stdin + return Err(CliError::WriteError { + path: "".to_string(), + source: io::Error::new(io::ErrorKind::InvalidInput, "cannot write to stdin"), + }); + } + if source != formatted { + fs::write(&input, &formatted).map_err(|e| CliError::WriteError { + path: input.display().to_string(), + source: e, + })?; + eprintln!("Formatted: {}", filename); + } else { + eprintln!("Already formatted: {}", filename); + } + } else { + // Default: print to stdout + print!("{}", formatted); + } + + Ok(()) +} + +// Arguments come from CLI parsing - grouping them wouldn't improve readability +#[allow(clippy::too_many_arguments)] +fn cmd_lint( + input: PathBuf, + level: LintLevel, + deny_warnings: bool, + format: LintFormat, + fix: bool, + unsafe_fixes: bool, + show_diff: bool, + statistics_only: bool, +) -> Result<(), CliError> { + use zlup::linter::{apply_fixes, FixSafety, LintConfig, Linter, Severity}; + + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = e.location.line.saturating_sub(1) as usize * 80 + e.location.column as usize; + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Configure linter + let config = match level { + LintLevel::Minimal => LintConfig::minimal(), + LintLevel::Relaxed => LintConfig::relaxed(), + LintLevel::Strict => LintConfig::strict(), + }; + + // Run linter (with source for fix computation) + let diagnostics = Linter::new(config) + .with_source(&source) + .lint(&program); + + // Statistics-only mode + if statistics_only { + let errors = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)).count(); + let warnings = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(); + let hints = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Hint)).count(); + let fixable_safe = diagnostics.iter().filter(|d| d.has_safe_fix()).count(); + let fixable_unsafe = diagnostics.iter().filter(|d| { + d.fix.as_ref().is_some_and(|f| f.safety == FixSafety::Unsafe) + }).count(); + + println!("File: {}", filename); + println!("Errors: {}", errors); + println!("Warnings: {}", warnings); + println!("Hints: {}", hints); + println!("Total: {}", diagnostics.len()); + println!("Fixable (safe): {}", fixable_safe); + println!("Fixable (unsafe): {}", fixable_unsafe); + + if errors > 0 || (deny_warnings && warnings > 0) { + return Err(CliError::LintFailed { + path: filename, + error_count: errors, + warning_count: warnings, + }); + } + return Ok(()); + } + + // Diff mode - show what would change without applying + if show_diff { + let fix_result = apply_fixes(&source, &diagnostics, unsafe_fixes); + + if fix_result.safe_fixes_applied > 0 || fix_result.unsafe_fixes_applied > 0 { + eprintln!( + "Would apply {} safe fix(es){} to {}", + fix_result.safe_fixes_applied, + if fix_result.unsafe_fixes_applied > 0 { + format!(" and {} unsafe fix(es)", fix_result.unsafe_fixes_applied) + } else { + String::new() + }, + filename + ); + + // Generate unified diff + print_unified_diff(&source, &fix_result.source, &filename); + + if fix_result.fixes_skipped > 0 { + eprintln!( + "Would skip {} fix(es) (conflicts or safety level)", + fix_result.fixes_skipped + ); + } + } else { + eprintln!("No fixes available"); + } + + // Still return error if there are issues + let has_errors = diagnostics.iter().any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = diagnostics.iter().any(|d| matches!(d.severity, Severity::Warning)); + if has_errors || (deny_warnings && has_warnings) { + let errors = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)).count(); + let warnings = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(); + return Err(CliError::LintFailed { + path: filename, + error_count: errors, + warning_count: warnings, + }); + } + return Ok(()); + } + + // Apply fixes if requested + if fix { + let fix_result = apply_fixes(&source, &diagnostics, unsafe_fixes); + + if fix_result.safe_fixes_applied > 0 || fix_result.unsafe_fixes_applied > 0 { + // Write fixed source back to file + if input.as_os_str() == "-" { + // Can't write back to stdin + return Err(CliError::WriteError { + path: "".to_string(), + source: io::Error::new(io::ErrorKind::InvalidInput, "cannot apply fixes to stdin"), + }); + } + + fs::write(&input, &fix_result.source).map_err(|e| CliError::WriteError { + path: input.display().to_string(), + source: e, + })?; + + eprintln!( + "Applied {} safe fix(es){} to {}", + fix_result.safe_fixes_applied, + if fix_result.unsafe_fixes_applied > 0 { + format!(" and {} unsafe fix(es)", fix_result.unsafe_fixes_applied) + } else { + String::new() + }, + filename + ); + + if fix_result.fixes_skipped > 0 { + eprintln!( + "Skipped {} fix(es) (conflicts or safety level)", + fix_result.fixes_skipped + ); + } + + // Re-lint to show remaining issues + let program = zlup::parse_file(&fix_result.source, &filename).map_err(|e| { + let start = e.location.line.saturating_sub(1) as usize * 80 + e.location.column as usize; + CliError::ParseError { + src: NamedSource::new(&filename, fix_result.source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + let config = match level { + LintLevel::Minimal => LintConfig::minimal(), + LintLevel::Relaxed => LintConfig::relaxed(), + LintLevel::Strict => LintConfig::strict(), + }; + + let remaining_diagnostics = Linter::new(config) + .with_source(&fix_result.source) + .lint(&program); + + if remaining_diagnostics.is_empty() { + eprintln!("All issues fixed!"); + return Ok(()); + } + + // Output remaining diagnostics + match format { + LintFormat::Pretty => print_diagnostics_pretty(&remaining_diagnostics, &filename), + LintFormat::Json => print_diagnostics_json(&remaining_diagnostics), + LintFormat::Compact => print_diagnostics_compact(&remaining_diagnostics, &filename), + } + + let errors = remaining_diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)).count(); + let warnings = remaining_diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(); + let hints = remaining_diagnostics.iter().filter(|d| matches!(d.severity, Severity::Hint)).count(); + + eprintln!(); + eprintln!( + "Remaining: {} error(s), {} warning(s), {} hint(s) in {}", + errors, warnings, hints, filename + ); + + let has_errors = remaining_diagnostics.iter().any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = remaining_diagnostics.iter().any(|d| matches!(d.severity, Severity::Warning)); + + if has_errors || (deny_warnings && has_warnings) { + return Err(CliError::LintFailed { + path: filename, + error_count: errors, + warning_count: warnings, + }); + } + + return Ok(()); + } else { + eprintln!("No fixes available to apply"); + } + } + + // Check for errors + let has_errors = diagnostics.iter().any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = diagnostics.iter().any(|d| matches!(d.severity, Severity::Warning)); + + // Output diagnostics + match format { + LintFormat::Pretty => print_diagnostics_pretty(&diagnostics, &filename), + LintFormat::Json => print_diagnostics_json(&diagnostics), + LintFormat::Compact => print_diagnostics_compact(&diagnostics, &filename), + } + + // Summary + if !diagnostics.is_empty() { + let errors = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)).count(); + let warnings = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(); + let hints = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Hint)).count(); + let fixable_safe = diagnostics.iter().filter(|d| d.has_safe_fix()).count(); + let fixable_total = diagnostics.iter().filter(|d| d.has_fix()).count(); + + eprintln!(); + eprintln!( + "Found {} error(s), {} warning(s), {} hint(s) in {}", + errors, warnings, hints, filename + ); + + if fixable_safe > 0 { + eprintln!( + "{} issue(s) can be fixed automatically (run with --fix)", + fixable_safe + ); + } + if fixable_total > fixable_safe { + eprintln!( + "{} additional issue(s) can be fixed with --fix --unsafe-fixes", + fixable_total - fixable_safe + ); + } + } else { + eprintln!("No issues found in {}", filename); + } + + // Return error if there are errors, or warnings in deny mode + if has_errors || (deny_warnings && has_warnings) { + return Err(CliError::LintFailed { + path: filename, + error_count: diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(), + warning_count: diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(), + }); + } + + Ok(()) +} + +fn print_diagnostics_pretty(diagnostics: &[zlup::linter::LintDiagnostic], filename: &str) { + use zlup::linter::Severity; + + for diag in diagnostics { + let (severity_str, color) = match diag.severity { + Severity::Hint => ("hint", "\x1b[36m"), // Cyan + Severity::Warning => ("warning", "\x1b[33m"), // Yellow + Severity::Error => ("error", "\x1b[31m"), // Red + Severity::Deny => ("deny", "\x1b[91m"), // Bright Red + }; + let reset = "\x1b[0m"; + let bold = "\x1b[1m"; + + if let Some(ref loc) = diag.location { + eprintln!( + "{}{}{}:{}{}: {}{}{}: {}", + bold, filename, reset, loc.line, loc.column, color, severity_str, reset, diag.message + ); + } else { + eprintln!( + "{}{}{}: {}{}{}: {}", + bold, filename, reset, color, severity_str, reset, diag.message + ); + } + + eprintln!(" [{}]", diag.rule); + + if let Some(ref suggestion) = diag.suggestion { + eprintln!(" \x1b[32mhelp{}: {}", reset, suggestion); + } + eprintln!(); + } +} + +fn print_diagnostics_json(diagnostics: &[zlup::linter::LintDiagnostic]) { + use serde_json::json; + + let json_diags: Vec<_> = diagnostics + .iter() + .map(|d| { + json!({ + "rule": d.rule, + "message": d.message, + "severity": format!("{:?}", d.severity).to_lowercase(), + "location": d.location.as_ref().map(|l| json!({ + "line": l.line, + "column": l.column + })), + "suggestion": d.suggestion + }) + }) + .collect(); + + println!("{}", serde_json::to_string_pretty(&json_diags).unwrap()); +} + +fn print_diagnostics_compact(diagnostics: &[zlup::linter::LintDiagnostic], filename: &str) { + use zlup::linter::Severity; + + for diag in diagnostics { + let severity = match diag.severity { + Severity::Hint => "H", + Severity::Warning => "W", + Severity::Error => "E", + Severity::Deny => "D", + }; + + if let Some(ref loc) = diag.location { + println!( + "{}:{}:{}: {} [{}] {}", + filename, loc.line, loc.column, severity, diag.rule, diag.message + ); + } else { + println!("{}: {} [{}] {}", filename, severity, diag.rule, diag.message); + } + } +} + +/// Print a unified diff between original and fixed source code. +fn print_unified_diff(original: &str, fixed: &str, filename: &str) { + let orig_lines: Vec<&str> = original.lines().collect(); + let fixed_lines: Vec<&str> = fixed.lines().collect(); + + // ANSI colors + let red = "\x1b[31m"; + let green = "\x1b[32m"; + let cyan = "\x1b[36m"; + let reset = "\x1b[0m"; + + println!("{}--- a/{}{}",cyan, filename, reset); + println!("{}+++ b/{}{}", cyan, filename, reset); + + // Simple diff: find changed lines + let max_len = orig_lines.len().max(fixed_lines.len()); + let mut i = 0; + + while i < max_len { + // Find a hunk of changes + let hunk_start = i; + let mut orig_hunk = Vec::new(); + let mut fixed_hunk = Vec::new(); + let mut has_changes = false; + + // Collect context before changes (up to 3 lines) + let context_start = hunk_start.saturating_sub(3); + + // Find changes + while i < max_len { + let orig_line = orig_lines.get(i).copied(); + let fixed_line = fixed_lines.get(i).copied(); + + if orig_line != fixed_line { + has_changes = true; + if let Some(line) = orig_line { + orig_hunk.push((i, line)); + } + if let Some(line) = fixed_line { + fixed_hunk.push((i, line)); + } + i += 1; + } else if has_changes { + // Add trailing context + let mut context_count = 0; + while i < max_len && context_count < 3 { + let ol = orig_lines.get(i).copied(); + let fl = fixed_lines.get(i).copied(); + if ol == fl { + context_count += 1; + i += 1; + } else { + break; + } + } + break; + } else { + i += 1; + } + } + + if has_changes { + // Print hunk header + let orig_start = orig_hunk.first().map(|(n, _)| *n + 1).unwrap_or(hunk_start + 1); + let fixed_start = fixed_hunk.first().map(|(n, _)| *n + 1).unwrap_or(hunk_start + 1); + println!( + "{}@@ -{},{} +{},{} @@{}", + cyan, + orig_start, + orig_hunk.len(), + fixed_start, + fixed_hunk.len(), + reset + ); + + // Print context before + for j in context_start..hunk_start { + if let Some(line) = orig_lines.get(j) { + println!(" {}", line); + } + } + + // Print removed lines + for (_, line) in &orig_hunk { + println!("{}-{}{}", red, line, reset); + } + + // Print added lines + for (_, line) in &fixed_hunk { + println!("{}+{}{}", green, line, reset); + } + } + } +} + +// ============================================================================= +// Analyze Command +// ============================================================================= + +fn cmd_analyze(input: PathBuf, format: AnalyzeFormat, verbose: bool) -> Result<(), CliError> { + use zlup::analysis::{analyze_parallelism, AllocatorAnalysis, DependencyGraph, OperationTagger}; + + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + // Run analysis passes + let allocator_analysis = AllocatorAnalysis::analyze(&program); + let tagger = OperationTagger::tag(&program); + let dep_graph = DependencyGraph::build(tagger.operations); + let summaries = analyze_parallelism(&program); + + match format { + AnalyzeFormat::Text => { + println!("=== Parallelism Analysis: {} ===\n", filename); + + // Allocator summary + println!("Allocators:"); + if allocator_analysis.allocators.is_empty() { + println!(" (none)"); + } else { + for (name, info) in &allocator_analysis.allocators { + let size_str = info.size.map(|s| format!("[{}]", s)).unwrap_or_else(|| "[?]".to_string()); + println!(" {} {}qubit (scope depth: {}, line: {})", + name, size_str, info.scope_depth, info.defined_at_line); + } + } + println!(); + + // Function summaries + println!("Function Analysis:"); + for summary in &summaries { + println!(" {}:", summary.function_name); + println!(" Total operations: {}", summary.total_ops); + println!(" Quantum operations: {}", summary.quantum_ops); + println!(" Classical operations: {}", summary.classical_ops); + println!(" Parallel layers: {}", summary.num_layers); + println!(" Max parallelism: {} ops/layer", summary.max_parallelism); + println!(); + } + + // Detailed output if verbose + if verbose { + println!("=== Dependency Graph ===\n"); + dep_graph.debug_print(); + } + } + AnalyzeFormat::Json => { + use serde_json::json; + + let allocators: Vec<_> = allocator_analysis.allocators.iter().map(|(name, info)| { + json!({ + "name": name, + "size": info.size, + "scope_depth": info.scope_depth, + "defined_at_line": info.defined_at_line, + }) + }).collect(); + + let functions: Vec<_> = summaries.iter().map(|s| { + json!({ + "name": s.function_name, + "total_ops": s.total_ops, + "quantum_ops": s.quantum_ops, + "classical_ops": s.classical_ops, + "num_layers": s.num_layers, + "max_parallelism": s.max_parallelism, + }) + }).collect(); + + let layers = dep_graph.parallel_layers(); + let layer_details: Vec<_> = layers.iter().enumerate().map(|(i, ops)| { + let op_details: Vec<_> = ops.iter().map(|&id| { + let op = &dep_graph.operations[id]; + json!({ + "id": op.id, + "description": op.description, + "line": op.line, + "is_quantum": op.touches_qubits(), + }) + }).collect(); + json!({ + "layer": i, + "operations": op_details, + }) + }).collect(); + + let output = json!({ + "file": filename, + "allocators": allocators, + "functions": functions, + "parallel_layers": layer_details, + "total_operations": dep_graph.operations.len(), + "total_dependencies": dep_graph.edges.len(), + }); + + println!("{}", serde_json::to_string_pretty(&output).unwrap()); + } + } + + Ok(()) +} + +// ============================================================================= +// Eval Command +// ============================================================================= + +fn cmd_eval(expr: String, verbose: bool) -> Result<(), CliError> { + use zlup::comptime::ComptimeEvaluator; + + // Read expression from stdin if - + let input = if expr == "-" { + let mut buf = String::new(); + io::stdin().read_to_string(&mut buf).map_err(|e| CliError::ReadError { + path: "".to_string(), + source: e, + })?; + buf + } else { + expr + }; + + let input = input.trim(); + + // Wrap in a minimal program context for parsing + // Try as expression first, then as statements + let source = if input.contains(';') || input.contains(":=") || input.starts_with("fn ") { + // Looks like statements - wrap in a function + format!("fn __eval__() -> type {{ {} }}", input) + } else { + // Simple expression - wrap to evaluate + format!("__result__ := {};", input) + }; + + if verbose { + eprintln!("--- Source ---"); + eprintln!("{}", source); + } + + // Parse + let program = zlup::parse(&source).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new("", source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + if verbose { + eprintln!("--- AST ---"); + eprintln!("{:#?}", program); + } + + // Try comptime evaluation + let mut evaluator = ComptimeEvaluator::new(); + + // Evaluate declarations + for decl in &program.declarations { + match decl { + zlup::ast::TopLevelDecl::Binding(binding) => { + if let Some(ref value) = binding.value { + match evaluator.eval_expr(value) { + Ok(value) => { + if binding.name == "__result__" { + // This is our wrapped expression result + println!("{}", value); + } else if verbose { + println!("{} = {}", binding.name, value); + } + } + Err(e) => { + if verbose { + eprintln!("Comptime eval error: {}", e); + } + // Fall back to showing the expression was parsed + println!("(parsed: {})", binding.name); + } + } + } + } + zlup::ast::TopLevelDecl::Fn(func) => { + if verbose { + println!("fn {} defined", func.name); + } + } + _ => {} + } + } + + Ok(()) +} + +// ============================================================================= +// Doc Command +// ============================================================================= + +fn cmd_doc(input: PathBuf, output: Option, all: bool) -> Result<(), CliError> { + use zlup::docgen::{extract_doc_items, generate_markdown, DocConfig}; + + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + let config = DocConfig { + include_private: all, + ..Default::default() + }; + + let items = extract_doc_items(&program, &config); + let module_name = input.file_stem() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "module".to_string()); + let markdown = generate_markdown(&items, &module_name); + + write_output(output.as_ref(), &markdown)?; + Ok(()) +} + +// ============================================================================= +// Test Command +// ============================================================================= + +fn cmd_test( + input: PathBuf, + filter: Option, + strict: bool, + verbose: bool, +) -> Result<(), CliError> { + use zlup::test_runner::{format_results, TestOutcome, TestRunConfig, TestRunner}; + + let (source, filename) = read_source(&input)?; + + // Parse + let program = zlup::parse_file(&source, &filename).map_err(|e| { + let start = location_to_offset(&source, &e.location); + CliError::ParseError { + src: NamedSource::new(&filename, source.clone()), + span: SourceSpan::from(start..start + 1), + help: friendly_parse_error(&e.message), + } + })?; + + let config = TestRunConfig { + filter, + strict, + verbose, + }; + + let runner = TestRunner::new(config); + let results = runner.run(&program); + let output = format_results(&results); + print!("{}", output); + + // Exit with failure if any tests failed + let has_failures = results.iter().any(|r| matches!(r.outcome, TestOutcome::Fail(_))); + if has_failures { + return Err(CliError::CodegenError { + message: "some tests failed".to_string(), + }); + } + + Ok(()) +} + +// ============================================================================= +// Main +// ============================================================================= + +fn main() -> ExitCode { + // Initialize logging from ZLUP_LOG environment variable + zlup::logging::init(); + + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Init { name, here } => cmd_init(name, here), + + Commands::Build { + strict, + target, + format, + mode, + compact, + } => cmd_build(strict, target, format, mode, compact), + + Commands::Compile { + input, + output, + target, + format, + mode, + compact, + strict, + log_level, + elide_sim, + } => cmd_compile(input, output, target, format, mode, compact, strict, log_level, elide_sim), + + Commands::Check { input, strict } => cmd_check(input, strict), + + Commands::Parse { input, format } => cmd_parse(input, format), + + Commands::Format { + input, + write, + check, + } => cmd_format(input, write, check), + + Commands::Lint { + input, + level, + deny_warnings, + format, + fix, + unsafe_fixes, + diff, + statistics, + } => cmd_lint(input, level, deny_warnings, format, fix, unsafe_fixes, diff, statistics), + + Commands::Eval { expr, verbose } => cmd_eval(expr, verbose), + + Commands::Analyze { input, format, verbose } => cmd_analyze(input, format, verbose), + + Commands::Doc { input, output, all } => cmd_doc(input, output, all), + + Commands::Test { input, filter, strict, verbose } => cmd_test(input, filter, strict, verbose), + }; + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{:?}", miette::Report::new(e)); + ExitCode::FAILURE + } + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + + // ------------------------------------------------------------------------- + // Target defaults + // ------------------------------------------------------------------------- + + #[test] + fn target_simulator_is_permissive() { + assert!(!Target::Simulator.default_strict()); + assert_eq!(Target::Simulator.default_log_elision(), None); + } + + #[test] + fn target_hardware_is_strict() { + assert!(Target::Hardware.default_strict()); + assert_eq!(Target::Hardware.default_log_elision(), Some(300)); // Warn+ + } + + #[test] + fn target_emulator_is_strict_but_more_logs() { + assert!(Target::Emulator.default_strict()); + assert_eq!(Target::Emulator.default_log_elision(), Some(200)); // Info+ + } + + // ------------------------------------------------------------------------- + // Mode behavior + // ------------------------------------------------------------------------- + + #[test] + fn mode_debug_is_permissive() { + assert!(!Mode::Debug.strict_bias()); + assert_eq!(Mode::Debug.log_elision_adjustment(), None); + } + + #[test] + fn mode_release_adds_strictness() { + assert!(Mode::Release.strict_bias()); + assert_eq!(Mode::Release.log_elision_adjustment(), Some(100)); // Debug+ + } + + // ------------------------------------------------------------------------- + // Effective settings (target + mode combinations) + // ------------------------------------------------------------------------- + + #[test] + fn simulator_debug_is_fully_permissive() { + let (strict, log_elision) = effective_settings(Target::Simulator, Mode::Debug); + assert!(!strict); + assert_eq!(log_elision, None); // All logs + } + + #[test] + fn simulator_release_enables_strict_and_elides_trace() { + let (strict, log_elision) = effective_settings(Target::Simulator, Mode::Release); + assert!(strict); // Release adds strict + assert_eq!(log_elision, Some(100)); // Debug+ (elide trace) + } + + #[test] + fn hardware_debug_is_strict_but_keeps_warn_logs() { + let (strict, log_elision) = effective_settings(Target::Hardware, Mode::Debug); + assert!(strict); // Hardware is always strict + assert_eq!(log_elision, Some(300)); // Warn+ from target + } + + #[test] + fn hardware_release_is_strict_with_warn_logs() { + let (strict, log_elision) = effective_settings(Target::Hardware, Mode::Release); + assert!(strict); + // Hardware default (300) > Release adjustment (100), so 300 wins + assert_eq!(log_elision, Some(300)); + } + + #[test] + fn emulator_debug_is_strict_with_info_logs() { + let (strict, log_elision) = effective_settings(Target::Emulator, Mode::Debug); + assert!(strict); + assert_eq!(log_elision, Some(200)); // Info+ + } + + #[test] + fn emulator_release_is_strict_with_info_logs() { + let (strict, log_elision) = effective_settings(Target::Emulator, Mode::Release); + assert!(strict); + // Emulator default (200) > Release adjustment (100), so 200 wins + assert_eq!(log_elision, Some(200)); + } + + // ------------------------------------------------------------------------- + // Output path derivation + // ------------------------------------------------------------------------- + + #[test] + fn derive_output_path_slr() { + let input = Path::new("/path/to/program.zlp"); + let output = derive_output_path(input, Format::Slr); + assert_eq!(output, Path::new("/path/to/program.slr.json")); + } + + #[test] + fn derive_output_path_phir_json() { + let input = Path::new("/path/to/program.zlp"); + let output = derive_output_path(input, Format::PhirJson); + assert_eq!(output, Path::new("/path/to/program.phir.json")); + } + + #[test] + fn derive_output_path_qasm() { + let input = Path::new("/path/to/program.zlp"); + let output = derive_output_path(input, Format::Qasm); + assert_eq!(output, Path::new("/path/to/program.qasm")); + } + + #[test] + fn derive_output_path_stdin_returns_stdout() { + let input = Path::new("-"); + let output = derive_output_path(input, Format::Slr); + assert_eq!(output, Path::new("-")); + } + + // ------------------------------------------------------------------------- + // Log level constants (for reference in tests) + // ------------------------------------------------------------------------- + + #[test] + fn log_levels_are_spaced_by_100() { + // Trace = 0, Debug = 100, Info = 200, Warn = 300, Error = 400 + // This documents the expected spacing + assert_eq!(Target::Emulator.default_log_elision(), Some(200)); // Info + assert_eq!(Target::Hardware.default_log_elision(), Some(300)); // Warn + assert_eq!(Mode::Release.log_elision_adjustment(), Some(100)); // Debug + } +} diff --git a/exp/zlup/src/module.rs b/exp/zlup/src/module.rs new file mode 100644 index 000000000..7907a238a --- /dev/null +++ b/exp/zlup/src/module.rs @@ -0,0 +1,671 @@ +//! Module system for Zlup. +//! +//! This module provides support for importing and organizing code across multiple files. +//! +//! ## Usage +//! +//! ```zlup +//! // Import a local file +//! utils := @import("utils.zlp"); +//! +//! // Import from a subdirectory +//! qec := @import("lib/qec.zlp"); +//! +//! // Access exported symbols +//! x := utils.helper_function(); +//! ``` +//! +//! ## Module Resolution +//! +//! Import paths are resolved relative to the importing file's directory. +//! - `"foo.zlp"` -> same directory +//! - `"lib/foo.zlp"` -> lib subdirectory +//! - `"../foo.zlp"` -> parent directory + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +use crate::ast::{Program, TopLevelDecl, TypeExpr}; +use crate::parser::ParseError; + +// ============================================================================= +// Errors +// ============================================================================= + +/// Module loading errors. +#[derive(Debug, Error)] +pub enum ModuleError { + #[error("module not found: {path}")] + NotFound { path: String }, + + #[error("failed to read module '{path}': {source}")] + ReadError { + path: String, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse module '{path}': {message}")] + ParseError { path: String, message: String }, + + #[error("circular import detected: {path}")] + CircularImport { path: String }, + + #[error("invalid import path: {path}")] + InvalidPath { path: String }, +} + +impl From for ModuleError { + fn from(err: ParseError) -> Self { + ModuleError::ParseError { + path: err.location.file.unwrap_or_default(), + message: err.message, + } + } +} + +/// Result type for module operations. +pub type ModuleResult = Result; + +// ============================================================================= +// Module +// ============================================================================= + +/// A loaded module. +#[derive(Debug, Clone)] +pub struct Module { + /// Absolute path to the module file. + pub path: PathBuf, + /// The module's AST. + pub program: Program, + /// Exported symbols (pub declarations). + pub exports: BTreeMap, +} + +/// An exported symbol from a module. +#[derive(Debug, Clone)] +pub enum ExportedSymbol { + /// A function declaration with signature. + Function { + name: String, + params: Vec<(String, TypeExpr)>, + return_type: Option, + }, + /// A constant declaration. + Const { name: String }, + /// A type declaration (struct, enum, union). + Type { name: String }, + /// An error set declaration (classical errors). + ErrorSet { + name: String, + /// The error variant names in this set. + variants: Vec, + }, + /// A fault set declaration (quantum faults). + FaultSet { + name: String, + /// The fault variant names in this set. + variants: Vec, + }, +} + +impl Module { + /// Create a new module from an AST. + pub fn new(path: PathBuf, program: Program) -> Self { + let exports = Self::collect_exports(&program); + Self { + path, + program, + exports, + } + } + + /// Collect exported symbols from the program. + fn collect_exports(program: &Program) -> BTreeMap { + let mut exports = BTreeMap::new(); + + for decl in &program.declarations { + match decl { + TopLevelDecl::Fn(fn_decl) if fn_decl.is_pub => { + let params: Vec<(String, TypeExpr)> = fn_decl + .params + .iter() + .map(|p| (p.name.clone(), p.ty.clone())) + .collect(); + exports.insert( + fn_decl.name.clone(), + ExportedSymbol::Function { + name: fn_decl.name.clone(), + params, + return_type: fn_decl.return_type.clone(), + }, + ); + } + TopLevelDecl::Binding(binding) if binding.is_pub => { + exports.insert( + binding.name.clone(), + ExportedSymbol::Const { + name: binding.name.clone(), + }, + ); + } + TopLevelDecl::Struct(struct_decl) if struct_decl.is_pub => { + exports.insert( + struct_decl.name.clone(), + ExportedSymbol::Type { + name: struct_decl.name.clone(), + }, + ); + } + TopLevelDecl::Enum(enum_decl) if enum_decl.is_pub => { + exports.insert( + enum_decl.name.clone(), + ExportedSymbol::Type { + name: enum_decl.name.clone(), + }, + ); + } + TopLevelDecl::Union(union_decl) if union_decl.is_pub => { + exports.insert( + union_decl.name.clone(), + ExportedSymbol::Type { + name: union_decl.name.clone(), + }, + ); + } + TopLevelDecl::ErrorSet(error_set) if error_set.is_pub => { + exports.insert( + error_set.name.clone(), + ExportedSymbol::ErrorSet { + name: error_set.name.clone(), + variants: error_set.variants.iter().map(|v| v.name.clone()).collect(), + }, + ); + } + TopLevelDecl::FaultSet(fault_set) if fault_set.is_pub => { + exports.insert( + fault_set.name.clone(), + ExportedSymbol::FaultSet { + name: fault_set.name.clone(), + variants: fault_set.variants.iter().map(|v| v.name.clone()).collect(), + }, + ); + } + _ => {} + } + } + + exports + } + + /// Check if a symbol is exported. + pub fn has_export(&self, name: &str) -> bool { + self.exports.contains_key(name) + } + + /// Get an exported symbol. + pub fn get_export(&self, name: &str) -> Option<&ExportedSymbol> { + self.exports.get(name) + } +} + +// ============================================================================= +// Module Loader +// ============================================================================= + +/// Loads and caches modules. +#[derive(Debug, Default)] +pub struct ModuleLoader { + /// Cached modules by absolute path. + cache: BTreeMap, + /// Currently loading modules (for circular import detection). + loading: Vec, + /// Search paths for modules. + search_paths: Vec, +} + +impl ModuleLoader { + /// Create a new module loader. + pub fn new() -> Self { + Self { + cache: BTreeMap::new(), + loading: Vec::new(), + search_paths: Vec::new(), + } + } + + /// Add a search path for modules. + pub fn add_search_path(&mut self, path: impl AsRef) { + self.search_paths.push(path.as_ref().to_path_buf()); + } + + /// Load a module from an import path. + /// + /// # Arguments + /// * `import_path` - The path from the @import directive + /// * `from_file` - The file containing the import (for relative resolution) + pub fn load(&mut self, import_path: &str, from_file: Option<&Path>) -> ModuleResult<&Module> { + // Resolve the import path + let resolved_path = self.resolve_path(import_path, from_file)?; + + // Check cache + if self.cache.contains_key(&resolved_path) { + return Ok(self.cache.get(&resolved_path).unwrap()); + } + + // Check for circular imports + if self.loading.contains(&resolved_path) { + return Err(ModuleError::CircularImport { + path: resolved_path.display().to_string(), + }); + } + + // Mark as loading + self.loading.push(resolved_path.clone()); + + // Load and parse the file + let source = fs::read_to_string(&resolved_path).map_err(|e| ModuleError::ReadError { + path: resolved_path.display().to_string(), + source: e, + })?; + + let filename = resolved_path.display().to_string(); + let program = crate::parse_file(&source, &filename)?; + + // Create module + let module = Module::new(resolved_path.clone(), program); + + // Remove from loading + self.loading.retain(|p| p != &resolved_path); + + // Cache and return + self.cache.insert(resolved_path.clone(), module); + Ok(self.cache.get(&resolved_path).unwrap()) + } + + /// Resolve an import path to an absolute path. + /// + /// Resolution follows Zig-style semantics: + /// - `@import("foo.zlp")` - looks for `foo.zlp` directly + /// - `@import("foo")` - looks for `foo.zlp` OR `foo/foo.zlp` (directory with entry file) + /// - `@import("std")` - special case for standard library + fn resolve_path(&self, import_path: &str, from_file: Option<&Path>) -> ModuleResult { + // Validate import path + if import_path.is_empty() { + return Err(ModuleError::InvalidPath { + path: import_path.to_string(), + }); + } + + // Handle special imports + if import_path == "std" { + return self.resolve_std(); + } + + // Get the directory of the importing file + let base_dir = if let Some(from) = from_file { + from.parent().unwrap_or(Path::new(".")).to_path_buf() + } else { + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")) + }; + + // Try to find the module + self.find_module(import_path, &base_dir) + } + + /// Resolve the standard library path. + fn resolve_std(&self) -> ModuleResult { + // Standard library location resolution: + // 1. Check ZLUP_STDLIB_PATH environment variable + // 2. Check for lib/std relative to executable + // 3. Check search paths + + // Try environment variable first + if let Ok(stdlib_path) = std::env::var("ZLUP_STDLIB_PATH") { + let stdlib = PathBuf::from(&stdlib_path); + + // Try std/std.zlp (directory with entry file) + let std_dir_entry = stdlib.join("std").join("std.zlp"); + if std_dir_entry.exists() { + return std_dir_entry.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); + } + + // Try std.zlp directly + let std_file = stdlib.join("std.zlp"); + if std_file.exists() { + return std_file.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_file.display().to_string(), + source: e, + }); + } + } + + // Try relative to executable + if let Ok(exe_path) = std::env::current_exe() + && let Some(exe_dir) = exe_path.parent() + { + let lib_dir = exe_dir.join("lib"); + + // Try lib/std/std.zlp + let std_dir_entry = lib_dir.join("std").join("std.zlp"); + if std_dir_entry.exists() { + return std_dir_entry.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); + } + + // Try lib/std.zlp + let std_file = lib_dir.join("std.zlp"); + if std_file.exists() { + return std_file.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_file.display().to_string(), + source: e, + }); + } + } + + // Try search paths + for search_path in &self.search_paths { + // Try std/std.zlp + let std_dir_entry = search_path.join("std").join("std.zlp"); + if std_dir_entry.exists() { + return std_dir_entry.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); + } + + // Try std.zlp + let std_file = search_path.join("std.zlp"); + if std_file.exists() { + return std_file.canonicalize().map_err(|e| ModuleError::ReadError { + path: std_file.display().to_string(), + source: e, + }); + } + } + + Err(ModuleError::NotFound { + path: "std (set ZLUP_STDLIB_PATH to point to stdlib directory)".to_string(), + }) + } + + /// Find a module given an import path and base directory. + /// + /// Tries multiple resolution strategies: + /// 1. Direct path (if it has .zlp extension) + /// 2. Path with .zlp appended + /// 3. Directory with entry file (name/name.zlp) + fn find_module(&self, import_path: &str, base_dir: &Path) -> ModuleResult { + let has_extension = import_path.ends_with(".zlp"); + + // Build list of candidates to try + let mut candidates = Vec::new(); + + // If it already has .zlp extension, try as-is first + if has_extension { + candidates.push(base_dir.join(import_path)); + } else { + // Try with .zlp extension + candidates.push(base_dir.join(format!("{}.zlp", import_path))); + + // Try as directory with entry file (Zig-style: foo -> foo/foo.zlp) + let module_name = Path::new(import_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(import_path); + candidates.push(base_dir.join(import_path).join(format!("{}.zlp", module_name))); + } + + // Try candidates relative to base_dir + for candidate in &candidates { + if candidate.exists() { + return candidate.canonicalize().map_err(|e| ModuleError::ReadError { + path: candidate.display().to_string(), + source: e, + }); + } + } + + // Try search paths with same candidate patterns + for search_path in &self.search_paths { + let search_candidates: Vec = if has_extension { + vec![search_path.join(import_path)] + } else { + let module_name = Path::new(import_path) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(import_path); + vec![ + search_path.join(format!("{}.zlp", import_path)), + search_path.join(import_path).join(format!("{}.zlp", module_name)), + ] + }; + + for candidate in search_candidates { + if candidate.exists() { + return candidate.canonicalize().map_err(|e| ModuleError::ReadError { + path: candidate.display().to_string(), + source: e, + }); + } + } + } + + Err(ModuleError::NotFound { + path: import_path.to_string(), + }) + } + + /// Get a cached module. + pub fn get(&self, path: &Path) -> Option<&Module> { + self.cache.get(path) + } + + /// Get all loaded modules. + pub fn modules(&self) -> impl Iterator { + self.cache.values() + } + + /// Clear the module cache. + pub fn clear_cache(&mut self) { + self.cache.clear(); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::TempDir; + + #[test] + fn test_module_exports() { + let source = r#" + pub fn helper() -> unit {} + fn private_fn() -> unit {} + pub VALUE: u32 = 42; + PRIVATE: u32 = 0; + "#; + + let program = crate::parse_file(source, "test.zlp").unwrap(); + let module = Module::new(PathBuf::from("test.zlp"), program); + + assert!(module.has_export("helper")); + assert!(module.has_export("VALUE")); + assert!(!module.has_export("private_fn")); + assert!(!module.has_export("PRIVATE")); + } + + #[test] + fn test_module_loader_basic() { + let temp_dir = TempDir::new().unwrap(); + + // Create a module file + let module_path = temp_dir.path().join("utils.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn helper() -> unit {{}}").unwrap(); + + // Create a main file that imports it + let main_path = temp_dir.path().join("main.zlp"); + let mut file = fs::File::create(&main_path).unwrap(); + writeln!(file, "utils := @import(\"utils.zlp\");").unwrap(); + writeln!(file, "fn main() -> unit {{}}").unwrap(); + + // Load the module + let mut loader = ModuleLoader::new(); + let module = loader.load("utils.zlp", Some(&main_path)).unwrap(); + + assert!(module.has_export("helper")); + } + + #[test] + fn test_module_loader_not_found() { + let mut loader = ModuleLoader::new(); + let result = loader.load("nonexistent.zlp", None); + assert!(matches!(result, Err(ModuleError::NotFound { .. }))); + } + + #[test] + fn test_circular_import_detection() { + let temp_dir = TempDir::new().unwrap(); + + // Create two files that import each other + let a_path = temp_dir.path().join("a.zlp"); + let b_path = temp_dir.path().join("b.zlp"); + + let mut file = fs::File::create(&a_path).unwrap(); + writeln!(file, "b := @import(\"b.zlp\");").unwrap(); + writeln!(file, "pub fn from_a() -> unit {{}}").unwrap(); + + let mut file = fs::File::create(&b_path).unwrap(); + writeln!(file, "a := @import(\"a.zlp\");").unwrap(); + writeln!(file, "pub fn from_b() -> unit {{}}").unwrap(); + + // The loader itself doesn't detect cycles during parsing + // (that would require semantic analysis to process @import) + // But we can test the loading mechanism + let mut loader = ModuleLoader::new(); + + // Loading a.zlp should work (doesn't process imports during parse) + let result = loader.load("a.zlp", Some(&temp_dir.path().join("main.zlp"))); + assert!(result.is_ok()); + } + + #[test] + fn test_search_paths() { + let temp_dir = TempDir::new().unwrap(); + let lib_dir = temp_dir.path().join("lib"); + fs::create_dir(&lib_dir).unwrap(); + + // Create a module in lib/ + let module_path = lib_dir.join("mymod.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn mymod_fn() -> unit {{}}").unwrap(); + + // Load with search path + let mut loader = ModuleLoader::new(); + loader.add_search_path(&lib_dir); + + let module = loader.load("mymod.zlp", None).unwrap(); + assert!(module.has_export("mymod_fn")); + } + + #[test] + fn test_no_extension_import() { + // @import("utils") should find utils.zlp + let temp_dir = TempDir::new().unwrap(); + + let module_path = temp_dir.path().join("utils.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn util_fn() -> unit {{}}").unwrap(); + + let main_path = temp_dir.path().join("main.zlp"); + + let mut loader = ModuleLoader::new(); + let module = loader.load("utils", Some(&main_path)).unwrap(); + + assert!(module.has_export("util_fn")); + } + + #[test] + fn test_directory_style_import() { + // @import("mylib") should find mylib/mylib.zlp (Zig-style) + let temp_dir = TempDir::new().unwrap(); + + let lib_dir = temp_dir.path().join("mylib"); + fs::create_dir(&lib_dir).unwrap(); + + let module_path = lib_dir.join("mylib.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn lib_fn() -> unit {{}}").unwrap(); + + let main_path = temp_dir.path().join("main.zlp"); + + let mut loader = ModuleLoader::new(); + let module = loader.load("mylib", Some(&main_path)).unwrap(); + + assert!(module.has_export("lib_fn")); + } + + #[test] + fn test_nested_directory_import() { + // @import("qec/decoder") should find qec/decoder.zlp + let temp_dir = TempDir::new().unwrap(); + + let qec_dir = temp_dir.path().join("qec"); + fs::create_dir(&qec_dir).unwrap(); + + let module_path = qec_dir.join("decoder.zlp"); + let mut file = fs::File::create(&module_path).unwrap(); + writeln!(file, "pub fn decode() -> unit {{}}").unwrap(); + + let main_path = temp_dir.path().join("main.zlp"); + + let mut loader = ModuleLoader::new(); + let module = loader.load("qec/decoder", Some(&main_path)).unwrap(); + + assert!(module.has_export("decode")); + } + + #[test] + fn test_explicit_extension_preferred() { + // @import("utils.zlp") should find utils.zlp even if utils/utils.zlp exists + let temp_dir = TempDir::new().unwrap(); + + // Create utils.zlp + let direct_path = temp_dir.path().join("utils.zlp"); + let mut file = fs::File::create(&direct_path).unwrap(); + writeln!(file, "pub fn direct() -> unit {{}}").unwrap(); + + // Create utils/utils.zlp + let utils_dir = temp_dir.path().join("utils"); + fs::create_dir(&utils_dir).unwrap(); + let dir_path = utils_dir.join("utils.zlp"); + let mut file = fs::File::create(&dir_path).unwrap(); + writeln!(file, "pub fn from_dir() -> unit {{}}").unwrap(); + + let main_path = temp_dir.path().join("main.zlp"); + + let mut loader = ModuleLoader::new(); + + // Explicit extension should find the direct file + let module = loader.load("utils.zlp", Some(&main_path)).unwrap(); + assert!(module.has_export("direct")); + + // Without extension, should find direct file first (before dir) + loader.clear_cache(); + let module = loader.load("utils", Some(&main_path)).unwrap(); + assert!(module.has_export("direct")); + } +} diff --git a/exp/zlup/src/optimize.rs b/exp/zlup/src/optimize.rs new file mode 100644 index 000000000..02760a434 --- /dev/null +++ b/exp/zlup/src/optimize.rs @@ -0,0 +1,2746 @@ +//! Optimization passes for Zlup AST. +//! +//! This module provides various optimization passes that transform the AST +//! to produce more efficient code. All optimizations preserve program semantics. +//! +//! ## Available Passes +//! +//! - **Constant Folding**: Evaluate constant expressions at compile time +//! - **Dead Code Elimination**: Remove unreachable code +//! - **Unused Binding Elimination**: Remove unused variable declarations +//! - **Gate Cancellation**: Cancel adjacent inverse quantum gates (X X = I, H H = I) +//! - **Identity Removal**: Remove gates that have no effect +//! +//! ## Usage +//! +//! ```rust +//! use zlup::optimize::Optimizer; +//! +//! let source = "fn main() -> unit { x := 1 + 2; return unit; }"; +//! let program = zlup::parse(source).expect("parse failed"); +//! let mut optimizer = Optimizer::new(); +//! let optimized = optimizer.optimize(program); +//! // optimized has constant expressions folded (1 + 2 -> 3) +//! ``` + +use std::collections::BTreeSet; + +use crate::ast::{ + Attribute, BinaryExpr, BinaryOp, Binding, Block, BoolLit, ElseBranch, Expr, FloatLit, + ForRange, ForStmt, GateKind, GateOp, IfStmt, IntLit, Program, SlotRef, Stmt, TopLevelDecl, + UnaryExpr, UnaryOp, +}; +use crate::comptime::{ComptimeEvaluator, ComptimeValue}; + +// ============================================================================= +// Configuration +// ============================================================================= + +/// Configuration for the optimizer. +#[derive(Debug, Clone)] +pub struct OptimizeConfig { + /// Enable constant folding + pub constant_folding: bool, + /// Enable dead code elimination + pub dead_code_elimination: bool, + /// Enable unused binding elimination + pub unused_binding_elimination: bool, + /// Enable gate cancellation + pub gate_cancellation: bool, + /// Enable identity gate removal + pub identity_removal: bool, + /// Enable inline for loop unrolling + pub inline_for_unrolling: bool, + /// Maximum number of optimization iterations + pub max_iterations: usize, + /// Maximum number of iterations for inline for unrolling (safety limit) + pub max_inline_for_iterations: usize, +} + +impl Default for OptimizeConfig { + fn default() -> Self { + Self { + constant_folding: true, + dead_code_elimination: true, + unused_binding_elimination: true, + gate_cancellation: true, + identity_removal: true, + inline_for_unrolling: true, + max_iterations: 10, + max_inline_for_iterations: 1024, + } + } +} + +impl OptimizeConfig { + /// Create a config with all optimizations enabled. + pub fn all() -> Self { + Self::default() + } + + /// Create a config with no optimizations. + pub fn none() -> Self { + Self { + constant_folding: false, + dead_code_elimination: false, + unused_binding_elimination: false, + gate_cancellation: false, + identity_removal: false, + inline_for_unrolling: false, + max_iterations: 0, + max_inline_for_iterations: 0, + } + } +} + +// ============================================================================= +// Statistics +// ============================================================================= + +/// Statistics about optimizations performed. +#[derive(Debug, Clone, Default)] +pub struct OptimizeStats { + /// Number of constants folded + pub constants_folded: usize, + /// Number of dead code blocks removed + pub dead_code_removed: usize, + /// Number of unused bindings removed + pub unused_bindings_removed: usize, + /// Number of gate pairs cancelled + pub gates_cancelled: usize, + /// Number of identity gates removed + pub identities_removed: usize, + /// Number of inline for loops unrolled + pub inline_for_unrolled: usize, + /// Total number of statements generated from inline for unrolling + pub inline_for_statements_generated: usize, + /// Number of optimization iterations performed + pub iterations: usize, +} + +// ============================================================================= +// Optimizer +// ============================================================================= + +/// AST optimizer. +pub struct Optimizer { + config: OptimizeConfig, + stats: OptimizeStats, + evaluator: ComptimeEvaluator, +} + +impl Default for Optimizer { + fn default() -> Self { + Self::new() + } +} + +impl Optimizer { + /// Create a new optimizer with default configuration. + pub fn new() -> Self { + Self { + config: OptimizeConfig::default(), + evaluator: ComptimeEvaluator::new(), + stats: OptimizeStats::default(), + } + } + + /// Create a new optimizer with specific configuration. + pub fn with_config(config: OptimizeConfig) -> Self { + Self { + config, + evaluator: ComptimeEvaluator::new(), + stats: OptimizeStats::default(), + } + } + + /// Get optimization statistics. + pub fn stats(&self) -> &OptimizeStats { + &self.stats + } + + /// Optimize a program. + pub fn optimize(&mut self, program: Program) -> Program { + let mut result = program; + + for iteration in 0..self.config.max_iterations { + let before_stats = self.stats.clone(); + + result = self.optimize_pass(result); + + self.stats.iterations = iteration + 1; + + // Check if any optimizations were performed + if self.stats.constants_folded == before_stats.constants_folded + && self.stats.dead_code_removed == before_stats.dead_code_removed + && self.stats.unused_bindings_removed == before_stats.unused_bindings_removed + && self.stats.gates_cancelled == before_stats.gates_cancelled + && self.stats.identities_removed == before_stats.identities_removed + { + // No changes, stop iterating + break; + } + } + + result + } + + /// Run one optimization pass. + fn optimize_pass(&mut self, program: Program) -> Program { + let mut declarations = Vec::new(); + + for decl in program.declarations { + declarations.push(self.optimize_decl(decl)); + } + + Program { + name: program.name, + declarations, + location: program.location, + } + } + + /// Optimize a top-level declaration. + fn optimize_decl(&mut self, decl: TopLevelDecl) -> TopLevelDecl { + match decl { + TopLevelDecl::Fn(mut fn_decl) => { + fn_decl.body = self.optimize_block(fn_decl.body); + TopLevelDecl::Fn(fn_decl) + } + TopLevelDecl::Binding(binding) => { + TopLevelDecl::Binding(self.optimize_binding(binding)) + } + other => other, + } + } + + /// Optimize a block of statements. + fn optimize_block(&mut self, block: Block) -> Block { + let mut statements = Vec::new(); + + // First pass: optimize individual statements + for stmt in block.statements { + if let Some(optimized) = self.optimize_stmt(stmt) { + statements.push(optimized); + } + } + + // Gate cancellation pass + if self.config.gate_cancellation { + statements = self.cancel_gates(statements); + } + + // Unused binding elimination + if self.config.unused_binding_elimination { + statements = self.eliminate_unused_bindings(statements); + } + + Block { + label: block.label, + attrs: block.attrs, + statements, + trailing_expr: block.trailing_expr, + location: block.location, + } + } + + /// Optimize a statement. Returns None if the statement should be removed. + fn optimize_stmt(&mut self, stmt: Stmt) -> Option { + match stmt { + Stmt::Binding(binding) => Some(Stmt::Binding(self.optimize_binding(binding))), + + Stmt::Expr(expr_stmt) => { + // Check for identity gates (e.g., rz(0)) before optimization + if self.config.identity_removal + && let Expr::Gate(ref gate) = expr_stmt.expr + && let Some(angle) = gate.params.first() + && self.is_zero_angle(angle) { + self.stats.identities_removed += 1; + return None; // Remove the identity gate + } + let optimized = self.optimize_expr(expr_stmt.expr); + Some(Stmt::Expr(crate::ast::ExprStmt { + expr: optimized, + attrs: expr_stmt.attrs, + location: expr_stmt.location, + })) + } + + Stmt::If(if_stmt) => self.optimize_if(if_stmt), + + Stmt::For(for_stmt) => { + // Try to unroll inline for loops + if self.config.inline_for_unrolling && for_stmt.is_inline { + if let Some(unrolled) = self.try_unroll_inline_for(&for_stmt) { + // Return the unrolled statements as a block + return Some(Stmt::Block(Block { + label: for_stmt.label.clone(), + attrs: vec![], + statements: unrolled, + trailing_expr: None, + location: for_stmt.location.clone(), + })); + } + } + Some(Stmt::For(self.optimize_for(for_stmt))) + } + + Stmt::Block(block) => Some(Stmt::Block(self.optimize_block(block))), + + Stmt::Gate(gate_op) => self.optimize_gate_stmt(gate_op), + + Stmt::Return(ret) => { + let mut optimized = ret; + if let Some(expr) = optimized.value { + optimized.value = Some(self.optimize_expr(expr)); + } + Some(Stmt::Return(optimized)) + } + + Stmt::Tick(tick_stmt) => { + // Optimize statements within the tick block individually + // Note: The tick block itself acts as a barrier for cross-block optimization, + // but gates WITHIN the same tick can still cancel each other + let optimized_stmts: Vec = tick_stmt + .body + .into_iter() + .filter_map(|stmt| self.optimize_stmt(stmt)) + .collect(); + + // Apply gate cancellation within the tick + let optimized_stmts = if self.config.gate_cancellation { + self.cancel_gates(optimized_stmts) + } else { + optimized_stmts + }; + + Some(Stmt::Tick(crate::ast::TickStmt { + label: tick_stmt.label, + body: optimized_stmts, + attrs: tick_stmt.attrs, + location: tick_stmt.location, + })) + } + + // Pass through other statements + other => Some(other), + } + } + + /// Optimize a binding. + fn optimize_binding(&mut self, binding: Binding) -> Binding { + let mut optimized = binding; + if let Some(value) = optimized.value { + optimized.value = Some(self.optimize_expr(value)); + } + optimized + } + + /// Optimize an expression. + fn optimize_expr(&mut self, expr: Expr) -> Expr { + // Try constant folding first + if self.config.constant_folding + && let Some(folded) = self.try_fold_constant(&expr) { + self.stats.constants_folded += 1; + return folded; + } + + // Recursively optimize subexpressions + match expr { + Expr::Binary(bin) => { + let left = self.optimize_expr(bin.left.clone()); + let right = self.optimize_expr(bin.right.clone()); + + // Try folding after optimizing children + let new_bin = BinaryExpr { + op: bin.op, + left, + right, + location: bin.location, + }; + + if self.config.constant_folding + && let Some(folded) = self.try_fold_binary(&new_bin) { + self.stats.constants_folded += 1; + return folded; + } + + Expr::Binary(Box::new(new_bin)) + } + + Expr::Unary(un) => { + let operand = self.optimize_expr(un.operand.clone()); + + let new_un = UnaryExpr { + op: un.op, + operand, + location: un.location, + }; + + if self.config.constant_folding + && let Some(folded) = self.try_fold_unary(&new_un) { + self.stats.constants_folded += 1; + return folded; + } + + Expr::Unary(Box::new(new_un)) + } + + Expr::Call(mut call) => { + call.args = call.args.into_iter().map(|a| self.optimize_expr(a)).collect(); + Expr::Call(call) + } + + Expr::Index(mut idx) => { + idx.object = self.optimize_expr(idx.object); + idx.index = self.optimize_expr(idx.index); + Expr::Index(idx) + } + + Expr::Field(mut field) => { + field.object = self.optimize_expr(field.object); + Expr::Field(field) + } + + Expr::Tuple(mut tuple) => { + tuple.elements = tuple.elements.into_iter().map(|e| self.optimize_expr(e)).collect(); + Expr::Tuple(tuple) + } + + Expr::BracketArray(mut arr) => { + arr.elements = arr.elements.into_iter().map(|e| self.optimize_expr(e)).collect(); + Expr::BracketArray(arr) + } + + // Pass through other expressions + other => other, + } + } + + /// Try to fold an expression to a constant. + fn try_fold_constant(&mut self, expr: &Expr) -> Option { + match self.evaluator.eval_expr(expr) { + Ok(value) => self.comptime_to_expr(&value, expr), + Err(_) => None, + } + } + + /// Try to fold a binary expression. + fn try_fold_binary(&mut self, bin: &BinaryExpr) -> Option { + // Check for identity operations + match (&bin.left, bin.op, &bin.right) { + // x + 0 = x, x - 0 = x + (_, BinaryOp::Add | BinaryOp::Sub, Expr::IntLit(IntLit { value: 0, .. })) => { + return Some(bin.left.clone()); + } + // 0 + x = x + (Expr::IntLit(IntLit { value: 0, .. }), BinaryOp::Add, _) => { + return Some(bin.right.clone()); + } + // x * 1 = x, x / 1 = x + (_, BinaryOp::Mul | BinaryOp::Div, Expr::IntLit(IntLit { value: 1, .. })) => { + return Some(bin.left.clone()); + } + // 1 * x = x + (Expr::IntLit(IntLit { value: 1, .. }), BinaryOp::Mul, _) => { + return Some(bin.right.clone()); + } + // x * 0 = 0 + (_, BinaryOp::Mul, Expr::IntLit(IntLit { value: 0, .. })) => { + return Some(Expr::IntLit(IntLit { + value: 0, + suffix: None, + location: bin.location.clone(), + })); + } + // 0 * x = 0 + (Expr::IntLit(IntLit { value: 0, .. }), BinaryOp::Mul, _) => { + return Some(Expr::IntLit(IntLit { + value: 0, + suffix: None, + location: bin.location.clone(), + })); + } + // x && true = x, x || false = x + (_, BinaryOp::And, Expr::BoolLit(BoolLit { value: true, .. })) => { + return Some(bin.left.clone()); + } + (_, BinaryOp::Or, Expr::BoolLit(BoolLit { value: false, .. })) => { + return Some(bin.left.clone()); + } + // true && x = x, false || x = x + (Expr::BoolLit(BoolLit { value: true, .. }), BinaryOp::And, _) => { + return Some(bin.right.clone()); + } + (Expr::BoolLit(BoolLit { value: false, .. }), BinaryOp::Or, _) => { + return Some(bin.right.clone()); + } + // x && false = false, x || true = true + (_, BinaryOp::And, Expr::BoolLit(BoolLit { value: false, .. })) => { + return Some(Expr::BoolLit(BoolLit { + value: false, + location: bin.location.clone(), + })); + } + (_, BinaryOp::Or, Expr::BoolLit(BoolLit { value: true, .. })) => { + return Some(Expr::BoolLit(BoolLit { + value: true, + location: bin.location.clone(), + })); + } + _ => {} + } + + // Try full constant evaluation + self.try_fold_constant(&Expr::Binary(Box::new(bin.clone()))) + } + + /// Try to fold a unary expression. + fn try_fold_unary(&mut self, un: &UnaryExpr) -> Option { + // Double negation: --x = x, !!x = x + if let Expr::Unary(inner) = &un.operand + && un.op == inner.op && matches!(un.op, UnaryOp::Neg | UnaryOp::Not) { + return Some(inner.operand.clone()); + } + + // Try full constant evaluation + self.try_fold_constant(&Expr::Unary(Box::new(un.clone()))) + } + + /// Convert a comptime value to an AST expression. + fn comptime_to_expr(&self, value: &ComptimeValue, original: &Expr) -> Option { + let location = match original { + Expr::Binary(b) => b.location.clone(), + Expr::Unary(u) => u.location.clone(), + Expr::IntLit(i) => i.location.clone(), + Expr::FloatLit(f) => f.location.clone(), + Expr::BoolLit(b) => b.location.clone(), + _ => None, + }; + + match value { + ComptimeValue::Int(v) => Some(Expr::IntLit(IntLit { + value: *v as i128, + suffix: None, + location, + })), + ComptimeValue::Uint(v) => Some(Expr::IntLit(IntLit { + value: *v as i128, + suffix: None, + location, + })), + ComptimeValue::Float(v) => Some(Expr::FloatLit(FloatLit { + value: *v, + suffix: None, + location, + })), + ComptimeValue::Bool(v) => Some(Expr::BoolLit(BoolLit { + value: *v, + location, + })), + _ => None, // Don't fold complex types + } + } + + /// Optimize an if statement. + fn optimize_if(&mut self, if_stmt: IfStmt) -> Option { + let condition = self.optimize_expr(if_stmt.condition); + + // Check for constant condition + if self.config.dead_code_elimination + && let Expr::BoolLit(BoolLit { value, .. }) = &condition { + self.stats.dead_code_removed += 1; + if *value { + // Condition is always true - keep then branch + return Some(Stmt::Block(self.optimize_block(if_stmt.then_body))); + } else { + // Condition is always false - keep else branch or remove + return match if_stmt.else_body { + Some(ElseBranch::Else(block)) => { + Some(Stmt::Block(self.optimize_block(block))) + } + Some(ElseBranch::ElseIf(nested)) => self.optimize_if(*nested), + None => None, + }; + } + } + + let then_body = self.optimize_block(if_stmt.then_body); + let else_body = match if_stmt.else_body { + Some(ElseBranch::Else(block)) => Some(ElseBranch::Else(self.optimize_block(block))), + Some(ElseBranch::ElseIf(nested)) => { + if let Some(Stmt::If(optimized)) = self.optimize_if(*nested) { + Some(ElseBranch::ElseIf(Box::new(optimized))) + } else { + None + } + } + None => None, + }; + + Some(Stmt::If(IfStmt { + condition, + capture: if_stmt.capture, + then_body, + else_body, + location: if_stmt.location, + })) + } + + /// Optimize a for statement. + fn optimize_for(&mut self, for_stmt: ForStmt) -> ForStmt { + let body = self.optimize_block(for_stmt.body); + + // Optimize range bounds + let range = match for_stmt.range { + ForRange::Range { start, end } => ForRange::Range { + start: self.optimize_expr(start), + end: self.optimize_expr(end), + }, + ForRange::Collection(expr) => ForRange::Collection(self.optimize_expr(expr)), + }; + + ForStmt { + label: for_stmt.label, + is_inline: for_stmt.is_inline, + range, + captures: for_stmt.captures, + body, + location: for_stmt.location, + } + } + + // ========================================================================= + // Inline For Loop Unrolling + // ========================================================================= + + /// Try to unroll an inline for loop. + /// + /// Returns `Some(unrolled_statements)` if the loop can be unrolled, `None` otherwise. + /// Unrolling is possible when: + /// - The loop is marked as `inline` + /// - The range bounds are comptime-evaluable + /// - The iteration count is within the safety limit + fn try_unroll_inline_for(&mut self, for_stmt: &ForStmt) -> Option> { + if !for_stmt.is_inline { + return None; + } + + // Evaluate range bounds at comptime + let (start, end) = match &for_stmt.range { + ForRange::Range { start, end } => { + let start_val = self.evaluator.eval_expr(start).ok()?.as_int()?; + let end_val = self.evaluator.eval_expr(end).ok()?.as_int()?; + (start_val, end_val) + } + ForRange::Collection(expr) => { + // For collections, evaluate and get the length + let val = self.evaluator.eval_expr(expr).ok()?; + match val { + ComptimeValue::Array(arr) => (0, arr.len() as i64), + _ => return None, + } + } + }; + + // Check iteration count against safety limit + let iteration_count = (end - start).max(0) as usize; + if iteration_count > self.config.max_inline_for_iterations { + return None; + } + + // Get the capture variable name + let capture_name = for_stmt.captures.first()?; + + // Generate unrolled statements + let mut unrolled = Vec::with_capacity(iteration_count * for_stmt.body.statements.len()); + for i in start..end { + // Substitute the loop variable with the concrete value in each statement + for stmt in &for_stmt.body.statements { + let substituted = self.substitute_in_stmt(stmt, capture_name, i); + // Recursively optimize the substituted statement (handles nested inline for) + if let Some(optimized) = self.optimize_stmt(substituted) { + // If the optimized statement is a block (from nested unrolling), flatten it + match optimized { + Stmt::Block(block) => unrolled.extend(block.statements), + other => unrolled.push(other), + } + } + } + } + + self.stats.inline_for_unrolled += 1; + self.stats.inline_for_statements_generated += unrolled.len(); + + Some(unrolled) + } + + /// Substitute a variable with a concrete integer value in a statement. + fn substitute_in_stmt(&self, stmt: &Stmt, var_name: &str, value: i64) -> Stmt { + match stmt { + Stmt::Binding(binding) => Stmt::Binding(Binding { + name: binding.name.clone(), + ty: binding.ty.clone(), + value: binding.value.as_ref().map(|e| self.substitute_in_expr(e, var_name, value)), + is_mutable: binding.is_mutable, + is_pub: binding.is_pub, + doc_comment: binding.doc_comment.clone(), + location: binding.location.clone(), + }), + + Stmt::Expr(expr_stmt) => Stmt::Expr(crate::ast::ExprStmt { + expr: self.substitute_in_expr(&expr_stmt.expr, var_name, value), + attrs: expr_stmt.attrs.clone(), + location: expr_stmt.location.clone(), + }), + + Stmt::Gate(gate_op) => Stmt::Gate(GateOp { + kind: gate_op.kind, + targets: gate_op + .targets + .iter() + .map(|t| self.substitute_in_slot_ref(t, var_name, value)) + .collect(), + params: gate_op + .params + .iter() + .map(|e| self.substitute_in_expr(e, var_name, value)) + .collect(), + attrs: gate_op.attrs.clone(), + location: gate_op.location.clone(), + }), + + Stmt::If(if_stmt) => Stmt::If(IfStmt { + condition: self.substitute_in_expr(&if_stmt.condition, var_name, value), + capture: if_stmt.capture.clone(), + then_body: self.substitute_in_block(&if_stmt.then_body, var_name, value), + else_body: if_stmt.else_body.as_ref().map(|eb| match eb { + ElseBranch::Else(block) => { + ElseBranch::Else(self.substitute_in_block(block, var_name, value)) + } + ElseBranch::ElseIf(nested) => { + if let Stmt::If(nested_if) = self.substitute_in_stmt(&Stmt::If(*nested.clone()), var_name, value) { + ElseBranch::ElseIf(Box::new(nested_if)) + } else { + eb.clone() + } + } + }), + location: if_stmt.location.clone(), + }), + + Stmt::For(for_stmt) => Stmt::For(ForStmt { + label: for_stmt.label.clone(), + is_inline: for_stmt.is_inline, + range: match &for_stmt.range { + ForRange::Range { start, end } => ForRange::Range { + start: self.substitute_in_expr(start, var_name, value), + end: self.substitute_in_expr(end, var_name, value), + }, + ForRange::Collection(expr) => { + ForRange::Collection(self.substitute_in_expr(expr, var_name, value)) + } + }, + captures: for_stmt.captures.clone(), + body: self.substitute_in_block(&for_stmt.body, var_name, value), + location: for_stmt.location.clone(), + }), + + Stmt::Block(block) => Stmt::Block(self.substitute_in_block(block, var_name, value)), + + Stmt::Return(ret) => Stmt::Return(crate::ast::ReturnStmt { + value: ret.value.as_ref().map(|e| self.substitute_in_expr(e, var_name, value)), + location: ret.location.clone(), + }), + + Stmt::Assign(assign) => Stmt::Assign(crate::ast::AssignStmt { + target: self.substitute_in_expr(&assign.target, var_name, value), + op: assign.op, + value: self.substitute_in_expr(&assign.value, var_name, value), + location: assign.location.clone(), + }), + + Stmt::Tick(tick) => Stmt::Tick(crate::ast::TickStmt { + label: tick.label.clone(), + attrs: tick.attrs.clone(), + body: tick.body.iter().map(|s| self.substitute_in_stmt(s, var_name, value)).collect(), + location: tick.location.clone(), + }), + + // Pass through statements that don't contain expressions + other => other.clone(), + } + } + + /// Substitute a variable with a concrete integer value in a block. + fn substitute_in_block(&self, block: &Block, var_name: &str, value: i64) -> Block { + Block { + label: block.label.clone(), + attrs: block.attrs.clone(), + statements: block + .statements + .iter() + .map(|s| self.substitute_in_stmt(s, var_name, value)) + .collect(), + trailing_expr: block + .trailing_expr + .as_ref() + .map(|e| Box::new(self.substitute_in_expr(e, var_name, value))), + location: block.location.clone(), + } + } + + /// Substitute a variable with a concrete integer value in an expression. + fn substitute_in_expr(&self, expr: &Expr, var_name: &str, value: i64) -> Expr { + match expr { + Expr::Ident(ident) if ident.name == var_name => { + // Replace identifier with the concrete value + Expr::IntLit(IntLit { + value: value as i128, + suffix: None, + location: ident.location.clone(), + }) + } + + Expr::Binary(bin) => Expr::Binary(Box::new(BinaryExpr { + op: bin.op, + left: self.substitute_in_expr(&bin.left, var_name, value), + right: self.substitute_in_expr(&bin.right, var_name, value), + location: bin.location.clone(), + })), + + Expr::Unary(un) => Expr::Unary(Box::new(UnaryExpr { + op: un.op, + operand: self.substitute_in_expr(&un.operand, var_name, value), + location: un.location.clone(), + })), + + Expr::Index(idx) => Expr::Index(Box::new(crate::ast::IndexExpr { + object: self.substitute_in_expr(&idx.object, var_name, value), + index: self.substitute_in_expr(&idx.index, var_name, value), + location: idx.location.clone(), + })), + + Expr::Field(field) => Expr::Field(Box::new(crate::ast::FieldExpr { + object: self.substitute_in_expr(&field.object, var_name, value), + field: field.field.clone(), + location: field.location.clone(), + })), + + Expr::Call(call) => Expr::Call(Box::new(crate::ast::CallExpr { + callee: self.substitute_in_expr(&call.callee, var_name, value), + args: call.args.iter().map(|a| self.substitute_in_expr(a, var_name, value)).collect(), + location: call.location.clone(), + })), + + Expr::Tuple(tuple) => Expr::Tuple(Box::new(crate::ast::TupleExpr { + elements: tuple.elements.iter().map(|e| self.substitute_in_expr(e, var_name, value)).collect(), + location: tuple.location.clone(), + })), + + Expr::BracketArray(arr) => Expr::BracketArray(Box::new(crate::ast::BracketArrayExpr { + elements: arr.elements.iter().map(|e| self.substitute_in_expr(e, var_name, value)).collect(), + location: arr.location.clone(), + })), + + Expr::Gate(gate) => Expr::Gate(Box::new(crate::ast::GateExpr { + kind: gate.kind, + params: gate.params.iter().map(|p| self.substitute_in_expr(p, var_name, value)).collect(), + target: self.substitute_in_expr(&gate.target, var_name, value), + location: gate.location.clone(), + })), + + Expr::SlotRef(slot) => Expr::SlotRef(Box::new(self.substitute_in_slot_ref(slot, var_name, value))), + + Expr::If(if_expr) => Expr::If(Box::new(crate::ast::IfExpr { + condition: self.substitute_in_expr(&if_expr.condition, var_name, value), + then_expr: self.substitute_in_expr(&if_expr.then_expr, var_name, value), + else_expr: self.substitute_in_expr(&if_expr.else_expr, var_name, value), + location: if_expr.location.clone(), + })), + + // Pass through expressions that don't contain the variable + other => other.clone(), + } + } + + /// Substitute a variable in a slot reference. + fn substitute_in_slot_ref(&self, slot: &SlotRef, var_name: &str, value: i64) -> SlotRef { + SlotRef { + allocator: slot.allocator.clone(), + index: Box::new(self.substitute_in_expr(&slot.index, var_name, value)), + location: slot.location.clone(), + } + } + + /// Optimize a gate statement. + fn optimize_gate_stmt(&mut self, gate_op: GateOp) -> Option { + // Remove identity rotations (rotation by 0) + if self.config.identity_removal + && let Some(angle) = gate_op.params.first() + && self.is_zero_angle(angle) { + self.stats.identities_removed += 1; + return None; + } + + Some(Stmt::Gate(gate_op)) + } + + /// Check if an expression represents a zero angle. + fn is_zero_angle(&self, expr: &Expr) -> bool { + match expr { + Expr::IntLit(IntLit { value: 0, .. }) => true, + Expr::FloatLit(FloatLit { value, .. }) if *value == 0.0 => true, + Expr::AngleLit(angle_lit) => self.is_zero_angle(&angle_lit.value), + _ => false, + } + } + + /// Cancel adjacent inverse gates. + /// + /// Respects optimization barriers: + /// - `@attr(preserve, ...)` attribute on gates prevents cancellation + /// - `@attr(round, n)` attribute changes act as barriers (different rounds don't cancel) + /// - `@attr(timing, ...)` attribute preserves gates for timing purposes + /// - `@attr(identity, ...)` explicitly marks intentional identity operations + /// - Blocks with `@attr(noopt, ...)` act as optimization barriers + fn cancel_gates(&mut self, statements: Vec) -> Vec { + let mut result = Vec::new(); + let mut current_round: Option = None; + + let mut i = 0; + while i < statements.len() { + let stmt = &statements[i]; + + // Check for optimization barriers + if self.is_optimization_barrier(stmt) { + result.push(statements[i].clone()); + i += 1; + continue; + } + + // Track round changes - different rounds don't cancel across + if let Some(round) = self.get_round_attr(stmt) { + if current_round.is_some() && current_round != Some(round) { + // Round changed - this is a barrier + current_round = Some(round); + result.push(statements[i].clone()); + i += 1; + continue; + } + current_round = Some(round); + } + + // Check if this and the next statement are inverse gates that can cancel + // Gates can be either Stmt::Gate(GateOp) or Stmt::Expr(ExprStmt { expr: Expr::Gate(...) }) + if i + 1 < statements.len() + && let (Some((g1, attrs1, no_opt1)), Some((g2, attrs2, no_opt2))) = + (self.extract_gate_info(stmt), self.extract_gate_info(&statements[i + 1])) + { + // Don't cancel if either gate is wrapped in @no_optimize or has preserve attr + if !no_opt1 + && !no_opt2 + && !self.has_preserve_attr(&attrs1) + && !self.has_preserve_attr(&attrs2) + && !self.is_optimization_barrier(&statements[i + 1]) + && self.are_inverse_gate_exprs(&g1, &g2) + { + self.stats.gates_cancelled += 2; + i += 2; // Skip both gates + continue; + } + } + + result.push(statements[i].clone()); + i += 1; + } + + result + } + + /// Extract gate info from a statement (handles both Stmt::Gate and Stmt::Expr with Expr::Gate). + /// Also handles @no_optimize(gate_expr) wrapped gates. + fn extract_gate_info(&self, stmt: &Stmt) -> Option<(crate::ast::GateExpr, Vec, bool)> { + match stmt { + Stmt::Gate(gate_op) => { + // Convert GateOp to GateExpr-like structure + // For GateOp, we need to convert targets to a single Expr + let target = if gate_op.targets.len() == 1 { + Expr::SlotRef(Box::new(gate_op.targets[0].clone())) + } else { + // Multiple targets - create a tuple + Expr::Tuple(Box::new(crate::ast::TupleExpr { + elements: gate_op + .targets + .iter() + .map(|t| Expr::SlotRef(Box::new(t.clone()))) + .collect(), + location: gate_op.location.clone(), + })) + }; + Some(( + crate::ast::GateExpr { + kind: gate_op.kind, + params: gate_op.params.clone(), + target, + location: gate_op.location.clone(), + }, + gate_op.attrs.clone(), + false, // not wrapped in @no_optimize + )) + } + Stmt::Expr(expr_stmt) => { + // Check for @no_optimize(gate_expr) builtin + if let Expr::Builtin(builtin) = &expr_stmt.expr + && builtin.name == "no_optimize" && builtin.args.len() == 1 + && let Expr::Gate(gate_expr) = &builtin.args[0] { + return Some(( + *gate_expr.clone(), + expr_stmt.attrs.clone(), + true, // wrapped in @no_optimize - should not be cancelled + )); + } + // Regular gate expression + if let Expr::Gate(gate_expr) = &expr_stmt.expr { + Some((*gate_expr.clone(), expr_stmt.attrs.clone(), false)) + } else { + None + } + } + _ => None, + } + } + + /// Check if two gate expressions are inverses (cancel each other). + fn are_inverse_gate_exprs( + &self, + g1: &crate::ast::GateExpr, + g2: &crate::ast::GateExpr, + ) -> bool { + // Must have same target + if !self.same_gate_target(&g1.target, &g2.target) { + return false; + } + + // Check for self-inverse gates + match (g1.kind, g2.kind) { + // H H = I, X X = I, Y Y = I, Z Z = I + (GateKind::H, GateKind::H) + | (GateKind::X, GateKind::X) + | (GateKind::Y, GateKind::Y) + | (GateKind::Z, GateKind::Z) => true, + + // T Tdg = I, Tdg T = I + (GateKind::T, GateKind::Tdg) | (GateKind::Tdg, GateKind::T) => true, + + // SX SXdg = I, SZ SZdg = I (SZ is the S gate), etc. + (GateKind::SX, GateKind::SXdg) | (GateKind::SXdg, GateKind::SX) => true, + (GateKind::SY, GateKind::SYdg) | (GateKind::SYdg, GateKind::SY) => true, + (GateKind::SZ, GateKind::SZdg) | (GateKind::SZdg, GateKind::SZ) => true, + + // F Fdg = I, etc. + (GateKind::F, GateKind::Fdg) | (GateKind::Fdg, GateKind::F) => true, + (GateKind::F4, GateKind::F4dg) | (GateKind::F4dg, GateKind::F4) => true, + + // Two-qubit self-inverse: CX CX = I, CZ CZ = I, SWAP SWAP = I + (GateKind::CX, GateKind::CX) + | (GateKind::CY, GateKind::CY) + | (GateKind::CZ, GateKind::CZ) + | (GateKind::CH, GateKind::CH) + | (GateKind::SWAP, GateKind::SWAP) => true, + + // Rotation cancellation: RX(a) RX(-a) = I + (GateKind::RX, GateKind::RX) + | (GateKind::RY, GateKind::RY) + | (GateKind::RZ, GateKind::RZ) => { + self.are_inverse_rotations(&g1.params, &g2.params) + } + + _ => false, + } + } + + /// Check if two gate targets are the same. + fn same_gate_target(&self, t1: &Expr, t2: &Expr) -> bool { + match (t1, t2) { + // Single qubit: compare SlotRef + (Expr::SlotRef(s1), Expr::SlotRef(s2)) => { + self.same_slot_ref(s1, s2) + } + // Index expressions (q[0], q[1], etc.) + (Expr::Index(idx1), Expr::Index(idx2)) => { + self.same_index_expr(idx1, idx2) + } + // Tuple targets (for multi-qubit gates like cx (q[0], q[1])) + (Expr::Tuple(tup1), Expr::Tuple(tup2)) => { + if tup1.elements.len() != tup2.elements.len() { + return false; + } + tup1.elements.iter().zip(tup2.elements.iter()).all(|(a, b)| { + self.same_gate_target(a, b) + }) + } + _ => false, + } + } + + /// Check if two index expressions are the same (e.g., q[0] == q[0]). + fn same_index_expr(&self, idx1: &crate::ast::IndexExpr, idx2: &crate::ast::IndexExpr) -> bool { + // Compare the object (e.g., 'q' in q[0]) + let same_object = match (&idx1.object, &idx2.object) { + (Expr::Ident(id1), Expr::Ident(id2)) => id1.name == id2.name, + _ => false, + }; + if !same_object { + return false; + } + // Compare the index (e.g., '0' in q[0]) + match (&idx1.index, &idx2.index) { + (Expr::IntLit(lit1), Expr::IntLit(lit2)) => lit1.value == lit2.value, + _ => false, + } + } + + /// Check if two SlotRefs are the same. + fn same_slot_ref(&self, s1: &SlotRef, s2: &SlotRef) -> bool { + if s1.allocator != s2.allocator { + return false; + } + // Compare indices + match (&*s1.index, &*s2.index) { + ( + Expr::IntLit(IntLit { value: v1, .. }), + Expr::IntLit(IntLit { value: v2, .. }), + ) => v1 == v2, + _ => false, + } + } + + /// Check if a statement acts as an optimization barrier. + /// + /// Use `@preserve {}` blocks or `tick {}` to prevent optimization across boundaries. + fn is_optimization_barrier(&self, stmt: &Stmt) -> bool { + match stmt { + Stmt::Gate(gate) => self.has_preserve_attr(&gate.attrs), + Stmt::Block(block) => self.has_preserve_attr(&block.attrs), + // Tick blocks always act as barriers - they represent time slices + // Gates in different ticks shouldn't cancel across the boundary + Stmt::Tick(_) => true, + _ => false, + } + } + + /// Check if attributes contain a preserve marker. + /// + /// Recognized preserve attributes: + /// - `@preserve` - explicit preserve + /// - `@timing` - preserved for timing purposes + /// - `@identity` - intentional identity operation + /// - `@noopt` - no optimization + /// + /// Note: `tick {}` blocks are handled separately as always being barriers. + fn has_preserve_attr(&self, attrs: &[Attribute]) -> bool { + attrs.iter().any(|attr| { + matches!( + attr.name.as_str(), + "preserve" | "timing" | "identity" | "noopt" + ) + }) + } + + /// Get the round number from a @round(n) attribute, if present. + fn get_round_attr(&self, stmt: &Stmt) -> Option { + let attrs = match stmt { + Stmt::Gate(gate) => &gate.attrs, + Stmt::Block(block) => &block.attrs, + Stmt::Tick(tick) => &tick.attrs, + _ => return None, + }; + + for attr in attrs { + if attr.name == "round" + && let Some(crate::ast::AttributeValue::Int(n)) = &attr.value { + return Some(*n); + } + } + None + } + + /// Check if two gates are inverses (cancel each other). + fn are_inverse_gates(&self, g1: &GateOp, g2: &GateOp) -> bool { + // Must have same targets + if !self.same_targets(&g1.targets, &g2.targets) { + return false; + } + + // Check for self-inverse gates + match (g1.kind, g2.kind) { + // H H = I, X X = I, Y Y = I, Z Z = I + (GateKind::H, GateKind::H) + | (GateKind::X, GateKind::X) + | (GateKind::Y, GateKind::Y) + | (GateKind::Z, GateKind::Z) => true, + + // T Tdg = I, Tdg T = I + (GateKind::T, GateKind::Tdg) | (GateKind::Tdg, GateKind::T) => true, + + // SX SXdg = I, SZ SZdg = I (SZ is the S gate), etc. + (GateKind::SX, GateKind::SXdg) | (GateKind::SXdg, GateKind::SX) => true, + (GateKind::SY, GateKind::SYdg) | (GateKind::SYdg, GateKind::SY) => true, + (GateKind::SZ, GateKind::SZdg) | (GateKind::SZdg, GateKind::SZ) => true, + + // F Fdg = I, etc. + (GateKind::F, GateKind::Fdg) | (GateKind::Fdg, GateKind::F) => true, + (GateKind::F4, GateKind::F4dg) | (GateKind::F4dg, GateKind::F4) => true, + + // Two-qubit self-inverse: CX CX = I, CZ CZ = I, SWAP SWAP = I + (GateKind::CX, GateKind::CX) + | (GateKind::CY, GateKind::CY) + | (GateKind::CZ, GateKind::CZ) + | (GateKind::CH, GateKind::CH) + | (GateKind::SWAP, GateKind::SWAP) => true, + + // Rotation cancellation: RX(a) RX(-a) = I + (GateKind::RX, GateKind::RX) + | (GateKind::RY, GateKind::RY) + | (GateKind::RZ, GateKind::RZ) => { + self.are_inverse_rotations(&g1.params, &g2.params) + } + + _ => false, + } + } + + /// Check if rotation parameters are inverses. + fn are_inverse_rotations(&self, params1: &[Expr], params2: &[Expr]) -> bool { + if params1.len() != 1 || params2.len() != 1 { + return false; + } + + // Simple check: if one is the negation of the other + match (¶ms1[0], ¶ms2[0]) { + (Expr::IntLit(IntLit { value: a, .. }), Expr::Unary(un)) => { + if un.op == UnaryOp::Neg + && let Expr::IntLit(IntLit { value: b, .. }) = &un.operand { + return *a == *b; + } + false + } + (Expr::Unary(un), Expr::IntLit(IntLit { value: b, .. })) => { + if un.op == UnaryOp::Neg + && let Expr::IntLit(IntLit { value: a, .. }) = &un.operand { + return *a == *b; + } + false + } + ( + Expr::FloatLit(FloatLit { value: a, .. }), + Expr::FloatLit(FloatLit { value: b, .. }), + ) => (*a + *b).abs() < 1e-10, + _ => false, + } + } + + /// Check if two target lists are the same. + fn same_targets(&self, t1: &[SlotRef], t2: &[SlotRef]) -> bool { + if t1.len() != t2.len() { + return false; + } + for (a, b) in t1.iter().zip(t2.iter()) { + if a.allocator != b.allocator { + return false; + } + // Compare indices + match (&*a.index, &*b.index) { + (Expr::IntLit(IntLit { value: v1, .. }), Expr::IntLit(IntLit { value: v2, .. })) => { + if v1 != v2 { + return false; + } + } + _ => return false, + } + } + true + } + + /// Eliminate unused bindings from a block. + fn eliminate_unused_bindings(&mut self, statements: Vec) -> Vec { + // Collect all used identifiers + let mut used: BTreeSet = BTreeSet::new(); + for stmt in &statements { + self.collect_used_identifiers(stmt, &mut used); + } + + // Filter out unused bindings (but keep those with side effects) + let mut result = Vec::new(); + for stmt in statements { + match &stmt { + Stmt::Binding(binding) => { + if used.contains(&binding.name) || self.has_side_effects(&binding.value) { + result.push(stmt); + } else { + self.stats.unused_bindings_removed += 1; + } + } + _ => result.push(stmt), + } + } + + result + } + + /// Collect all identifiers used in a statement. + fn collect_used_identifiers(&self, stmt: &Stmt, used: &mut BTreeSet) { + match stmt { + Stmt::Expr(expr_stmt) => self.collect_used_in_expr(&expr_stmt.expr, used), + Stmt::Binding(binding) => { + if let Some(value) = &binding.value { + self.collect_used_in_expr(value, used); + } + } + Stmt::If(if_stmt) => { + self.collect_used_in_expr(&if_stmt.condition, used); + for s in &if_stmt.then_body.statements { + self.collect_used_identifiers(s, used); + } + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + ElseBranch::Else(block) => { + for s in &block.statements { + self.collect_used_identifiers(s, used); + } + } + ElseBranch::ElseIf(nested) => { + self.collect_used_identifiers(&Stmt::If(*nested.clone()), used); + } + } + } + } + Stmt::For(for_stmt) => { + match &for_stmt.range { + ForRange::Range { start, end } => { + self.collect_used_in_expr(start, used); + self.collect_used_in_expr(end, used); + } + ForRange::Collection(expr) => self.collect_used_in_expr(expr, used), + } + for s in &for_stmt.body.statements { + self.collect_used_identifiers(s, used); + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + self.collect_used_in_expr(value, used); + } + } + Stmt::Block(block) => { + for s in &block.statements { + self.collect_used_identifiers(s, used); + } + } + Stmt::Gate(gate) => { + for target in &gate.targets { + used.insert(target.allocator.clone()); + } + for param in &gate.params { + self.collect_used_in_expr(param, used); + } + } + _ => {} + } + } + + /// Collect identifiers used in an expression. + fn collect_used_in_expr(&self, expr: &Expr, used: &mut BTreeSet) { + match expr { + Expr::Ident(ident) => { + used.insert(ident.name.clone()); + } + Expr::Binary(bin) => { + self.collect_used_in_expr(&bin.left, used); + self.collect_used_in_expr(&bin.right, used); + } + Expr::Unary(un) => { + self.collect_used_in_expr(&un.operand, used); + } + Expr::Call(call) => { + self.collect_used_in_expr(&call.callee, used); + for arg in &call.args { + self.collect_used_in_expr(arg, used); + } + } + Expr::Index(idx) => { + self.collect_used_in_expr(&idx.object, used); + self.collect_used_in_expr(&idx.index, used); + } + Expr::Field(field) => { + self.collect_used_in_expr(&field.object, used); + } + Expr::Tuple(tuple) => { + for elem in &tuple.elements { + self.collect_used_in_expr(elem, used); + } + } + Expr::BracketArray(arr) => { + for elem in &arr.elements { + self.collect_used_in_expr(elem, used); + } + } + Expr::SlotRef(slot) => { + used.insert(slot.allocator.clone()); + self.collect_used_in_expr(&slot.index, used); + } + _ => {} + } + } + + /// Check if an expression might have side effects. + fn has_side_effects(&self, expr: &Option) -> bool { + match expr { + None => false, + Some(Expr::Call(_)) => true, // Function calls may have side effects + Some(Expr::Gate(_)) => true, // Gate expressions have side effects + Some(Expr::Measure(_)) => true, // Measurements have side effects + _ => false, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + // ========================================================================= + // Constant Folding Tests + // ========================================================================= + + #[test] + fn test_constant_folding_arithmetic() { + let source = "x := 2 + 3;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // The binding should have a constant value of 5 + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 5); + } else { + panic!("Expected constant folded to IntLit"); + } + } + assert!(optimizer.stats.constants_folded > 0); + } + + #[test] + fn test_constant_folding_subtraction() { + let source = "x := 10 - 3;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 7); + } else { + panic!("Expected constant folded to IntLit"); + } + } + } + + #[test] + fn test_constant_folding_multiplication() { + let source = "x := 4 * 5;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 20); + } else { + panic!("Expected constant folded to IntLit"); + } + } + } + + #[test] + fn test_constant_folding_division() { + let source = "x := 20 / 4;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 5); + } else { + panic!("Expected constant folded to IntLit"); + } + } + } + + #[test] + fn test_constant_folding_nested_arithmetic() { + let source = "x := (2 + 3) * (4 - 1);"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 15); // (2+3) * (4-1) = 5 * 3 = 15 + } else { + panic!("Expected constant folded to IntLit"); + } + } + } + + #[test] + fn test_constant_folding_boolean_and() { + // Zlup uses 'and' keyword (not &&) + let source = "pub fn test() -> bool { x := true; return x and false; }\n"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // Verify the optimizer runs without error + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + assert_eq!(fn_decl.name, "test"); + } + } + + #[test] + fn test_constant_folding_boolean_or() { + // Zlup uses 'or' keyword (not ||) + let source = "pub fn test() -> bool { x := false; return x or true; }\n"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + assert_eq!(fn_decl.name, "test"); + } + } + + #[test] + fn test_constant_folding_comparison_eq() { + let source = "x := 5 == 5;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "5 == 5 should be true"); + } else { + panic!("Expected constant folded to BoolLit"); + } + } + } + + #[test] + fn test_constant_folding_comparison_ne() { + let source = "x := 5 != 3;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "5 != 3 should be true"); + } else { + panic!("Expected constant folded to BoolLit"); + } + } + } + + #[test] + fn test_constant_folding_comparison_lt() { + let source = "x := 3 < 5;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "3 < 5 should be true"); + } else { + panic!("Expected constant folded to BoolLit"); + } + } + } + + #[test] + fn test_constant_folding_comparison_gt() { + let source = "x := 5 > 3;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "5 > 3 should be true"); + } else { + panic!("Expected constant folded to BoolLit"); + } + } + } + + // ========================================================================= + // Identity Simplification Tests + // ========================================================================= + + #[test] + fn test_identity_simplification() { + let source = "x := y + 0;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_add_zero_left() { + let source = "x := 0 + y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "0 + y should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_subtract_zero() { + let source = "x := y - 0;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y - 0 should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_multiply_one_right() { + let source = "x := y * 1;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y * 1 should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_multiply_one_left() { + let source = "x := 1 * y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "1 * y should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_divide_one() { + let source = "x := y / 1;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y / 1 should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_multiply_zero_right() { + let source = "x := y * 0;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 0, "y * 0 should simplify to 0"); + } else { + panic!("Expected simplified to 0"); + } + } + } + + #[test] + fn test_identity_multiply_zero_left() { + let source = "x := 0 * y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 0, "0 * y should simplify to 0"); + } else { + panic!("Expected simplified to 0"); + } + } + } + + #[test] + fn test_identity_and_true_right() { + // Zlup uses 'and' keyword, not '&&' + let source = "x := y and true;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y and true should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_and_true_left() { + // Zlup uses 'and' keyword, not '&&' + let source = "x := true and y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "true and y should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_or_false_right() { + // Zlup uses 'or' keyword, not '||' + let source = "x := y or false;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "y or false should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_or_false_left() { + // Zlup uses 'or' keyword, not '||' + let source = "x := false or y;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::Ident(ident)) = &binding.value { + assert_eq!(ident.name, "y", "false or y should simplify to y"); + } else { + panic!("Expected simplified to just 'y'"); + } + } + } + + #[test] + fn test_identity_and_false_short_circuit() { + // Zlup uses 'and' keyword, not '&&' + let source = "x := y and false;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(!lit.value, "y and false should simplify to false"); + } else { + panic!("Expected simplified to false"); + } + } + } + + #[test] + fn test_identity_or_true_short_circuit() { + // Zlup uses 'or' keyword, not '||' + let source = "x := y or true;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "y or true should simplify to true"); + } else { + panic!("Expected simplified to true"); + } + } + } + + // ========================================================================= + // Dead Code Elimination Tests + // ========================================================================= + + #[test] + fn test_dead_code_elimination() { + let source = r#" +pub fn main() -> unit { + if false { + x := 1; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // The if statement should be removed + assert!(optimizer.stats.dead_code_removed > 0); + + // Verify the function body no longer contains an if statement + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_if = fn_decl.body.statements.iter().any(|s| matches!(s, Stmt::If(_))); + assert!(!has_if, "if statement should have been eliminated"); + } + } + + #[test] + fn test_dead_code_if_true_keeps_then_branch() { + let source = r#" +pub fn main() -> unit { + if true { + x := 42; + } else { + y := 0; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert!(optimizer.stats.dead_code_removed > 0); + + // The if should be replaced by just the then body block + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_if = fn_decl.body.statements.iter().any(|s| matches!(s, Stmt::If(_))); + assert!(!has_if, "if true should be replaced by then block"); + } + } + + #[test] + fn test_dead_code_if_false_keeps_else_branch() { + let source = r#" +pub fn main() -> unit { + if false { + x := 42; + } else { + y := 0; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert!(optimizer.stats.dead_code_removed > 0); + + // The if should be replaced by just the else body block + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_if = fn_decl.body.statements.iter().any(|s| matches!(s, Stmt::If(_))); + assert!(!has_if, "if false should be replaced by else block"); + } + } + + // ========================================================================= + // Double Negation Tests + // ========================================================================= + + #[test] + fn test_double_negation() { + let source = "x := --5;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::IntLit(lit)) = &binding.value { + assert_eq!(lit.value, 5); + } else { + panic!("Expected double negation folded"); + } + } + } + + #[test] + fn test_double_not() { + let source = "x := !!true;"; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + if let Some(Expr::BoolLit(lit)) = &binding.value { + assert!(lit.value, "!!true should be true"); + } else { + panic!("Expected double not folded to BoolLit"); + } + } + } + + // ========================================================================= + // Gate Cancellation Tests + // ========================================================================= + + #[test] + fn test_gate_cancellation_h_h() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "H H should cancel"); + + // Verify no H gates remain + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let h_count = fn_decl.body.statements.iter().filter(|s| { + matches!(s, Stmt::Gate(g) if g.kind == GateKind::H) + }).count(); + assert_eq!(h_count, 0, "Both H gates should be cancelled"); + } + } + + #[test] + fn test_gate_cancellation_x_x() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + x q[0]; + x q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "X X should cancel"); + } + + #[test] + fn test_gate_cancellation_y_y() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + y q[0]; + y q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "Y Y should cancel"); + } + + #[test] + fn test_gate_cancellation_z_z() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + z q[0]; + z q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "Z Z should cancel"); + } + + #[test] + fn test_gate_cancellation_sz_szdg() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + sz q[0]; + szdg q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "SZ SZdg should cancel"); + } + + #[test] + fn test_gate_cancellation_t_tdg() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + t q[0]; + tdg q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "T Tdg should cancel"); + } + + #[test] + fn test_gate_cancellation_cx_cx() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + cx (q[0], q[1]); + cx (q[0], q[1]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "CX CX should cancel"); + } + + #[test] + fn test_gate_cancellation_cz_cz() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + cz (q[0], q[1]); + cz (q[0], q[1]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "CZ CZ should cancel"); + } + + #[test] + fn test_gate_cancellation_swap_swap() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + swap (q[0], q[1]); + swap (q[0], q[1]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 2, "SWAP SWAP should cancel"); + } + + #[test] + fn test_gate_no_cancel_different_qubits() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + h q[1]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // Should NOT cancel - different qubits + assert_eq!(optimizer.stats.gates_cancelled, 0, "H on different qubits should not cancel"); + + // Verify both H gates remain + // Note: Gates are represented as Stmt::Expr containing Expr::Gate + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let h_count = fn_decl.body.statements.iter().filter(|s| { + if let Stmt::Expr(expr_stmt) = s { + if let Expr::Gate(gate) = &expr_stmt.expr { + return gate.kind == GateKind::H; + } + } + false + }).count(); + assert_eq!(h_count, 2, "Both H gates on different qubits should remain"); + } + } + + #[test] + fn test_gate_no_cancel_different_types() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + x q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // Should NOT cancel - different gate types + assert_eq!(optimizer.stats.gates_cancelled, 0, "H X should not cancel"); + } + + // ========================================================================= + // Optimization Barrier Tests + // ========================================================================= + + #[test] + fn test_has_preserve_attr() { + use crate::ast::Attribute; + + let optimizer = Optimizer::new(); + + // Test @preserve + let attrs = vec![Attribute::flag("preserve")]; + assert!(optimizer.has_preserve_attr(&attrs)); + + // Test @timing + let attrs = vec![Attribute::flag("timing")]; + assert!(optimizer.has_preserve_attr(&attrs)); + + // Test @identity + let attrs = vec![Attribute::flag("identity")]; + assert!(optimizer.has_preserve_attr(&attrs)); + + // Test @noopt + let attrs = vec![Attribute::flag("noopt")]; + assert!(optimizer.has_preserve_attr(&attrs)); + + // Test non-preserve attr + let attrs = vec![Attribute::flag("other")]; + assert!(!optimizer.has_preserve_attr(&attrs)); + + // Test empty attrs + let attrs: Vec = vec![]; + assert!(!optimizer.has_preserve_attr(&attrs)); + } + + #[test] + fn test_no_optimize_prevents_gate_cancellation() { + // @no_optimize(expr) builtin prevents cancellation + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + @no_optimize(h q[0]); + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // The first H is wrapped in @no_optimize, so cancellation shouldn't happen + assert_eq!(optimizer.stats.gates_cancelled, 0, "@no_optimize should prevent cancellation"); + } + + #[test] + fn test_no_optimize_on_second_gate_prevents_cancellation() { + // @no_optimize on the second gate also prevents cancellation + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + @no_optimize(h q[0]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 0, "@no_optimize on second gate should prevent cancellation"); + } + + #[test] + fn test_no_optimize_both_gates_no_cancellation() { + // Both gates wrapped in @no_optimize + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + @no_optimize(h q[0]); + @no_optimize(h q[0]); + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 0, "Both @no_optimize should prevent cancellation"); + } + + #[test] + fn test_tick_blocks_are_barriers() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + tick { + x q[1]; + } + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // tick {} is a barrier, so the two H gates shouldn't cancel + assert_eq!(optimizer.stats.gates_cancelled, 0, "tick should act as barrier"); + } + + #[test] + fn test_gates_cancel_within_same_tick() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + tick { + h q[0]; + h q[0]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // Within the same tick, gates CAN cancel + assert_eq!(optimizer.stats.gates_cancelled, 2, "H H within same tick should cancel"); + } + + // ========================================================================= + // @round(n) Attribute Tests + // ========================================================================= + + #[test] + fn test_get_round_attr() { + use crate::ast::{Attribute, AttributeValue, Block}; + + let optimizer = Optimizer::new(); + + // Create a block with @round(0) + let block = Block { + label: None, + attrs: vec![Attribute::with_value("round", AttributeValue::Int(0))], + statements: vec![], + trailing_expr: None, + location: None, + }; + + let stmt = Stmt::Block(block); + assert_eq!(optimizer.get_round_attr(&stmt), Some(0)); + + // Create a block with @round(5) + let block = Block { + label: None, + attrs: vec![Attribute::with_value("round", AttributeValue::Int(5))], + statements: vec![], + trailing_expr: None, + location: None, + }; + + let stmt = Stmt::Block(block); + assert_eq!(optimizer.get_round_attr(&stmt), Some(5)); + + // Block without round attr + let block = Block { + label: None, + attrs: vec![], + statements: vec![], + trailing_expr: None, + location: None, + }; + + let stmt = Stmt::Block(block); + assert_eq!(optimizer.get_round_attr(&stmt), None); + } + + // ========================================================================= + // Unused Binding Elimination Tests + // ========================================================================= + + #[test] + fn test_unused_binding_eliminated() { + let source = r#" +pub fn main() -> unit { + unused := 42; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert!(optimizer.stats.unused_bindings_removed > 0, "unused binding should be removed"); + + // Verify the binding was removed + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_binding = fn_decl.body.statements.iter().any(|s| { + matches!(s, Stmt::Binding(b) if b.name == "unused") + }); + assert!(!has_binding, "unused binding should be eliminated"); + } + } + + #[test] + fn test_used_binding_kept() { + let source = r#" +pub fn main() -> i32 { + used := 42; + return used; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // Verify the binding was NOT removed + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_binding = fn_decl.body.statements.iter().any(|s| { + matches!(s, Stmt::Binding(b) if b.name == "used") + }); + assert!(has_binding, "used binding should be kept"); + } + } + + // ========================================================================= + // Zero-Angle Identity Removal Tests + // ========================================================================= + + #[test] + fn test_zero_rotation_removed() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + rz(0) q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + assert!(optimizer.stats.identities_removed > 0, "rz(0) should be removed as identity"); + + // Verify the gate was removed + // Note: Gates are represented as Stmt::Expr containing Expr::Gate + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let has_rz = fn_decl.body.statements.iter().any(|s| { + if let Stmt::Expr(expr_stmt) = s { + if let Expr::Gate(gate) = &expr_stmt.expr { + return gate.kind == GateKind::RZ; + } + } + false + }); + assert!(!has_rz, "rz(0) identity gate should be removed"); + } + } + + // ========================================================================= + // Optimizer Configuration Tests + // ========================================================================= + + #[test] + fn test_disable_constant_folding() { + let source = "x := 2 + 3;"; + let ast = parse(source).unwrap(); + let mut config = OptimizeConfig::all(); + config.constant_folding = false; + let mut optimizer = Optimizer::with_config(config); + let optimized = optimizer.optimize(ast); + + // Constant folding disabled - should still have binary expression + if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { + assert!(matches!(&binding.value, Some(Expr::Binary(_))), + "with constant_folding disabled, should keep binary expr"); + } + assert_eq!(optimizer.stats.constants_folded, 0); + } + + #[test] + fn test_disable_gate_cancellation() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut config = OptimizeConfig::all(); + config.gate_cancellation = false; + let mut optimizer = Optimizer::with_config(config); + let optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 0, "gate_cancellation disabled"); + + // Both H gates should remain + // Note: Gates are represented as Stmt::Expr containing Expr::Gate + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let h_count = fn_decl.body.statements.iter().filter(|s| { + if let Stmt::Expr(expr_stmt) = s { + if let Expr::Gate(gate) = &expr_stmt.expr { + return gate.kind == GateKind::H; + } + } + false + }).count(); + assert_eq!(h_count, 2, "Both H gates should remain when cancellation disabled"); + } + } + + #[test] + fn test_disable_all_optimizations() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + unused := 2 + 3; + h q[0]; + h q[0]; + if false { + x q[0]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::with_config(OptimizeConfig::none()); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.constants_folded, 0); + assert_eq!(optimizer.stats.dead_code_removed, 0); + assert_eq!(optimizer.stats.unused_bindings_removed, 0); + assert_eq!(optimizer.stats.gates_cancelled, 0); + assert_eq!(optimizer.stats.identities_removed, 0); + assert_eq!(optimizer.stats.iterations, 0); + } + + // ========================================================================= + // Edge Cases + // ========================================================================= + + #[test] + fn test_multiple_gate_pairs_cancel() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + h q[0]; + x q[0]; + x q[0]; + z q[0]; + z q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats.gates_cancelled, 6, "All 3 pairs should cancel"); + } + + #[test] + fn test_interleaved_gates_no_cancel() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + x q[0]; + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // H and H are not adjacent, so should not cancel + assert_eq!(optimizer.stats.gates_cancelled, 0, "Non-adjacent gates should not cancel"); + } + + #[test] + fn test_optimizer_stats() { + let source = r#" +pub fn main() -> unit { + q := qalloc(2); + pz q; + x := 2 + 3; + h q[0]; + h q[0]; + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + let stats = optimizer.stats(); + assert!(stats.constants_folded > 0); + assert!(stats.gates_cancelled >= 2); + assert!(stats.iterations >= 1); + } + + // ========================================================================= + // Inline For Loop Unrolling Tests + // ========================================================================= + + #[test] + fn test_inline_for_unrolling_basic() { + // inline for i in 0..4 { h q[i]; } should produce 4 h gates + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..4 { + h q[i]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let optimized = optimizer.optimize(ast); + + // Verify unrolling happened + assert!( + optimizer.stats.inline_for_unrolled > 0, + "Expected inline for to be unrolled, stats: {:?}", + optimizer.stats + ); + assert_eq!( + optimizer.stats.inline_for_statements_generated, 4, + "Expected 4 statements generated from inline for" + ); + + // Verify the function body has 4 h gates + // Note: Gates can be represented as either Stmt::Gate or Stmt::Expr(Expr::Gate) + fn count_gates_recursive(stmts: &[Stmt]) -> usize { + let mut count = 0; + for stmt in stmts { + match stmt { + Stmt::Gate(_) => count += 1, + Stmt::Expr(e) => { + if matches!(e.expr, Expr::Gate(_)) { + count += 1; + } + } + Stmt::Block(b) => count += count_gates_recursive(&b.statements), + _ => {} + } + } + count + } + if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { + let gate_count = count_gates_recursive(&fn_decl.body.statements); + // Should have pz + 4 h gates + // At minimum, we should have 4 h gates from unrolling (pz may or may not count) + assert!(gate_count >= 4, "Expected at least 4 gates, got {}", gate_count); + } + } + + #[test] + fn test_inline_for_nested() { + // Nested 2x3 inline for should produce 6 statements + let source = r#" +pub fn main() -> unit { + q := qalloc(6); + pz q; + inline for i in 0..2 { + inline for j in 0..3 { + h q[i * 3 + j]; + } + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // Should unroll both loops: outer produces 2 iterations, + // each containing an inner loop that produces 3 statements + assert!( + optimizer.stats.inline_for_unrolled >= 2, + "Expected at least 2 inline for loops unrolled" + ); + // 2 outer iterations * 3 inner statements = 6 statements + // Plus the 2 inner loops themselves produce statements + assert!( + optimizer.stats.inline_for_statements_generated >= 6, + "Expected at least 6 statements generated, got {}", + optimizer.stats.inline_for_statements_generated + ); + } + + #[test] + fn test_inline_for_with_comptime_bounds() { + // Bounds using comptime expression (2 * 2) + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..(2 * 2) { + h q[i]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert!( + optimizer.stats.inline_for_unrolled > 0, + "Expected inline for with comptime bounds to be unrolled" + ); + assert_eq!( + optimizer.stats.inline_for_statements_generated, 4, + "Expected 4 statements from 2*2=4" + ); + } + + #[test] + fn test_inline_for_empty_range() { + // 0..0 should produce 0 statements + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..0 { + h q[i]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + // Empty range still counts as unrolled (just produces 0 statements) + assert_eq!( + optimizer.stats.inline_for_statements_generated, 0, + "Expected 0 statements from empty range" + ); + } + + #[test] + fn test_regular_for_not_unrolled() { + // Regular for loop (without inline) should not be unrolled + let source = r#" +pub fn main() -> unit { + q := qalloc(4); + pz q; + for i in 0..4 { + h q[i]; + } + return unit; +} +"#; + let ast = parse(source).unwrap(); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!( + optimizer.stats.inline_for_unrolled, 0, + "Regular for loop should not be unrolled" + ); + } +} diff --git a/exp/zlup/src/parser.rs b/exp/zlup/src/parser.rs new file mode 100644 index 000000000..7863981da --- /dev/null +++ b/exp/zlup/src/parser.rs @@ -0,0 +1,4054 @@ +//! Recursive descent parser for Zluppy. +//! +//! This module implements a hand-written recursive descent parser rather than +//! using the visitor pattern. This choice aligns with NASA Power of 10 principles: +//! +//! - **Explicit control flow**: No hidden dispatch mechanisms +//! - **Predictable**: Direct function calls, easy to debug +//! - **Simple**: Each grammar rule maps to a function +//! +//! The parser consumes tokens from the pest lexer and builds the AST. + +use crate::ast::*; +use pest::iterators::{Pair, Pairs}; +use pest::Parser; +use pest_derive::Parser; + +/// Pest parser generated from grammar. +#[derive(Parser)] +#[grammar = "zluppy.pest"] +struct ZluppyParser; + +/// Parser state. +pub struct ParserState<'a> { + source: &'a str, + file: Option, +} + +use thiserror::Error; + +/// Parse result type. +pub type ParseResult = Result; + +/// Parse error. +#[derive(Debug, Clone, Error)] +#[error("{message}")] +pub struct ParseError { + pub message: String, + pub location: SourceLocation, +} + +// ============================================================================= +// Input Size Limits +// ============================================================================= + +/// Maximum allowed source file size in bytes (10 MB). +/// This prevents DoS attacks via extremely large input files. +pub const MAX_SOURCE_SIZE: usize = 10 * 1024 * 1024; + +/// Maximum allowed number of AST nodes per file. +/// This prevents memory exhaustion from deeply nested or repetitive code. +pub const MAX_AST_NODES: usize = 1_000_000; + +impl<'a> ParserState<'a> { + /// Create a new parser state. + pub fn new(source: &'a str) -> Self { + Self { source, file: None } + } + + /// Set the file name for error reporting. + pub fn with_file(mut self, file: impl Into) -> Self { + self.file = Some(file.into()); + self + } + + /// Parse the source into a program AST. + pub fn parse(&self) -> ParseResult { + // Check source size limit + if self.source.len() > MAX_SOURCE_SIZE { + return Err(ParseError { + message: format!( + "source file too large: {} bytes exceeds maximum of {} bytes", + self.source.len(), + MAX_SOURCE_SIZE + ), + location: SourceLocation::default(), + }); + } + + let pairs = ZluppyParser::parse(Rule::program, self.source) + .map_err(|e| self.pest_error(e))?; + + self.parse_program(pairs) + } + + /// Convert a pest error to our error type. + fn pest_error(&self, e: pest::error::Error) -> ParseError { + let (line, column, end_line, end_column) = match e.line_col { + pest::error::LineColLocation::Pos((l, c)) => (l as u32, c as u32, l as u32, c as u32 + 1), + pest::error::LineColLocation::Span((l, c), (el, ec)) => (l as u32, c as u32, el as u32, ec as u32), + }; + ParseError { + message: e.to_string(), + location: SourceLocation { + line, + column, + end_line, + end_column, + file: self.file.clone(), + }, + } + } + + /// Get source location from a pest pair. + fn location(&self, pair: &Pair) -> SourceLocation { + let span = pair.as_span(); + let (line, column) = pair.line_col(); + let (end_line, end_column) = span.end_pos().line_col(); + SourceLocation { + line: line as u32, + column: column as u32, + end_line: end_line as u32, + end_column: end_column as u32, + file: self.file.clone(), + } + } + + /// Create an error at the given pair's location. + fn error(&self, pair: &Pair, message: impl Into) -> ParseError { + ParseError { + message: message.into(), + location: self.location(pair), + } + } + + /// Create an error at an approximate location (for when we don't have a pair). + fn error_at(&self, location: SourceLocation, message: impl Into) -> ParseError { + ParseError { + message: message.into(), + location, + } + } + + /// Expect exactly one inner element from a pair. + /// Returns an error if the pair has no inner elements. + fn expect_inner(&self, pair: Pair<'a, Rule>, context: &str) -> ParseResult> { + let location = self.location(&pair); + pair.into_inner() + .next() + .ok_or_else(|| self.error_at(location, format!("expected inner element in {}", context))) + } + + /// Expect the next element from an iterator. + /// Returns an error if the iterator is exhausted. + fn expect_next( + &self, + iter: &mut impl Iterator>, + location: &SourceLocation, + context: &str, + ) -> ParseResult> { + iter.next() + .ok_or_else(|| self.error_at(location.clone(), format!("expected {} but found end of input", context))) + } + + // ========================================================================= + // Program Structure + // ========================================================================= + + /// Parse: program = { SOI ~ top_level_decl* ~ EOI } + fn parse_program(&self, pairs: Pairs) -> ParseResult { + let mut declarations = Vec::new(); + + // The pairs iterator contains a single Rule::program pair + // We need to descend into its children to find top_level_decl items + for pair in pairs { + if pair.as_rule() == Rule::program { + // Iterate over program's children + for inner_pair in pair.into_inner() { + match inner_pair.as_rule() { + Rule::top_level_decl => { + declarations.push(self.parse_top_level_decl(inner_pair)?); + } + Rule::EOI => break, + _ => {} // Skip SOI, whitespace, etc. + } + } + } + } + + Ok(Program { + name: self.file.clone().unwrap_or_else(|| "main".to_string()), + declarations, + location: None, + }) + } + + /// Parse: top_level_decl = { binding_decl | fn_decl | ... } + fn parse_top_level_decl(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "top_level_decl")?; + + match inner.as_rule() { + Rule::binding_decl => Ok(TopLevelDecl::Binding(self.parse_binding_decl(inner)?)), + Rule::fn_decl => Ok(TopLevelDecl::Fn(self.parse_fn_decl(inner)?)), + Rule::extern_fn_decl => Ok(TopLevelDecl::ExternFn(self.parse_extern_fn_decl(inner)?)), + Rule::struct_decl => Ok(TopLevelDecl::Struct(self.parse_struct_decl(inner)?)), + Rule::enum_decl => Ok(TopLevelDecl::Enum(self.parse_enum_decl(inner)?)), + Rule::union_decl => Ok(TopLevelDecl::Union(self.parse_union_decl(inner)?)), + Rule::error_set_decl => Ok(TopLevelDecl::ErrorSet(self.parse_error_set_decl(inner)?)), + Rule::fault_set_decl => Ok(TopLevelDecl::FaultSet(self.parse_fault_set_decl(inner)?)), + Rule::test_decl => Ok(TopLevelDecl::Test(self.parse_test_decl(inner)?)), + Rule::declare_gate_decl => Ok(TopLevelDecl::DeclareGate(self.parse_declare_gate_decl(inner)?)), + Rule::gate_decl => Ok(TopLevelDecl::Gate(self.parse_gate_decl(inner)?)), + _ => Err(self.error(&inner, format!("unexpected {:?}", inner.as_rule()))), + } + } + + // ========================================================================= + // Declarations + // ========================================================================= + + /// Parse binding declaration. + /// New syntax: + /// x := 42; -- immutable, type inferred + /// x: i32 = 42; -- immutable, type explicit + /// mut x := 42; -- mutable, type inferred + /// mut x: i32 = 42; -- mutable, type explicit + /// Legacy syntax (backward compatible): + /// const x = 42; -- immutable + /// var x = 42; -- mutable + fn parse_binding_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut is_mutable = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut ty = None; + let mut value = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::pub_keyword => { + is_pub = true; + } + Rule::mut_keyword => { + is_mutable = true; + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(item)?); + } + Rule::expr => { + value = Some(self.parse_expr(item)?); + } + Rule::undefined_literal => { + value = None; // Explicit undefined + } + _ => {} + } + } + + Ok(Binding { + name, + ty, + value, + is_mutable, + is_pub, + doc_comment, + location, + }) + } + + /// Parse: fn_decl = { "pub"? ~ "inline"? ~ "fn" ~ identifier ~ "(" ~ param_list? ~ ")" ~ fn_error_mode? ~ return_type? ~ block } + fn parse_fn_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut is_inline = false; + let mut error_mode = None; + let mut doc_comment = None; + let mut name = String::new(); + let mut params = Vec::new(); + let mut return_type = None; + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::fn_error_mode => { + error_mode = Some(self.parse_fn_error_mode(item)?); + } + Rule::identifier | Rule::member_name => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::param_list => { + params = self.parse_param_list(item)?; + } + Rule::return_type => { + return_type = Some(self.parse_return_type(item)?); + } + Rule::block => { + body = Some(self.parse_block(item)?); + } + _ => { + if item.as_rule() == Rule::pub_keyword { + is_pub = true; + } else if item.as_rule() == Rule::inline_keyword { + is_inline = true; + } + } + } + } + + Ok(FnDecl { + name, + params, + return_type, + body: body.expect("function must have body"), + is_pub, + is_inline, + error_mode, + doc_comment, + location, + }) + } + + /// Parse external function declaration (FFI). + /// Grammar: extern_fn_decl = { doc_comment* ~ link_attr? ~ pub_keyword? ~ "extern" ~ string_literal ~ "fn" ~ identifier ~ "(" ~ param_list? ~ ")" ~ return_type? ~ ";" } + fn parse_extern_fn_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut library = None; + let mut calling_convention = String::new(); + let mut name = String::new(); + let mut params = Vec::new(); + let mut return_type = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::link_attr => { + // Extract library name from @link("libname") + for link_item in item.into_inner() { + if link_item.as_rule() == Rule::string_literal { + let s = link_item.as_str(); + library = Some(s[1..s.len() - 1].to_string()); + } + } + } + Rule::pub_keyword => { + is_pub = true; + } + Rule::string_literal => { + // Extract the calling convention from the string literal + let s = item.as_str(); + // Remove quotes + calling_convention = s[1..s.len() - 1].to_string(); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::param_list => { + params = self.parse_param_list(item)?; + } + Rule::return_type => { + return_type = Some(self.parse_return_type(item)?); + } + _ => {} + } + } + + Ok(ExternFnDecl { + name, + library, + calling_convention, + params, + return_type, + is_pub, + doc_comment, + location, + }) + } + + /// Parse function error mode: try or try! + fn parse_fn_error_mode(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "fn_error_mode")?; + match inner.as_rule() { + Rule::try_keyword => Ok(TryMode::Collect), + Rule::try_bang_keyword => Ok(TryMode::Propagate), + _ => Err(self.error(&inner, "expected try or try!")), + } + } + + /// Parse parameter list. + fn parse_param_list(&self, pair: Pair) -> ParseResult> { + let mut params = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::param { + params.push(self.parse_param(item)?); + } + } + Ok(params) + } + + /// Parse a single parameter. + fn parse_param(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let inner_pair = self.expect_inner(pair, "param")?; + + match inner_pair.as_rule() { + Rule::self_param => { + // Rust-style self receiver: &self or &mut self + let text = inner_pair.as_str(); + let is_mutable = text.contains("mut"); + + // Self type is *Self (pointer to self) or *const Self + let self_type = if is_mutable { + TypeExpr::Pointer(Box::new(PointerType { + pointee: TypeExpr::Named(TypePath { + segments: vec!["Self".to_string()], + location: location.clone(), + }), + is_const: false, + is_many: false, + sentinel: None, + })) + } else { + TypeExpr::Pointer(Box::new(PointerType { + pointee: TypeExpr::Named(TypePath { + segments: vec!["Self".to_string()], + location: location.clone(), + }), + is_const: true, + is_many: false, + sentinel: None, + })) + }; + + Ok(Param { + name: "self".to_string(), + ty: self_type, + is_comptime: false, + location, + }) + } + Rule::regular_param => { + // Regular parameter: comptime? name: type + self.parse_regular_param(inner_pair, location) + } + other => { + return Err(ParseError { + message: format!("unexpected rule {:?}, expected param (self_param or regular_param)", other), + location: location.unwrap_or_default(), + }); + } + } + } + + /// Parse a regular (non-self) parameter. + fn parse_regular_param(&self, pair: Pair, location: Option) -> ParseResult { + let inner = pair.into_inner(); + + let mut is_comptime = false; + let mut name = String::new(); + let mut ty = None; + + for item in inner { + match item.as_rule() { + Rule::comptime_modifier => { + is_comptime = true; + } + Rule::identifier => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(item)?); + } + _ => {} + } + } + + Ok(Param { + name, + ty: ty.expect("parameter must have type"), + is_comptime, + location, + }) + } + + /// Parse return type. + fn parse_return_type(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "return_type")?; + self.parse_type_expr(inner) + } + + /// Parse struct declaration. + fn parse_struct_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut is_packed = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut fields = Vec::new(); + let mut methods = Vec::new(); + let mut associated_consts = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::struct_body => { + let (f, m, c) = self.parse_struct_body(item)?; + fields = f; + methods = m; + associated_consts = c; + } + _ => { + if item.as_rule() == Rule::pub_keyword { + is_pub = true; + } else if item.as_rule() == Rule::packed_keyword { + is_packed = true; + } + } + } + } + + Ok(StructDecl { + name, + fields, + methods, + associated_consts, + is_pub, + is_packed, + doc_comment, + location, + }) + } + + /// Parse struct body (fields, methods, and associated bindings). + fn parse_struct_body( + &self, + pair: Pair, + ) -> ParseResult<(Vec, Vec, Vec)> { + let mut fields = Vec::new(); + let mut methods = Vec::new(); + let mut associated_consts = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::struct_field => { + fields.push(self.parse_struct_field(item)?); + } + Rule::fn_decl => { + methods.push(self.parse_fn_decl(item)?); + } + Rule::binding_decl => { + // Associated constants defined within the struct + associated_consts.push(self.parse_binding_decl(item)?); + } + _ => {} + } + } + + Ok((fields, methods, associated_consts)) + } + + /// Parse struct field. + fn parse_struct_field(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut doc_comment = None; + let mut name = String::new(); + let mut ty = None; + let mut default = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier | Rule::member_name => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(item)?); + } + Rule::expr => { + default = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(StructField { + name, + ty: ty.expect("field must have type"), + default, + doc_comment, + location, + }) + } + + /// Parse enum declaration. + fn parse_enum_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut tag_type = None; + let mut variants = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::type_expr => { + tag_type = Some(self.parse_type_expr(item)?); + } + Rule::enum_body => { + variants = self.parse_enum_body(item)?; + } + _ => { + if item.as_rule() == Rule::pub_keyword { + is_pub = true; + } + } + } + } + + Ok(EnumDecl { + name, + tag_type, + variants, + is_pub, + doc_comment, + location, + }) + } + + /// Parse enum body. + fn parse_enum_body(&self, pair: Pair) -> ParseResult> { + let mut variants = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::enum_variant { + variants.push(self.parse_enum_variant(item)?); + } + } + Ok(variants) + } + + /// Parse enum variant. + fn parse_enum_variant(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = String::new(); + let mut value = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + name = item.as_str().to_string(); + } + Rule::expr => { + value = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(EnumVariant { + name, + value, + location, + }) + } + + /// Parse union declaration. + /// Grammar: union_decl = { doc_comment* ~ "pub"? ~ identifier ~ "=" ~ "union" ~ union_tag? ~ "{" ~ union_body ~ "}" ~ ";" } + fn parse_union_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut tag: Option> = None; + let mut fields = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::union_tag => { + tag = Some(self.parse_union_tag(item)?); + } + Rule::union_body => { + fields = self.parse_union_body(item)?; + } + _ => { + if item.as_rule() == Rule::pub_keyword { + is_pub = true; + } + } + } + } + + Ok(UnionDecl { + name, + tag, + fields, + is_pub, + doc_comment, + location, + }) + } + + /// Parse union tag. + /// Grammar: union_tag = { "(" ~ ("enum" | type_expr) ~ ")" } + fn parse_union_tag(&self, pair: Pair) -> ParseResult> { + let inner = pair.into_inner().next(); + match inner { + Some(item) if item.as_rule() == Rule::type_expr => { + Ok(Some(self.parse_type_expr(item)?)) + } + _ => { + // "enum" keyword means auto-tagged + Ok(None) + } + } + } + + /// Parse union body. + fn parse_union_body(&self, pair: Pair) -> ParseResult> { + let mut fields = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::union_field { + fields.push(self.parse_union_field(item)?); + } + } + Ok(fields) + } + + /// Parse union field. + /// Grammar: union_field = { identifier ~ (":" ~ type_expr)? } + fn parse_union_field(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = String::new(); + let mut ty = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(item)?); + } + _ => {} + } + } + + Ok(UnionField { name, ty, location }) + } + + /// Parse error set declaration - classical/logical errors. + /// `DecodeError := error { SyndromeAmbiguous, WeightTooHigh };` + fn parse_error_set_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut variants = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::error_set_body => { + variants = self.parse_error_set_body(item)?; + } + Rule::pub_keyword => { + is_pub = true; + } + _ => {} + } + } + + Ok(ErrorSetDecl { + name, + variants, + is_pub, + doc_comment, + location, + }) + } + + /// Parse fault set declaration - quantum/physical faults. + /// `QuantumFault := fault { Leakage, QubitLoss, GateFailure };` + fn parse_fault_set_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut variants = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::error_set_body => { + // Reuse error_set_body parsing for fault variants + variants = self.parse_error_set_body(item)?; + } + Rule::pub_keyword => { + is_pub = true; + } + _ => {} + } + } + + Ok(FaultSetDecl { + name, + variants, + is_pub, + doc_comment, + location, + }) + } + + /// Parse error set body. + fn parse_error_set_body(&self, pair: Pair) -> ParseResult> { + let mut variants = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::error_variant { + variants.push(self.parse_error_variant(item)?); + } + } + Ok(variants) + } + + /// Parse a single error variant. + /// Can be just a name or name with data type: `Leakage: struct { ... }` + fn parse_error_variant(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = String::new(); + let mut data_type = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + name = item.as_str().to_string(); + } + Rule::type_expr => { + data_type = Some(self.parse_type_expr(item)?); + } + _ => {} + } + } + + Ok(ErrorVariant { + name, + data_type, + location, + }) + } + + /// Parse test declaration. + fn parse_test_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = String::new(); + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::string_literal => { + name = self.parse_string_content(item.as_str()); + } + Rule::block => { + body = Some(self.parse_block(item)?); + } + _ => {} + } + } + + Ok(TestDecl { + name, + body: body.expect("test must have body"), + location, + }) + } + + /// Parse target gate declaration: `declare gate name(params)(qubits);` + fn parse_declare_gate_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut params = Vec::new(); + let mut qubits = Vec::new(); + let mut seen_first_param_list = false; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::pub_keyword => { + is_pub = true; + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::gate_param_list => { + params = self.parse_gate_param_list(item)?; + seen_first_param_list = true; + } + Rule::qubit_param_list => { + qubits = self.parse_qubit_param_list(item)?; + } + _ => {} + } + } + // If we never saw a gate_param_list, params stays empty (no params) + let _ = seen_first_param_list; + + Ok(TargetGateDecl { + name, + params, + qubits, + is_pub, + doc_comment, + location, + }) + } + + /// Parse composite gate declaration: `gate name(params)(qubits) { body }` + fn parse_gate_decl(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut is_pub = false; + let mut doc_comment = None; + let mut name = String::new(); + let mut params = Vec::new(); + let mut qubits = Vec::new(); + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::doc_comment => { + doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); + } + Rule::pub_keyword => { + is_pub = true; + } + Rule::identifier => { + if name.is_empty() { + name = item.as_str().to_string(); + } + } + Rule::gate_param_list => { + params = self.parse_gate_param_list(item)?; + } + Rule::qubit_param_list => { + qubits = self.parse_qubit_param_list(item)?; + } + Rule::block => { + body = Some(self.parse_block(item)?); + } + _ => {} + } + } + + Ok(CompositeGateDecl { + name, + params, + qubits, + body: body.expect("gate must have body"), + is_pub, + doc_comment, + location, + }) + } + + /// Parse a list of gate parameters. + fn parse_gate_param_list(&self, pair: Pair) -> ParseResult> { + let mut params = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::gate_param { + let location = Some(self.location(&item)); + let mut name = String::new(); + let mut ty = None; + for inner in item.into_inner() { + match inner.as_rule() { + Rule::identifier => { + if name.is_empty() { + name = inner.as_str().to_string(); + } + } + Rule::type_expr => { + ty = Some(self.parse_type_expr(inner)?); + } + _ => {} + } + } + params.push(GateParam { name, ty, location }); + } + } + Ok(params) + } + + /// Parse a list of qubit parameters. + fn parse_qubit_param_list(&self, pair: Pair) -> ParseResult> { + let mut qubits = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::qubit_param { + let location = Some(self.location(&item)); + let name = item.as_str().to_string(); + qubits.push(QubitParam { name, location }); + } + } + Ok(qubits) + } + + // ========================================================================= + // Statements + // ========================================================================= + + /// Parse a statement. + /// Handles both the case where pair is a `statement` wrapper rule and + /// where pair is a specific statement type directly (e.g., from `(block | statement)` patterns). + fn parse_statement(&self, pair: Pair<'a, Rule>) -> ParseResult { + // If the pair is a `statement` rule, unwrap it to get the inner statement type + // Otherwise, use the pair directly (for cases like `(block | statement)` in grammar) + let inner = if pair.as_rule() == Rule::statement { + self.expect_inner(pair, "statement")? + } else { + pair + }; + + match inner.as_rule() { + Rule::binding_decl => Ok(Stmt::Binding(self.parse_binding_decl(inner)?)), + Rule::alias_stmt => Ok(Stmt::Alias(self.parse_alias_stmt(inner)?)), + Rule::assign_stmt => Ok(Stmt::Assign(self.parse_assign_stmt(inner)?)), + Rule::if_stmt => Ok(Stmt::If(self.parse_if_stmt(inner)?)), + Rule::for_stmt => Ok(Stmt::For(self.parse_for_stmt(inner)?)), + Rule::switch_stmt => Ok(Stmt::Switch(self.parse_switch_stmt(inner)?)), + Rule::tick_stmt => Ok(Stmt::Tick(self.parse_tick_stmt(inner)?)), + Rule::try_block_stmt => Ok(Stmt::TryBlock(self.parse_try_block_stmt(inner)?)), + Rule::return_stmt => Ok(Stmt::Return(self.parse_return_stmt(inner)?)), + Rule::break_stmt => Ok(Stmt::Break(self.parse_break_stmt(inner)?)), + Rule::continue_stmt => Ok(Stmt::Continue(self.parse_continue_stmt(inner)?)), + Rule::defer_stmt => Ok(Stmt::Defer(self.parse_defer_stmt(inner)?)), + Rule::errdefer_stmt => Ok(Stmt::Errdefer(self.parse_errdefer_stmt(inner)?)), + Rule::block => Ok(Stmt::Block(self.parse_block(inner)?)), + Rule::expr_stmt => Ok(Stmt::Expr(self.parse_expr_stmt(inner)?)), + _ => Err(self.error(&inner, format!("unexpected statement {:?}", inner.as_rule()))), + } + } + + /// Parse alias statement. + /// Grammar: alias_stmt = { "alias" ~ ws ~ identifier ~ ws ~ ":" ~ ws ~ "=" ~ ws ~ expr ~ ws ~ ";" } + fn parse_alias_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut name = None; + let mut source = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + name = Some(item.as_str().to_string()); + } + Rule::expr => { + source = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + let name = name.ok_or_else(|| { + self.error_at(location.clone().unwrap_or_default(), "alias requires a name") + })?; + let source = source.ok_or_else(|| { + self.error_at( + location.clone().unwrap_or_default(), + "alias requires a source expression", + ) + })?; + + Ok(AliasBinding { + name, + source, + location, + }) + } + + /// Parse tick statement. + /// Grammar: tick_stmt = { attribute_list? ~ ws ~ "tick" ~ ws ~ attribute_list? ~ ws ~ tick_label? ~ ws ~ tick_body } + fn parse_tick_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut label = None; + let mut attrs = Vec::new(); + let mut body = Vec::new(); + + for item in inner { + match item.as_rule() { + Rule::attribute_list => { + // Parse all attributes and add to attrs + attrs.extend(self.parse_attribute_list(item)?); + } + Rule::tick_label => { + // tick_label = { string_literal | identifier } + let label_inner = self.expect_inner(item, "tick_label")?; + label = Some(match label_inner.as_rule() { + Rule::string_literal => self.parse_string_content(label_inner.as_str()), + Rule::identifier => label_inner.as_str().to_string(), + _ => label_inner.as_str().to_string(), + }); + } + Rule::tick_body => { + // tick_body = { "{" ~ ws ~ (statement ~ ws)* ~ "}" } + for stmt_pair in item.into_inner() { + if stmt_pair.as_rule() == Rule::statement { + body.push(self.parse_statement(stmt_pair)?); + } + } + } + _ => {} + } + } + + Ok(TickStmt { + label, + attrs, + body, + location, + }) + } + + /// Parse an attribute list. + /// Grammar: attribute_list = { attribute ~ (ws ~ attribute)* } + fn parse_attribute_list(&self, pair: Pair) -> ParseResult> { + let mut attrs = Vec::new(); + for attr_pair in pair.into_inner() { + match attr_pair.as_rule() { + Rule::attribute => { + attrs.push(self.parse_attribute(attr_pair)?); + } + Rule::attrs_block => { + attrs.extend(self.parse_attrs_block(attr_pair)?); + } + _ => {} + } + } + Ok(attrs) + } + + /// Parse a single attribute. + /// Grammar: attribute = { "@attr" ~ ws ~ "(" ~ ws ~ identifier ~ ws ~ "," ~ ws ~ attr_value ~ ws ~ ")" } + fn parse_attribute(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut inner = pair.into_inner(); + + // First inner element is the identifier (attribute key) + let name_pair = inner.next().expect("attribute must have a key"); + let name = name_pair.as_str().to_string(); + + // Second element is the value + let value_pair = inner.next().expect("attribute must have a value"); + let value = Some(self.parse_attr_value(value_pair)?); + + Ok(Attribute { + name, + value, + location, + }) + } + + /// Parse an attrs block. + /// Grammar: attrs_block = { "@attrs" ~ ws ~ "(" ~ ws ~ "{" ~ ws ~ attrs_entries? ~ ws ~ "}" ~ ws ~ ")" } + fn parse_attrs_block(&self, pair: Pair) -> ParseResult> { + let location = Some(self.location(&pair)); + let mut attrs = Vec::new(); + + for item in pair.into_inner() { + if item.as_rule() == Rule::attrs_entries { + for entry in item.into_inner() { + if entry.as_rule() == Rule::attrs_entry { + let mut entry_inner = entry.into_inner(); + let name = entry_inner.next().expect("attrs_entry must have key").as_str().to_string(); + let value_pair = entry_inner.next().expect("attrs_entry must have value"); + let value = Some(self.parse_attr_value(value_pair)?); + attrs.push(Attribute { + name, + value, + location: location.clone(), + }); + } + } + } + } + + Ok(attrs) + } + + /// Parse an attribute value. + /// Grammar: attr_value = { string_literal | number_literal | bool_literal | identifier } + fn parse_attr_value(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "attr_value")?; + match inner.as_rule() { + Rule::string_literal => { + let s = self.parse_string_content(inner.as_str()); + Ok(AttributeValue::String(s)) + } + Rule::number_literal => { + // Parse the number text - handle floats vs integers + let s = inner.as_str().replace('_', ""); + if s.contains('.') || s.contains('e') || s.contains('E') { + let value: f64 = s.parse().map_err(|_| self.error(&inner, "invalid float literal"))?; + Ok(AttributeValue::Float(value)) + } else if s.starts_with("0x") || s.starts_with("0X") { + let value = i64::from_str_radix(&s[2..], 16) + .map_err(|_| self.error(&inner, "invalid hex literal"))?; + Ok(AttributeValue::Int(value)) + } else if s.starts_with("0b") || s.starts_with("0B") { + let value = i64::from_str_radix(&s[2..], 2) + .map_err(|_| self.error(&inner, "invalid binary literal"))?; + Ok(AttributeValue::Int(value)) + } else if s.starts_with("0o") || s.starts_with("0O") { + let value = i64::from_str_radix(&s[2..], 8) + .map_err(|_| self.error(&inner, "invalid octal literal"))?; + Ok(AttributeValue::Int(value)) + } else { + let value: i64 = s.parse().map_err(|_| self.error(&inner, "invalid integer literal"))?; + Ok(AttributeValue::Int(value)) + } + } + Rule::bool_literal => { + let value = inner.as_str() == "true"; + Ok(AttributeValue::Bool(value)) + } + Rule::identifier => { + // Identifier as value (e.g., for enum-like values) + Ok(AttributeValue::Ident(inner.as_str().to_string())) + } + _ => Err(self.error(&inner, "invalid attribute value")), + } + } + + /// Parse a block. + fn parse_block(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut statements = Vec::new(); + let mut label = None; + let mut trailing_expr = None; + let mut attrs = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::attribute_list => { + attrs.extend(self.parse_attribute_list(item)?); + } + Rule::label => { + label = Some(self.parse_label(item)?); + } + Rule::statement => { + statements.push(self.parse_statement(item)?); + } + Rule::trailing_expr => { + // Trailing expression (block's return value) + let inner = self.expect_inner(item, "trailing_expr")?; + trailing_expr = Some(Box::new(self.parse_expr(inner)?)); + } + _ => {} + } + } + + Ok(Block { + label, + attrs, + statements, + trailing_expr, + location, + }) + } + + /// Parse a label. + fn parse_label(&self, pair: Pair<'a, Rule>) -> ParseResult { + let ident = self.expect_inner(pair, "label")?; + Ok(ident.as_str().to_string()) + } + + /// Parse try block statement. + /// Grammar: try_block_stmt = { try_collect_block | try_bang_block } + fn parse_try_block_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "try_block_stmt")?; + match inner.as_rule() { + Rule::try_collect_block => self.parse_try_collect_block(inner), + Rule::try_bang_block => self.parse_try_bang_block(inner), + _ => Err(self.error(&inner, "expected try or try! block")), + } + } + + /// Parse try { } block (collect all errors). + fn parse_try_collect_block(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut body = None; + let mut catch_clause = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::block => { + body = Some(self.parse_block(item)?); + } + Rule::catch_clause => { + catch_clause = Some(self.parse_catch_clause(item)?); + } + _ => {} + } + } + + Ok(TryBlockStmt { + mode: TryMode::Collect, + body: body.expect("try block must have body"), + catch_clause, + location, + }) + } + + /// Parse try! { } block (stop on first error). + fn parse_try_bang_block(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut body = None; + let mut catch_clause = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::block => { + body = Some(self.parse_block(item)?); + } + Rule::catch_clause => { + catch_clause = Some(self.parse_catch_clause(item)?); + } + _ => {} + } + } + + Ok(TryBlockStmt { + mode: TryMode::Propagate, + body: body.expect("try! block must have body"), + catch_clause, + location, + }) + } + + /// Parse catch clause: catch |err| { ... } or catch |err| expr + fn parse_catch_clause(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut capture = String::new(); + let mut body = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::identifier => { + capture = item.as_str().to_string(); + } + Rule::block => { + let parsed_block = self.parse_block(item)?; + body = Some(Expr::Block(Box::new(BlockExpr { + label: String::new(), + attrs: parsed_block.attrs, + statements: parsed_block.statements, + trailing_expr: parsed_block.trailing_expr, + location: None, + }))); + } + Rule::expr => { + body = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(CatchClause { + capture, + body: body.expect("catch clause must have body"), + location, + }) + } + + /// Parse assignment statement. + fn parse_assign_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + + let target = self.parse_expr(self.expect_next(&mut inner, &loc, "target")?)?; + let op = self.parse_assign_op(self.expect_next(&mut inner, &loc, "operator")?)?; + let value = self.parse_expr(self.expect_next(&mut inner, &loc, "value")?)?; + + Ok(AssignStmt { + target, + op, + value, + location, + }) + } + + /// Parse assignment operator. + fn parse_assign_op(&self, pair: Pair) -> ParseResult { + Ok(match pair.as_str() { + "=" => AssignOp::Assign, + "+=" => AssignOp::AddAssign, + "-=" => AssignOp::SubAssign, + "*=" => AssignOp::MulAssign, + "/=" => AssignOp::DivAssign, + "&=" => AssignOp::AndAssign, + "|=" => AssignOp::OrAssign, + "^=" => AssignOp::XorAssign, + _ => return Err(self.error(&pair, "unknown assignment operator")), + }) + } + + /// Parse if statement. + fn parse_if_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + + // First is either if_unwrap_clause or if_condition + let first = self.expect_next(&mut inner, &loc, "if condition")?; + let (condition, capture) = match first.as_rule() { + Rule::if_unwrap_clause => { + // if value := expr { ... } (Go-style unwrapping) + let first_loc = self.location(&first); + let mut clause_inner = first.into_inner(); + let capture_name = self.expect_next(&mut clause_inner, &first_loc, "capture name")?.as_str().to_string(); + let expr = self.parse_expr(self.expect_next(&mut clause_inner, &first_loc, "unwrap expression")?)?; + (expr, Some(capture_name)) + } + Rule::if_condition => { + // if condition { ... } + let cond_inner = self.expect_inner(first, "if_condition")?; + (self.parse_expr(cond_inner)?, None) + } + other => { + return Err(ParseError { + message: format!("unexpected rule {:?}, expected if_unwrap_clause or if_condition", other), + location: location.unwrap_or_default(), + }); + } + }; + + let mut then_body = None; + let mut else_body = None; + + for item in inner { + match item.as_rule() { + Rule::block if then_body.is_none() => { + then_body = Some(self.parse_block(item)?); + } + Rule::statement if then_body.is_none() => { + // Single statement as then body + let stmt = self.parse_statement(item)?; + then_body = Some(Block { + statements: vec![stmt], + label: None, + attrs: Vec::new(), + trailing_expr: None, + location: None, + }); + } + Rule::if_stmt => { + else_body = Some(ElseBranch::ElseIf(Box::new(self.parse_if_stmt(item)?))); + } + Rule::block => { + else_body = Some(ElseBranch::Else(self.parse_block(item)?)); + } + Rule::statement => { + // Single statement as else body + let stmt = self.parse_statement(item)?; + else_body = Some(ElseBranch::Else(Block { + statements: vec![stmt], + label: None, + attrs: Vec::new(), + trailing_expr: None, + location: None, + })); + } + _ => {} + } + } + + Ok(IfStmt { + condition, + capture, + then_body: then_body.unwrap_or_else(|| Block { + statements: vec![], + label: None, + attrs: Vec::new(), + trailing_expr: None, + location: None, + }), + else_body, + location, + }) + } + + /// Parse for statement (bounded iteration - NASA Power of 10 compliant). + fn parse_for_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut label = None; + let mut is_inline = false; + let mut range = None; + let mut captures = Vec::new(); + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::label => label = Some(self.parse_label(item)?), + Rule::for_range => range = Some(self.parse_for_range(item)?), + Rule::capture_list => captures = self.parse_capture_list(item)?, + Rule::block => body = Some(self.parse_block(item)?), + _ => { + if item.as_rule() == Rule::inline_keyword { + is_inline = true; + } + } + } + } + + Ok(ForStmt { + label, + is_inline, + range: range.expect("for needs range"), + captures, + body: body.expect("for needs body"), + location, + }) + } + + /// Parse for range. + fn parse_for_range(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "for_range")?; + + match inner.as_rule() { + Rule::range_lit => { + // range_lit = { range_start ~ ".." ~ range_end } + let loc = self.location(&inner); + let mut range_inner = inner.into_inner(); + let start_pair = self.expect_next(&mut range_inner, &loc, "range start")?; + let end_pair = self.expect_next(&mut range_inner, &loc, "range end")?; + + // range_start/range_end = { number_literal | identifier } + let start = self.parse_range_bound(start_pair)?; + let end = self.parse_range_bound(end_pair)?; + + Ok(ForRange::Range { start, end }) + } + Rule::expr => { + // Collection iteration or single expression + let first = self.parse_expr(inner)?; + Ok(ForRange::Collection(first)) + } + _ => { + // Try parsing as expression + let first = self.parse_expr(inner)?; + Ok(ForRange::Collection(first)) + } + } + } + + /// Parse range bound expression. + /// range_bound = { range_bound_term ~ (ws ~ range_bound_op ~ ws ~ range_bound_term)* } + fn parse_range_bound(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // Parse the first term + let first_term = self.expect_next(&mut inner, &location, "range bound term")?; + let mut result = self.parse_range_bound_term(first_term)?; + + // Parse remaining (op, term) pairs + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() == Rule::range_bound_op { + let op = match op_pair.as_str() { + "+" => BinaryOp::Add, + "-" => BinaryOp::Sub, + "*" => BinaryOp::Mul, + "/" => BinaryOp::Div, + _ => unreachable!(), + }; + + let term_pair = inner.next().expect("operator needs right operand"); + let right = self.parse_range_bound_term(term_pair)?; + + result = Expr::Binary(Box::new(BinaryExpr { + left: result, + op, + right, + location: Some(location.clone()), + })); + } + } + + Ok(result) + } + + /// Parse range bound term. + /// range_bound_term = { number_literal | range_bound_field_access | identifier | "(" ~ ws ~ range_bound ~ ws ~ ")" } + fn parse_range_bound_term(&self, pair: Pair) -> ParseResult { + let inner = pair.into_inner().next().expect("range_bound_term needs content"); + match inner.as_rule() { + Rule::number_literal | Rule::identifier => self.parse_primary_expr(inner), + Rule::range_bound_field_access => self.parse_range_bound_field_access(inner), + Rule::range_bound => self.parse_range_bound(inner), + _ => unreachable!("unexpected rule in range_bound_term: {:?}", inner.as_rule()), + } + } + + /// Parse range bound field access (e.g., self.len, arr.count). + /// range_bound_field_access = { identifier ~ ("." ~ identifier)+ } + fn parse_range_bound_field_access(&self, pair: Pair) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // First identifier is the base + let first = inner.next().expect("field access needs base"); + let mut result = Expr::Ident(Ident { + name: first.as_str().to_string(), + location: Some(self.location(&first)), + }); + + // Remaining member names are field accesses + for field_pair in inner { + if field_pair.as_rule() == Rule::identifier || field_pair.as_rule() == Rule::member_name { + result = Expr::Field(Box::new(FieldExpr { + object: result, + field: field_pair.as_str().to_string(), + location: Some(location.clone()), + })); + } + } + + Ok(result) + } + + /// Parse range expression for slicing: expr? ~ ".." ~ expr? + /// Examples: 0..2, 0.., ..2, .. + fn parse_range_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let mut start = None; + let mut end = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::expr => { + // First expr is start, second is end + if start.is_none() { + start = Some(self.parse_expr(item)?); + } else { + end = Some(self.parse_expr(item)?); + } + } + _ => {} + } + } + + Ok(Expr::Range(Box::new(RangeExpr { start, end, location }))) + } + + /// Parse capture list. + fn parse_capture_list(&self, pair: Pair) -> ParseResult> { + let mut captures = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::identifier { + captures.push(item.as_str().to_string()); + } + } + Ok(captures) + } + + /// Parse switch statement. + fn parse_switch_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + + let value = self.parse_expr(self.expect_next(&mut inner, &loc, "switch value")?)?; + let mut prongs = Vec::new(); + + for item in inner { + if item.as_rule() == Rule::switch_prong { + prongs.push(self.parse_switch_prong(item)?); + } + } + + Ok(SwitchStmt { + value, + prongs, + location, + }) + } + + /// Parse switch prong. + fn parse_switch_prong(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut cases = Vec::new(); + let mut is_else = false; + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::switch_case => cases.push(self.parse_switch_case(item)?), + Rule::expr | Rule::block => body = Some(self.parse_expr(item)?), + _ => { + if item.as_str() == "else" { + is_else = true; + } + } + } + } + + Ok(SwitchProng { + cases, + is_else, + body: body.expect("prong needs body"), + location, + }) + } + + /// Parse switch case. + fn parse_switch_case(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + + let value = self.parse_expr(self.expect_next(&mut inner, &loc, "case value")?)?; + let end = inner.next().map(|p| self.parse_expr(p)).transpose()?; + + Ok(SwitchCase { + value, + end, + location, + }) + } + + /// Parse return statement. + fn parse_return_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let value = pair.into_inner().next().map(|p| self.parse_expr(p)).transpose()?; + + Ok(ReturnStmt { value, location }) + } + + /// Parse break statement. + fn parse_break_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut label = None; + let mut value = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::identifier => label = Some(item.as_str().to_string()), + Rule::expr => value = Some(self.parse_expr(item)?), + _ => {} + } + } + + Ok(BreakStmt { + label, + value, + location, + }) + } + + /// Parse continue statement. + fn parse_continue_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let label = pair + .into_inner() + .find(|p| p.as_rule() == Rule::identifier) + .map(|p| p.as_str().to_string()); + + Ok(ContinueStmt { label, location }) + } + + /// Parse defer statement. + fn parse_defer_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = self.expect_inner(pair, "defer_stmt")?; + let body = Box::new(self.parse_statement(inner)?); + + Ok(DeferStmt { body, location }) + } + + /// Parse errdefer statement. + /// Grammar: errdefer_stmt = { "errdefer" ~ ws ~ ("|" ~ ws ~ identifier ~ ws ~ "|" ~ ws)? ~ (block | statement) } + fn parse_errdefer_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut capture = None; + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::identifier => { + // Capture variable name + capture = Some(item.as_str().to_string()); + } + Rule::block | Rule::statement => { + // Body of errdefer + body = Some(Box::new(self.parse_statement(item)?)); + } + _ => { + // Must be block or statement (fallback) + body = Some(Box::new(self.parse_statement(item)?)); + } + } + } + + let body = body.ok_or_else(|| self.error_at( + location.clone().unwrap_or_default(), + "errdefer requires a body" + ))?; + + Ok(ErrDeferStmt { + body, + capture, + location, + }) + } + + /// Parse expression statement. + /// Grammar: expr_stmt = { attribute_list? ~ ws ~ expr ~ ws ~ ";" } + fn parse_expr_stmt(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = pair.into_inner(); + + let mut attrs = Vec::new(); + let mut expr = None; + + for item in inner { + match item.as_rule() { + Rule::attribute_list => { + attrs = self.parse_attribute_list(item)?; + } + Rule::expr => { + expr = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(ExprStmt { + expr: expr.expect("expression statement must have an expression"), + attrs, + location, + }) + } + + // ========================================================================= + // Expressions + // ========================================================================= + + /// Parse an expression (entry point). + fn parse_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "expr")?; + + match inner.as_rule() { + Rule::comptime_expr => self.parse_comptime_expr(inner), + Rule::runtime_expr => self.parse_runtime_expr(inner), + Rule::or_expr => self.parse_or_expr(inner), + Rule::primary_expr => self.parse_primary_expr(inner), + _ => self.parse_primary_expr(inner), + } + } + + /// Parse comptime expression. + /// Grammar: comptime_expr = { "comptime" ~ ws ~ (block | runtime_expr) } + fn parse_comptime_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let inner = self.expect_inner(pair, "comptime_expr")?; + + // The inner can be either a block or a runtime_expr + let expr = match inner.as_rule() { + Rule::block => { + // Convert the block to a BlockExpr + let block = self.parse_block(inner)?; + Expr::Block(Box::new(BlockExpr { + label: block.label.unwrap_or_default(), + attrs: block.attrs, + statements: block.statements, + trailing_expr: block.trailing_expr, + location: block.location, + })) + } + _ => self.parse_expr(inner)?, + }; + + Ok(Expr::Comptime(Box::new(ComptimeExpr { + inner: expr, + location, + }))) + } + + /// Parse runtime expression (binary operators). + fn parse_runtime_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "runtime_expr")?; + self.parse_or_expr(inner) + } + + /// Parse or expression. + /// Grammar: or_expr = { catch_expr ~ (or_kw ~ catch_expr)* } + fn parse_or_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = self.parse_catch_expr(self.expect_next(&mut inner, &loc, "or operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip the or_kw operator rule + let right_pair = if next_pair.as_rule() == Rule::or_kw { + self.expect_next(&mut inner, &loc, "or right operand")? + } else { + next_pair + }; + let right = self.parse_catch_expr(right_pair)?; + left = Expr::Binary(Box::new(BinaryExpr { + op: BinaryOp::Or, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse catch expression. + /// Grammar: catch_expr = { orelse_expr ~ (catch_kw ~ ("|" ~ ws ~ identifier ~ ws ~ "|" ~ ws)? ~ orelse_expr)* } + fn parse_catch_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let loc = location.clone().unwrap_or_default(); + let mut inner = pair.into_inner(); + let mut left = self.parse_orelse_expr(self.expect_next(&mut inner, &loc, "catch operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip the catch_kw operator rule if present + let next_pair = if next_pair.as_rule() == Rule::catch_kw { + self.expect_next(&mut inner, &loc, "catch handler")? + } else { + next_pair + }; + + // Check if this is an identifier (error capture) or an orelse_expr (handler) + if next_pair.as_rule() == Rule::identifier { + // This is the capture variable: catch |err| handler + let capture = Some(next_pair.as_str().to_string()); + let handler = self.parse_orelse_expr(self.expect_next(&mut inner, &loc, "catch handler body")?)?; + left = Expr::Catch(Box::new(CatchExpr { + operand: left, + capture, + handler, + location: location.clone(), + })); + } else { + // No capture: catch handler + let handler = self.parse_orelse_expr(next_pair)?; + left = Expr::Catch(Box::new(CatchExpr { + operand: left, + capture: None, + handler, + location: location.clone(), + })); + } + } + + Ok(left) + } + + /// Parse orelse expression. + /// Grammar: orelse_expr = { and_expr ~ (orelse_kw ~ and_expr)* } + fn parse_orelse_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = self.parse_and_expr(self.expect_next(&mut inner, &loc, "orelse operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip the orelse_kw operator rule + let right_pair = if next_pair.as_rule() == Rule::orelse_kw { + self.expect_next(&mut inner, &loc, "orelse right operand")? + } else { + next_pair + }; + let right = self.parse_and_expr(right_pair)?; + left = Expr::Binary(Box::new(BinaryExpr { + op: BinaryOp::Orelse, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse and expression. + /// Grammar: and_expr = { cmp_expr ~ (and_kw ~ cmp_expr)* } + fn parse_and_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = self.parse_cmp_expr(self.expect_next(&mut inner, &loc, "and operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip the and_kw operator rule + let right_pair = if next_pair.as_rule() == Rule::and_kw { + self.expect_next(&mut inner, &loc, "and right operand")? + } else { + next_pair + }; + let right = self.parse_cmp_expr(right_pair)?; + left = Expr::Binary(Box::new(BinaryExpr { + op: BinaryOp::And, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse binary chain with given operator. + fn parse_binary_chain(&self, pair: Pair<'a, Rule>, op: BinaryOp) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "binary operand")?)?; + + while let Some(next_pair) = inner.next() { + // Skip operator rules (symbol operators) + let right_pair = if matches!( + next_pair.as_rule(), + Rule::bitor_op | Rule::bitxor_op | Rule::bitand_op + ) { + self.expect_next(&mut inner, &loc, "binary right operand")? + } else { + next_pair + }; + let right = self.parse_next_precedence(right_pair)?; + left = Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse next precedence level. + fn parse_next_precedence(&self, pair: Pair<'a, Rule>) -> ParseResult { + match pair.as_rule() { + Rule::catch_expr => self.parse_catch_expr(pair), + Rule::orelse_expr => self.parse_orelse_expr(pair), + Rule::and_expr => self.parse_and_expr(pair), + Rule::cmp_expr => self.parse_cmp_expr(pair), + Rule::bitwise_or_expr => self.parse_binary_chain(pair, BinaryOp::BitOr), + Rule::bitwise_xor_expr => self.parse_binary_chain(pair, BinaryOp::BitXor), + Rule::bitwise_and_expr => self.parse_binary_chain(pair, BinaryOp::BitAnd), + Rule::shift_expr => self.parse_shift_expr(pair), + Rule::add_expr => self.parse_add_expr(pair), + Rule::suffixed_expr => self.parse_suffixed_expr(pair), + Rule::mul_expr => self.parse_mul_expr(pair), + Rule::unary_expr => self.parse_unary_expr(pair), + Rule::postfix_expr => self.parse_postfix_expr(pair), + Rule::primary_expr => self.parse_primary_expr(pair), + _ => self.parse_primary_expr(pair), + } + } + + /// Parse comparison expression. + fn parse_cmp_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "cmp operand")?)?; + + if let Some(op_pair) = inner.next() { + let op = self.parse_cmp_op(op_pair)?; + let right = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "cmp right operand")?)?; + Ok(Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + }))) + } else { + Ok(left) + } + } + + /// Parse comparison operator. + fn parse_cmp_op(&self, pair: Pair) -> ParseResult { + // Check for sub-rules first (in_op, not_in_op) + if let Some(inner) = pair.clone().into_inner().next() { + return match inner.as_rule() { + Rule::in_op => Ok(BinaryOp::In), + Rule::not_in_op => Ok(BinaryOp::NotIn), + _ => Err(self.error(&pair, "unknown comparison operator")), + }; + } + // Direct string match for simple operators + Ok(match pair.as_str() { + "==" => BinaryOp::Eq, + "!=" => BinaryOp::Ne, + "<" => BinaryOp::Lt, + "<=" => BinaryOp::Le, + ">" => BinaryOp::Gt, + ">=" => BinaryOp::Ge, + "in" => BinaryOp::In, + _ => return Err(self.error(&pair, "unknown comparison operator")), + }) + } + + /// Parse shift expression. + fn parse_shift_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "shift operand")?)?; + + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() != Rule::shift_op { + continue; + } + let op = match op_pair.as_str() { + "<<" => BinaryOp::Shl, + ">>" => BinaryOp::Shr, + _ => continue, + }; + let right = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "shift right operand")?)?; + left = Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse additive expression. + fn parse_add_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "add operand")?)?; + + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() != Rule::add_op { + continue; + } + let op = match op_pair.as_str() { + "+" => BinaryOp::Add, + "-" => BinaryOp::Sub, + _ => continue, + }; + let right = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "add right operand")?)?; + left = Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse multiplicative expression. + fn parse_mul_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + let mut left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "mul operand")?)?; + + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() != Rule::mul_op { + continue; + } + let op = match op_pair.as_str() { + "*" => BinaryOp::Mul, + "/" => BinaryOp::Div, + "%" => BinaryOp::Mod, + _ => continue, + }; + let right = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "mul right operand")?)?; + left = Expr::Binary(Box::new(BinaryExpr { + op, + left, + right, + location: None, + })); + } + + Ok(left) + } + + /// Parse suffixed expression: `mul_expr ~ (expr_suffix)?` + /// Handles both angle units (`0.25 turns`, `pi/4 rad`) and type suffixes (`42 u32`, `1/4 f64`). + fn parse_suffixed_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // Parse the expression part + let value = self.parse_next_precedence(self.expect_next(&mut inner, &location, "expression in suffixed expr")?)?; + + // Check for optional suffix (angle unit or type) + if let Some(suffix_pair) = inner.next() + && suffix_pair.as_rule() == Rule::expr_suffix { + // Get the inner rule (angle_unit or type_suffix) + let inner_suffix = self.expect_inner(suffix_pair, "expr_suffix")?; + match inner_suffix.as_rule() { + Rule::angle_unit => { + let unit = match inner_suffix.as_str() { + "turns" => AngleUnit::Turns, + "rad" => AngleUnit::Rad, + _ => return Err(self.error(&inner_suffix, "unknown angle unit")), + }; + return Ok(Expr::AngleLit(Box::new(AngleLit { + value, + unit, + location: Some(location), + }))); + } + Rule::type_ascription_suffix => { + // Get the actual type keyword + let type_inner = self.expect_inner(inner_suffix, "type_ascription_suffix")?; + let type_name = type_inner.as_str().to_string(); + return Ok(Expr::TypeAscription(Box::new(TypeAscription { + value, + type_name, + location: Some(location), + }))); + } + _ => {} + } + } + + // No suffix - return the value as-is + Ok(value) + } + + /// Parse unary expression. + fn parse_unary_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner().peekable(); + let mut ops = Vec::new(); + + // Collect unary operators + while let Some(item) = inner.peek() { + if item.as_rule() == Rule::unary_op { + // Safe: we just peeked and confirmed it exists + if let Some(op) = inner.next() { + ops.push(op); + } + } else { + break; + } + } + + // Parse the operand + let operand = self.expect_next(&mut inner, &location, "operand in unary expression")?; + let mut expr = self.parse_postfix_expr(operand)?; + + // Apply operators in reverse order + for op_pair in ops.into_iter().rev() { + let op = match op_pair.as_str() { + "try" => UnaryOp::Try, + "-" => UnaryOp::Neg, + "!" => UnaryOp::Not, + "~" => UnaryOp::BitNot, + "&" => UnaryOp::AddrOf, + "*" => UnaryOp::Deref, + _ => continue, + }; + expr = Expr::Unary(Box::new(UnaryExpr { + op, + operand: expr, + location: Some(self.location(&op_pair)), + })); + } + + Ok(expr) + } + + /// Parse postfix expression. + fn parse_postfix_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + let primary = self.expect_next(&mut inner, &location, "primary expression")?; + let mut expr = self.parse_primary_expr(primary)?; + + // Apply postfix operators + for op in inner { + // Capture end location from the operator + let op_span = op.as_span(); + let (op_end_line, op_end_col) = op_span.end_pos().line_col(); + + // Get start location from current expression, or use operator's start + let expr_start = expr.get_location(); + let (start_line, start_col) = expr_start + .as_ref() + .map(|loc| (loc.line, loc.column)) + .unwrap_or_else(|| { + let (l, c) = op.line_col(); + (l as u32, c as u32) + }); + + // Combined location spans from expression start to operator end + let combined_location = SourceLocation { + line: start_line, + column: start_col, + end_line: op_end_line as u32, + end_column: op_end_col as u32, + file: self.file.clone(), + }; + + // postfix_op is a wrapper around call | field_access | index_access | optional_unwrap | error_unwrap + // We need to get the inner rule + let actual_op = if op.as_rule() == Rule::postfix_op { + self.expect_inner(op, "postfix_op")? + } else { + op + }; + match actual_op.as_rule() { + Rule::call => { + let args = self.parse_arg_list(actual_op)?; + expr = Expr::Call(Box::new(CallExpr { + callee: expr, + args, + location: Some(combined_location), + })); + } + Rule::batch_apply => { + // batch_apply = { ws ~ "{" ~ ws ~ batch_elements? ~ ws ~ "}" } + let mut targets = Vec::new(); + for inner in actual_op.into_inner() { + if inner.as_rule() == Rule::batch_elements { + for elem in inner.into_inner() { + targets.push(self.parse_expr(elem)?); + } + } + } + expr = Expr::BatchApply(Box::new(BatchApplyExpr { + operation: expr, + targets, + location: Some(combined_location), + })); + } + Rule::field_access => { + let field = self.expect_inner(actual_op, "field_access")?.as_str().to_string(); + expr = Expr::Field(Box::new(FieldExpr { + object: expr, + field, + location: Some(combined_location), + })); + } + Rule::index_access => { + let index_inner = self.expect_inner(actual_op, "index_access")?; + // Check if the index is a range expression (for slicing) + let index = if index_inner.as_rule() == Rule::range_expr { + self.parse_range_expr(index_inner)? + } else { + self.parse_expr(index_inner)? + }; + expr = Expr::Index(Box::new(IndexExpr { + object: expr, + index, + location: Some(combined_location), + })); + } + Rule::optional_unwrap => { + expr = Expr::Unary(Box::new(UnaryExpr { + op: UnaryOp::OptionalUnwrap, + operand: expr, + location: Some(combined_location), + })); + } + Rule::error_unwrap => { + expr = Expr::Unary(Box::new(UnaryExpr { + op: UnaryOp::ErrorUnwrap, + operand: expr, + location: Some(combined_location), + })); + } + _ => {} + } + } + + Ok(expr) + } + + /// Parse argument list. + fn parse_arg_list(&self, pair: Pair) -> ParseResult> { + let mut args = Vec::new(); + for item in pair.into_inner() { + match item.as_rule() { + Rule::arg_list => { + for arg in item.into_inner() { + if arg.as_rule() == Rule::expr { + args.push(self.parse_expr(arg)?); + } + } + } + Rule::expr => { + args.push(self.parse_expr(item)?); + } + _ => {} + } + } + Ok(args) + } + + /// Parse primary expression. + fn parse_primary_expr(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + + match pair.as_rule() { + Rule::number_literal => self.parse_number(pair), + Rule::string_literal => Ok(Expr::StringLit(StringLit { + value: self.parse_string_content(pair.as_str()), + location, + })), + Rule::raw_string => { + // Raw string: r"..." - no escape processing + let s = pair.as_str(); + // Remove r" prefix and " suffix + let value = s[2..s.len() - 1].to_string(); + Ok(Expr::StringLit(StringLit { value, location })) + } + Rule::multiline_string => { + // Multi-line string: """...""" - preserves newlines + let s = pair.as_str(); + // Remove """ prefix and """ suffix (3 chars each) + let content = &s[3..s.len() - 3]; + // Process escape sequences within the content + let value = self.unescape(content); + Ok(Expr::StringLit(StringLit { value, location })) + } + Rule::f_string => self.parse_f_string(pair), + Rule::char_literal => { + let s = pair.as_str(); + let c = self.parse_char_content(s); + Ok(Expr::CharLit(CharLit { value: c, location })) + } + Rule::bool_literal => Ok(Expr::BoolLit(BoolLit { + value: pair.as_str() == "true", + location, + })), + Rule::none_literal => Ok(Expr::Null(NullLit { location })), + Rule::undefined_literal => Ok(Expr::Undefined(UndefinedLit { location })), + Rule::unit_literal => Ok(Expr::Unit(UnitLit { location })), + Rule::identifier => Ok(Expr::Ident(Ident { + name: pair.as_str().to_string(), + location, + })), + Rule::builtin_call => self.parse_builtin_call(pair), + Rule::struct_init => self.parse_struct_init(pair), + Rule::array_init => self.parse_array_init(pair), + Rule::if_expr => self.parse_if_expr(pair), + Rule::try_block_expr => self.parse_try_block_expr(pair), + Rule::block_expr => self.parse_block_expr(pair), + Rule::paren_or_tuple => { + // paren_or_tuple = { "(" ~ ws ~ expr ~ (ws ~ "," ~ ws ~ expr)* ~ (ws ~ ",")? ~ ws ~ ")" } + // Single element without trailing comma -> parenthesized expr (unwrap) + // Multiple elements or trailing comma -> tuple + let pair_str = pair.as_str(); + let has_trailing_comma = pair_str.trim_end_matches(')').trim_end().ends_with(','); + + let mut elements = Vec::new(); + for inner in pair.into_inner() { + elements.push(self.parse_expr(inner)?); + } + + if elements.len() == 1 && !has_trailing_comma { + // Single element, no trailing comma -> parenthesized expression + Ok(elements.pop().unwrap()) + } else { + // Multiple elements or trailing comma -> tuple + Ok(Expr::Tuple(Box::new(TupleExpr { elements, location }))) + } + } + Rule::bracket_array => { + // bracket_array = { "[" ~ ws ~ bracket_array_elements? ~ ws ~ "]" } + let mut elements = Vec::new(); + for inner in pair.into_inner() { + if inner.as_rule() == Rule::bracket_array_elements { + for elem in inner.into_inner() { + elements.push(self.parse_expr(elem)?); + } + } + } + Ok(Expr::BracketArray(Box::new(BracketArrayExpr { + elements, + location, + }))) + } + Rule::set_literal => { + // set_literal = { "set" ~ ws ~ "{" ~ ws ~ set_elements? ~ ws ~ "}" } + let mut elements = Vec::new(); + for inner in pair.into_inner() { + if inner.as_rule() == Rule::set_elements { + for elem in inner.into_inner() { + elements.push(self.parse_expr(elem)?); + } + } + } + Ok(Expr::Set(Box::new(SetExpr { + elements, + element_type: None, + location, + }))) + } + Rule::measure_expr => { + // measure_expr = { "mz" ~ ws ~ "(" ~ ws ~ pack_modifier? ~ type_expr ~ ws ~ ")" ~ ws ~ measure_target } + let mut result_type = None; + let mut targets = None; + let mut pack = false; + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::pack_modifier => { + pack = true; + } + Rule::type_expr => { + result_type = Some(self.parse_type_expr(inner)?); + } + Rule::measure_target => { + // measure_target = { bracket_array | postfix_expr } + let target_inner = self.expect_inner(inner, "measure_target")?; + // Handle each rule type directly + targets = Some(match target_inner.as_rule() { + Rule::bracket_array => self.parse_primary_expr(target_inner)?, + Rule::postfix_expr => self.parse_postfix_expr(target_inner)?, + _ => self.parse_primary_expr(target_inner)?, + }); + } + _ => {} + } + } + Ok(Expr::Measure(Box::new(MeasureExpr { + result_type: result_type.expect("measure requires type"), + pack, + targets: targets.expect("measure requires targets"), + location, + }))) + } + Rule::channel_expr => { + // channel_expr = { emit_prefix ~ ws ~ channel_name ~ ws ~ "." ~ ws ~ channel_command } + // emit_prefix = { "@emit" ~ ws ~ "." } + // channel_command = { channel_command_name ~ ws ~ "(" ~ ws ~ channel_args? ~ ws ~ ")" } + let mut channel = None; + let mut command = None; + let mut args = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::emit_prefix => { + // Just consume the @emit. prefix + } + Rule::channel_name => { + channel = Some(item.as_str().to_string()); + } + Rule::channel_command => { + for cmd_item in item.into_inner() { + match cmd_item.as_rule() { + Rule::channel_command_name => { + command = Some(cmd_item.as_str().to_string()); + } + Rule::channel_args => { + for arg_item in cmd_item.into_inner() { + if arg_item.as_rule() == Rule::channel_arg { + args.push(self.parse_channel_arg(arg_item)?); + } + } + } + _ => {} + } + } + } + _ => {} + } + } + + Ok(Expr::Channel(Box::new(ChannelExpr { + channel: channel.expect("channel_expr requires channel name"), + command: command.expect("channel_expr requires command"), + args, + location, + }))) + } + Rule::result_expr => { + // result_expr = { "result" ~ ws ~ "(" ~ ws ~ string_literal ~ ws ~ "," ~ ws ~ expr ~ ws ~ ")" } + let mut tag = None; + let mut value = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::string_literal => { + tag = Some(self.parse_string_content(item.as_str())); + } + Rule::expr => { + value = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + Ok(Expr::Result(Box::new(ResultExpr { + tag: tag.expect("result requires tag"), + value: value.expect("result requires value"), + location, + }))) + } + Rule::gate_expr => { + // gate_expr = { param_gate_expr | simple_gate_expr } + let inner = self.expect_inner(pair, "gate_expr")?; + self.parse_gate_expr_inner(inner, location) + } + Rule::param_gate_expr | Rule::simple_gate_expr => { + self.parse_gate_expr_inner(pair, location) + } + Rule::struct_literal => { + // struct_literal = { ("packed" ~ ws)? ~ "struct" ~ ws ~ "{" ~ ws ~ struct_body ~ ws ~ "}" } + // Anonymous struct type definition: struct { x: i32, y: i32 } + let mut is_packed = false; + let mut fields = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::packed_keyword => { + is_packed = true; + } + Rule::struct_body => { + // Parse struct fields (ignoring methods and bindings for anonymous structs) + for body_item in item.into_inner() { + if body_item.as_rule() == Rule::struct_field { + fields.push(self.parse_struct_field(body_item)?); + } + } + } + _ => {} + } + } + + Ok(Expr::AnonStruct(Box::new(AnonStructExpr { + fields, + is_packed, + location, + }))) + } + Rule::enum_literal => { + // enum_literal = similar to struct_literal + Ok(Expr::StructInit(Box::new(StructInitExpr { + ty: None, + fields: Vec::new(), + location, + }))) + } + Rule::fn_literal => { + // Anonymous function: fn(params) -> return_type { body } + let inner = pair.into_inner(); + let mut params = Vec::new(); + let mut return_type = None; + let mut body = None; + + for item in inner { + match item.as_rule() { + Rule::param_list => { + params = self.parse_param_list(item)?; + } + Rule::return_type => { + return_type = Some(self.parse_return_type(item)?); + } + Rule::block => { + body = Some(self.parse_block(item)?); + } + _ => {} + } + } + + Ok(Expr::FnLit(Box::new(FnDecl { + name: "".to_string(), + params, + return_type, + body: body.expect("function literal must have body"), + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location, + }))) + } + Rule::self_expr => Ok(Expr::Ident(Ident { + name: "Self".to_string(), + location, + })), + Rule::error_value => { + // error_value = { "error" ~ "." ~ identifier } + let inner = self.expect_inner(pair, "error_value")?; + let name = inner.as_str().to_string(); + Ok(Expr::ErrorValue(Box::new(ErrorValueExpr { + name, + location, + }))) + } + Rule::fault_value => { + // fault_value = { "fault" ~ "." ~ identifier } + let inner = self.expect_inner(pair, "fault_value")?; + let name = inner.as_str().to_string(); + Ok(Expr::FaultValue(Box::new(FaultValueExpr { + name, + location, + }))) + } + Rule::array_type_expr => { + // array_type_expr = { "[" ~ ws ~ (array_size | "_")? ~ ... ~ "]" ~ ws ~ type_identifier } + // This represents a type as a value (like [N]T) + Ok(Expr::Ident(Ident { + name: pair.as_str().to_string(), + location, + })) + } + Rule::primary_expr => { + let inner = self.expect_inner(pair, "primary_expr")?; + self.parse_primary_expr(inner) + } + Rule::atom => { + // atom is a compound-atomic wrapper around leaf expressions + let inner = self.expect_inner(pair, "atom")?; + self.parse_primary_expr(inner) + } + _ => Err(self.error(&pair, format!("unexpected primary {:?}", pair.as_rule()))), + } + } + + /// Parse number literal with optional type suffix. + fn parse_number(&self, pair: Pair) -> ParseResult { + let loc = self.location(&pair); + let raw = pair.as_str(); + + // Extract type suffix if present + let (num_str, suffix) = self.extract_number_suffix(raw); + let s = num_str.replace('_', ""); + + // Check for float (must check before extracting suffix changes things) + let is_float = s.contains('.') || + (s.contains('e') || s.contains('E')) && !s.starts_with("0x") && !s.starts_with("0X"); + + if is_float { + let value: f64 = s.parse().map_err(|_| { + self.error_at(loc.clone(), "invalid float literal") + })?; + Ok(Expr::FloatLit(FloatLit { value, suffix, location: Some(loc) })) + } else if s.starts_with("0x") || s.starts_with("0X") { + let value = i128::from_str_radix(&s[2..], 16).map_err(|_| { + self.error_at(loc.clone(), "invalid hex literal") + })?; + Ok(Expr::IntLit(IntLit { value, suffix, location: Some(loc) })) + } else if s.starts_with("0b") || s.starts_with("0B") { + let value = i128::from_str_radix(&s[2..], 2).map_err(|_| { + self.error_at(loc.clone(), "invalid binary literal") + })?; + Ok(Expr::IntLit(IntLit { value, suffix, location: Some(loc) })) + } else if s.starts_with("0o") || s.starts_with("0O") { + let value = i128::from_str_radix(&s[2..], 8).map_err(|_| { + self.error_at(loc.clone(), "invalid octal literal") + })?; + Ok(Expr::IntLit(IntLit { value, suffix, location: Some(loc) })) + } else { + let value: i128 = s.parse().map_err(|_| { + self.error_at(loc.clone(), "invalid integer literal") + })?; + Ok(Expr::IntLit(IntLit { value, suffix, location: Some(loc) })) + } + } + + /// Extract type suffix from a number literal string. + /// Returns (number_part, optional_suffix). + fn extract_number_suffix<'b>(&self, s: &'b str) -> (&'b str, Option) { + // Integer suffixes (check longer ones first) + const INT_SUFFIXES: &[&str] = &[ + "u128", "i128", "usize", "isize", + "u64", "i64", "u32", "i32", "u16", "i16", "u8", "i8", "u1", "i1", + ]; + // Float suffixes + const FLOAT_SUFFIXES: &[&str] = &["f128", "f64", "f32", "f16", "a64"]; + + // Check for suffix with optional underscore separator + for &suffix in INT_SUFFIXES.iter().chain(FLOAT_SUFFIXES.iter()) { + // Check for _suffix pattern + let with_underscore = format!("_{}", suffix); + if s.ends_with(&with_underscore) { + return (&s[..s.len() - with_underscore.len()], Some(suffix.to_string())); + } + // Check for direct suffix (no underscore) + if let Some(prefix) = s.strip_suffix(suffix) { + // Make sure we're not matching part of a hex digit + if !prefix.is_empty() && (prefix.ends_with(|c: char| c.is_ascii_digit()) || prefix.ends_with('_')) { + return (prefix, Some(suffix.to_string())); + } + } + } + + (s, None) + } + + /// Parse string content (remove quotes and handle escapes). + fn parse_string_content(&self, s: &str) -> String { + let s = &s[1..s.len() - 1]; // Remove quotes + self.unescape(s) + } + + /// Parse char content. + fn parse_char_content(&self, s: &str) -> char { + let s = &s[1..s.len() - 1]; // Remove quotes + let unescaped = self.unescape(s); + unescaped.chars().next().unwrap_or('\0') + } + + /// Parse f-string (Python-style interpolated string): f"Hello {name}!" + /// Supports format specifiers: f"{x:.2f}", f"{name:>10}" + fn parse_f_string(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut parts = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::f_string_part => { + let inner = self.expect_inner(item, "f_string_part")?; + match inner.as_rule() { + Rule::f_string_text => { + let text = self.unescape(inner.as_str()); + parts.push(FStringPart::Text(text)); + } + Rule::f_string_interp => { + let (expr, format) = self.parse_f_string_interp(inner)?; + parts.push(FStringPart::Expr { expr, format }); + } + _ => {} + } + } + Rule::f_string_text => { + let text = self.unescape(item.as_str()); + parts.push(FStringPart::Text(text)); + } + Rule::f_string_interp => { + let (expr, format) = self.parse_f_string_interp(item)?; + parts.push(FStringPart::Expr { expr, format }); + } + _ => {} + } + } + + Ok(Expr::FString(Box::new(FStringExpr { parts, location }))) + } + + /// Parse f-string interpolation: {expr} or {expr:format} + fn parse_f_string_interp(&self, pair: Pair<'a, Rule>) -> ParseResult<(Expr, Option)> { + let location = self.location(&pair); + let mut expr = None; + let mut format = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::expr => { + expr = Some(self.parse_expr(inner)?); + } + Rule::f_string_format => { + // f_string_format = { ":" ~ f_string_format_spec } + for fmt_inner in inner.into_inner() { + if fmt_inner.as_rule() == Rule::f_string_format_spec { + let spec = fmt_inner.as_str().to_string(); + if !spec.is_empty() { + format = Some(spec); + } + } + } + } + _ => {} + } + } + + let expr = expr.ok_or_else(|| self.error_at(location, "expected expression in f-string interpolation"))?; + Ok((expr, format)) + } + + /// Unescape string content. + fn unescape(&self, s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('n') => result.push('\n'), + Some('r') => result.push('\r'), + Some('t') => result.push('\t'), + Some('\\') => result.push('\\'), + Some('"') => result.push('"'), + Some('\'') => result.push('\''), + Some('0') => result.push('\0'), + Some('{') => result.push('{'), + Some('}') => result.push('}'), + Some('x') => { + let hex: String = chars.by_ref().take(2).collect(); + if let Ok(n) = u8::from_str_radix(&hex, 16) { + result.push(n as char); + } + } + Some(other) => { + result.push('\\'); + result.push(other); + } + None => result.push('\\'), + } + } else { + result.push(c); + } + } + + result + } + + /// Parse builtin call (@import, @This, etc.). + fn parse_builtin_call(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + + let name = self.expect_next(&mut inner, &loc, "builtin name")?.as_str().to_string(); + let location = Some(loc); + let args = if let Some(arg_list) = inner.next() { + self.parse_arg_list(arg_list)? + } else { + Vec::new() + }; + + Ok(Expr::Builtin(Box::new(BuiltinExpr { + name, + args, + location, + }))) + } + + /// Parse struct initialization. + fn parse_struct_init(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let inner_pair = self.expect_inner(pair, "struct_init")?; + + // struct_init contains either typed_struct_init or anon_struct_init + match inner_pair.as_rule() { + Rule::typed_struct_init => self.parse_typed_struct_init(inner_pair, location), + Rule::anon_struct_init => self.parse_anon_struct_init(inner_pair, location), + other => { + return Err(ParseError { + message: format!("unexpected rule {:?}, expected typed_struct_init or anon_struct_init", other), + location: location.unwrap_or_default(), + }); + } + } + } + + fn parse_typed_struct_init( + &self, + pair: Pair, + location: Option, + ) -> ParseResult { + let mut inner = pair.into_inner(); + + // First element is type_identifier + let ty = if let Some(first) = inner.next() { + if first.as_rule() == Rule::type_identifier { + Some(self.parse_type_identifier(first)?) + } else { + None + } + } else { + None + }; + + let fields = self.collect_field_inits(inner)?; + + Ok(Expr::StructInit(Box::new(StructInitExpr { + ty, + fields, + location, + }))) + } + + fn parse_anon_struct_init( + &self, + pair: Pair, + location: Option, + ) -> ParseResult { + let inner = pair.into_inner(); + let fields = self.collect_field_inits(inner)?; + + Ok(Expr::StructInit(Box::new(StructInitExpr { + ty: None, + fields, + location, + }))) + } + + fn collect_field_inits(&self, inner: Pairs) -> ParseResult> { + let mut fields = Vec::new(); + for item in inner { + match item.as_rule() { + Rule::field_init => { + fields.push(self.parse_field_init(item)?); + } + Rule::field_init_list => { + for field in item.into_inner() { + if field.as_rule() == Rule::field_init { + fields.push(self.parse_field_init(field)?); + } + } + } + _ => {} + } + } + Ok(fields) + } + + /// Parse field initializer. + fn parse_field_init(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + + let name = self.expect_next(&mut inner, &loc, "field name")?.as_str().to_string(); + let location = Some(loc); + + // Rust-style: `field: value` or shorthand `field` (when var name matches) + let value = if let Some(expr_pair) = inner.next() { + self.parse_expr(expr_pair)? + } else { + // Shorthand: `field` expands to `field: field` + Expr::Ident(Ident { + name: name.clone(), + location: location.clone(), + }) + }; + + Ok(FieldInit { + name, + value, + location, + }) + } + + /// Parse array initialization. + fn parse_array_init(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = Some(self.location(&pair)); + let mut inner = pair.into_inner().peekable(); + + let ty = if let Some(first) = inner.peek() { + if first.as_rule() == Rule::type_expr { + // Safe: we just peeked and confirmed it exists + if let Some(type_pair) = inner.next() { + Some(self.parse_type_expr(type_pair)?) + } else { + None + } + } else { + None + } + } else { + None + }; + + let mut elements = Vec::new(); + for item in inner { + if item.as_rule() == Rule::expr { + elements.push(self.parse_expr(item)?); + } + } + + Ok(Expr::ArrayInit(Box::new(ArrayInitExpr { + ty, + elements, + location, + }))) + } + + /// Parse try block expression. + /// Grammar: try_block_expr = { try_collect_expr | try_bang_expr } + fn parse_try_block_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let inner = self.expect_inner(pair, "try_block_expr")?; + let location = Some(self.location(&inner)); + + match inner.as_rule() { + Rule::try_collect_expr => { + let mut body = None; + let mut catch_clause = None; + for item in inner.into_inner() { + match item.as_rule() { + Rule::block => body = Some(self.parse_block(item)?), + Rule::catch_clause => catch_clause = Some(self.parse_catch_clause(item)?), + _ => {} + } + } + Ok(Expr::TryBlock(Box::new(TryBlockExpr { + mode: TryMode::Collect, + body: body.expect("try block must have body"), + catch_clause, + location, + }))) + } + Rule::try_bang_expr => { + let mut body = None; + let mut catch_clause = None; + for item in inner.into_inner() { + match item.as_rule() { + Rule::block => body = Some(self.parse_block(item)?), + Rule::catch_clause => catch_clause = Some(self.parse_catch_clause(item)?), + _ => {} + } + } + Ok(Expr::TryBlock(Box::new(TryBlockExpr { + mode: TryMode::Propagate, + body: body.expect("try! block must have body"), + catch_clause, + location, + }))) + } + _ => Err(self.error(&inner, "expected try or try! expression")), + } + } + + /// Parse if expression. + fn parse_if_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let loc = self.location(&pair); + let mut inner = pair.into_inner(); + + // First is the condition (in parentheses) + let cond_pair = self.expect_next(&mut inner, &loc, "if condition")?; + let condition = self.parse_expr(cond_pair)?; + + // Then block for the "then" branch + let then_block = self.expect_next(&mut inner, &loc, "then block")?; + let then_parsed = self.parse_block(then_block)?; + let then_expr = Expr::Block(Box::new(BlockExpr { + label: then_parsed.label.clone().unwrap_or_default(), + attrs: then_parsed.attrs, + statements: then_parsed.statements, + trailing_expr: then_parsed.trailing_expr, + location: then_parsed.location, + })); + + // Else branch: either if_expr or block + let else_item = self.expect_next(&mut inner, &loc, "else branch")?; + let location = Some(loc); + let else_expr = match else_item.as_rule() { + Rule::if_expr => self.parse_if_expr(else_item)?, + Rule::block => { + let else_parsed = self.parse_block(else_item)?; + Expr::Block(Box::new(BlockExpr { + label: else_parsed.label.clone().unwrap_or_default(), + attrs: else_parsed.attrs, + statements: else_parsed.statements, + trailing_expr: else_parsed.trailing_expr, + location: else_parsed.location, + })) + } + _ => return Err(ParseError { + message: format!("Expected block or if_expr in else branch, got {:?}", else_item.as_rule()), + location: self.location(&else_item), + }), + }; + + Ok(Expr::If(Box::new(IfExpr { + condition, + then_expr, + else_expr, + location, + }))) + } + + /// Parse block expression. + fn parse_block_expr(&self, pair: Pair) -> ParseResult { + let location = Some(self.location(&pair)); + let mut statements = Vec::new(); + let mut trailing_expr = None; + let mut attrs = Vec::new(); + let mut label = String::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::attribute_list => { + attrs.extend(self.parse_attribute_list(item)?); + } + Rule::label => { + label = self.parse_label(item)?; + } + Rule::statement => { + statements.push(self.parse_statement(item)?); + } + Rule::trailing_expr => { + let expr_inner = self.expect_inner(item, "trailing_expr")?; + trailing_expr = Some(Box::new(self.parse_expr(expr_inner)?)); + } + _ => {} + } + } + + Ok(Expr::Block(Box::new(BlockExpr { + label, + attrs, + statements, + trailing_expr, + location, + }))) + } + + // ========================================================================= + // Types + // ========================================================================= + + /// Parse a type expression. + fn parse_type_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // type_expr = { type_prefix ~ type_suffix? } + // First get the type_prefix + let prefix_pair = self.expect_next(&mut inner, &location, "type_prefix")?; + let base_type = self.parse_type_prefix(prefix_pair)?; + + // Check for type_suffix (error union: E!T where E is error, T is payload) + if let Some(suffix_pair) = inner.next() + && suffix_pair.as_rule() == Rule::type_suffix { + // type_suffix = { "!" ~ type_prefix } + // In E!T syntax: base_type is E (error), suffix is T (payload) + let payload_type_pair = self.expect_inner(suffix_pair, "type_suffix")?; + let payload_type = self.parse_type_prefix(payload_type_pair)?; + return Ok(TypeExpr::ErrorUnion(Box::new(ErrorUnionType { + error_type: base_type, + payload_type, + }))); + } + + Ok(base_type) + } + + /// Parse type_prefix. + fn parse_type_prefix(&self, pair: Pair<'a, Rule>) -> ParseResult { + // type_prefix contains one of the type alternatives + let inner = self.expect_inner(pair, "type_prefix")?; + + match inner.as_rule() { + Rule::optional_type => { + let inner_type = self.parse_type_prefix(self.expect_inner(inner, "optional_type")?)?; + Ok(TypeExpr::Optional(Box::new(inner_type))) + } + Rule::pointer_type => self.parse_pointer_type(inner), + Rule::array_type => self.parse_array_type(inner), + Rule::tuple_type => self.parse_tuple_type(inner), + Rule::fn_type => self.parse_fn_type(inner), + Rule::set_type => self.parse_set_type(inner), + Rule::struct_literal => self.parse_struct_type(inner), + Rule::enum_literal => self.parse_enum_type(inner), + Rule::builtin_type => self.parse_builtin_type(inner), + Rule::type_identifier => self.parse_type_identifier(inner), + _ => Err(ParseError { + message: format!("unexpected type {:?}", inner.as_rule()), + location: self.location(&inner), + }), + } + } + + /// Parse inline struct type: struct { x: i32, y: i32 } + fn parse_struct_type(&self, pair: Pair) -> ParseResult { + let mut is_packed = false; + let mut fields = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::packed_keyword => { + is_packed = true; + } + Rule::struct_body => { + for body_item in item.into_inner() { + if body_item.as_rule() == Rule::struct_field { + fields.push(self.parse_struct_field(body_item)?); + } + } + } + _ => {} + } + } + + Ok(TypeExpr::Struct(Box::new(InlineStructType { + fields, + is_packed, + }))) + } + + /// Parse inline enum type: enum { a, b, c } or enum(u8) { a, b, c } + fn parse_enum_type(&self, pair: Pair) -> ParseResult { + let mut tag_type = None; + let mut variants = Vec::new(); + + for item in pair.into_inner() { + match item.as_rule() { + Rule::type_expr => { + tag_type = Some(self.parse_type_expr(item)?); + } + Rule::enum_body => { + for body_item in item.into_inner() { + if body_item.as_rule() == Rule::enum_variant { + variants.push(self.parse_enum_variant(body_item)?); + } + } + } + _ => {} + } + } + + Ok(TypeExpr::Enum(Box::new(InlineEnumType { + variants, + tag_type, + }))) + } + + /// Parse pointer type. + /// Grammar: pointer_type = { pointer_prefix ~ ws ~ ("const" ~ ws)? ~ type_prefix } + /// Grammar: pointer_prefix = { "[*:" ~ ws ~ expr ~ ws ~ "]" | "[*]" | "*" } + fn parse_pointer_type(&self, pair: Pair) -> ParseResult { + let inner = pair.into_inner(); + let mut is_many = false; + let mut is_const = false; + let mut sentinel = None; + let mut pointee = None; + + for item in inner { + match item.as_rule() { + Rule::pointer_prefix => { + // Parse the pointer prefix to determine is_many and sentinel + let prefix_str = item.as_str(); + if prefix_str == "*" { + // Single pointer: *T + is_many = false; + } else if prefix_str == "[*]" { + // Many pointer without sentinel: [*]T + is_many = true; + } else { + // Sentinel-terminated many pointer: [*:expr]T + is_many = true; + // The prefix contains an expr for the sentinel value + for prefix_item in item.into_inner() { + if prefix_item.as_rule() == Rule::expr { + sentinel = Some(self.parse_expr(prefix_item)?); + } + } + } + } + Rule::type_prefix => { + pointee = Some(self.parse_type_prefix(item)?); + } + _ => { + // Check for "const" keyword + if item.as_str() == "const" { + is_const = true; + } + } + } + } + + Ok(TypeExpr::Pointer(Box::new(PointerType { + pointee: pointee.expect("pointer needs pointee"), + is_const, + is_many, + sentinel, + }))) + } + + /// Parse array type. + fn parse_array_type(&self, pair: Pair) -> ParseResult { + let inner = pair.into_inner(); + let mut size = None; + let mut sentinel = None; + let mut element = None; + + for item in inner { + match item.as_rule() { + Rule::array_size => { + // array_size = { array_size_term ~ (ws ~ array_size_op ~ ws ~ array_size_term)* } + size = Some(self.parse_array_size_expr(item)?); + } + Rule::expr => { + // This is for sentinel: [N:sentinel]T + sentinel = Some(self.parse_expr(item)?); + } + Rule::type_prefix => { + // Grammar uses type_prefix for array element type + element = Some(self.parse_type_prefix(item)?); + } + _ => {} + } + } + + Ok(TypeExpr::Array(Box::new(ArrayType { + element: element.expect("array needs element type"), + size, + sentinel, + }))) + } + + /// Parse array size expression. + /// array_size = { array_size_term ~ (ws ~ array_size_op ~ ws ~ array_size_term)* } + fn parse_array_size_expr(&self, pair: Pair) -> ParseResult { + let location = self.location(&pair); + let mut inner = pair.into_inner(); + + // Parse the first term + let first_term = inner.next().expect("array_size needs at least one term"); + let mut result = self.parse_array_size_term(first_term)?; + + // Parse remaining (op, term) pairs + while let Some(op_pair) = inner.next() { + if op_pair.as_rule() == Rule::array_size_op { + let op = match op_pair.as_str() { + "+" => BinaryOp::Add, + "-" => BinaryOp::Sub, + "*" => BinaryOp::Mul, + "/" => BinaryOp::Div, + _ => unreachable!(), + }; + + let term_pair = inner.next().expect("operator needs right operand"); + let right = self.parse_array_size_term(term_pair)?; + + result = Expr::Binary(Box::new(BinaryExpr { + left: result, + op, + right, + location: Some(location.clone()), + })); + } + } + + Ok(result) + } + + /// Parse array size term. + /// array_size_term = { number_literal | identifier | "(" ~ ws ~ array_size ~ ws ~ ")" } + fn parse_array_size_term(&self, pair: Pair) -> ParseResult { + let inner = pair.into_inner().next().expect("array_size_term needs content"); + match inner.as_rule() { + Rule::number_literal | Rule::identifier => self.parse_primary_expr(inner), + Rule::array_size => self.parse_array_size_expr(inner), + _ => unreachable!("unexpected rule in array_size_term: {:?}", inner.as_rule()), + } + } + + /// Parse function type. + fn parse_fn_type(&self, pair: Pair) -> ParseResult { + let inner = pair.into_inner(); + let mut params = Vec::new(); + let mut return_type = None; + + for item in inner { + match item.as_rule() { + Rule::type_list => { + // type_list = { type_prefix ~ (ws ~ "," ~ ws ~ type_prefix)* } + for ty in item.into_inner() { + if ty.as_rule() == Rule::type_prefix { + params.push(self.parse_type_prefix(ty)?); + } + } + } + Rule::type_prefix => { + // Return type is type_prefix + return_type = Some(self.parse_type_prefix(item)?); + } + _ => {} + } + } + + Ok(TypeExpr::Fn(Box::new(FnType { + params, + return_type, + }))) + } + + /// Parse tuple type: (T1, T2) or (T1, T2, T3, ...) + fn parse_tuple_type(&self, pair: Pair) -> ParseResult { + let mut elements = Vec::new(); + + for item in pair.into_inner() { + if item.as_rule() == Rule::type_prefix { + elements.push(self.parse_type_prefix(item)?); + } + } + + Ok(TypeExpr::Tuple(elements)) + } + + /// Parse set type: Set(T) + fn parse_set_type(&self, pair: Pair) -> ParseResult { + // set_type = { "Set" ~ ws ~ "(" ~ ws ~ type_expr ~ ws ~ ")" } + for item in pair.into_inner() { + if item.as_rule() == Rule::type_expr { + let element_type = self.parse_type_expr(item)?; + return Ok(TypeExpr::Set(Box::new(element_type))); + } + } + Err(ParseError { + message: "Set type requires element type".to_string(), + location: SourceLocation::default(), + }) + } + + /// Parse builtin type. + fn parse_builtin_type(&self, pair: Pair) -> ParseResult { + // Check for Self type first (has inner rule) + let inner = pair.clone().into_inner().next(); + if let Some(inner_pair) = inner + && inner_pair.as_rule() == Rule::self_type { + return Ok(TypeExpr::Named(TypePath { + segments: vec!["Self".to_string()], + location: Some(self.location(&pair)), + })); + } + + let s = pair.as_str(); + + // Check for quantum types first + match s { + "qubit" => return Ok(TypeExpr::Qubit), + "bit" => return Ok(TypeExpr::Bit), + "Alloc" => return Ok(TypeExpr::QAlloc(None)), + "unit" => return Ok(TypeExpr::Unit), + "type" => return Ok(TypeExpr::Type), + "anytype" => return Ok(TypeExpr::AnyType), + "bool" => return Ok(TypeExpr::Primitive(PrimitiveType::Bool)), + _ => {} + } + + // Check for integer/float types + if let Some(prim) = self.parse_primitive_type(s) { + return Ok(TypeExpr::Primitive(prim)); + } + + Err(ParseError { + message: format!("unknown builtin type: {}", s), + location: self.location(&pair), + }) + } + + /// Parse primitive type from string. + /// Supports arbitrary bit-width integers like Zig: u1, u4, u7, u128, etc. + fn parse_primitive_type(&self, s: &str) -> Option { + // Special cases first + match s { + "usize" => return Some(PrimitiveType::Usize), + "isize" => return Some(PrimitiveType::Isize), + "f16" => return Some(PrimitiveType::F16), + "f32" => return Some(PrimitiveType::F32), + "f64" => return Some(PrimitiveType::F64), + "f128" => return Some(PrimitiveType::F128), + "a64" => return Some(PrimitiveType::A64), + "bool" => return Some(PrimitiveType::Bool), + _ => {} + } + + // Arbitrary-width integers: u or i + // Valid bit widths are 1-128 + if let Some(bits_str) = s.strip_prefix('u') { + if let Ok(bits) = bits_str.parse::() + && (1..=128).contains(&bits) { + return Some(PrimitiveType::UInt { bits }); + } + } else if let Some(bits_str) = s.strip_prefix('i') + && let Ok(bits) = bits_str.parse::() + && (1..=128).contains(&bits) { + return Some(PrimitiveType::IInt { bits }); + } + + None + } + + /// Parse type identifier (named type). + fn parse_type_identifier(&self, pair: Pair) -> ParseResult { + let mut segments = Vec::new(); + for item in pair.into_inner() { + if item.as_rule() == Rule::identifier { + segments.push(item.as_str().to_string()); + } + } + + Ok(TypeExpr::Named(TypePath { + segments, + location: None, + })) + } + + /// Parse a channel argument (positional or named). + fn parse_channel_arg(&self, pair: Pair) -> ParseResult { + // channel_arg = { (identifier ~ ws ~ ":" ~ ws ~ expr) | expr } + let mut name = None; + let mut value = None; + + for item in pair.into_inner() { + match item.as_rule() { + Rule::identifier => { + name = Some(item.as_str().to_string()); + } + Rule::expr => { + value = Some(self.parse_expr(item)?); + } + _ => {} + } + } + + let expr = value.expect("channel_arg requires expression"); + + if let Some(n) = name { + Ok(ChannelArg::Named { name: n, value: expr }) + } else { + Ok(ChannelArg::Positional(expr)) + } + } + + /// Parse a gate expression (either param_gate_expr or simple_gate_expr). + fn parse_gate_expr_inner( + &self, + pair: Pair, + location: Option, + ) -> ParseResult { + let mut gate_kind = None; + let mut params = Vec::new(); + let mut target = None; + + for inner in pair.into_inner() { + match inner.as_rule() { + Rule::param_gate_keyword | Rule::simple_gate_keyword => { + gate_kind = Some(self.parse_gate_keyword(inner.as_str())?); + } + Rule::gate_params => { + // gate_params = { "(" ~ ws ~ arg_list ~ ws ~ ")" } + for param_inner in inner.into_inner() { + if param_inner.as_rule() == Rule::arg_list { + for arg in param_inner.into_inner() { + if arg.as_rule() == Rule::expr { + params.push(self.parse_expr(arg)?); + } + } + } + } + } + Rule::gate_target => { + // gate_target = { gate_set_target | tuple_expr | bracket_array | gate_qubit_target } + let target_inner = self.expect_inner(inner, "gate_target")?; + target = Some(match target_inner.as_rule() { + Rule::gate_set_target => { + // Parse as a set expression + let mut elements = Vec::new(); + for elem in target_inner.into_inner() { + if elem.as_rule() == Rule::batch_elements { + for e in elem.into_inner() { + if e.as_rule() == Rule::expr { + elements.push(self.parse_expr(e)?); + } + } + } + } + Expr::Set(Box::new(SetExpr { + elements, + element_type: None, + location: location.clone(), + })) + } + Rule::paren_or_tuple => { + self.parse_primary_expr(target_inner)? + } + Rule::bracket_array => self.parse_primary_expr(target_inner)?, + Rule::postfix_expr => self.parse_postfix_expr(target_inner)?, + Rule::gate_qubit_target => { + // gate_qubit_target = { !operator_keyword ~ postfix_expr } + // Just parse the inner postfix_expr + let inner_expr = self.expect_inner(target_inner, "gate_qubit_target")?; + self.parse_postfix_expr(inner_expr)? + } + other => { + return Err(ParseError { + message: format!("unexpected rule {:?}, expected gate_target", other), + location: location.clone().unwrap_or_default(), + }); + } + }); + } + _ => {} + } + } + + Ok(Expr::Gate(Box::new(GateExpr { + kind: gate_kind.expect("gate requires keyword"), + params, + target: target.expect("gate requires target"), + location, + }))) + } + + /// Parse gate keyword to GateKind. + fn parse_gate_keyword(&self, s: &str) -> ParseResult { + use GateKind::*; + match s { + // Single-qubit Pauli gates + "x" => Ok(X), + "y" => Ok(Y), + "z" => Ok(Z), + // Hadamard + "h" => Ok(H), + // T gates (fourth root of Z) + "t" => Ok(T), + "tdg" => Ok(Tdg), + // Square root gates + "sx" => Ok(SX), + "sy" => Ok(SY), + "sz" => Ok(SZ), + "sxdg" => Ok(SXdg), + "sydg" => Ok(SYdg), + "szdg" => Ok(SZdg), + // Rotation gates + "rx" => Ok(RX), + "ry" => Ok(RY), + "rz" => Ok(RZ), + // Two-qubit gates + "cx" => Ok(CX), + "cy" => Ok(CY), + "cz" => Ok(CZ), + "ch" => Ok(CH), + // Two-qubit rotation gates + "sxx" => Ok(SXX), + "syy" => Ok(SYY), + "szz" => Ok(SZZ), + "sxxdg" => Ok(SXXdg), + "syydg" => Ok(SYYdg), + "szzdg" => Ok(SZZdg), + "rzz" => Ok(RZZ), + "crz" => Ok(RZZ), // CRZ is effectively RZZ + // Swap gates + "swap" => Ok(SWAP), + "iswap" => Ok(ISWAP), + // Three-qubit gates + "ccx" => Ok(CCX), // Toffoli gate + // Face rotations + "f" => Ok(F), + "fdg" => Ok(Fdg), + "f4" => Ok(F4), + "f4dg" => Ok(F4dg), + // Prepare operation + "pz" => Ok(PZ), + other => { + let suggestion = suggest_gate_name(other); + let message = match suggestion { + Some(name) => format!("unknown gate '{}', did you mean '{}'?", other, name), + None => format!("unknown gate '{}'", other), + }; + Err(ParseError { + message, + location: SourceLocation::default(), + }) + } + } + } +} + +/// Known gate names for suggestions. +const KNOWN_GATE_NAMES: &[&str] = &[ + "x", "y", "z", "h", "t", "tdg", "sx", "sy", "sz", "sxdg", "sydg", "szdg", + "rx", "ry", "rz", "cx", "cy", "cz", "ch", "sxx", "syy", "szz", + "sxxdg", "syydg", "szzdg", "rzz", "crz", "swap", "iswap", "ccx", + "f", "fdg", "f4", "f4dg", "pz", +]; + +/// Deprecated gate name mappings. +const DEPRECATED_GATES: &[(&str, &str)] = &[ + ("s", "sz"), + ("sdg", "szdg"), +]; + +/// Suggest a gate name for a misspelled or deprecated gate keyword. +pub fn suggest_gate_name(unknown: &str) -> Option<&'static str> { + // Check deprecated names first + for &(old, new) in DEPRECATED_GATES { + if unknown == old { + return Some(new); + } + } + + // Find closest match by edit distance + let mut best: Option<(&str, usize)> = None; + for &name in KNOWN_GATE_NAMES { + let dist = edit_distance(unknown, name); + if dist <= 2 { + match best { + Some((_, best_dist)) if dist < best_dist => best = Some((name, dist)), + None => best = Some((name, dist)), + _ => {} + } + } + } + best.map(|(name, _)| name) +} + +/// Compute the Levenshtein edit distance between two strings. +pub fn edit_distance(a: &str, b: &str) -> usize { + let a_bytes = a.as_bytes(); + let b_bytes = b.as_bytes(); + let m = a_bytes.len(); + let n = b_bytes.len(); + + // Use single-row optimization + let mut prev = vec![0usize; n + 1]; + for j in 0..=n { + prev[j] = j; + } + + for i in 1..=m { + let mut curr = vec![0usize; n + 1]; + curr[0] = i; + for j in 1..=n { + let cost = if a_bytes[i - 1] == b_bytes[j - 1] { 0 } else { 1 }; + curr[j] = (prev[j] + 1) + .min(curr[j - 1] + 1) + .min(prev[j - 1] + cost); + } + prev = curr; + } + + prev[n] +} + +/// Parse a Zluppy source string. +pub fn parse(source: &str) -> ParseResult { + log::debug!("Parsing {} bytes of source", source.len()); + let result = ParserState::new(source).parse(); + match &result { + Ok(program) => { + log::debug!("Parsed {} top-level declarations", program.declarations.len()); + log::trace!("AST: {:?}", program); + } + Err(e) => { + log::debug!("Parse error: {}", e); + } + } + result +} + +/// Parse a Zluppy source file. +pub fn parse_file(source: &str, filename: impl Into) -> ParseResult { + let filename = filename.into(); + log::debug!("Parsing file '{}' ({} bytes)", filename, source.len()); + let result = ParserState::new(source).with_file(&filename).parse(); + match &result { + Ok(program) => { + log::debug!("Parsed {} top-level declarations from '{}'", program.declarations.len(), filename); + } + Err(e) => { + log::debug!("Parse error in '{}': {}", filename, e); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty() { + let result = parse(""); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_const() { + // Immutable binding with explicit type + let result = parse("x: u32 = 42;"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_function() { + let result = parse("fn main() -> unit { return unit; }"); + assert!(result.is_ok()); + } + + #[test] + fn test_parse_binding_function_call() { + // Mutable binding with inferred type + let source = "mut q := qalloc(2);"; + let result = parse(source); + assert!(result.is_ok(), "Parse failed: {:?}", result); + let program = result.unwrap(); + + // Check that there's exactly one declaration + assert_eq!(program.declarations.len(), 1, "Expected 1 declaration"); + + // Check that it's a binding declaration + if let TopLevelDecl::Binding(binding) = &program.declarations[0] { + assert_eq!(binding.name, "q"); + assert!(binding.is_mutable); + // Check that the value is a Call expression + if let Some(Expr::Call(call)) = &binding.value { + if let Expr::Ident(ident) = &call.callee { + assert_eq!(ident.name, "qalloc"); + } else { + panic!("Callee should be Ident, got: {:?}", call.callee); + } + assert_eq!(call.args.len(), 1, "Expected 1 argument"); + } else { + panic!("Value should be Call, got: {:?}", binding.value); + } + } else { + panic!("Should be a Binding declaration, got: {:?}", program.declarations[0]); + } + } + + #[test] + fn test_parse_true_and_expr() { + // Test that `true and y` parses as an expression + let source = "x := true and y;\n"; + let result = parse(source); + assert!(result.is_ok(), "Parse failed: {:?}", result); + } + + #[test] + fn test_parse_just_true() { + // Test just `true` as expression - this works + let source = "x := true;\n"; + let result = parse(source); + assert!(result.is_ok(), "Parse failed: {:?}", result); + } + + #[test] + fn test_parse_ident_and_true() { + // Test `y and true` - boolean on right works + let source = "x := y and true;\n"; + let result = parse(source); + assert!(result.is_ok(), "Parse failed: {:?}", result); + } + + // ========================================================================= + // Input Size Limit Tests + // ========================================================================= + + #[test] + fn test_max_source_size_constant() { + // Verify the constant is reasonable (10MB) + assert_eq!(MAX_SOURCE_SIZE, 10 * 1024 * 1024); + } + + #[test] + fn test_source_size_limit_enforced() { + // Create a source that exceeds the limit + let large_source = "x".repeat(MAX_SOURCE_SIZE + 1); + let result = parse(&large_source); + assert!(result.is_err(), "Expected error for oversized source"); + let err = result.unwrap_err(); + assert!( + err.message.contains("too large"), + "Expected 'too large' in error message: {}", + err.message + ); + } + + #[test] + fn test_normal_source_within_limit() { + // Normal source should parse fine + let source = "fn main() -> unit { return unit; }"; + assert!(source.len() < MAX_SOURCE_SIZE); + let result = parse(source); + assert!(result.is_ok(), "Expected normal source to parse: {:?}", result); + } +} diff --git a/exp/zlup/src/pretty.rs b/exp/zlup/src/pretty.rs new file mode 100644 index 000000000..66c4a23b2 --- /dev/null +++ b/exp/zlup/src/pretty.rs @@ -0,0 +1,1900 @@ +//! AST-based pretty printer for Zlup. +//! +//! Provides canonical formatting by parsing source to AST and +//! pretty-printing with consistent style rules. + +use crate::ast::*; + +/// Pretty printing options. +#[derive(Debug, Clone)] +pub struct PrettyOptions { + /// Use spaces instead of tabs. + pub use_spaces: bool, + /// Number of spaces per indent level (if using spaces). + pub indent_size: usize, + /// Maximum line length before wrapping. + pub max_line_length: usize, +} + +impl Default for PrettyOptions { + fn default() -> Self { + Self { + use_spaces: true, + indent_size: 4, + max_line_length: 100, + } + } +} + +/// AST-based pretty printer. +pub struct PrettyPrinter { + options: PrettyOptions, + output: String, + indent_level: usize, + at_line_start: bool, +} + +impl PrettyPrinter { + pub fn new(options: PrettyOptions) -> Self { + Self { + options, + output: String::new(), + indent_level: 0, + at_line_start: true, + } + } + + /// Pretty print a program. + pub fn print_program(&mut self, program: &Program) -> String { + for (i, decl) in program.declarations.iter().enumerate() { + if i > 0 { + self.newline(); + } + self.print_top_level_decl(decl); + } + self.ensure_trailing_newline(); + std::mem::take(&mut self.output) + } + + fn indent_str(&self) -> String { + if self.options.use_spaces { + " ".repeat(self.options.indent_size) + } else { + "\t".to_string() + } + } + + fn write(&mut self, s: &str) { + if self.at_line_start && !s.is_empty() { + for _ in 0..self.indent_level { + self.output.push_str(&self.indent_str()); + } + self.at_line_start = false; + } + self.output.push_str(s); + } + + fn newline(&mut self) { + self.output.push('\n'); + self.at_line_start = true; + } + + fn ensure_trailing_newline(&mut self) { + if !self.output.ends_with('\n') { + self.output.push('\n'); + } + } + + fn indent(&mut self) { + self.indent_level += 1; + } + + fn dedent(&mut self) { + if self.indent_level > 0 { + self.indent_level -= 1; + } + } + + // ========================================================================= + // Top-level declarations + // ========================================================================= + + fn print_top_level_decl(&mut self, decl: &TopLevelDecl) { + match decl { + TopLevelDecl::Binding(b) => self.print_binding(b), + TopLevelDecl::Fn(f) => self.print_fn_decl(f), + TopLevelDecl::ExternFn(f) => self.print_extern_fn_decl(f), + TopLevelDecl::Struct(s) => self.print_struct_decl(s), + TopLevelDecl::Enum(e) => self.print_enum_decl(e), + TopLevelDecl::Union(u) => self.print_union_decl(u), + TopLevelDecl::ErrorSet(e) => self.print_error_set_decl(e), + TopLevelDecl::FaultSet(f) => self.print_fault_set_decl(f), + TopLevelDecl::Test(t) => self.print_test_decl(t), + TopLevelDecl::DeclareGate(g) => { + self.write(&format!("declare gate {}(", g.name)); + for (i, p) in g.params.iter().enumerate() { + if i > 0 { self.write(", "); } + self.write(&p.name); + } + self.write(")("); + for (i, q) in g.qubits.iter().enumerate() { + if i > 0 { self.write(", "); } + self.write(&q.name); + } + self.write(");"); + self.newline(); + } + TopLevelDecl::Gate(g) => { + self.write(&format!("gate {}(", g.name)); + for (i, p) in g.params.iter().enumerate() { + if i > 0 { self.write(", "); } + self.write(&p.name); + } + self.write(")("); + for (i, q) in g.qubits.iter().enumerate() { + if i > 0 { self.write(", "); } + self.write(&q.name); + } + self.write(") "); + self.print_block(&g.body); + self.newline(); + } + } + } + + fn print_doc_comment(&mut self, doc: &Option) { + if let Some(doc) = doc { + for line in doc.lines() { + self.write("/// "); + self.write(line); + self.newline(); + } + } + } + + fn print_binding(&mut self, binding: &Binding) { + self.print_doc_comment(&binding.doc_comment); + + if binding.is_pub { + self.write("pub "); + } + if binding.is_mutable { + self.write("mut "); + } + self.write(&binding.name); + + if let Some(ty) = &binding.ty { + self.write(": "); + self.print_type_expr(ty); + } + + if let Some(value) = &binding.value { + if binding.ty.is_some() { + self.write(" = "); + } else { + self.write(" := "); + } + self.print_expr(value); + } + + self.write(";"); + self.newline(); + } + + fn print_fn_decl(&mut self, func: &FnDecl) { + self.print_doc_comment(&func.doc_comment); + + if func.is_pub { + self.write("pub "); + } + if func.is_inline { + self.write("inline "); + } + + self.write("fn "); + self.write(&func.name); + self.write("("); + + for (i, param) in func.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_param(param); + } + + self.write(")"); + + if let Some(ret) = &func.return_type { + self.write(" -> "); + self.print_type_expr(ret); + } + + self.write(" "); + self.print_block(&func.body); + self.newline(); + } + + fn print_param(&mut self, param: &Param) { + // Check for Rust-style self parameter + if param.name == "self" + && let TypeExpr::Pointer(ptr) = ¶m.ty + && let TypeExpr::Named(path) = &ptr.pointee + && path.segments == vec!["Self".to_string()] { + // This is a self parameter - print as &self or &mut self + if ptr.is_const { + self.write("&self"); + } else { + self.write("&mut self"); + } + return; + } + + // Regular parameter + if param.is_comptime { + self.write("comptime "); + } + self.write(¶m.name); + self.write(": "); + self.print_type_expr(¶m.ty); + } + + fn print_extern_fn_decl(&mut self, func: &ExternFnDecl) { + self.print_doc_comment(&func.doc_comment); + + if let Some(lib) = &func.library { + self.write("@link(\""); + self.write(lib); + self.write("\") "); + } + + if func.is_pub { + self.write("pub "); + } + + self.write("extern \""); + self.write(&func.calling_convention); + self.write("\" fn "); + self.write(&func.name); + self.write("("); + + for (i, param) in func.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_param(param); + } + + self.write(")"); + + if let Some(ret) = &func.return_type { + self.write(" -> "); + self.print_type_expr(ret); + } + + self.write(";"); + self.newline(); + } + + fn print_struct_decl(&mut self, s: &StructDecl) { + self.print_doc_comment(&s.doc_comment); + + if s.is_pub { + self.write("pub "); + } + + self.write("const "); + self.write(&s.name); + self.write(" = "); + + if s.is_packed { + self.write("packed "); + } + self.write("struct {"); + + if s.fields.is_empty() && s.methods.is_empty() && s.associated_consts.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for field in &s.fields { + self.print_struct_field(field); + } + + for method in &s.methods { + self.newline(); + self.print_fn_decl(method); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_struct_field(&mut self, field: &StructField) { + self.print_doc_comment(&field.doc_comment); + self.write(&field.name); + self.write(": "); + self.print_type_expr(&field.ty); + + if let Some(default) = &field.default { + self.write(" = "); + self.print_expr(default); + } + + self.write(","); + self.newline(); + } + + fn print_enum_decl(&mut self, e: &EnumDecl) { + self.print_doc_comment(&e.doc_comment); + + if e.is_pub { + self.write("pub "); + } + + self.write("const "); + self.write(&e.name); + self.write(" = enum"); + + if let Some(tag) = &e.tag_type { + self.write("("); + self.print_type_expr(tag); + self.write(")"); + } + + self.write(" {"); + + if e.variants.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for variant in &e.variants { + self.write(&variant.name); + if let Some(val) = &variant.value { + self.write(" = "); + self.print_expr(val); + } + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_union_decl(&mut self, u: &UnionDecl) { + self.print_doc_comment(&u.doc_comment); + + if u.is_pub { + self.write("pub "); + } + + self.write("const "); + self.write(&u.name); + self.write(" = union"); + + match &u.tag { + Some(Some(ty)) => { + self.write("("); + self.print_type_expr(ty); + self.write(")"); + } + Some(None) => { + self.write("(enum)"); + } + None => {} + } + + self.write(" {"); + + if u.fields.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for field in &u.fields { + self.write(&field.name); + if let Some(ty) = &field.ty { + self.write(": "); + self.print_type_expr(ty); + } + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_error_set_decl(&mut self, e: &ErrorSetDecl) { + self.print_doc_comment(&e.doc_comment); + + if e.is_pub { + self.write("pub "); + } + + self.write(&e.name); + self.write(" := error {"); + + if e.variants.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for variant in &e.variants { + self.write(&variant.name); + if let Some(ty) = &variant.data_type { + self.write(": "); + self.print_type_expr(ty); + } + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_fault_set_decl(&mut self, f: &FaultSetDecl) { + self.print_doc_comment(&f.doc_comment); + + if f.is_pub { + self.write("pub "); + } + + self.write(&f.name); + self.write(" := fault {"); + + if f.variants.is_empty() { + self.write("};"); + } else { + self.newline(); + self.indent(); + + for variant in &f.variants { + self.write(&variant.name); + if let Some(ty) = &variant.data_type { + self.write(": "); + self.print_type_expr(ty); + } + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("};"); + } + self.newline(); + } + + fn print_test_decl(&mut self, t: &TestDecl) { + self.write("test \""); + self.write(&t.name); + self.write("\" "); + self.print_block(&t.body); + self.newline(); + } + + // ========================================================================= + // Statements + // ========================================================================= + + fn print_stmt(&mut self, stmt: &Stmt) { + match stmt { + Stmt::Binding(b) => self.print_binding(b), + Stmt::Alias(a) => self.print_alias_binding(a), + Stmt::Assign(a) => self.print_assign_stmt(a), + Stmt::If(i) => self.print_if_stmt(i), + Stmt::For(f) => self.print_for_stmt(f), + Stmt::Switch(s) => self.print_switch_stmt(s), + Stmt::Tick(t) => self.print_tick_stmt(t), + Stmt::TryBlock(t) => self.print_try_block_stmt(t), + Stmt::Return(r) => self.print_return_stmt(r), + Stmt::Break(b) => self.print_break_stmt(b), + Stmt::Continue(c) => self.print_continue_stmt(c), + Stmt::Defer(d) => self.print_defer_stmt(d), + Stmt::Errdefer(e) => self.print_errdefer_stmt(e), + Stmt::Block(b) => { + self.print_block(b); + self.newline(); + } + Stmt::Expr(e) => self.print_expr_stmt(e), + Stmt::Gate(g) => self.print_gate_op(g), + Stmt::Prepare(p) => self.print_prepare_op(p), + Stmt::Measure(m) => self.print_measure_op(m), + Stmt::Barrier(b) => self.print_barrier_op(b), + } + } + + fn print_alias_binding(&mut self, a: &AliasBinding) { + self.write("alias "); + self.write(&a.name); + self.write(" := "); + self.print_expr(&a.source); + self.write(";"); + self.newline(); + } + + fn print_assign_stmt(&mut self, a: &AssignStmt) { + self.print_expr(&a.target); + self.write(" "); + self.write(match a.op { + AssignOp::Assign => "=", + AssignOp::AddAssign => "+=", + AssignOp::SubAssign => "-=", + AssignOp::MulAssign => "*=", + AssignOp::DivAssign => "/=", + AssignOp::AndAssign => "&=", + AssignOp::OrAssign => "|=", + AssignOp::XorAssign => "^=", + }); + self.write(" "); + self.print_expr(&a.value); + self.write(";"); + self.newline(); + } + + fn print_if_stmt(&mut self, i: &IfStmt) { + self.write("if ("); + self.print_expr(&i.condition); + self.write(")"); + + if let Some(cap) = &i.capture { + self.write(" |"); + self.write(cap); + self.write("|"); + } + + self.write(" "); + self.print_block(&i.then_body); + + if let Some(else_branch) = &i.else_body { + self.write(" else "); + match else_branch { + ElseBranch::ElseIf(elif) => self.print_if_stmt(elif), + ElseBranch::Else(block) => { + self.print_block(block); + self.newline(); + } + } + } else { + self.newline(); + } + } + + fn print_for_stmt(&mut self, f: &ForStmt) { + if let Some(label) = &f.label { + self.write(label); + self.write(": "); + } + + if f.is_inline { + self.write("inline "); + } + + self.write("for "); + + // Print captures (loop variables) first: for i, j in ... + if f.captures.is_empty() { + self.write("_ "); + } else { + for (i, cap) in f.captures.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(cap); + } + self.write(" "); + } + + self.write("in "); + + match &f.range { + ForRange::Range { start, end } => { + self.print_expr(start); + self.write(".."); + self.print_expr(end); + } + ForRange::Collection(coll) => { + self.print_expr(coll); + } + } + + self.write(" "); + self.print_block(&f.body); + self.newline(); + } + + fn print_switch_stmt(&mut self, s: &SwitchStmt) { + self.write("switch ("); + self.print_expr(&s.value); + self.write(") {"); + self.newline(); + self.indent(); + + for prong in &s.prongs { + if prong.is_else { + self.write("else"); + } else { + for (i, case) in prong.cases.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(&case.value); + if let Some(end) = &case.end { + self.write(".."); + self.print_expr(end); + } + } + } + self.write(" => "); + self.print_expr(&prong.body); + self.write(","); + self.newline(); + } + + self.dedent(); + self.write("}"); + self.newline(); + } + + fn print_tick_stmt(&mut self, t: &TickStmt) { + // Print attributes + for attr in &t.attrs { + self.print_attribute(attr); + self.newline(); + } + + self.write("tick"); + if let Some(label) = &t.label { + self.write("(\""); + self.write(label); + self.write("\")"); + } + self.write(" {"); + + if t.body.is_empty() { + self.write("}"); + } else { + self.newline(); + self.indent(); + + for stmt in &t.body { + self.print_stmt(stmt); + } + + self.dedent(); + self.write("}"); + } + self.newline(); + } + + fn print_try_block_stmt(&mut self, t: &TryBlockStmt) { + match t.mode { + TryMode::Collect => self.write("try "), + TryMode::Propagate => self.write("try! "), + } + self.print_block(&t.body); + + if let Some(catch) = &t.catch_clause { + self.write(" catch |"); + self.write(&catch.capture); + self.write("| "); + self.print_expr(&catch.body); + } + self.newline(); + } + + fn print_return_stmt(&mut self, r: &ReturnStmt) { + self.write("return"); + // Simplify `return unit;` to `return;` for cleaner output + if let Some(val) = &r.value { + if !matches!(val, Expr::Unit(_)) { + self.write(" "); + self.print_expr(val); + } + } + self.write(";"); + self.newline(); + } + + fn print_break_stmt(&mut self, b: &BreakStmt) { + self.write("break"); + if let Some(label) = &b.label { + self.write(" :"); + self.write(label); + } + if let Some(val) = &b.value { + self.write(" "); + self.print_expr(val); + } + self.write(";"); + self.newline(); + } + + fn print_continue_stmt(&mut self, c: &ContinueStmt) { + self.write("continue"); + if let Some(label) = &c.label { + self.write(" :"); + self.write(label); + } + self.write(";"); + self.newline(); + } + + fn print_defer_stmt(&mut self, d: &DeferStmt) { + self.write("defer "); + // Defer body is printed inline, not as a full statement + self.print_stmt_inline(&d.body); + self.newline(); + } + + fn print_errdefer_stmt(&mut self, e: &ErrDeferStmt) { + self.write("errdefer"); + if let Some(cap) = &e.capture { + self.write(" |"); + self.write(cap); + self.write("|"); + } + self.write(" "); + self.print_stmt_inline(&e.body); + self.newline(); + } + + fn print_stmt_inline(&mut self, stmt: &Stmt) { + // Print statement without trailing newline + match stmt { + Stmt::Expr(e) => { + self.print_expr(&e.expr); + self.write(";"); + } + Stmt::Block(b) => self.print_block(b), + _ => self.print_stmt(stmt), + } + } + + fn print_expr_stmt(&mut self, e: &ExprStmt) { + // Print attributes + for attr in &e.attrs { + self.print_attribute(attr); + self.newline(); + } + + self.print_expr(&e.expr); + self.write(";"); + self.newline(); + } + + fn print_attribute(&mut self, attr: &Attribute) { + self.write("@"); + self.write(&attr.name); + if let Some(val) = &attr.value { + self.write("("); + match val { + AttributeValue::Bool(b) => self.write(if *b { "true" } else { "false" }), + AttributeValue::Int(i) => self.write(&i.to_string()), + AttributeValue::Float(f) => self.write(&f.to_string()), + AttributeValue::String(s) => { + self.write("\""); + self.write(s); + self.write("\""); + } + AttributeValue::Ident(i) => self.write(i), + } + self.write(")"); + } + } + + // ========================================================================= + // Quantum operations + // ========================================================================= + + fn print_gate_op(&mut self, g: &GateOp) { + self.write(&format!("{:?}", g.kind).to_lowercase()); + + if !g.params.is_empty() { + self.write("("); + for (i, param) in g.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(param); + } + self.write(")"); + } + + self.write(" "); + + if g.targets.len() == 1 { + self.print_slot_ref(&g.targets[0]); + } else { + self.write("("); + for (i, target) in g.targets.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_slot_ref(target); + } + self.write(")"); + } + + self.write(";"); + self.newline(); + } + + fn print_slot_ref(&mut self, slot: &SlotRef) { + self.write(&slot.allocator); + self.write("["); + self.print_expr(&slot.index); + self.write("]"); + } + + fn print_prepare_op(&mut self, p: &PrepareOp) { + self.write("pz "); + if let Some(slots) = &p.slots { + // pz {q[0], q[1], ...}; + self.write("{"); + for (i, slot) in slots.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&format!("{}[{}]", p.allocator, slot)); + } + self.write("}"); + } else { + // pz q; + self.write(&p.allocator); + } + self.write(";"); + self.newline(); + } + + fn print_measure_op(&mut self, m: &MeasureOp) { + self.write("measure("); + for (i, target) in m.targets.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_slot_ref(target); + } + self.write(")"); + + if !m.results.is_empty() { + self.write(" -> "); + for (i, result) in m.results.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&result.register); + self.write("["); + self.print_expr(&result.index); + self.write("]"); + } + } + + self.write(";"); + self.newline(); + } + + fn print_barrier_op(&mut self, b: &BarrierOp) { + self.write("barrier("); + for (i, alloc) in b.allocators.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(alloc); + } + self.write(");"); + self.newline(); + } + + // ========================================================================= + // Blocks + // ========================================================================= + + fn print_block(&mut self, block: &Block) { + // Print block attributes + for attr in &block.attrs { + self.print_attribute(attr); + self.newline(); + } + + if let Some(label) = &block.label { + self.write(label); + self.write(": "); + } + + self.write("{"); + + if block.statements.is_empty() && block.trailing_expr.is_none() { + self.write("}"); + } else { + self.newline(); + self.indent(); + + for stmt in &block.statements { + self.print_stmt(stmt); + } + + if let Some(expr) = &block.trailing_expr { + self.print_expr(expr); + self.newline(); + } + + self.dedent(); + self.write("}"); + } + } + + // ========================================================================= + // Expressions + // ========================================================================= + + fn print_expr(&mut self, expr: &Expr) { + match expr { + Expr::IntLit(lit) => { + self.write(&lit.value.to_string()); + if let Some(suffix) = &lit.suffix { + self.write("_"); + self.write(suffix); + } + } + Expr::FloatLit(lit) => { + self.write(&lit.value.to_string()); + if let Some(suffix) = &lit.suffix { + self.write("_"); + self.write(suffix); + } + } + Expr::AngleLit(angle) => { + self.print_expr(&angle.value); + self.write(" "); + self.write(match angle.unit { + AngleUnit::Turns => "turns", + AngleUnit::Rad => "rad", + }); + } + Expr::TypeAscription(asc) => { + self.print_expr(&asc.value); + self.write(" "); + self.write(&asc.type_name); + } + Expr::BoolLit(lit) => { + self.write(if lit.value { "true" } else { "false" }); + } + Expr::StringLit(lit) => { + self.write("\""); + self.write(&escape_string(&lit.value)); + self.write("\""); + } + Expr::FString(fstr) => { + self.write("f\""); + for part in &fstr.parts { + match part { + FStringPart::Text(text) => { + self.write(&escape_fstring_text(text)); + } + FStringPart::Expr { expr, format } => { + self.write("{"); + self.print_expr(expr); + if let Some(fmt) = format { + self.write(":"); + self.write(fmt); + } + self.write("}"); + } + } + } + self.write("\""); + } + Expr::CharLit(lit) => { + self.write("'"); + self.write(&escape_char(lit.value)); + self.write("'"); + } + Expr::Null(_) => self.write("none"), + Expr::Undefined(_) => self.write("undefined"), + Expr::Unit(_) => self.write("unit"), + Expr::Ident(ident) => self.write(&ident.name), + Expr::SlotRef(slot) => self.print_slot_ref(slot), + Expr::BitRef(bit) => { + self.write(&bit.register); + self.write("["); + self.print_expr(&bit.index); + self.write("]"); + } + Expr::Binary(bin) => self.print_binary_expr(bin), + Expr::Unary(un) => self.print_unary_expr(un), + Expr::Field(field) => { + self.print_expr(&field.object); + self.write("."); + self.write(&field.field); + } + Expr::Index(idx) => { + self.print_expr(&idx.object); + self.write("["); + self.print_expr(&idx.index); + self.write("]"); + } + Expr::Call(call) => { + self.print_expr(&call.callee); + self.write("("); + for (i, arg) in call.args.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(arg); + } + self.write(")"); + } + Expr::BatchApply(batch) => { + self.print_expr(&batch.operation); + self.write(" {"); + for (i, target) in batch.targets.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(target); + } + self.write("}"); + } + Expr::If(if_expr) => { + self.write("if ("); + self.print_expr(&if_expr.condition); + self.write(") "); + self.print_expr(&if_expr.then_expr); + self.write(" else "); + self.print_expr(&if_expr.else_expr); + } + Expr::Block(block) => { + for attr in &block.attrs { + self.print_attribute(attr); + self.write(" "); + } + self.write(&block.label); + self.write(": {"); + if block.statements.is_empty() && block.trailing_expr.is_none() { + self.write("}"); + } else { + self.newline(); + self.indent(); + for stmt in &block.statements { + self.print_stmt(stmt); + } + if let Some(expr) = &block.trailing_expr { + self.print_expr(expr); + self.newline(); + } + self.dedent(); + self.write("}"); + } + } + Expr::Comptime(ct) => { + self.write("comptime "); + self.print_expr(&ct.inner); + } + Expr::Builtin(bi) => { + self.write("@"); + self.write(&bi.name); + self.write("("); + for (i, arg) in bi.args.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(arg); + } + self.write(")"); + } + Expr::AnonStruct(anon) => { + if anon.is_packed { + self.write("packed "); + } + self.write("struct {"); + if anon.fields.is_empty() { + self.write("}"); + } else { + self.newline(); + self.indent(); + for field in &anon.fields { + self.print_struct_field(field); + } + self.dedent(); + self.write("}"); + } + } + Expr::StructInit(init) => { + if let Some(ty) = &init.ty { + self.print_type_expr(ty); + self.write(" "); + } else { + // Anonymous struct uses .{ } syntax + self.write("."); + } + self.write("{"); + for (i, field) in init.fields.iter().enumerate() { + if i > 0 { + self.write(", "); + } + // Check for shorthand: field name matches identifier value + let is_shorthand = matches!(&field.value, Expr::Ident(ident) if ident.name == field.name); + if is_shorthand { + // Shorthand: just `name` instead of `name: name` + self.write(&field.name); + } else { + // Rust-style: `name: value` + self.write(&field.name); + self.write(": "); + self.print_expr(&field.value); + } + } + self.write("}"); + } + Expr::ArrayInit(init) => { + if let Some(ty) = &init.ty { + self.print_type_expr(ty); + } + self.write("{"); + for (i, elem) in init.elements.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(elem); + } + self.write("}"); + } + Expr::BracketArray(arr) => { + self.write("["); + for (i, elem) in arr.elements.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(elem); + } + self.write("]"); + } + Expr::Tuple(tuple) => { + self.write("("); + for (i, elem) in tuple.elements.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(elem); + } + self.write(")"); + } + Expr::Set(set) => { + if let Some(ty) = &set.element_type { + self.write("Set("); + self.print_type_expr(ty); + self.write(")"); + } + self.write("{"); + for (i, elem) in set.elements.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(elem); + } + self.write("}"); + } + Expr::Range(range) => { + if let Some(start) = &range.start { + self.print_expr(start); + } + self.write(".."); + if let Some(end) = &range.end { + self.print_expr(end); + } + } + Expr::Measure(m) => { + self.write("mz("); + if m.pack { + self.write("pack "); + } + self.print_type_expr(&m.result_type); + self.write(") "); + self.print_expr(&m.targets); + } + Expr::Gate(g) => { + self.write(&format!("{:?}", g.kind).to_lowercase()); + if !g.params.is_empty() { + self.write("("); + for (i, param) in g.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_expr(param); + } + self.write(")"); + } + self.write(" "); + self.print_expr(&g.target); + } + Expr::ErrorValue(err) => { + self.write("error."); + self.write(&err.name); + } + Expr::FaultValue(fault) => { + self.write("fault."); + self.write(&fault.name); + } + Expr::Catch(c) => { + self.print_expr(&c.operand); + self.write(" catch"); + if let Some(cap) = &c.capture { + self.write(" |"); + self.write(cap); + self.write("|"); + } + self.write(" "); + self.print_expr(&c.handler); + } + Expr::TryBlock(t) => { + match t.mode { + TryMode::Collect => self.write("try "), + TryMode::Propagate => self.write("try! "), + } + self.print_block(&t.body); + if let Some(catch) = &t.catch_clause { + self.write(" catch |"); + self.write(&catch.capture); + self.write("| "); + self.print_expr(&catch.body); + } + } + Expr::FnLit(f) => { + self.write("fn("); + for (i, param) in f.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_param(param); + } + self.write(")"); + if let Some(ret) = &f.return_type { + self.write(" -> "); + self.print_type_expr(ret); + } + self.write(" "); + self.print_block(&f.body); + } + Expr::Channel(channel) => { + self.write("@emit."); + self.write(&channel.channel); + self.write("."); + self.write(&channel.command); + self.write("("); + for (i, arg) in channel.args.iter().enumerate() { + if i > 0 { + self.write(", "); + } + match arg { + ChannelArg::Positional(expr) => { + self.print_expr(expr); + } + ChannelArg::Named { name, value } => { + self.write(name); + self.write(": "); + self.print_expr(value); + } + } + } + self.write(")"); + } + Expr::Result(result) => { + self.write("result(\""); + self.write(&escape_string(&result.tag)); + self.write("\", "); + self.print_expr(&result.value); + self.write(")"); + } + } + } + + fn print_binary_expr(&mut self, bin: &BinaryExpr) { + let needs_parens = matches!( + bin.op, + BinaryOp::And | BinaryOp::Or | BinaryOp::Orelse | BinaryOp::Catch + ); + + if needs_parens { + self.write("("); + } + + self.print_expr(&bin.left); + + self.write(" "); + self.write(match bin.op { + BinaryOp::Add => "+", + BinaryOp::Sub => "-", + BinaryOp::Mul => "*", + BinaryOp::Div => "/", + BinaryOp::Mod => "%", + BinaryOp::Eq => "==", + BinaryOp::Ne => "!=", + BinaryOp::Lt => "<", + BinaryOp::Le => "<=", + BinaryOp::Gt => ">", + BinaryOp::Ge => ">=", + BinaryOp::In => "in", + BinaryOp::NotIn => "not in", + BinaryOp::And => "and", + BinaryOp::Or => "or", + BinaryOp::Orelse => "orelse", + BinaryOp::Catch => "catch", + BinaryOp::BitAnd => "&", + BinaryOp::BitOr => "|", + BinaryOp::BitXor => "^", + BinaryOp::Shl => "<<", + BinaryOp::Shr => ">>", + }); + self.write(" "); + + self.print_expr(&bin.right); + + if needs_parens { + self.write(")"); + } + } + + fn print_unary_expr(&mut self, un: &UnaryExpr) { + match un.op { + UnaryOp::Neg => self.write("-"), + UnaryOp::Not => self.write("!"), + UnaryOp::BitNot => self.write("~"), + UnaryOp::AddrOf => self.write("&"), + UnaryOp::Deref => self.write("*"), + UnaryOp::OptionalUnwrap => { + self.print_expr(&un.operand); + self.write(".?"); + return; + } + UnaryOp::ErrorUnwrap => { + self.print_expr(&un.operand); + self.write(".!"); + return; + } + UnaryOp::Try => self.write("try "), + } + self.print_expr(&un.operand); + } + + // ========================================================================= + // Types + // ========================================================================= + + fn print_type_expr(&mut self, ty: &TypeExpr) { + match ty { + TypeExpr::Primitive(p) => self.print_primitive_type(p), + TypeExpr::Qubit => self.write("qubit"), + TypeExpr::Bit => self.write("bit"), + TypeExpr::QAlloc(cap) => { + self.write("qalloc"); + if let Some(c) = cap { + self.write("("); + self.print_expr(c); + self.write(")"); + } + } + TypeExpr::Array(arr) => { + self.write("["); + if let Some(size) = &arr.size { + self.print_expr(size); + } + self.write("]"); + self.print_type_expr(&arr.element); + } + TypeExpr::Pointer(ptr) => { + if ptr.is_many { + self.write("[*"); + } else { + self.write("*"); + } + if ptr.is_const { + self.write("const "); + } + self.print_type_expr(&ptr.pointee); + } + TypeExpr::Optional(inner) => { + self.write("?"); + self.print_type_expr(inner); + } + TypeExpr::ErrorUnion(eu) => { + self.print_type_expr(&eu.error_type); + self.write("!"); + self.print_type_expr(&eu.payload_type); + } + TypeExpr::CollectedErrors(ce) => { + self.write("[]"); + self.print_type_expr(&ce.error_type); + self.write("!"); + self.print_type_expr(&ce.payload_type); + } + TypeExpr::Fn(f) => { + self.write("fn("); + for (i, param) in f.params.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_type_expr(param); + } + self.write(")"); + if let Some(ret) = &f.return_type { + self.write(" -> "); + self.print_type_expr(ret); + } + } + TypeExpr::Tuple(types) => { + self.write("("); + for (i, t) in types.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.print_type_expr(t); + } + self.write(")"); + } + TypeExpr::Set(elem) => { + self.write("Set("); + self.print_type_expr(elem); + self.write(")"); + } + TypeExpr::Named(path) => { + for (i, seg) in path.segments.iter().enumerate() { + if i > 0 { + self.write("."); + } + self.write(seg); + } + } + TypeExpr::Type => self.write("type"), + TypeExpr::AnyType => self.write("anytype"), + TypeExpr::Unit => self.write("unit"), + TypeExpr::Struct(s) => { + if s.is_packed { + self.write("packed "); + } + self.write("struct { "); + for (i, field) in s.fields.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&field.name); + self.write(": "); + self.print_type_expr(&field.ty); + } + self.write(" }"); + } + TypeExpr::Enum(e) => { + self.write("enum "); + if let Some(tag) = &e.tag_type { + self.write("("); + self.print_type_expr(tag); + self.write(") "); + } + self.write("{ "); + for (i, variant) in e.variants.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(&variant.name); + if let Some(val) = &variant.value { + self.write(" = "); + self.print_expr(val); + } + } + self.write(" }"); + } + } + } + + fn print_primitive_type(&mut self, p: &PrimitiveType) { + match p { + PrimitiveType::UInt { bits } => { + self.write("u"); + self.write(&bits.to_string()); + } + PrimitiveType::IInt { bits } => { + self.write("i"); + self.write(&bits.to_string()); + } + PrimitiveType::Usize => self.write("usize"), + PrimitiveType::Isize => self.write("isize"), + PrimitiveType::F16 => self.write("f16"), + PrimitiveType::F32 => self.write("f32"), + PrimitiveType::F64 => self.write("f64"), + PrimitiveType::F128 => self.write("f128"), + PrimitiveType::A64 => self.write("a64"), + PrimitiveType::Bool => self.write("bool"), + } + } +} + +// ============================================================================= +// String escaping helpers +// ============================================================================= + +fn escape_string(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + match c { + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + c if c.is_control() => { + result.push_str(&format!("\\x{:02x}", c as u32)); + } + c => result.push(c), + } + } + result +} + +fn escape_char(c: char) -> String { + match c { + '\n' => "\\n".to_string(), + '\r' => "\\r".to_string(), + '\t' => "\\t".to_string(), + '\\' => "\\\\".to_string(), + '\'' => "\\'".to_string(), + c if c.is_control() => format!("\\x{:02x}", c as u32), + c => c.to_string(), + } +} + +/// Escape text inside f-strings (also escapes { and }) +fn escape_fstring_text(s: &str) -> String { + let mut result = String::new(); + for c in s.chars() { + match c { + '\n' => result.push_str("\\n"), + '\r' => result.push_str("\\r"), + '\t' => result.push_str("\\t"), + '\\' => result.push_str("\\\\"), + '"' => result.push_str("\\\""), + '{' => result.push_str("\\{"), + '}' => result.push_str("\\}"), + c if c.is_control() => { + result.push_str(&format!("\\x{:02x}", c as u32)); + } + c => result.push(c), + } + } + result +} + +// ============================================================================= +// Public API +// ============================================================================= + +/// Format a Zlup program AST to canonical string form. +pub fn pretty_print(program: &Program, options: &PrettyOptions) -> String { + let mut printer = PrettyPrinter::new(options.clone()); + printer.print_program(program) +} + +/// Format Zlup source code using AST-based pretty printing. +/// +/// This parses the source to an AST and pretty-prints it, providing +/// more accurate formatting than text-based approaches. +/// +/// Returns `None` if the source cannot be parsed. +pub fn format_source(source: &str, options: &PrettyOptions) -> Option { + match crate::parser::parse(source) { + Ok(program) => Some(pretty_print(&program, options)), + Err(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser::parse; + + fn format(source: &str) -> String { + let program = parse(source).expect("Failed to parse"); + pretty_print(&program, &PrettyOptions::default()) + } + + #[test] + fn test_simple_function() { + let source = "fn main() -> unit { return unit; }"; + let formatted = format(source); + assert!(formatted.contains("fn main() -> unit")); + assert!(formatted.contains("return;")); + } + + #[test] + fn test_function_with_params() { + let source = "fn add(a: u32, b: u32) -> u32 { return a + b; }"; + let formatted = format(source); + assert!(formatted.contains("fn add(a: u32, b: u32) -> u32")); + } + + #[test] + fn test_binding_with_type() { + let source = "x: u32 = 42;"; + let formatted = format(source); + assert!(formatted.contains("x: u32 = 42;")); + } + + #[test] + fn test_binding_inferred() { + let source = "x := 42;"; + let formatted = format(source); + assert!(formatted.contains("x := 42;")); + } + + #[test] + fn test_mutable_binding() { + let source = "mut x := 42;"; + let formatted = format(source); + assert!(formatted.contains("mut x := 42;")); + } + + #[test] + fn test_if_statement() { + let source = "fn test() -> unit { if (x == 1) { y := 2; } return unit; }"; + let formatted = format(source); + assert!(formatted.contains("if")); + assert!(formatted.contains("x == 1")); + assert!(formatted.contains("y := 2;")); + } + + #[test] + fn test_if_else() { + let source = "fn test() -> unit { if (x) { a := 1; } else { b := 2; } return unit; }"; + let formatted = format(source); + assert!(formatted.contains("if (x)")); + assert!(formatted.contains("else {")); + } + + #[test] + fn test_for_loop() { + let source = "fn test() -> unit { for i in 0..10 { x := i; } }"; + let formatted = format(source); + assert!(formatted.contains("for")); + assert!(formatted.contains("0..10")); + } + + #[test] + fn test_quantum_gate() { + let source = "fn test() -> unit { h q[0]; }"; + let formatted = format(source); + assert!(formatted.contains("h q[0]")); + } + + #[test] + fn test_two_qubit_gate() { + let source = "fn test() -> unit { cx (q[0], q[1]); }"; + let formatted = format(source); + assert!(formatted.contains("cx (q[0], q[1])")); + } + + #[test] + fn test_parameterized_gate() { + let source = "fn test() -> unit { rx(0.5) q[0]; }"; + let formatted = format(source); + assert!(formatted.contains("rx(0.5) q[0]")); + } + + #[test] + fn test_tick_block() { + let source = "fn test() -> unit { tick { h q[0]; cx (q[0], q[1]); } }"; + let formatted = format(source); + assert!(formatted.contains("tick {")); + } + + #[test] + fn test_indentation() { + let source = "fn main() -> unit { if (true) { x := 1; } return unit; }"; + let formatted = format(source); + // Check that nested content is indented - just verify content is present + assert!(formatted.contains("if (true)")); + assert!(formatted.contains("x := 1;")); + } + + #[test] + fn test_binary_operators() { + let source = "fn test() -> unit { x := a + b * c; }"; + let formatted = format(source); + assert!(formatted.contains("a + b * c")); + } + + #[test] + fn test_comparison_operators() { + let source = "fn test() -> unit { if (x == 1) { y := 2; } return unit; }"; + let formatted = format(source); + assert!(formatted.contains("x == 1")); + } + + #[test] + fn test_array_literal() { + let source = "fn test() -> unit { arr := [1, 2, 3]; }"; + let formatted = format(source); + assert!(formatted.contains("[1, 2, 3]")); + } + + #[test] + fn test_tuple() { + let source = "fn test() -> unit { t := (1, 2, 3); }"; + let formatted = format(source); + assert!(formatted.contains("(1, 2, 3)")); + } + + #[test] + fn test_string_literal() { + let source = r#"fn test() -> unit { s := "hello"; }"#; + let formatted = format(source); + assert!(formatted.contains(r#""hello""#)); + } + + #[test] + fn test_string_escapes() { + let source = r#"fn test() -> unit { s := "hello\nworld"; }"#; + let formatted = format(source); + assert!(formatted.contains(r#"\n"#)); + } + + #[test] + fn test_pub_function() { + let source = "pub fn exported() -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("pub fn exported()")); + } + + #[test] + fn test_inline_function() { + let source = "inline fn fast() -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("inline fn fast()")); + } + + #[test] + fn test_field_access() { + // Test field access works correctly + let source = "fn test() -> unit { x := obj.field; return unit; }"; + let formatted = format(source); + assert!(formatted.contains("obj.field")); + } + + #[test] + fn test_custom_indent() { + let source = "fn main() -> unit { x := 1; }"; + let options = PrettyOptions { + indent_size: 2, + ..Default::default() + }; + let program = parse(source).unwrap(); + let formatted = pretty_print(&program, &options); + assert!(formatted.contains(" x := 1;")); // 2 spaces + } + + #[test] + fn test_tabs() { + let source = "fn main() -> unit { x := 1; }"; + let options = PrettyOptions { + use_spaces: false, + ..Default::default() + }; + let program = parse(source).unwrap(); + let formatted = pretty_print(&program, &options); + assert!(formatted.contains("\tx := 1;")); // tab + } + + #[test] + fn test_trailing_newline() { + let source = "fn main() -> unit { }"; + let formatted = format(source); + assert!(formatted.ends_with('\n')); + } + + #[test] + fn test_empty_block() { + let source = "fn empty() -> unit {}"; + let formatted = format(source); + assert!(formatted.contains("{}")); + } + + #[test] + fn test_multiple_functions() { + let source = "fn a() -> unit { } fn b() -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("fn a()")); + assert!(formatted.contains("fn b()")); + } + + #[test] + fn test_extern_fn() { + let source = r#"extern "C" fn puts(s: [*]const u8) -> i32;"#; + let formatted = format(source); + assert!(formatted.contains(r#"extern "C" fn puts"#)); + } + + #[test] + fn test_builtin_call() { + let source = "fn test() -> unit { x := @sizeOf(u32); }"; + let formatted = format(source); + assert!(formatted.contains("@sizeOf(u32)")); + } + + #[test] + fn test_return_statement() { + let source = "fn test() -> u32 { return 42; }"; + let formatted = format(source); + assert!(formatted.contains("return 42;")); + } + + #[test] + fn test_break_continue() { + let source = "fn test() -> unit { for i in 0..10 { break; } return unit; }"; + let formatted = format(source); + assert!(formatted.contains("break;")); + } + + #[test] + fn test_deeply_nested() { + let source = "fn main() -> unit { if (a) { if (b) { if (c) { x := 1; } return unit; } return unit; } return unit; }"; + let formatted = format(source); + // Should have proper nesting - just check content is preserved + assert!(formatted.contains("if (a)")); + assert!(formatted.contains("if (b)")); + assert!(formatted.contains("if (c)")); + assert!(formatted.contains("x := 1;")); + } + + #[test] + fn test_measurement() { + let source = "fn test() -> unit { r := mz(u8) q; }"; + let formatted = format(source); + assert!(formatted.contains("mz(u8) q")); + } + + #[test] + fn test_optional_type() { + let source = "fn test(x: ?u32) -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("?u32")); + } + + #[test] + fn test_pointer_type() { + let source = "fn test(p: *u32) -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("*u32")); + } + + #[test] + fn test_array_type() { + let source = "fn test(arr: [10]u32) -> unit { }"; + let formatted = format(source); + assert!(formatted.contains("[10]u32")); + } + + #[test] + fn test_format_source_returns_none_on_invalid() { + let result = format_source("fn broken(", &PrettyOptions::default()); + assert!(result.is_none()); + } + + #[test] + fn test_format_source_works_on_valid() { + let result = format_source("fn main() -> unit {}", &PrettyOptions::default()); + assert!(result.is_some()); + assert!(result.unwrap().contains("fn main()")); + } +} diff --git a/exp/zlup/src/rational.rs b/exp/zlup/src/rational.rs new file mode 100644 index 000000000..01868757f --- /dev/null +++ b/exp/zlup/src/rational.rs @@ -0,0 +1,844 @@ +//! Rational number type for exact fraction representation. +//! +//! This module provides a `Rational` type that represents fractions exactly, +//! avoiding floating-point precision issues. This is particularly important +//! for angle calculations in quantum computing where angles like 1/4 turn +//! (pi/2 radians) must be exact. +//! +//! # Examples +//! +//! ``` +//! use zlup::rational::Rational; +//! +//! let quarter = Rational::new(1, 4); +//! let half = Rational::new(1, 2); +//! assert_eq!(quarter + quarter, half); +//! ``` + +use std::cmp::Ordering; +use std::fmt; +use std::ops::{Add, Div, Mul, Neg, Sub}; + +/// A rational number represented as numerator/denominator. +/// +/// Rationals are always stored in lowest terms with a positive denominator. +#[derive(Clone, Copy, Eq, PartialEq, Hash)] +pub struct Rational { + /// Numerator (can be negative) + num: i64, + /// Denominator (always positive, never zero) + den: u64, +} + +impl Rational { + /// Create a new rational number, automatically reducing to lowest terms. + pub fn new(numerator: i64, denominator: i64) -> Self { + if denominator == 0 { + panic!("Rational denominator cannot be zero"); + } + + // Normalize sign: denominator is always positive + let (num, den) = if denominator < 0 { + (-numerator, (-denominator) as u64) + } else { + (numerator, denominator as u64) + }; + + // Reduce to lowest terms + let g = gcd(num.unsigned_abs(), den); + Self { + num: num / g as i64, + den: den / g, + } + } + + /// Create a rational from an integer. + pub fn from_int(n: i64) -> Self { + Self { num: n, den: 1 } + } + + /// Create zero. + pub const ZERO: Rational = Rational { num: 0, den: 1 }; + + /// Create one. + pub const ONE: Rational = Rational { num: 1, den: 1 }; + + /// Create one half. + pub const HALF: Rational = Rational { num: 1, den: 2 }; + + /// Create one quarter. + pub const QUARTER: Rational = Rational { num: 1, den: 4 }; + + /// Create one eighth. + pub const EIGHTH: Rational = Rational { num: 1, den: 8 }; + + /// Get the numerator. + pub fn numerator(&self) -> i64 { + self.num + } + + /// Get the denominator. + pub fn denominator(&self) -> u64 { + self.den + } + + /// Check if this is zero. + pub fn is_zero(&self) -> bool { + self.num == 0 + } + + /// Check if this is an integer. + pub fn is_integer(&self) -> bool { + self.den == 1 + } + + /// Convert to an integer if exact, otherwise None. + pub fn to_integer(&self) -> Option { + if self.den == 1 { + Some(self.num) + } else { + None + } + } + + /// Convert to f64. + pub fn to_f64(&self) -> f64 { + self.num as f64 / self.den as f64 + } + + /// Try to convert an f64 to a rational. + /// + /// Uses continued fraction approximation to find a rational with + /// denominator up to `max_denominator` that approximates the float. + pub fn from_f64(value: f64, max_denominator: u64) -> Option { + if !value.is_finite() { + return None; + } + + // Handle negative values + if value < 0.0 { + return Self::from_f64(-value, max_denominator).map(|r| -r); + } + + // Handle zero + if value == 0.0 { + return Some(Self::ZERO); + } + + // Handle integers + if value == value.floor() && value.abs() < i64::MAX as f64 { + return Some(Self::from_int(value as i64)); + } + + // Continued fraction approximation + let mut x = value; + let a0 = x.floor() as i64; + + // Build convergents + let mut h_prev: i64 = 1; + let mut k_prev: u64 = 0; + let mut h_curr: i64 = a0; + let mut k_curr: u64 = 1; + + const MAX_ITERATIONS: usize = 50; + const TOLERANCE: f64 = 1e-15; + + for _ in 0..MAX_ITERATIONS { + let frac = x - x.floor(); + if frac.abs() < TOLERANCE { + break; + } + + x = 1.0 / frac; + let a = x.floor() as i64; + + // Compute next convergent + let h_next = a.saturating_mul(h_curr).saturating_add(h_prev); + let k_next = (a as u64).saturating_mul(k_curr).saturating_add(k_prev); + + if k_next > max_denominator { + break; + } + + h_prev = h_curr; + k_prev = k_curr; + h_curr = h_next; + k_curr = k_next; + + // Check if we've converged + let approx = h_curr as f64 / k_curr as f64; + if (approx - value).abs() < TOLERANCE { + break; + } + } + + Some(Self::new(h_curr, k_curr as i64)) + } + + /// Try to recognize a common fraction from a float value. + /// + /// Returns Some if the value is very close to a common fraction + /// like 1/2, 1/4, 1/8, 1/3, etc. + pub fn from_f64_common(value: f64) -> Option { + const TOLERANCE: f64 = 1e-12; + + // Common fractions to check + static COMMON: &[(f64, i64, u64)] = &[ + (0.0, 0, 1), + (1.0, 1, 1), + (0.5, 1, 2), + (0.25, 1, 4), + (0.125, 1, 8), + (0.0625, 1, 16), + (0.75, 3, 4), + (0.375, 3, 8), + (0.625, 5, 8), + (0.875, 7, 8), + // Thirds + (1.0 / 3.0, 1, 3), + (2.0 / 3.0, 2, 3), + // Sixths + (1.0 / 6.0, 1, 6), + (5.0 / 6.0, 5, 6), + // Twelfths + (1.0 / 12.0, 1, 12), + (5.0 / 12.0, 5, 12), + (7.0 / 12.0, 7, 12), + (11.0 / 12.0, 11, 12), + ]; + + // Handle negative + let (abs_value, sign) = if value < 0.0 { + (-value, -1i64) + } else { + (value, 1i64) + }; + + // Check for common fractions + for &(frac_val, num, den) in COMMON { + if (abs_value - frac_val).abs() < TOLERANCE { + return Some(Self { + num: sign * num, + den, + }); + } + } + + // Check for fractions with small denominators (1-16) + for den in 1u64..=16 { + let num = (abs_value * den as f64).round() as i64; + if num >= 0 { + let approx = num as f64 / den as f64; + if (approx - abs_value).abs() < TOLERANCE { + return Some(Self::new(sign * num, den as i64)); + } + } + } + + None + } + + /// Convert an f64 to its exact rational representation. + /// + /// Every IEEE 754 float is exactly representable as a rational number + /// (specifically, a dyadic rational with power-of-2 denominator). + /// This is similar to Python's `fractions.Fraction.from_float()`. + /// + /// Note: The resulting rational may have a very large denominator. + /// Use `limit_denominator()` to find a simpler approximation. + /// + /// # Examples + /// + /// ``` + /// use zlup::rational::Rational; + /// + /// // Exact representations + /// assert_eq!(Rational::from_f64_exact(0.5), Some(Rational::new(1, 2))); + /// assert_eq!(Rational::from_f64_exact(0.25), Some(Rational::new(1, 4))); + /// + /// // 0.1 is not exactly representable in binary, so we get the exact + /// // IEEE 754 representation as a rational + /// let r = Rational::from_f64_exact(0.1).unwrap(); + /// assert_eq!(r.to_f64(), 0.1); // Round-trips exactly + /// ``` + pub fn from_f64_exact(value: f64) -> Option { + if !value.is_finite() { + return None; + } + + if value == 0.0 { + return Some(Self::ZERO); + } + + // Handle negative values + let (abs_value, sign) = if value < 0.0 { + (-value, -1i64) + } else { + (value, 1i64) + }; + + // Decompose the float into mantissa and exponent + // f64 = mantissa * 2^exponent where mantissa is in [1, 2) + // But we want the integer mantissa representation + let bits = abs_value.to_bits(); + let exponent_bits = ((bits >> 52) & 0x7FF) as i32; + let mantissa_bits = bits & 0x000F_FFFF_FFFF_FFFF; + + if exponent_bits == 0 { + // Subnormal number + // Value = mantissa_bits * 2^(-1022 - 52) + let num = sign * (mantissa_bits as i64); + let exp = 1022 + 52; + // Denominator is 2^exp, which is huge for subnormals + // We'll simplify by dividing out common factors of 2 + return Self::from_mantissa_exp(num, exp); + } + + // Normal number + // The implicit leading 1 bit: mantissa = 1.mantissa_bits + // So integer mantissa = (1 << 52) | mantissa_bits + let int_mantissa = (1u64 << 52) | mantissa_bits; + let exponent = exponent_bits - 1023 - 52; // Subtract bias and mantissa bits + + let num = sign * (int_mantissa as i64); + + if exponent >= 0 { + // Value = mantissa * 2^exponent (integer result) + if exponent < 63 { + Some(Self::from_int(num << exponent)) + } else { + // Too large, would overflow + None + } + } else { + // Value = mantissa / 2^(-exponent) + Self::from_mantissa_exp(num, (-exponent) as u32) + } + } + + /// Helper: create rational from mantissa / 2^exp, reducing common factors + fn from_mantissa_exp(mantissa: i64, exp: u32) -> Option { + if mantissa == 0 { + return Some(Self::ZERO); + } + + // Count trailing zeros in mantissa to reduce the fraction + let trailing_zeros = (mantissa.unsigned_abs()).trailing_zeros(); + let reduced_mantissa = mantissa >> trailing_zeros; + let reduced_exp = exp.saturating_sub(trailing_zeros); + + if reduced_exp > 62 { + // Denominator would overflow u64 + return None; + } + + let denominator = 1u64 << reduced_exp; + Some(Self { + num: reduced_mantissa, + den: denominator, + }) + } + + /// Find the closest rational with denominator at most `max_denominator`. + /// + /// Similar to Python's `Fraction.limit_denominator()`. Useful for + /// simplifying exact float conversions to human-readable fractions. + /// + /// # Examples + /// + /// ``` + /// use zlup::rational::Rational; + /// + /// // The exact representation of 0.1 has a huge denominator + /// let exact = Rational::from_f64_exact(0.1).unwrap(); + /// + /// // Limit to denominator <= 10 gives us 1/10 + /// let simple = exact.limit_denominator(10); + /// assert_eq!(simple, Rational::new(1, 10)); + /// ``` + pub fn limit_denominator(&self, max_denominator: u64) -> Self { + if self.den <= max_denominator { + return *self; + } + + // Use continued fraction algorithm to find best approximation + // This is the standard algorithm from Python's fractions module + let mut p0: i64 = 0; + let mut q0: u64 = 1; + let mut p1: i64 = 1; + let mut q1: u64 = 0; + + let mut n = self.num.abs(); + let mut d = self.den; + + loop { + let a = n / d as i64; + let q2 = q0 + (a as u64) * q1; + + if q2 > max_denominator { + break; + } + + let p2 = p0 + a * p1; + p0 = p1; + q0 = q1; + p1 = p2; + q1 = q2; + + let new_n = d as i64; + d = (n % d as i64) as u64; + n = new_n; + + if d == 0 { + break; + } + } + + // Choose between p1/q1 and the mediant + let k = (max_denominator - q0) / q1; + let bound1 = Self::new(p0 + (k as i64) * p1, (q0 + k * q1) as i64); + let bound2 = Self::new(p1, q1 as i64); + + let abs_self = self.abs(); + let diff1 = (abs_self - bound1).abs(); + let diff2 = (abs_self - bound2).abs(); + + let result = if diff1 <= diff2 { bound1 } else { bound2 }; + + if self.num < 0 { + -result + } else { + result + } + } + + /// Smart float-to-rational conversion. + /// + /// Tries multiple strategies in order of preference: + /// 1. Exact integer check + /// 2. Common fractions (1/2, 1/3, 1/4, etc.) + /// 3. Exact IEEE 754 conversion, limited to reasonable denominator + /// 4. Continued fraction approximation + /// + /// This is the recommended method for converting floats to rationals. + pub fn from_f64_best(value: f64, max_denominator: u64) -> Option { + if !value.is_finite() { + return None; + } + + // 1. Check for zero + if value == 0.0 { + return Some(Self::ZERO); + } + + // 2. Check for exact integer + if value == value.floor() && value.abs() < i64::MAX as f64 { + return Some(Self::from_int(value as i64)); + } + + // 3. Check for common fractions (most quantum angles) + if let Some(r) = Self::from_f64_common(value) + && r.den <= max_denominator { + return Some(r); + } + + // 4. Try exact conversion with limit + if let Some(exact) = Self::from_f64_exact(value) { + let limited = exact.limit_denominator(max_denominator); + // Verify it's a good approximation + if (limited.to_f64() - value).abs() < 1e-15 * value.abs().max(1.0) { + return Some(limited); + } + } + + // 5. Fall back to continued fraction approximation + Self::from_f64(value, max_denominator) + } + + /// Return the absolute value. + pub fn abs(&self) -> Self { + Self { + num: self.num.abs(), + den: self.den, + } + } + + /// Return the reciprocal (1/self). + pub fn recip(&self) -> Self { + if self.num == 0 { + panic!("Cannot take reciprocal of zero"); + } + if self.num > 0 { + Self { + num: self.den as i64, + den: self.num as u64, + } + } else { + Self { + num: -(self.den as i64), + den: (-self.num) as u64, + } + } + } + + /// Try to recognize a float value as a rational multiple of pi. + /// + /// If `value ≈ (n/d) * pi`, returns `Some((n, d))`. + /// This is useful for converting radians to turns while preserving precision. + pub fn from_f64_pi_multiple(value: f64) -> Option<(i64, u64)> { + use std::f64::consts::PI; + const TOLERANCE: f64 = 1e-12; + + if !value.is_finite() { + return None; + } + + let (abs_value, sign) = if value < 0.0 { + (-value, -1i64) + } else { + (value, 1i64) + }; + + // Try to express as (n/d) * pi for small denominators + for d in 1u64..=16 { + let n_float = abs_value * d as f64 / PI; + let n = n_float.round() as i64; + + if n > 0 { + let expected = n as f64 * PI / d as f64; + if (expected - abs_value).abs() < TOLERANCE * abs_value.max(1.0) { + let r = Self::new(sign * n, d as i64); + return Some((r.num, r.den)); + } + } + } + + None + } + + /// Convert a radian value (as a float) to turns as a Rational. + /// + /// Detects if the radian value is a rational multiple of pi and preserves + /// that precision. If `radians = (n/d) * pi`, then `turns = n / (2*d)`. + pub fn radians_to_turns(radians: f64) -> Option { + // First try to detect if this is a rational multiple of pi + if let Some((n, d)) = Self::from_f64_pi_multiple(radians) { + // (n/d) * pi radians = n / (2*d) turns + return Some(Self::new(n, 2 * d as i64)); + } + + // Fall back to direct conversion + let turns = radians / (2.0 * std::f64::consts::PI); + Self::from_f64_best(turns, 1000) + } + + /// Convert turns (as a Rational) to radians as a float. + pub fn turns_to_radians(&self) -> f64 { + self.to_f64() * 2.0 * std::f64::consts::PI + } +} + +impl fmt::Debug for Rational { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.den == 1 { + write!(f, "Rational({})", self.num) + } else { + write!(f, "Rational({}/{})", self.num, self.den) + } + } +} + +impl fmt::Display for Rational { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.den == 1 { + write!(f, "{}", self.num) + } else { + write!(f, "{}/{}", self.num, self.den) + } + } +} + +impl Default for Rational { + fn default() -> Self { + Self::ZERO + } +} + +impl From for Rational { + fn from(n: i64) -> Self { + Self::from_int(n) + } +} + +impl From for Rational { + fn from(n: i32) -> Self { + Self::from_int(n as i64) + } +} + +impl PartialOrd for Rational { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Rational { + fn cmp(&self, other: &Self) -> Ordering { + // a/b compared to c/d: compare a*d to c*b + let lhs = self.num as i128 * other.den as i128; + let rhs = other.num as i128 * self.den as i128; + lhs.cmp(&rhs) + } +} + +impl Add for Rational { + type Output = Self; + + fn add(self, other: Self) -> Self { + // a/b + c/d = (a*d + c*b) / (b*d) + let num = self.num as i128 * other.den as i128 + other.num as i128 * self.den as i128; + let den = self.den as i128 * other.den as i128; + Self::new(num as i64, den as i64) + } +} + +impl Sub for Rational { + type Output = Self; + + fn sub(self, other: Self) -> Self { + self + (-other) + } +} + +impl Mul for Rational { + type Output = Self; + + fn mul(self, other: Self) -> Self { + // a/b * c/d = (a*c) / (b*d) + // Cross-reduce first to avoid overflow + let g1 = gcd(self.num.unsigned_abs(), other.den); + let g2 = gcd(other.num.unsigned_abs(), self.den); + + let num = (self.num / g1 as i64) * (other.num / g2 as i64); + let den = (self.den / g2) * (other.den / g1); + + Self { num, den } + } +} + +impl Div for Rational { + type Output = Self; + + // Using multiplication by reciprocal is the standard way to implement + // division for rationals: a/b ÷ c/d = a/b × d/c + #[allow(clippy::suspicious_arithmetic_impl)] + fn div(self, other: Self) -> Self { + self * other.recip() + } +} + +impl Neg for Rational { + type Output = Self; + + fn neg(self) -> Self { + Self { + num: -self.num, + den: self.den, + } + } +} + +/// Greatest common divisor using Euclidean algorithm. +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + let t = b; + b = a % b; + a = t; + } + a.max(1) // Ensure we never return 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_reduces() { + let r = Rational::new(2, 4); + assert_eq!(r.numerator(), 1); + assert_eq!(r.denominator(), 2); + } + + #[test] + fn test_negative_denominator() { + let r = Rational::new(1, -2); + assert_eq!(r.numerator(), -1); + assert_eq!(r.denominator(), 2); + } + + #[test] + fn test_add() { + let a = Rational::new(1, 4); + let b = Rational::new(1, 4); + assert_eq!(a + b, Rational::new(1, 2)); + } + + #[test] + fn test_sub() { + let a = Rational::new(1, 2); + let b = Rational::new(1, 4); + assert_eq!(a - b, Rational::new(1, 4)); + } + + #[test] + fn test_mul() { + let a = Rational::new(2, 3); + let b = Rational::new(3, 4); + assert_eq!(a * b, Rational::new(1, 2)); + } + + #[test] + fn test_div() { + let a = Rational::new(1, 2); + let b = Rational::new(1, 4); + assert_eq!(a / b, Rational::new(2, 1)); + } + + #[test] + fn test_to_f64() { + let r = Rational::new(1, 4); + assert_eq!(r.to_f64(), 0.25); + } + + #[test] + fn test_from_f64_common() { + assert_eq!(Rational::from_f64_common(0.25), Some(Rational::new(1, 4))); + assert_eq!(Rational::from_f64_common(0.125), Some(Rational::new(1, 8))); + assert_eq!(Rational::from_f64_common(0.5), Some(Rational::new(1, 2))); + assert_eq!(Rational::from_f64_common(1.0 / 3.0), Some(Rational::new(1, 3))); + } + + #[test] + fn test_from_f64_approximation() { + let r = Rational::from_f64(0.333333333, 100).unwrap(); + assert_eq!(r, Rational::new(1, 3)); + } + + #[test] + fn test_comparison() { + let a = Rational::new(1, 3); + let b = Rational::new(1, 4); + assert!(a > b); + } + + #[test] + fn test_display() { + assert_eq!(format!("{}", Rational::new(1, 4)), "1/4"); + assert_eq!(format!("{}", Rational::new(3, 1)), "3"); + } + + #[test] + fn test_from_f64_exact_dyadic() { + // Dyadic fractions (power of 2 denominators) are exactly representable + assert_eq!(Rational::from_f64_exact(0.5), Some(Rational::new(1, 2))); + assert_eq!(Rational::from_f64_exact(0.25), Some(Rational::new(1, 4))); + assert_eq!(Rational::from_f64_exact(0.125), Some(Rational::new(1, 8))); + assert_eq!(Rational::from_f64_exact(0.0625), Some(Rational::new(1, 16))); + assert_eq!(Rational::from_f64_exact(0.75), Some(Rational::new(3, 4))); + assert_eq!(Rational::from_f64_exact(0.375), Some(Rational::new(3, 8))); + } + + #[test] + fn test_from_f64_exact_roundtrip() { + // Any float should round-trip through exact conversion + let values = [0.1, 0.2, 0.3, 0.7, 1.1, 3.14159, 0.123456789]; + for &v in &values { + let r = Rational::from_f64_exact(v).unwrap(); + assert_eq!(r.to_f64(), v, "Round-trip failed for {}", v); + } + } + + #[test] + fn test_from_f64_exact_negative() { + assert_eq!(Rational::from_f64_exact(-0.5), Some(Rational::new(-1, 2))); + assert_eq!(Rational::from_f64_exact(-0.25), Some(Rational::new(-1, 4))); + } + + #[test] + fn test_from_f64_exact_special() { + assert_eq!(Rational::from_f64_exact(0.0), Some(Rational::ZERO)); + assert_eq!(Rational::from_f64_exact(f64::NAN), None); + assert_eq!(Rational::from_f64_exact(f64::INFINITY), None); + assert_eq!(Rational::from_f64_exact(f64::NEG_INFINITY), None); + } + + #[test] + fn test_limit_denominator() { + // 0.1 exact representation has large denominator + let exact = Rational::from_f64_exact(0.1).unwrap(); + assert!(exact.denominator() > 10, "0.1 exact should have large denominator"); + + // Limit to 10 should give 1/10 + let limited = exact.limit_denominator(10); + assert_eq!(limited, Rational::new(1, 10)); + + // Verify it's close enough + assert!((limited.to_f64() - 0.1).abs() < 1e-10); + } + + #[test] + fn test_limit_denominator_already_small() { + let r = Rational::new(1, 4); + let limited = r.limit_denominator(100); + assert_eq!(limited, Rational::new(1, 4)); + } + + #[test] + fn test_limit_denominator_pi_approximations() { + // Pi ≈ 3.14159... + let pi_exact = Rational::from_f64_exact(std::f64::consts::PI).unwrap(); + + // Famous approximations: + // 22/7 ≈ 3.142857 (denominator 7) + let approx_7 = pi_exact.limit_denominator(10); + assert_eq!(approx_7, Rational::new(22, 7)); + + // 333/106 ≈ 3.141509 (denominator 106) + let approx_1000 = pi_exact.limit_denominator(1000); + assert!((approx_1000.to_f64() - std::f64::consts::PI).abs() < 0.0001); + } + + #[test] + fn test_from_f64_best() { + // Common fractions should be recognized + assert_eq!(Rational::from_f64_best(0.25, 100), Some(Rational::new(1, 4))); + assert_eq!(Rational::from_f64_best(0.5, 100), Some(Rational::new(1, 2))); + assert_eq!(Rational::from_f64_best(1.0 / 3.0, 100), Some(Rational::new(1, 3))); + + // Integers + assert_eq!(Rational::from_f64_best(5.0, 100), Some(Rational::new(5, 1))); + + // Arbitrary value should get best approximation + let r = Rational::from_f64_best(0.1, 100).unwrap(); + assert_eq!(r, Rational::new(1, 10)); + } + + #[test] + fn test_quantum_angle_fractions() { + // Common quantum computing angles as fractions of a turn + // T-gate: 1/8 turn + assert_eq!(Rational::from_f64_best(0.125, 100), Some(Rational::new(1, 8))); + + // S-gate: 1/4 turn + assert_eq!(Rational::from_f64_best(0.25, 100), Some(Rational::new(1, 4))); + + // Z-gate: 1/2 turn + assert_eq!(Rational::from_f64_best(0.5, 100), Some(Rational::new(1, 2))); + + // T-dagger: 7/8 turn + assert_eq!(Rational::from_f64_best(0.875, 100), Some(Rational::new(7, 8))); + + // S-dagger: 3/4 turn + assert_eq!(Rational::from_f64_best(0.75, 100), Some(Rational::new(3, 4))); + } +} diff --git a/exp/zlup/src/semantic.rs b/exp/zlup/src/semantic.rs new file mode 100644 index 000000000..f201254b3 --- /dev/null +++ b/exp/zlup/src/semantic.rs @@ -0,0 +1,8812 @@ +//! Semantic analysis for Zluppy programs. +//! +//! This module performs: +//! - Name resolution and scope management +//! - Type checking and inference +//! - Comptime evaluation +//! - Quantum resource validation (for HUGR codegen) +//! - **Qubit state tracking** (prepared/unprepared lifecycle) +//! - **Allocator capacity validation** (bounds checking) +//! - **Loop bound checking** (NASA Power of 10 compliance) +//! +//! ## Qubit State Tracking +//! +//! Zluppy tracks qubit states at compile time. Every qubit slot has exactly +//! two states: +//! +//! ```text +//! ┌────────────┐ prepare() ┌──────────┐ +//! │ unprepared │ ──────────> │ prepared │ +//! └────────────┘ └──────────┘ +//! ^ │ +//! │ measure() │ +//! └──────────────────────────┘ +//! ``` +//! +//! - **unprepared**: Initial state, or after measurement +//! - **prepared**: Ready for gate operations +//! +//! Gates on unprepared qubits are compile-time errors. This is the allocator +//! model's answer to Guppy's linear types - simpler, explicit, same safety. +//! +//! The semantic analysis produces a typed AST and symbol table that +//! can be used by code generators (HUGR, SLR-AST, etc.). + +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use thiserror::Error; + +use crate::module::{ModuleLoader, ExportedSymbol}; +use crate::ast::{ + self, BinaryOp, Binding, Block, ElseBranch, Expr, FnDecl, ForRange, FStringPart, + PrimitiveType, Program, SourceLocation, Stmt, StructDecl, TopLevelDecl, TypeExpr, + UnaryOp, +}; +use crate::comptime::{ComptimeEvaluator, ComptimeValue}; + +// ============================================================================= +// Semantic Errors +// ============================================================================= + +/// Semantic analysis errors. +#[derive(Debug, Clone, Error)] +pub enum SemanticError { + #[error("undefined symbol '{name}'")] + UndefinedSymbol { + name: String, + location: SourceLocation, + }, + + #[error("symbol '{name}' already defined")] + DuplicateSymbol { + name: String, + location: SourceLocation, + }, + + #[error("type mismatch: expected {expected}, found {found}")] + TypeMismatch { + expected: String, + found: String, + location: SourceLocation, + }, + + #[error("cannot infer type for '{name}'")] + CannotInferType { + name: String, + location: SourceLocation, + }, + + #[error("empty array literal requires explicit type annotation: use `[]: [0]T` or provide elements")] + EmptyArrayNeedsType { location: SourceLocation }, + + #[error("empty set literal requires explicit type annotation: use `set{{}} as Set(T)` or provide elements")] + EmptySetNeedsType { location: SourceLocation }, + + #[error("invalid integer bit width {bits}: must be between 1 and 128")] + InvalidBitWidth { bits: u16, location: SourceLocation }, + + #[error("gate '{gate}' requires {expected} qubits, got {found}")] + GateArityMismatch { + gate: String, + expected: usize, + found: usize, + location: SourceLocation, + }, + + #[error("ambiguous target for multi-qubit gate '{gate}': use explicit qubit pairs like '{gate} (q[0], q[1])' or batch '{gate} {{(q[0], q[1]), ...}}'")] + AmbiguousGateTarget { gate: String, location: SourceLocation }, + + #[error("invalid gate syntax: use '{gate} {hint}' instead of '{gate}(...)'")] + InvalidGateSyntax { + gate: String, + hint: String, + location: SourceLocation, + }, + + #[error("invalid qubit reference")] + InvalidQubitRef { location: SourceLocation }, + + #[error("allocator '{name}' not found")] + AllocatorNotFound { + name: String, + location: SourceLocation, + }, + + #[error("comptime evaluation failed: {message}")] + ComptimeError { + message: String, + location: SourceLocation, + }, + + #[error("function '{name}' not found")] + FunctionNotFound { + name: String, + location: SourceLocation, + }, + + #[error("cannot call non-function type")] + NotCallable { location: SourceLocation }, + + #[error("wrong number of arguments: expected {expected}, got {found}")] + ArgumentCountMismatch { + expected: usize, + found: usize, + location: SourceLocation, + }, + + #[error("{message}")] + Other { message: String }, + + #[error("module error: {message}")] + ModuleError { + message: String, + location: SourceLocation, + }, + + // ========================================================================= + // Qubit State Errors (Zluppy-specific safety) + // ========================================================================= + #[error("qubit '{allocator}[{index}]' is not prepared - call prepare() first")] + QubitNotPrepared { + allocator: String, + index: usize, + location: SourceLocation, + }, + + #[error("qubit '{allocator}[{index}]' is already prepared")] + QubitAlreadyPrepared { + allocator: String, + index: usize, + location: SourceLocation, + }, + + #[error("qubit index {index} out of bounds for allocator '{allocator}' (capacity: {capacity})")] + QubitIndexOutOfBounds { + allocator: String, + index: usize, + capacity: usize, + location: SourceLocation, + }, + + #[error("array index {index} out of bounds for array of size {size}")] + ArrayIndexOutOfBounds { + index: usize, + size: u64, + location: SourceLocation, + }, + + #[error("cannot call .child() on immutable allocator '{name}' - declare with 'mut' to partition: mut {name} := qalloc(...)")] + ChildRequiresMutableParent { + name: String, + location: SourceLocation, + }, + + #[error("cannot assign to immutable variable '{name}' - declare with 'mut' to allow modification: mut {name} := ...")] + ImmutableAssignment { + name: String, + location: SourceLocation, + }, + + // ========================================================================= + // NASA Power of 10 Errors + // ========================================================================= + #[error("unbounded loop detected - use bounded 'for' loops instead")] + UnboundedLoop { location: SourceLocation }, + + #[error("loop bound too large ({bound}) - maximum allowed is {max}")] + LoopBoundTooLarge { + bound: usize, + max: usize, + location: SourceLocation, + }, + + #[error("recursion detected in function '{name}' - recursion is not allowed")] + RecursionDetected { + name: String, + location: SourceLocation, + }, + + #[error("invalid measurement type '{ty}' - expected u1, u8, u64, []u1, []u8, or []u64")] + InvalidMeasurementType { + ty: String, + location: SourceLocation, + }, + + #[error("measurement requires type and target arguments")] + MeasurementMissingArgs { location: SourceLocation }, + + #[error("deprecated measurement syntax: use 'mz(T) target' instead of 'mz(T, target)'")] + DeprecatedMeasurementSyntax { location: SourceLocation }, + + #[error("deprecated syntax: use '{new}' instead of '{old}'")] + DeprecatedSyntax { + old: String, + new: String, + location: SourceLocation, + }, + + #[error("measurement type mismatch: declared [{declared}]{element} but measuring {actual} qubit(s)")] + MeasurementSizeMismatch { + declared: String, + element: String, + actual: usize, + location: SourceLocation, + }, + + #[error("single qubit measurement requires scalar type (e.g., 'mz(u1) q[0]'), not array type")] + MeasurementScalarExpected { location: SourceLocation }, + + #[error("multiple qubit measurement requires array type (e.g., 'mz([2]u1) [q[0], q[1]]')")] + MeasurementArrayExpected { location: SourceLocation }, + + #[error("pack mode: type {ty} has {capacity} bits but measuring {qubits} qubit(s)")] + MeasurementPackCapacity { + ty: String, + capacity: usize, + qubits: usize, + location: SourceLocation, + }, + + #[error("pack mode requires compile-time verifiable type size, but '{ty}' has unknown bit capacity")] + MeasurementPackUnknownSize { + ty: String, + location: SourceLocation, + }, + + #[error("qubit '{allocator}[{index}]' used multiple times within tick block - parallel operations cannot target the same qubit")] + DuplicateQubitInTick { + allocator: String, + index: usize, + location: SourceLocation, + }, + + #[error("nested tick blocks are not allowed - a tick is an atomic time slice")] + NestedTick { location: SourceLocation }, + + #[error("duplicate qubit in measurement: {allocator}[{index}]")] + DuplicateQubitInMeasurement { + allocator: String, + index: usize, + location: SourceLocation, + }, + + #[error("{keyword} outside of loop")] + BreakContinueOutsideLoop { + keyword: String, + location: SourceLocation, + }, + + #[error("inline for range must be comptime-evaluable, but '{expr}' cannot be evaluated at compile time")] + InlineForRangeNotComptime { + expr: String, + location: SourceLocation, + }, + + #[error("'break' is not allowed in inline for loops - inline for is unrolled at compile time")] + BreakInInlineFor { location: SourceLocation }, + + #[error("'continue' is not allowed in inline for loops - inline for is unrolled at compile time")] + ContinueInInlineFor { location: SourceLocation }, + + #[error("alias '{new_alias}' overlaps with existing alias '{existing_alias}' on source '{source_var}'")] + OverlappingAlias { + new_alias: String, + existing_alias: String, + source_var: String, + overlap_range: String, + location: SourceLocation, + }, + + #[error("alias source must be a slice expression (e.g., arr[0..4]), found '{found}'")] + AliasSourceNotSlice { + found: String, + location: SourceLocation, + }, + + #[error("alias range must be comptime-evaluable for overlap checking, but '{expr}' cannot be evaluated at compile time")] + AliasRangeNotComptime { + expr: String, + location: SourceLocation, + }, + + #[error("missing return statement in function '{name}' - all code paths must have explicit returns (use 'return unit;' for unit functions)")] + MissingReturn { + name: String, + location: SourceLocation, + }, + + #[error("'return;' without a value is only allowed in functions that return unit, but this function returns '{expected}'")] + ReturnWithoutValue { + expected: String, + location: SourceLocation, + }, + + #[error("'catch' can only be used on error union types (T!E), found '{found}'")] + CatchOnNonErrorType { + found: String, + location: SourceLocation, + }, + + #[error("undefined type '{name}'")] + UndefinedType { + name: String, + location: SourceLocation, + }, + + #[error("type could not be fully resolved: '{ty}' contains unresolved type variables")] + UnresolvedType { + ty: String, + context: String, + location: SourceLocation, + }, + + #[error("symbol table limit exceeded: {count} symbols exceeds maximum of {max}")] + SymbolTableLimitExceeded { count: usize, max: usize }, + + #[error("scope nesting limit exceeded: {depth} levels exceeds maximum of {max}")] + ScopeNestingLimitExceeded { depth: usize, max: usize }, + + #[error("duplicate case value '{value}' in switch statement")] + DuplicateSwitchCase { + value: String, + location: SourceLocation, + }, + + // ========================================================================= + // Reference Safety Errors (safe-by-constraint memory model) + // ========================================================================= + #[error("cannot return reference to local variable '{name}' - local variables are deallocated when the function returns")] + ReturnReferenceToLocal { + name: String, + location: SourceLocation, + }, + + #[error("cannot return slice of local array '{name}' - local arrays are deallocated when the function returns")] + ReturnSliceOfLocal { + name: String, + location: SourceLocation, + }, + + #[error("cannot store reference to local '{name}' in outer scope - would create dangling reference")] + ReferenceEscapesScope { + name: String, + location: SourceLocation, + }, +} + +impl SemanticError { + /// Get the source location of the error, if available. + pub fn location(&self) -> Option<&SourceLocation> { + match self { + Self::UndefinedSymbol { location, .. } => Some(location), + Self::DuplicateSymbol { location, .. } => Some(location), + Self::TypeMismatch { location, .. } => Some(location), + Self::CannotInferType { location, .. } => Some(location), + Self::EmptyArrayNeedsType { location } => Some(location), + Self::EmptySetNeedsType { location } => Some(location), + Self::InvalidBitWidth { location, .. } => Some(location), + Self::GateArityMismatch { location, .. } => Some(location), + Self::AmbiguousGateTarget { location, .. } => Some(location), + Self::InvalidGateSyntax { location, .. } => Some(location), + Self::InvalidQubitRef { location } => Some(location), + Self::AllocatorNotFound { location, .. } => Some(location), + Self::ComptimeError { location, .. } => Some(location), + Self::FunctionNotFound { location, .. } => Some(location), + Self::NotCallable { location } => Some(location), + Self::ArgumentCountMismatch { location, .. } => Some(location), + Self::QubitNotPrepared { location, .. } => Some(location), + Self::QubitAlreadyPrepared { location, .. } => Some(location), + Self::QubitIndexOutOfBounds { location, .. } => Some(location), + Self::ArrayIndexOutOfBounds { location, .. } => Some(location), + Self::ChildRequiresMutableParent { location, .. } => Some(location), + Self::ImmutableAssignment { location, .. } => Some(location), + Self::UnboundedLoop { location } => Some(location), + Self::LoopBoundTooLarge { location, .. } => Some(location), + Self::RecursionDetected { location, .. } => Some(location), + Self::InvalidMeasurementType { location, .. } => Some(location), + Self::MeasurementMissingArgs { location } => Some(location), + Self::DeprecatedMeasurementSyntax { location } => Some(location), + Self::DeprecatedSyntax { location, .. } => Some(location), + Self::MeasurementSizeMismatch { location, .. } => Some(location), + Self::MeasurementScalarExpected { location } => Some(location), + Self::MeasurementArrayExpected { location } => Some(location), + Self::MeasurementPackCapacity { location, .. } => Some(location), + Self::MeasurementPackUnknownSize { location, .. } => Some(location), + Self::DuplicateQubitInTick { location, .. } => Some(location), + Self::NestedTick { location } => Some(location), + Self::DuplicateQubitInMeasurement { location, .. } => Some(location), + Self::BreakContinueOutsideLoop { location, .. } => Some(location), + Self::InlineForRangeNotComptime { location, .. } => Some(location), + Self::BreakInInlineFor { location } => Some(location), + Self::ContinueInInlineFor { location } => Some(location), + Self::OverlappingAlias { location, .. } => Some(location), + Self::AliasSourceNotSlice { location, .. } => Some(location), + Self::AliasRangeNotComptime { location, .. } => Some(location), + Self::MissingReturn { location, .. } => Some(location), + Self::ReturnWithoutValue { location, .. } => Some(location), + Self::ModuleError { location, .. } => Some(location), + Self::CatchOnNonErrorType { location, .. } => Some(location), + Self::UndefinedType { location, .. } => Some(location), + Self::UnresolvedType { location, .. } => Some(location), + Self::SymbolTableLimitExceeded { .. } => None, + Self::ScopeNestingLimitExceeded { .. } => None, + Self::DuplicateSwitchCase { location, .. } => Some(location), + Self::ReturnReferenceToLocal { location, .. } => Some(location), + Self::ReturnSliceOfLocal { location, .. } => Some(location), + Self::ReferenceEscapesScope { location, .. } => Some(location), + Self::Other { .. } => None, + } + } +} + +pub type SemanticResult = Result; + +/// Multiple semantic errors collected during analysis. +/// +/// This type is returned by `analyze_collecting_errors` to provide all +/// errors at once, allowing developers to fix multiple issues in one pass. +#[derive(Debug, Clone)] +pub struct SemanticErrors { + errors: Vec, +} + +impl SemanticErrors { + /// Create a new collection of semantic errors. + pub fn new(errors: Vec) -> Self { + Self { errors } + } + + /// Get the number of errors. + pub fn len(&self) -> usize { + self.errors.len() + } + + /// Check if there are no errors. + pub fn is_empty(&self) -> bool { + self.errors.is_empty() + } + + /// Get an iterator over the errors. + pub fn iter(&self) -> impl Iterator { + self.errors.iter() + } + + /// Get the first error, if any. + pub fn first(&self) -> Option<&SemanticError> { + self.errors.first() + } + + /// Convert to a Vec of errors. + pub fn into_vec(self) -> Vec { + self.errors + } +} + +impl std::fmt::Display for SemanticErrors { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + writeln!(f, "Found {} error(s):", self.errors.len())?; + for (i, err) in self.errors.iter().enumerate() { + writeln!(f, " {}. {}", i + 1, err)?; + } + Ok(()) + } +} + +impl std::error::Error for SemanticErrors {} + +impl IntoIterator for SemanticErrors { + type Item = SemanticError; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.errors.into_iter() + } +} + +impl<'a> IntoIterator for &'a SemanticErrors { + type Item = &'a SemanticError; + type IntoIter = std::slice::Iter<'a, SemanticError>; + + fn into_iter(self) -> Self::IntoIter { + self.errors.iter() + } +} + +// ============================================================================= +// Input Size Limits +// ============================================================================= + +/// Maximum number of symbols allowed in the symbol table. +/// This prevents memory exhaustion from programs with excessive declarations. +pub const MAX_SYMBOL_COUNT: usize = 100_000; + +/// Maximum scope nesting depth. +/// This prevents stack overflow from deeply nested scopes. +pub const MAX_SCOPE_DEPTH: usize = 256; + +// ============================================================================= +// Resolved Types +// ============================================================================= + +/// A validated bit width for integer types. +/// +/// Guarantees the value is in the valid range 1-128 (matching Rust's max integer size). +/// Once constructed, a BitWidth is always valid - invalid values are unrepresentable. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BitWidth(u16); + +impl BitWidth { + /// Minimum valid bit width. + pub const MIN: u16 = 1; + /// Maximum valid bit width (matches Rust's i128/u128). + pub const MAX: u16 = 128; + + /// Create a new BitWidth if the value is valid (1-128). + /// Returns None for invalid values like 0 or 129+. + pub const fn new(bits: u16) -> Option { + if bits >= Self::MIN && bits <= Self::MAX { + Some(Self(bits)) + } else { + None + } + } + + /// Create a BitWidth without validation. + /// # Safety + /// The caller must ensure bits is in range 1-128. + pub const unsafe fn new_unchecked(bits: u16) -> Self { + Self(bits) + } + + /// Common bit width constants. + pub const BITS_1: Self = Self(1); + pub const BITS_8: Self = Self(8); + pub const BITS_16: Self = Self(16); + pub const BITS_32: Self = Self(32); + pub const BITS_64: Self = Self(64); + pub const BITS_128: Self = Self(128); + + /// Get the bit width value. + pub const fn get(self) -> u16 { + self.0 + } + + /// Create a BitWidth, panicking if invalid. + /// Use only for compile-time known values. + #[track_caller] + pub const fn must(bits: u16) -> Self { + match Self::new(bits) { + Some(bw) => bw, + None => panic!("invalid bit width"), + } + } +} + +impl std::fmt::Display for BitWidth { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl TryFrom for BitWidth { + type Error = &'static str; + + fn try_from(bits: u16) -> Result { + Self::new(bits).ok_or("bit width must be between 1 and 128") + } +} + +/// Resolved types after semantic analysis. +/// +/// Unlike `TypeExpr` which is a syntactic representation, `Type` is the +/// semantic representation with all information resolved. +#[derive(Debug, Clone, PartialEq)] +pub enum Type { + // Primitives + Bool, + // Arbitrary-width integers (like Zig: u1, u4, u7, u128, etc.) + UInt { bits: BitWidth }, // Unsigned integer with N bits + IInt { bits: BitWidth }, // Signed integer with N bits + Usize, // Platform-dependent unsigned size + Isize, // Platform-dependent signed size + // Floating point + F16, + F32, + F64, + F128, + A64, // Angle type (maps to PECOS Angle64) + + // Quantum types + Qubit, + Bit, + /// Allocator with known capacity + Allocator { capacity: Option }, + + // Compound types + Array { + element: Box, + size: Option, + }, + Slice { + element: Box, + }, + Set { + element: Box, + }, + Pointer { + pointee: Box, + is_const: bool, + is_many: bool, + }, + Optional { + inner: Box, + }, + ErrorUnion { + error: Box, + payload: Box, + }, + /// Collected errors: []E!T - array of errors E with value T (both, not either/or) + /// Used for QEC-style error collection + CollectedErrors { + error: Box, + payload: Box, + }, + /// Tuple type: (T1, T2, ...) + Tuple { + elements: Vec, + }, + + // Function type + Function { + params: Vec, + return_type: Box, + }, + + // Named/user-defined types + Struct { + name: String, + fields: Vec<(String, Type)>, + }, + Enum { + name: String, + variants: Vec, + }, + Union { + name: String, + /// Fields of the union: (name, optional payload type) + fields: Vec<(String, Option)>, + /// Is this union tagged (has an auto-generated or external tag)? + is_tagged: bool, + }, + /// Classical error set - crashes if unhandled + /// Each variant is (name, optional_associated_data_type) + ErrorSet { + name: String, + errors: Vec<(String, Option>)>, + }, + /// Quantum fault set - collected in try blocks + /// Each variant is (name, optional_associated_data_type) + FaultSet { + name: String, + faults: Vec<(String, Option>)>, + }, + /// The `anyerror` type - represents any error type + AnyError, + /// The `anyfault` type - represents any fault type + AnyFault, + + // Special types + Unit, // Unit type - has exactly one value + Type, // The metatype (type of types) + Comptime(Box), // Comptime-known value of this type + Never, // Bottom type (for functions that don't return) + + /// Imported module type + Module { + /// Absolute path to the module file + path: String, + /// Exported symbols: name -> (kind, type) + exports: std::collections::BTreeMap, + }, + + // Unknown (for type inference) + Unknown, +} + +/// Kind of exported symbol from a module. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ModuleExportKind { + Function, + Const, + Type, + ErrorSet, + FaultSet, +} + +impl Type { + /// Check if this type is numeric. + pub fn is_numeric(&self) -> bool { + matches!( + self, + Type::UInt { .. } + | Type::IInt { .. } + | Type::Usize + | Type::Isize + | Type::F16 + | Type::F32 + | Type::F64 + | Type::F128 + | Type::A64 + ) + } + + /// Check if this type is an integer. + pub fn is_integer(&self) -> bool { + matches!( + self, + Type::UInt { .. } | Type::IInt { .. } | Type::Usize | Type::Isize + ) + } + + /// Check if this type is a floating point. + pub fn is_float(&self) -> bool { + matches!(self, Type::F16 | Type::F32 | Type::F64 | Type::F128) + } + + /// Check if this type is a quantum type. + pub fn is_quantum(&self) -> bool { + matches!( + self, + Type::Qubit | Type::Bit | Type::Allocator { .. } + ) + } + + /// Get the display name for error messages. + pub fn display_name(&self) -> String { + match self { + Type::Bool => "bool".to_string(), + Type::UInt { bits } => format!("u{bits}"), + Type::IInt { bits } => format!("i{bits}"), + Type::Usize => "usize".to_string(), + Type::Isize => "isize".to_string(), + Type::F16 => "f16".to_string(), + Type::F32 => "f32".to_string(), + Type::F64 => "f64".to_string(), + Type::F128 => "f128".to_string(), + Type::A64 => "a64".to_string(), + Type::Qubit => "qubit".to_string(), + Type::Bit => "bit".to_string(), + Type::Allocator { capacity: Some(n) } => format!("qalloc({n})"), + Type::Allocator { capacity: None } => "qalloc".to_string(), + Type::Array { + element, + size: Some(n), + } => format!("[{n}]{}", element.display_name()), + Type::Array { + element, + size: None, + } => format!("[_]{}", element.display_name()), + Type::Slice { element } => format!("[]{}", element.display_name()), + Type::Pointer { + pointee, is_const, .. + } => { + if *is_const { + format!("*const {}", pointee.display_name()) + } else { + format!("*{}", pointee.display_name()) + } + } + Type::Optional { inner } => format!("?{}", inner.display_name()), + Type::ErrorUnion { error, payload } => { + // Syntax is E!T where E is error type and T is payload type + format!("{}!{}", error.display_name(), payload.display_name()) + } + Type::CollectedErrors { error, payload } => { + // Syntax is []E!T where E is error type and T is payload type + format!("[]{}!{}", error.display_name(), payload.display_name()) + } + Type::Tuple { elements } => { + let elem_strs: Vec<_> = elements.iter().map(|e| e.display_name()).collect(); + format!("({})", elem_strs.join(", ")) + } + Type::Function { + params, + return_type, + } => { + let params_str: Vec<_> = params.iter().map(|p| p.display_name()).collect(); + format!("fn({}) {}", params_str.join(", "), return_type.display_name()) + } + Type::Struct { name, .. } => name.clone(), + Type::Enum { name, .. } => name.clone(), + Type::Union { name, .. } => name.clone(), + Type::ErrorSet { name, .. } => format!("error.{}", name), + Type::FaultSet { name, .. } => format!("fault.{}", name), + Type::AnyError => "anyerror".to_string(), + Type::AnyFault => "anyfault".to_string(), + Type::Unit => "unit".to_string(), + Type::Type => "type".to_string(), + Type::Comptime(inner) => format!("comptime {}", inner.display_name()), + Type::Never => "noreturn".to_string(), + Type::Unknown => "unknown".to_string(), + Type::Set { element } => format!("Set({})", element.display_name()), + Type::Module { path, .. } => format!("module({})", path), + } + } + + /// Check if this type contains `Unknown` anywhere in its structure. + /// + /// Returns `true` if this type is `Unknown` or contains `Unknown` in any + /// nested position (e.g., `Array { element: Unknown, .. }`). + pub fn contains_unknown(&self) -> bool { + match self { + Type::Unknown => true, + + // Primitives never contain Unknown + Type::Bool + | Type::UInt { .. } + | Type::IInt { .. } + | Type::Usize + | Type::Isize + | Type::F16 + | Type::F32 + | Type::F64 + | Type::F128 + | Type::A64 + | Type::Qubit + | Type::Bit + | Type::Unit + | Type::Type + | Type::Never + | Type::AnyError + | Type::AnyFault => false, + + // Check allocator (no nested types) + Type::Allocator { .. } => false, + + // Container types - check nested types + Type::Array { element, .. } => element.contains_unknown(), + Type::Slice { element } => element.contains_unknown(), + Type::Set { element } => element.contains_unknown(), + Type::Pointer { pointee, .. } => pointee.contains_unknown(), + Type::Optional { inner } => inner.contains_unknown(), + Type::Comptime(inner) => inner.contains_unknown(), + + // Compound types with two nested types + Type::ErrorUnion { error, payload } => { + error.contains_unknown() || payload.contains_unknown() + } + Type::CollectedErrors { error, payload } => { + error.contains_unknown() || payload.contains_unknown() + } + + // Collections of types + Type::Tuple { elements } => elements.iter().any(|e| e.contains_unknown()), + Type::Function { params, return_type } => { + params.iter().any(|p| p.contains_unknown()) || return_type.contains_unknown() + } + + // Named types with fields + Type::Struct { fields, .. } => fields.iter().any(|(_, ty)| ty.contains_unknown()), + Type::Union { fields, .. } => fields + .iter() + .any(|(_, opt_ty)| opt_ty.as_ref().is_some_and(|ty| ty.contains_unknown())), + + // Named types without nested types to check + Type::Enum { .. } | Type::ErrorSet { .. } | Type::FaultSet { .. } => false, + + // Module exports + Type::Module { exports, .. } => { + exports.values().any(|(_, ty)| ty.contains_unknown()) + } + } + } + + /// Try to resolve this type, returning `Some(ResolvedType)` if it contains + /// no `Unknown` anywhere in its structure, or `None` otherwise. + pub fn resolve(&self) -> Option { + if self.contains_unknown() { + None + } else { + Some(ResolvedType(self.clone())) + } + } + + /// Check if this type is fully resolved (contains no Unknown). + pub fn is_resolved(&self) -> bool { + !self.contains_unknown() + } +} + +/// A type that is guaranteed to contain no `Unknown` anywhere in its structure. +/// +/// This wrapper provides type-level safety at API boundaries where we need to +/// guarantee that type inference has completed. Use `Type::resolve()` to create +/// a `ResolvedType` from a `Type`. +/// +/// # Example +/// +/// ```rust +/// use zlup::semantic::Type; +/// +/// // ResolvedType guarantees no Unknown variants +/// let ty = Type::Bool.resolve().expect("Bool is resolved"); +/// assert!(matches!(ty.as_type(), Type::Bool)); +/// +/// // Unknown types cannot become ResolvedType +/// let unknown = Type::Unknown.resolve(); +/// assert!(unknown.is_none()); +/// ``` +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedType(Type); + +impl ResolvedType { + /// Get the underlying `Type`. + /// + /// The returned type is guaranteed to not contain `Unknown`. + pub fn as_type(&self) -> &Type { + &self.0 + } + + /// Unwrap into the underlying `Type`. + /// + /// The returned type is guaranteed to not contain `Unknown`. + pub fn into_type(self) -> Type { + self.0 + } + + /// Get the display name for error messages. + pub fn display_name(&self) -> String { + self.0.display_name() + } + + /// Check if this type is numeric. + pub fn is_numeric(&self) -> bool { + self.0.is_numeric() + } + + /// Check if this type is an integer. + pub fn is_integer(&self) -> bool { + self.0.is_integer() + } + + /// Check if this type is a floating point. + pub fn is_float(&self) -> bool { + self.0.is_float() + } + + /// Check if this type is a quantum type. + pub fn is_quantum(&self) -> bool { + self.0.is_quantum() + } +} + +impl std::fmt::Display for ResolvedType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0.display_name()) + } +} + +// ============================================================================= +// Symbol Table +// ============================================================================= + +/// Symbol kinds. +#[derive(Debug, Clone)] +pub enum SymbolKind { + /// Local or global variable + Variable { + ty: Type, + is_const: bool, + is_comptime: bool, + }, + /// Function + Function { + params: Vec<(String, Type)>, + return_type: Type, + is_pub: bool, + /// Which parameter indices are comptime (for generic functions) + comptime_param_indices: Vec, + /// Original function declaration (for instantiation of generics) + original_decl: Option>, + }, + /// Type definition (struct, enum) + TypeDef { ty: Type }, + /// Qubit allocator + Allocator { capacity: Option }, + /// Parameter + Parameter { ty: Type, is_comptime: bool }, +} + +/// A symbol in the symbol table. +#[derive(Debug, Clone)] +pub struct Symbol { + pub name: String, + pub kind: SymbolKind, + pub location: Option, +} + +/// A scope containing symbols. +#[derive(Debug)] +pub struct Scope { + /// Symbols defined in this scope + symbols: BTreeMap, + /// Parent scope index (None for global scope) + parent: Option, + /// Scope kind for context + kind: ScopeKind, +} + +/// Scope kinds for different contexts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScopeKind { + Global, + Function, + Block, + Loop, + Struct, +} + +/// Symbol table with nested scopes. +#[derive(Debug)] +pub struct SymbolTable { + scopes: Vec, + current_scope: usize, +} + +impl SymbolTable { + /// Create a new symbol table with a global scope. + pub fn new() -> Self { + let global_scope = Scope { + symbols: BTreeMap::new(), + parent: None, + kind: ScopeKind::Global, + }; + Self { + scopes: vec![global_scope], + current_scope: 0, + } + } + + /// Push a new scope. + pub fn push_scope(&mut self, kind: ScopeKind) -> SemanticResult<()> { + // Check scope nesting limit + let depth = self.scope_depth(); + if depth >= MAX_SCOPE_DEPTH { + return Err(SemanticError::ScopeNestingLimitExceeded { + depth: depth + 1, + max: MAX_SCOPE_DEPTH, + }); + } + + let new_scope = Scope { + symbols: BTreeMap::new(), + parent: Some(self.current_scope), + kind, + }; + self.scopes.push(new_scope); + self.current_scope = self.scopes.len() - 1; + Ok(()) + } + + /// Calculate current scope nesting depth. + fn scope_depth(&self) -> usize { + let mut depth = 0; + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + depth += 1; + scope_idx = self.scopes[idx].parent; + } + depth + } + + /// Pop the current scope. + pub fn pop_scope(&mut self) { + if let Some(parent) = self.scopes[self.current_scope].parent { + self.current_scope = parent; + } + } + + /// Define a symbol in the current scope. + pub fn define(&mut self, symbol: Symbol) -> SemanticResult<()> { + // Check symbol table size limit + let total_symbols: usize = self.scopes.iter().map(|s| s.symbols.len()).sum(); + if total_symbols >= MAX_SYMBOL_COUNT { + return Err(SemanticError::SymbolTableLimitExceeded { + count: total_symbols + 1, + max: MAX_SYMBOL_COUNT, + }); + } + + let scope = &mut self.scopes[self.current_scope]; + if scope.symbols.contains_key(&symbol.name) { + return Err(SemanticError::DuplicateSymbol { + name: symbol.name.clone(), + location: symbol.location.clone().unwrap_or_default(), + }); + } + scope.symbols.insert(symbol.name.clone(), symbol); + Ok(()) + } + + /// Look up a symbol, searching parent scopes. + pub fn lookup(&self, name: &str) -> Option<&Symbol> { + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + let scope = &self.scopes[idx]; + if let Some(symbol) = scope.symbols.get(name) { + return Some(symbol); + } + scope_idx = scope.parent; + } + None + } + + /// Look up a symbol only in the current scope. + pub fn lookup_current(&self, name: &str) -> Option<&Symbol> { + self.scopes[self.current_scope].symbols.get(name) + } + + /// Get the current scope kind. + pub fn current_scope_kind(&self) -> ScopeKind { + self.scopes[self.current_scope].kind + } + + /// Check if we're inside a loop. + pub fn in_loop(&self) -> bool { + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + if self.scopes[idx].kind == ScopeKind::Loop { + return true; + } + scope_idx = self.scopes[idx].parent; + } + false + } + + /// Find an error set containing the given variant name. + /// Returns the Type::ErrorSet if found. + pub fn find_error_set_by_variant(&self, variant_name: &str) -> Option { + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + let scope = &self.scopes[idx]; + for symbol in scope.symbols.values() { + if let SymbolKind::TypeDef { ty } = &symbol.kind + && let Type::ErrorSet { name, errors } = ty + && errors.iter().any(|(n, _)| n == variant_name) { + return Some(Type::ErrorSet { + name: name.clone(), + errors: errors.clone(), + }); + } + } + scope_idx = scope.parent; + } + None + } + + /// Find a fault set containing the given variant name. + /// Returns the Type::FaultSet if found. + pub fn find_fault_set_by_variant(&self, variant_name: &str) -> Option { + let mut scope_idx = Some(self.current_scope); + while let Some(idx) = scope_idx { + let scope = &self.scopes[idx]; + for symbol in scope.symbols.values() { + if let SymbolKind::TypeDef { ty } = &symbol.kind + && let Type::FaultSet { name, faults } = ty + && faults.iter().any(|(n, _)| n == variant_name) { + return Some(Type::FaultSet { + name: name.clone(), + faults: faults.clone(), + }); + } + } + scope_idx = scope.parent; + } + None + } +} + +impl Default for SymbolTable { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================= +// Qubit State Tracking +// ============================================================================= + +/// Qubit slot state - exactly two states as per Zluppy design. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum QubitState { + /// Initial state, or after measurement. Cannot apply gates. + Unprepared, + /// Ready for gate operations. Result of prepare(). + Prepared, +} + +impl QubitState { + /// Check if gates can be applied to this qubit. + pub fn can_apply_gate(&self) -> bool { + matches!(self, QubitState::Prepared) + } +} + +/// Information about an allocator for tracking. +#[derive(Debug, Clone)] +pub struct AllocatorInfo { + /// Name of the allocator variable. + pub name: String, + /// Known capacity (if comptime-known). + pub capacity: Option, + /// Parent allocator (for child allocators). + pub parent: Option, + /// State of each qubit slot. + pub slot_states: Vec, +} + +impl AllocatorInfo { + /// Create a new allocator with given capacity. + pub fn new(name: impl Into, capacity: usize) -> Self { + Self { + name: name.into(), + capacity: Some(capacity), + parent: None, + slot_states: vec![QubitState::Unprepared; capacity], + } + } + + /// Create a child allocator. + pub fn child(name: impl Into, parent: impl Into, capacity: usize) -> Self { + Self { + name: name.into(), + capacity: Some(capacity), + parent: Some(parent.into()), + slot_states: vec![QubitState::Unprepared; capacity], + } + } + + /// Create an allocator with unknown capacity. + pub fn unknown(name: impl Into) -> Self { + Self { + name: name.into(), + capacity: None, + parent: None, + slot_states: Vec::new(), + } + } + + /// Prepare a specific slot. + pub fn prepare_slot(&mut self, index: usize) -> Result<(), (usize, QubitState)> { + if let Some(state) = self.slot_states.get_mut(index) { + if *state == QubitState::Prepared { + return Err((index, *state)); + } + *state = QubitState::Prepared; + Ok(()) + } else if self.capacity.is_none() { + // Unknown capacity - assume valid + Ok(()) + } else { + Err((index, QubitState::Unprepared)) + } + } + + /// Prepare all slots. + pub fn prepare_all(&mut self) { + for state in &mut self.slot_states { + *state = QubitState::Prepared; + } + } + + /// Measure a specific slot (transitions to unprepared). + pub fn measure_slot(&mut self, index: usize) { + if let Some(state) = self.slot_states.get_mut(index) { + *state = QubitState::Unprepared; + } + } + + /// Get the state of a slot. + pub fn get_state(&self, index: usize) -> Option { + self.slot_states.get(index).copied() + } + + /// Check if an index is in bounds. + pub fn is_in_bounds(&self, index: usize) -> bool { + match self.capacity { + Some(cap) => index < cap, + None => true, // Unknown capacity - assume valid + } + } +} + +/// Tracks qubit states across the program. +#[derive(Debug, Default)] +pub struct QubitStateTracker { + /// Allocators by name. + allocators: BTreeMap, +} + +impl QubitStateTracker { + /// Create a new tracker. + pub fn new() -> Self { + Self::default() + } + + /// Register an allocator. + pub fn register_allocator(&mut self, info: AllocatorInfo) { + self.allocators.insert(info.name.clone(), info); + } + + /// Get an allocator by name. + pub fn get_allocator(&self, name: &str) -> Option<&AllocatorInfo> { + self.allocators.get(name) + } + + /// Get a mutable allocator by name. + pub fn get_allocator_mut(&mut self, name: &str) -> Option<&mut AllocatorInfo> { + self.allocators.get_mut(name) + } + + /// Check if a qubit slot is prepared for gate operations. + pub fn is_prepared(&self, allocator: &str, index: usize) -> Option { + self.allocators + .get(allocator) + .and_then(|a| a.get_state(index)) + .map(|s| s == QubitState::Prepared) + } + + /// Validate a qubit reference for gate operations. + pub fn validate_for_gate( + &self, + allocator: &str, + index: usize, + location: &SourceLocation, + ) -> SemanticResult<()> { + let alloc = self.allocators.get(allocator).ok_or_else(|| { + SemanticError::AllocatorNotFound { + name: allocator.to_string(), + location: location.clone(), + } + })?; + + // Check bounds + if !alloc.is_in_bounds(index) { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: allocator.to_string(), + index, + capacity: alloc.capacity.unwrap_or(0), + location: location.clone(), + }); + } + + // Check state (only if capacity is known) + if alloc.capacity.is_some() + && let Some(state) = alloc.get_state(index) + && !state.can_apply_gate() { + return Err(SemanticError::QubitNotPrepared { + allocator: allocator.to_string(), + index, + location: location.clone(), + }); + } + + Ok(()) + } +} + +// ============================================================================= +// NASA Power of 10 Checks +// ============================================================================= + +/// Maximum allowed loop bound (NASA Power of 10 Rule 2). +pub const MAX_LOOP_BOUND: usize = 1_000_000; + +/// Tracks function calls to detect recursion. +#[derive(Debug, Default)] +pub struct RecursionTracker { + /// Currently active function call stack. + call_stack: BTreeSet, +} + +impl RecursionTracker { + pub fn new() -> Self { + Self::default() + } + + /// Enter a function. Returns error if already in the call stack. + pub fn enter_function(&mut self, name: &str, location: &SourceLocation) -> SemanticResult<()> { + if self.call_stack.contains(name) { + return Err(SemanticError::RecursionDetected { + name: name.to_string(), + location: location.clone(), + }); + } + self.call_stack.insert(name.to_string()); + Ok(()) + } + + /// Exit a function. + pub fn exit_function(&mut self, name: &str) { + self.call_stack.remove(name); + } + + /// Check if a function is in the call stack. + pub fn is_in_call_stack(&self, name: &str) -> bool { + self.call_stack.contains(name) + } +} + +// ============================================================================= +// Semantic Analyzer +// ============================================================================= + +/// Semantic analyzer for Zluppy programs. +pub struct SemanticAnalyzer { + /// Symbol table + pub symbols: SymbolTable, + /// Qubit state tracker + pub qubit_states: QubitStateTracker, + /// Recursion tracker (NASA Power of 10) + pub recursion_tracker: RecursionTracker, + /// Current function return type (for return statement checking) + current_return_type: Option, + /// Current function name (for call tracking) + current_function: Option, + /// Whether to enforce strict qubit state checking + strict_mode: bool, + /// Errors collected during analysis + errors: Vec, + /// Comptime evaluator for compile-time expression evaluation + comptime: ComptimeEvaluator, + /// Storage for comptime-evaluated values by expression location + comptime_values: BTreeMap, + /// Module loader for handling @import + module_loader: ModuleLoader, + /// Current file path (for resolving relative imports) + current_file: Option, + /// Loop nesting depth (for validating break/continue) + loop_depth: usize, + /// Inline for nesting depth (to disallow break/continue in inline for) + inline_for_depth: usize, + /// Tick nesting depth (to disallow nested ticks) + tick_depth: usize, + /// Call graph for mutual recursion detection (strict mode) + /// Maps function name -> set of functions it calls + call_graph: BTreeMap>, + /// Set of user-defined function names (for call graph filtering) + user_functions: BTreeSet, + /// Cache of generic function instantiations. + /// Key: (original function name, serialized comptime arg values) + /// Value: mangled name of the specialized function + generic_instantiations: BTreeMap<(String, String), String>, + /// Storage for specialized function declarations generated from generics + specialized_functions: Vec, + /// Alias tracking for overlap detection + /// Key: alias name, Value: AliasInfo + aliases: BTreeMap, + /// Gate registry for custom gate declarations + gate_registry: BTreeMap, +} + +/// Signature of a registered gate (built-in or custom). +#[derive(Debug, Clone)] +pub struct GateSignature { + pub name: String, + pub num_params: usize, + pub num_qubits: usize, + pub is_builtin: bool, +} + +/// Information about an alias for overlap detection. +#[derive(Debug, Clone)] +pub struct AliasInfo { + /// Name of the alias + pub name: String, + /// Name of the source variable + pub source: String, + /// Static range if known (start..end) + pub range: Option<(i64, i64)>, + /// Source location for error reporting + pub location: SourceLocation, +} + +impl SemanticAnalyzer { + /// Create a new semantic analyzer with strict mode enabled (the default). + /// + /// Strict mode enforces safety guarantees required for quantum programs: + /// - Qubit state checking (gates on unprepared qubits are errors) + /// - Loop bounds checked against MAX_LOOP_BOUND + /// - Recursion is prohibited (use FFI with Rust if needed) + /// - Explicit return statements required + /// + /// This is the recommended mode for quantum programs. + pub fn new() -> Self { + let mut analyzer = Self { + symbols: SymbolTable::new(), + qubit_states: QubitStateTracker::new(), + recursion_tracker: RecursionTracker::new(), + current_return_type: None, + current_function: None, + strict_mode: true, // Strict by default for quantum safety + errors: Vec::new(), + comptime: ComptimeEvaluator::new(), + comptime_values: BTreeMap::new(), + module_loader: ModuleLoader::new(), + current_file: None, + loop_depth: 0, + inline_for_depth: 0, + tick_depth: 0, + call_graph: BTreeMap::new(), + user_functions: BTreeSet::new(), + generic_instantiations: BTreeMap::new(), + specialized_functions: Vec::new(), + aliases: BTreeMap::new(), + gate_registry: BTreeMap::new(), + }; + analyzer.define_builtins(); + analyzer.populate_builtin_gates(); + analyzer + } + + /// Set the current file path for resolving relative imports. + pub fn set_current_file(&mut self, path: impl Into) { + self.current_file = Some(path.into()); + } + + /// Create a new semantic analyzer with permissive mode (strict mode disabled). + /// + /// Permissive mode relaxes some safety checks: + /// - Qubit state checking is not enforced + /// - Loop bounds are not checked + /// + /// Note: Recursion is always prohibited (use FFI with Rust if needed). + /// + /// Use this mode only when interfacing with external code or for testing. + /// For production quantum programs, use `new()` (strict mode). + pub fn new_permissive() -> Self { + let mut analyzer = Self::new(); + analyzer.strict_mode = false; + analyzer + } + + /// Enable or disable strict mode. + pub fn set_strict_mode(&mut self, strict: bool) { + self.strict_mode = strict; + } + + /// Define built-in functions and types. + fn define_builtins(&mut self) { + // Built-in quantum gates are recognized by name during call analysis + // Built-in types are handled in resolve_type + + // Define qalloc as a built-in function that returns an allocator + let _ = self.symbols.define(Symbol { + name: "qalloc".to_string(), + kind: SymbolKind::Function { + params: vec![("capacity".to_string(), Type::UInt { bits: BitWidth::BITS_32 })], + return_type: Type::Allocator { capacity: None }, + is_pub: true, + comptime_param_indices: vec![], + original_decl: None, + }, + location: None, + }); + + // Define measure as a built-in + let _ = self.symbols.define(Symbol { + name: "measure".to_string(), + kind: SymbolKind::Function { + params: vec![("target".to_string(), Type::Qubit)], + return_type: Type::Bit, + is_pub: true, + comptime_param_indices: vec![], + original_decl: None, + }, + location: None, + }); + } + + /// Populate the gate registry with all built-in gates. + fn populate_builtin_gates(&mut self) { + let builtin_gates: &[(&str, usize, usize)] = &[ + // (name, num_params, num_qubits) + ("x", 0, 1), ("y", 0, 1), ("z", 0, 1), + ("h", 0, 1), + ("t", 0, 1), ("tdg", 0, 1), + ("sx", 0, 1), ("sy", 0, 1), ("sz", 0, 1), + ("sxdg", 0, 1), ("sydg", 0, 1), ("szdg", 0, 1), + ("rx", 1, 1), ("ry", 1, 1), ("rz", 1, 1), + ("cx", 0, 2), ("cy", 0, 2), ("cz", 0, 2), ("ch", 0, 2), + ("sxx", 0, 2), ("syy", 0, 2), ("szz", 0, 2), + ("sxxdg", 0, 2), ("syydg", 0, 2), ("szzdg", 0, 2), + ("rzz", 1, 2), + ("swap", 0, 2), ("iswap", 0, 2), + ("ccx", 0, 3), + ("f", 0, 1), ("fdg", 0, 1), ("f4", 0, 1), ("f4dg", 0, 1), + ("pz", 0, 1), + ]; + + for &(name, num_params, num_qubits) in builtin_gates { + self.gate_registry.insert( + name.to_string(), + GateSignature { + name: name.to_string(), + num_params, + num_qubits, + is_builtin: true, + }, + ); + } + } + + /// Analyze a program. + pub fn analyze(&mut self, program: &Program) -> SemanticResult<()> { + log::debug!( + "Analyzing program with {} declarations (strict={})", + program.declarations.len(), + self.strict_mode + ); + + // First pass: collect all top-level declarations + log::trace!("Pass 1: collecting top-level declarations"); + for decl in &program.declarations { + self.collect_top_level(decl)?; + } + + // Second pass: analyze bodies + log::trace!("Pass 2: analyzing declaration bodies"); + for decl in &program.declarations { + self.analyze_top_level(decl)?; + } + + // Check for mutual recursion in strict mode + if self.strict_mode { + log::trace!("Pass 3: checking call graph for cycles"); + self.check_call_graph_cycles()?; + } + + // Validation pass: ensure no unresolved types remain in symbol table + // This is a safety net to catch any Unknown types that slip through + log::trace!("Pass 4: validating resolved types"); + self.validate_types_resolved(); + + if self.errors.is_empty() { + log::debug!("Semantic analysis completed successfully"); + Ok(()) + } else { + log::debug!("Semantic analysis found {} error(s)", self.errors.len()); + // Return the first error for backward compatibility + Err(self.errors.remove(0)) + } + } + + /// Analyze a program and return all collected errors. + /// + /// Unlike `analyze()` which returns the first error, this method + /// returns all errors found during analysis, allowing developers + /// to fix multiple issues in one pass. + /// + /// # Example + /// + /// ```rust + /// use zlup::semantic::SemanticAnalyzer; + /// + /// // Program with multiple errors + /// let source = "fn main() -> unit { x := undefined; y := also_undefined; return unit; }"; + /// let program = zlup::parse(source).expect("parse failed"); + /// let mut analyzer = SemanticAnalyzer::new(); + /// match analyzer.analyze_collecting_errors(&program) { + /// Ok(()) => println!("No errors"), + /// Err(errors) => { + /// // All errors collected, not just the first one + /// assert!(errors.len() >= 2); + /// } + /// } + /// ``` + pub fn analyze_collecting_errors(&mut self, program: &Program) -> Result<(), SemanticErrors> { + // First pass: collect all top-level declarations + for decl in &program.declarations { + if let Err(e) = self.collect_top_level(decl) { + self.errors.push(e); + } + } + + // Second pass: analyze bodies + for decl in &program.declarations { + if let Err(e) = self.analyze_top_level(decl) { + self.errors.push(e); + } + } + + // Validation pass: ensure no unresolved types remain in symbol table + self.validate_types_resolved(); + + if self.errors.is_empty() { + Ok(()) + } else { + Err(SemanticErrors::new(std::mem::take(&mut self.errors))) + } + } + + /// Get the number of errors collected so far. + pub fn error_count(&self) -> usize { + self.errors.len() + } + + /// Get all collected errors without consuming them. + pub fn errors(&self) -> &[SemanticError] { + &self.errors + } + + /// Take all collected errors, leaving the error list empty. + pub fn take_errors(&mut self) -> Vec { + std::mem::take(&mut self.errors) + } + + /// Check for cycles in the call graph (mutual recursion detection). + /// Uses DFS to detect back edges in the call graph. + fn check_call_graph_cycles(&self) -> SemanticResult<()> { + let mut visited = BTreeSet::new(); + let mut rec_stack = BTreeSet::new(); + + for func in &self.user_functions { + if !visited.contains(func) { + if let Some(cycle_func) = self.dfs_detect_cycle(func, &mut visited, &mut rec_stack) { + return Err(SemanticError::RecursionDetected { + name: cycle_func, + location: SourceLocation::default(), + }); + } + } + } + Ok(()) + } + + /// DFS helper for cycle detection. Returns the name of a function in a cycle if found. + fn dfs_detect_cycle( + &self, + func: &str, + visited: &mut BTreeSet, + rec_stack: &mut BTreeSet, + ) -> Option { + visited.insert(func.to_string()); + rec_stack.insert(func.to_string()); + + if let Some(callees) = self.call_graph.get(func) { + for callee in callees { + // If callee is in the current recursion stack, we found a cycle + if rec_stack.contains(callee) { + return Some(callee.clone()); + } + // If not visited, recurse + if !visited.contains(callee) { + if let Some(cycle) = self.dfs_detect_cycle(callee, visited, rec_stack) { + return Some(cycle); + } + } + } + } + + rec_stack.remove(func); + None + } + + /// Validate that no Unknown types remain in the symbol table. + /// + /// This is a safety net that runs after semantic analysis to ensure + /// all types are fully resolved before code generation. + fn validate_types_resolved(&mut self) { + for scope in &self.symbols.scopes { + for symbol in scope.symbols.values() { + let (ty, context) = match &symbol.kind { + SymbolKind::Variable { ty, .. } => (ty, "variable"), + SymbolKind::Function { return_type, .. } => (return_type, "function return type"), + SymbolKind::TypeDef { ty } => (ty, "type definition"), + SymbolKind::Parameter { ty, .. } => (ty, "parameter"), + SymbolKind::Allocator { .. } => continue, + }; + + // Skip Module types - they can have Unknown in exports due to + // incomplete type extraction from imported modules + if matches!(ty, Type::Module { .. }) { + continue; + } + + if ty.contains_unknown() { + self.errors.push(SemanticError::UnresolvedType { + ty: ty.display_name(), + context: format!("{} '{}'", context, symbol.name), + location: symbol.location.clone().unwrap_or_default(), + }); + } + + // Also check function parameter types + if let SymbolKind::Function { params, .. } = &symbol.kind { + for (param_name, param_ty) in params { + if param_ty.contains_unknown() { + self.errors.push(SemanticError::UnresolvedType { + ty: param_ty.display_name(), + context: format!("parameter '{}' of function '{}'", param_name, symbol.name), + location: symbol.location.clone().unwrap_or_default(), + }); + } + } + } + } + } + } + + /// Collect top-level declarations (forward declaration pass). + fn collect_top_level(&mut self, decl: &TopLevelDecl) -> SemanticResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) => { + let params: Vec<(String, Type)> = fn_decl + .params + .iter() + .map(|p| (p.name.clone(), self.resolve_type(&p.ty))) + .collect(); + let return_type = fn_decl + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + + // Collect indices of comptime parameters (for generic instantiation) + let comptime_param_indices: Vec = fn_decl + .params + .iter() + .enumerate() + .filter(|(_, p)| p.is_comptime) + .map(|(i, _)| i) + .collect(); + + // Store original declaration if function has comptime params (is generic) + let original_decl = if !comptime_param_indices.is_empty() { + Some(Box::new(fn_decl.clone())) + } else { + None + }; + + self.symbols.define(Symbol { + name: fn_decl.name.clone(), + kind: SymbolKind::Function { + params, + return_type, + is_pub: fn_decl.is_pub, + comptime_param_indices, + original_decl, + }, + location: fn_decl.location.clone(), + })?; + + // Track user-defined functions for call graph analysis + self.user_functions.insert(fn_decl.name.clone()); + } + TopLevelDecl::Struct(struct_decl) => { + let fields: Vec<(String, Type)> = struct_decl + .fields + .iter() + .map(|f| (f.name.clone(), self.resolve_type(&f.ty))) + .collect(); + + self.symbols.define(Symbol { + name: struct_decl.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Struct { + name: struct_decl.name.clone(), + fields, + }, + }, + location: struct_decl.location.clone(), + })?; + } + TopLevelDecl::Enum(enum_decl) => { + let variants: Vec = + enum_decl.variants.iter().map(|v| v.name.clone()).collect(); + + self.symbols.define(Symbol { + name: enum_decl.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Enum { + name: enum_decl.name.clone(), + variants, + }, + }, + location: enum_decl.location.clone(), + })?; + } + TopLevelDecl::Binding(binding) => { + // Binding declarations are collected but not fully analyzed yet + // For struct/enum type bindings, also register as TypeDef + let ty = if let Some(type_expr) = &binding.ty { + self.resolve_type(type_expr) + } else if let Some(value) = &binding.value { + // Try to infer type from value (for struct { } bindings) + match self.analyze_expr(value) { + Ok(ty) => ty, + Err(e) => { + self.errors.push(e); + Type::Unknown + } + } + } else { + Type::Unknown // Will be inferred + }; + + // If binding a struct/enum type, register as TypeDef + if let Type::Struct { fields, .. } = &ty { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Struct { + name: binding.name.clone(), + fields: fields.clone(), + }, + }, + location: binding.location.clone(), + })?; + } else if let Type::Enum { variants, .. } = &ty { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Enum { + name: binding.name.clone(), + variants: variants.clone(), + }, + }, + location: binding.location.clone(), + })?; + } else { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::Variable { + ty, + is_const: !binding.is_mutable, + is_comptime: !binding.is_mutable, // Top-level immutable bindings are comptime + }, + location: binding.location.clone(), + })?; + } + } + TopLevelDecl::Test(_) => { + // Tests don't declare symbols + } + TopLevelDecl::DeclareGate(gate) => { + // Register target gate in the gate registry + self.gate_registry.insert( + gate.name.clone(), + GateSignature { + name: gate.name.clone(), + num_params: gate.params.len(), + num_qubits: gate.qubits.len(), + is_builtin: false, + }, + ); + } + TopLevelDecl::Gate(gate) => { + // Register composite gate in the gate registry + self.gate_registry.insert( + gate.name.clone(), + GateSignature { + name: gate.name.clone(), + num_params: gate.params.len(), + num_qubits: gate.qubits.len(), + is_builtin: false, + }, + ); + } + TopLevelDecl::ErrorSet(error_set) => { + // Error sets define a type containing the error values with optional associated data + let errors: Vec<(String, Option>)> = error_set + .variants + .iter() + .map(|v| { + let data_type = v.data_type.as_ref().map(|ty| Box::new(self.resolve_type(ty))); + (v.name.clone(), data_type) + }) + .collect(); + + self.symbols.define(Symbol { + name: error_set.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::ErrorSet { + name: error_set.name.clone(), + errors, + }, + }, + location: error_set.location.clone(), + })?; + } + TopLevelDecl::FaultSet(fault_set) => { + // Fault sets define a type containing the fault values with optional associated data + let faults: Vec<(String, Option>)> = fault_set + .variants + .iter() + .map(|v| { + let data_type = v.data_type.as_ref().map(|ty| Box::new(self.resolve_type(ty))); + (v.name.clone(), data_type) + }) + .collect(); + + self.symbols.define(Symbol { + name: fault_set.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::FaultSet { + name: fault_set.name.clone(), + faults, + }, + }, + location: fault_set.location.clone(), + })?; + } + TopLevelDecl::Union(union_decl) => { + // Union defines a tagged union type + let fields: Vec<(String, Option)> = union_decl + .fields + .iter() + .map(|f| { + ( + f.name.clone(), + f.ty.as_ref().map(|t| self.resolve_type(t)), + ) + }) + .collect(); + + // tag: None = untagged, Some(None) = auto-tagged, Some(Some(_)) = external tag + let is_tagged = union_decl.tag.is_some(); + + self.symbols.define(Symbol { + name: union_decl.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Union { + name: union_decl.name.clone(), + fields, + is_tagged, + }, + }, + location: union_decl.location.clone(), + })?; + } + TopLevelDecl::ExternFn(extern_fn) => { + // External functions are registered like regular functions + let params: Vec<(String, Type)> = extern_fn + .params + .iter() + .map(|p| (p.name.clone(), self.resolve_type(&p.ty))) + .collect(); + let return_type = extern_fn + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + + self.symbols.define(Symbol { + name: extern_fn.name.clone(), + kind: SymbolKind::Function { + params, + return_type, + is_pub: extern_fn.is_pub, + comptime_param_indices: vec![], // Extern functions don't support comptime params + original_decl: None, + }, + location: extern_fn.location.clone(), + })?; + } + } + Ok(()) + } + + /// Analyze a top-level declaration (full analysis pass). + fn analyze_top_level(&mut self, decl: &TopLevelDecl) -> SemanticResult<()> { + match decl { + TopLevelDecl::Fn(fn_decl) => self.analyze_fn(fn_decl), + TopLevelDecl::Struct(struct_decl) => self.analyze_struct(struct_decl), + TopLevelDecl::Binding(binding) => self.analyze_binding(binding), + TopLevelDecl::Test(test_decl) => self.analyze_block(&test_decl.body), + TopLevelDecl::Enum(_) => Ok(()), // Enums are fully analyzed in collect pass + TopLevelDecl::Union(_) => Ok(()), // Unions are fully analyzed in collect pass + TopLevelDecl::ErrorSet(_) => Ok(()), // Error sets are fully analyzed in collect pass + TopLevelDecl::FaultSet(_) => Ok(()), // Fault sets are fully analyzed in collect pass + TopLevelDecl::ExternFn(_) => Ok(()), // Extern functions are fully analyzed in collect pass + TopLevelDecl::DeclareGate(_) => Ok(()), // Fully analyzed in collect pass + TopLevelDecl::Gate(gate) => { + // Analyze composite gate body in a scope with qubit/param bindings + self.symbols.push_scope(ScopeKind::Function)?; + for qp in &gate.qubits { + self.symbols.define(Symbol { + name: qp.name.clone(), + kind: SymbolKind::Variable { + ty: Type::Qubit, + is_const: false, + is_comptime: false, + }, + location: qp.location.clone(), + })?; + } + for gp in &gate.params { + self.symbols.define(Symbol { + name: gp.name.clone(), + kind: SymbolKind::Variable { + ty: Type::A64, // Gate params are angles by default + is_const: false, + is_comptime: false, + }, + location: gp.location.clone(), + })?; + } + let result = self.analyze_block(&gate.body); + self.symbols.pop_scope(); + result + } + } + } + + /// Analyze a function declaration. + fn analyze_fn(&mut self, fn_decl: &FnDecl) -> SemanticResult<()> { + self.symbols.push_scope(ScopeKind::Function)?; + + // Set current function context + let prev_function = self.current_function.take(); + self.current_function = Some(fn_decl.name.clone()); + + // Track function in call stack for recursion detection (always enforced) + self.recursion_tracker.enter_function( + &fn_decl.name, + &fn_decl.location.clone().unwrap_or_default(), + )?; + + // Define parameters + for param in &fn_decl.params { + let ty = self.resolve_type(¶m.ty); + self.symbols.define(Symbol { + name: param.name.clone(), + kind: SymbolKind::Parameter { + ty: ty.clone(), + is_comptime: param.is_comptime, + }, + location: param.location.clone(), + })?; + } + + // Set return type for return statement checking + let return_type = fn_decl + .return_type + .as_ref() + .map(|t| self.resolve_type(t)); + self.current_return_type = return_type.clone(); + + // Analyze body + self.analyze_block(&fn_decl.body)?; + + // Check that all functions have explicit returns on all paths + // (NASA Power of 10: explicit control flow) + // - Unit functions must have explicit `return unit;` + // - Non-unit functions must have explicit `return expr;` + // - Never functions are exempt (they never return normally) + let is_never = matches!(&return_type, Some(Type::Never)); + if !is_never && !self.block_always_returns(&fn_decl.body) { + return Err(SemanticError::MissingReturn { + name: fn_decl.name.clone(), + location: fn_decl.location.clone().unwrap_or_default(), + }); + } + + // Exit recursion tracker (always enforced) + self.recursion_tracker.exit_function(&fn_decl.name); + + // Restore previous function context + self.current_return_type = None; + self.current_function = prev_function; + self.symbols.pop_scope(); + Ok(()) + } + + /// Analyze a struct declaration. + fn analyze_struct(&mut self, struct_decl: &StructDecl) -> SemanticResult<()> { + // Analyze default field values + for field in &struct_decl.fields { + if let Some(default) = &field.default { + let field_ty = self.resolve_type(&field.ty); + let expr_ty = self.analyze_expr(default)?; + self.check_assignable(&field_ty, &expr_ty, field.location.clone())?; + } + } + + // Analyze methods + for method in &struct_decl.methods { + self.analyze_fn(method)?; + } + + Ok(()) + } + + /// Check if a block always returns (all code paths have explicit return). + /// This enforces explicit returns - trailing expressions don't count. + fn block_always_returns(&self, block: &Block) -> bool { + // Check each statement - if any always returns, the block returns + for stmt in &block.statements { + if self.stmt_always_returns(stmt) { + return true; + } + } + // Also check trailing expression - if it always returns, block returns + // (e.g., `if (cond) { return 1; } else { return 2; }` as trailing expr) + if let Some(trailing) = &block.trailing_expr { + if self.expr_always_returns(trailing) { + return true; + } + } + false + } + + /// Check if a statement always returns. + fn stmt_always_returns(&self, stmt: &Stmt) -> bool { + match stmt { + Stmt::Return(_) => true, + + Stmt::If(if_stmt) => { + // If with else - both branches must return + if let Some(else_branch) = &if_stmt.else_body { + let then_returns = self.block_always_returns(&if_stmt.then_body); + let else_returns = self.else_branch_always_returns(else_branch); + then_returns && else_returns + } else { + // If without else - doesn't guarantee return + false + } + } + + Stmt::Block(block) => self.block_always_returns(block), + + Stmt::Switch(switch_stmt) => { + // All prongs must return, and there must be an else prong + let has_else = switch_stmt.prongs.iter().any(|p| p.is_else); + if !has_else { + return false; + } + switch_stmt.prongs.iter().all(|p| self.expr_always_returns(&p.body)) + } + + Stmt::For(for_stmt) => { + // For loop body might not execute at all + // Even if body returns, loop might have 0 iterations + // But if body has unreachable return, we can't reach after loop + // For simplicity, assume for loops don't guarantee return + let _ = for_stmt; + false + } + + // Expression statements might contain if expressions that return + Stmt::Expr(expr_stmt) => self.expr_always_returns(&expr_stmt.expr), + + // Other statements don't return + Stmt::Binding(_) + | Stmt::Alias(_) + | Stmt::Assign(_) + | Stmt::Tick(_) + | Stmt::TryBlock(_) + | Stmt::Break(_) + | Stmt::Continue(_) + | Stmt::Defer(_) + | Stmt::Errdefer(_) + | Stmt::Gate(_) + | Stmt::Prepare(_) + | Stmt::Measure(_) + | Stmt::Barrier(_) => false, + } + } + + /// Check if an else branch always returns. + fn else_branch_always_returns(&self, else_branch: &ElseBranch) -> bool { + match else_branch { + ElseBranch::ElseIf(nested_if) => { + // Recursively check the nested if statement + if let Some(else_body) = &nested_if.else_body { + let then_returns = self.block_always_returns(&nested_if.then_body); + let else_returns = self.else_branch_always_returns(else_body); + then_returns && else_returns + } else { + // else if without else - doesn't guarantee return + false + } + } + ElseBranch::Else(block) => self.block_always_returns(block), + } + } + + /// Check if an expression always returns. + /// This handles block expressions that might contain return statements. + fn expr_always_returns(&self, expr: &Expr) -> bool { + match expr { + Expr::Block(block_expr) => { + // Check statements in the block expression + for stmt in &block_expr.statements { + if self.stmt_always_returns(stmt) { + return true; + } + } + // Trailing expression doesn't count as return + false + } + Expr::If(if_expr) => { + // If expression always has both branches (ternary form) + // Both must return for the expression to always return + self.expr_always_returns(&if_expr.then_expr) + && self.expr_always_returns(&if_expr.else_expr) + } + // Most expressions don't contain return statements + _ => false, + } + } + + /// Analyze a binding declaration. + fn analyze_binding(&mut self, binding: &Binding) -> SemanticResult<()> { + if let Some(value) = &binding.value { + let expr_ty = self.analyze_expr(value)?; + + if let Some(type_expr) = &binding.ty { + let declared_ty = self.resolve_type(type_expr); + self.check_assignable(&declared_ty, &expr_ty, binding.location.clone())?; + } + } + + Ok(()) + } + + /// Analyze a block. + fn analyze_block(&mut self, block: &Block) -> SemanticResult<()> { + self.symbols.push_scope(ScopeKind::Block)?; + + for stmt in &block.statements { + self.analyze_stmt(stmt)?; + } + + self.symbols.pop_scope(); + Ok(()) + } + + /// Analyze a statement. + fn analyze_stmt(&mut self, stmt: &Stmt) -> SemanticResult<()> { + match stmt { + Stmt::Binding(binding) => { + let ty = if let Some(value) = &binding.value { + let expr_ty = self.analyze_expr(value)?; + + // Register allocator if this is a qalloc() call + // (gates don't require mut, only .child() does) + if let Some(capacity) = self.try_extract_allocator_capacity(value) { + self.qubit_states + .register_allocator(AllocatorInfo::new(&binding.name, capacity)); + } + // Register child allocator if this is base.child(n) + else if let Some((parent, capacity)) = + self.try_extract_child_allocator(value) + { + // Check that the parent allocator is mutable + if let Some(symbol) = self.symbols.lookup(&parent) + && let SymbolKind::Variable { is_const: true, .. } = &symbol.kind { + return Err(SemanticError::ChildRequiresMutableParent { + name: parent, + location: binding.location.clone().unwrap_or_default(), + }); + } + self.qubit_states.register_allocator(AllocatorInfo::child( + &binding.name, + parent, + capacity, + )); + } + + // Try to evaluate immutable bindings at comptime + if !binding.is_mutable { + let mut evaluator = ComptimeEvaluator::new(); + // Populate evaluator context with existing comptime values + for (name, comptime_val) in &self.comptime_values { + evaluator.context.define(name, comptime_val.clone()); + } + if let Ok(comptime_val) = evaluator.eval_expr(value) { + self.comptime_values.insert(binding.name.clone(), comptime_val); + } + } + + if let Some(type_expr) = &binding.ty { + let declared_ty = self.resolve_type(type_expr); + self.check_assignable(&declared_ty, &expr_ty, binding.location.clone())?; + declared_ty + } else { + expr_ty + } + } else if let Some(type_expr) = &binding.ty { + self.resolve_type(type_expr) + } else { + return Err(SemanticError::CannotInferType { + name: binding.name.clone(), + location: binding.location.clone().unwrap_or_default(), + }); + }; + + // If binding a struct/enum type, also register as TypeDef for type resolution + // This allows: Syndrome := struct { x: u8 }; mz(pack Syndrome) [...] + if let Type::Struct { fields, .. } = &ty { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Struct { + name: binding.name.clone(), + fields: fields.clone(), + }, + }, + location: binding.location.clone(), + })?; + } else if let Type::Enum { variants, .. } = &ty { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::TypeDef { + ty: Type::Enum { + name: binding.name.clone(), + variants: variants.clone(), + }, + }, + location: binding.location.clone(), + })?; + } else { + self.symbols.define(Symbol { + name: binding.name.clone(), + kind: SymbolKind::Variable { + ty, + is_const: !binding.is_mutable, + is_comptime: false, + }, + location: binding.location.clone(), + })?; + } + } + Stmt::Alias(alias) => { + self.analyze_alias(alias)?; + } + Stmt::Assign(assign) => { + // Check mutability before allowing assignment + self.check_assignment_target_mutable(&assign.target, &assign.location)?; + + let target_ty = self.analyze_expr(&assign.target)?; + let value_ty = self.analyze_expr(&assign.value)?; + self.check_assignable(&target_ty, &value_ty, assign.location.clone())?; + } + Stmt::If(if_stmt) => { + let cond_ty = self.analyze_expr(&if_stmt.condition)?; + + // Check if this is an optional unwrap pattern: if (opt) |value| { ... } + if let Some(capture_name) = &if_stmt.capture { + // Condition must be an optional type + if let Type::Optional { inner } = &cond_ty { + // Create a new scope for the then block with the capture variable + self.symbols.push_scope(ScopeKind::Block)?; + self.symbols.define(Symbol { + name: capture_name.clone(), + kind: SymbolKind::Variable { + ty: *inner.clone(), + is_const: true, + is_comptime: false, + }, + location: if_stmt.location.clone(), + })?; + self.analyze_block(&if_stmt.then_body)?; + self.symbols.pop_scope(); + } else { + return Err(SemanticError::TypeMismatch { + expected: "optional type (?T)".to_string(), + found: cond_ty.display_name(), + location: if_stmt.location.clone().unwrap_or_default(), + }); + } + } else { + // Regular if statement - condition must be bool + self.check_assignable(&Type::Bool, &cond_ty, if_stmt.location.clone())?; + self.analyze_block(&if_stmt.then_body)?; + } + + // Analyze else branch (no capture variable here) + if let Some(else_branch) = &if_stmt.else_body { + match else_branch { + ast::ElseBranch::ElseIf(else_if) => { + self.analyze_stmt(&Stmt::If(*else_if.clone()))?; + } + ast::ElseBranch::Else(block) => { + self.analyze_block(block)?; + } + } + } + } + Stmt::For(for_stmt) => { + self.symbols.push_scope(ScopeKind::Loop)?; + self.loop_depth += 1; + + // Inline for loops require comptime-evaluable ranges + if for_stmt.is_inline { + self.inline_for_depth += 1; + // Validate that range bounds are comptime-evaluable + match &for_stmt.range { + ForRange::Range { start, end } => { + let mut evaluator = ComptimeEvaluator::new(); + // Populate evaluator with known comptime values + for (name, val) in &self.comptime_values { + evaluator.context.define(name, val.clone()); + } + if evaluator.eval_expr(start).is_err() { + return Err(SemanticError::InlineForRangeNotComptime { + expr: format!("{:?}", start), + location: for_stmt.location.clone().unwrap_or_default(), + }); + } + if evaluator.eval_expr(end).is_err() { + return Err(SemanticError::InlineForRangeNotComptime { + expr: format!("{:?}", end), + location: for_stmt.location.clone().unwrap_or_default(), + }); + } + } + ForRange::Collection(expr) => { + let mut evaluator = ComptimeEvaluator::new(); + for (name, val) in &self.comptime_values { + evaluator.context.define(name, val.clone()); + } + if evaluator.eval_expr(expr).is_err() { + return Err(SemanticError::InlineForRangeNotComptime { + expr: format!("{:?}", expr), + location: for_stmt.location.clone().unwrap_or_default(), + }); + } + } + } + } + + // Infer type from range expression + let capture_type = self.infer_for_range_type(&for_stmt.range)?; + + // Define capture variables with inferred type + for capture in &for_stmt.captures { + self.symbols.define(Symbol { + name: capture.clone(), + kind: SymbolKind::Variable { + ty: capture_type.clone(), + is_const: true, + is_comptime: for_stmt.is_inline, + }, + location: for_stmt.location.clone(), + })?; + } + + self.analyze_block(&for_stmt.body)?; + + if for_stmt.is_inline { + self.inline_for_depth -= 1; + } + self.loop_depth -= 1; + self.symbols.pop_scope(); + } + Stmt::Switch(switch_stmt) => { + let _value_ty = self.analyze_expr(&switch_stmt.value)?; + + // Track seen case values for duplicate detection + let mut seen_cases: std::collections::BTreeSet = std::collections::BTreeSet::new(); + + for prong in &switch_stmt.prongs { + for case in &prong.cases { + self.analyze_expr(&case.value)?; + + // Try to get a string representation for duplicate detection + // For literals and simple expressions, use their string form + let case_key = self.case_value_key(&case.value); + if let Some(key) = case_key { + if !seen_cases.insert(key.clone()) { + return Err(SemanticError::DuplicateSwitchCase { + value: key, + location: case.location.clone().unwrap_or_default(), + }); + } + } + } + self.analyze_expr(&prong.body)?; + } + } + Stmt::Return(ret) => { + if let Some(value) = &ret.value { + let value_ty = self.analyze_expr(value)?; + if let Some(expected) = &self.current_return_type { + self.check_assignable(expected, &value_ty, ret.location.clone())?; + } + // Check for escaping references to local variables + // This is always enforced (safe-by-constraint memory model) + self.check_no_local_escape(value, ret.location.clone().unwrap_or_default())?; + } else { + // `return;` without a value is only allowed for unit functions + // (equivalent to `return unit;`) + if let Some(expected) = &self.current_return_type { + if !matches!(expected, Type::Unit) { + return Err(SemanticError::ReturnWithoutValue { + expected: expected.display_name(), + location: ret.location.clone().unwrap_or_default(), + }); + } + } + // If no return type specified, unit is implied - return; is valid + } + } + Stmt::Break(break_stmt) => { + if self.loop_depth == 0 { + return Err(SemanticError::BreakContinueOutsideLoop { + keyword: "break".to_string(), + location: break_stmt.location.clone().unwrap_or_default(), + }); + } + // break is not allowed in inline for loops + if self.inline_for_depth > 0 { + return Err(SemanticError::BreakInInlineFor { + location: break_stmt.location.clone().unwrap_or_default(), + }); + } + // Analyze break value if present + if let Some(value) = &break_stmt.value { + self.analyze_expr(value)?; + } + } + Stmt::Continue(continue_stmt) => { + if self.loop_depth == 0 { + return Err(SemanticError::BreakContinueOutsideLoop { + keyword: "continue".to_string(), + location: continue_stmt.location.clone().unwrap_or_default(), + }); + } + // continue is not allowed in inline for loops + if self.inline_for_depth > 0 { + return Err(SemanticError::ContinueInInlineFor { + location: continue_stmt.location.clone().unwrap_or_default(), + }); + } + } + Stmt::Defer(defer) => { + self.analyze_stmt(&defer.body)?; + } + Stmt::Errdefer(errdefer) => { + // If there's a capture, add it to scope for the body + if let Some(capture_name) = &errdefer.capture { + self.symbols.push_scope(ScopeKind::Block)?; + // The captured error has type anyerror (could be any error type) + self.symbols.define(Symbol { + name: capture_name.clone(), + kind: SymbolKind::Variable { + ty: Type::AnyError, + is_const: true, + is_comptime: false, + }, + location: errdefer.location.clone(), + })?; + self.analyze_stmt(&errdefer.body)?; + self.symbols.pop_scope(); + } else { + self.analyze_stmt(&errdefer.body)?; + } + } + Stmt::Block(block) => { + self.analyze_block(block)?; + } + Stmt::Expr(expr_stmt) => { + self.analyze_expr(&expr_stmt.expr)?; + } + Stmt::Gate(gate_op) => { + // Validate gate arity + if gate_op.targets.len() != gate_op.kind.arity() { + return Err(SemanticError::GateArityMismatch { + gate: format!("{:?}", gate_op.kind), + expected: gate_op.kind.arity(), + found: gate_op.targets.len(), + location: gate_op.location.clone().unwrap_or_default(), + }); + } + + // Validate targets are valid qubit references and check state + for target in &gate_op.targets { + self.validate_qubit_ref(target)?; + + // In strict mode, verify qubits are prepared + if self.strict_mode { + // Try to extract constant index for state tracking + if let Some(index) = self.try_extract_constant_usize(&target.index) { + let location = gate_op.location.clone().unwrap_or_default(); + self.qubit_states.validate_for_gate( + &target.allocator, + index, + &location, + )?; + } + // If index is not constant, we can't track state at compile time + // (runtime checking would be needed) + } + } + + // Validate parameters for parameterized gates + for param in &gate_op.params { + let param_ty = self.analyze_expr(param)?; + if !param_ty.is_float() && param_ty != Type::Unknown { + self.errors.push(SemanticError::TypeMismatch { + expected: "float".to_string(), + found: param_ty.display_name(), + location: gate_op.location.clone().unwrap_or_default(), + }); + } + } + } + Stmt::Prepare(prepare_op) => { + // Validate allocator exists + if self.symbols.lookup(&prepare_op.allocator).is_none() { + return Err(SemanticError::AllocatorNotFound { + name: prepare_op.allocator.clone(), + location: prepare_op.location.clone().unwrap_or_default(), + }); + } + + // Track state transitions + if let Some(alloc) = self + .qubit_states + .get_allocator_mut(&prepare_op.allocator) + { + if let Some(slots) = &prepare_op.slots { + // Prepare specific slots + for &slot in slots { + let slot_usize = slot as usize; + if self.strict_mode { + if let Err((idx, _state)) = alloc.prepare_slot(slot_usize) { + // Slot out of bounds or already prepared + if !alloc.is_in_bounds(idx) { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: prepare_op.allocator.clone(), + index: idx, + capacity: alloc.capacity.unwrap_or(0), + location: prepare_op.location.clone().unwrap_or_default(), + }); + } + } + } else { + let _ = alloc.prepare_slot(slot_usize); + } + } + } else { + // Prepare all slots + alloc.prepare_all(); + } + } + } + Stmt::Measure(measure_op) => { + for target in &measure_op.targets { + self.validate_qubit_ref(target)?; + + // Try to extract constant index for state tracking + if let Some(index) = self.try_extract_constant_usize(&target.index) { + // In strict mode, verify qubits are prepared before measurement + if self.strict_mode { + let location = target.location.clone().unwrap_or_default(); + self.qubit_states.validate_for_gate( + &target.allocator, + index, + &location, + )?; + } + + // Transition to unprepared after measurement + if let Some(alloc) = self.qubit_states.get_allocator_mut(&target.allocator) { + alloc.measure_slot(index); + } + } + // If index is not constant, we can't track state at compile time + } + } + Stmt::Barrier(_) => { + // Barriers are always valid + } + Stmt::Tick(tick_stmt) => { + // Tick blocks represent parallel gate layers - no nesting allowed + if self.tick_depth > 0 { + return Err(SemanticError::NestedTick { + location: tick_stmt.location.clone().unwrap_or_default(), + }); + } + + // In strict mode, validate that no qubit is used twice within a tick + if self.strict_mode { + self.check_duplicate_qubits_in_tick(&tick_stmt.body, &tick_stmt.location)?; + } + + // Track tick depth and analyze statements + self.tick_depth += 1; + for stmt in &tick_stmt.body { + self.analyze_stmt(stmt)?; + } + self.tick_depth -= 1; + } + Stmt::TryBlock(try_block) => { + // Analyze statements within the try block body + for stmt in &try_block.body.statements { + self.analyze_stmt(stmt)?; + } + // Analyze trailing expression if present + if let Some(trailing) = &try_block.body.trailing_expr { + self.analyze_expr(trailing)?; + } + // Analyze catch clause if present + if let Some(catch_clause) = &try_block.catch_clause { + // The catch variable is in scope for the catch body + // For now, just analyze the body expression + self.analyze_expr(&catch_clause.body)?; + } + } + } + Ok(()) + } + + /// Analyze an expression and return its type. + fn analyze_expr(&mut self, expr: &Expr) -> SemanticResult { + match expr { + Expr::IntLit(lit) => { + // Use suffix type if present, otherwise default to i64 + if let Some(suffix) = &lit.suffix { + Ok(int_suffix_to_type(suffix)) + } else { + Ok(Type::IInt { bits: BitWidth::BITS_64 }) + } + } + Expr::FloatLit(lit) => { + // Use suffix type if present, otherwise default to f64 + if let Some(suffix) = &lit.suffix { + Ok(float_suffix_to_type(suffix)) + } else { + Ok(Type::F64) + } + } + Expr::AngleLit(angle) => { + // Analyze the inner value expression + let inner_type = self.analyze_expr(&angle.value)?; + // Inner must be numeric + if !matches!( + inner_type, + Type::IInt { .. } + | Type::UInt { .. } + | Type::F32 + | Type::F64 + | Type::F16 + | Type::F128 + ) { + return Err(SemanticError::TypeMismatch { + expected: "numeric".to_string(), + found: format!("{:?}", inner_type), + location: angle.location.clone().unwrap_or_default(), + }); + } + // Angle literals have type a64 + Ok(Type::A64) + } + Expr::TypeAscription(asc) => { + // Analyze the inner value expression + let _inner_type = self.analyze_expr(&asc.value)?; + // Parse the type name and return that type + match asc.type_name.as_str() { + "f16" => Ok(Type::F16), + "f32" => Ok(Type::F32), + "f64" => Ok(Type::F64), + "f128" => Ok(Type::F128), + "a64" => Ok(Type::A64), + "u8" => Ok(Type::UInt { bits: BitWidth::BITS_8 }), + "u16" => Ok(Type::UInt { bits: BitWidth::BITS_16 }), + "u32" => Ok(Type::UInt { bits: BitWidth::BITS_32 }), + "u64" => Ok(Type::UInt { bits: BitWidth::BITS_64 }), + "u128" => Ok(Type::UInt { bits: BitWidth::BITS_128 }), + "usize" => Ok(Type::Usize), + "i8" => Ok(Type::IInt { bits: BitWidth::BITS_8 }), + "i16" => Ok(Type::IInt { bits: BitWidth::BITS_16 }), + "i32" => Ok(Type::IInt { bits: BitWidth::BITS_32 }), + "i64" => Ok(Type::IInt { bits: BitWidth::BITS_64 }), + "i128" => Ok(Type::IInt { bits: BitWidth::BITS_128 }), + "isize" => Ok(Type::Isize), + _ => Err(SemanticError::TypeMismatch { + expected: "valid numeric type suffix (u8, u16, u32, u64, i8, i16, i32, i64, f32, f64, a64, etc.)".to_string(), + found: asc.type_name.clone(), + location: asc.location.clone().unwrap_or_default(), + }), + } + } + Expr::BoolLit(_) => Ok(Type::Bool), + Expr::StringLit(_) => Ok(Type::Slice { + element: Box::new(Type::UInt { bits: BitWidth::BITS_8 }), + }), + Expr::FString(fstr) => { + // Analyze all interpolated expressions for errors + for part in &fstr.parts { + if let FStringPart::Expr { expr, format: _ } = part { + self.analyze_expr(expr)?; + } + } + // F-strings produce string slices + Ok(Type::Slice { + element: Box::new(Type::UInt { bits: BitWidth::BITS_8 }), + }) + } + Expr::CharLit(_) => Ok(Type::UInt { bits: BitWidth::BITS_8 }), + Expr::Null(_) => Ok(Type::Optional { + inner: Box::new(Type::Unknown), + }), + Expr::Undefined(_) => Ok(Type::Unknown), + Expr::Unit(_) => Ok(Type::Unit), + + Expr::Ident(ident) => { + if let Some(symbol) = self.symbols.lookup(&ident.name) { + match &symbol.kind { + SymbolKind::Variable { ty, .. } => Ok(ty.clone()), + SymbolKind::Parameter { ty, .. } => Ok(ty.clone()), + SymbolKind::Function { + params, + return_type, + .. + } => Ok(Type::Function { + params: params.iter().map(|(_, t)| t.clone()).collect(), + return_type: Box::new(return_type.clone()), + }), + SymbolKind::TypeDef { ty } => Ok(ty.clone()), + SymbolKind::Allocator { capacity } => { + Ok(Type::Allocator { capacity: *capacity }) + } + } + } else { + // Check if it's a built-in constant + if is_builtin_constant(&ident.name) { + Ok(get_builtin_constant_type(&ident.name)) + // Check if it's a built-in gate name + } else if is_gate_name(&ident.name) { + Ok(Type::Function { + params: vec![Type::Qubit], + return_type: Box::new(Type::Unit), + }) + // Check if it's a built-in type name (for comptime type values) + } else if resolve_builtin_type_name(&ident.name).is_some() { + Ok(Type::Type) // The expression evaluates to a type value + } else { + Err(SemanticError::UndefinedSymbol { + name: ident.name.clone(), + location: ident.location.clone().unwrap_or_default(), + }) + } + } + } + + Expr::Binary(binary) => { + let left_ty = self.analyze_expr(&binary.left)?; + let right_ty = self.analyze_expr(&binary.right)?; + self.check_binary_op(binary.op, &left_ty, &right_ty, binary.location.clone()) + } + + Expr::Unary(unary) => { + let operand_ty = self.analyze_expr(&unary.operand)?; + self.check_unary_op(unary.op, &operand_ty, unary.location.clone()) + } + + Expr::Call(call) => { + let callee_ty = self.analyze_expr(&call.callee)?; + + // Reject gate names used with call syntax - must use gate expression syntax + if let Expr::Ident(ident) = &call.callee { + if ident.name == "mz" { + // Reject old syntax: mz(type, target) - should use mz(T) target + return Err(SemanticError::DeprecatedMeasurementSyntax { + location: call.location.clone().unwrap_or_default(), + }); + } + if let Some(info) = get_gate_info(&ident.name) { + // Gate names cannot be called with function syntax + // Generate helpful hint based on gate type + let hint = if info.parameterized { + match info.arity { + 1 => "() q[i]".to_string(), + 2 => "() (q[i], q[j])".to_string(), + _ => "() (q[...])".to_string(), + } + } else { + match info.arity { + 1 => "q[i]".to_string(), + 2 => "(q[i], q[j])".to_string(), + 3 => "(q[i], q[j], q[k])".to_string(), + _ => "(q[...])".to_string(), + } + }; + return Err(SemanticError::InvalidGateSyntax { + gate: ident.name.clone(), + hint, + location: call.location.clone().unwrap_or_default(), + }); + } + + // Recursion is never allowed (NASA Power of 10 compliance) + // Use FFI with Rust if complex recursive algorithms are needed + // Check for both direct and mutual recursion using the call stack + if self.recursion_tracker.is_in_call_stack(&ident.name) { + return Err(SemanticError::RecursionDetected { + name: ident.name.clone(), + location: call.location.clone().unwrap_or_default(), + }); + } + + // Record call in call graph for mutual recursion detection + if let Some(ref caller) = self.current_function { + if self.user_functions.contains(&ident.name) { + self.call_graph + .entry(caller.clone()) + .or_default() + .insert(ident.name.clone()); + } + } + + // Check if this is a generic function that needs instantiation + // First, extract all needed data from the immutable borrow + let generic_info = self.symbols.lookup(&ident.name).and_then(|symbol| { + if let SymbolKind::Function { + comptime_param_indices, + original_decl, + return_type: fn_return_type, + .. + } = &symbol.kind + { + if !comptime_param_indices.is_empty() { + if let Some(original) = original_decl { + return Some(( + comptime_param_indices.clone(), + *original.clone(), + fn_return_type.clone(), + )); + } + } + } + None + }); + + // Now we can use the extracted data with a mutable borrow + if let Some((comptime_param_indices, original_decl, fn_return_type)) = generic_info { + // Evaluate comptime arguments + let mut comptime_args = Vec::new(); + for &idx in &comptime_param_indices { + if idx < call.args.len() { + // Try to evaluate the argument at comptime + match self.comptime.eval_expr(&call.args[idx]) { + Ok(val) => comptime_args.push(val), + Err(e) => { + return Err(SemanticError::ComptimeError { + message: format!( + "comptime argument {} must be evaluable at compile time: {}", + idx, e + ), + location: call.args[idx] + .get_location() + .unwrap_or_default(), + }); + } + } + } + } + + // Instantiate the generic function + let _mangled_name = self.instantiate_generic_function( + &ident.name, + &comptime_args, + &comptime_param_indices, + &original_decl, + )?; + + // For now, return the original return type + // (Full specialization would substitute types too) + return Ok(fn_return_type); + } + } + + match callee_ty { + Type::Function { params, return_type } => { + // Validate argument count + if call.args.len() != params.len() { + return Err(SemanticError::ArgumentCountMismatch { + expected: params.len(), + found: call.args.len(), + location: call.location.clone().unwrap_or_default(), + }); + } + + // Validate argument types + for (arg, param_ty) in + call.args.iter().zip(params.iter()) + { + let arg_ty = self.analyze_expr(arg)?; + if !self.types_compatible(&arg_ty, param_ty) { + return Err(SemanticError::TypeMismatch { + expected: param_ty.display_name(), + found: arg_ty.display_name(), + location: arg + .get_location() + .unwrap_or_else(|| call.location.clone().unwrap_or_default()), + }); + } + } + + Ok(*return_type) + } + _ => Err(SemanticError::NotCallable { + location: call.location.clone().unwrap_or_default(), + }), + } + } + + Expr::BatchApply(batch) => { + // Batch apply: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } + // Analyze the operation and targets + self.analyze_expr(&batch.operation)?; + + // Extract gate name to check arity + let gate_name = match &batch.operation { + Expr::Ident(ident) => Some(ident.name.clone()), + Expr::Call(call) => { + if let Expr::Ident(ident) = &call.callee { + Some(ident.name.clone()) + } else { + None + } + } + _ => None, + }; + + // Validate target arity if we know the gate + if let Some(ref name) = gate_name + && let Some(info) = get_gate_info(name) { + for target in &batch.targets { + let target_arity = self.count_target_elements(target); + if target_arity != info.arity { + return Err(SemanticError::GateArityMismatch { + gate: name.clone(), + expected: info.arity, + found: target_arity, + location: batch.location.clone().unwrap_or_default(), + }); + } + } + } + + for target in &batch.targets { + self.analyze_expr(target)?; + } + // Batch gate operations return unit + Ok(Type::Unit) + } + + Expr::Measure(measure) => { + // mz(T) target - per-qubit mode, count must match + // mz(pack T) target - pack mode, type must have enough bits + let location = measure.location.clone().unwrap_or_default(); + let result_type = self.resolve_type(&measure.result_type); + + // Count targets + let target_count = self.count_measurement_targets(&measure.targets)?; + + if measure.pack { + // Pack mode: bits fill the type, must have enough capacity + let bit_capacity = self.type_bit_size(&result_type); + + match bit_capacity { + Some(capacity) => { + if capacity < target_count { + return Err(SemanticError::MeasurementPackCapacity { + ty: result_type.display_name(), + capacity, + qubits: target_count, + location, + }); + } + } + None => { + // Pack mode requires compile-time verifiable bit size + return Err(SemanticError::MeasurementPackUnknownSize { + ty: result_type.display_name(), + location, + }); + } + } + + self.analyze_expr(&measure.targets)?; + self.validate_gate_target_bounds(&measure.targets)?; + Ok(result_type) + } else { + // Per-qubit mode: count must match exactly + match &result_type { + // Scalar type: must have exactly 1 target + Type::UInt { .. } => { + if target_count != 1 { + return Err(SemanticError::MeasurementArrayExpected { location }); + } + self.analyze_expr(&measure.targets)?; + self.validate_gate_target_bounds(&measure.targets)?; + Ok(result_type) + } + // Array type: size must be explicit and match target count + Type::Array { element, size } => { + // Validate element type is a valid measurement type (unsigned integer) + let is_valid_element = matches!(**element, Type::UInt { .. }); + if !is_valid_element { + return Err(SemanticError::InvalidMeasurementType { + ty: element.display_name(), + location, + }); + } + + // Size must be explicit (no [_]T inference for measurements) + let declared_size = match size { + Some(s) => *s, + None => { + return Err(SemanticError::InvalidMeasurementType { + ty: format!("[_]{} - use explicit size like [{}]{}", + element.display_name(), + target_count, + element.display_name()), + location, + }); + } + }; + + // Check size matches target count + if declared_size as usize != target_count { + return Err(SemanticError::MeasurementSizeMismatch { + declared: declared_size.to_string(), + element: element.display_name(), + actual: target_count, + location, + }); + } + + self.analyze_expr(&measure.targets)?; + self.validate_gate_target_bounds(&measure.targets)?; + Ok(Type::Array { + element: element.clone(), + size: Some(declared_size), + }) + } + // Struct type in per-qubit mode: each qubit produces one struct + Type::Struct { .. } => { + if target_count != 1 { + return Err(SemanticError::MeasurementArrayExpected { location }); + } + self.analyze_expr(&measure.targets)?; + self.validate_gate_target_bounds(&measure.targets)?; + Ok(result_type) + } + _ => Err(SemanticError::InvalidMeasurementType { + ty: result_type.display_name(), + location, + }), + } + } + } + + Expr::Gate(gate) => { + // gate target or gate(params) target + // Validate parameters for parameterized gates + let gate_kind = gate.kind; + if gate_kind.is_parameterized() { + if gate.params.is_empty() { + return Err(SemanticError::GateArityMismatch { + gate: format!("{:?}", gate_kind), + expected: 1, + found: 0, + location: gate.location.clone().unwrap_or_default(), + }); + } + // Analyze parameter expressions + for param in &gate.params { + self.analyze_expr(param)?; + } + } + + // For multi-qubit gates (arity > 1), reject bare allocator targets + // e.g., `cx q` is ambiguous - use `cx (q[0], q[1])` or `cx {(q[0], q[1]), ...}` + if gate_kind.arity() > 1 + && let Expr::Ident(ident) = &gate.target { + // Check if this is an allocator + if let Some(symbol) = self.symbols.lookup(&ident.name) { + let is_allocator = match &symbol.kind { + SymbolKind::Variable { ty, .. } => matches!(ty, Type::Allocator { .. }), + SymbolKind::Allocator { .. } => true, + _ => false, + }; + if is_allocator { + return Err(SemanticError::AmbiguousGateTarget { + gate: format!("{:?}", gate_kind).to_lowercase(), + location: gate.location.clone().unwrap_or_default(), + }); + } + } + } + + // Check gate target arity + let expected_arity = gate_kind.arity(); + match &gate.target { + // Set literal: each element must have correct arity + Expr::Set(set) => { + for element in &set.elements { + let found_arity = self.count_target_elements(element); + if found_arity != expected_arity { + return Err(SemanticError::GateArityMismatch { + gate: format!("{:?}", gate_kind).to_lowercase(), + expected: expected_arity, + found: found_arity, + location: gate.location.clone().unwrap_or_default(), + }); + } + } + } + // Single target (qubit ref or tuple) + target => { + let found_arity = self.count_target_elements(target); + if found_arity != expected_arity { + return Err(SemanticError::GateArityMismatch { + gate: format!("{:?}", gate_kind).to_lowercase(), + expected: expected_arity, + found: found_arity, + location: gate.location.clone().unwrap_or_default(), + }); + } + } + } + + // Analyze target expression + self.analyze_expr(&gate.target)?; + + // Validate qubit bounds for gate targets + self.validate_gate_target_bounds(&gate.target)?; + + // Handle prepare gates specially (PZ resets qubits to |0⟩) + if gate_kind.is_prepare() { + // Prepare gates can be applied to unprepared qubits + // and transition them to the Prepared state + self.prepare_gate_targets(&gate.target); + } else if self.strict_mode { + // In strict mode, verify qubits are prepared before non-prepare gates + self.validate_gate_target_states(&gate.target, &gate.location.clone().unwrap_or_default())?; + } + + // Gate operations are statements, return unit + Ok(Type::Unit) + } + + Expr::Field(field) => { + let object_ty = self.analyze_expr(&field.object)?; + match object_ty { + Type::Struct { fields, .. } => { + if let Some((_, ty)) = fields.iter().find(|(n, _)| n == &field.field) { + Ok(ty.clone()) + } else { + Err(SemanticError::UndefinedSymbol { + name: field.field.clone(), + location: field.location.clone().unwrap_or_default(), + }) + } + } + Type::Array { element, .. } => { + // Array properties + match field.field.as_str() { + "len" => Ok(Type::Usize), // Compile-time known length + "ptr" => Ok(Type::Pointer { + pointee: element, + is_const: false, + is_many: true, + }), + _ => Err(SemanticError::UndefinedSymbol { + name: field.field.clone(), + location: field.location.clone().unwrap_or_default(), + }), + } + } + Type::Slice { element } => { + // Slice properties + match field.field.as_str() { + "len" => Ok(Type::Usize), // Dynamic length + "ptr" => Ok(Type::Pointer { + pointee: element, + is_const: false, + is_many: true, + }), + _ => Err(SemanticError::UndefinedSymbol { + name: field.field.clone(), + location: field.location.clone().unwrap_or_default(), + }), + } + } + Type::Allocator { .. } => { + // Allocator methods + match field.field.as_str() { + "child" => Ok(Type::Function { + params: vec![Type::UInt { bits: BitWidth::BITS_32 }], + return_type: Box::new(Type::Allocator { capacity: None }), + }), + "release" => Ok(Type::Function { + params: vec![], + return_type: Box::new(Type::Unit), + }), + // Deprecated: use `pz q` or `pz {q[0], q[1]}` instead + "prepare_all" | "prepare" => { + Err(SemanticError::DeprecatedSyntax { + old: format!("{}.{}()", + if let Expr::Ident(id) = &field.object { &id.name } else { "allocator" }, + field.field), + new: if field.field == "prepare_all" { + "pz ".to_string() + } else { + "pz {q[i], q[j], ...}".to_string() + }, + location: field.location.clone().unwrap_or_default(), + }) + } + _ => Ok(Type::Unknown), + } + } + Type::Module { exports, .. } => { + // Module field access - look up exported symbol + if let Some((_, ty)) = exports.get(&field.field) { + Ok(ty.clone()) + } else { + Err(SemanticError::UndefinedSymbol { + name: field.field.clone(), + location: field.location.clone().unwrap_or_default(), + }) + } + } + _ => Ok(Type::Unknown), + } + } + + Expr::Index(index) => { + let object_ty = self.analyze_expr(&index.object)?; + let _index_ty = self.analyze_expr(&index.index)?; + + // Check if index is a range expression (slicing) + let is_slice_op = matches!(&index.index, Expr::Range(_)); + + match object_ty { + Type::Array { element, size } => { + if is_slice_op { + // arr[0..2] returns a slice + Ok(Type::Slice { element }) + } else { + // Bounds check: if both size and index are known at compile time + if let Some(n) = size { + if let Some(idx) = self.try_extract_constant_usize(&index.index) { + if idx >= n as usize { + return Err(SemanticError::ArrayIndexOutOfBounds { + index: idx, + size: n, + location: index.location.clone().unwrap_or_default(), + }); + } + } + } + // arr[0] returns an element + Ok(*element) + } + } + Type::Slice { element } => { + if is_slice_op { + // slice[0..2] returns a slice (re-slicing) + Ok(Type::Slice { element }) + } else { + // slice[0] returns an element + Ok(*element) + } + } + Type::Allocator { .. } => Ok(Type::Qubit), + _ => Ok(Type::Unknown), + } + } + + Expr::If(if_expr) => { + let cond_ty = self.analyze_expr(&if_expr.condition)?; + self.check_assignable(&Type::Bool, &cond_ty, if_expr.location.clone())?; + + let then_ty = self.analyze_expr(&if_expr.then_expr)?; + let else_ty = self.analyze_expr(&if_expr.else_expr)?; + + if then_ty == else_ty { + Ok(then_ty) + } else { + Ok(Type::Unknown) // Could be improved with type unification + } + } + + Expr::Block(block) => { + self.symbols.push_scope(ScopeKind::Block)?; + for stmt in &block.statements { + self.analyze_stmt(stmt)?; + } + self.symbols.pop_scope(); + Ok(Type::Unknown) // Block expression type depends on break value + } + + Expr::Comptime(comptime) => { + // First, analyze the inner expression to get its type + let inner_ty = self.analyze_expr(&comptime.inner)?; + + // Evaluate the expression at compile time + match self.comptime.eval_expr(&comptime.inner) { + Ok(value) => { + // Store the evaluated value by location key + if let Some(loc) = &comptime.location { + let key = format!("{}:{}", loc.line, loc.column); + self.comptime_values.insert(key, value); + } + Ok(Type::Comptime(Box::new(inner_ty))) + } + Err(e) => { + // Comptime evaluation failed + let location = comptime + .location + .clone() + .unwrap_or_else(|| SourceLocation::new(0, 0)); + Err(SemanticError::ComptimeError { + message: e.message, + location, + }) + } + } + } + + Expr::Builtin(builtin) => { + match builtin.name.as_str() { + "import" => self.analyze_import(builtin), + "This" => Ok(Type::Type), + "sizeOf" => Ok(Type::Usize), + "typeInfo" => Ok(Type::Type), + "typeName" => Ok(Type::Slice { + element: Box::new(Type::UInt { bits: BitWidth::BITS_8 }), + }), + "swap" => { + // @swap(&a, &b) - swap two values in place + // Requires exactly 2 pointer arguments of the same type + if builtin.args.len() != 2 { + return Err(SemanticError::ArgumentCountMismatch { + expected: 2, + found: builtin.args.len(), + location: builtin.location.clone().unwrap_or_default(), + }); + } + let ty1 = self.analyze_expr(&builtin.args[0])?; + let ty2 = self.analyze_expr(&builtin.args[1])?; + // Both must be pointers to the same type + match (&ty1, &ty2) { + (Type::Pointer { pointee: e1, .. }, Type::Pointer { pointee: e2, .. }) => { + if e1 != e2 { + return Err(SemanticError::TypeMismatch { + expected: format!("*{:?}", e1), + found: format!("*{:?}", e2), + location: builtin.location.clone().unwrap_or_default(), + }); + } + } + (Type::Pointer { .. }, _) => { + return Err(SemanticError::TypeMismatch { + expected: "pointer".to_string(), + found: format!("{:?}", ty2), + location: builtin.location.clone().unwrap_or_default(), + }); + } + (_, Type::Pointer { .. }) => { + return Err(SemanticError::TypeMismatch { + expected: "pointer".to_string(), + found: format!("{:?}", ty1), + location: builtin.location.clone().unwrap_or_default(), + }); + } + _ => { + return Err(SemanticError::TypeMismatch { + expected: "pointer".to_string(), + found: format!("{:?}", ty1), + location: builtin.location.clone().unwrap_or_default(), + }); + } + } + Ok(Type::Unit) + } + _ => Ok(Type::Unknown), + } + } + + Expr::AnonStruct(anon) => { + // Anonymous struct type definition: struct { x: i32, y: i32 } + // This creates a type, not a value + let fields: Vec<(String, Type)> = anon + .fields + .iter() + .map(|f| (f.name.clone(), self.resolve_type(&f.ty))) + .collect(); + Ok(Type::Struct { + name: "".to_string(), + fields, + }) + } + + Expr::StructInit(init) => { + if let Some(ty) = &init.ty { + Ok(self.resolve_type(ty)) + } else { + // Anonymous struct initialization + let mut fields: Vec<(String, Type)> = Vec::with_capacity(init.fields.len()); + for f in &init.fields { + let ty = match self.analyze_expr(&f.value) { + Ok(ty) => ty, + Err(e) => { + self.errors.push(e); + Type::Unknown + } + }; + fields.push((f.name.clone(), ty)); + } + Ok(Type::Struct { + name: "".to_string(), + fields, + }) + } + } + + Expr::ArrayInit(init) => { + let element_type = if let Some(elem) = init.elements.first() { + self.analyze_expr(elem)? + } else { + Type::Unknown + }; + Ok(Type::Array { + element: Box::new(element_type), + size: Some(init.elements.len() as u64), + }) + } + + Expr::Range(_) => Ok(Type::Unknown), // Range type + Expr::SlotRef(_) => Ok(Type::Qubit), + Expr::BitRef(_) => Ok(Type::Bit), + + Expr::BracketArray(arr) => { + // Bracket array [a, b, c] - infer element type from first element + if arr.elements.is_empty() { + // In strict mode, empty arrays require explicit type annotation + if self.strict_mode { + return Err(SemanticError::EmptyArrayNeedsType { + location: arr.location.clone().unwrap_or_default(), + }); + } + Ok(Type::Slice { + element: Box::new(Type::Unknown), + }) + } else { + let element_ty = self.analyze_expr(&arr.elements[0])?; + // Analyze all elements for side effects/validation + for elem in arr.elements.iter().skip(1) { + let _ = self.analyze_expr(elem)?; + } + Ok(Type::Array { + element: Box::new(element_ty), + size: Some(arr.elements.len() as u64), + }) + } + } + + Expr::Tuple(tuple) => { + // Tuple (a, b) - analyze each element and build tuple type + let element_types: Result, SemanticError> = tuple + .elements + .iter() + .map(|elem| self.analyze_expr(elem)) + .collect(); + Ok(Type::Tuple { + elements: element_types?, + }) + } + + Expr::Set(set_expr) => { + // Set literal {a, b, c} - infer element type from first element + if set_expr.elements.is_empty() { + // Empty set - check if we have an explicit element type + if let Some(type_expr) = &set_expr.element_type { + let element_ty = self.resolve_type(type_expr); + Ok(Type::Set { + element: Box::new(element_ty), + }) + } else { + // In strict mode, empty sets require explicit type annotation + if self.strict_mode { + return Err(SemanticError::EmptySetNeedsType { + location: set_expr.location.clone().unwrap_or_default(), + }); + } + Ok(Type::Set { + element: Box::new(Type::Unknown), + }) + } + } else { + let element_ty = self.analyze_expr(&set_expr.elements[0])?; + // Analyze all elements for side effects/validation + for elem in set_expr.elements.iter().skip(1) { + let _ = self.analyze_expr(elem)?; + } + Ok(Type::Set { + element: Box::new(element_ty), + }) + } + } + + Expr::ErrorValue(err) => { + // Look up which error set contains this variant + if let Some(error_type) = self.symbols.find_error_set_by_variant(&err.name) { + Ok(error_type) + } else { + Err(SemanticError::UndefinedSymbol { + name: format!("error.{}", err.name), + location: err.location.clone().unwrap_or_default(), + }) + } + } + + Expr::FaultValue(fault) => { + // Look up which fault set contains this variant + if let Some(fault_type) = self.symbols.find_fault_set_by_variant(&fault.name) { + Ok(fault_type) + } else { + Err(SemanticError::UndefinedSymbol { + name: format!("fault.{}", fault.name), + location: fault.location.clone().unwrap_or_default(), + }) + } + } + + Expr::Catch(catch) => { + // catch expression: operand catch |err| handler + // Type is the payload type of the error union operand + let operand_ty = self.analyze_expr(&catch.operand)?; + let _handler_ty = self.analyze_expr(&catch.handler)?; + + // If operand is an error union T!E, the result is T + match operand_ty { + Type::ErrorUnion { payload, .. } => Ok(*payload), + Type::Unknown => Ok(Type::Unknown), // Allow Unknown for error recovery + _ => { + // Non-error-union with catch - this is an error + Err(SemanticError::CatchOnNonErrorType { + found: operand_ty.display_name(), + location: catch.location.clone().unwrap_or_default(), + }) + } + } + } + + Expr::TryBlock(try_block) => { + // Analyze the try block body + for stmt in &try_block.body.statements { + self.analyze_stmt(stmt)?; + } + + // Get the type of trailing expression (if any) + let body_type = if let Some(trailing) = &try_block.body.trailing_expr { + self.analyze_expr(trailing)? + } else { + Type::Unit + }; + + // Analyze catch clause if present + let catch_type = if let Some(catch_clause) = &try_block.catch_clause { + Some(self.analyze_expr(&catch_clause.body)?) + } else { + None + }; + + // Return type depends on mode and whether there's a catch clause + use crate::ast::TryMode; + match try_block.mode { + TryMode::Collect => { + // try {} (collect mode) -> []AnyError!T + // Collects all errors that occur during execution + if catch_type.is_some() { + // With catch, errors are handled - return array of results + Ok(Type::Slice { + element: Box::new(body_type), + }) + } else { + // Without catch, return error union array + Ok(Type::Slice { + element: Box::new(Type::ErrorUnion { + error: Box::new(Type::AnyError), + payload: Box::new(body_type), + }), + }) + } + } + TryMode::Propagate => { + // try! {} (propagate mode) -> E!T or T (if catch handles it) + if let Some(catch_ty) = catch_type { + // With catch, the catch provides the fallback value + // Type is union of body_type and catch_type + if body_type == catch_ty { + Ok(body_type) + } else { + // Types must be compatible + Ok(body_type) + } + } else { + // Without catch, return error union + Ok(Type::ErrorUnion { + error: Box::new(Type::AnyError), + payload: Box::new(body_type), + }) + } + } + } + } + + Expr::FnLit(func) => { + // Function literal - return function type + // At comptime, these can return types (type constructors) + let param_types: Vec = func.params.iter() + .map(|p| self.resolve_type(&p.ty)) + .collect(); + let return_type = func.return_type.as_ref() + .map(|ty| self.resolve_type(ty)) + .unwrap_or(Type::Unit); + Ok(Type::Function { + params: param_types, + return_type: Box::new(return_type), + }) + } + + Expr::Result(result) => { + // Result expressions - emit tagged values to caller + // Tag is compile-time string (already validated by parser) + // Analyze the value expression + self.analyze_expr(&result.value)?; + + // Result expressions evaluate to unit + Ok(Type::Unit) + } + + Expr::Channel(channel) => { + // Channel expressions - >channel.command(args) + // Analyze all argument expressions + for arg in &channel.args { + self.analyze_expr(arg.value())?; + } + + // Channel expressions evaluate to unit + Ok(Type::Unit) + } + } + } + + /// Resolve a type expression to a semantic type. + fn resolve_type(&mut self, type_expr: &TypeExpr) -> Type { + match type_expr { + TypeExpr::Primitive(prim) => match prim { + PrimitiveType::Bool => Type::Bool, + PrimitiveType::UInt { bits } => Type::UInt { + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64) + }, + PrimitiveType::IInt { bits } => Type::IInt { + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64) + }, + PrimitiveType::Usize => Type::Usize, + PrimitiveType::Isize => Type::Isize, + PrimitiveType::F16 => Type::F16, + PrimitiveType::F32 => Type::F32, + PrimitiveType::F64 => Type::F64, + PrimitiveType::F128 => Type::F128, + PrimitiveType::A64 => Type::A64, + }, + TypeExpr::Qubit => Type::Qubit, + TypeExpr::Bit => Type::Bit, + TypeExpr::QAlloc(_) => Type::Allocator { capacity: None }, + TypeExpr::Array(array) => { + let element = self.resolve_type(&array.element); + // Evaluate size expression at comptime if present + if let Some(size_expr) = &array.size { + let mut evaluator = ComptimeEvaluator::new(); + // Populate evaluator context with stored comptime values (for const propagation) + for (name, value) in &self.comptime_values { + evaluator.context.define(name, value.clone()); + } + let size = if let Ok(value) = evaluator.eval_expr(size_expr) { + value.to_usize().map(|n| n as u64) + } else { + None + }; + Type::Array { + element: Box::new(element), + size, + } + } else { + // []T with no size is a slice type + Type::Slice { + element: Box::new(element), + } + } + } + TypeExpr::Pointer(ptr) => { + let pointee = self.resolve_type(&ptr.pointee); + Type::Pointer { + pointee: Box::new(pointee), + is_const: ptr.is_const, + is_many: ptr.is_many, + } + } + TypeExpr::Optional(inner) => Type::Optional { + inner: Box::new(self.resolve_type(inner)), + }, + TypeExpr::ErrorUnion(eu) => Type::ErrorUnion { + error: Box::new(self.resolve_type(&eu.error_type)), + payload: Box::new(self.resolve_type(&eu.payload_type)), + }, + TypeExpr::CollectedErrors(ce) => Type::CollectedErrors { + error: Box::new(self.resolve_type(&ce.error_type)), + payload: Box::new(self.resolve_type(&ce.payload_type)), + }, + TypeExpr::Tuple(elements) => { + let resolved: Vec = elements.iter().map(|t| self.resolve_type(t)).collect(); + Type::Tuple { elements: resolved } + } + TypeExpr::Fn(fn_type) => { + let params: Vec = fn_type.params.iter().map(|t| self.resolve_type(t)).collect(); + let return_type = fn_type + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + Type::Function { + params, + return_type: Box::new(return_type), + } + } + TypeExpr::Named(path) => { + let name = path.segments.join("."); + if let Some(symbol) = self.symbols.lookup(&name) + && let SymbolKind::TypeDef { ty } = &symbol.kind { + return ty.clone(); + } + // Report error for undefined type name + self.errors.push(SemanticError::UndefinedType { + name: name.clone(), + location: path.location.clone().unwrap_or_default(), + }); + Type::Unknown + } + TypeExpr::Type => Type::Type, + TypeExpr::AnyType => Type::Unknown, + TypeExpr::Unit => Type::Unit, + TypeExpr::Set(element_type) => Type::Set { + element: Box::new(self.resolve_type(element_type)), + }, + TypeExpr::Struct(s) => { + // Anonymous struct type + let fields = s.fields.iter() + .map(|f| (f.name.clone(), self.resolve_type(&f.ty))) + .collect(); + Type::Struct { + name: String::new(), // Anonymous + fields, + } + } + TypeExpr::Enum(e) => { + // Anonymous enum type + let variants = e.variants.iter() + .map(|v| v.name.clone()) + .collect(); + Type::Enum { + name: String::new(), // Anonymous + variants, + } + } + } + } + + /// Check if an assignment target is mutable. + /// Returns an error if trying to assign to an immutable variable. + fn check_assignment_target_mutable( + &self, + target: &Expr, + location: &Option, + ) -> SemanticResult<()> { + match target { + // Direct variable assignment: x = value + Expr::Ident(ident) => { + if let Some(symbol) = self.symbols.lookup(&ident.name) { + match &symbol.kind { + SymbolKind::Variable { is_const: true, .. } => { + return Err(SemanticError::ImmutableAssignment { + name: ident.name.clone(), + location: location.clone().unwrap_or_default(), + }); + } + SymbolKind::Parameter { .. } => { + // Parameters are always immutable + return Err(SemanticError::ImmutableAssignment { + name: ident.name.clone(), + location: location.clone().unwrap_or_default(), + }); + } + _ => {} + } + } + Ok(()) + } + // Field assignment: obj.field = value - check root object mutability + Expr::Field(field) => self.check_assignment_target_mutable(&field.object, location), + // Index assignment: arr[i] = value - check root object mutability + Expr::Index(index) => self.check_assignment_target_mutable(&index.object, location), + // Dereference assignment: *ptr = value - allowed if pointer is valid + Expr::Unary(unary) if unary.op == ast::UnaryOp::Deref => Ok(()), + // Other expressions (like function calls) can't be assigned to + _ => Ok(()), + } + } + + /// Get a string key for a case value expression for duplicate detection. + /// Returns Some(key) for literals and simple expressions that can be compared. + fn case_value_key(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(lit) => Some(lit.value.to_string()), + Expr::FloatLit(lit) => Some(lit.value.to_string()), + Expr::BoolLit(lit) => Some(lit.value.to_string()), + Expr::StringLit(lit) => Some(format!("\"{}\"", lit.value)), + Expr::CharLit(lit) => Some(format!("'{}'", lit.value)), + Expr::Ident(ident) => Some(ident.name.clone()), + Expr::Field(fa) => { + // For enum variants like Color.Red + if let Expr::Ident(ident) = &fa.object { + Some(format!("{}.{}", ident.name, fa.field)) + } else { + None + } + } + // For complex expressions, we can't easily detect duplicates + _ => None, + } + } + + /// Check if a value type is assignable to a target type. + fn check_assignable( + &self, + target: &Type, + value: &Type, + location: Option, + ) -> SemanticResult<()> { + // Same type is always ok + if target == value { + return Ok(()); + } + + // Unknown types are compatible with anything (for inference) + if *target == Type::Unknown || *value == Type::Unknown { + return Ok(()); + } + + // Allow null (?unknown) to be assigned to any optional type ?T + if let (Type::Optional { .. }, Type::Optional { inner }) = (target, value) + && **inner == Type::Unknown { + // null (which is ?unknown) can be assigned to any ?T + return Ok(()); + } + + // Allow numeric coercion between numeric types only + // (but NOT from numeric to bool or vice versa) + if target.is_numeric() && value.is_numeric() { + return Ok(()); + } + + // Allow T to be assigned to T!E (returning success from error union function) + if let Type::ErrorUnion { payload, .. } = target + && self.check_assignable(payload.as_ref(), value, location.clone()).is_ok() { + return Ok(()); + } + + // Allow error value to be assigned to T!E (returning error from error union function) + if let Type::ErrorUnion { error, .. } = target { + // Check if value is an error type that's compatible with the expected error type + match value { + // Same error set - always compatible + Type::ErrorSet { name: value_name, errors: value_errors } => { + if let Type::ErrorSet { name: expected_name, errors: expected_errors } = error.as_ref() { + // Exact match + if value_name == expected_name { + return Ok(()); + } + // Value's errors are a subset of expected's errors (union compatibility) + if value_errors.iter().all(|e| expected_errors.contains(e)) { + return Ok(()); + } + } + // Also allow if expected is AnyError + if *error.as_ref() == Type::AnyError { + return Ok(()); + } + } + // AnyError can be returned from any error union + Type::AnyError => return Ok(()), + _ => {} + } + } + + // Allow fault value to be assigned to T!F (returning fault from fault union function) + if let Type::ErrorUnion { error, .. } = target { + if let Type::FaultSet { name: value_name, faults: value_faults } = value { + if let Type::FaultSet { name: expected_name, faults: expected_faults } = error.as_ref() { + // Exact match + if value_name == expected_name { + return Ok(()); + } + // Value's faults are a subset of expected's faults (union compatibility) + if value_faults.iter().all(|f| expected_faults.contains(f)) { + return Ok(()); + } + } + // Also allow if expected is AnyFault + if *error.as_ref() == Type::AnyFault { + return Ok(()); + } + } + } + + Err(SemanticError::TypeMismatch { + expected: target.display_name(), + found: value.display_name(), + location: location.unwrap_or_default(), + }) + } + + /// Check if two types are compatible (for function argument checking). + /// Returns true if `value` can be passed where `expected` is required. + fn types_compatible(&self, value: &Type, expected: &Type) -> bool { + // Same type is always compatible + if value == expected { + return true; + } + + // Unknown types are compatible with anything (for inference) + if *value == Type::Unknown || *expected == Type::Unknown { + return true; + } + + // Allow numeric coercion between numeric types + if value.is_numeric() && expected.is_numeric() { + return true; + } + + // Allow T to be passed where ?T is expected + if let Type::Optional { inner } = expected + && self.types_compatible(value, inner) { + return true; + } + + false + } + + /// Infer the element type from a for loop range. + /// Returns the type that the loop variable should have. + fn infer_for_range_type(&mut self, range: &ForRange) -> SemanticResult { + match range { + ForRange::Range { start, end } => { + // Analyze start and end expressions to get their types + let start_ty = self.analyze_expr(start)?; + let end_ty = self.analyze_expr(end)?; + + // For numeric ranges, prefer the start type if both are numeric + // If one is Unknown, use the other + if start_ty == Type::Unknown { + if end_ty == Type::Unknown { + // Both unknown, default to usize for indices + Ok(Type::Usize) + } else { + Ok(end_ty) + } + } else if end_ty == Type::Unknown || start_ty == end_ty { + Ok(start_ty) + } else if start_ty.is_numeric() && end_ty.is_numeric() { + // Both numeric but different - use start type + Ok(start_ty) + } else { + // Mismatched types - default to usize + Ok(Type::Usize) + } + } + ForRange::Collection(expr) => { + // Analyze the collection expression + let coll_ty = self.analyze_expr(expr)?; + + // Extract element type from collection + match coll_ty { + Type::Array { element, .. } => Ok(*element), + Type::Slice { element } => Ok(*element), + Type::Set { element } => Ok(*element), + Type::Allocator { .. } => Ok(Type::Qubit), // Iterating over qubit allocator + _ => { + // For other types (including Pointer), default to usize + Ok(Type::Usize) + } + } + } + } + } + + /// Check that an expression doesn't escape a reference to a local variable. + /// This prevents returning pointers/slices to stack-allocated data. + fn check_no_local_escape(&self, expr: &Expr, location: SourceLocation) -> SemanticResult<()> { + match expr { + // &x - check if x is a local variable + Expr::Unary(unary) if unary.op == UnaryOp::AddrOf => { + if let Some(name) = self.get_local_var_name(&unary.operand) { + return Err(SemanticError::ReturnReferenceToLocal { name, location }); + } + } + // arr[start..end] - check if arr is a local array (slice creation) + Expr::Range(range) => { + // Range expressions in return context could be slices + // For now, we check if the operands reference locals + if let Some(start) = &range.start { + self.check_no_local_escape(start, location.clone())?; + } + if let Some(end) = &range.end { + self.check_no_local_escape(end, location.clone())?; + } + } + // Index with range: arr[0..n] + Expr::Index(index) => { + // Check if this is a slice (index is a range) of a local array + if matches!(index.index, Expr::Range(_)) { + if let Some(name) = self.get_local_var_name(&index.object) { + return Err(SemanticError::ReturnSliceOfLocal { name, location }); + } + } + } + // Tuple/struct with references inside - check each element + Expr::Tuple(tuple) => { + for elem in &tuple.elements { + self.check_no_local_escape(elem, location.clone())?; + } + } + Expr::StructInit(init) => { + for field in &init.fields { + self.check_no_local_escape(&field.value, location.clone())?; + } + } + Expr::BracketArray(arr) => { + for elem in &arr.elements { + self.check_no_local_escape(elem, location.clone())?; + } + } + _ => {} + } + Ok(()) + } + + /// Get the name of a local variable if the expression is a simple identifier + /// referring to a variable defined in the current function scope (not a parameter). + fn get_local_var_name(&self, expr: &Expr) -> Option { + if let Expr::Ident(ident) = expr { + // Check if this identifier is a local variable (not a parameter or global) + if let Some(symbol) = self.symbols.lookup(&ident.name) { + match &symbol.kind { + SymbolKind::Variable { .. } => { + // It's a variable - check if it's in function scope (local) + // For now, we consider all variables in function scope as local + // Parameters are tracked separately as SymbolKind::Parameter + return Some(ident.name.clone()); + } + SymbolKind::Parameter { .. } => { + // Parameters are borrowed from caller, so returning ¶m is OK + // (the caller owns the data, not us) + return None; + } + _ => return None, + } + } + } + None + } + + /// Check binary operator types. + fn check_binary_op( + &self, + op: BinaryOp, + left: &Type, + right: &Type, + location: Option, + ) -> SemanticResult { + match op { + BinaryOp::Add | BinaryOp::Mul | BinaryOp::Div | BinaryOp::Mod => { + if left.is_numeric() && right.is_numeric() { + Ok(left.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "numeric".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::Sub => { + // - works for numeric (subtraction) and Set (difference) + if left.is_numeric() && right.is_numeric() { + Ok(left.clone()) + } else if let (Type::Set { element: l_elem }, Type::Set { element: r_elem }) = + (left, right) + { + // Set difference returns a set of the same element type + let _ = r_elem; // Both sets should have compatible element types + Ok(Type::Set { + element: l_elem.clone(), + }) + } else { + Err(SemanticError::TypeMismatch { + expected: "numeric or Set".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::Eq | BinaryOp::Ne => Ok(Type::Bool), + BinaryOp::Lt | BinaryOp::Le | BinaryOp::Gt | BinaryOp::Ge => { + // These work for numeric (comparison) and Set (subset/superset) + if left.is_numeric() && right.is_numeric() { + Ok(Type::Bool) + } else if matches!((left, right), (Type::Set { .. }, Type::Set { .. })) { + // Set comparisons: < (proper subset), <= (subset), > (proper superset), >= (superset) + Ok(Type::Bool) + } else { + Err(SemanticError::TypeMismatch { + expected: "numeric or Set".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::And | BinaryOp::Or => { + if *left == Type::Bool && *right == Type::Bool { + Ok(Type::Bool) + } else { + Err(SemanticError::TypeMismatch { + expected: "bool".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::Orelse => { + // orelse: ?T orelse T -> T + // Left must be optional, right must be assignable to inner type + if let Type::Optional { inner } = left { + // Check if right is assignable to inner type (allows numeric coercion) + self.check_assignable(inner, right, location.clone())?; + Ok(*inner.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "optional type (?T)".to_string(), + found: left.display_name(), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::BitAnd | BinaryOp::BitOr | BinaryOp::BitXor => { + // Bitwise ops work for integers, and also for sets: + // & = intersection, | = union, ^ = symmetric difference + if left.is_integer() && right.is_integer() { + Ok(left.clone()) + } else if let (Type::Set { element: l_elem }, Type::Set { element: _ }) = + (left, right) + { + // Set operations return a set of the same element type + Ok(Type::Set { + element: l_elem.clone(), + }) + } else if let ( + Type::ErrorSet { name: l_name, errors: l_errors }, + Type::ErrorSet { name: r_name, errors: r_errors }, + ) = (left, right) + { + // Error set union: ErrorA || ErrorB + // Only BitOr makes sense for error sets (union) + if op != BinaryOp::BitOr { + return Err(SemanticError::TypeMismatch { + expected: "|| (union) operator for error sets".to_string(), + found: format!("{:?}", op), + location: location.unwrap_or_default(), + }); + } + // Combine error variants, deduplicating + let mut combined_errors = l_errors.clone(); + for err in r_errors { + if !combined_errors.contains(err) { + combined_errors.push(err.clone()); + } + } + Ok(Type::ErrorSet { + name: format!("{}||{}", l_name, r_name), + errors: combined_errors, + }) + } else if let ( + Type::FaultSet { name: l_name, faults: l_faults }, + Type::FaultSet { name: r_name, faults: r_faults }, + ) = (left, right) + { + // Fault set union: FaultA || FaultB + if op != BinaryOp::BitOr { + return Err(SemanticError::TypeMismatch { + expected: "|| (union) operator for fault sets".to_string(), + found: format!("{:?}", op), + location: location.unwrap_or_default(), + }); + } + let mut combined_faults = l_faults.clone(); + for fault in r_faults { + if !combined_faults.contains(fault) { + combined_faults.push(fault.clone()); + } + } + Ok(Type::FaultSet { + name: format!("{}||{}", l_name, r_name), + faults: combined_faults, + }) + } else { + Err(SemanticError::TypeMismatch { + expected: "integer, Set, or error/fault set".to_string(), + found: format!("{} and {}", left.display_name(), right.display_name()), + location: location.unwrap_or_default(), + }) + } + } + BinaryOp::Shl | BinaryOp::Shr => { + if left.is_integer() { + Ok(left.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "integer".to_string(), + found: left.display_name(), + location: location.unwrap_or_default(), + }) + } + } + + BinaryOp::In | BinaryOp::NotIn => { + // Membership operators: element in Set(element) -> bool + if let Type::Set { element: set_elem } = right { + // Check that left type matches the set's element type + // For now, just return bool - more strict checking can be added later + let _ = set_elem; // Acknowledge we have the element type + Ok(Type::Bool) + } else { + Err(SemanticError::TypeMismatch { + expected: "Set".to_string(), + found: right.display_name(), + location: location.unwrap_or_default(), + }) + } + } + + BinaryOp::Catch => { + // catch: T!E catch handler -> T + // Left should be error union, right is handler that returns T + match left { + Type::ErrorUnion { payload, .. } => { + // Handler should return payload type (or be compatible) + // For now, just check that handler produces a value + let _ = right; // Handler type - could validate more strictly + Ok(*payload.clone()) + } + _ => Err(SemanticError::TypeMismatch { + expected: "error union (T!E)".to_string(), + found: left.display_name(), + location: location.unwrap_or_default(), + }), + } + } + } + } + + /// Check unary operator types. + fn check_unary_op( + &self, + op: UnaryOp, + operand: &Type, + location: Option, + ) -> SemanticResult { + match op { + UnaryOp::Neg => { + if operand.is_numeric() { + Ok(operand.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "numeric".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }) + } + } + UnaryOp::Not => { + if *operand == Type::Bool { + Ok(Type::Bool) + } else { + Err(SemanticError::TypeMismatch { + expected: "bool".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }) + } + } + UnaryOp::BitNot => { + if operand.is_integer() { + Ok(operand.clone()) + } else { + Err(SemanticError::TypeMismatch { + expected: "integer".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }) + } + } + UnaryOp::AddrOf => Ok(Type::Pointer { + pointee: Box::new(operand.clone()), + is_const: false, + is_many: false, + }), + UnaryOp::Deref => match operand { + Type::Pointer { pointee, .. } => Ok(*pointee.clone()), + _ => Err(SemanticError::TypeMismatch { + expected: "pointer".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }), + }, + UnaryOp::OptionalUnwrap => match operand { + Type::Optional { inner } => Ok(*inner.clone()), + _ => Err(SemanticError::TypeMismatch { + expected: "optional".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }), + }, + UnaryOp::ErrorUnwrap => { + // For error unions, unwrap returns the success type + match operand { + Type::ErrorUnion { payload, .. } => Ok(*payload.clone()), + _ => Err(SemanticError::TypeMismatch { + expected: "error union (T!E)".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }), + } + } + UnaryOp::Try => { + // try: T!E -> T, propagates E to caller + match operand { + Type::ErrorUnion { payload, .. } => Ok(*payload.clone()), + _ => Err(SemanticError::TypeMismatch { + expected: "error union (T!E)".to_string(), + found: operand.display_name(), + location: location.unwrap_or_default(), + }), + } + } + } + } + + /// Validate a qubit reference. + fn validate_qubit_ref(&self, slot_ref: &ast::SlotRef) -> SemanticResult<()> { + // First check that the allocator exists in the symbol table + let is_allocator = if let Some(symbol) = self.symbols.lookup(&slot_ref.allocator) { + match &symbol.kind { + SymbolKind::Variable { ty, .. } | SymbolKind::Parameter { ty, .. } => { + matches!(ty, Type::Allocator { .. }) + } + SymbolKind::Allocator { .. } => true, + _ => false, + } + } else { + false + }; + + if !is_allocator { + return Err(SemanticError::AllocatorNotFound { + name: slot_ref.allocator.clone(), + location: slot_ref.location.clone().unwrap_or_default(), + }); + } + + // Check bounds if both index and capacity are known at compile time + // Get capacity from qubit_states (where qalloc capacity is tracked) + if let Some(alloc_info) = self.qubit_states.get_allocator(&slot_ref.allocator) + && let Some(capacity) = alloc_info.capacity + && let Some(index) = self.try_extract_constant_usize(&slot_ref.index) + && index >= capacity { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: slot_ref.allocator.clone(), + index, + capacity, + location: slot_ref.location.clone().unwrap_or_default(), + }); + } + + Ok(()) + } + + /// Validate qubit bounds for gate target expressions. + /// This handles Index expressions (q[5]), tuples, sets, etc. + fn validate_gate_target_bounds(&self, target: &Expr) -> SemanticResult<()> { + match target { + Expr::Index(index_expr) => { + // Check if this is an allocator index access (q[5]) + if let Expr::Ident(ident) = &index_expr.object { + let allocator_name = &ident.name; + + // Check if this is an allocator + let is_allocator = if let Some(symbol) = self.symbols.lookup(allocator_name) { + matches!(&symbol.kind, + SymbolKind::Variable { ty: Type::Allocator { .. }, .. } | + SymbolKind::Allocator { .. } + ) + } else { + false + }; + + if is_allocator { + // Get capacity from qubit_states + if let Some(alloc_info) = self.qubit_states.get_allocator(allocator_name) + && let Some(capacity) = alloc_info.capacity + && let Some(index) = self.try_extract_constant_usize(&index_expr.index) + && index >= capacity { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: allocator_name.clone(), + index, + capacity, + location: index_expr.location.clone().unwrap_or_default(), + }); + } + } + } + Ok(()) + } + Expr::Tuple(tuple) => { + // Validate each element in the tuple (e.g., (q[0], q[1])) + for elem in &tuple.elements { + self.validate_gate_target_bounds(elem)?; + } + Ok(()) + } + Expr::Set(set_expr) => { + // Validate each element in the set (e.g., {q[0], q[1], q[2]}) + for elem in &set_expr.elements { + self.validate_gate_target_bounds(elem)?; + } + Ok(()) + } + Expr::BracketArray(array) => { + // Validate each element in the array (e.g., [q[0], q[1], q[2]]) + for elem in &array.elements { + self.validate_gate_target_bounds(elem)?; + } + Ok(()) + } + _ => Ok(()), + } + } + + /// Validate qubit states for gate target expressions (strict mode only). + /// This ensures qubits are prepared before gates are applied. + fn validate_gate_target_states( + &self, + target: &Expr, + location: &SourceLocation, + ) -> SemanticResult<()> { + // Extract qubit IDs from the target expression + let qubit_ids = self.extract_qubit_ids_from_arg(target); + + // Validate each qubit is prepared + for (allocator, index) in qubit_ids { + self.qubit_states.validate_for_gate(&allocator, index, location)?; + } + + Ok(()) + } + + /// Prepare qubits targeted by a prepare gate (PZ). + /// Transitions targeted qubits to the Prepared state. + fn prepare_gate_targets(&mut self, target: &Expr) { + // Extract qubit IDs from the target expression + let qubit_ids = self.extract_qubit_ids_from_arg(target); + + // Transition each qubit to Prepared state + for (allocator, index) in qubit_ids { + if let Some(alloc) = self.qubit_states.get_allocator_mut(&allocator) { + let _ = alloc.prepare_slot(index); + } + } + + // Also handle the case where the target is a bare allocator (pz q; prepares all) + if let Expr::Ident(ident) = target { + if let Some(alloc) = self.qubit_states.get_allocator_mut(&ident.name) { + alloc.prepare_all(); + } + } + } + + /// Check for duplicate qubit usage within a tick block. + /// In quantum computing, parallel operations cannot target the same qubit. + fn check_duplicate_qubits_in_tick( + &self, + statements: &[ast::Stmt], + tick_location: &Option, + ) -> SemanticResult<()> { + // Collect all qubit identifications: (allocator_name, constant_index) + let mut seen_qubits: BTreeSet<(String, usize)> = BTreeSet::new(); + + for stmt in statements { + let qubits = self.collect_qubit_ids_from_stmt(stmt); + + for (allocator, index) in qubits { + let key = (allocator.clone(), index); + if !seen_qubits.insert(key) { + // Duplicate found + return Err(SemanticError::DuplicateQubitInTick { + allocator, + index, + location: tick_location.clone().unwrap_or_default(), + }); + } + } + } + + Ok(()) + } + + /// Collect qubit identifications (allocator, index) from a statement. + fn collect_qubit_ids_from_stmt(&self, stmt: &ast::Stmt) -> Vec<(String, usize)> { + match stmt { + // Stmt::Gate uses SlotRef + ast::Stmt::Gate(gate_op) => gate_op + .targets + .iter() + .filter_map(|slot_ref| { + self.try_extract_constant_usize(&slot_ref.index) + .map(|idx| (slot_ref.allocator.clone(), idx)) + }) + .collect(), + // Stmt::Measure uses SlotRef + ast::Stmt::Measure(measure_op) => measure_op + .targets + .iter() + .filter_map(|slot_ref| { + self.try_extract_constant_usize(&slot_ref.index) + .map(|idx| (slot_ref.allocator.clone(), idx)) + }) + .collect(), + // PrepareOp doesn't target individual qubits in tick context + ast::Stmt::Prepare(_) => Vec::new(), + // Nested tick blocks recursively check their contents + ast::Stmt::Tick(tick_stmt) => tick_stmt + .body + .iter() + .flat_map(|s| self.collect_qubit_ids_from_stmt(s)) + .collect(), + // Expression statements can contain gate calls (h(q[0]), cx(q[0], q[1]), etc.) + ast::Stmt::Expr(expr_stmt) => self.collect_qubit_ids_from_expr(&expr_stmt.expr), + // Other statements don't contain qubit references + _ => Vec::new(), + } + } + + /// Collect qubit identifications from an expression (for gate calls and measurements). + fn collect_qubit_ids_from_expr(&self, expr: &Expr) -> Vec<(String, usize)> { + match expr { + // Gate expression: h q[0], cx (q[0], q[1]), rx(angle) q[0], etc. + Expr::Gate(gate) => { + // Extract qubit IDs from the gate target + self.extract_qubit_ids_from_arg(&gate.target) + } + // Measure expression: mz(u1) q[0], mz(u8) q[0..8], etc. + Expr::Measure(measure) => { + // Extract qubit IDs from the measurement target + self.extract_qubit_ids_from_arg(&measure.targets) + } + // Direct function call: h(q[0]), cx(q[0], q[1]), etc. + Expr::Call(call) => { + // Check if this is a gate call + if let Expr::Ident(ident) = &call.callee + && is_gate_name(&ident.name) { + // Collect qubit IDs from arguments + return call + .args + .iter() + .flat_map(|arg| self.extract_qubit_ids_from_arg(arg)) + .collect(); + } + Vec::new() + } + // Batch apply: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } + Expr::BatchApply(batch) => { + // Extract gate name from operation + let gate_name = match &batch.operation { + Expr::Ident(ident) => Some(&ident.name), + Expr::Call(call) => { + if let Expr::Ident(ident) = &call.callee { + Some(&ident.name) + } else { + None + } + } + _ => None, + }; + if let Some(name) = gate_name + && is_gate_name(name) { + return batch + .targets + .iter() + .flat_map(|target| self.extract_qubit_ids_from_arg(target)) + .collect(); + } + Vec::new() + } + _ => Vec::new(), + } + } + + /// Extract qubit (allocator, index) from a gate argument expression. + fn extract_qubit_ids_from_arg(&self, expr: &Expr) -> Vec<(String, usize)> { + match expr { + // Index expression: q[0] + Expr::Index(index_expr) => { + if let Expr::Ident(ident) = &index_expr.object + && let Some(idx) = self.try_extract_constant_usize(&index_expr.index) { + return vec![(ident.name.clone(), idx)]; + } + Vec::new() + } + // Tuple of qubits: (q[0], q[1]) for two-qubit gates + Expr::Tuple(tuple) => tuple + .elements + .iter() + .flat_map(|e| self.extract_qubit_ids_from_arg(e)) + .collect(), + // Address-of array: &[q[0], q[1]] + Expr::Unary(unary) if unary.op == ast::UnaryOp::AddrOf => { + self.extract_qubit_ids_from_arg(&unary.operand) + } + // Bracket array: [q[0], q[1]] + Expr::BracketArray(arr) => arr + .elements + .iter() + .flat_map(|e| self.extract_qubit_ids_from_arg(e)) + .collect(), + // Set literal: [q[0], q[1] + Expr::Set(set) => set + .elements + .iter() + .flat_map(|e| self.extract_qubit_ids_from_arg(e)) + .collect(), + _ => Vec::new(), + } + } + + // ========================================================================= + // Allocator Extraction Helpers + // ========================================================================= + + /// Try to extract allocator capacity from a qalloc(n) call. + fn try_extract_allocator_capacity(&self, expr: &Expr) -> Option { + if let Expr::Call(call) = expr + && let Expr::Ident(ident) = &call.callee + && ident.name == "qalloc" && call.args.len() == 1 { + return self.try_extract_constant_usize(&call.args[0]); + } + None + } + + /// Try to extract child allocator info from base.child(n) call. + fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { + if let Expr::Call(call) = expr + && let Expr::Field(field) = &call.callee + && field.field == "child" && call.args.len() == 1 + && let Expr::Ident(parent_ident) = &field.object { + let capacity = self.try_extract_constant_usize(&call.args[0])?; + return Some((parent_ident.name.clone(), capacity)); + } + None + } + + /// Try to extract a constant usize from an expression. + fn try_extract_constant_usize(&self, expr: &Expr) -> Option { + match expr { + Expr::IntLit(lit) => Some(lit.value as usize), + Expr::Ident(ident) => { + // Try to look up a comptime constant in our stored values + if let Some(symbol) = self.symbols.lookup(&ident.name) + && let SymbolKind::Variable { + ty: Type::Comptime(_), + is_const: true, + .. + } = &symbol.kind + { + // Look up the value in the comptime evaluator context + if let Some(val) = self.comptime.context.lookup(&ident.name) { + return val.to_usize(); + } + } + None + } + // For other expressions, try comptime evaluation + _ => { + // Create a temporary evaluator to try evaluation + let mut evaluator = ComptimeEvaluator::new(); + if let Ok(value) = evaluator.eval_expr(expr) { + value.to_usize() + } else { + None + } + } + } + } + + /// Count the number of elements in a gate target expression. + /// Used for batch gate arity checking. + /// - Single qubit ref (q[0]) => 1 + /// - Tuple of 2 (q[0], q[1]) => 2 + /// - Tuple of 3 (q[0], q[1], q[2]) => 3 + fn count_target_elements(&self, expr: &Expr) -> usize { + match expr { + Expr::Tuple(tuple) => tuple.elements.len(), + // Single element (qubit ref, identifier, etc.) + _ => 1, + } + } + + // ========================================================================= + // NASA Power of 10 Helpers + // ========================================================================= + + /// Check if a loop has a valid bound (NASA Power of 10 Rule 2). + fn check_loop_bound(&self, bound: usize, location: &SourceLocation) -> SemanticResult<()> { + if bound > MAX_LOOP_BOUND { + return Err(SemanticError::LoopBoundTooLarge { + bound, + max: MAX_LOOP_BOUND, + location: location.clone(), + }); + } + Ok(()) + } + + // ========================================================================= + // Typed Measurement Analysis + // ========================================================================= + + /// Analyze a typed measurement call: mz(T) target + /// + /// Examples: + /// - `mz(u1) q[0]` - single qubit, returns u1 + /// - `mz([]u1, &[q[0], q[1]])` - multiple qubits, returns []u1 + fn analyze_typed_measurement(&mut self, call: &ast::CallExpr) -> SemanticResult { + let location = call.location.clone().unwrap_or_default(); + + // Must have exactly 2 arguments: type and target + if call.args.len() != 2 { + return Err(SemanticError::MeasurementMissingArgs { location }); + } + + // Extract measurement result type from first argument + let result_type = self.extract_measurement_type(&call.args[0])?; + + // Extract and validate targets from second argument + let targets = self.extract_measurement_targets(&call.args[1])?; + + // Check for duplicate qubits (array elements must be unique but ordered) + self.check_measurement_uniqueness(&targets, &location)?; + + // Validate qubit states if in strict mode + for (allocator, index) in &targets { + if self.strict_mode { + self.qubit_states.validate_for_gate(allocator, *index, &location)?; + } + // Transition to unprepared after measurement + if let Some(alloc) = self.qubit_states.get_allocator_mut(allocator) { + alloc.measure_slot(*index); + } + } + + // Return type depends on whether targets is single or multiple + // For single target, return scalar; for multiple, return slice + if targets.len() == 1 { + // Single target: mz(u1) q[0] returns u1 + Ok(result_type) + } else { + // Multiple targets: mz([]u1, &[q[0], q[1]]) returns []u1 + // The result_type should already be a slice type + Ok(result_type) + } + } + + /// Extract measurement result type from the type argument. + /// + /// Valid types: u1, u8, u64, []u1, []u8, []u64 + fn extract_measurement_type(&self, expr: &Expr) -> SemanticResult { + let location = expr.get_location().unwrap_or_default(); + + match expr { + // Simple type: u1, u8, u64, etc. (arbitrary bit-width) + Expr::Ident(ident) => { + // Parse arbitrary unsigned integer type: u + if let Some(bits_str) = ident.name.strip_prefix('u') + && let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) { + return Ok(Type::UInt { bits: bw }); + } + Err(SemanticError::InvalidMeasurementType { + ty: ident.name.clone(), + location, + }) + } + + // Array type: []u1, []u8, []u64 - parsed as array_type_expr + Expr::ArrayInit(arr) => { + // Empty array init [] with element type + // This is how []u1 might be parsed - check the type + if arr.elements.is_empty() { + // Need to get element type from context + Ok(Type::Slice { + element: Box::new(Type::UInt { bits: BitWidth::BITS_1 }), // Default to u1 + }) + } else { + Err(SemanticError::InvalidMeasurementType { + ty: "array literal".to_string(), + location, + }) + } + } + + // SlotRef for []type syntax (array type expression) + // The parser might produce this for []u1 + Expr::SlotRef(slot_ref) => { + // This might be []u1 parsed as a slot ref with allocator="u1" + // Parse arbitrary unsigned integer type from allocator name + if let Some(bits_str) = slot_ref.allocator.strip_prefix('u') + && let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) { + return Ok(Type::Slice { + element: Box::new(Type::UInt { bits: bw }), + }); + } + Err(SemanticError::InvalidMeasurementType { + ty: format!("[]{}", slot_ref.allocator), + location, + }) + } + + _ => Err(SemanticError::InvalidMeasurementType { + ty: "unknown".to_string(), + location, + }), + } + } + + /// Count measurement targets from the target expression. + /// + /// Accepts: + /// - Single qubit: q[0] → 1 + /// - Bracket array: [q[0], q[1]] → element count + /// - Allocator: q → allocator capacity + fn count_measurement_targets(&self, expr: &Expr) -> SemanticResult { + match expr { + // Single qubit: q[0] + Expr::Index(_) => Ok(1), + + // Bracket array: [q[0], q[1]] + Expr::BracketArray(arr) => Ok(arr.elements.len()), + + // Allocator: q (measure all qubits) + Expr::Ident(ident) => { + if let Some(alloc) = self.qubit_states.get_allocator(&ident.name) { + if let Some(capacity) = alloc.capacity { + Ok(capacity) + } else { + // Unknown capacity - can't validate at compile time + Ok(0) // Will be validated at runtime + } + } else { + Ok(0) // Not an allocator, will be caught by analyze_expr + } + } + + _ => Ok(0), // Will be caught by analyze_expr + } + } + + /// Calculate the bit size of a type for pack mode validation. + /// + /// Returns None if the size cannot be determined at compile time. + fn type_bit_size(&self, ty: &Type) -> Option { + match ty { + // Arbitrary-width integers + Type::UInt { bits } | Type::IInt { bits } => Some(bits.get() as usize), + Type::Bool => Some(1), + Type::Array { element, size } => { + if let (Some(elem_bits), Some(arr_size)) = (self.type_bit_size(element), size) { + Some(elem_bits * (*arr_size as usize)) + } else { + None + } + } + Type::Struct { fields, .. } => { + let mut total = 0; + for (_, field_ty) in fields { + if let Some(bits) = self.type_bit_size(field_ty) { + total += bits; + } else { + return None; + } + } + Some(total) + } + Type::Tuple { elements } => { + let mut total = 0; + for elem_ty in elements { + if let Some(bits) = self.type_bit_size(elem_ty) { + total += bits; + } else { + return None; + } + } + Some(total) + } + _ => None, // Unknown size for other types + } + } + + /// Extract measurement targets from the target argument (legacy). + /// + /// Accepts: + /// - Single qubit: q[0] + /// - Array of qubits: &[q[0], q[1]] + fn extract_measurement_targets(&self, expr: &Expr) -> SemanticResult> { + let location = expr.get_location().unwrap_or_default(); + + match expr { + // Single qubit: q[0] + Expr::Index(index) => { + let (allocator, idx) = self.extract_qubit_from_index(index)?; + Ok(vec![(allocator, idx)]) + } + + // Address-of array: &[q[0], q[1]] + Expr::Unary(unary) if matches!(unary.op, UnaryOp::AddrOf) => { + match &unary.operand { + Expr::BracketArray(arr) => { + let mut targets = Vec::new(); + for elem in &arr.elements { + if let Expr::Index(index) = elem { + let (allocator, idx) = self.extract_qubit_from_index(index)?; + targets.push((allocator, idx)); + } else { + return Err(SemanticError::InvalidQubitRef { location }); + } + } + Ok(targets) + } + _ => Err(SemanticError::InvalidQubitRef { location }), + } + } + + _ => Err(SemanticError::InvalidQubitRef { location }), + } + } + + /// Extract allocator name and index from an index expression. + fn extract_qubit_from_index(&self, index: &ast::IndexExpr) -> SemanticResult<(String, usize)> { + let location = index.location.clone().unwrap_or_default(); + + // Get allocator name + let allocator = match &index.object { + Expr::Ident(ident) => ident.name.clone(), + _ => return Err(SemanticError::InvalidQubitRef { location }), + }; + + // Get index (must be comptime-known for uniqueness checking) + let idx = self.try_extract_constant_usize(&index.index) + .ok_or(SemanticError::InvalidQubitRef { location })?; + + Ok((allocator, idx)) + } + + /// Check that all qubits in measurement are unique. + fn check_measurement_uniqueness( + &self, + targets: &[(String, usize)], + location: &SourceLocation, + ) -> SemanticResult<()> { + let mut seen = BTreeSet::new(); + for (allocator, index) in targets { + let key = (allocator.clone(), *index); + if !seen.insert(key) { + return Err(SemanticError::DuplicateQubitInMeasurement { + allocator: allocator.clone(), + index: *index, + location: location.clone(), + }); + } + } + Ok(()) + } + + /// Analyze an @import builtin expression. + fn analyze_import(&mut self, builtin: &ast::BuiltinExpr) -> SemanticResult { + let location = builtin.location.clone().unwrap_or_default(); + + // Extract the import path from the first argument + if builtin.args.is_empty() { + return Err(SemanticError::TypeMismatch { + expected: "string literal".to_string(), + found: "no arguments".to_string(), + location, + }); + } + + let import_path = match &builtin.args[0] { + Expr::StringLit(s) => s.value.clone(), + _ => { + return Err(SemanticError::TypeMismatch { + expected: "string literal".to_string(), + found: "non-string expression".to_string(), + location, + }); + } + }; + + // Try to load the module + let from_file = self.current_file.as_deref(); + match self.module_loader.load(&import_path, from_file) { + Ok(module) => { + // Clone the exports and path to release the borrow on module_loader + let module_exports = module.exports.clone(); + let module_path = module.path.display().to_string(); + // module reference is now released after cloning + + // Build exports map for the type + let mut exports = std::collections::BTreeMap::new(); + for (name, export) in &module_exports { + let (kind, ty) = match export { + ExportedSymbol::Function { params, return_type, .. } => { + // Extract function signature from AST + let param_types: Vec = params + .iter() + .map(|(_, ty)| self.resolve_type(ty)) + .collect(); + let ret_type = return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + (ModuleExportKind::Function, Type::Function { + params: param_types, + return_type: Box::new(ret_type), + }) + } + ExportedSymbol::Const { .. } => { + (ModuleExportKind::Const, Type::Unknown) + } + ExportedSymbol::Type { .. } => { + (ModuleExportKind::Type, Type::Type) + } + ExportedSymbol::ErrorSet { variants, .. } => { + // Imported error sets don't carry associated data types + let errors: Vec<(String, Option>)> = variants + .iter() + .map(|v| (v.clone(), None)) + .collect(); + (ModuleExportKind::ErrorSet, Type::ErrorSet { + name: name.clone(), + errors, + }) + } + ExportedSymbol::FaultSet { variants, .. } => { + // Imported fault sets don't carry associated data types + let faults: Vec<(String, Option>)> = variants + .iter() + .map(|v| (v.clone(), None)) + .collect(); + (ModuleExportKind::FaultSet, Type::FaultSet { + name: name.clone(), + faults, + }) + } + }; + exports.insert(name.clone(), (kind, ty)); + } + + Ok(Type::Module { + path: module_path, + exports, + }) + } + Err(e) => { + // Module loading failed - report as semantic error + Err(SemanticError::ModuleError { + message: e.to_string(), + location, + }) + } + } + } + + // ========================================================================= + // Alias Analysis + // ========================================================================= + + /// Analyze an alias statement. + /// Validates that the source is a slice expression and checks for overlaps. + fn analyze_alias(&mut self, alias: &crate::ast::AliasBinding) -> SemanticResult<()> { + let location = alias.location.clone().unwrap_or_default(); + + // Extract source variable and range from the alias source expression + let (source_name, range) = self.extract_alias_source_info(&alias.source, &location)?; + + // Check for overlaps with existing aliases on the same source + for (existing_name, existing_info) in &self.aliases { + if existing_info.source == source_name { + if let (Some(new_range), Some(existing_range)) = (range, existing_info.range) { + if Self::ranges_overlap(new_range, existing_range) { + return Err(SemanticError::OverlappingAlias { + new_alias: alias.name.clone(), + existing_alias: existing_name.clone(), + source_var: source_name.clone(), + overlap_range: format!( + "{}..{} overlaps with {}..{}", + new_range.0, new_range.1, + existing_range.0, existing_range.1 + ), + location, + }); + } + } + } + } + + // Analyze the source expression for type checking + let source_ty = self.analyze_expr(&alias.source)?; + + // Store alias info for future overlap checks + self.aliases.insert( + alias.name.clone(), + AliasInfo { + name: alias.name.clone(), + source: source_name.clone(), + range, + location: location.clone(), + }, + ); + + // Register the alias as a variable in the symbol table + self.symbols.define(Symbol { + name: alias.name.clone(), + kind: SymbolKind::Variable { + ty: source_ty, + is_const: true, // Aliases are always immutable in MVP + is_comptime: false, + }, + location: Some(location), + })?; + + Ok(()) + } + + /// Extract the source variable name and static range from an alias source expression. + fn extract_alias_source_info( + &self, + expr: &crate::ast::Expr, + location: &SourceLocation, + ) -> SemanticResult<(String, Option<(i64, i64)>)> { + // The source must be a slice expression: source[start..end] + if let crate::ast::Expr::Index(index) = expr { + // Get the base name + let source_name = self.extract_base_name(&index.object)?; + + // Get the range if it's a RangeExpr + if let crate::ast::Expr::Range(range_expr) = &index.index { + // Try to evaluate bounds at comptime + let start = if let Some(start_expr) = &range_expr.start { + self.try_eval_comptime_int(start_expr) + } else { + Some(0) // Default start is 0 + }; + + let end = if let Some(end_expr) = &range_expr.end { + self.try_eval_comptime_int(end_expr) + } else { + None // Open-ended range + }; + + if let (Some(s), Some(e)) = (start, end) { + return Ok((source_name, Some((s, e)))); + } else { + // Range is not fully comptime - allow but skip overlap checking + return Ok((source_name, None)); + } + } + } + + // Not a valid slice expression + Err(SemanticError::AliasSourceNotSlice { + found: format!("{:?}", expr), + location: location.clone(), + }) + } + + /// Extract the base variable name from an expression. + fn extract_base_name(&self, expr: &crate::ast::Expr) -> SemanticResult { + match expr { + crate::ast::Expr::Ident(ident) => Ok(ident.name.clone()), + crate::ast::Expr::Index(index) => self.extract_base_name(&index.object), + crate::ast::Expr::Field(field) => self.extract_base_name(&field.object), + _ => Ok("".to_string()), + } + } + + /// Try to evaluate an expression as a comptime integer. + fn try_eval_comptime_int(&self, expr: &crate::ast::Expr) -> Option { + let mut evaluator = ComptimeEvaluator::new(); + // Populate with known comptime values + for (name, val) in &self.comptime_values { + evaluator.context.define(name, val.clone()); + } + match evaluator.eval_expr(expr) { + Ok(ComptimeValue::Int(n)) => Some(n), + Ok(ComptimeValue::Uint(n)) => Some(n as i64), + _ => None, + } + } + + /// Check if two ranges overlap. + fn ranges_overlap(a: (i64, i64), b: (i64, i64)) -> bool { + // Ranges [a.0, a.1) and [b.0, b.1) overlap if: + // a.0 < b.1 && b.0 < a.1 + a.0 < b.1 && b.0 < a.1 + } + + // ========================================================================= + // Generic Type Instantiation + // ========================================================================= + + /// Serialize comptime values to a string key for caching. + fn serialize_comptime_args(args: &[ComptimeValue]) -> String { + args.iter() + .map(|v| format!("{}", v)) + .collect::>() + .join("_") + } + + /// Generate a mangled name for a specialized function. + fn mangle_generic_name(base_name: &str, comptime_args: &[ComptimeValue]) -> String { + let args_suffix = comptime_args + .iter() + .map(|v| match v { + ComptimeValue::Int(n) => format!("{}", n), + ComptimeValue::Uint(n) => format!("{}", n), + ComptimeValue::Bool(b) => if *b { "true" } else { "false" }.to_string(), + ComptimeValue::Type(t) => t.display_name().replace(' ', "_"), + ComptimeValue::String(s) => s.replace(' ', "_"), + _ => format!("{:?}", v), + }) + .collect::>() + .join("__"); + format!("{}__CT__{}", base_name, args_suffix) + } + + /// Instantiate a generic function with concrete comptime arguments. + /// Returns the mangled name of the specialized function. + pub fn instantiate_generic_function( + &mut self, + fn_name: &str, + comptime_args: &[ComptimeValue], + comptime_param_indices: &[usize], + original_decl: &crate::ast::FnDecl, + ) -> SemanticResult { + // Create cache key + let args_key = Self::serialize_comptime_args(comptime_args); + let cache_key = (fn_name.to_string(), args_key.clone()); + + // Check if already instantiated + if let Some(mangled_name) = self.generic_instantiations.get(&cache_key) { + return Ok(mangled_name.clone()); + } + + // Generate mangled name for the specialized function + let mangled_name = Self::mangle_generic_name(fn_name, comptime_args); + + // Clone the original declaration and substitute comptime params + let mut specialized = original_decl.clone(); + specialized.name = mangled_name.clone(); + + // Build a mapping from comptime param names to their concrete values + let mut comptime_bindings: BTreeMap = BTreeMap::new(); + for (i, ¶m_idx) in comptime_param_indices.iter().enumerate() { + if param_idx < original_decl.params.len() && i < comptime_args.len() { + let param_name = &original_decl.params[param_idx].name; + comptime_bindings.insert(param_name.clone(), comptime_args[i].clone()); + } + } + + // Remove comptime parameters from the specialized function + // (they become concrete values, not parameters) + specialized.params = original_decl + .params + .iter() + .enumerate() + .filter(|(i, _)| !comptime_param_indices.contains(i)) + .map(|(_, p)| p.clone()) + .collect(); + + // Store the comptime bindings for use during analysis of the specialized function + for (name, value) in &comptime_bindings { + self.comptime_values.insert(name.clone(), value.clone()); + self.comptime.context.define(name, value.clone()); + } + + // Register the specialized function in the symbol table + let params: Vec<(String, Type)> = specialized + .params + .iter() + .map(|p| (p.name.clone(), self.resolve_type(&p.ty))) + .collect(); + let return_type = specialized + .return_type + .as_ref() + .map(|t| self.resolve_type(t)) + .unwrap_or(Type::Unit); + + self.symbols.define(Symbol { + name: mangled_name.clone(), + kind: SymbolKind::Function { + params, + return_type, + is_pub: false, // Specialized functions are internal + comptime_param_indices: vec![], // No longer generic + original_decl: None, + }, + location: specialized.location.clone(), + })?; + + // Store the specialized function for later codegen + self.specialized_functions.push(specialized); + + // Cache the instantiation + self.generic_instantiations + .insert(cache_key, mangled_name.clone()); + + Ok(mangled_name) + } + + /// Get the list of specialized functions generated during analysis. + pub fn get_specialized_functions(&self) -> &[crate::ast::FnDecl] { + &self.specialized_functions + } +} + +impl Default for SemanticAnalyzer { + fn default() -> Self { + Self::new() + } +} + +/// Gate information for semantic analysis. +struct GateInfo { + /// Number of qubit arguments (arity) + arity: usize, + /// Whether the gate takes angle parameters + parameterized: bool, +} + +/// Get gate information by name. +/// Returns None if the name is not a recognized gate. +fn get_gate_info(name: &str) -> Option { + match name { + // Single-qubit Pauli gates (non-parameterized, arity 1) + "h" | "x" | "y" | "z" => Some(GateInfo { arity: 1, parameterized: false }), + // Square root gates (sx, sy, sz and their daggers) + // Note: S gate is "sz" not "s", Sdg is "szdg" not "sdg" + "sx" | "sy" | "sz" | "sxdg" | "sydg" | "szdg" => Some(GateInfo { arity: 1, parameterized: false }), + // T gates (fourth root of Z) + "t" | "tdg" => Some(GateInfo { arity: 1, parameterized: false }), + // F gates + "f" | "fdg" | "f4" | "f4dg" => Some(GateInfo { arity: 1, parameterized: false }), + // Rotation gates (parameterized, arity 1) + "rx" | "ry" | "rz" => Some(GateInfo { arity: 1, parameterized: true }), + // Two-qubit gates (non-parameterized, arity 2) + "cx" | "cy" | "cz" | "ch" => Some(GateInfo { arity: 2, parameterized: false }), + "swap" | "iswap" => Some(GateInfo { arity: 2, parameterized: false }), + // Square-root two-qubit gates + "sxx" | "syy" | "szz" | "sxxdg" | "syydg" | "szzdg" => Some(GateInfo { arity: 2, parameterized: false }), + // Controlled rotation (parameterized, arity 2) + "crz" | "rzz" => Some(GateInfo { arity: 2, parameterized: true }), + // Three-qubit gates + "ccx" => Some(GateInfo { arity: 3, parameterized: false }), + // Special operations (handled separately but recognized as gates) + "mz" | "pz" => Some(GateInfo { arity: 1, parameterized: false }), + _ => None, + } +} + +/// Check if a name is a built-in gate. +/// All gate names must be lowercase. +fn is_gate_name(name: &str) -> bool { + get_gate_info(name).is_some() +} + +/// Check if a name is a built-in constant. +fn is_builtin_constant(name: &str) -> bool { + matches!(name, "pi" | "tau" | "e") +} + +/// Get the type of a built-in constant. +fn get_builtin_constant_type(name: &str) -> Type { + match name { + "pi" | "tau" | "e" => Type::A64, + _ => Type::Unknown, + } +} + +/// Maximum valid bit width for integer types (matches Rust's i128/u128). +const MAX_INT_BITS: u16 = 128; + +/// Validate that a bit width is valid (1-128). +fn is_valid_bit_width(bits: u16) -> bool { + (1..=MAX_INT_BITS).contains(&bits) +} + +/// Resolve a built-in type name to a Type (for comptime type values). +/// Returns Some(Type) if the name is a built-in type, None otherwise. +/// Returns None for invalid bit widths (e.g., u0, u9999). +fn resolve_builtin_type_name(name: &str) -> Option { + // Special cases first + match name { + "bool" => return Some(Type::Bool), + "usize" => return Some(Type::Usize), + "isize" => return Some(Type::Isize), + "f16" => return Some(Type::F16), + "f32" => return Some(Type::F32), + "f64" => return Some(Type::F64), + "f128" => return Some(Type::F128), + "a64" => return Some(Type::A64), + "type" => return Some(Type::Type), + "unit" => return Some(Type::Unit), + "qubit" => return Some(Type::Qubit), + "bit" => return Some(Type::Bit), + _ => {} + } + + // Arbitrary-width integers: u or i + if let Some(bits_str) = name.strip_prefix('u') { + if let Ok(bits) = bits_str.parse::() { + if let Some(bw) = BitWidth::new(bits) { + return Some(Type::UInt { bits: bw }); + } + // Invalid bit width - return None to trigger error + return None; + } + } else if let Some(bits_str) = name.strip_prefix('i') + && let Ok(bits) = bits_str.parse::() { + if let Some(bw) = BitWidth::new(bits) { + return Some(Type::IInt { bits: bw }); + } + // Invalid bit width - return None to trigger error + return None; + } + + None +} + +/// Convert an integer type suffix to a Type. +/// Supports arbitrary bit-width integers: u1, u4, u7, u128, i32, etc. +fn int_suffix_to_type(suffix: &str) -> Type { + // Handle optional underscore prefix + let s = suffix.strip_prefix('_').unwrap_or(suffix); + + // Special cases + match s { + "usize" => return Type::Usize, + "isize" => return Type::Isize, + _ => {} + } + + // Arbitrary-width integers: u or i + if let Some(bits_str) = s.strip_prefix('u') { + if let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) { + return Type::UInt { bits: bw }; + } + // Invalid bit width - fall through to default + } else if let Some(bits_str) = s.strip_prefix('i') + && let Ok(bits) = bits_str.parse::() + && let Some(bw) = BitWidth::new(bits) { + return Type::IInt { bits: bw }; + } + // Invalid bit width - fall through to default + + Type::IInt { bits: BitWidth::BITS_64 } // Default fallback +} + +/// Convert a float type suffix to a Type. +fn float_suffix_to_type(suffix: &str) -> Type { + // Handle optional underscore prefix + let s = suffix.strip_prefix('_').unwrap_or(suffix); + match s { + "f16" => Type::F16, + "f32" => Type::F32, + "f64" => Type::F64, + "f128" => Type::F128, + "a64" => Type::A64, + _ => Type::F64, // Default fallback + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + fn analyze(source: &str) -> SemanticResult<()> { + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program) + } + + #[test] + fn test_const_declaration() { + assert!(analyze("x: u32 = 42;").is_ok()); + } + + #[test] + fn test_function_declaration() { + assert!(analyze("fn add(a: u32, b: u32) -> u32 { return a + b; }").is_ok()); + } + + #[test] + fn test_type_mismatch() { + // First check that parsing works + let program1 = parse("x: u32 = 42;").unwrap(); + assert!(!program1.declarations.is_empty(), "u32 version should have declarations"); + + let program2 = parse("x: bool = 42;").unwrap(); + assert!(!program2.declarations.is_empty(), "bool version should have declarations: got {:?}", program2); + + // bool is not numeric, so int can't be assigned + let result = analyze("x: bool = 42;"); + assert!(result.is_err(), "Expected type mismatch error"); + } + + #[test] + fn test_undefined_symbol() { + // Note: "test" is a keyword, so use "run" instead + // Also "y" is now a gate name, so use "foo" instead + let result = analyze("fn run() -> unit { x := foo; }"); + if result.is_ok() { + panic!("Expected undefined symbol error but got ok"); + } + } + + #[test] + fn test_quantum_alloc() { + assert!(analyze( + r#" + fn main() -> unit { + mut q := qalloc(2); + + return unit; } + "# + ) + .is_ok()); + } + + // ========================================================================= + // Qubit State Tracking Tests + // ========================================================================= + + fn analyze_strict(source: &str) -> SemanticResult<()> { + // Note: new() is now strict by default, so this is equivalent to analyze() + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program) + } + + fn analyze_permissive(source: &str) -> SemanticResult<()> { + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program) + } + + #[test] + fn test_allocator_info_new() { + let alloc = AllocatorInfo::new("q", 4); + assert_eq!(alloc.name, "q"); + assert_eq!(alloc.capacity, Some(4)); + assert_eq!(alloc.slot_states.len(), 4); + assert!(alloc.slot_states.iter().all(|s| *s == QubitState::Unprepared)); + } + + #[test] + fn test_allocator_info_prepare_slot() { + let mut alloc = AllocatorInfo::new("q", 4); + + // Prepare slot 0 + assert!(alloc.prepare_slot(0).is_ok()); + assert_eq!(alloc.get_state(0), Some(QubitState::Prepared)); + assert_eq!(alloc.get_state(1), Some(QubitState::Unprepared)); + + // Can't prepare already prepared slot + assert!(alloc.prepare_slot(0).is_err()); + } + + #[test] + fn test_allocator_info_prepare_all() { + let mut alloc = AllocatorInfo::new("q", 4); + alloc.prepare_all(); + + assert!(alloc.slot_states.iter().all(|s| *s == QubitState::Prepared)); + } + + #[test] + fn test_allocator_info_measure_slot() { + let mut alloc = AllocatorInfo::new("q", 4); + alloc.prepare_all(); + alloc.measure_slot(1); + + assert_eq!(alloc.get_state(0), Some(QubitState::Prepared)); + assert_eq!(alloc.get_state(1), Some(QubitState::Unprepared)); + assert_eq!(alloc.get_state(2), Some(QubitState::Prepared)); + } + + #[test] + fn test_allocator_info_bounds() { + let alloc = AllocatorInfo::new("q", 4); + assert!(alloc.is_in_bounds(0)); + assert!(alloc.is_in_bounds(3)); + assert!(!alloc.is_in_bounds(4)); + assert!(!alloc.is_in_bounds(100)); + } + + #[test] + fn test_qubit_state_tracker() { + let mut tracker = QubitStateTracker::new(); + tracker.register_allocator(AllocatorInfo::new("q", 2)); + + assert!(tracker.get_allocator("q").is_some()); + assert!(tracker.get_allocator("x").is_none()); + + assert_eq!(tracker.is_prepared("q", 0), Some(false)); + + if let Some(alloc) = tracker.get_allocator_mut("q") { + alloc.prepare_all(); + } + + assert_eq!(tracker.is_prepared("q", 0), Some(true)); + } + + #[test] + fn test_recursion_tracker() { + let mut tracker = RecursionTracker::new(); + let loc = SourceLocation::default(); + + // First call should succeed + assert!(tracker.enter_function("foo", &loc).is_ok()); + assert!(tracker.is_in_call_stack("foo")); + + // Recursive call should fail + let result = tracker.enter_function("foo", &loc); + assert!(matches!(result, Err(SemanticError::RecursionDetected { .. }))); + + // Exit and re-enter should work + tracker.exit_function("foo"); + assert!(!tracker.is_in_call_stack("foo")); + assert!(tracker.enter_function("foo", &loc).is_ok()); + } + + #[test] + fn test_loop_bound_check() { + let analyzer = SemanticAnalyzer::new(); + let loc = SourceLocation::default(); + + // Small bound should pass + assert!(analyzer.check_loop_bound(100, &loc).is_ok()); + assert!(analyzer.check_loop_bound(MAX_LOOP_BOUND, &loc).is_ok()); + + // Large bound should fail + let result = analyzer.check_loop_bound(MAX_LOOP_BOUND + 1, &loc); + assert!(matches!(result, Err(SemanticError::LoopBoundTooLarge { .. }))); + } + + // ========================================================================= + // Semantic Analyzer Integration Tests + // ========================================================================= + + #[test] + fn test_allocator_registration() { + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + + return unit; } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program).expect("analysis failed"); + + // Check allocator was registered + assert!(analyzer.qubit_states.get_allocator("q").is_some()); + let alloc = analyzer.qubit_states.get_allocator("q").unwrap(); + assert_eq!(alloc.capacity, Some(4)); + } + + #[test] + fn test_child_allocator_registration() { + let source = r#" + fn main() -> unit { + mut base := qalloc(8); + mut q := base.child(4); + + return unit; } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program).expect("analysis failed"); + + // Check both allocators were registered + assert!(analyzer.qubit_states.get_allocator("base").is_some()); + assert!(analyzer.qubit_states.get_allocator("q").is_some()); + + let child = analyzer.qubit_states.get_allocator("q").unwrap(); + assert_eq!(child.capacity, Some(4)); + assert_eq!(child.parent, Some("base".to_string())); + } + + #[test] + fn test_immutable_allocator_for_gates() { + // Allocators don't need mut when just applying gates + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + h q[0]; + cx (q[0], q[1]); + return unit; + } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.analyze(&program).expect("analysis should succeed for immutable allocator with gates"); + + // Check allocator was registered + assert!(analyzer.qubit_states.get_allocator("q").is_some()); + } + + #[test] + fn test_child_requires_mutable_parent() { + // .child() requires the parent to be mutable + let source = r#" + fn main() -> unit { + base := qalloc(8); + q := base.child(4); + return unit; + } + "#; + + let program = parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!( + matches!(&err, SemanticError::ChildRequiresMutableParent { name, .. } if name == "base"), + "expected ChildRequiresMutableParent error for 'base', got {:?}", + err + ); + } + + #[test] + fn test_strict_mode_toggle() { + // new() is now strict by default + let analyzer = SemanticAnalyzer::new(); + assert!(analyzer.strict_mode); + + // new_permissive() disables strict mode + let permissive_analyzer = SemanticAnalyzer::new_permissive(); + assert!(!permissive_analyzer.strict_mode); + + // Can toggle strict mode off + let mut toggled = SemanticAnalyzer::new(); + toggled.set_strict_mode(false); + assert!(!toggled.strict_mode); + } + + #[test] + fn test_gate_on_unprepared_qubit_rejected_strict() { + // In strict mode, gates on unprepared qubits should fail + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + h q[0]; // No pz q; first - qubit is unprepared + return unit; + } + "# + ); + assert!(result.is_err(), "Expected QubitNotPrepared error"); + assert!( + matches!(result.unwrap_err(), SemanticError::QubitNotPrepared { .. }), + "Expected QubitNotPrepared error" + ); + } + + #[test] + fn test_pz_prepares_qubits() { + // PZ (prepare Z) can be applied to unprepared qubits and prepares them + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; // Prepare all qubits + h q[0]; // Now this should succeed + return unit; + } + "# + ); + assert!(result.is_ok(), "PZ should prepare qubits for subsequent gates: {:?}", result); + } + + #[test] + fn test_pz_on_specific_qubit() { + // PZ can prepare specific qubits + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q[0]; // Prepare only q[0] + h q[0]; // This should succeed + return unit; + } + "# + ); + assert!(result.is_ok(), "PZ should prepare specific qubit: {:?}", result); + } + + // ========================================================================= + // Typed Measurement Tests + // ========================================================================= + + #[test] + fn test_typed_measurement_single_qubit() { + // Single qubit measurement: mz(u1) q[0] + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + r := mz(u1) q[0]; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected typed measurement to pass: {:?}", result); + } + + #[test] + fn test_typed_measurement_array() { + // Array measurement with explicit size: mz([2]u1) [q[0], q[1]] + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(4); + pz q; + results := mz([2]u1) [q[0], q[1]]; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected typed array measurement to pass: {:?}", result); + } + + #[test] + fn test_typed_measurement_size_mismatch() { + // Size mismatch: declared [3]u1 but only 2 targets + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(4); + pz q; + results := mz([3]u1) [q[0], q[1]]; + return unit; + } + "# + ); + assert!(matches!(result, Err(SemanticError::MeasurementSizeMismatch { .. })), + "Expected MeasurementSizeMismatch error, got: {:?}", result); + } + + #[test] + fn test_typed_measurement_scalar_with_multiple_targets() { + // Scalar type with multiple targets should fail + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(4); + pz q; + results := mz(u1) [q[0], q[1]]; + return unit; + } + "# + ); + assert!(matches!(result, Err(SemanticError::MeasurementArrayExpected { .. })), + "Expected MeasurementArrayExpected error, got: {:?}", result); + } + + #[test] + fn test_typed_measurement_missing_args() { + // Old call syntax should be rejected + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + r := mz(); + return unit; + } + "# + ); + assert!(matches!(result, Err(SemanticError::DeprecatedMeasurementSyntax { .. })), + "Expected DeprecatedMeasurementSyntax error for old mz() call syntax"); + } + + #[test] + fn test_typed_measurement_invalid_type() { + // Invalid type (f64) should fail + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + r := mz(f64) q[0]; + return unit; + } + "# + ); + assert!(matches!(result, Err(SemanticError::InvalidMeasurementType { .. })), + "Expected InvalidMeasurementType error, got: {:?}", result); + } + + #[test] + fn test_measurement_pack_u8() { + // Pack 8 qubits into u8 + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(8); + pz q; + bits := mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected pack measurement to pass: {:?}", result); + } + + #[test] + fn test_measurement_pack_fewer_qubits() { + // Pack 4 qubits into u8 (has room for 8) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(4); + pz q; + bits := mz(pack u8) [q[0], q[1], q[2], q[3]]; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected pack with extra capacity to pass: {:?}", result); + } + + #[test] + fn test_measurement_pack_capacity_error() { + // Try to pack 10 qubits into u8 (only 8 bits) - should fail + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(10); + pz q; + bits := mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7], q[8], q[9]]; + return unit; + } + "# + ); + assert!(matches!(result, Err(SemanticError::MeasurementPackCapacity { .. })), + "Expected MeasurementPackCapacity error, got: {:?}", result); + } + + #[test] + fn test_measurement_pack_array() { + // Pack 16 qubits into [2]u8 + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(16); + pz q; + bits := mz(pack [2]u8) q; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected pack into array to pass: {:?}", result); + } + + // ========================================================================= + // Optional Type Tests + // ========================================================================= + + #[test] + fn test_optional_type_declaration() { + // Optional type declaration + let result = analyze( + r#" + fn main() -> unit { + mut x: ?u32 = none; + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected optional type to pass: {:?}", result); + } + + #[test] + fn test_orelse_operator() { + // orelse operator: ?T orelse T -> T + let result = analyze( + r#" + fn main() -> unit { + mut x: ?u32 = none; + y: u32 = x orelse 42; + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected orelse to pass: {:?}", result); + } + + #[test] + fn test_orelse_type_mismatch() { + // orelse with wrong default type should fail + let result = analyze( + r#" + fn main() -> unit { + mut x: ?u32 = none; + y := x orelse true; + + return unit; } + "# + ); + assert!(matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for orelse"); + } + + #[test] + fn test_orelse_non_optional() { + // orelse on non-optional should fail + let result = analyze( + r#" + fn main() -> unit { + mut x: u32 = 10; + y := x orelse 42; + + return unit; } + "# + ); + assert!(matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for non-optional"); + } + + #[test] + fn test_optional_unwrap() { + // .? unwrap operator + let result = analyze( + r#" + fn main() -> unit { + mut x: ?u32 = none; + y := x.?; + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected .? unwrap to pass: {:?}", result); + } + + #[test] + fn test_if_unwrap_optional() { + // if-unwrap syntax: if value := opt { ... } (walrus operator) + let result = analyze( + r#" + fn find() -> ?u32 { + return none; + } + fn main() -> unit { + opt := find(); + if value := opt { + x: u32 = value; + } + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected if-unwrap to pass: {:?}", result); + } + + #[test] + fn test_if_unwrap_non_optional() { + // if-unwrap on non-optional should fail + let result = analyze( + r#" + fn main() -> unit { + x: u32 = 42; + if value := x { + y := value; + } + } + "# + ); + assert!(matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for if-unwrap on non-optional"); + } + + // ========================================================================= + // Comptime Tests + // ========================================================================= + + #[test] + fn test_comptime_expression() { + // Comptime expression should be evaluated + let result = analyze( + r#" + fn main() -> unit { + x := comptime 2 + 3; + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected comptime expression to pass: {:?}", result); + } + + #[test] + fn test_comptime_block() { + // Comptime block expression should be evaluated + // Note: We test a simpler block expression to avoid parser edge cases + let result = analyze( + r#" + fn main() -> unit { + y := comptime (10 + 20); + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected comptime expression to pass: {:?}", result); + } + + #[test] + fn test_comptime_type_returns_comptime() { + // Comptime expression should have Comptime type wrapper + let source = r#" + fn main() -> unit { + x := comptime 42; + + return unit; } + "#; + let program = crate::parse(source).expect("parse failed"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Expected comptime to pass: {:?}", result); + } + + #[test] + fn test_comptime_function_parameter() { + // Comptime parameters should be accepted + let result = analyze( + r#" + fn make_array(comptime size: u32) -> unit { + mut x: u32 = size; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected comptime param to pass: {:?}", result); + } + + #[test] + fn test_comptime_parameter_in_expression() { + // Comptime parameters should be usable in expressions + let result = analyze( + r#" + fn compute(comptime n: u32) -> u32 { + return n * 2; + } + "# + ); + assert!(result.is_ok(), "Expected comptime param in expr: {:?}", result); + } + + #[test] + fn test_error_set_definition() { + // Error set definitions should be analyzed correctly + let result = analyze( + r#" + MyError := error { + OutOfMemory, + InvalidInput, + }; + "# + ); + assert!(result.is_ok(), "Expected error set definition to pass: {:?}", result); + } + + #[test] + fn test_error_union_type() { + // Error union type should work in function signatures + let result = analyze( + r#" + MyError := error { Failed }; + + fn risky() -> MyError!u32 { + x: u32 = 42; + return x; + } + "# + ); + assert!(result.is_ok(), "Expected error union type to pass: {:?}", result); + } + + #[test] + fn test_error_value_type_tracking() { + // Error values from defined error sets should have correct type + let result = analyze( + r#" + MyError := error { OutOfMemory, InvalidInput }; + + fn risky() -> MyError!u32 { + if true { + return error.OutOfMemory; + } + return 42; + } + "# + ); + assert!(result.is_ok(), "Expected error value return to pass: {:?}", result); + } + + #[test] + fn test_error_value_wrong_set() { + // Error values from different error sets should not be assignable + let result = analyze( + r#" + MyError := error { Failed }; + OtherError := error { NotFound }; + + fn risky() -> MyError!u32 { + return error.NotFound; + } + "# + ); + // This should fail because NotFound is from OtherError, not MyError + assert!(result.is_err(), "Expected mismatched error set to fail"); + } + + #[test] + fn test_error_set_union() { + // Error set union using | operator should combine errors from both sets + let result = analyze( + r#" + IoError := error { FileNotFound, PermissionDenied }; + NetworkError := error { Timeout, ConnectionRefused }; + + fn combined_errors() -> unit { + combined := IoError | NetworkError; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected error set union to pass: {:?}", result); + } + + #[test] + fn test_error_set_with_associated_data() { + // Error sets can have associated data types for their variants + let result = analyze( + r#" + FileError := error { + NotFound: struct { path: []u8 }, + PermissionDenied: struct { path: []u8, mode: u32 }, + IoError, + }; + "# + ); + assert!(result.is_ok(), "Expected error set with associated data to pass: {:?}", result); + } + + #[test] + fn test_fault_set_with_associated_data() { + // Fault sets can also have associated data types + let result = analyze( + r#" + QuantumFault := fault { + Leakage: struct { qubit_id: u32, gate: []u8 }, + BitFlip, + PhaseError: struct { angle: f64 }, + }; + "# + ); + assert!(result.is_ok(), "Expected fault set with associated data to pass: {:?}", result); + } + + #[test] + fn test_fault_set_definition_basic() { + // Basic fault set definition should work + let result = analyze("GateFaults := fault { Leakage, Depolarization };"); + assert!(result.is_ok(), "Expected fault set definition to pass: {:?}", result); + } + + #[test] + fn test_fault_set_as_value() { + // Fault set should be usable as a value (like error sets) + let result = analyze( + r#" + GateFaults := fault { Leakage }; + + fn test_fault() -> unit { + x := GateFaults; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected fault set as value to pass: {:?}", result); + } + + #[test] + fn test_fault_set_union() { + // Fault set union using | operator should combine faults from both sets + let result = analyze( + r#" + GateFaults := fault { Leakage, Depolarization }; + MeasurementFaults := fault { BitFlip, ReadoutError }; + + fn combined_faults() -> unit { + combined := GateFaults | MeasurementFaults; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected fault set union to pass: {:?}", result); + } + + #[test] + fn test_try_block_collect_mode() { + // try {} (collect mode) should analyze correctly + let result = analyze( + r#" + fn risky_collect() -> unit { + errors := try { + x := 42; + }; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected try collect block to pass: {:?}", result); + } + + #[test] + fn test_try_block_propagate_mode() { + // try! {} (propagate mode) should analyze correctly + let result = analyze( + r#" + fn risky_propagate() -> unit { + result := try! { + x := 42; + }; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected try! propagate block to pass: {:?}", result); + } + + #[test] + fn test_try_block_with_catch() { + // try! {} with catch clause should analyze correctly + let result = analyze( + r#" + fn risky_with_catch() -> unit { + result := try! { + x := 42; + x + } catch |err| { + 0 + }; + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected try! with catch to pass: {:?}", result); + } + + #[test] + fn test_errdefer_basic() { + // Basic errdefer without capture should pass semantic analysis + let result = analyze( + r#" + fn risky() -> unit { + errdefer cleanup(); + return unit; + } + + fn cleanup() -> unit { return unit; } + "# + ); + assert!(result.is_ok(), "Expected basic errdefer to pass: {:?}", result); + } + + #[test] + fn test_errdefer_with_capture() { + // Errdefer with capture should provide the error variable in scope + let result = analyze( + r#" + fn risky() -> unit { + errdefer |err| { + x := err; + } + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected errdefer with capture to pass: {:?}", result); + } + + #[test] + fn test_union_tagged() { + // Tagged union with auto-enum should pass semantic analysis + let result = analyze( + r#" + Value := union(enum) { + Int: i32, + Float: f64, + None, + }; + "# + ); + assert!(result.is_ok(), "Expected tagged union to pass: {:?}", result); + } + + #[test] + fn test_union_untagged() { + // Untagged union should pass semantic analysis + let result = analyze( + r#" + RawValue := union { + Int: i32, + Float: f64, + }; + "# + ); + assert!(result.is_ok(), "Expected untagged union to pass: {:?}", result); + } + + // ========================================================================= + // Module Import Tests + // ========================================================================= + + #[test] + fn test_module_import_not_found() { + // Importing a non-existent module should fail + let result = analyze(r#"utils := @import("nonexistent.zlp");"#); + assert!(result.is_err(), "Expected module not found error"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::ModuleError { .. }), + "Expected ModuleError, got {:?}", + e + ); + } + } + + #[test] + fn test_module_import_with_file() { + use std::io::Write; + use tempfile::TempDir; + + // Create a temporary module file + let temp_dir = TempDir::new().unwrap(); + let utils_path = temp_dir.path().join("utils.zlp"); + let mut file = std::fs::File::create(&utils_path).unwrap(); + writeln!(file, "pub fn helper() -> unit {{ return unit; }}").unwrap(); + writeln!(file, "pub VALUE: u32 = 42;").unwrap(); + + // Create main file that imports utils + let main_path = temp_dir.path().join("main.zlp"); + let mut file = std::fs::File::create(&main_path).unwrap(); + writeln!(file, "utils := @import(\"utils.zlp\");").unwrap(); + writeln!(file, "fn main() -> unit {{ return unit; }}").unwrap(); + + // Parse and analyze main file + let source = std::fs::read_to_string(&main_path).unwrap(); + let program = crate::parse_file(&source, main_path.display().to_string()).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.set_current_file(&main_path); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Expected import to succeed: {:?}", result); + } + + #[test] + fn test_module_import_call_function() { + use std::io::Write; + use tempfile::TempDir; + + // Create a temporary module file with a function + let temp_dir = TempDir::new().unwrap(); + let utils_path = temp_dir.path().join("utils.zlp"); + let mut file = std::fs::File::create(&utils_path).unwrap(); + writeln!(file, "pub fn add(a: u32, b: u32) -> u32 {{ return a + b; }}").unwrap(); + + // Create main file that imports and calls the function + let main_path = temp_dir.path().join("main.zlp"); + let mut file = std::fs::File::create(&main_path).unwrap(); + writeln!(file, "utils := @import(\"utils.zlp\");").unwrap(); + writeln!(file, "fn main() -> unit {{").unwrap(); + writeln!(file, " result := utils.add(1, 2);").unwrap(); + writeln!(file, " return unit;").unwrap(); + writeln!(file, "}}").unwrap(); + + // Parse and analyze main file + let source = std::fs::read_to_string(&main_path).unwrap(); + let program = crate::parse_file(&source, main_path.display().to_string()).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.set_current_file(&main_path); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Expected module function call to succeed: {:?}", result); + } + + #[test] + fn test_module_import_wrong_arg_count() { + use std::io::Write; + use tempfile::TempDir; + + // Create a temporary module file with a function + let temp_dir = TempDir::new().unwrap(); + let utils_path = temp_dir.path().join("utils.zlp"); + let mut file = std::fs::File::create(&utils_path).unwrap(); + writeln!(file, "pub fn add(a: u32, b: u32) -> u32 {{ return a + b; }}").unwrap(); + + // Create main file that calls with wrong number of arguments + let main_path = temp_dir.path().join("main.zlp"); + let mut file = std::fs::File::create(&main_path).unwrap(); + writeln!(file, "utils := @import(\"utils.zlp\");").unwrap(); + writeln!(file, "fn main() -> unit {{").unwrap(); + writeln!(file, " result := utils.add(1);").unwrap(); // Missing second arg + writeln!(file, " return unit;").unwrap(); + writeln!(file, "}}").unwrap(); + + // Parse and analyze main file + let source = std::fs::read_to_string(&main_path).unwrap(); + let program = crate::parse_file(&source, main_path.display().to_string()).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.set_current_file(&main_path); + let result = analyzer.analyze(&program); + // This should fail due to argument count mismatch + assert!(result.is_err(), "Expected error for wrong argument count"); + } + + // ========================================================================= + // Tuple Type Tests + // ========================================================================= + + #[test] + fn test_tuple_type_inference() { + // Tuple literal should have correct type + let result = analyze( + r#" + fn main() -> unit { + pair := (1, 2); + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected tuple literal to pass: {:?}", result); + } + + #[test] + fn test_tuple_mixed_types() { + // Tuple can contain mixed types + let result = analyze( + r#" + fn main() -> unit { + mixed := (42, true); + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected mixed tuple to pass: {:?}", result); + } + + #[test] + fn test_tuple_with_qubits() { + // Tuple of qubit references (for two-qubit gates) + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + cx (q[0], q[1]); + + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected tuple with qubits to pass: {:?}", result); + } + + #[test] + fn test_tuple_type_annotation() { + // Tuple with explicit type annotation (i64 is default int type) + let result = analyze( + r#" + fn main() -> unit { + mut pair: (i64, bool) = (42, true); + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected tuple type annotation to pass: {:?}", result); + } + + #[test] + fn test_tuple_type_annotation_triple() { + // Triple tuple with explicit type annotation + let result = analyze( + r#" + fn main() -> unit { + triple: (i64, i64, i64) = (1, 2, 3); + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected triple tuple type to pass: {:?}", result); + } + + #[test] + fn test_tuple_type_mismatch() { + // Tuple type mismatch should fail + let result = analyze( + r#" + fn main() -> unit { + mut pair: (u32, bool) = (42, true); + + return unit; } + "# + ); + // Should fail because 42 is i64, not u32 + assert!(result.is_err(), "Expected tuple type mismatch error"); + } + + // ========================================================================= + // Numeric Type Suffix Tests + // ========================================================================= + + #[test] + fn test_int_suffix_u32() { + // Integer with u32 suffix should have u32 type + let result = analyze( + r#" + fn main() -> unit { + x: u32 = 42u32; + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected u32 suffix to pass: {:?}", result); + } + + #[test] + fn test_int_suffix_with_underscore() { + // Integer with underscore separator before suffix + let result = analyze( + r#" + fn main() -> unit { + x: u64 = 1000_u64; + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected _u64 suffix to pass: {:?}", result); + } + + #[test] + fn test_float_suffix_f32() { + // Float with f32 suffix + let result = analyze( + r#" + fn main() -> unit { + x: f32 = 3.14f32; + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected f32 suffix to pass: {:?}", result); + } + + #[test] + fn test_suffix_tuple_match() { + // Suffixes allow tuple type to match + let result = analyze( + r#" + fn main() -> unit { + pair: (u32, bool) = (42u32, true); + + return unit; } + "# + ); + assert!(result.is_ok(), "Expected suffixed tuple to pass: {:?}", result); + } + + #[test] + fn test_suffix_mismatch() { + // Suffix type should not match different declared type + // bool cannot be assigned to an integer type + let result = analyze( + r#" + fn main() -> unit { + x: u32 = true; + + return unit; } + "# + ); + assert!(result.is_err(), "Expected type mismatch with bool and u32"); + } + + #[test] + fn test_suffix_default_type_vs_suffix() { + // Without suffix, 42 is i64 which won't match (u32, bool) tuple + // With suffix, 42u32 matches (u32, bool) tuple + let result_no_suffix = analyze( + r#" + fn main() -> unit { + pair: (u32, bool) = (42, true); + + return unit; } + "# + ); + let result_with_suffix = analyze( + r#" + fn main() -> unit { + pair: (u32, bool) = (42u32, true); + + return unit; } + "# + ); + // Tuple elements require exact type match (no numeric coercion) + // Unsuffixed 42 is i64, so (i64, bool) doesn't match (u32, bool) + assert!(result_no_suffix.is_err(), "Expected unsuffixed tuple to fail type check"); + // With suffix, types match exactly + assert!(result_with_suffix.is_ok(), "Expected suffixed tuple to pass: {:?}", result_with_suffix); + } + + // ========================================================================= + // Tick Block Duplicate Qubit Tests + // ========================================================================= + + #[test] + fn test_tick_no_duplicate_qubits() { + // Valid: different qubits in parallel + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + tick { + h q[0]; + h q[1]; + } + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected no duplicate error: {:?}", result); + } + + #[test] + fn test_tick_duplicate_qubit_same_gate() { + // Invalid: same qubit used twice in parallel + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + tick { + h q[0]; + x q[0]; + } + } + "# + ); + assert!( + matches!(result, Err(SemanticError::DuplicateQubitInTick { ref allocator, index, .. }) if allocator == "q" && index == 0), + "Expected DuplicateQubitInTick error for q[0]: {:?}", + result + ); + } + + #[test] + fn test_tick_duplicate_in_two_qubit_gate() { + // Invalid: qubit used in both h and cx + let result = analyze_strict( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + tick { + h q[0]; + cx (q[0], q[1]); + } + } + "# + ); + assert!( + matches!(result, Err(SemanticError::DuplicateQubitInTick { .. })), + "Expected DuplicateQubitInTick error: {:?}", + result + ); + } + + #[test] + fn test_tick_no_duplicate_permissive() { + // In permissive mode, duplicates are allowed (no error) + let result = analyze_permissive( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + tick { + h q[0]; + x q[0]; + } + return unit; + } + "# + ); + assert!(result.is_ok(), "Permissive mode should allow duplicate qubits: {:?}", result); + } + + #[test] + fn test_nested_tick_error() { + // Invalid: nested tick blocks + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + tick { + h q[0]; + tick { + x q[1]; + } + } + return unit; + } + "# + ); + assert!(result.is_err(), "Expected nested tick error"); + let err = result.unwrap_err(); + assert!( + matches!(err, SemanticError::NestedTick { .. }), + "Expected NestedTick error, got: {:?}", + err + ); + } + + #[test] + fn test_sequential_ticks_ok() { + // Valid: sequential tick blocks (not nested) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + tick { h q[0]; } + tick { h q[1]; } + tick { cx (q[0], q[1]); } + return unit; + } + "# + ); + assert!(result.is_ok(), "Sequential ticks should be valid: {:?}", result); + } + + // ========================================================================= + // Break/Continue Validation Tests + // ========================================================================= + + #[test] + fn test_break_inside_loop() { + // Valid: break inside a for loop + let result = analyze( + r#" + fn main() -> unit { + for i in 0..10 { + if (i == 5) { + break; + } + } + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected break inside loop to pass: {:?}", result); + } + + #[test] + fn test_continue_inside_loop() { + // Valid: continue inside a for loop + let result = analyze( + r#" + fn main() -> unit { + for i in 0..10 { + if (i == 5) { + continue; + } + } + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected continue inside loop to pass: {:?}", result); + } + + #[test] + fn test_break_outside_loop() { + // Invalid: break outside of any loop + let result = analyze( + r#" + fn main() -> unit { + break; + + return unit; } + "# + ); + assert!( + matches!(result, Err(SemanticError::BreakContinueOutsideLoop { ref keyword, .. }) if keyword == "break"), + "Expected BreakContinueOutsideLoop error for break: {:?}", + result + ); + } + + #[test] + fn test_continue_outside_loop() { + // Invalid: continue outside of any loop + let result = analyze( + r#" + fn main() -> unit { + continue; + + return unit; } + "# + ); + assert!( + matches!(result, Err(SemanticError::BreakContinueOutsideLoop { ref keyword, .. }) if keyword == "continue"), + "Expected BreakContinueOutsideLoop error for continue: {:?}", + result + ); + } + + #[test] + fn test_break_in_nested_loop() { + // Valid: break inside nested loops + let result = analyze( + r#" + fn main() -> unit { + for i in 0..10 { + for j in 0..10 { + if (j == 5) { + break; + } + } + } + return unit; + } + "# + ); + assert!(result.is_ok(), "Expected break in nested loop to pass: {:?}", result); + } + + #[test] + fn test_for_loop_range_type_inference_default() { + // For loop with plain integer literals defaults to i64 + let result = analyze( + r#" + fn main() -> unit { + for i in 0..10 { + x: i64 = i; // Should work since i is i64 + } + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected for loop with i64 range to pass: {:?}", result); + } + + #[test] + fn test_for_loop_range_type_inference_u32() { + // For loop with u32 suffix should infer u32 loop variable + let result = analyze( + r#" + fn main() -> unit { + for i in 0u32..10u32 { + x: u32 = i; // Should work since i is u32 + } + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected for loop with u32 range to pass: {:?}", result); + } + + #[test] + fn test_for_loop_range_type_inference_usize() { + // For loop with usize suffix should infer usize loop variable + let result = analyze( + r#" + fn main() -> unit { + for i in 0_usize..10_usize { + x: usize = i; // Should work since i is usize + } + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected for loop with usize range to pass: {:?}", result); + } + + #[test] + fn test_array_type_size_literal() { + // Array type with literal size should analyze correctly + let result = analyze( + r#" + fn main() -> unit { + mut arr: [10]u32 = undefined; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected array with literal size to pass: {:?}", result); + } + + #[test] + fn test_array_type_size_hex_literal() { + // Array type with hex literal size should analyze correctly + let result = analyze( + r#" + fn main() -> unit { + mut arr: [0x10]u8 = undefined; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected array with hex size to pass: {:?}", result); + } + + #[test] + fn test_array_slice_type() { + // Array slice (no size) should analyze correctly + let result = analyze( + r#" + fn get_slice() -> []u8 { + return undefined; + } + "#, + ); + assert!(result.is_ok(), "Expected slice type to pass: {:?}", result); + } + + #[test] + fn test_const_propagation_array_size() { + // Const propagation: use const value as array size + let result = analyze( + r#" + fn main() -> unit { + N := 10; + mut arr: [N]u32 = undefined; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected const propagation for array size to pass: {:?}", result); + } + + #[test] + fn test_const_propagation_array_size_with_type() { + // Const propagation with explicit type annotation + let result = analyze( + r#" + fn main() -> unit { + SIZE: usize = 5; + mut buffer: [SIZE]u8 = undefined; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected typed const propagation to pass: {:?}", result); + } + + #[test] + fn test_const_propagation_chained() { + // Chained propagation: const derived from another const + let result = analyze( + r#" + fn main() -> unit { + A := 4; + B := A; + mut arr: [B]u32 = undefined; + + return unit; } + "#, + ); + assert!(result.is_ok(), "Expected chained const propagation to pass: {:?}", result); + } + + // ========================================================================= + // Array and Slice Property Tests + // ========================================================================= + + #[test] + fn test_array_len_property() { + // Array .len returns the compile-time known length + let result = analyze( + r#" + fn main() -> unit { + arr: [10]u32 = undefined; + len := arr.len; + + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected array .len to pass: {:?}", result); + } + + #[test] + fn test_array_ptr_property() { + // Array .ptr returns a pointer to the first element + let result = analyze( + r#" + fn main() -> unit { + arr: [10]u32 = undefined; + ptr := arr.ptr; + + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected array .ptr to pass: {:?}", result); + } + + #[test] + fn test_slice_len_property() { + // Slice .len returns the dynamic length + let result = analyze( + r#" + fn process(data: []u32) -> unit { + len := data.len; + + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected slice .len to pass: {:?}", result); + } + + #[test] + fn test_slice_ptr_property() { + // Slice .ptr returns a pointer to the first element + let result = analyze( + r#" + fn process(data: []u32) -> unit { + ptr := data.ptr; + + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected slice .ptr to pass: {:?}", result); + } + + // ========================================================================= + // Gate Syntax Tests + // ========================================================================= + + #[test] + fn test_valid_gate_syntax() { + // Correct gate syntax: h q[0], cx (q[0], q[1]) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + h q[0]; + cx (q[0], q[1]); + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected valid gate syntax to pass: {:?}", result); + } + + #[test] + fn test_qubit_index_out_of_bounds() { + // Access q[5] when allocator only has 2 qubits + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + h q[5]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), + "Expected QubitIndexOutOfBounds error, got: {:?}", result + ); + } + + #[test] + fn test_qubit_index_at_boundary() { + // Access q[1] when allocator has 2 qubits (valid, 0-indexed) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + h q[1]; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected index at boundary to pass: {:?}", result); + } + + #[test] + fn test_qubit_index_exactly_at_capacity() { + // Access q[2] when allocator has 2 qubits (invalid, 0-indexed means max is 1) + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + h q[2]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { index: 2, capacity: 2, .. })), + "Expected QubitIndexOutOfBounds error for index at capacity, got: {:?}", result + ); + } + + #[test] + fn test_measurement_index_out_of_bounds() { + // Measurement should also catch out-of-bounds + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + r := mz(u1) q[5]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), + "Expected QubitIndexOutOfBounds error for measurement, got: {:?}", result + ); + } + + #[test] + fn test_measurement_array_index_out_of_bounds() { + // Array measurement with one out-of-bounds index + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + r := mz([2]u1) [q[0], q[5]]; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), + "Expected QubitIndexOutOfBounds error for array measurement, got: {:?}", result + ); + } + + #[test] + fn test_pz_index_out_of_bounds() { + // Prepare specific slots with out-of-bounds index + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz {q[0], q[5]}; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), + "Expected QubitIndexOutOfBounds error for pz, got: {:?}", result + ); + } + + #[test] + fn test_paren_gate_syntax_valid() { + // h(q[0]) is now valid - parentheses are just grouping, equivalent to h q[0] + // This is more intuitive and consistent with how most languages work + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(1); + pz q; + h(q[0]); + return unit; + } + "#, + ); + assert!(result.is_ok(), "h(q[0]) should be valid (parens are grouping): {:?}", result); + } + + #[test] + fn test_cx_tuple_syntax_valid() { + // cx(q[0], q[1]) is valid - tuple target for two-qubit gate + let result = analyze( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + cx(q[0], q[1]); + return unit; + } + "#, + ); + assert!(result.is_ok(), "cx(q[0], q[1]) should be valid: {:?}", result); + } + + #[test] + fn test_rx_without_angle_error() { + // rx is a parameterized gate, it REQUIRES an angle parameter + // rx q[0] without angle should fail at parse time + let source = r#" + fn main() -> unit { + q := qalloc(1); + pz q; + rx q[0]; + return unit; + } + "#; + let result = parse(source); + assert!(result.is_err(), "rx without angle should fail at parse time"); + } + + // ========================================================================= + // Type Ascription Tests (space-separated: `42 u32`, `1/4 f64`) + // ========================================================================= + + #[test] + fn test_type_ascription_simple() { + // Type ascription with space: `42 u32` + let result = analyze( + r#" + fn main() -> unit { + x: u32 = 42 u32; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected type ascription `42 u32` to pass: {:?}", result); + } + + #[test] + fn test_type_ascription_float() { + // Type ascription with float: `3.14 f64` + let result = analyze( + r#" + fn main() -> unit { + x: f64 = 3.14 f64; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected type ascription `3.14 f64` to pass: {:?}", result); + } + + #[test] + fn test_type_ascription_expression() { + // Type ascription on expression: `1/4 f64` should be 0.25 + let result = analyze( + r#" + fn main() -> unit { + x: f64 = 1/4 f64; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected type ascription `1/4 f64` to pass: {:?}", result); + } + + // ========================================================================= + // Angle Literal Tests (`0.25 turns`, `pi/4 rad`) + // ========================================================================= + + #[test] + fn test_angle_literal_turns() { + // Angle literal with turns unit + let result = analyze( + r#" + fn main() -> unit { + angle: a64 = 0.25 turns; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected angle literal `0.25 turns` to pass: {:?}", result); + } + + #[test] + fn test_angle_literal_half_turn() { + // Half turn + let result = analyze( + r#" + fn main() -> unit { + angle: a64 = 0.5 turns; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected angle literal `0.5 turns` to pass: {:?}", result); + } + + #[test] + fn test_angle_literal_type_is_a64() { + // Angle literals should be type a64 + let result = analyze( + r#" + fn main() -> unit { + angle := 0.25 turns; + check: a64 = angle; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected angle literal to be a64 type: {:?}", result); + } + + // ========================================================================= + // Error Reporting Tests - Ensure errors are not silently swallowed + // ========================================================================= + + #[test] + fn test_undefined_symbol_in_binding_reports_error() { + // Undefined symbols in bindings should report an error, not silently use Type::Unknown + let result = analyze("x := undefined_symbol;"); + assert!(result.is_err(), "Expected undefined symbol error"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::UndefinedSymbol { .. }), + "Expected UndefinedSymbol, got {:?}", + e + ); + } + } + + #[test] + fn test_undefined_symbol_in_struct_init_reports_error() { + // Undefined symbols in struct field values should report an error + let result = analyze( + r#" + fn main() -> unit { + s := .{ x: undefined_symbol }; + return unit; + } + "#, + ); + assert!(result.is_err(), "Expected undefined symbol error in struct init"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::UndefinedSymbol { .. }), + "Expected UndefinedSymbol, got {:?}", + e + ); + } + } + + #[test] + fn test_direct_recursion_rejected() { + // Direct recursion should be rejected in strict mode (NASA Power of 10 compliance) + let result = analyze_strict( + r#" + fn factorial(n: u32) -> u32 { + if n == 0 { + return 1; + } + return n * factorial(n - 1); + } + "#, + ); + assert!(result.is_err(), "Expected recursion error"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::RecursionDetected { .. }), + "Expected RecursionDetected, got {:?}", + e + ); + } + } + + #[test] + fn test_recursion_rejected_even_in_permissive_mode() { + // Recursion is always rejected (safe-by-constraint, no escape hatch) + // Use FFI with Rust if recursive algorithms are needed + let result = analyze_permissive( + r#" + fn factorial(n: u32) -> u32 { + if n == 0 { + return 1; + } + return n * factorial(n - 1); + } + "#, + ); + assert!(result.is_err(), "Recursion should be rejected even in permissive mode"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::RecursionDetected { .. }), + "Expected RecursionDetected, got {:?}", + e + ); + } + } + + #[test] + fn test_non_recursive_function_allowed() { + // Non-recursive functions should be allowed + let result = analyze( + r#" + fn helper() -> u32 { + return 42; + } + fn main() -> u32 { + return helper(); + } + "#, + ); + assert!(result.is_ok(), "Expected non-recursive call to pass: {:?}", result); + } + + #[test] + fn test_catch_on_non_error_type_rejected() { + // catch on non-error types should be rejected + let result = analyze( + r#" + fn main() -> u32 { + x := 42 catch 0; + return x; + } + "#, + ); + assert!(result.is_err(), "Expected catch on non-error type to fail"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::CatchOnNonErrorType { .. }), + "Expected CatchOnNonErrorType, got {:?}", + e + ); + } + } + + #[test] + fn test_catch_on_error_union_allowed() { + // catch on error union types should be allowed + let result = analyze( + r#" + MyError := error { Fail }; + fn might_fail() -> MyError!u32 { + return 42; + } + fn main() -> u32 { + x := might_fail() catch 0; + return x; + } + "#, + ); + assert!(result.is_ok(), "Expected catch on error union to pass: {:?}", result); + } + + #[test] + fn test_batch_gate_single_qubit_correct_arity() { + // Single qubit gate with single qubit targets should pass + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + h { q[0], q[1], q[2] }; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected single qubit batch gate to pass: {:?}", result); + } + + #[test] + fn test_batch_gate_two_qubit_correct_arity() { + // Two qubit gate with pair targets should pass + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + cx { (q[0], q[1]), (q[2], q[3]) }; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected two qubit batch gate to pass: {:?}", result); + } + + #[test] + fn test_batch_gate_two_qubit_wrong_arity() { + // Two qubit gate with single qubit target should fail + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + cx { q[0] }; + return unit; + } + "#, + ); + assert!(result.is_err(), "Expected two qubit gate with single qubit to fail"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::GateArityMismatch { expected: 2, found: 1, .. }), + "Expected GateArityMismatch, got {:?}", + e + ); + } + } + + #[test] + fn test_batch_gate_single_qubit_wrong_arity() { + // Single qubit gate with pair target should fail + let result = analyze( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + h { (q[0], q[1]) }; + return unit; + } + "#, + ); + assert!(result.is_err(), "Expected single qubit gate with pair to fail"); + if let Err(e) = result { + assert!( + matches!(e, SemanticError::GateArityMismatch { expected: 1, found: 2, .. }), + "Expected GateArityMismatch, got {:?}", + e + ); + } + } + + // ========================================================================= + // ResolvedType and contains_unknown Tests + // ========================================================================= + + #[test] + fn test_contains_unknown_primitive_types() { + // Primitives never contain Unknown + assert!(!Type::Bool.contains_unknown()); + assert!(!Type::UInt { bits: BitWidth::BITS_32 }.contains_unknown()); + assert!(!Type::IInt { bits: BitWidth::BITS_64 }.contains_unknown()); + assert!(!Type::F64.contains_unknown()); + assert!(!Type::Qubit.contains_unknown()); + assert!(!Type::Unit.contains_unknown()); + assert!(!Type::Never.contains_unknown()); + } + + #[test] + fn test_contains_unknown_direct() { + // Unknown itself contains unknown + assert!(Type::Unknown.contains_unknown()); + } + + #[test] + fn test_contains_unknown_nested_in_array() { + // Array with Unknown element contains unknown + let arr_unknown = Type::Array { + element: Box::new(Type::Unknown), + size: Some(10), + }; + assert!(arr_unknown.contains_unknown()); + + // Array with concrete element doesn't contain unknown + let arr_u32 = Type::Array { + element: Box::new(Type::UInt { bits: BitWidth::BITS_32 }), + size: Some(10), + }; + assert!(!arr_u32.contains_unknown()); + } + + #[test] + fn test_contains_unknown_nested_in_tuple() { + // Tuple with Unknown element + let tuple_with_unknown = Type::Tuple { + elements: vec![ + Type::UInt { bits: BitWidth::BITS_32 }, + Type::Unknown, + Type::Bool, + ], + }; + assert!(tuple_with_unknown.contains_unknown()); + + // Tuple without Unknown + let tuple_concrete = Type::Tuple { + elements: vec![ + Type::UInt { bits: BitWidth::BITS_32 }, + Type::Bool, + ], + }; + assert!(!tuple_concrete.contains_unknown()); + } + + #[test] + fn test_contains_unknown_deeply_nested() { + // Unknown deeply nested: Array(Optional(Unknown)) + let deeply_nested = Type::Array { + element: Box::new(Type::Optional { + inner: Box::new(Type::Unknown), + }), + size: Some(5), + }; + assert!(deeply_nested.contains_unknown()); + } + + #[test] + fn test_contains_unknown_error_union() { + // Unknown in error position + let unknown_error = Type::ErrorUnion { + error: Box::new(Type::Unknown), + payload: Box::new(Type::UInt { bits: BitWidth::BITS_32 }), + }; + assert!(unknown_error.contains_unknown()); + + // Unknown in payload position + let unknown_payload = Type::ErrorUnion { + error: Box::new(Type::AnyError), + payload: Box::new(Type::Unknown), + }; + assert!(unknown_payload.contains_unknown()); + + // Neither contains Unknown + let concrete_union = Type::ErrorUnion { + error: Box::new(Type::AnyError), + payload: Box::new(Type::UInt { bits: BitWidth::BITS_32 }), + }; + assert!(!concrete_union.contains_unknown()); + } + + #[test] + fn test_resolve_success() { + // Concrete type resolves successfully + let ty = Type::UInt { bits: BitWidth::BITS_32 }; + let resolved = ty.resolve(); + assert!(resolved.is_some()); + assert_eq!(resolved.unwrap().display_name(), "u32"); + } + + #[test] + fn test_resolve_failure_direct() { + // Unknown type fails to resolve + let ty = Type::Unknown; + assert!(ty.resolve().is_none()); + } + + #[test] + fn test_resolve_failure_nested() { + // Type containing Unknown fails to resolve + let ty = Type::Optional { + inner: Box::new(Type::Unknown), + }; + assert!(ty.resolve().is_none()); + } + + #[test] + fn test_resolved_type_methods() { + let ty = Type::UInt { bits: BitWidth::BITS_64 }; + let resolved = ty.resolve().unwrap(); + + // Check wrapper methods work correctly + assert!(resolved.is_numeric()); + assert!(resolved.is_integer()); + assert!(!resolved.is_float()); + assert!(!resolved.is_quantum()); + assert_eq!(resolved.display_name(), "u64"); + } + + #[test] + fn test_is_resolved() { + assert!(Type::Bool.is_resolved()); + assert!(Type::F64.is_resolved()); + assert!(!Type::Unknown.is_resolved()); + + let nested = Type::Array { + element: Box::new(Type::Unknown), + size: None, + }; + assert!(!nested.is_resolved()); + } + + // ========================================================================= + // Undefined Type Error Tests + // ========================================================================= + + #[test] + fn test_undefined_type_in_variable_declaration() { + // Using undefined type should produce an error + let result = analyze( + r#" + fn main() -> unit { + x: UndefinedType = 42; + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::UndefinedType { ref name, .. }) if name == "UndefinedType"), + "Expected UndefinedType error, got: {:?}", + result + ); + } + + #[test] + fn test_undefined_type_in_function_param() { + // Using undefined type in function parameter should produce an error + let result = analyze( + r#" + fn foo(x: NonexistentType) -> unit { + return unit; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::UndefinedType { ref name, .. }) if name == "NonexistentType"), + "Expected UndefinedType error, got: {:?}", + result + ); + } + + #[test] + fn test_undefined_type_in_return_type() { + // Using undefined type in return type should produce an error + let result = analyze( + r#" + fn foo() -> MissingType { + return 42; + } + "#, + ); + assert!( + matches!(result, Err(SemanticError::UndefinedType { ref name, .. }) if name == "MissingType"), + "Expected UndefinedType error, got: {:?}", + result + ); + } + + #[test] + fn test_defined_type_works() { + // Defined types should work correctly + let result = analyze( + r#" + MyStruct := struct { x: u32 }; + fn main() -> unit { + s: MyStruct = MyStruct { x: 42 }; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected defined type to work: {:?}", result); + } + + // ========================================================================= + // Input Size Limit Tests + // ========================================================================= + + #[test] + fn test_scope_nesting_within_limit() { + // Normal scope nesting should work + let result = analyze( + r#" + fn main() -> unit { + { + { + { + x := 42; + } + } + } + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected normal nesting to work: {:?}", result); + } + + #[test] + fn test_symbol_table_normal_usage() { + // Normal symbol usage should work + let result = analyze( + r#" + fn main() -> unit { + a := 1; + b := 2; + c := 3; + d := 4; + e := 5; + return unit; + } + "#, + ); + assert!(result.is_ok(), "Expected normal symbol usage to work: {:?}", result); + } + + #[test] + fn test_max_scope_depth_constant() { + // Verify the constant is reasonable + assert!(MAX_SCOPE_DEPTH >= 64, "MAX_SCOPE_DEPTH should be at least 64"); + assert!(MAX_SCOPE_DEPTH <= 1024, "MAX_SCOPE_DEPTH should not be excessive"); + } + + #[test] + fn test_max_symbol_count_constant() { + // Verify the constant is reasonable + assert!(MAX_SYMBOL_COUNT >= 10_000, "MAX_SYMBOL_COUNT should be at least 10000"); + assert!(MAX_SYMBOL_COUNT <= 10_000_000, "MAX_SYMBOL_COUNT should not be excessive"); + } + + // ========================================================================= + // Multi-Error Collection Tests + // ========================================================================= + + #[test] + fn test_analyze_collecting_errors_multiple() { + // Program with multiple errors should collect them all + let source = r#" + fn foo() -> UndefinedType1 { + return 42; + } + fn bar() -> UndefinedType2 { + return 42; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze_collecting_errors(&program); + + assert!(result.is_err()); + let errors = result.unwrap_err(); + assert!(errors.len() >= 2, "Expected at least 2 errors, got {}", errors.len()); + + // Check that both undefined types are reported + let error_names: Vec<_> = errors.iter() + .filter_map(|e| { + if let SemanticError::UndefinedType { name, .. } = e { + Some(name.as_str()) + } else { + None + } + }) + .collect(); + assert!(error_names.contains(&"UndefinedType1"), "Should report UndefinedType1"); + assert!(error_names.contains(&"UndefinedType2"), "Should report UndefinedType2"); + } + + #[test] + fn test_analyze_collecting_errors_none() { + // Valid program should have no errors + let source = r#" + fn main() -> unit { + return unit; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze_collecting_errors(&program); + + assert!(result.is_ok(), "Expected no errors: {:?}", result); + } + + #[test] + fn test_semantic_errors_display() { + // Test the Display impl for SemanticErrors + let errors = SemanticErrors::new(vec![ + SemanticError::UndefinedType { + name: "Foo".to_string(), + location: SourceLocation::default(), + }, + SemanticError::UndefinedType { + name: "Bar".to_string(), + location: SourceLocation::default(), + }, + ]); + + let display = format!("{}", errors); + assert!(display.contains("2 error(s)")); + assert!(display.contains("Foo")); + assert!(display.contains("Bar")); + } + + #[test] + fn test_semantic_errors_iter() { + let errors = SemanticErrors::new(vec![ + SemanticError::UndefinedType { + name: "A".to_string(), + location: SourceLocation::default(), + }, + SemanticError::UndefinedType { + name: "B".to_string(), + location: SourceLocation::default(), + }, + ]); + + assert_eq!(errors.len(), 2); + assert!(!errors.is_empty()); + + let names: Vec<_> = errors.iter() + .filter_map(|e| { + if let SemanticError::UndefinedType { name, .. } = e { + Some(name.clone()) + } else { + None + } + }) + .collect(); + assert_eq!(names, vec!["A", "B"]); + } + + #[test] + fn test_error_count_and_take() { + let source = r#" + fn foo(x: UndefinedType) -> unit { + return unit; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let _ = analyzer.analyze(&program); // Ignore result + + // Errors should have been collected + assert!(analyzer.error_count() > 0); + + // Take errors should empty the list + let taken = analyzer.take_errors(); + assert!(!taken.is_empty()); + assert_eq!(analyzer.error_count(), 0); + } + + #[test] + fn test_slice_element_access() { + // Test that indexing a slice parameter with an integer returns element type + // NOTE: Variable name must NOT be a gate name (s, h, x, y, z, t are gates) + let source = r#" + fn get_element(data: []i32) -> i32 { + return data[0]; + } + "#; + let result = analyze(source); + assert!(result.is_ok(), "Slice element access should work: {:?}", result.err()); + } + + // ========================================================================= + // Inline For Loop Validation Tests + // ========================================================================= + + #[test] + fn test_inline_for_valid() { + // Valid inline for with comptime range + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..4 { + h q[i]; + } + return unit; + } + "#; + let result = analyze(source); + assert!(result.is_ok(), "Valid inline for should pass: {:?}", result.err()); + } + + #[test] + fn test_inline_for_with_comptime_expr() { + // Valid inline for with comptime expression bound + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..(2 * 2) { + h q[i]; + } + return unit; + } + "#; + let result = analyze(source); + assert!(result.is_ok(), "Inline for with comptime expr should pass: {:?}", result.err()); + } + + #[test] + fn test_inline_for_error_break() { + // break is not allowed in inline for + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..4 { + h q[i]; + break; + } + return unit; + } + "#; + let result = analyze(source); + assert!( + matches!(result, Err(SemanticError::BreakInInlineFor { .. })), + "Expected BreakInInlineFor error, got {:?}", + result + ); + } + + #[test] + fn test_inline_for_error_continue() { + // continue is not allowed in inline for + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..4 { + h q[i]; + continue; + } + return unit; + } + "#; + let result = analyze(source); + assert!( + matches!(result, Err(SemanticError::ContinueInInlineFor { .. })), + "Expected ContinueInInlineFor error, got {:?}", + result + ); + } + + #[test] + fn test_inline_for_error_non_comptime_range() { + // Runtime variable in range is not allowed + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + mut n := 4; + inline for i in 0..n { + h q[i]; + } + return unit; + } + "#; + let result = analyze(source); + assert!( + matches!(result, Err(SemanticError::InlineForRangeNotComptime { .. })), + "Expected InlineForRangeNotComptime error, got {:?}", + result + ); + } + + #[test] + fn test_inline_for_nested_break_in_regular_for() { + // break in a regular for inside inline for should be allowed + let source = r#" + fn main() -> unit { + q := qalloc(4); + pz q; + inline for i in 0..2 { + for j in 0..10 { + if j == 5 { + break; + } + } + } + return unit; + } + "#; + let result = analyze(source); + // This should fail because we're still inside an inline for + // and break applies to the inner regular for + assert!( + matches!(result, Err(SemanticError::BreakInInlineFor { .. })), + "Break in nested regular for inside inline for should fail: {:?}", + result + ); + } + + // ========================================================================= + // Generic Type Instantiation Tests + // ========================================================================= + + #[test] + fn test_mangle_generic_name() { + use crate::comptime::ComptimeValue; + + let args = vec![ + ComptimeValue::Type(Type::UInt { bits: BitWidth::must(32) }), + ComptimeValue::Uint(4), + ]; + let mangled = SemanticAnalyzer::mangle_generic_name("make_array", &args); + assert!(mangled.starts_with("make_array__CT__")); + assert!(mangled.contains("u32") || mangled.contains("UInt")); + assert!(mangled.contains("4")); + } + + #[test] + fn test_serialize_comptime_args() { + use crate::comptime::ComptimeValue; + + let args = vec![ + ComptimeValue::Int(42), + ComptimeValue::Bool(true), + ]; + let serialized = SemanticAnalyzer::serialize_comptime_args(&args); + assert!(serialized.contains("42")); + assert!(serialized.contains("true")); + } + + #[test] + fn test_generic_function_detection() { + // Test that functions with comptime params are detected + // Use a comptime integer parameter since comptime type params need more work + let source = r#" + fn repeat(comptime N: u32, val: i32) -> i32 { + return val; + } + "#; + let program = crate::parse(source).unwrap(); + + // First verify the parser detected the comptime param + if let crate::ast::TopLevelDecl::Fn(fn_decl) = &program.declarations[0] { + assert_eq!(fn_decl.params.len(), 2); + assert!( + fn_decl.params[0].is_comptime, + "First param should be comptime (parser should set this). Got: {:?}", + fn_decl.params[0] + ); + assert!(!fn_decl.params[1].is_comptime, "Second param should not be comptime"); + } else { + panic!("Expected function declaration"); + } + + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Generic function should parse: {:?}", result.err()); + + // Check that the function was registered with comptime param info + if let Some(symbol) = analyzer.symbols.lookup("repeat") { + if let SymbolKind::Function { comptime_param_indices, original_decl, .. } = &symbol.kind { + assert_eq!(comptime_param_indices.len(), 1, "Should have 1 comptime param"); + assert_eq!(comptime_param_indices[0], 0, "First param should be comptime"); + assert!(original_decl.is_some(), "Should store original decl for generic"); + } else { + panic!("Expected function symbol"); + } + } else { + panic!("Function not found in symbol table"); + } + } + + #[test] + fn test_non_generic_function_no_original_decl() { + // Test that regular functions don't store original_decl + let source = r#" + fn add(a: i32, b: i32) -> i32 { + return a + b; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let _ = analyzer.analyze(&program); + + if let Some(symbol) = analyzer.symbols.lookup("add") { + if let SymbolKind::Function { comptime_param_indices, original_decl, .. } = &symbol.kind { + assert!(comptime_param_indices.is_empty(), "Should have no comptime params"); + assert!(original_decl.is_none(), "Should not store original decl for non-generic"); + } + } + } + + // ========================================================================= + // Alias Tests + // ========================================================================= + + #[test] + fn test_alias_basic() { + // Basic alias should parse and analyze + let source = r#" + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias view := arr[0..4]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Basic alias should analyze: {:?}", result); + } + + #[test] + fn test_alias_non_overlapping() { + // Non-overlapping aliases on same source should work + let source = r#" + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias first := arr[0..4]; + alias second := arr[4..8]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Non-overlapping aliases should work: {:?}", result); + } + + #[test] + fn test_alias_overlapping_error() { + // Overlapping aliases should be an error + let source = r#" + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias first := arr[0..4]; + alias second := arr[2..6]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!( + matches!(result, Err(SemanticError::OverlappingAlias { .. })), + "Overlapping aliases should error: {:?}", + result + ); + } + + #[test] + fn test_alias_adjacent_ranges() { + // Adjacent ranges [0..4) and [4..8) should not overlap + let source = r#" + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias a := arr[0..4]; + alias b := arr[4..8]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Adjacent ranges should not overlap: {:?}", result); + } + + #[test] + fn test_alias_different_sources() { + // Aliases on different sources can have overlapping ranges + let source = r#" + pub fn main() -> unit { + arr1: [8]u32 = undefined; + arr2: [8]u32 = undefined; + alias a := arr1[0..4]; + alias b := arr2[0..4]; + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Different sources can have same ranges: {:?}", result); + } + + #[test] + fn test_alias_usable_as_value() { + // Alias should be usable like a slice + let source = r#" + fn take_slice(data: []u32) -> unit { + return; + } + pub fn main() -> unit { + arr: [8]u32 = undefined; + alias view := arr[0..4]; + take_slice(view); + return; + } + "#; + let program = crate::parse(source).unwrap(); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Alias should be usable as slice: {:?}", result); + } + + #[test] + fn test_ranges_overlap_function() { + // Test the ranges_overlap helper + assert!(SemanticAnalyzer::ranges_overlap((0, 4), (2, 6))); // Overlap + assert!(SemanticAnalyzer::ranges_overlap((2, 6), (0, 4))); // Overlap (reversed) + assert!(!SemanticAnalyzer::ranges_overlap((0, 4), (4, 8))); // Adjacent, no overlap + assert!(!SemanticAnalyzer::ranges_overlap((0, 2), (5, 8))); // Disjoint + assert!(SemanticAnalyzer::ranges_overlap((0, 10), (5, 6))); // Contained + } + + // ========================================================================= + // Array bounds checking + // ========================================================================= + + #[test] + fn test_array_index_out_of_bounds() { + let result = analyze(r#" + fn main() -> unit { + arr: [3]i64 = [1, 2, 3]; + x := arr[5]; + return; + } + "#); + assert!(result.is_err(), "Expected ArrayIndexOutOfBounds error"); + let err = result.unwrap_err(); + assert!( + err.to_string().contains("out of bounds"), + "Error message should mention out of bounds, got: {err}", + ); + } + + #[test] + fn test_array_index_at_boundary() { + // arr[2] on [3]i64 is valid (indices 0, 1, 2) + let result = analyze(r#" + fn main() -> unit { + arr: [3]i64 = [1, 2, 3]; + x := arr[2]; + return; + } + "#); + assert!(result.is_ok(), "arr[2] on [3]i64 should be valid, got: {:?}", result); + } + + #[test] + fn test_array_index_literal_at_size() { + // arr[3] on [3]i64 is out of bounds (valid indices are 0, 1, 2) + let result = analyze(r#" + fn main() -> unit { + arr: [3]i64 = [1, 2, 3]; + x := arr[3]; + return; + } + "#); + assert!(result.is_err(), "Expected ArrayIndexOutOfBounds for index == size"); + } + + #[test] + fn test_array_dynamic_index_no_error() { + // Dynamic index should not produce a compile-time error + assert!(analyze(r#" + fn get(arr: [3]i64, i: i64) -> i64 { + return arr[i]; + } + "#).is_ok()); + } + + // ========================================================================= + // Custom gate declarations + // ========================================================================= + + #[test] + fn test_declare_gate_registered() { + // declare gate should be registered in the gate registry + assert!(analyze(r#" + declare gate my_rx(theta)(q); + "#).is_ok()); + } + + #[test] + fn test_declare_gate_no_params() { + assert!(analyze(r#" + declare gate my_x()(q); + "#).is_ok()); + } + + #[test] + fn test_declare_gate_multi_qubit() { + assert!(analyze(r#" + declare gate cnot()(control, target); + "#).is_ok()); + } + + #[test] + fn test_composite_gate_basic() { + assert!(analyze(r#" + gate my_h()(q) { + h q; + } + "#).is_ok()); + } + + #[test] + fn test_composite_gate_multi_qubit() { + assert!(analyze(r#" + gate bell()(q0, q1) { + h q0; + cx q0, q1; + } + "#).is_ok()); + } + + #[test] + fn test_declare_gate_duplicate_rejected() { + // Defining same gate name twice should fail + let result = analyze(r#" + declare gate my_gate()(q); + declare gate my_gate()(q); + "#); + assert!(result.is_err(), "Duplicate gate declaration should fail"); + } + + #[test] + fn test_builtin_gates_still_work() { + // Built-in gates should still work alongside custom gate declarations + assert!(analyze(r#" + declare gate custom_rx(theta)(q); + + fn apply(q: qubit) -> unit { + h q; + x q; + return; + } + "#).is_ok()); + } +} diff --git a/exp/zlup/src/test_runner.rs b/exp/zlup/src/test_runner.rs new file mode 100644 index 000000000..f228005f0 --- /dev/null +++ b/exp/zlup/src/test_runner.rs @@ -0,0 +1,322 @@ +//! Built-in test runner for Zluppy programs. +//! +//! Discovers `test "name" { ... }` blocks in Zluppy source files, +//! analyzes them, and runs classical tests via the comptime evaluator. + +use std::time::{Duration, Instant}; + +use crate::ast::{Program, TestDecl, TopLevelDecl}; +use crate::semantic::SemanticAnalyzer; + +/// Configuration for the test runner. +#[derive(Debug, Clone)] +pub struct TestRunConfig { + /// Only run tests matching this pattern (substring match) + pub filter: Option, + /// Enable strict mode (NASA Power of 10) + pub strict: bool, + /// Print verbose output + pub verbose: bool, +} + +impl Default for TestRunConfig { + fn default() -> Self { + Self { + filter: None, + strict: false, + verbose: false, + } + } +} + +/// Outcome of a single test. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TestOutcome { + Pass, + Fail(String), + Skip(String), +} + +/// Result of running a single test. +#[derive(Debug, Clone)] +pub struct TestResult { + pub name: String, + pub outcome: TestOutcome, + pub duration: Duration, +} + +/// Test runner for Zluppy programs. +pub struct TestRunner { + config: TestRunConfig, +} + +impl TestRunner { + /// Create a new test runner with the given configuration. + pub fn new(config: TestRunConfig) -> Self { + Self { config } + } + + /// Discover test declarations from a program AST. + pub fn discover_tests<'a>(&self, program: &'a Program) -> Vec<&'a TestDecl> { + let mut tests = Vec::new(); + for decl in &program.declarations { + if let TopLevelDecl::Test(test) = decl { + if let Some(ref filter) = self.config.filter { + if !test.name.contains(filter.as_str()) { + continue; + } + } + tests.push(test); + } + } + tests + } + + /// Run all discovered tests in a program. + pub fn run(&self, program: &Program) -> Vec { + let tests = self.discover_tests(program); + let mut results = Vec::new(); + + // Run each test — skip quantum tests, try semantic analysis for classical ones + for test in &tests { + let result = self.run_single_test(test, program); + results.push(result); + } + + results + } + + /// Run a single test. + fn run_single_test(&self, test: &TestDecl, program: &Program) -> TestResult { + let start = Instant::now(); + + // Check if the test body contains quantum operations + // The comptime evaluator cannot run quantum gates + if contains_quantum_ops(&test.body) { + return TestResult { + name: test.name.clone(), + outcome: TestOutcome::Skip( + "test contains quantum operations (gates/measurements) which cannot be evaluated at compile time".to_string(), + ), + duration: start.elapsed(), + }; + } + + // Run semantic analysis on the program + let mut analyzer = SemanticAnalyzer::new(); + if self.config.strict { + analyzer.set_strict_mode(true); + } + if let Err(err) = analyzer.analyze(program) { + return TestResult { + name: test.name.clone(), + outcome: TestOutcome::Fail(format!("semantic error: {}", err)), + duration: start.elapsed(), + }; + } + + // For now, tests that pass semantic analysis and don't contain + // quantum operations are considered passing. Full comptime evaluation + // of test bodies will be added when the comptime evaluator supports + // statement-level evaluation. + TestResult { + name: test.name.clone(), + outcome: TestOutcome::Pass, + duration: start.elapsed(), + } + } +} + +/// Check if a block contains quantum operations. +fn contains_quantum_ops(block: &crate::ast::Block) -> bool { + use crate::ast::Stmt; + for stmt in &block.statements { + match stmt { + Stmt::Gate(_) | Stmt::Prepare(_) | Stmt::Measure(_) | Stmt::Barrier(_) => { + return true; + } + Stmt::Expr(expr_stmt) => { + if expr_contains_quantum(&expr_stmt.expr) { + return true; + } + } + Stmt::If(if_stmt) => { + if contains_quantum_ops(&if_stmt.then_body) { + return true; + } + if let Some(ref else_branch) = if_stmt.else_body { + match else_branch { + crate::ast::ElseBranch::Else(block) => { + if contains_quantum_ops(block) { + return true; + } + } + crate::ast::ElseBranch::ElseIf(if_stmt) => { + if contains_quantum_ops(&if_stmt.then_body) { + return true; + } + } + } + } + } + Stmt::For(for_stmt) => { + if contains_quantum_ops(&for_stmt.body) { + return true; + } + } + Stmt::Block(block) => { + if contains_quantum_ops(block) { + return true; + } + } + Stmt::Tick(tick) => { + // Tick blocks always contain quantum ops + let _ = tick; + return true; + } + _ => {} + } + } + false +} + +/// Check if an expression contains quantum operations (gate calls, qalloc, etc.). +fn expr_contains_quantum(expr: &crate::ast::Expr) -> bool { + matches!(expr, crate::ast::Expr::Gate(_)) +} + +/// Format test results for terminal output. +pub fn format_results(results: &[TestResult]) -> String { + let mut out = String::new(); + + for result in results { + let status = match &result.outcome { + TestOutcome::Pass => "PASS", + TestOutcome::Fail(_) => "FAIL", + TestOutcome::Skip(_) => "SKIP", + }; + + out.push_str(&format!( + " {} {} ({:.3}ms)\n", + status, + result.name, + result.duration.as_secs_f64() * 1000.0, + )); + + match &result.outcome { + TestOutcome::Fail(msg) => { + out.push_str(&format!(" {}\n", msg)); + } + TestOutcome::Skip(reason) => { + out.push_str(&format!(" {}\n", reason)); + } + _ => {} + } + } + + let pass_count = results.iter().filter(|r| r.outcome == TestOutcome::Pass).count(); + let fail_count = results.iter().filter(|r| matches!(r.outcome, TestOutcome::Fail(_))).count(); + let skip_count = results.iter().filter(|r| matches!(r.outcome, TestOutcome::Skip(_))).count(); + let total = results.len(); + + out.push_str(&format!( + "\n{} tests: {} passed, {} failed, {} skipped\n", + total, pass_count, fail_count, skip_count, + )); + + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parse; + + #[test] + fn test_discover_tests() { + let source = r#" + test "addition works" { + x := 1 + 1; + } + test "subtraction works" { + x := 2 - 1; + } + fn main() -> unit { return; } + "#; + let program = parse(source).unwrap(); + let runner = TestRunner::new(TestRunConfig::default()); + let tests = runner.discover_tests(&program); + assert_eq!(tests.len(), 2); + assert_eq!(tests[0].name, "addition works"); + assert_eq!(tests[1].name, "subtraction works"); + } + + #[test] + fn test_filter_by_name() { + let source = r#" + test "addition works" { + x := 1 + 1; + } + test "subtraction works" { + x := 2 - 1; + } + "#; + let program = parse(source).unwrap(); + let runner = TestRunner::new(TestRunConfig { + filter: Some("addition".to_string()), + ..Default::default() + }); + let tests = runner.discover_tests(&program); + assert_eq!(tests.len(), 1); + assert_eq!(tests[0].name, "addition works"); + } + + #[test] + fn test_passing_test() { + let source = r#" + test "simple math" { + x := 1 + 1; + } + "#; + let program = parse(source).unwrap(); + let runner = TestRunner::new(TestRunConfig::default()); + let results = runner.run(&program); + assert_eq!(results.len(), 1); + assert_eq!(results[0].outcome, TestOutcome::Pass); + } + + #[test] + fn test_quantum_test_skipped() { + let source = r#" + test "quantum test" { + mut q := qalloc(2); + h q[0]; + } + "#; + let program = parse(source).unwrap(); + let runner = TestRunner::new(TestRunConfig::default()); + let results = runner.run(&program); + assert_eq!(results.len(), 1); + assert!(matches!(results[0].outcome, TestOutcome::Skip(_))); + } + + #[test] + fn test_format_results() { + let results = vec![ + TestResult { + name: "test 1".to_string(), + outcome: TestOutcome::Pass, + duration: Duration::from_millis(1), + }, + TestResult { + name: "test 2".to_string(), + outcome: TestOutcome::Fail("assertion failed".to_string()), + duration: Duration::from_millis(2), + }, + ]; + let output = format_results(&results); + assert!(output.contains("PASS")); + assert!(output.contains("FAIL")); + assert!(output.contains("2 tests: 1 passed, 1 failed, 0 skipped")); + } +} diff --git a/exp/zlup/src/tests.rs b/exp/zlup/src/tests.rs new file mode 100644 index 000000000..41a7109c9 --- /dev/null +++ b/exp/zlup/src/tests.rs @@ -0,0 +1,1810 @@ +//! Comprehensive tests for Zluppy grammar and parser. + +use crate::parser::parse; + +// ============================================================================= +// Helper macros +// ============================================================================= + +/// Assert that parsing succeeds +macro_rules! assert_parses { + ($src:expr) => { + match parse($src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse:\n{}\nError: {}", $src, e), + } + }; +} + +/// Assert that parsing fails +macro_rules! assert_parse_fails { + ($src:expr) => { + assert!( + parse($src).is_err(), + "Expected parse to fail but it succeeded:\n{}", + $src + ); + }; +} + +// ============================================================================= +// Empty and minimal programs +// ============================================================================= + +#[test] +fn test_empty_program() { + assert_parses!(""); +} + +#[test] +fn test_whitespace_only() { + assert_parses!(" \n\t\n "); +} + +#[test] +fn test_comments_only() { + assert_parses!("// line comment\n"); + assert_parses!("/* block comment */"); + assert_parses!("// comment 1\n// comment 2\n"); + assert_parses!("/* multi\nline\ncomment */"); +} + +// ============================================================================= +// Const declarations +// ============================================================================= + +#[test] +fn test_const_with_type() { + assert_parses!("x: u32 = 42;"); + assert_parses!("pi: f64 = 3.14159;"); + assert_parses!("flag: bool = true;"); +} + +#[test] +fn test_const_inferred_type() { + assert_parses!("x := 42;"); + assert_parses!("name := \"hello\";"); +} + +#[test] +fn test_const_pub() { + assert_parses!("pub API_VERSION: u32 = 1;"); +} + +#[test] +fn test_const_expressions() { + assert_parses!("sum := 1 + 2;"); + assert_parses!("product := 3 * 4;"); + assert_parses!("complex := (1 + 2) * 3;"); +} + +// ============================================================================= +// Var declarations +// ============================================================================= + +#[test] +fn test_var_with_type() { + assert_parses!("mut count: u32 = 0;"); + assert_parses!("mut name: []u8 = undefined;"); +} + +#[test] +fn test_var_undefined() { + assert_parses!("mut buffer: [1024]u8 = undefined;"); +} + +#[test] +fn test_var_pub() { + assert_parses!("pub mut global_state: i32 = 0;"); +} + +// ============================================================================= +// Function declarations +// ============================================================================= + +#[test] +fn test_fn_no_params_void() { + assert_parses!("fn main() -> unit {}"); +} + +#[test] +fn test_fn_with_params() { + assert_parses!("fn add(a: i32, b: i32) -> i32 { return a + b; }"); +} + +#[test] +fn test_fn_pub() { + assert_parses!("pub fn process() -> unit {}"); +} + +#[test] +fn test_fn_inline() { + assert_parses!("inline fn fast_add(a: i32, b: i32) -> i32 { return a + b; }"); +} + +#[test] +fn test_extern_fn_basic() { + // Basic extern function with C calling convention + assert_parses!(r#"extern "C" fn decode(data: [*]u8, len: usize) -> i32;"#); +} + +#[test] +fn test_extern_fn_no_params() { + // Extern function with no parameters + assert_parses!(r#"extern "C" fn get_version() -> u32;"#); +} + +#[test] +fn test_extern_fn_no_return() { + // Extern function with no return type (returns unit) + assert_parses!(r#"extern "C" fn init();"#); +} + +#[test] +fn test_extern_fn_pub() { + // Public extern function + assert_parses!(r#"pub extern "C" fn mwpm_decode(syndrome: [*]const u8, n: usize) -> i32;"#); +} + +#[test] +fn test_extern_fn_rust_abi() { + // Extern function with Rust calling convention + assert_parses!(r#"extern "Rust" fn pecos_decode(data: *const u8) -> DecoderResult;"#); +} + +#[test] +fn test_fn_comptime_param() { + assert_parses!("fn make_array(comptime T: type, comptime N: usize) -> unit {}"); +} + +#[test] +fn test_fn_with_body() { + assert_parses!( + r#" + fn factorial(n: u64) -> u64 { + if (n <= 1) { + return 1; + } + return n * factorial(n - 1); + } + "# + ); +} + +// ============================================================================= +// Struct declarations +// ============================================================================= + +#[test] +fn test_struct_empty() { + assert_parses!("Empty := struct {};"); +} + +#[test] +fn test_struct_with_fields() { + assert_parses!( + r#" + Point := struct { + x: f64, + y: f64, + }; + "# + ); +} + +#[test] +fn test_struct_with_defaults() { + assert_parses!( + r#" + Config := struct { + width: u32 = 800, + height: u32 = 600, + fullscreen: bool = false, + }; + "# + ); +} + +#[test] +fn test_struct_with_methods() { + assert_parses!( + r#" + Counter := struct { + value: u32, + + fn increment(&mut self) -> unit { + self.value = self.value + 1; + } + + fn get(&mut self) -> u32 { + return self.value; + } + }; + "# + ); +} + +#[test] +fn test_struct_pub() { + assert_parses!( + r#" + pub PublicStruct := struct { + data: u32, + }; + "# + ); +} + +#[test] +fn test_keywords_as_member_names() { + // Keywords can be used as struct field names + assert_parses!( + r#" + MyStruct := struct { + set: bool, + union: u32, + type: usize, + }; + "# + ); + + // Keywords can be used as method names + assert_parses!( + r#" + Container := struct { + data: u32, + + pub fn set(&mut self, value: u32) -> void { + self.data = value; + return unit; + } + + pub fn union(&mut self, other: *Self) -> void { + self.data = self.data + other.data; + return unit; + } + }; + "# + ); + + // Keywords can be accessed as field names + assert_parses!( + r#" + fn use_keywords(s: *MyStruct) -> void { + x := s.set; + y := s.union; + z := s.type; + return unit; + } + "# + ); + + // Keywords in range bounds (field access) + assert_parses!( + r#" + fn iterate(s: *Container) -> void { + for i in 0..s.set { + process(i); + } + } + "# + ); +} + +#[test] +fn test_block_attributes() { + // Block with single attribute + assert_parses!( + r#" + fn main() -> unit { + @attr(kind, "init") + { + h q[0]; + } + return unit; + } + "# + ); + + // Block with multiple attributes + assert_parses!( + r#" + fn main() -> unit { + @attrs({round: 0, kind: "syndrome"}) + { + cx (q[0], q[1]); + } + return unit; + } + "# + ); + + // Labeled block with attributes + assert_parses!( + r#" + fn main() -> unit { + @attr(priority, 1) + setup: { + pz q; + h q[0]; + } + return unit; + } + "# + ); + + // Block expression with attributes + assert_parses!( + r#" + fn compute() -> u32 { + result := @attr(optimized, true) compute_block: { + x := 1 + 2; + x * 3 + }; + return result; + } + "# + ); +} + +// ============================================================================= +// Enum declarations +// ============================================================================= + +#[test] +fn test_enum_simple() { + assert_parses!( + r#" + Color := enum { + Red, + Green, + Blue, + }; + "# + ); +} + +#[test] +fn test_enum_with_values() { + assert_parses!( + r#" + Status := enum(u8) { + Ok = 0, + Error = 1, + Pending = 2, + }; + "# + ); +} + +#[test] +fn test_union_tagged() { + // Auto-tagged union with union(enum) + assert_parses!( + r#" + Value := union(enum) { + Int: i32, + Float: f64, + Bool: bool, + None, + }; + "# + ); +} + +#[test] +fn test_union_untagged() { + // Untagged union + assert_parses!( + r#" + RawValue := union { + Int: i32, + Float: f64, + }; + "# + ); +} + +#[test] +fn test_union_external_tag() { + // Externally-tagged union + assert_parses!( + r#" + MyTag := enum { A, B, C }; + MyUnion := union(MyTag) { + A: u32, + B: f32, + C, + }; + "# + ); +} + +// ============================================================================= +// Control flow statements +// ============================================================================= + +#[test] +fn test_if_simple() { + assert_parses!( + r#" + fn run() -> unit { + if (x > 0) { + do_something(); + } + } + "# + ); +} + +#[test] +fn test_if_else() { + assert_parses!( + r#" + fn run() -> unit { + if (x > 0) { + positive(); + } else { + non_positive(); + } + } + "# + ); +} + +#[test] +fn test_if_else_if() { + assert_parses!( + r#" + fn run() -> unit { + if (x > 0) { + positive(); + } else if (x < 0) { + negative(); + } else { + zero(); + } + } + "# + ); +} + +#[test] +fn test_for_loop_range() { + assert_parses!( + r#" + fn run() -> unit { + for i in 0..10 { + process(i); + } + } + "# + ); +} + +#[test] +fn test_for_loop_inline() { + assert_parses!( + r#" + fn run() -> unit { + inline for i in 0..4 { + unrolled(i); + } + } + "# + ); +} + +#[test] +fn test_for_loop_runtime_bound() { + // Runtime-bounded for loop (bound is a runtime value) + assert_parses!( + r#" + fn process(n: usize) -> unit { + for i in 0..n { + do_work(i); + } + } + "# + ); + + // Runtime bound with method call on self + assert_parses!( + r#" + fn iterate(&mut self) -> unit { + for i in 0..self.len { + process(self.items[i]); + } + } + "# + ); + + // Mixed: comptime capacity bound with early break on runtime value + assert_parses!( + r#" + Container := fn(comptime capacity: usize) -> type { + struct { + items: [capacity]u32 = undefined, + len: usize = 0, + + fn search(&mut self, target: u32) -> ?usize { + for i in 0..capacity { + if (i >= self.len) { + break; + } + if (self.items[i] == target) { + return i; + } + } + return none; + } + } + }; + "# + ); +} + +#[test] +fn test_switch() { + assert_parses!( + r#" + fn handle(x: u32) -> unit { + switch (x) { + 0 => handle_zero(), + 1 => handle_one(), + else => handle_other(), + } + } + "# + ); +} + +#[test] +fn test_return() { + assert_parses!("fn run() -> u32 { return 42; }"); + assert_parses!("fn run() -> unit { return; }"); +} + +#[test] +fn test_break_continue() { + assert_parses!( + r#" + fn run() -> unit { + for i in 0..100 { + if (done) { + break; + } + if (skip) { + continue; + } + } + } + "# + ); +} + +#[test] +fn test_labeled_break() { + assert_parses!( + r#" + fn run() -> unit { + outer: for _ in 0..100 { + for _ in 0..100 { + break :outer; + } + } + } + "# + ); +} + +#[test] +fn test_defer() { + assert_parses!( + r#" + fn run() -> unit { + defer cleanup(); + do_work(); + } + "# + ); +} + +#[test] +fn test_errdefer() { + // Basic errdefer without capture + assert_parses!( + r#" + fn run() -> unit { + errdefer cleanup(); + do_work(); + } + "# + ); + + // errdefer with capture + assert_parses!( + r#" + fn run() -> unit { + errdefer |err| { + log_error(err); + } + do_work(); + } + "# + ); + + // errdefer with block body + assert_parses!( + r#" + fn run() -> unit { + errdefer { + cleanup(); + notify(); + } + do_work(); + } + "# + ); +} + +// ============================================================================= +// Expressions +// ============================================================================= + +#[test] +fn test_literals() { + assert_parses!("a := 42;"); + assert_parses!("b := 3.14;"); + assert_parses!("c := true;"); + assert_parses!("d := false;"); + assert_parses!("e := none;"); + assert_parses!(r#"f := "hello";"#); + assert_parses!("g := 'x';"); +} + +#[test] +fn test_number_formats() { + assert_parses!("dec := 42;"); + assert_parses!("hex := 0xFF;"); + assert_parses!("bin := 0b1010;"); + assert_parses!("oct := 0o77;"); + assert_parses!("with_underscore := 1_000_000;"); + assert_parses!("float := 3.14159;"); + assert_parses!("exp := 1e10;"); +} + +#[test] +fn test_binary_operators() { + assert_parses!("a := 1 + 2;"); + assert_parses!("b := 3 - 1;"); + assert_parses!("c := 2 * 3;"); + assert_parses!("d := 10 / 2;"); + assert_parses!("e := 10 % 3;"); + assert_parses!("f := a == b;"); + assert_parses!("g := a != b;"); + assert_parses!("h := a < b;"); + assert_parses!("i := a <= b;"); + assert_parses!("j := a > b;"); + assert_parses!("k := a >= b;"); + assert_parses!("l := a and b;"); + assert_parses!("m := a or b;"); + assert_parses!("n := a & b;"); + assert_parses!("o := a | b;"); + assert_parses!("p := a ^ b;"); + assert_parses!("q := a << 2;"); + assert_parses!("r := a >> 2;"); +} + +#[test] +fn test_unary_operators() { + assert_parses!("a := -x;"); + assert_parses!("b := !flag;"); + assert_parses!("c := ~bits;"); + assert_parses!("d := &value;"); + assert_parses!("e := *ptr;"); +} + +#[test] +fn test_operator_precedence() { + assert_parses!("a := 1 + 2 * 3;"); + assert_parses!("b := (1 + 2) * 3;"); + assert_parses!("c := a and b or c;"); + assert_parses!("d := a == b and c != d;"); +} + +#[test] +fn test_function_calls() { + assert_parses!("a := foo();"); + assert_parses!("b := bar(1);"); + assert_parses!("c := baz(1, 2, 3);"); + assert_parses!("d := nested(inner(x));"); +} + +#[test] +fn test_field_access() { + assert_parses!("a := obj.field;"); + assert_parses!("b := obj.nested.field;"); + assert_parses!("c := get_obj().field;"); +} + +#[test] +fn test_index_access() { + assert_parses!("a := arr[0];"); + assert_parses!("b := arr[i];"); + assert_parses!("c := matrix[i][j];"); +} + +#[test] +fn test_if_expression() { + // If expressions require parentheses around condition to disambiguate from the block + // Blocks support trailing expressions like Rust + assert_parses!("max := if (a > b) { a } else { b };"); +} + +#[test] +fn test_break_with_expression() { + // Simple break with expression + assert_parses!("fn run() -> unit { break 42; }"); + // Break with label and expression + assert_parses!("fn run() -> unit { break :lbl 42; }"); +} + +#[test] +fn test_block_expression() { + assert_parses!( + r#" + fn run() -> u32 { + result := blk: { + temp := compute(); + break :blk temp * 2; + }; + return result; + } + "# + ); +} + +#[test] +fn test_builtin_calls() { + assert_parses!(r#"std := @import("std");"#); + // Self is now a keyword (like Rust), no need for @This() alias + assert_parses!("x := Self;"); // Self can be used as a type value + assert_parses!("size := @sizeOf(u32);"); +} + +#[test] +fn test_struct_init() { + // Rust-style struct init: `Type { field: value }` + assert_parses!("p := Point { x: 1.0, y: 2.0 };"); + assert_parses!("empty := .{};"); + assert_parses!("anon := .{ a: 1, b: 2 };"); + // Shorthand when variable name matches field name + assert_parses!("x := 1; y := 2; p := Point { x, y };"); +} + +#[test] +fn test_array_init() { + assert_parses!("arr := .{ 1, 2, 3 };"); + assert_parses!("typed := [3]u32{ 1, 2, 3 };"); +} + +// ============================================================================= +// Types +// ============================================================================= + +#[test] +fn test_primitive_types() { + assert_parses!("a: u8 = 0;"); + assert_parses!("b: u16 = 0;"); + assert_parses!("c: u32 = 0;"); + assert_parses!("d: u64 = 0;"); + assert_parses!("e: i8 = 0;"); + assert_parses!("f: i16 = 0;"); + assert_parses!("g: i32 = 0;"); + assert_parses!("h: i64 = 0;"); + assert_parses!("i: f32 = 0.0;"); + assert_parses!("j: f64 = 0.0;"); + assert_parses!("k: bool = true;"); + assert_parses!("l: usize = 0;"); +} + +#[test] +fn test_array_types() { + assert_parses!("a: [10]u8 = undefined;"); + assert_parses!("b: []u8 = undefined;"); + assert_parses!("c: [_]u8 = undefined;"); +} + +#[test] +fn test_array_size_expressions() { + // Simple identifier + assert_parses!("a: [N]u8 = undefined;"); + // Simple literal + assert_parses!("b: [64]u8 = undefined;"); + // Addition expression + assert_parses!("c: [N + 1]u8 = undefined;"); + // Subtraction expression + assert_parses!("d: [N - 1]u8 = undefined;"); + // Multiplication expression + assert_parses!("e: [N * 2]u8 = undefined;"); + // Division expression + assert_parses!("f: [N / 2]u8 = undefined;"); + // Complex expression + assert_parses!("g: [N + M + 1]u8 = undefined;"); + // Parenthesized expression + assert_parses!("h: [(N + 1) * 2]u8 = undefined;"); + // In function returning comptime type + assert_parses!(r#" + pub MyArray := fn(comptime N: usize) -> type { + struct { + data: [N + 1]u8 = undefined, + } + }; + "#); +} + +#[test] +fn test_pointer_types() { + assert_parses!("a: *u32 = undefined;"); + // Note: When type is explicit, use = not := + assert_parses!("b: *u32 = undefined;"); + assert_parses!("c: [*]u8 = undefined;"); +} + +#[test] +fn test_sentinel_terminated_pointers() { + // Null-terminated string (sentinel = 0) + assert_parses!("s: [*:0]u8 = undefined;"); + // Sentinel with different value + assert_parses!("t: [*:255]u8 = undefined;"); + // Sentinel with const + assert_parses!("u: [*:0]const u8 = undefined;"); + // Sentinel with expression + assert_parses!("v: [*:0xFF]u8 = undefined;"); +} + +#[test] +fn test_sentinel_terminated_arrays() { + // Array with sentinel value + assert_parses!("a: [10:0]u8 = undefined;"); + // Array with different sentinel + assert_parses!("b: [5:255]u8 = undefined;"); + // Slice with sentinel (no size, just sentinel) + assert_parses!("c: [:0]u8 = undefined;"); +} + +#[test] +fn test_optional_types() { + assert_parses!("a: ?u32 = none;"); + assert_parses!("b: ?*Node = none;"); +} + +#[test] +fn test_error_union_types() { + assert_parses!("fn run() -> Error!u32 { return 42; }"); +} + +#[test] +fn test_error_value() { + // error.Name syntax for error literals + // Use := when type is inferred (walrus operator) + assert_parses!("e := error.OutOfMemory;"); + assert_parses!("e := error.InvalidArgument;"); + assert_parses!("e := error.FileNotFound;"); +} + +#[test] +fn test_error_set_definition() { + // Error set definition: MyError := error { ... } + assert_parses!("MyError := error { OutOfMemory };"); + assert_parses!("FileError := error { NotFound, AccessDenied, Busy };"); + assert_parses!( + r#" + DivisionError := error { + DivisionByZero, + Overflow, + Underflow, + }; + "# + ); + // Public error set + assert_parses!("pub IoError := error { ReadError, WriteError };"); +} + +#[test] +fn test_fault_set_definition() { + // Fault set definition: MyFault := fault { ... } + assert_parses!("QuantumFault := fault { Leakage };"); + assert_parses!("GateFault := fault { Leakage, QubitLoss, GateFailure };"); + assert_parses!( + r#" + QuantumFault := fault { + Leakage, + QubitLoss, + GateFailure, + }; + "# + ); + // Fault with associated data (using type references) + assert_parses!( + r#" + QuantumFault := fault { + Leakage: LeakageInfo, + QubitLoss: QubitLossInfo, + }; + "# + ); + // Public fault set + assert_parses!("pub QuantumFault := fault { Leakage, QubitLoss };"); +} + +#[test] +fn test_set_literal() { + // Set literals with set keyword (consistent with struct, enum, union) + assert_parses!("primes := set { 2, 3, 5, 7 };"); + assert_parses!("empty := set {};"); + assert_parses!("single := set { 42 };"); + // Set with explicit type + assert_parses!("nums: Set(i64) = set { 1, 2, 3 };"); +} + +#[test] +fn test_batch_apply() { + // Batch gate apply: h { q[0], q[1] } - set semantics (order doesn't matter) + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + h { q[0], q[1], q[2] }; + } + "# + ); + // Parameterized gate batch + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + rz(pi/4) { q[0], q[1] }; + } + "# + ); + // Two-qubit gate batch with pairs + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + cx { (q[0], q[1]), (q[2], q[3]) }; + } + "# + ); +} + +#[test] +fn test_measure_syntax() { + // New measurement syntax: mz(T) targets + // Inline array (ordered results) + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + results := mz(u1) [q[0], q[1], q[2]]; + } + "# + ); + // Single qubit measurement + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(2); + r := mz(u1) q[0]; + } + "# + ); + // Different result types + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(2); + r8 := mz(u8) [q[0]]; + r64 := mz(u64) [q[0], q[1]]; + } + "# + ); +} + +#[test] +fn test_try_expression() { + // try expr - error propagation + assert_parses!( + r#" + fn process() -> Error!u32 { + result := try doSomething(); + return result; + } + "# + ); +} + +#[test] +fn test_catch_expression() { + // expr catch handler + assert_parses!( + r#" + fn safe_divide(a: u32, b: u32) -> u32 { + return divide(a, b) catch 0; + } + "# + ); + // expr catch |err| handler + assert_parses!( + r#" + fn safe_op() -> u32 { + return risky() catch |err| handleError(err); + } + "# + ); +} + +#[test] +fn test_error_unwrap() { + // .! postfix operator for error unwrap + assert_parses!( + r#" + fn unwrap_result(r: Error!u32) -> u32 { + return r.!; + } + "# + ); +} + +#[test] +fn test_try_block_collect() { + // try { } - collect quantum faults (QEC pattern) + assert_parses!( + r#" + fn qec_round(q: []qubit) try -> []QuantumFault!void { + h q[0]; + cx (q[0], q[1]); + } + "# + ); + // try { } as statement (no semicolon after block) + assert_parses!( + r#" + fn circuit() -> unit { + mut q := qalloc(4); + try { + h q[0]; + cx (q[0], q[1]); + } + } + "# + ); + // try { } with catch + assert_parses!( + r#" + fn circuit() -> unit { + mut q := qalloc(4); + try { + h q[0]; + } catch |err| { + log(err); + } + } + "# + ); +} + +#[test] +fn test_try_block_propagate() { + // try! { } - stop on first fault/error (traditional) + assert_parses!( + r#" + fn strict_circuit(q: []qubit) try! -> QuantumFault!void { + h q[0]; + cx (q[0], q[1]); + } + "# + ); + // try! { } as statement with catch (no semicolon after block) + assert_parses!( + r#" + fn circuit() -> unit { + mut q := qalloc(4); + try! { + h q[0]; + cx (q[0], q[1]); + } catch |err| { + abort(); + } + } + "# + ); +} + +#[test] +fn test_try_block_expression() { + // try { } as expression (for assignment) + assert_parses!( + r#" + fn collect_errors() -> unit { + mut q := qalloc(4); + errors := try { + h q[0]; + cx (q[0], q[1]); + }; + } + "# + ); + // try! { } as expression with catch providing default + assert_parses!( + r#" + fn safe_measure() -> u1 { + mut q := qalloc(1); + result := try! { + h q[0]; + mz(u1) q[0] + } catch |err| { 0 }; + return result; + } + "# + ); +} + +#[test] +fn test_function_types() { + assert_parses!("callback: fn(u32) -> unit = undefined;"); + assert_parses!("binary_fn: fn(i32, i32) -> i32 = undefined;"); +} + +#[test] +fn test_named_types() { + assert_parses!("a: MyStruct = undefined;"); + assert_parses!("b: std.mem.Allocator = undefined;"); +} + +// ============================================================================= +// Quantum-specific syntax +// ============================================================================= + +#[test] +fn test_quantum_types() { + assert_parses!("q: qubit = undefined;"); + assert_parses!("b: bit = undefined;"); + assert_parses!("mut alloc: Alloc = undefined;"); +} + +#[test] +fn test_quantum_allocator() { + assert_parses!( + r#" + fn main() -> unit { + mut base := qalloc(100); + mut data := base.child(9); + mut ancilla := base.child(8); + } + "# + ); +} + +#[test] +fn test_quantum_prepare() { + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + pz {q[0], q[1], q[2]}; + } + "# + ); +} + +#[test] +fn test_quantum_gates() { + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + H(q[0]); + X(q[1]); + CX(q[0], q[1]); + RZ(q[0], 0.5); + } + "# + ); +} + +#[test] +fn test_swap_and_toffoli_gates() { + // SWAP gate + assert_parses!( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + swap (q[0], q[1]); + } + "# + ); + + // iSWAP gate + assert_parses!( + r#" + fn main() -> unit { + q := qalloc(2); + pz q; + iswap (q[0], q[1]); + } + "# + ); + + // CCX (Toffoli) gate + assert_parses!( + r#" + fn main() -> unit { + q := qalloc(3); + pz q; + ccx (q[0], q[1], q[2]); + } + "# + ); +} + +#[test] +fn test_quantum_measure() { + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(2); + pz q; + H(q[0]); + CX(q[0], q[1]); + result := measure(q[0]); + all_results := measure(q); + } + "# + ); +} + +#[test] +fn test_quantum_conditional() { + assert_parses!( + r#" + fn main() -> unit { + mut q := qalloc(2); + pz q; + H(q[0]); + m := measure(q[0]); + if (m) { + X(q[1]); + } + } + "# + ); +} + +#[test] +fn test_quantum_bell_state() { + assert_parses!( + r#" + pub fn bell_state() -> unit { + mut base := qalloc(2); + mut q := base.child(2); + + pz q; + + H(q[0]); + CX(q[0], q[1]); + + results := measure(q); + } + "# + ); +} + +#[test] +fn test_quantum_teleportation() { + assert_parses!( + r#" + pub fn teleport() -> unit { + mut base := qalloc(10); + mut state := base.child(1); + mut epr := base.child(2); + + pz state; + pz epr; + + H(epr[0]); + CX(epr[0], epr[1]); + + CX(state[0], epr[0]); + H(state[0]); + + m1 := measure(state[0]); + m2 := measure(epr[0]); + + if (m2) { + X(epr[1]); + } + if (m1) { + Z(epr[1]); + } + } + "# + ); +} + +// ============================================================================= +// Comptime features +// ============================================================================= + +#[test] +fn test_comptime_expression() { + assert_parses!("size := comptime 4 * 8;"); +} + +#[test] +fn test_comptime_block() { + assert_parses!( + r#" + value := comptime { + mut result: u32 = 0; + result = 42; + }; + "# + ); +} + +#[test] +fn test_comptime_type_function() { + assert_parses!( + r#" + fn make_array(comptime T: type, comptime N: usize) -> type { + return [N]T; + } + "# + ); +} + +// ============================================================================= +// Test declarations +// ============================================================================= + +#[test] +fn test_test_decl() { + assert_parses!( + r#" + test "basic addition" { + result := 1 + 1; + } + "# + ); +} + +// ============================================================================= +// Documentation comments +// ============================================================================= + +#[test] +fn test_doc_comments() { + assert_parses!( + r#" + /// This is a documented function. + /// It does important things. + pub fn documented() -> unit {} + "# + ); +} + +// ============================================================================= +// Complex programs +// ============================================================================= + +#[test] +fn test_surface_code_skeleton() { + assert_parses!( + r#" + pub fn surface_code(comptime distance: u32) -> type { + num_data := distance * distance; + num_ancilla := (distance - 1) * (distance - 1) * 2; + + return struct { + data: Alloc, + ancilla: Alloc, + + // Self is implicitly available in struct methods (Rust-style) + + pub fn init(base: *Alloc) -> Self { + return Self { + data: base.child(num_data), + ancilla: base.child(num_ancilla), + }; + } + + pub fn syndrome_round(&mut self) -> unit { + pz self.ancilla; + + inline for i in 0..num_ancilla { + h(self.ancilla[i]); + } + + syndrome := measure(self.ancilla); + } + }; + } + "# + ); +} + +// ============================================================================= +// Error cases (should fail to parse) +// ============================================================================= + +#[test] +fn test_missing_semicolon() { + assert_parse_fails!("x := 42"); +} + +#[test] +fn test_invalid_identifier() { + assert_parse_fails!("const 123abc = 1;"); +} + +#[test] +fn test_unbalanced_braces() { + assert_parse_fails!("fn run() -> unit {"); + assert_parse_fails!("fn run() -> unit }"); +} + +#[test] +fn test_unbalanced_parens() { + assert_parse_fails!("x := (1 + 2;"); +} + +// ============================================================================= +// Zig-style syntax should fail (we use Rust-style) +// ============================================================================= + +#[test] +fn test_zig_style_struct_init_fails() { + // Old Zig-style: `.field = value` - should fail, use `field: value` + assert_parse_fails!("p := Point { .x = 1, .y = 2 };"); + assert_parse_fails!("anon := .{ .a = 1, .b = 2 };"); +} + +#[test] +fn test_zig_style_no_space_before_brace_ok() { + // `Type{ ... }` without space is still valid + assert_parses!("p := Point{ x: 1, y: 2 };"); +} + +#[test] +fn test_rust_style_struct_init_ok() { + // Rust-style: `field: value` - should work + assert_parses!("p := Point { x: 1, y: 2 };"); + assert_parses!("anon := .{ a: 1, b: 2 };"); + // Shorthand + assert_parses!("x := 1; p := Point { x };"); +} + +#[test] +fn test_zig_style_this_parses_as_builtin() { + // @This() still parses (as builtin call) but Self is preferred + // Semantic analysis could reject it, but parsing accepts it + assert_parses!("T := @This();"); +} + +#[test] +fn test_zig_style_null_parses_as_identifier() { + // null parses as identifier, but will fail semantic analysis + // (undefined symbol). Use none instead. + assert_parses!("x := null;"); +} + +#[test] +fn test_explicit_self_type_still_valid() { + // Explicit self: *Type is still valid (for advanced cases) + // But &mut self is the idiomatic style + assert_parses!("fn foo(self: *Foo) -> unit {}"); +} + +#[test] +fn test_rust_style_self_ok() { + // Rust-style Self and &mut self should work + assert_parses!("fn foo() -> Self { return Self; }"); + assert_parses!("Foo := struct { fn bar(&mut self) -> unit { return unit; } };"); + assert_parses!("Foo := struct { fn baz(&self) -> unit { return unit; } };"); +} + +#[test] +fn test_rust_style_none_ok() { + // none instead of null + assert_parses!("x := none;"); + assert_parses!("opt: ?u32 = none;"); +} + +#[test] +fn test_python_style_fstring_ok() { + // Python-style f-strings + assert_parses!(r#"msg := f"Hello";"#); + assert_parses!(r#"name := "World"; msg := f"Hello {name}";"#); + assert_parses!(r#"x := 42; msg := f"value = {x}";"#); + assert_parses!(r#"a := 1; b := 2; msg := f"sum = {a + b}";"#); + assert_parses!(r#"msg := f"escaped \{ brace \}";"#); +} + +#[test] +fn test_python_style_fstring_format_spec_ok() { + // F-strings with format specifiers (Python-style) + assert_parses!(r#"x := 3.14159; msg := f"pi = {x:.2f}";"#); + assert_parses!(r#"n := 42; msg := f"padded = {n:08d}";"#); + assert_parses!(r#"name := "Alice"; msg := f"name = {name:>10}";"#); + assert_parses!(r#"val := 255; msg := f"hex = {val:x}";"#); + // Multiple format specifiers in one string + assert_parses!(r#"x := 1.0; y := 2.0; msg := f"({x:.1f}, {y:.1f})";"#); +} + +#[test] +fn test_python_style_raw_string_ok() { + // Python-style raw strings (no escape processing) + assert_parses!(r#"path := r"C:\Users\test";"#); + assert_parses!(r#"regex := r"\d+\.\d+";"#); + assert_parses!(r#"s := r"no escapes: \n \t \\";"#); +} + +#[test] +fn test_python_style_in_operator_ok() { + // Python-style in/not in operators + assert_parses!("items := set { 1, 2, 3 }; x := 5 in items;"); + assert_parses!("items := set { 1, 2, 3 }; x := 5 not in items;"); +} + +#[test] +fn test_python_style_multiline_string_ok() { + // Python-style triple-quoted multi-line strings + assert_parses!(r#"text := """hello""";"#); + assert_parses!( + r#"text := """line 1 +line 2 +line 3""";"# + ); + assert_parses!( + r#"sql := """ +SELECT * +FROM users +WHERE active = true +""";"# + ); + // Escape sequences still work inside multi-line strings + assert_parses!(r#"text := """tab:\there""";"#); +} + +// ============================================================================= +// Standard Library Tests +// ============================================================================= + +#[test] +fn test_std_bits_parses() { + let src = include_str!("../std/bits.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/bits.zlup:\n{}", e), + } +} + +#[test] +fn test_std_containers_parses() { + let src = include_str!("../std/containers.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/containers.zlup:\n{}", e), + } +} + +#[test] +fn test_std_qec_parses() { + let src = include_str!("../std/qec.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/qec.zlup:\n{}", e), + } +} + +#[test] +fn test_std_math_parses() { + let src = include_str!("../std/math.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/math.zlup:\n{}", e), + } +} + +#[test] +fn test_std_main_parses() { + let src = include_str!("../std/std.zlup"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/std.zlup:\n{}", e), + } +} + +#[test] +fn test_std_f64_parses() { + let src = include_str!("../std/f64.zlp"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/f64.zlp:\n{}", e), + } +} + +#[test] +fn test_std_a64_parses() { + let src = include_str!("../std/a64.zlp"); + match parse(src) { + Ok(_) => {} + Err(e) => panic!("Failed to parse std/a64.zlp:\n{}", e), + } +} + +// ============================================================================= +// Deprecated gate names rejected at grammar level +// ============================================================================= + +#[test] +fn test_deprecated_s_gate_rejected() { + // "s" is no longer a valid gate keyword — use "sz" instead + assert_parse_fails!("fn main() -> unit { s q[0]; }"); +} + +#[test] +fn test_deprecated_sdg_gate_rejected() { + // "sdg" is no longer a valid gate keyword — use "szdg" instead + assert_parse_fails!("fn main() -> unit { sdg q[0]; }"); +} + +// ============================================================================= +// Gate name suggestion helpers +// ============================================================================= + +#[test] +fn test_suggest_gate_name_deprecated() { + use crate::parser::suggest_gate_name; + assert_eq!(suggest_gate_name("s"), Some("sz")); + assert_eq!(suggest_gate_name("sdg"), Some("szdg")); +} + +#[test] +fn test_suggest_gate_name_typo() { + use crate::parser::suggest_gate_name; + // Close misspellings should suggest the correct gate + assert_eq!(suggest_gate_name("cx"), Some("cx")); // exact match (dist 0) + assert_eq!(suggest_gate_name("hh"), Some("h")); // edit distance 1 + assert_eq!(suggest_gate_name("swp"), Some("swap")); // edit distance 1 +} + +#[test] +fn test_suggest_gate_name_no_match() { + use crate::parser::suggest_gate_name; + assert_eq!(suggest_gate_name("foobar"), None); +} + +#[test] +fn test_edit_distance() { + use crate::parser::edit_distance; + assert_eq!(edit_distance("", ""), 0); + assert_eq!(edit_distance("abc", "abc"), 0); + assert_eq!(edit_distance("abc", "abd"), 1); + assert_eq!(edit_distance("abc", "ab"), 1); + assert_eq!(edit_distance("abc", "abcd"), 1); + assert_eq!(edit_distance("kitten", "sitting"), 3); +} + +// ============================================================================= +// Custom gate declarations +// ============================================================================= + +#[test] +fn test_parse_declare_gate() { + assert_parses!( + "declare gate my_gate(theta)(q);" + ); +} + +#[test] +fn test_parse_declare_gate_no_params() { + assert_parses!( + "declare gate my_x()(q);" + ); +} + +#[test] +fn test_parse_declare_gate_multiple_qubits() { + assert_parses!( + "declare gate cnot()(control, target);" + ); +} + +#[test] +fn test_parse_declare_gate_multiple_params() { + assert_parses!( + "declare gate u3(theta, phi, lambda)(q);" + ); +} + +#[test] +fn test_parse_declare_gate_pub() { + assert_parses!( + "pub declare gate rz(angle)(q);" + ); +} + +#[test] +fn test_parse_composite_gate() { + assert_parses!( + r#" + gate my_h()(q) { + h q; + } + "# + ); +} + +#[test] +fn test_parse_composite_gate_with_params() { + assert_parses!( + r#" + gate rx(theta)(q) { + h q; + } + "# + ); +} + +#[test] +fn test_parse_composite_gate_multi_qubit() { + assert_parses!( + r#" + gate bell()(q0, q1) { + h q0; + cx q0, q1; + } + "# + ); +} + +#[test] +fn test_parse_pub_composite_gate() { + assert_parses!( + r#" + pub gate swap()(a, b) { + cx a, b; + cx b, a; + cx a, b; + } + "# + ); +} + +#[test] +fn test_parse_declare_gate_and_fn_together() { + assert_parses!( + r#" + declare gate custom_rx(theta)(q); + + pub fn apply_custom(q: qubit) -> unit { + h q; + return; + } + "# + ); +} + +#[test] +fn test_parse_composite_gate_with_typed_param() { + assert_parses!( + "gate rz(theta: a64)(q) { h q; }" + ); +} diff --git a/exp/zlup/src/zluppy.pest b/exp/zlup/src/zluppy.pest new file mode 100644 index 000000000..0d2aa186b --- /dev/null +++ b/exp/zlup/src/zluppy.pest @@ -0,0 +1,990 @@ +// Zluppy Grammar +// A Zig-inspired quantum programming language for SLR +// +// Design principles (NASA Power of 10 influenced): +// 1. Simple, predictable control flow +// 2. Fixed bounds on loops where possible +// 3. Allocator-based resource management (no dynamic alloc after init) +// 4. Explicit over implicit +// 5. Comptime for metaprogramming + +// ============================================================================= +// Whitespace and Comments +// ============================================================================= + +// Whitespace includes newlines for simpler grammar +WHITESPACE = _{ " " | "\t" | "\r" | "\n" } +line_comment = _{ "//" ~ (!"\n" ~ ANY)* } +block_comment = _{ "/*" ~ (!"*/" ~ ANY)* ~ "*/" } +COMMENT = _{ line_comment | block_comment } + +// Explicit whitespace rule for places needing explicit control +ws = _{ (WHITESPACE | COMMENT)* } + +// Keyword operators with word boundary checks for binary expressions +and_kw = @{ "and" ~ !(ASCII_ALPHANUMERIC | "_") } +or_kw = @{ "or" ~ !(ASCII_ALPHANUMERIC | "_") } +orelse_kw = @{ "orelse" ~ !(ASCII_ALPHANUMERIC | "_") } +catch_kw = @{ "catch" ~ !(ASCII_ALPHANUMERIC | "_") } + +// Keywords that need to be captured +pub_keyword = { "pub" } +inline_keyword = { "inline" } +packed_keyword = { "packed" } + +// ============================================================================= +// Attributes (Metadata) +// ============================================================================= + +// Attributes provide metadata for ticks, gates, and other constructs +// @attr(key, value) - single attribute +// @attrs({key: value, ...}) - multiple attributes +// +// Examples: +// @attr(round, 0) +// @attr(kind, "syndrome") +// @attrs({round: 0, kind: "syndrome", error_rate: 0.001}) + +// Single attribute: @attr(key, value) +attribute = { "@attr" ~ ws ~ "(" ~ ws ~ identifier ~ ws ~ "," ~ ws ~ attr_value ~ ws ~ ")" } + +// Multiple attributes: @attrs({key: value, ...}) +attrs_block = { "@attrs" ~ ws ~ "(" ~ ws ~ "{" ~ ws ~ attrs_entries? ~ ws ~ "}" ~ ws ~ ")" } +attrs_entries = { attrs_entry ~ (ws ~ "," ~ ws ~ attrs_entry)* ~ ","? } +attrs_entry = { identifier ~ ws ~ ":" ~ ws ~ attr_value } + +attr_value = { string_literal | number_literal | bool_literal | identifier } +attribute_list = { (attribute | attrs_block) ~ (ws ~ (attribute | attrs_block))* } + +// ============================================================================= +// Program Structure +// ============================================================================= + +program = { SOI ~ ws ~ top_level_decl* ~ ws ~ EOI } + +top_level_decl = { + // error_set_decl and fault_set_decl must come before binding_decl + // because "Name := error { ... }" and "Name := fault { ... }" would + // otherwise match binding_decl (with struct init expression) + error_set_decl | + fault_set_decl | + declare_gate_decl | + gate_decl | + binding_decl | + extern_fn_decl | + fn_decl | + struct_decl | + enum_decl | + union_decl | + test_decl +} + +// ============================================================================= +// Declarations +// ============================================================================= + +// Binding declaration (Pascal/Go style with Rust mutability) +// Binding declaration syntax: +// x := value; -- immutable, type inferred +// x: T = value; -- immutable, type explicit +// mut x := value; -- mutable, type inferred +// mut x: T = value; -- mutable, type explicit +binding_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + (mut_keyword ~ ws)? ~ identifier ~ ws ~ ":" ~ ws ~ (type_expr ~ ws)? ~ "=" ~ ws ~ + (expr | undefined_literal) ~ ws ~ + ";" +} + +mut_keyword = { "mut" } + +// Function declaration +// try/try! modifiers indicate error handling mode for the function body +fn_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + inline_keyword? ~ ws ~ + "fn" ~ ws ~ member_name ~ ws ~ + "(" ~ ws ~ param_list? ~ ws ~ ")" ~ ws ~ + fn_error_mode? ~ ws ~ + return_type? ~ ws ~ + block +} + +// Function error mode: try (collect all) or try! (stop on first) +// fn foo() try -> []QuantumError!void { } +// fn foo() try! -> QuantumError!void { } +fn_error_mode = { try_bang_keyword | try_keyword } +try_keyword = { "try" ~ !("!") } +try_bang_keyword = { "try!" } + +// Comptime function (returns a type) +// pub fn make_code(comptime distance: u32) -> type { ... } +param_list = { param ~ (ws ~ "," ~ ws ~ param)* } +// Regular param or Rust-style self receiver +param = { self_param | regular_param } +// Rust-style self receivers: &self, &mut self, self +self_param = { "&" ~ ws ~ "mut"? ~ ws ~ "self" ~ !(ASCII_ALPHANUMERIC | "_") } +regular_param = { comptime_modifier? ~ ws ~ identifier ~ ws ~ ":" ~ ws ~ type_expr } +// Comptime modifier for parameters +comptime_modifier = { "comptime" } + +return_type = { "->" ~ ws ~ (type_expr | "!" ~ type_expr | "?" ~ type_expr) } + +// External function declaration (FFI) +// @link("libdecoder") extern "C" fn decode(data: [*]u8, len: usize) -> i32; +extern_fn_decl = { + doc_comment* ~ + link_attr? ~ ws ~ + pub_keyword? ~ ws ~ + "extern" ~ ws ~ string_literal ~ ws ~ + "fn" ~ ws ~ identifier ~ ws ~ + "(" ~ ws ~ param_list? ~ ws ~ ")" ~ ws ~ + return_type? ~ ws ~ + ";" +} + +// Library linkage attribute for extern functions +link_attr = { "@link" ~ ws ~ "(" ~ ws ~ string_literal ~ ws ~ ")" } + +// Struct declaration +struct_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + (packed_keyword ~ ws)? ~ "struct" ~ ws ~ "{" ~ ws ~ + struct_body ~ ws ~ + "}" ~ ws ~ ";" +} + +struct_body = { (ws ~ (binding_decl | fn_decl | struct_field))* ~ ws } +struct_field = { + doc_comment* ~ + member_name ~ ws ~ ":" ~ ws ~ type_expr ~ ws ~ + ("=" ~ ws ~ expr ~ ws)? ~ ","? +} + +// Enum declaration +enum_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + "enum" ~ ws ~ ("(" ~ ws ~ type_expr ~ ws ~ ")")? ~ ws ~ "{" ~ ws ~ + enum_body ~ ws ~ + "}" ~ ws ~ ";" +} + +enum_body = { enum_variant ~ (ws ~ "," ~ ws ~ enum_variant)* ~ ws ~ ","? } +enum_variant = { identifier ~ (ws ~ "=" ~ ws ~ expr)? } + +// Union declaration (tagged union / sum type) +// Value := union(enum) { Int: i32, Float: f64, None } -- auto-tagged +// Value := union(MyEnum) { Int: i32, Float: f64 } -- externally tagged +// Value := union { Int: i32, Float: f64 } -- untagged +union_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + "union" ~ ws ~ union_tag? ~ ws ~ "{" ~ ws ~ + union_body ~ ws ~ + "}" ~ ws ~ ";" +} + +union_tag = { "(" ~ ws ~ ("enum" | type_expr) ~ ws ~ ")" } +union_body = { union_field ~ (ws ~ "," ~ ws ~ union_field)* ~ ws ~ ","? } +union_field = { identifier ~ (ws ~ ":" ~ ws ~ type_expr)? } + +// Error set declaration - classical/logical errors that crash if unhandled +// DecodeError := error { SyndromeAmbiguous, WeightTooHigh }; +error_set_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + "error" ~ ws ~ "{" ~ ws ~ + error_set_body ~ ws ~ + "}" ~ ws ~ ";" +} + +// Fault set declaration - quantum/physical faults, collected in try blocks +// QuantumFault := fault { Leakage, QubitLoss, GateFailure }; +fault_set_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + identifier ~ ws ~ ":=" ~ ws ~ + "fault" ~ ws ~ "{" ~ ws ~ + error_set_body ~ ws ~ + "}" ~ ws ~ ";" +} + +error_set_body = { error_variant ~ (ws ~ "," ~ ws ~ error_variant)* ~ ws ~ ","? } + +// Error/fault variant can optionally have associated data +// Leakage: struct { gate: []const u8, qubit: usize } +error_variant = { identifier ~ (ws ~ ":" ~ ws ~ type_expr)? } + +// Test declaration (NASA Power of 10: assertions) +test_decl = { + "test" ~ ws ~ string_literal ~ ws ~ block +} + +// Custom gate declarations +// declare gate name(params)(qubits); -- target gate (provided by backend) +// gate name(params)(qubits) { body } -- composite gate (defined inline) +declare_gate_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + "declare" ~ ws ~ "gate" ~ ws ~ identifier ~ ws ~ + "(" ~ ws ~ gate_param_list? ~ ws ~ ")" ~ ws ~ + "(" ~ ws ~ qubit_param_list? ~ ws ~ ")" ~ ws ~ + ";" +} + +gate_decl = { + doc_comment* ~ + pub_keyword? ~ ws ~ + "gate" ~ ws ~ identifier ~ ws ~ + "(" ~ ws ~ gate_param_list? ~ ws ~ ")" ~ ws ~ + "(" ~ ws ~ qubit_param_list? ~ ws ~ ")" ~ ws ~ + block +} + +gate_param_list = { gate_param ~ (ws ~ "," ~ ws ~ gate_param)* } +gate_param = { identifier ~ (ws ~ ":" ~ ws ~ type_expr)? } + +qubit_param_list = { qubit_param ~ (ws ~ "," ~ ws ~ qubit_param)* } +qubit_param = { identifier } + +// ============================================================================= +// Statements +// ============================================================================= + +statement = { + binding_decl | + alias_stmt | + assign_stmt | + if_stmt | + for_stmt | + switch_stmt | + tick_stmt | + try_block_stmt | + return_stmt | + break_stmt | + continue_stmt | + defer_stmt | + errdefer_stmt | + block | + expr_stmt +} + +// Alias statement - creates a named view into existing data +// alias name := slice_expr; +// Aliases are immutable views with overlap checking +alias_stmt = { + "alias" ~ ws ~ identifier ~ ws ~ ":" ~ ws ~ "=" ~ ws ~ expr ~ ws ~ ";" +} + +// Try blocks for error handling +// try { ... } - collect all errors (QEC pattern), returns []E!T +// try! { ... } - stop on first error (traditional), returns E!T +// Both can have optional catch clause +try_block_stmt = { try_collect_block | try_bang_block } + +// try { } - collect all errors, continue executing +try_collect_block = { + "try" ~ ws ~ block ~ ws ~ catch_clause? +} + +// try! { } - stop on first error, propagate immediately +try_bang_block = { + "try!" ~ ws ~ block ~ ws ~ catch_clause? +} + +// catch |err| { } or catch |err| expr +catch_clause = { + "catch" ~ ws ~ "|" ~ ws ~ identifier ~ ws ~ "|" ~ ws ~ (block | expr) +} + +// Tick block - a time slice of parallel quantum gates +// tick { ... } - anonymous tick +// tick name { ... } - named tick +// tick "name" { ... } - named tick with string literal +// @attr(round, 0) tick { ... } - tick with prefix attribute +// @attrs({round: 0, kind: "syndrome"}) tick { ... } - tick with multiple attributes +tick_stmt = { + attribute_list? ~ ws ~ + "tick" ~ ws ~ attribute_list? ~ ws ~ tick_label? ~ ws ~ tick_body +} +tick_label = { string_literal | identifier } +tick_body = { "{" ~ ws ~ (statement ~ ws)* ~ "}" } + +// Assignment: x = value; or x.field = value; or x[i] = value; +assign_stmt = { lvalue ~ ws ~ assign_op ~ ws ~ expr ~ ws ~ ";" } +assign_op = { "=" | "+=" | "-=" | "*=" | "/=" | "&=" | "|=" | "^=" } +lvalue = { primary_expr ~ (field_access | index_access)* } + +// If statement (Rust/Python-style, with Go-style unwrapping) +// Regular: if condition { ... } else { ... } +// Optional unwrapping: if value := opt { ... } else { ... } +if_stmt = { + "if" ~ ws ~ (if_unwrap_clause | if_condition) ~ ws ~ + (block | statement) ~ + (ws ~ "else" ~ ws ~ (if_stmt | block | statement))? +} + +// if value := optional { ... } (Go-style unwrapping) +if_unwrap_clause = { + identifier ~ ws ~ ":=" ~ ws ~ expr +} + +// Regular condition (no parentheses required) +if_condition = { expr } + +// For loop (bounded iteration - NASA Power of 10 compliant) +// Rust/Python-style syntax: +// for i in 0..n { ... } +// for item in items { ... } +// for i, item in items { ... } +for_stmt = { + label? ~ + (inline_keyword ~ ws)? ~ + "for" ~ ws ~ capture_list ~ ws ~ "in" ~ ws ~ for_range ~ ws ~ + block +} + +for_range = { range_lit | expr } +range_lit = { range_bound ~ ".." ~ range_bound } +// Range bound allows expressions including field access and arithmetic +range_bound = { range_bound_term ~ (ws ~ range_bound_op ~ ws ~ range_bound_term)* } +range_bound_term = { + number_literal | + range_bound_field_access | + identifier | + "(" ~ ws ~ range_bound ~ ws ~ ")" +} +range_bound_field_access = { identifier ~ ("." ~ member_name)+ } +range_bound_op = { "+" | "-" | "*" | "/" } +capture_list = { identifier ~ (ws ~ "," ~ ws ~ identifier)* } + +// Switch statement +switch_stmt = { + "switch" ~ ws ~ "(" ~ ws ~ expr ~ ws ~ ")" ~ ws ~ "{" ~ ws ~ + (switch_prong ~ ws)* ~ + "}" +} + +switch_prong = { + (switch_case ~ (ws ~ "," ~ ws ~ switch_case)* | "else") ~ ws ~ + "=>" ~ ws ~ (block | expr) ~ ws ~ ","? +} +switch_case = { expr ~ (ws ~ ".." ~ ws ~ expr)? } + +// Control flow +return_stmt = { "return" ~ (ws ~ expr)? ~ ws ~ ";" } +break_stmt = { "break" ~ (ws ~ ":" ~ identifier)? ~ (ws ~ expr)? ~ ws ~ ";" } +continue_stmt = { "continue" ~ (ws ~ ":" ~ identifier)? ~ ws ~ ";" } +defer_stmt = { "defer" ~ ws ~ (block | statement) } +errdefer_stmt = { "errdefer" ~ ws ~ ("|" ~ ws ~ identifier ~ ws ~ "|" ~ ws)? ~ (block | statement) } + +// Block (can be labeled for break) +// Supports optional trailing expression for return value (like Rust) +// Block with optional attributes and label +// @attr(kind, "syndrome") { ... } +// @attrs({round: 1}) my_block: { ... } +block = { attribute_list? ~ ws ~ label? ~ "{" ~ ws ~ (statement ~ ws)* ~ (trailing_expr ~ ws)? ~ "}" } +trailing_expr = { expr } +label = { identifier ~ ws ~ ":" ~ ws } + +// Expression statement (can have prefix attributes for gates) +// @attr(syndrome, "X") cx({(q[0], q[1])}); +expr_stmt = { attribute_list? ~ ws ~ expr ~ ws ~ ";" } + +// ============================================================================= +// Quantum Statements (integrated into expression/statement system) +// ============================================================================= + +// Quantum operations use DSL-like syntax: +// h q[0]; - single-qubit gate +// cx (q[0], q[1]); - two-qubit gate with tuple +// rx(0.123) q[0]; - parameterized gate +// h {q[0], q[1], q[2]}; - batch apply (set semantics) +// cx {(q[0], q[1]), (q[2], q[3])}; - batch two-qubit gates +// r := mz(u1) q[0]; - measurement with type +// pz q; - prepare all qubits +// pz {q[0], q[1]}; - prepare specific qubits + +// ============================================================================= +// Expressions +// ============================================================================= + +expr = { comptime_expr | runtime_expr } + +comptime_expr = { "comptime" ~ ws ~ (block | runtime_expr) } + +runtime_expr = { or_expr } + +// Binary operators with precedence (lowest to highest) +// orelse/catch have lowest precedence (for optional/error unwrapping) +// +// NOTE: Keyword operators (and_kw, or_kw, orelse_kw, catch_kw) are defined above +// with word boundary checks to prevent matching parts of identifiers. +or_expr = { catch_expr ~ (or_kw ~ catch_expr)* } +catch_expr = { orelse_expr ~ (catch_kw ~ ("|" ~ ws ~ identifier ~ ws ~ "|" ~ ws)? ~ orelse_expr)* } +orelse_expr = { and_expr ~ (orelse_kw ~ and_expr)* } +and_expr = { cmp_expr ~ (and_kw ~ cmp_expr)* } +cmp_expr = { bitwise_or_expr ~ (ws ~ cmp_op ~ ws ~ bitwise_or_expr)? } +cmp_op = { "==" | "!=" | "<=" | ">=" | "<" | ">" | not_in_op | in_op } +in_op = { "in" } +not_in_op = { "not" ~ ws ~ "in" } + +bitwise_or_expr = { bitwise_xor_expr ~ (ws ~ bitor_op ~ ws ~ bitwise_xor_expr)* } +bitor_op = { "|" } +bitwise_xor_expr = { bitwise_and_expr ~ (ws ~ bitxor_op ~ ws ~ bitwise_and_expr)* } +bitxor_op = { "^" } +bitwise_and_expr = { shift_expr ~ (ws ~ bitand_op ~ ws ~ shift_expr)* } +bitand_op = { "&" } +shift_expr = { add_expr ~ (ws ~ shift_op ~ ws ~ add_expr)* } +shift_op = { "<<" | ">>" } +add_expr = { suffixed_expr ~ (ws ~ add_op ~ ws ~ suffixed_expr)* } +add_op = { "+" | "-" } +mul_expr = { unary_expr ~ (ws ~ mul_op ~ ws ~ unary_expr)* } +mul_op = { "*" | "/" | "%" } + +// Suffixed expression: expression with optional type/unit suffix +// Allows space between expression and suffix for readability: +// 0.25 turns, 1/4 turns - angle units +// 42 u32, 1/4 f64 - type suffixes +// (a + b) i64 - typed expression result +suffixed_expr = { mul_expr ~ (ws ~ expr_suffix)? } + +// Expression suffix: angle units or type names +// Angle units: turns (native), rad (radians) +// Type ascription: numeric types for explicit typing +expr_suffix = { angle_unit | type_ascription_suffix } +angle_unit = { ("turns" | "rad") ~ !(ASCII_ALPHANUMERIC | "_") } +type_ascription_suffix = { (float_type_kw | int_type_kw) ~ !(ASCII_ALPHANUMERIC | "_") } +float_type_kw = @{ "f128" | "f64" | "f32" | "f16" | "a64" } +int_type_kw = @{ + "u128" | "u64" | "u32" | "u16" | "u8" | "usize" | + "i128" | "i64" | "i32" | "i16" | "i8" | "isize" +} + +// Unary operators +// "try" is for error propagation: try foo() returns payload or propagates error +// Note: "try" unary must not be followed by "{" (try block) or "!" (try!) +unary_expr = { (unary_op ~ ws)* ~ postfix_expr } +unary_op = { try_unary | "-" | "!" | "~" | "&" | "*" } +try_unary = { "try" ~ !("{" | "!") } + +// Postfix expressions +postfix_expr = { primary_expr ~ postfix_op* } +postfix_op = { call | batch_apply | field_access | index_access | optional_unwrap | error_unwrap } + +call = { "(" ~ ws ~ arg_list? ~ ws ~ ")" } +arg_list = { expr ~ (ws ~ "," ~ ws ~ expr)* } + +// Batch apply: h { q[0], q[1] } or cx { (q[0], q[1]), (q[2], q[3]) } +// For gates where order doesn't matter (set semantics) +batch_apply = { ws ~ "{" ~ ws ~ batch_elements? ~ ws ~ "}" } +batch_elements = { expr ~ (ws ~ "," ~ ws ~ expr)* ~ ","? } + +field_access = { "." ~ ws ~ member_name } +index_access = { "[" ~ ws ~ (range_expr | expr) ~ ws ~ "]" } +optional_unwrap = { ".?" } +error_unwrap = { "." ~ "!" } + +range_expr = { expr? ~ ".." ~ expr? } + +// Primary expressions +// Split into recursive expressions (that contain `expr`) and atomic expressions. +// Atomic expressions use compound-atomic ($) to fix parsing issues with keyword +// operators (and, or, etc.) when boolean literals appear on the left side. +primary_expr = { + paren_or_tuple | // (a) or (a, b) - combined to avoid backtracking + if_expr | + try_block_expr | // try { } or try! { } as expression + block_expr | + struct_literal | + enum_literal | + struct_init | + array_init | + array_type_expr | // [N]T as expression (type value) + bracket_array | // [a, b, c] - array literal + set_literal | // set { a, b, c } - set literal + measure_expr | // mz(T) [targets] - measurement with ordered results + channel_expr | // >channel.command(...) - side-channel communication + result_expr | // result("tag", value) - emit results to caller (special, never elided) + builtin_call | + fn_literal | + f_string | // f"Hello {name}" - must come before string_literal + gate_expr | // h q[0], rx(0.123) q[0] - quantum gates (late to avoid x orelse confusion) + atom // Compound-atomic leaf expressions +} + +// Atomic (leaf) expressions that don't recursively contain `expr`. +// Using compound-atomic ($) to prevent implicit WHITESPACE from interfering +// with keyword operator matching (e.g., `true and y`). +atom = ${ + number_literal | + raw_string | // r"path\to\file" - raw string, no escapes + multiline_string | // """...""" - must come before string_literal + string_literal | + char_literal | + bool_literal | + none_literal | + undefined_literal | + unit_literal | + self_expr | + error_value | // error.Name - error value literal + fault_value | // fault.Name - fault value literal + identifier +} + +// Try block as expression (for assignments) +// errors := try { ... }; +// result := try! { ... } catch |err| { default }; +try_block_expr = { try_collect_expr | try_bang_expr } + +try_collect_expr = { + "try" ~ ws ~ block ~ ws ~ catch_clause? +} + +try_bang_expr = { + "try!" ~ ws ~ block ~ ws ~ catch_clause? +} + +// Measurement expression: mz(T) targets or mz(pack T) targets +// +// Per-qubit mode (no pack): N qubits → [N]T, count must match exactly +// mz(u1) q[0] - single qubit → u1 +// mz([4]u1) [q[0], q[1], q[2], q[3]] - 4 qubits → [4]u1 +// +// Pack mode: bits fill T sequentially, must have enough capacity +// mz(pack u8) [q[0], ..., q[7]] - 8 qubits → u8 +// mz(pack Syndrome) ancillas - N qubits → Syndrome struct +// +measure_expr = { "mz" ~ ws ~ "(" ~ ws ~ pack_modifier? ~ type_expr ~ ws ~ ")" ~ ws ~ measure_target } +pack_modifier = { "pack" ~ ws } +measure_target = { bracket_array | postfix_expr } + +// ============================================================================= +// Channel Expressions: >channel.command(args) +// ============================================================================= +// +// Unified syntax for all side-channel communication (sticky/barrier semantics). +// All channel expressions use the form: >channel.command(args) +// +// Built-in channels: +// @emit.log.trace(f"message") - logging at trace level +// @emit.log.debug("ns", f"message") - logging with sub-namespace +// @emit.log.info(f"msg", data: obj) - logging with structured data +// @emit.log.at(50, f"custom level") - logging with custom numeric level +// @emit.sim.send("key", value) - simulator message +// @emit.sim.noise_enable() - enable noise (default) +// @emit.sim.noise_disable() - disable noise +// @emit.hw.send("key", value) - hardware message +// +// Custom channels: +// @emit.timing.send("checkpoint", t) - timing instrumentation +// @emit.debug.send("state", snapshot) - debug snapshots +// +// Channel behavior is controlled by --target and configuration: +// - log: elided in release mode +// - sim: barrier for hardware, active for simulator +// - hw: active for hardware, elided for simulator +// - custom: configurable +// +channel_expr = { + emit_prefix ~ ws ~ channel_name ~ ws ~ "." ~ ws ~ channel_command +} + +// @emit prefix for side-channel communication +emit_prefix = { "@emit" ~ ws ~ "." } + +// Channel name (log, sim, hw, timing, etc.) +channel_name = @{ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* } + +channel_command = { + channel_command_name ~ ws ~ "(" ~ ws ~ channel_args? ~ ws ~ ")" +} + +// Use member_name to allow keywords like 'error' as channel commands +channel_command_name = @{ member_name } + +channel_args = { + channel_arg ~ (ws ~ "," ~ ws ~ channel_arg)* +} + +// Named or positional argument +channel_arg = { + (identifier ~ ws ~ ":" ~ ws ~ expr) | + expr +} + +// ============================================================================= +// Result Expression (Program Output Channel) +// ============================================================================= +// +// result(tag, value) emits a tagged value as program output. +// This is the primary way to return data from quantum programs to the caller. +// Unlike logs, results are NEVER elided - they're essential program outputs. +// +// Syntax: +// result("measurement", m) - simple result +// result("qec/syndrome", syndrome) - namespaced with / convention +// result("round_1/parity", parity) - hierarchical naming +// +// The tag must be a compile-time string literal (like Guppy). +// Value can be any serializable type: int, bool, float, arrays. +// +result_expr = { + "result" ~ ws ~ "(" ~ ws ~ string_literal ~ ws ~ "," ~ ws ~ expr ~ ws ~ ")" +} + + +// Gate expression: gate target or gate(params) target +// Consistent with measurement syntax - params in parens, target follows +// +// Non-parameterized single: h q[0] +// Non-parameterized batch: h {q[0], q[1]} +// Parameterized single: rx(0.123) q[0] +// Parameterized batch: rx(0.123) {q[0], q[1]} +// Two-qubit single: cx (q[0], q[1]) +// Two-qubit batch: cx {(q[0], q[1]), (q[2], q[3])} +// +// Note: Old syntax like h(q[0]) or rz(1.57, q[0]) is NOT supported +// Parameterized gates MUST have params, non-parameterized gates MUST NOT +gate_expr = { param_gate_expr | simple_gate_expr } +param_gate_expr = { param_gate_keyword ~ ws ~ gate_params ~ ws ~ gate_target } +simple_gate_expr = { simple_gate_keyword ~ ws ~ gate_target } +gate_params = { "(" ~ ws ~ arg_list ~ ws ~ ")" } +// Gate target must be a qubit expression, not a bare keyword or paren expr +// This prevents "x orelse" from parsing as gate(x, target=orelse) +// and prevents "h(q[0])" from parsing as gate h with target (q[0]) +gate_target = { gate_set_target | paren_or_tuple | bracket_array | gate_qubit_target } +// gate_qubit_target must NOT start with "(" - that would be call syntax or tuple +// Use negative lookahead to reject "(expr)" patterns +gate_qubit_target = { !operator_keyword ~ !paren_or_tuple ~ postfix_expr } +operator_keyword = { "and" | "or" | "orelse" | "catch" } +gate_set_target = { "{" ~ ws ~ batch_elements? ~ ws ~ "}" } + +// Gate keywords - all quantum gates and prepare operations (lowercase only) +// Order matters: longer matches first to avoid prefix issues +// Must not be followed by alphanumeric or underscore (to avoid matching 'true' as 't' + 'rue') + +// Parameterized gates - REQUIRE angle parameters: rx(angle), ry(angle), etc. +param_gate_keyword = @{ + ( + // Two-qubit parameterized gates + "rzz" | "crz" | + // Single-qubit rotation gates + "rx" | "ry" | "rz" + ) ~ !(ASCII_ALPHANUMERIC | "_") +} + +// Non-parameterized gates - take only qubit targets, NO angle parameters +simple_gate_keyword = @{ + ( + // Daggers and compound gates (longer names first) + "sxxdg" | "syydg" | "szzdg" | + "sxdg" | "sydg" | "szdg" | + "f4dg" | "fdg" | + "iswap" | "swap" | + "tdg" | + // Two-qubit gates (non-parameterized) + "sxx" | "syy" | "szz" | + "ccx" | + "cx" | "cy" | "cz" | "ch" | + // Single-qubit compound gates + "sx" | "sy" | "sz" | + "f4" | + // Simple single-qubit gates (shortest last) + "x" | "y" | "z" | + "h" | "t" | "f" | + // Prepare operation (reset to |0⟩ state) + "pz" + ) ~ !(ASCII_ALPHANUMERIC | "_") +} + +// Combined for convenience (used in semantic analysis) +gate_keyword = @{ param_gate_keyword | simple_gate_keyword } + +// Error value literal: error.OutOfMemory, error.InvalidArgument, etc. +error_value = { "error" ~ "." ~ identifier } + +// Fault value literal: fault.Leakage, fault.QubitLoss, etc. +fault_value = { "fault" ~ "." ~ identifier } + +// Array type as expression (types are first-class values like in Zig) +array_type_expr = { "[" ~ ws ~ (array_size | "_")? ~ ws ~ (":" ~ ws ~ expr ~ ws)? ~ "]" ~ ws ~ type_identifier } + +// Combined parenthesized/tuple expression +// Parses (expr) as paren_expr, (expr,) or (expr, expr, ...) as tuple +// This avoids exponential backtracking on deeply nested parens by committing after "(" +paren_or_tuple = { "(" ~ ws ~ expr ~ (ws ~ "," ~ ws ~ expr)* ~ (ws ~ ",")? ~ ws ~ ")" } + +// Bracket array literal: [a, b, c] - for quantum batch operations +bracket_array = { "[" ~ ws ~ bracket_array_elements? ~ ws ~ "]" } +bracket_array_elements = { expr ~ (ws ~ "," ~ ws ~ expr)* ~ ","? } + +// Set literal: set { a, b, c } - unique unordered elements +// Consistent with struct { }, enum { } pattern +set_literal = { "set" ~ ws ~ "{" ~ ws ~ set_elements? ~ ws ~ "}" } +set_elements = { expr ~ (ws ~ "," ~ ws ~ expr)* ~ ","? } + +// Set type: Set(T) +set_type = { "Set" ~ ws ~ "(" ~ ws ~ type_expr ~ ws ~ ")" } + +// Struct as expression (anonymous struct type) +struct_literal = { + (packed_keyword ~ ws)? ~ "struct" ~ ws ~ "{" ~ ws ~ struct_body ~ ws ~ "}" +} + +// Enum as expression (anonymous enum type) +enum_literal = { + "enum" ~ ws ~ ("(" ~ ws ~ type_expr ~ ws ~ ")")? ~ ws ~ "{" ~ ws ~ enum_body ~ ws ~ "}" +} + +// If as expression (returns value) +// Parentheses required for condition to disambiguate from then-block +// if (condition) { then } else { else } +if_expr = { + "if" ~ ws ~ "(" ~ ws ~ expr ~ ws ~ ")" ~ ws ~ block ~ ws ~ "else" ~ ws ~ (if_expr | block) +} + +// Labeled block expression +// Supports optional trailing expression for return value (like Rust) +// Labeled block expression with optional attributes +// @attr(kind, "init") blk: { ... } +block_expr = { attribute_list? ~ ws ~ label ~ "{" ~ ws ~ (statement ~ ws)* ~ (trailing_expr ~ ws)? ~ "}" } + +// Struct initialization: .{ field: value, ... } or Type { field: value } +// Uses Rust-style syntax: `field: value` instead of Zig's `.field = value` +struct_init = { typed_struct_init | anon_struct_init } +typed_struct_init = { type_identifier ~ ws ~ "{" ~ ws ~ field_init_list? ~ ws ~ "}" } +anon_struct_init = { ".{" ~ ws ~ field_init_list? ~ ws ~ "}" } +field_init_list = { field_init ~ (ws ~ "," ~ ws ~ field_init)* ~ ","? } +// Rust-style field init: `name: value` or shorthand `name` (when var name matches field) +field_init = { identifier ~ ws ~ (":" ~ ws ~ expr)? } + +// Array/slice initialization +array_init = { typed_array_init | anon_array_init } +typed_array_init = { array_type ~ ws ~ "{" ~ ws ~ array_init_list? ~ ws ~ "}" } +anon_array_init = { ".{" ~ ws ~ array_init_list? ~ ws ~ "}" } +array_init_list = { expr ~ (ws ~ "," ~ ws ~ expr)* ~ ","? } + +// Builtin calls: @import, @This, @intCast, etc. +builtin_call = { "@" ~ identifier ~ "(" ~ ws ~ arg_list? ~ ws ~ ")" } + +// Anonymous function +fn_literal = { + "fn" ~ ws ~ "(" ~ ws ~ param_list? ~ ws ~ ")" ~ ws ~ + return_type? ~ ws ~ block +} + +// Self reference (Rust-style, replaces Zig's @This()) +self_expr = { "Self" ~ !(ASCII_ALPHANUMERIC | "_") } + +// ============================================================================= +// Types +// ============================================================================= + +// Type expression - avoid left recursion by using suffixes +type_expr = { type_prefix ~ type_suffix? } + +// Type prefixes (things that come before the base type) +type_prefix = { + optional_type | + pointer_type | + array_type | + tuple_type | + fn_type | + set_type | + struct_literal | + enum_literal | + builtin_type | + type_identifier +} + +// Tuple type: (T1, T2) or (T1, T2, T3, ...) +tuple_type = { "(" ~ ws ~ type_prefix ~ ws ~ "," ~ ws ~ type_prefix ~ (ws ~ "," ~ ws ~ type_prefix)* ~ ws ~ ","? ~ ws ~ ")" } + +// Type suffix for error unions: T!E (payload!error) +exclamation = _{ "!" } +type_suffix = { exclamation ~ type_prefix } + +// Optional type: ?T +optional_type = { "?" ~ ws ~ type_prefix } + +// Pointer types: *T, [*]T (many-pointer), [*:sentinel]T (sentinel-terminated many-pointer) +pointer_type = { pointer_prefix ~ ws ~ ("const" ~ ws)? ~ type_prefix } +pointer_prefix = { "[*:" ~ ws ~ expr ~ ws ~ "]" | "[*]" | "*" } + +// Array types: [N]T, []T (slice), [N:sentinel]T +array_type = { + "[" ~ ws ~ (array_size | "_")? ~ ws ~ (":" ~ ws ~ expr ~ ws)? ~ "]" ~ ws ~ type_prefix +} + +// Array size - allows comptime expressions like N + 1, N * 2, etc. +array_size = { array_size_term ~ (ws ~ array_size_op ~ ws ~ array_size_term)* } +array_size_term = { number_literal | identifier | "(" ~ ws ~ array_size ~ ws ~ ")" } +array_size_op = { "+" | "-" | "*" | "/" } + +// Function type: fn(args) -> return_type +fn_type = { "fn" ~ "(" ~ ws ~ type_list? ~ ws ~ ")" ~ ws ~ ("->" ~ ws ~ type_prefix)? } +type_list = { type_prefix ~ (ws ~ "," ~ ws ~ type_prefix)* } + +// Built-in types +builtin_type = { + self_type | int_type | float_type | angle_type | "bool" | "unit" | "type" | "anytype" | + "qubit" | "bit" +} + +// Self type - refers to the enclosing struct type (Rust-style) +self_type = { "Self" ~ !(ASCII_ALPHANUMERIC | "_") } + +angle_type = @{ "a64" } + +// Integer types: arbitrary bit width like Zig (u1, u4, u7, u128, usize, etc.) +int_type = @{ + ("u" | "i") ~ ("size" | ASCII_DIGIT+) +} + +float_type = @{ "f" ~ ("16" | "32" | "64" | "128") } + +// Named type (struct, enum, etc.) +// Also accepts Self keyword as a type +type_identifier = { (identifier | self_type_keyword) ~ ("." ~ identifier)* } +self_type_keyword = { "Self" ~ !(ASCII_ALPHANUMERIC | "_") } + +// ============================================================================= +// Literals +// ============================================================================= + +// Number literals with optional type suffix +// Examples: 42, 42u32, 42_u8, 0xFF_u16, 1.5, 1.5f32 +number_literal = @{ + hex_literal | binary_literal | octal_literal | float_literal | int_literal +} + +hex_literal = @{ "0x" ~ (ASCII_HEX_DIGIT | "_")+ ~ int_suffix? } +binary_literal = @{ "0b" ~ ("0" | "1" | "_")+ ~ int_suffix? } +octal_literal = @{ "0o" ~ (ASCII_OCT_DIGIT | "_")+ ~ int_suffix? } +// Float: 1.5, 1.5e10, 1e10 (scientific notation without decimal) +float_literal = @{ int_literal_base ~ ("." ~ ASCII_DIGIT+ ~ exp_part? | exp_part) ~ float_suffix? } +exp_part = @{ ("e" | "E") ~ ("+" | "-")? ~ ASCII_DIGIT+ } +int_literal = @{ int_literal_base ~ int_suffix? } +int_literal_base = @{ ASCII_DIGIT ~ (ASCII_DIGIT | "_")* } + +// Type suffixes for literals +int_suffix = @{ "_"? ~ ( + "u128" | "u64" | "u32" | "u16" | "u8" | "u1" | "usize" | + "i128" | "i64" | "i32" | "i16" | "i8" | "i1" | "isize" +) } +float_suffix = @{ "_"? ~ ("f128" | "f64" | "f32" | "f16" | "a64") } + +string_literal = @{ "\"" ~ (escape_seq | (!("\"" | "\\") ~ ANY))* ~ "\"" } +char_literal = @{ "'" ~ (escape_seq | (!("'" | "\\") ~ ANY)) ~ "'" } +escape_seq = @{ "\\" ~ ("n" | "r" | "t" | "\\" | "\"" | "'" | "0" | "{" | "}" | "x" ~ ASCII_HEX_DIGIT{2}) } + +// F-string (Python-style string interpolation): f"Hello {name}, you have {count} items" +// Expressions inside {} are evaluated and converted to strings +// Use \{ and \} to escape literal braces +// Format specifiers: f"{x:.2f}", f"{name:>10}", f"{n:08d}" +f_string = { "f\"" ~ f_string_part* ~ "\"" } +f_string_part = { f_string_interp | f_string_text } +f_string_interp = { "{" ~ ws ~ expr ~ ws ~ f_string_format? ~ "}" } +f_string_format = { ":" ~ f_string_format_spec } +f_string_format_spec = @{ (!("}") ~ ANY)* } +f_string_text = @{ (escape_seq | (!("\"" | "\\" | "{" | "}") ~ ANY))+ } + +// Raw string (Python-style): r"path\to\file" - backslashes are literal, not escapes +// Useful for file paths, regex patterns, etc. +raw_string = @{ "r\"" ~ (!"\"" ~ ANY)* ~ "\"" } + +// Multi-line string (Python-style): """...""" - preserves newlines and whitespace +// Content is taken literally except for escape sequences +// Use for multi-line text, documentation, or embedded DSLs +multiline_string = @{ "\"\"\"" ~ multiline_string_content ~ "\"\"\"" } +multiline_string_content = @{ (escape_seq | (!"\"\"\"" ~ ANY))* } + +// Word boundary check to prevent matching prefix of identifier (e.g., "true_flag") +bool_literal = { ("true" | "false") ~ !(ASCII_ALPHANUMERIC | "_") } +// none instead of null (consistent with other lowercase keywords) +none_literal = { "none" ~ !(ASCII_ALPHANUMERIC | "_") } +undefined_literal = { "undefined" ~ !(ASCII_ALPHANUMERIC | "_") } +unit_literal = { "unit" ~ !(ASCII_ALPHANUMERIC | "_") } + +// ============================================================================= +// Identifiers and Keywords +// ============================================================================= + +identifier = @{ + !keyword ~ (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* +} + +// Member name - allows keywords as field/method names (like Rust's raw identifiers) +// Used in struct field declarations, method definitions, and field access +member_name = @{ + (ASCII_ALPHA | "_") ~ (ASCII_ALPHANUMERIC | "_")* +} + +// Reserved keywords (qalloc, Reg are not keywords - they're callable builtins) +// Keywords must be followed by non-alphanumeric to ensure word boundary +// Note: "try!" is handled specially in fn_error_mode, not as a keyword +keyword = @{ + ("fn" | "pub" | "inline" | "comptime" | "mut" | + "struct" | "enum" | "union" | "packed" | "set" | + "if" | "else" | "for" | "switch" | "tick" | + "return" | "break" | "continue" | "defer" | "errdefer" | + "and" | "or" | "orelse" | "try" | "catch" | + "true" | "false" | "none" | "undefined" | "Self" | + "test" | "error" | "type" | "anytype" | "unit" | + "qubit" | "bit" | "alias" | + "turns" | "rad") ~ !(ASCII_ALPHANUMERIC | "_") +} + +// ============================================================================= +// Documentation Comments +// ============================================================================= + +doc_comment = @{ "///" ~ (!NEWLINE ~ ANY)* ~ NEWLINE } + +// ============================================================================= +// Quantum-Specific Builtins (recognized at semantic level) +// ============================================================================= + +// These are parsed as regular function calls but have special semantics: +// +// Allocator operations: +// qalloc(capacity) - Create base allocator +// alloc.child(size) - Create child allocator +// alloc.release() - Explicit release (usually automatic) +// +// Prepare operations (use pz gate instead of method calls): +// pz alloc - Prepare all qubits in allocator +// pz {q[0], q[1]} - Prepare specific qubits +// +// Gates (single qubit): +// h(q[i]), x(q[i]), y(q[i]), z(q[i]) +// s(q[i]), sdg(q[i]), t(q[i]), tdg(q[i]) +// sx(q[i]), sy(q[i]), sz(q[i]) +// rx(q[i], angle), ry(q[i], angle), rz(q[i], angle) +// +// Gates (two qubit): +// cx(ctrl, target), cy(ctrl, target), cz(ctrl, target) +// ch(ctrl, target) +// sxx(q1, q2), syy(q1, q2), szz(q1, q2) +// rzz(q1, q2, angle) +// +// Measurement: +// measure(q[i]) -> bit +// measure(alloc) -> [N]bit +// +// Builtins: +// @import(path) +// @This() +// @compileError(msg) +// @compileLog(...) +// @typeInfo(T) +// @typeName(T) diff --git a/exp/zlup/std/a64.zlp b/exp/zlup/std/a64.zlp new file mode 100644 index 000000000..57d16952f --- /dev/null +++ b/exp/zlup/std/a64.zlp @@ -0,0 +1,76 @@ +/// Standard library: a64 angle constants +/// +/// All angles are in turns (the native unit for a64). +/// 1 turn = full rotation = 2*pi radians = 360 degrees +/// +/// Usage: +/// std := @import("std"); +/// rz(std.a64.quarter_turn turns) q[0]; +/// +/// Common quantum gate angles: +/// - T-gate: 1/8 turn = pi/4 rad = 45 deg +/// - S-gate: 1/4 turn = pi/2 rad = 90 deg +/// - Z-gate: 1/2 turn = pi rad = 180 deg + +// ============================================================================= +// Fundamental Turn Fractions (exact in Angle64) +// ============================================================================= + +/// Zero angle +pub zero: a64 = 0 turns; + +/// Full turn (equivalent to zero due to periodicity) +pub full_turn: a64 = 1 turns; + +/// Half turn = pi radians = 180 degrees (Z-gate equivalent) +pub half_turn: a64 = 1/2 turns; + +/// Quarter turn = pi/2 radians = 90 degrees (S-gate) +pub quarter_turn: a64 = 1/4 turns; + +/// Three-quarter turn = 3*pi/2 radians = 270 degrees +pub three_quarter_turn: a64 = 3/4 turns; + +/// Eighth turn = pi/4 radians = 45 degrees (T-gate) +pub eighth_turn: a64 = 1/8 turns; + +/// Three-eighths turn = 3*pi/4 radians = 135 degrees +pub three_eighth_turn: a64 = 3/8 turns; + +/// Sixteenth turn = pi/8 radians = 22.5 degrees +pub sixteenth_turn: a64 = 1/16 turns; + +// ============================================================================= +// Gate-Named Aliases (for convenience) +// ============================================================================= + +/// T-gate angle = 1/8 turn = pi/4 radians +pub t_angle: a64 = 1/8 turns; + +/// T-dagger angle = -1/8 turn = -pi/4 radians = 7/8 turns +pub tdg_angle: a64 = 7/8 turns; + +/// S-gate angle = 1/4 turn = pi/2 radians +pub s_angle: a64 = 1/4 turns; + +/// S-dagger angle = -1/4 turn = -pi/2 radians = 3/4 turns +pub sdg_angle: a64 = 3/4 turns; + +/// Z-gate angle = 1/2 turn = pi radians +pub z_angle: a64 = 1/2 turns; + +// ============================================================================= +// Thirds (exact in Angle64) +// ============================================================================= + +/// Third turn = 2*pi/3 radians = 120 degrees +pub third_turn: a64 = 1/3 turns; + +/// Two-thirds turn = 4*pi/3 radians = 240 degrees +pub two_third_turn: a64 = 2/3 turns; + +/// Sixth turn = pi/3 radians = 60 degrees +pub sixth_turn: a64 = 1/6 turns; + +/// Twelfth turn = pi/6 radians = 30 degrees +pub twelfth_turn: a64 = 1/12 turns; diff --git a/exp/zlup/std/algorithm.zlp b/exp/zlup/std/algorithm.zlp new file mode 100644 index 000000000..5f9726235 --- /dev/null +++ b/exp/zlup/std/algorithm.zlp @@ -0,0 +1,344 @@ +/// Standard library: Simple algorithms +/// +/// Fixed-size algorithms for common operations. +/// All bounds are explicit and checked. + +// ============================================================================= +// Swap Operations +// ============================================================================= + +/// Swap two u32 values (returns (b, a) as packed u64) +/// To unpack: a = result >> 32, b = result & 0xFFFFFFFF +pub fn swap_u32_packed(a: u32, b: u32) -> u64 { + high: u64 = b; + low: u64 = a; + return (high << 32) | low; +} + +// ============================================================================= +// Linear Search (Fixed-Size Arrays) +// ============================================================================= + +/// Search for value in 4-element array +/// Returns index (0-3) if found, 4 if not found +pub fn linear_search_4_u32(v0: u32, v1: u32, v2: u32, v3: u32, target: u32) -> u32 { + if v0 == target { return 0; } + if v1 == target { return 1; } + if v2 == target { return 2; } + if v3 == target { return 3; } + return 4; +} + +/// Search for value in 8-element array +pub fn linear_search_8_u32(v0: u32, v1: u32, v2: u32, v3: u32, + v4: u32, v5: u32, v6: u32, v7: u32, target: u32) -> u32 { + if v0 == target { return 0; } + if v1 == target { return 1; } + if v2 == target { return 2; } + if v3 == target { return 3; } + if v4 == target { return 4; } + if v5 == target { return 5; } + if v6 == target { return 6; } + if v7 == target { return 7; } + return 8; +} + +// ============================================================================= +// Min/Max in Fixed-Size Arrays +// ============================================================================= + +/// Find minimum of 4 values +pub fn min4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + mut m := v0; + if v1 < m { m = v1; } + if v2 < m { m = v2; } + if v3 < m { m = v3; } + return m; +} + +/// Find maximum of 4 values +pub fn max4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + mut m := v0; + if v1 > m { m = v1; } + if v2 > m { m = v2; } + if v3 > m { m = v3; } + return m; +} + +/// Find minimum of 8 values +pub fn min8_u32(v0: u32, v1: u32, v2: u32, v3: u32, + v4: u32, v5: u32, v6: u32, v7: u32) -> u32 { + m0 := min4_u32(v0, v1, v2, v3); + m1 := min4_u32(v4, v5, v6, v7); + if m0 < m1 { return m0; } + return m1; +} + +/// Find maximum of 8 values +pub fn max8_u32(v0: u32, v1: u32, v2: u32, v3: u32, + v4: u32, v5: u32, v6: u32, v7: u32) -> u32 { + m0 := max4_u32(v0, v1, v2, v3); + m1 := max4_u32(v4, v5, v6, v7); + if m0 > m1 { return m0; } + return m1; +} + +/// Find index of minimum in 4 values +pub fn argmin4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + mut idx: u32 = 0; + mut m := v0; + if v1 < m { m = v1; idx = 1; } + if v2 < m { m = v2; idx = 2; } + if v3 < m { idx = 3; } + return idx; +} + +/// Find index of maximum in 4 values +pub fn argmax4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + mut idx: u32 = 0; + mut m := v0; + if v1 > m { m = v1; idx = 1; } + if v2 > m { m = v2; idx = 2; } + if v3 > m { idx = 3; } + return idx; +} + +// ============================================================================= +// Sum and Product +// ============================================================================= + +/// Sum of 4 values +pub fn sum4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + return v0 + v1 + v2 + v3; +} + +/// Sum of 8 values +pub fn sum8_u32(v0: u32, v1: u32, v2: u32, v3: u32, + v4: u32, v5: u32, v6: u32, v7: u32) -> u32 { + return v0 + v1 + v2 + v3 + v4 + v5 + v6 + v7; +} + +/// Product of 4 values +pub fn prod4_u32(v0: u32, v1: u32, v2: u32, v3: u32) -> u32 { + return v0 * v1 * v2 * v3; +} + +// ============================================================================= +// Counting +// ============================================================================= + +/// Count values equal to target in 4 elements +pub fn count4_eq_u32(v0: u32, v1: u32, v2: u32, v3: u32, target: u32) -> u32 { + mut c: u32 = 0; + if v0 == target { c = c + 1; } + if v1 == target { c = c + 1; } + if v2 == target { c = c + 1; } + if v3 == target { c = c + 1; } + return c; +} + +/// Count values less than threshold in 4 elements +pub fn count4_lt_u32(v0: u32, v1: u32, v2: u32, v3: u32, threshold: u32) -> u32 { + mut c: u32 = 0; + if v0 < threshold { c = c + 1; } + if v1 < threshold { c = c + 1; } + if v2 < threshold { c = c + 1; } + if v3 < threshold { c = c + 1; } + return c; +} + +/// Count values greater than threshold in 4 elements +pub fn count4_gt_u32(v0: u32, v1: u32, v2: u32, v3: u32, threshold: u32) -> u32 { + mut c: u32 = 0; + if v0 > threshold { c = c + 1; } + if v1 > threshold { c = c + 1; } + if v2 > threshold { c = c + 1; } + if v3 > threshold { c = c + 1; } + return c; +} + +// ============================================================================= +// All/Any Predicates +// ============================================================================= + +/// Check if all 4 values equal target +pub fn all4_eq_u32(v0: u32, v1: u32, v2: u32, v3: u32, target: u32) -> bool { + return v0 == target and v1 == target and v2 == target and v3 == target; +} + +/// Check if any of 4 values equals target +pub fn any4_eq_u32(v0: u32, v1: u32, v2: u32, v3: u32, target: u32) -> bool { + return v0 == target or v1 == target or v2 == target or v3 == target; +} + +/// Check if all 4 values are less than threshold +pub fn all4_lt_u32(v0: u32, v1: u32, v2: u32, v3: u32, threshold: u32) -> bool { + return v0 < threshold and v1 < threshold and v2 < threshold and v3 < threshold; +} + +/// Check if any of 4 values is less than threshold +pub fn any4_lt_u32(v0: u32, v1: u32, v2: u32, v3: u32, threshold: u32) -> bool { + return v0 < threshold or v1 < threshold or v2 < threshold or v3 < threshold; +} + +// ============================================================================= +// Sorting Networks (Fixed-Size) +// ============================================================================= + +/// Compare-and-swap: returns (min, max) packed as (min << 32) | max +fn cas_u32(a: u32, b: u32) -> u64 { + if a <= b { + high: u64 = a; + low: u64 = b; + return (high << 32) | low; + } + high: u64 = b; + low: u64 = a; + return (high << 32) | low; +} + +/// Sort 2 values ascending, returns (min << 32) | max +pub fn sort2_u32(a: u32, b: u32) -> u64 { + return cas_u32(a, b); +} + +/// Sort 3 values ascending +/// Returns packed: (v0 << 42) | (v1 << 21) | v2 (21 bits each, assumes values < 2M) +/// For full range, use sort3_u32_to_array or successive extractions +pub fn sort3_u32_packed(a: u32, b: u32, c: u32) -> u64 { + // Sorting network for 3 elements + // Compare a,b + r1 := cas_u32(a, b); + lo1: u32 = (r1 & 0xFFFFFFFF); + hi1: u32 = (r1 >> 32); + + // Compare hi1,c + r2 := cas_u32(hi1, c); + mid: u32 = (r2 & 0xFFFFFFFF); + hi2: u32 = (r2 >> 32); + + // Compare lo1,mid + r3 := cas_u32(lo1, mid); + v0: u32 = (r3 >> 32); + v1: u32 = (r3 & 0xFFFFFFFF); + v2: u32 = hi2; + + // Pack as 21-bit values (assuming values fit) + result: u64 = 0; + x0: u64 = v0; + x1: u64 = v1; + x2: u64 = v2; + result = (x0 << 42) | (x1 << 21) | x2; + return result; +} + +/// Median of 3 values +pub fn median3_u32(a: u32, b: u32, c: u32) -> u32 { + // Using sorting network approach + if a <= b { + if b <= c { + return b; // a <= b <= c + } + if a <= c { + return c; // a <= c < b + } + return a; // c < a <= b + } + // b < a + if a <= c { + return a; // b < a <= c + } + if b <= c { + return c; // b <= c < a + } + return b; // c < b < a +} + +// ============================================================================= +// Bit Counting in Multiple Values +// ============================================================================= + +/// Count total set bits across 4 u8 values +pub fn total_bits_4_u8(v0: u8, v1: u8, v2: u8, v3: u8) -> u32 { + // Inline popcount for each + mut total: u32 = 0; + + mut n := v0; + for _ in 0..8 { + if n == 0 { break; } + n = n & (n - 1); + total = total + 1; + } + + n = v1; + for _ in 0..8 { + if n == 0 { break; } + n = n & (n - 1); + total = total + 1; + } + + n = v2; + for _ in 0..8 { + if n == 0 { break; } + n = n & (n - 1); + total = total + 1; + } + + n = v3; + for _ in 0..8 { + if n == 0 { break; } + n = n & (n - 1); + total = total + 1; + } + + return total; +} + +// ============================================================================= +// Prefix Sum (Scan) +// ============================================================================= + +/// Exclusive prefix sum of 4 values +/// Returns packed: (s0 << 48) | (s1 << 32) | (s2 << 16) | s3 +/// where s0=0, s1=v0, s2=v0+v1, s3=v0+v1+v2 +pub fn prefix_sum4_u16(v0: u16, v1: u16, v2: u16, v3: u16) -> u64 { + s0: u64 = 0; + s1: u64 = v0; + s2: u64 = v0 + v1; + s3: u64 = v0 + v1 + v2; + return (s0 << 48) | (s1 << 32) | (s2 << 16) | s3; +} + +// ============================================================================= +// Hamming Distance +// ============================================================================= + +/// Hamming distance between two u32 values (count of differing bits) +pub fn hamming_u32(a: u32, b: u32) -> u32 { + diff := a ^ b; + mut count: u32 = 0; + mut n := diff; + for _ in 0..32 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Hamming distance between two u64 values +pub fn hamming_u64(a: u64, b: u64) -> u64 { + diff := a ^ b; + mut count: u64 = 0; + mut n := diff; + for _ in 0..64 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} diff --git a/exp/zlup/std/bits.zlp b/exp/zlup/std/bits.zlp new file mode 100644 index 000000000..77ccd44e7 --- /dev/null +++ b/exp/zlup/std/bits.zlp @@ -0,0 +1,458 @@ +/// Standard library: Bitwise operations for syndrome processing +/// +/// Usage: +/// std := @import("std"); +/// weight := std.bits.popcount_u8(syndrome); +/// parity := std.bits.parity_u8(syndrome); + +// ============================================================================= +// Popcount (Count Set Bits) +// ============================================================================= + +/// Count the number of 1 bits in a u8 +pub fn popcount_u8(x: u8) -> u8 { + // Brian Kernighan's algorithm + mut count: u8 = 0; + mut n := x; + for _ in 0..8 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Count the number of 1 bits in a u16 +pub fn popcount_u16(x: u16) -> u16 { + mut count: u16 = 0; + mut n := x; + for _ in 0..16 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Count the number of 1 bits in a u32 +pub fn popcount_u32(x: u32) -> u32 { + mut count: u32 = 0; + mut n := x; + for _ in 0..32 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Count the number of 1 bits in a u64 +pub fn popcount_u64(x: u64) -> u64 { + mut count: u64 = 0; + mut n := x; + for _ in 0..64 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +// ============================================================================= +// Parity (XOR of All Bits) +// ============================================================================= + +/// Compute parity of a u8 (1 if odd number of 1s, 0 if even) +pub fn parity_u8(x: u8) -> u8 { + mut p := x; + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Compute parity of a u16 +pub fn parity_u16(x: u16) -> u16 { + mut p := x; + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Compute parity of a u32 +pub fn parity_u32(x: u32) -> u32 { + mut p := x; + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Compute parity of a u64 +pub fn parity_u64(x: u64) -> u64 { + mut p := x; + p = p ^ (p >> 32); + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +// ============================================================================= +// Bit Extraction and Manipulation +// ============================================================================= + +/// Get a single bit from a u8 (returns 0 or 1) +pub fn get_bit_u8(x: u8, index: u8) -> u8 { + if index >= 8 { + return 0; + } + return (x >> index) & 1; +} + +/// Get a single bit from a u32 (returns 0 or 1) +pub fn get_bit_u32(x: u32, index: u32) -> u32 { + if index >= 32 { + return 0; + } + return (x >> index) & 1; +} + +/// Get a single bit from a u64 (returns 0 or 1) +pub fn get_bit_u64(x: u64, index: u64) -> u64 { + if index >= 64 { + return 0; + } + return (x >> index) & 1; +} + +/// Set a single bit in a u8 +pub fn set_bit_u8(x: u8, index: u8, value: u8) -> u8 { + if index >= 8 { + return x; + } + mask: u8 = 1 << index; + if value == 1 { + return x | mask; + } else { + return x & (~mask); + } +} + +/// Set a single bit in a u32 +pub fn set_bit_u32(x: u32, index: u32, value: u32) -> u32 { + if index >= 32 { + return x; + } + mask: u32 = 1 << index; + if value == 1 { + return x | mask; + } else { + return x & (~mask); + } +} + +/// Set a single bit in a u64 +pub fn set_bit_u64(x: u64, index: u64, value: u64) -> u64 { + if index >= 64 { + return x; + } + mask: u64 = 1 << index; + if value == 1 { + return x | mask; + } else { + return x & (~mask); + } +} + +/// Toggle a single bit in a u8 +pub fn toggle_bit_u8(x: u8, index: u8) -> u8 { + if index >= 8 { + return x; + } + mask: u8 = 1 << index; + return x ^ mask; +} + +/// Toggle a single bit in a u32 +pub fn toggle_bit_u32(x: u32, index: u32) -> u32 { + if index >= 32 { + return x; + } + mask: u32 = 1 << index; + return x ^ mask; +} + +/// Toggle a single bit in a u64 +pub fn toggle_bit_u64(x: u64, index: u64) -> u64 { + if index >= 64 { + return x; + } + mask: u64 = 1 << index; + return x ^ mask; +} + +/// Extract a range of bits from a u32 +/// Returns bits [start, start+len) shifted to LSB position +pub fn extract_bits_u32(x: u32, start: u32, len: u32) -> u32 { + if start >= 32 or len == 0 { + return 0; + } + mut actual_len := len; + if start + len > 32 { + actual_len = 32 - start; + } + one: u32 = 1; + mask := (one << actual_len) - 1; + return (x >> start) & mask; +} + +/// Extract a range of bits from a u64 +pub fn extract_bits_u64(x: u64, start: u64, len: u64) -> u64 { + if start >= 64 or len == 0 { + return 0; + } + mut actual_len := len; + if start + len > 64 { + actual_len = 64 - start; + } + one: u64 = 1; + mask := (one << actual_len) - 1; + return (x >> start) & mask; +} + +// ============================================================================= +// Rotation +// ============================================================================= + +/// Rotate left for u32 +pub fn rotl_u32(x: u32, n: u32) -> u32 { + shift := n % 32; + if shift == 0 { + return x; + } + return (x << shift) | (x >> (32 - shift)); +} + +/// Rotate right for u32 +pub fn rotr_u32(x: u32, n: u32) -> u32 { + shift := n % 32; + if shift == 0 { + return x; + } + return (x >> shift) | (x << (32 - shift)); +} + +/// Rotate left for u64 +pub fn rotl_u64(x: u64, n: u64) -> u64 { + shift := n % 64; + if shift == 0 { + return x; + } + return (x << shift) | (x >> (64 - shift)); +} + +/// Rotate right for u64 +pub fn rotr_u64(x: u64, n: u64) -> u64 { + shift := n % 64; + if shift == 0 { + return x; + } + return (x >> shift) | (x << (64 - shift)); +} + +// ============================================================================= +// Leading/Trailing Zeros +// ============================================================================= + +/// Count leading zeros in a u32 +pub fn clz_u32(x: u32) -> u32 { + if x == 0 { + return 32; + } + mut n: u32 = 0; + mut val := x; + if (val & 0xFFFF0000) == 0 { n = n + 16; val = val << 16; } + if (val & 0xFF000000) == 0 { n = n + 8; val = val << 8; } + if (val & 0xF0000000) == 0 { n = n + 4; val = val << 4; } + if (val & 0xC0000000) == 0 { n = n + 2; val = val << 2; } + if (val & 0x80000000) == 0 { n = n + 1; } + return n; +} + +/// Count trailing zeros in a u32 +pub fn ctz_u32(x: u32) -> u32 { + if x == 0 { + return 32; + } + mut n: u32 = 0; + mut val := x; + if (val & 0x0000FFFF) == 0 { n = n + 16; val = val >> 16; } + if (val & 0x000000FF) == 0 { n = n + 8; val = val >> 8; } + if (val & 0x0000000F) == 0 { n = n + 4; val = val >> 4; } + if (val & 0x00000003) == 0 { n = n + 2; val = val >> 2; } + if (val & 0x00000001) == 0 { n = n + 1; } + return n; +} + +/// Count leading zeros in a u64 +pub fn clz_u64(x: u64) -> u64 { + if x == 0 { + return 64; + } + // Check high 32 bits first + if (x >> 32) != 0 { + // High bits have data, count zeros there + mut n: u64 = 0; + mut val := x >> 32; + if (val & 0xFFFF0000) == 0 { n = n + 16; val = val << 16; } + if (val & 0xFF000000) == 0 { n = n + 8; val = val << 8; } + if (val & 0xF0000000) == 0 { n = n + 4; val = val << 4; } + if (val & 0xC0000000) == 0 { n = n + 2; val = val << 2; } + if (val & 0x80000000) == 0 { n = n + 1; } + return n; + } + // High bits are zero, count in low bits + 32 + mut n: u64 = 32; + mut val := x; + if (val & 0xFFFF0000) == 0 { n = n + 16; val = val << 16; } + if (val & 0xFF000000) == 0 { n = n + 8; val = val << 8; } + if (val & 0xF0000000) == 0 { n = n + 4; val = val << 4; } + if (val & 0xC0000000) == 0 { n = n + 2; val = val << 2; } + if (val & 0x80000000) == 0 { n = n + 1; } + return n; +} + +/// Count trailing zeros in a u64 +pub fn ctz_u64(x: u64) -> u64 { + if x == 0 { + return 64; + } + // Check low 32 bits first + if (x & 0xFFFFFFFF) != 0 { + // Low bits have data + mut n: u64 = 0; + mut val := x; + if (val & 0x0000FFFF) == 0 { n = n + 16; val = val >> 16; } + if (val & 0x000000FF) == 0 { n = n + 8; val = val >> 8; } + if (val & 0x0000000F) == 0 { n = n + 4; val = val >> 4; } + if (val & 0x00000003) == 0 { n = n + 2; val = val >> 2; } + if (val & 0x00000001) == 0 { n = n + 1; } + return n; + } + // Low bits are zero, count in high bits + 32 + mut n: u64 = 32; + mut val := x >> 32; + if (val & 0x0000FFFF) == 0 { n = n + 16; val = val >> 16; } + if (val & 0x000000FF) == 0 { n = n + 8; val = val >> 8; } + if (val & 0x0000000F) == 0 { n = n + 4; val = val >> 4; } + if (val & 0x00000003) == 0 { n = n + 2; val = val >> 2; } + if (val & 0x00000001) == 0 { n = n + 1; } + return n; +} + +// ============================================================================= +// Bit Reversal +// ============================================================================= + +/// Reverse bits in a u8 +pub fn reverse_u8(x: u8) -> u8 { + mut v := x; + v = ((v & 0xF0) >> 4) | ((v & 0x0F) << 4); + v = ((v & 0xCC) >> 2) | ((v & 0x33) << 2); + v = ((v & 0xAA) >> 1) | ((v & 0x55) << 1); + return v; +} + +/// Reverse bits in a u32 +pub fn reverse_u32(x: u32) -> u32 { + mut v := x; + v = ((v & 0xFFFF0000) >> 16) | ((v & 0x0000FFFF) << 16); + v = ((v & 0xFF00FF00) >> 8) | ((v & 0x00FF00FF) << 8); + v = ((v & 0xF0F0F0F0) >> 4) | ((v & 0x0F0F0F0F) << 4); + v = ((v & 0xCCCCCCCC) >> 2) | ((v & 0x33333333) << 2); + v = ((v & 0xAAAAAAAA) >> 1) | ((v & 0x55555555) << 1); + return v; +} + +// ============================================================================= +// Byte Swap +// ============================================================================= + +/// Swap bytes in a u16 (endianness conversion) +pub fn byte_swap_u16(x: u16) -> u16 { + return ((x & 0xFF) << 8) | ((x >> 8) & 0xFF); +} + +/// Swap bytes in a u32 (endianness conversion) +pub fn byte_swap_u32(x: u32) -> u32 { + return ((x & 0x000000FF) << 24) | + ((x & 0x0000FF00) << 8) | + ((x & 0x00FF0000) >> 8) | + ((x & 0xFF000000) >> 24); +} + +/// Swap bytes in a u64 (endianness conversion) +pub fn byte_swap_u64(x: u64) -> u64 { + mut v := x; + v = ((v & 0x00000000FFFFFFFF) << 32) | ((v >> 32) & 0x00000000FFFFFFFF); + v = ((v & 0x0000FFFF0000FFFF) << 16) | ((v >> 16) & 0x0000FFFF0000FFFF); + v = ((v & 0x00FF00FF00FF00FF) << 8) | ((v >> 8) & 0x00FF00FF00FF00FF); + return v; +} + +// ============================================================================= +// Syndrome-specific Operations +// ============================================================================= + +/// XOR two syndromes (for computing syndrome differences) +pub fn xor_u64(a: u64, b: u64) -> u64 { + return a ^ b; +} + +/// Check if syndrome indicates no errors (all zeros) +pub fn is_trivial_syndrome(syndrome: u64) -> bool { + return syndrome == 0; +} + +/// Get the index of the lowest set bit (useful for finding first defect) +/// Returns 64 if no bits are set +pub fn lowest_set_bit_u64(x: u64) -> u64 { + if x == 0 { + return 64; + } + return ctz_u64(x); +} + +/// Get the index of the highest set bit +/// Returns 64 if no bits are set +pub fn highest_set_bit_u64(x: u64) -> u64 { + if x == 0 { + return 64; + } + return 63 - clz_u64(x); +} + +/// Clear the lowest set bit +pub fn clear_lowest_bit_u64(x: u64) -> u64 { + return x & (x - 1); +} + +/// Isolate the lowest set bit (returns value with only that bit set) +pub fn isolate_lowest_bit_u64(x: u64) -> u64 { + return x & (~x + 1); +} diff --git a/exp/zlup/std/bits.zlup b/exp/zlup/std/bits.zlup new file mode 100644 index 000000000..e6bec753f --- /dev/null +++ b/exp/zlup/std/bits.zlup @@ -0,0 +1,176 @@ +/// Bitwise utilities for working with measurement results. +/// Provides functions for bit manipulation and parity calculations. + +// ============================================================================= +// Bit Counting +// ============================================================================= + +/// Count the number of set bits (population count/Hamming weight). +/// Useful for computing parity of measurement results. +/// +/// Example: +/// ```zlup +/// result: u8 = 0b10110100; +/// weight := popcount_u8(result); // 4 +/// ``` +pub fn popcount_u8(x: u8) -> u8 { + mut count: u8 = 0; + mut val := x; + for i in 0..8 { + count += val & 1; + val = val >> 1; + } + return count; +} + +/// Count set bits in a u16. +pub fn popcount_u16(x: u16) -> u16 { + mut count: u16 = 0; + mut val := x; + for i in 0..16 { + count += val & 1; + val = val >> 1; + } + return count; +} + +/// Count set bits in a u32. +pub fn popcount_u32(x: u32) -> u32 { + mut count: u32 = 0; + mut val := x; + for i in 0..32 { + count += val & 1; + val = val >> 1; + } + return count; +} + +/// Count set bits in a u64. +pub fn popcount_u64(x: u64) -> u64 { + mut count: u64 = 0; + mut val := x; + for i in 0..64 { + count += val & 1; + val = val >> 1; + } + return count; +} + +// ============================================================================= +// Parity +// ============================================================================= + +/// Compute the parity (XOR of all bits) of a u8. +/// Returns 0 if even number of 1s, 1 if odd. +/// +/// Example: +/// ```zlup +/// // Syndrome parity check +/// syndrome: u8 = mz(pack u8) ancillas; +/// if parity_u8(syndrome) == 1 { +/// // Odd parity - error detected +/// } +/// ``` +pub fn parity_u8(x: u8) -> u1 { + return (popcount_u8(x) & 1); +} + +/// Compute parity of a u16. +pub fn parity_u16(x: u16) -> u1 { + return (popcount_u16(x) & 1); +} + +/// Compute parity of a u32. +pub fn parity_u32(x: u32) -> u1 { + return (popcount_u32(x) & 1); +} + +/// Compute parity of a u64. +pub fn parity_u64(x: u64) -> u1 { + return (popcount_u64(x) & 1); +} + +// ============================================================================= +// Bit Extraction +// ============================================================================= + +/// Extract a single bit from a u8 at the given index. +/// Index 0 is the least significant bit. +/// +/// Example: +/// ```zlup +/// result: u8 = 0b10110100; +/// bit2 := get_bit_u8(result, 2); // 1 +/// bit0 := get_bit_u8(result, 0); // 0 +/// ``` +pub fn get_bit_u8(x: u8, index: u8) -> u1 { + return (x >> index) & 1; +} + +/// Extract a single bit from a u16. +pub fn get_bit_u16(x: u16, index: u16) -> u1 { + return (x >> index) & 1; +} + +/// Extract a single bit from a u32. +pub fn get_bit_u32(x: u32, index: u32) -> u1 { + return (x >> index) & 1; +} + +/// Extract a single bit from a u64. +pub fn get_bit_u64(x: u64, index: u64) -> u1 { + return (x >> index) & 1; +} + +// ============================================================================= +// Bit Manipulation +// ============================================================================= + +/// Set a bit to 1 at the given index. +pub fn set_bit_u8(x: u8, index: u8) -> u8 { + return x | (1 << index); +} + +/// Clear a bit to 0 at the given index. +pub fn clear_bit_u8(x: u8, index: u8) -> u8 { + return x & ~(1 << index); +} + +/// Toggle a bit at the given index. +pub fn toggle_bit_u8(x: u8, index: u8) -> u8 { + return x ^ (1 << index); +} + +// ============================================================================= +// Byte Order +// ============================================================================= + +/// Reverse the bits in a u8. +/// +/// Example: +/// ```zlup +/// x: u8 = 0b10110100; +/// reversed := reverse_bits_u8(x); // 0b00101101 +/// ``` +pub fn reverse_bits_u8(x: u8) -> u8 { + mut result: u8 = 0; + mut val := x; + for i in 0..8 { + result = (result << 1) | (val & 1); + val = val >> 1; + } + return result; +} + +/// Swap bytes in a u16 (big-endian to little-endian or vice versa). +pub fn swap_bytes_u16(x: u16) -> u16 { + return ((x & 0xFF) << 8) | ((x >> 8) & 0xFF); +} + +/// Swap bytes in a u32. +pub fn swap_bytes_u32(x: u32) -> u32 { + return ((x & 0xFF) << 24) | + ((x & 0xFF00) << 8) | + ((x >> 8) & 0xFF00) | + ((x >> 24) & 0xFF); +} diff --git a/exp/zlup/std/containers.zlup b/exp/zlup/std/containers.zlup new file mode 100644 index 000000000..06e0385d7 --- /dev/null +++ b/exp/zlup/std/containers.zlup @@ -0,0 +1,381 @@ +/// Standard library containers for Zluppy. +/// All containers have bounded capacity for NASA Power of 10 compliance. + +/// Error set for container operations. +pub OverflowError := error { Overflow }; + +/// Bounded stack (LIFO) container. +/// +/// Example: +/// ```zlup +/// stack: Stack(u32, 64) = .{}; +/// try stack.push(42); +/// if val := stack.pop() { +/// // use val +/// } +/// ``` +pub Stack := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + /// Internal storage + items: [capacity]T = undefined, + /// Current number of elements + len: usize = 0, + + /// Push an item onto the stack. + /// Returns error.Overflow if the stack is full. + pub fn push(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + self.items[self.len] = item; + self.len += 1; + return unit; + } + + /// Pop an item from the stack. + /// Returns none if the stack is empty. + pub fn pop(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + self.len -= 1; + return self.items[self.len]; + } + + /// Peek at the top item without removing it. + /// Returns none if the stack is empty. + pub fn peek(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + return self.items[self.len - 1]; + } + + /// Check if the stack is empty. + pub fn is_empty(&mut self) -> bool { + return self.len == 0; + } + + /// Check if the stack is full. + pub fn is_full(&mut self) -> bool { + return self.len >= capacity; + } + + /// Clear all items from the stack. + pub fn clear(&mut self) -> unit { + self.len = 0; + return unit; + } + + /// Get the current number of items. + pub fn count(&mut self) -> usize { + return self.len; + } + + /// Get the maximum capacity. + pub fn get_capacity(&mut self) -> usize { + return capacity; + } + } +}; + +/// Bounded queue (FIFO) container using a ring buffer. +/// +/// Example: +/// ```zlup +/// queue: Queue(u32, 64) = .{}; +/// try queue.enqueue(42); +/// if val := queue.dequeue() { +/// // use val +/// } +/// ``` +pub Queue := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + /// Internal storage (ring buffer) + items: [capacity]T = undefined, + /// Index of the front element + head: usize = 0, + /// Index where the next element will be inserted + tail: usize = 0, + /// Current number of elements + len: usize = 0, + + /// Add an item to the back of the queue. + /// Returns error.Overflow if the queue is full. + pub fn enqueue(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + self.items[self.tail] = item; + self.tail = (self.tail + 1) % capacity; + self.len += 1; + return unit; + } + + /// Remove and return the front item. + /// Returns none if the queue is empty. + pub fn dequeue(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + item := self.items[self.head]; + self.head = (self.head + 1) % capacity; + self.len -= 1; + return item; + } + + /// Peek at the front item without removing it. + /// Returns none if the queue is empty. + pub fn peek_front(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + return self.items[self.head]; + } + + /// Check if the queue is empty. + pub fn is_empty(&mut self) -> bool { + return self.len == 0; + } + + /// Check if the queue is full. + pub fn is_full(&mut self) -> bool { + return self.len >= capacity; + } + + /// Clear all items from the queue. + pub fn clear(&mut self) -> unit { + self.head = 0; + self.tail = 0; + self.len = 0; + return unit; + } + + /// Get the current number of items. + pub fn count(&mut self) -> usize { + return self.len; + } + } +}; + +/// Bounded double-ended queue (deque) container. +/// Supports efficient insertion and removal at both ends. +/// +/// Example: +/// ```zlup +/// deque: Deque(u32, 64) = .{}; +/// try deque.push_back(42); +/// try deque.push_front(1); +/// if val := deque.pop_front() { +/// // use val +/// } +/// ``` +pub Deque := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + /// Internal storage (ring buffer) + items: [capacity]T = undefined, + /// Index of the front element + head: usize = 0, + /// Current number of elements + len: usize = 0, + + /// Add an item to the back of the deque. + /// Returns error.Overflow if the deque is full. + pub fn push_back(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + tail := (self.head + self.len) % capacity; + self.items[tail] = item; + self.len += 1; + return unit; + } + + /// Add an item to the front of the deque. + /// Returns error.Overflow if the deque is full. + pub fn push_front(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + self.head = (self.head + capacity - 1) % capacity; + self.items[self.head] = item; + self.len += 1; + return unit; + } + + /// Remove and return the back item. + /// Returns none if the deque is empty. + pub fn pop_back(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + self.len -= 1; + tail := (self.head + self.len) % capacity; + return self.items[tail]; + } + + /// Remove and return the front item. + /// Returns none if the deque is empty. + pub fn pop_front(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + item := self.items[self.head]; + self.head = (self.head + 1) % capacity; + self.len -= 1; + return item; + } + + /// Peek at the front item without removing it. + pub fn peek_front(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + return self.items[self.head]; + } + + /// Peek at the back item without removing it. + pub fn peek_back(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + tail := (self.head + self.len - 1) % capacity; + return self.items[tail]; + } + + /// Check if the deque is empty. + pub fn is_empty(&mut self) -> bool { + return self.len == 0; + } + + /// Check if the deque is full. + pub fn is_full(&mut self) -> bool { + return self.len >= capacity; + } + + /// Clear all items from the deque. + pub fn clear(&mut self) -> unit { + self.head = 0; + self.len = 0; + return unit; + } + + /// Get the current number of items. + pub fn count(&mut self) -> usize { + return self.len; + } + } +}; + +/// Bounded priority queue (min-heap). +/// Elements are ordered by priority, with the smallest element at the front. +/// +/// Example: +/// ```zlup +/// pq: PriorityQueue(u32, 64) = .{}; +/// try pq.insert(42); +/// try pq.insert(10); +/// if val := pq.extract_min() { +/// // val is 10 +/// } +/// ``` +pub PriorityQueue := fn(comptime T: type, comptime capacity: usize) -> type { + struct { + /// Internal storage (binary heap) + items: [capacity]T = undefined, + /// Current number of elements + len: usize = 0, + + /// Insert an item into the priority queue. + /// Returns error.Overflow if the queue is full. + pub fn insert(&mut self, item: T) -> OverflowError!void { + if (self.len >= capacity) { + return error.Overflow; + } + // Add at the end + self.items[self.len] = item; + // Bubble up + self.bubble_up(self.len); + self.len += 1; + return unit; + } + + /// Remove and return the minimum element. + /// Returns none if the queue is empty. + pub fn extract_min(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + min := self.items[0]; + self.len -= 1; + if (self.len > 0) { + self.items[0] = self.items[self.len]; + self.bubble_down(0); + } + return min; + } + + /// Peek at the minimum element without removing it. + pub fn peek_min(&mut self) -> ?T { + if (self.len == 0) { + return none; + } + return self.items[0]; + } + + /// Check if the queue is empty. + pub fn is_empty(&mut self) -> bool { + return self.len == 0; + } + + // Internal: bubble up element at index i + // Max iterations is log2(capacity) which is bounded by capacity + fn bubble_up(&mut self, i: usize) -> unit { + mut idx := i; + // Bounded loop: at most capacity iterations (actually log2(capacity)) + for _ in 0..capacity { + if (idx == 0) { + break; + } + parent := (idx - 1) / 2; + if (self.items[idx] >= self.items[parent]) { + break; + } + // Swap + tmp := self.items[idx]; + self.items[idx] = self.items[parent]; + self.items[parent] = tmp; + idx = parent; + } + return unit; + } + + // Internal: bubble down element at index i + // Max iterations is log2(capacity) which is bounded by capacity + fn bubble_down(&mut self, i: usize) -> unit { + mut idx := i; + // Bounded loop: at most capacity iterations (actually log2(capacity)) + for _ in 0..capacity { + smallest := idx; + left := 2 * idx + 1; + right := 2 * idx + 2; + + if (left < self.len and self.items[left] < self.items[smallest]) { + smallest = left; + } + if (right < self.len and self.items[right] < self.items[smallest]) { + smallest = right; + } + if (smallest == idx) { + break; + } + // Swap + tmp := self.items[idx]; + self.items[idx] = self.items[smallest]; + self.items[smallest] = tmp; + idx = smallest; + } + return unit; + } + } +}; diff --git a/exp/zlup/std/f64.zlp b/exp/zlup/std/f64.zlp new file mode 100644 index 000000000..633b4d5c2 --- /dev/null +++ b/exp/zlup/std/f64.zlp @@ -0,0 +1,72 @@ +/// Standard library: f64 constants +/// +/// Usage: +/// std := @import("std"); +/// angle := std.f64.pi / 4.0; // pi/4 as f64 +/// +/// Or import directly: +/// f64_consts := @import("std/f64.zlp"); +/// angle := f64_consts.pi / 4.0; + +// ============================================================================= +// Mathematical Constants (f64) +// ============================================================================= + +/// Pi - ratio of circumference to diameter +pub pi: f64 = 3.14159265358979323846; + +/// Tau - 2*pi, full circle in radians +pub tau: f64 = 6.28318530717958647692; + +/// Euler's number +pub e: f64 = 2.71828182845904523536; + +/// Square root of 2 +pub sqrt2: f64 = 1.41421356237309504880; + +/// 1 / sqrt(2) - common in quantum gates +pub sqrt2_inv: f64 = 0.70710678118654752440; + +/// Square root of 3 +pub sqrt3: f64 = 1.73205080756887729353; + +/// Natural logarithm of 2 +pub ln2: f64 = 0.69314718055994530942; + +/// Natural logarithm of 10 +pub ln10: f64 = 2.30258509299404568402; + +// ============================================================================= +// Common Angle Fractions in Radians +// ============================================================================= + +/// pi/2 - quarter turn in radians +pub pi_2: f64 = 1.57079632679489661923; + +/// pi/4 - eighth turn in radians (T-gate) +pub pi_4: f64 = 0.78539816339744830962; + +/// pi/8 - sixteenth turn in radians +pub pi_8: f64 = 0.39269908169872415481; + +/// pi/3 - sixth turn in radians +pub pi_3: f64 = 1.04719755119659774615; + +/// pi/6 - twelfth turn in radians +pub pi_6: f64 = 0.52359877559829887308; + +// ============================================================================= +// Conversion Factors +// ============================================================================= + +/// Degrees to radians: multiply degrees by this +pub deg_to_rad: f64 = 0.01745329251994329577; + +/// Radians to degrees: multiply radians by this +pub rad_to_deg: f64 = 57.29577951308232087680; + +/// Turns to radians: multiply turns by this (= 2*pi) +pub turns_to_rad: f64 = 6.28318530717958647692; + +/// Radians to turns: multiply radians by this (= 1/(2*pi)) +pub rad_to_turns: f64 = 0.15915494309189533577; diff --git a/exp/zlup/std/math.zlp b/exp/zlup/std/math.zlp new file mode 100644 index 000000000..928e6a6a7 --- /dev/null +++ b/exp/zlup/std/math.zlp @@ -0,0 +1,523 @@ +/// Standard library: Mathematical functions +/// +/// For constants (pi, tau, e, etc.), use std.f64: +/// std := @import("std"); +/// angle := std.f64.pi / 4.0; +/// +/// For angle constants, use std.a64: +/// theta := std.a64.quarter_turn; + +// ============================================================================= +// Functions +// ============================================================================= + +/// Absolute value +pub fn abs_i32(x: i32) -> i32 { + if x < 0 { + return -x; + } + return x; +} + +/// Absolute value for i64 +pub fn abs_i64(x: i64) -> i64 { + if x < 0 { + return -x; + } + return x; +} + +/// Absolute value for f64 +pub fn abs_f64(x: f64) -> f64 { + if x < 0.0 { + return -x; + } + return x; +} + +/// Minimum of two i32 values +pub fn min_i32(a: i32, b: i32) -> i32 { + if a < b { + return a; + } + return b; +} + +/// Minimum of two u32 values +pub fn min_u32(a: u32, b: u32) -> u32 { + if a < b { + return a; + } + return b; +} + +/// Minimum of two f64 values +pub fn min_f64(a: f64, b: f64) -> f64 { + if a < b { + return a; + } + return b; +} + +/// Maximum of two i32 values +pub fn max_i32(a: i32, b: i32) -> i32 { + if a > b { + return a; + } + return b; +} + +/// Maximum of two u32 values +pub fn max_u32(a: u32, b: u32) -> u32 { + if a > b { + return a; + } + return b; +} + +/// Maximum of two f64 values +pub fn max_f64(a: f64, b: f64) -> f64 { + if a > b { + return a; + } + return b; +} + +/// Clamp value to range [lo, hi] +pub fn clamp_i32(x: i32, lo: i32, hi: i32) -> i32 { + if x < lo { + return lo; + } + if x > hi { + return hi; + } + return x; +} + +/// Clamp value to range [lo, hi] +pub fn clamp_u32(x: u32, lo: u32, hi: u32) -> u32 { + if x < lo { + return lo; + } + if x > hi { + return hi; + } + return x; +} + +/// Clamp value to range [lo, hi] +pub fn clamp_f64(x: f64, lo: f64, hi: f64) -> f64 { + if x < lo { + return lo; + } + if x > hi { + return hi; + } + return x; +} + +/// Sign of a number: -1, 0, or 1 +pub fn sign_i32(x: i32) -> i32 { + if x < 0 { + return -1; + } + if x > 0 { + return 1; + } + return 0; +} + +/// Sign of a number: -1.0, 0.0, or 1.0 +pub fn sign_f64(x: f64) -> f64 { + if x < 0.0 { + return -1.0; + } + if x > 0.0 { + return 1.0; + } + return 0.0; +} + +/// Integer division (floor division for positive numbers) +pub fn div_floor_i32(a: i32, b: i32) -> i32 { + result := a / b; + // Adjust for negative results to get floor behavior + if (a < 0) != (b < 0) and a % b != 0 { + return result - 1; + } + return result; +} + +/// Modulo operation (always non-negative for positive divisor) +pub fn mod_i32(a: i32, b: i32) -> i32 { + result := a % b; + if result < 0 { + return result + b; + } + return result; +} + +/// Check if integer is power of 2 +pub fn is_power_of_2_u32(x: u32) -> bool { + if x == 0 { + return false; + } + return (x & (x - 1)) == 0; +} + +/// Check if integer is power of 2 +pub fn is_power_of_2_u64(x: u64) -> bool { + if x == 0 { + return false; + } + return (x & (x - 1)) == 0; +} + +/// Next power of 2 greater than or equal to x +pub fn next_power_of_2_u32(x: u32) -> u32 { + if x == 0 { + return 1; + } + mut n := x - 1; + n = n | (n >> 1); + n = n | (n >> 2); + n = n | (n >> 4); + n = n | (n >> 8); + n = n | (n >> 16); + return n + 1; +} + +// ============================================================================= +// GCD and LCM +// ============================================================================= + +/// Greatest common divisor (Euclidean algorithm) +pub fn gcd_u32(a: u32, b: u32) -> u32 { + mut x := a; + mut y := b; + for _ in 0..32 { + if y == 0 { + return x; + } + t := y; + y = x % y; + x = t; + } + return x; +} + +/// Greatest common divisor for u64 +pub fn gcd_u64(a: u64, b: u64) -> u64 { + mut x := a; + mut y := b; + for _ in 0..64 { + if y == 0 { + return x; + } + t := y; + y = x % y; + x = t; + } + return x; +} + +/// Least common multiple +pub fn lcm_u32(a: u32, b: u32) -> u32 { + if a == 0 or b == 0 { + return 0; + } + return (a / gcd_u32(a, b)) * b; +} + +/// Least common multiple for u64 +pub fn lcm_u64(a: u64, b: u64) -> u64 { + if a == 0 or b == 0 { + return 0; + } + return (a / gcd_u64(a, b)) * b; +} + +// ============================================================================= +// Integer Square Root +// ============================================================================= + +/// Integer square root (floor of sqrt) +/// Uses Newton-Raphson iteration +pub fn isqrt_u32(x: u32) -> u32 { + if x == 0 { + return 0; + } + if x == 1 { + return 1; + } + + // Initial guess (half the bit width) + mut r := x / 2; + + // Newton-Raphson: r = (r + x/r) / 2 + for _ in 0..16 { + new_r := (r + x / r) / 2; + if new_r >= r { + return r; + } + r = new_r; + } + return r; +} + +/// Integer square root for u64 +pub fn isqrt_u64(x: u64) -> u64 { + if x == 0 { + return 0; + } + if x == 1 { + return 1; + } + + mut r := x / 2; + + for _ in 0..32 { + new_r := (r + x / r) / 2; + if new_r >= r { + return r; + } + r = new_r; + } + return r; +} + +// ============================================================================= +// Integer Logarithm +// ============================================================================= + +/// Floor of log base 2 (position of highest set bit) +/// Returns 0 for x=0 or x=1 +pub fn log2_u32(x: u32) -> u32 { + if x <= 1 { + return 0; + } + + mut n: u32 = 0; + mut val := x; + + if val >= 65536 { n = n + 16; val = val >> 16; } + if val >= 256 { n = n + 8; val = val >> 8; } + if val >= 16 { n = n + 4; val = val >> 4; } + if val >= 4 { n = n + 2; val = val >> 2; } + if val >= 2 { n = n + 1; } + + return n; +} + +/// Floor of log base 2 for u64 +pub fn log2_u64(x: u64) -> u64 { + if x <= 1 { + return 0; + } + + mut n: u64 = 0; + mut val := x; + + if val >= 4294967296 { n = n + 32; val = val >> 32; } + if val >= 65536 { n = n + 16; val = val >> 16; } + if val >= 256 { n = n + 8; val = val >> 8; } + if val >= 16 { n = n + 4; val = val >> 4; } + if val >= 4 { n = n + 2; val = val >> 2; } + if val >= 2 { n = n + 1; } + + return n; +} + +/// Ceiling of log base 2 +pub fn log2_ceil_u32(x: u32) -> u32 { + if x <= 1 { + return 0; + } + floor := log2_u32(x); + one: u32 = 1; + if (one << floor) == x { + return floor; + } + return floor + 1; +} + +/// Ceiling of log base 2 for u64 +pub fn log2_ceil_u64(x: u64) -> u64 { + if x <= 1 { + return 0; + } + floor := log2_u64(x); + one: u64 = 1; + if (one << floor) == x { + return floor; + } + return floor + 1; +} + +// ============================================================================= +// Factorial and Binomial (Small Numbers) +// ============================================================================= + +/// Factorial for small n (n <= 12 for u32, n <= 20 for u64) +/// Returns 0 on overflow +pub fn factorial_u32(n: u32) -> u32 { + if n > 12 { + return 0; // Overflow + } + mut result: u32 = 1; + mut i: u32 = 2; + for _ in 0..12 { + if i > n { + break; + } + result = result * i; + i = i + 1; + } + return result; +} + +/// Factorial for small n (max n=20) +pub fn factorial_u64(n: u64) -> u64 { + if n > 20 { + return 0; // Overflow + } + mut result: u64 = 1; + mut i: u64 = 2; + for _ in 0..20 { + if i > n { + break; + } + result = result * i; + i = i + 1; + } + return result; +} + +/// Binomial coefficient C(n, k) = n! / (k! * (n-k)!) +/// Uses multiplicative formula to avoid large intermediate values +pub fn binomial_u32(n: u32, k: u32) -> u32 { + if k > n { + return 0; + } + if k == 0 or k == n { + return 1; + } + + // Use smaller k for efficiency: C(n,k) = C(n, n-k) + mut kk := k; + if k > n - k { + kk = n - k; + } + + mut result: u32 = 1; + mut i: u32 = 0; + for _ in 0..16 { + if i >= kk { + break; + } + result = result * (n - i); + result = result / (i + 1); + i = i + 1; + } + return result; +} + +/// Binomial coefficient for u64 +pub fn binomial_u64(n: u64, k: u64) -> u64 { + if k > n { + return 0; + } + if k == 0 or k == n { + return 1; + } + + mut kk := k; + if k > n - k { + kk = n - k; + } + + mut result: u64 = 1; + mut i: u64 = 0; + for _ in 0..32 { + if i >= kk { + break; + } + result = result * (n - i); + result = result / (i + 1); + i = i + 1; + } + return result; +} + +// ============================================================================= +// Exponentiation +// ============================================================================= + +/// Integer power (exponentiation by squaring) +pub fn pow_u32(base: u32, exp: u32) -> u32 { + if exp == 0 { + return 1; + } + + mut result: u32 = 1; + mut b := base; + mut e := exp; + + for _ in 0..32 { + if e == 0 { + break; + } + if (e & 1) == 1 { + result = result * b; + } + e = e >> 1; + if e != 0 { + b = b * b; + } + } + return result; +} + +/// Integer power for u64 +pub fn pow_u64(base: u64, exp: u64) -> u64 { + if exp == 0 { + return 1; + } + + mut result: u64 = 1; + mut b := base; + mut e := exp; + + for _ in 0..64 { + if e == 0 { + break; + } + if (e & 1) == 1 { + result = result * b; + } + e = e >> 1; + if e != 0 { + b = b * b; + } + } + return result; +} + +// ============================================================================= +// Distance Functions +// ============================================================================= + +/// Manhattan distance in 2D +pub fn manhattan_i32(x1: i32, y1: i32, x2: i32, y2: i32) -> i32 { + return abs_i32(x2 - x1) + abs_i32(y2 - y1); +} + +/// Chebyshev distance in 2D (max of absolute differences) +pub fn chebyshev_i32(x1: i32, y1: i32, x2: i32, y2: i32) -> i32 { + dx := abs_i32(x2 - x1); + dy := abs_i32(y2 - y1); + if dx > dy { + return dx; + } + return dy; +} diff --git a/exp/zlup/std/math.zlup b/exp/zlup/std/math.zlup new file mode 100644 index 000000000..a6863c1ee --- /dev/null +++ b/exp/zlup/std/math.zlup @@ -0,0 +1,79 @@ +/// Mathematical constants and utilities for Zluppy. +/// Provides commonly used constants for quantum gate rotations. + +// ============================================================================= +// Fundamental Constants +// ============================================================================= + +/// Pi - the ratio of a circle's circumference to its diameter. +/// Commonly used in rotation gates: rz(pi/4), rx(pi/2), etc. +pub pi: a64 = 3.14159265358979323846; + +/// Tau - the ratio of a circle's circumference to its radius (2*pi). +/// Some prefer tau for full rotations: rz(tau/8) = rz(pi/4). +pub tau: a64 = 6.28318530717958647692; + +/// Euler's number - the base of natural logarithms. +/// Used in some advanced quantum algorithms. +pub e: a64 = 2.71828182845904523536; + +/// Square root of 2. +/// Used in normalization: 1/sqrt2 for Hadamard gate. +pub sqrt2: f64 = 1.41421356237309504880; + +/// Reciprocal of square root of 2. +/// Hadamard matrix elements: 1/sqrt(2). +pub inv_sqrt2: f64 = 0.70710678118654752440; + +// ============================================================================= +// Common Angle Fractions (in radians) +// ============================================================================= + +/// Pi divided by 2 (90 degrees). +/// T-gate squared, S-gate: rz(pi/2). +pub pi_2: a64 = 1.57079632679489661923; + +/// Pi divided by 4 (45 degrees). +/// T-gate angle: rz(pi/4). +pub pi_4: a64 = 0.78539816339744830962; + +/// Pi divided by 8 (22.5 degrees). +/// T-gate squared: rz(pi/8). +pub pi_8: a64 = 0.39269908169872415481; + +/// Pi divided by 3 (60 degrees). +pub pi_3: a64 = 1.04719755119659774615; + +/// Pi divided by 6 (30 degrees). +pub pi_6: a64 = 0.52359877559829887308; + +/// Pi divided by 16 (11.25 degrees). +pub pi_16: a64 = 0.19634954084936207740; + +/// 2*Pi divided by 3 (120 degrees). +pub two_pi_3: a64 = 2.09439510239319549230; + +// ============================================================================= +// Negative Angles (for convenience) +// ============================================================================= + +/// Negative pi/2 (-90 degrees). +pub neg_pi_2: a64 = -1.57079632679489661923; + +/// Negative pi/4 (-45 degrees). +pub neg_pi_4: a64 = -0.78539816339744830962; + +/// Negative pi/8 (-22.5 degrees). +pub neg_pi_8: a64 = -0.39269908169872415481; + +// ============================================================================= +// Degree Conversions +// ============================================================================= + +/// Degrees to radians conversion factor. +/// Usage: angle_rad := degrees * deg_to_rad +pub deg_to_rad: f64 = 0.01745329251994329577; + +/// Radians to degrees conversion factor. +/// Usage: angle_deg := radians * rad_to_deg +pub rad_to_deg: f64 = 57.29577951308232087680; diff --git a/exp/zlup/std/qec.zlup b/exp/zlup/std/qec.zlup new file mode 100644 index 000000000..03ef5d700 --- /dev/null +++ b/exp/zlup/std/qec.zlup @@ -0,0 +1,340 @@ +/// Quantum Error Correction utilities for Zluppy. +/// Data structures and algorithms commonly used in QEC decoders. + +/// Error set for QEC operations. +pub OverflowError := error { Overflow }; + +/// Union-Find (Disjoint Set Union) data structure. +/// Essential for MWPM (Minimum Weight Perfect Matching) decoders. +/// +/// Example: +/// ```zlup +/// uf: UnionFind(256) = .{}; +/// uf.union(0, 1); +/// uf.union(1, 2); +/// root := uf.find(2); // Returns same root as find(0) and find(1) +/// ``` +pub UnionFind := fn(comptime capacity: usize) -> type { + struct { + /// Parent pointers (parent[i] == i means i is a root) + parent: [capacity]usize = undefined, + /// Rank for union-by-rank optimization + rank: [capacity]usize = undefined, + /// Whether the structure has been initialized + initialized: bool = false, + + /// Initialize all elements as separate sets. + pub fn init(&mut self) -> unit { + inline for i in 0..capacity { + self.parent[i] = i; + self.rank[i] = 0; + } + self.initialized = true; + return unit; + } + + /// Find the representative (root) of the set containing x. + /// Uses path compression for efficiency. + pub fn find(&mut self, x: usize) -> usize { + if (self.parent[x] != x) { + self.parent[x] = self.find(self.parent[x]); + } + return self.parent[x]; + } + + /// Unite the sets containing x and y. + /// Uses union-by-rank for efficiency. + /// Returns true if the sets were different (and thus merged). + pub fn union(&mut self, x: usize, y: usize) -> bool { + root_x := self.find(x); + root_y := self.find(y); + + if (root_x == root_y) { + return false; // Already in same set + } + + // Union by rank + if (self.rank[root_x] < self.rank[root_y]) { + self.parent[root_x] = root_y; + } else if (self.rank[root_x] > self.rank[root_y]) { + self.parent[root_y] = root_x; + } else { + self.parent[root_y] = root_x; + self.rank[root_x] += 1; + } + return true; + } + + /// Check if x and y are in the same set. + pub fn connected(&mut self, x: usize, y: usize) -> bool { + return self.find(x) == self.find(y); + } + + /// Reset element x to its own set. + pub fn reset(&mut self, x: usize) -> unit { + self.parent[x] = x; + self.rank[x] = 0; + return unit; + } + + /// Reset all elements to separate sets. + pub fn reset_all(&mut self) -> unit { + self.init(); + return unit; + } + } +}; + +/// Syndrome storage for stabilizer codes. +/// Stores syndrome bits from measurement rounds. +/// +/// Example: +/// ```zlup +/// syndrome: SyndromeBuffer(16, 10) = .{}; // 16 ancillas, 10 rounds +/// syndrome.set(0, 0, true); // Ancilla 0, round 0 +/// if syndrome.get(0, 0) { ... } +/// ``` +pub SyndromeBuffer := fn(comptime num_ancillas: usize, comptime max_rounds: usize) -> type { + struct { + /// Syndrome data: data[round][ancilla] + data: [max_rounds][num_ancillas]bool = undefined, + /// Number of rounds recorded + rounds_recorded: usize = 0, + + /// Clear all syndrome data. + pub fn clear(&mut self) -> unit { + self.rounds_recorded = 0; + return unit; + } + + /// Set syndrome bit for given ancilla and round. + pub fn set(&mut self, ancilla: usize, round: usize, value: bool) -> unit { + self.data[round][ancilla] = value; + if (round >= self.rounds_recorded) { + self.rounds_recorded = round + 1; + } + return unit; + } + + /// Get syndrome bit for given ancilla and round. + pub fn get(&mut self, ancilla: usize, round: usize) -> bool { + return self.data[round][ancilla]; + } + + /// Record a full round of syndrome measurements. + pub fn record_round(&mut self, syndromes: [num_ancillas]bool) -> OverflowError!void { + if (self.rounds_recorded >= max_rounds) { + return error.Overflow; + } + self.data[self.rounds_recorded] = syndromes; + self.rounds_recorded += 1; + return unit; + } + + /// Get the number of rounds recorded. + pub fn num_rounds(&mut self) -> usize { + return self.rounds_recorded; + } + + /// Check if any syndrome was triggered in a round. + pub fn has_error(&mut self, round: usize) -> bool { + inline for ancilla in 0..num_ancillas { + if (self.data[round][ancilla]) { + return true; + } + } + return false; + } + + /// Count number of triggered syndromes in a round. + pub fn count_errors(&mut self, round: usize) -> usize { + mut count: usize = 0; + inline for ancilla in 0..num_ancillas { + if (self.data[round][ancilla]) { + count += 1; + } + } + return count; + } + } +}; + +/// Lookup table decoder for small codes. +/// Maps syndromes to corrections using a precomputed table. +/// +/// Example: +/// ```zlup +/// decoder: LookupDecoder(u8, u8, 256) = .{}; +/// decoder.add_entry(0b101, 0b001); // syndrome -> correction +/// if correction := decoder.decode(0b101) { ... } +/// ``` +pub LookupDecoder := fn(comptime SyndromeType: type, comptime CorrectionType: type, comptime table_size: usize) -> type { + struct { + /// Syndrome values + syndromes: [table_size]SyndromeType = undefined, + /// Corresponding corrections + corrections: [table_size]CorrectionType = undefined, + /// Number of entries in the table + num_entries: usize = 0, + + /// Add an entry to the lookup table. + pub fn add_entry(&mut self, syndrome: SyndromeType, correction: CorrectionType) -> OverflowError!void { + if (self.num_entries >= table_size) { + return error.Overflow; + } + self.syndromes[self.num_entries] = syndrome; + self.corrections[self.num_entries] = correction; + self.num_entries += 1; + return unit; + } + + /// Look up the correction for a syndrome. + /// Returns none if the syndrome is not in the table. + pub fn decode(&mut self, syndrome: SyndromeType) -> ?CorrectionType { + // Linear search with bounded iteration (table_size is comptime) + inline for i in 0..table_size { + if (i >= self.num_entries) { + break; + } + if (self.syndromes[i] == syndrome) { + return self.corrections[i]; + } + } + return none; + } + + /// Clear all entries. + pub fn clear(&mut self) -> unit { + self.num_entries = 0; + return unit; + } + } +}; + +/// Pauli frame tracker for tracking Pauli corrections. +/// Used in fault-tolerant protocols with deferred corrections. +/// +/// Example: +/// ```zlup +/// frame: PauliFrame(16) = .{}; // 16 logical qubits +/// frame.apply_x(0); // Track X correction on qubit 0 +/// frame.apply_z(0); // Track Z correction on qubit 0 +/// if frame.has_x(0) { ... } +/// ``` +pub PauliFrame := fn(comptime num_qubits: usize) -> type { + struct { + /// X corrections (bit i = 1 means X correction pending on qubit i) + x_frame: [num_qubits]bool = undefined, + /// Z corrections (bit i = 1 means Z correction pending on qubit i) + z_frame: [num_qubits]bool = undefined, + + /// Initialize with no corrections. + pub fn init(&mut self) -> unit { + inline for i in 0..num_qubits { + self.x_frame[i] = false; + self.z_frame[i] = false; + } + return unit; + } + + /// Apply X correction to qubit i (toggles the X frame). + pub fn apply_x(&mut self, i: usize) -> unit { + self.x_frame[i] = !self.x_frame[i]; + return unit; + } + + /// Apply Z correction to qubit i (toggles the Z frame). + pub fn apply_z(&mut self, i: usize) -> unit { + self.z_frame[i] = !self.z_frame[i]; + return unit; + } + + /// Apply Y correction to qubit i (toggles both X and Z frames). + pub fn apply_y(&mut self, i: usize) -> unit { + self.x_frame[i] = !self.x_frame[i]; + self.z_frame[i] = !self.z_frame[i]; + return unit; + } + + /// Check if X correction is pending on qubit i. + pub fn has_x(&mut self, i: usize) -> bool { + return self.x_frame[i]; + } + + /// Check if Z correction is pending on qubit i. + pub fn has_z(&mut self, i: usize) -> bool { + return self.z_frame[i]; + } + + /// Clear all corrections. + pub fn clear(&mut self) -> unit { + self.init(); + return unit; + } + + /// Get the total weight of pending corrections. + pub fn weight(&mut self) -> usize { + mut w: usize = 0; + inline for i in 0..num_qubits { + if (self.x_frame[i]) { + w += 1; + } + if (self.z_frame[i]) { + w += 1; + } + } + return w; + } + } +}; + +/// Sparse graph representation for decoder graphs. +/// Useful for representing syndrome graphs in MWPM decoders. +/// +/// Example: +/// ```zlup +/// graph: SparseGraph(64, 256) = .{}; // 64 nodes, 256 edges max +/// try graph.add_edge(0, 1, 10); // Edge from 0 to 1 with weight 10 +/// ``` +/// Edge structure for sparse graphs. +pub Edge := struct { + from: usize, + to: usize, + weight: i32, +}; + +pub SparseGraph := fn(comptime max_nodes: usize, comptime max_edges: usize) -> type { + struct { + /// Edge list + edges: [max_edges]Edge = undefined, + /// Number of edges + num_edges: usize = 0, + /// Adjacency list start indices for each node + adj_start: [max_nodes + 1]usize = undefined, + /// Whether adjacency list is built + adj_built: bool = false, + + /// Add an edge to the graph. + pub fn add_edge(&mut self, from_node: usize, to_node: usize, weight: i32) -> OverflowError!void { + if (self.num_edges >= max_edges) { + return error.Overflow; + } + self.edges[self.num_edges] = .{ from: from_node, to: to_node, weight: weight }; + self.num_edges += 1; + self.adj_built = false; + return unit; + } + + /// Get the number of edges. + pub fn edge_count(&mut self) -> usize { + return self.num_edges; + } + + /// Clear all edges. + pub fn clear(&mut self) -> unit { + self.num_edges = 0; + self.adj_built = false; + return unit; + } + } +}; diff --git a/exp/zlup/std/qec/decoder.zlp b/exp/zlup/std/qec/decoder.zlp new file mode 100644 index 000000000..747eb3065 --- /dev/null +++ b/exp/zlup/std/qec/decoder.zlp @@ -0,0 +1,151 @@ +/// Standard library: Simple decoders for small codes +/// +/// For production decoders (MWPM, Union-Find, ML), use Rust via FFI. +/// These are for small codes, testing, and educational purposes. + +// ============================================================================= +// 3-Qubit Bit-Flip Code +// ============================================================================= + +/// Decode 3-qubit bit-flip code +/// Syndrome (2 bits) -> which qubit to flip (0, 1, 2, or 3 for none) +pub fn bit_flip_3_decode(syndrome: u8) -> u8 { + s := syndrome & 3; + if s == 0 { + return 3; // No error + } + if s == 1 { + return 2; // Flip qubit 2 + } + if s == 2 { + return 0; // Flip qubit 0 + } + return 1; // s == 3: Flip qubit 1 +} + +/// Get correction bitmask for 3-qubit bit-flip code +/// Returns a u8 with the bit set for the qubit to correct +pub fn bit_flip_3_correction(syndrome: u8) -> u8 { + s := syndrome & 3; + if s == 0 { + return 0; // No correction + } + if s == 1 { + return 4; // Correct qubit 2 (bit 2) + } + if s == 2 { + return 1; // Correct qubit 0 (bit 0) + } + return 2; // s == 3: Correct qubit 1 (bit 1) +} + +// ============================================================================= +// Majority Vote +// ============================================================================= + +/// Majority vote decoder +/// Returns 1 if more than half the bits in syndrome are set +pub fn majority_vote(syndrome: u64, num_bits: u64) -> u64 { + mut count: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if s == 0 { + break; + } + if (s & 1) == 1 { + count = count + 1; + } + s = s >> 1; + } + + threshold := (num_bits / 2) + 1; + if count >= threshold { + return 1; + } + return 0; +} + +// ============================================================================= +// Parity Check +// ============================================================================= + +/// Compute single syndrome bit from error and parity check +/// check is a bitmask indicating which qubits participate +pub fn compute_check(err: u64, check: u64) -> u64 { + overlap := err & check; + // Compute parity + mut p := overlap; + p = p ^ (p >> 32); + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Compute full syndrome from error using check matrix +/// checks is an array of bitmasks (one per syndrome bit) +/// This version handles up to 8 checks +pub fn compute_syndrome_8(err: u64, c0: u64, c1: u64, c2: u64, c3: u64, c4: u64, c5: u64, c6: u64, c7: u64, num_checks: u64) -> u64 { + mut syndrome: u64 = 0; + + if num_checks > 0 { + syndrome = syndrome | (compute_check(err, c0) << 0); + } + if num_checks > 1 { + syndrome = syndrome | (compute_check(err, c1) << 1); + } + if num_checks > 2 { + syndrome = syndrome | (compute_check(err, c2) << 2); + } + if num_checks > 3 { + syndrome = syndrome | (compute_check(err, c3) << 3); + } + if num_checks > 4 { + syndrome = syndrome | (compute_check(err, c4) << 4); + } + if num_checks > 5 { + syndrome = syndrome | (compute_check(err, c5) << 5); + } + if num_checks > 6 { + syndrome = syndrome | (compute_check(err, c6) << 6); + } + if num_checks > 7 { + syndrome = syndrome | (compute_check(err, c7) << 7); + } + + return syndrome; +} + +// ============================================================================= +// Simple Lookup (8-bit syndrome) +// ============================================================================= + +/// Lookup table decode for 4-entry table (2-bit syndrome) +pub fn lookup_2bit(syndrome: u8, t0: u64, t1: u64, t2: u64, t3: u64) -> u64 { + s := syndrome & 3; + if s == 0 { + return t0; + } + if s == 1 { + return t1; + } + if s == 2 { + return t2; + } + return t3; +} + +/// Lookup table decode for 8-entry table (3-bit syndrome) +pub fn lookup_3bit(syndrome: u8, t0: u64, t1: u64, t2: u64, t3: u64, t4: u64, t5: u64, t6: u64, t7: u64) -> u64 { + s := syndrome & 7; + if s == 0 { return t0; } + if s == 1 { return t1; } + if s == 2 { return t2; } + if s == 3 { return t3; } + if s == 4 { return t4; } + if s == 5 { return t5; } + if s == 6 { return t6; } + return t7; +} diff --git a/exp/zlup/std/qec/errors.zlp b/exp/zlup/std/qec/errors.zlp new file mode 100644 index 000000000..fedfafd74 --- /dev/null +++ b/exp/zlup/std/qec/errors.zlp @@ -0,0 +1,210 @@ +/// Standard library: Error tracking utilities for QEC +/// +/// Utilities for tracking errors, counting faults, and managing error budgets. +/// All operations are explicit and bounded (no hidden allocations). + +// ============================================================================= +// Error Counters +// ============================================================================= + +/// Count errors in a syndrome history (packed u64 array style) +/// Each bit position tracks one stabilizer over time +/// Returns count of how many times syndrome bit was set +pub fn count_flips_at_position(history: u64, pos: u64) -> u64 { + if pos >= 64 { + return 0; + } + return (history >> pos) & 1; +} + +/// Count total number of error events (defects) across all positions +pub fn total_defects(syndrome: u64) -> u64 { + mut count: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if s == 0 { + break; + } + count = count + (s & 1); + s = s >> 1; + } + return count; +} + +/// Check if error count exceeds threshold +pub fn exceeds_threshold(error_count: u64, threshold: u64) -> bool { + return error_count > threshold; +} + +// ============================================================================= +// Error Rate Estimation +// ============================================================================= + +/// Estimate error rate from counts (returns permil - per 1000) +/// Avoids floating point: returns (errors * 1000) / total +pub fn error_rate_permil(errors: u64, total: u64) -> u64 { + if total == 0 { + return 0; + } + return (errors * 1000) / total; +} + +/// Estimate error rate (returns percent * 100 for precision) +/// Example: 3.5% returns 350 +pub fn error_rate_percent_x100(errors: u64, total: u64) -> u64 { + if total == 0 { + return 0; + } + return (errors * 10000) / total; +} + +// ============================================================================= +// Error Budget Tracking +// ============================================================================= + +/// Check if within error budget +/// budget_permil is the acceptable error rate in permil (e.g., 1 = 0.1%) +pub fn within_budget(errors: u64, total: u64, budget_permil: u64) -> bool { + if total == 0 { + return true; + } + actual := (errors * 1000) / total; + return actual <= budget_permil; +} + +/// Calculate remaining error budget before threshold +/// Returns how many more errors can occur before exceeding budget_permil +pub fn remaining_budget(errors: u64, total: u64, budget_permil: u64) -> u64 { + if total == 0 { + return 0; + } + // max_errors = (budget_permil * total) / 1000 + max_errors := (budget_permil * total) / 1000; + if errors >= max_errors { + return 0; + } + return max_errors - errors; +} + +// ============================================================================= +// Syndrome Difference Tracking +// ============================================================================= + +/// Compute syndrome change between two rounds +pub fn syndrome_delta(prev: u64, curr: u64) -> u64 { + return prev ^ curr; +} + +/// Count number of stabilizers that flipped between rounds +pub fn count_flipped(prev: u64, curr: u64) -> u64 { + delta := prev ^ curr; + mut count: u64 = 0; + mut d := delta; + for _ in 0..64 { + if d == 0 { + break; + } + count = count + (d & 1); + d = d >> 1; + } + return count; +} + +/// Check if syndrome is stable (no change) +pub fn is_stable(prev: u64, curr: u64) -> bool { + return prev == curr; +} + +// ============================================================================= +// Sliding Window Statistics +// ============================================================================= + +/// Update sliding window error count (simple, uses XOR history) +/// Returns new window with oldest bit removed and newest added +/// window: current window state (each bit = error in that round) +/// newest: 1 if error in current round, 0 otherwise +/// window_size: size of window (bits to track) +pub fn slide_window(window: u64, newest: u64, window_size: u64) -> u64 { + if window_size == 0 or window_size > 64 { + return window; + } + // Shift left to make room for new bit, mask to window size + one: u64 = 1; + mask := (one << window_size) - 1; + return ((window << 1) | (newest & 1)) & mask; +} + +/// Count errors in sliding window +pub fn window_error_count(window: u64) -> u64 { + mut count: u64 = 0; + mut w := window; + for _ in 0..64 { + if w == 0 { + break; + } + count = count + (w & 1); + w = w >> 1; + } + return count; +} + +// ============================================================================= +// Error Classification +// ============================================================================= + +/// Classify error weight (for debugging/analysis) +/// Returns: 0=none, 1=single, 2=double, 3=triple+ +pub fn classify_weight(syndrome: u64) -> u64 { + count := total_defects(syndrome); + if count == 0 { + return 0; + } + if count == 1 { + return 1; + } + if count == 2 { + return 2; + } + return 3; +} + +/// Check if error is likely correctable (weight <= t for distance 2t+1 code) +pub fn is_likely_correctable(syndrome: u64, t: u64) -> bool { + weight := total_defects(syndrome); + return weight <= t; +} + +// ============================================================================= +// Fault Flags +// ============================================================================= + +/// Pack multiple fault flags into a single u64 +/// Each bit represents one type of fault condition +pub FLAG_NONE: u64 = 0; +pub FLAG_X_ERROR: u64 = 1; +pub FLAG_Z_ERROR: u64 = 2; +pub FLAG_Y_ERROR: u64 = 3; // X | Z +pub FLAG_MEASUREMENT_ERROR: u64 = 4; +pub FLAG_LEAKAGE: u64 = 8; +pub FLAG_TIMING_ERROR: u64 = 16; +pub FLAG_THRESHOLD_EXCEEDED: u64 = 32; + +/// Set a fault flag +pub fn set_flag(flags: u64, flag: u64) -> u64 { + return flags | flag; +} + +/// Clear a fault flag +pub fn clear_flag(flags: u64, flag: u64) -> u64 { + return flags & (~flag); +} + +/// Check if flag is set +pub fn has_flag(flags: u64, flag: u64) -> bool { + return (flags & flag) != 0; +} + +/// Check if any error flag is set (X, Z, or Y) +pub fn has_pauli_error(flags: u64) -> bool { + return (flags & FLAG_Y_ERROR) != 0; +} diff --git a/exp/zlup/std/qec/pauli.zlp b/exp/zlup/std/qec/pauli.zlp new file mode 100644 index 000000000..0dc4e934e --- /dev/null +++ b/exp/zlup/std/qec/pauli.zlp @@ -0,0 +1,175 @@ +/// Standard library: Pauli frame tracking for QEC +/// +/// Pauli frames are represented as two u64 bitmasks (x_frame, z_frame) +/// supporting up to 64 qidxs. + +// ============================================================================= +// Pauli Frame Operations +// ============================================================================= + +/// Apply X correction to frame (toggle bit in x_frame) +pub fn frame_apply_x(x_frame: u64, qidx: u64) -> u64 { + if qidx >= 64 { + return x_frame; + } + mask: u64 = 1 << qidx; + return x_frame ^ mask; +} + +/// Apply Z correction to frame (toggle bit in z_frame) +pub fn frame_apply_z(z_frame: u64, qidx: u64) -> u64 { + if qidx >= 64 { + return z_frame; + } + mask: u64 = 1 << qidx; + return z_frame ^ mask; +} + +/// Check if qidx has X correction +pub fn frame_has_x(x_frame: u64, qidx: u64) -> bool { + if qidx >= 64 { + return false; + } + return ((x_frame >> qidx) & 1) == 1; +} + +/// Check if qidx has Z correction +pub fn frame_has_z(z_frame: u64, qidx: u64) -> bool { + if qidx >= 64 { + return false; + } + return ((z_frame >> qidx) & 1) == 1; +} + +// ============================================================================= +// Gate Propagation (returns new frame values) +// ============================================================================= + +/// Propagate X frame through CNOT: X on control propagates to target +/// Returns the new X frame +pub fn propagate_cx_x(x_frame: u64, control: u64, target: u64) -> u64 { + if control >= 64 or target >= 64 { + return x_frame; + } + // X on control propagates to target + if ((x_frame >> control) & 1) == 1 { + mask: u64 = 1 << target; + return x_frame ^ mask; + } + return x_frame; +} + +/// Propagate Z frame through CNOT: Z on target propagates to control +/// Returns the new Z frame +pub fn propagate_cx_z(z_frame: u64, control: u64, target: u64) -> u64 { + if control >= 64 or target >= 64 { + return z_frame; + } + // Z on target propagates to control + if ((z_frame >> target) & 1) == 1 { + mask: u64 = 1 << control; + return z_frame ^ mask; + } + return z_frame; +} + +/// Propagate X frame through CZ: X on qa propagates Z to qb +/// Returns the new Z frame (X frame unchanged by CZ) +pub fn propagate_cz_to_z(x_frame: u64, z_frame: u64, qa: u64, qb: u64) -> u64 { + if qa >= 64 or qb >= 64 { + return z_frame; + } + mut new_z := z_frame; + // X on a propagates Z to b + if ((x_frame >> qa) & 1) == 1 { + mask: u64 = 1 << qb; + new_z = new_z ^ mask; + } + // X on b propagates Z to a + if ((x_frame >> qb) & 1) == 1 { + mask: u64 = 1 << qa; + new_z = new_z ^ mask; + } + return new_z; +} + +/// Swap X and Z for a qidx (Hadamard propagation) +/// Returns (new_x_frame, new_z_frame) packed as (x << 64) | z... +/// Actually, let's return just the swapped values for one qidx +pub fn propagate_h_x(x_frame: u64, z_frame: u64, qidx: u64) -> u64 { + // After H, new X = old Z + if qidx >= 64 { + return x_frame; + } + has_z := ((z_frame >> qidx) & 1) == 1; + mask: u64 = 1 << qidx; + // Clear old X bit + mut new_x := x_frame & (~mask); + // Set from old Z + if has_z { + new_x = new_x | mask; + } + return new_x; +} + +pub fn propagate_h_z(x_frame: u64, z_frame: u64, qidx: u64) -> u64 { + // After H, new Z = old X + if qidx >= 64 { + return z_frame; + } + has_x := ((x_frame >> qidx) & 1) == 1; + mask: u64 = 1 << qidx; + // Clear old Z bit + mut new_z := z_frame & (~mask); + // Set from old X + if has_x { + new_z = new_z | mask; + } + return new_z; +} + +/// S gate propagation: X gains Z +pub fn propagate_s_z(x_frame: u64, z_frame: u64, qidx: u64) -> u64 { + if qidx >= 64 { + return z_frame; + } + // X on qidx gains Z + if ((x_frame >> qidx) & 1) == 1 { + mask: u64 = 1 << qidx; + return z_frame ^ mask; + } + return z_frame; +} + +// ============================================================================= +// Pauli Multiplication +// ============================================================================= + +/// Pauli operators: 0=I, 1=X, 2=Y, 3=Z +pub PAULI_I: u8 = 0; +pub PAULI_X: u8 = 1; +pub PAULI_Y: u8 = 2; +pub PAULI_Z: u8 = 3; + +/// Multiply two Pauli operators (ignoring phase) +pub fn pauli_mul(a: u8, b: u8) -> u8 { + if a == 0 { + return b; + } + if b == 0 { + return a; + } + if a == b { + return 0; + } + // Different non-identity: result is the third + return 6 - a - b; +} + +/// Check if two Paulis commute +pub fn pauli_commute(a: u8, b: u8) -> bool { + if a == 0 or b == 0 { + return true; + } + return a == b; +} diff --git a/exp/zlup/std/qec/syndrome.zlp b/exp/zlup/std/qec/syndrome.zlp new file mode 100644 index 000000000..6697ee028 --- /dev/null +++ b/exp/zlup/std/qec/syndrome.zlp @@ -0,0 +1,138 @@ +/// Standard library: Syndrome utilities for QEC +/// +/// Provides functions for syndrome processing and analysis. + +// ============================================================================= +// Syndrome Weight and Parity +// ============================================================================= + +/// Count the number of defects (set bits) in a syndrome +pub fn syndrome_weight(syndrome: u64) -> u64 { + mut count: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if s == 0 { + break; + } + if (s & 1) == 1 { + count = count + 1; + } + s = s >> 1; + } + return count; +} + +/// Compute the XOR of two syndromes (difference) +pub fn syndrome_diff(a: u64, b: u64) -> u64 { + return a ^ b; +} + +/// Check if syndrome is trivial (no errors) +pub fn is_trivial(syndrome: u64) -> bool { + return syndrome == 0; +} + +/// Compute parity of syndrome +pub fn syndrome_parity(syndrome: u64) -> u64 { + mut p := syndrome; + p = p ^ (p >> 32); + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +// ============================================================================= +// Defect Position Queries +// ============================================================================= + +/// Find position of first defect (lowest set bit) +/// Returns 64 if no defects +pub fn first_defect(syndrome: u64) -> u64 { + if syndrome == 0 { + return 64; + } + mut pos: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if (s & 1) == 1 { + return pos; + } + s = s >> 1; + pos = pos + 1; + } + return 64; +} + +/// Find position of last defect (highest set bit) +/// Returns 64 if no defects +pub fn last_defect(syndrome: u64) -> u64 { + if syndrome == 0 { + return 64; + } + mut last: u64 = 64; + mut pos: u64 = 0; + mut s := syndrome; + for _ in 0..64 { + if s == 0 { + break; + } + if (s & 1) == 1 { + last = pos; + } + s = s >> 1; + pos = pos + 1; + } + return last; +} + +// ============================================================================= +// Syndrome Bit Manipulation +// ============================================================================= + +/// Clear the first defect (lowest set bit) +pub fn clear_first(syndrome: u64) -> u64 { + return syndrome & (syndrome - 1); +} + +/// Isolate the first defect (return only lowest set bit) +pub fn isolate_first(syndrome: u64) -> u64 { + return syndrome & (~syndrome + 1); +} + +/// Get bit at position +pub fn get_bit(syndrome: u64, pos: u64) -> u64 { + if pos >= 64 { + return 0; + } + return (syndrome >> pos) & 1; +} + +/// Set bit at position +pub fn set_bit(syndrome: u64, pos: u64) -> u64 { + if pos >= 64 { + return syndrome; + } + mask: u64 = 1 << pos; + return syndrome | mask; +} + +/// Clear bit at position +pub fn clear_bit(syndrome: u64, pos: u64) -> u64 { + if pos >= 64 { + return syndrome; + } + mask: u64 = 1 << pos; + return syndrome & (~mask); +} + +/// Toggle bit at position +pub fn toggle_bit(syndrome: u64, pos: u64) -> u64 { + if pos >= 64 { + return syndrome; + } + mask: u64 = 1 << pos; + return syndrome ^ mask; +} diff --git a/exp/zlup/std/std.zlp b/exp/zlup/std/std.zlp new file mode 100644 index 000000000..719fc2915 --- /dev/null +++ b/exp/zlup/std/std.zlp @@ -0,0 +1,88 @@ +/// Zlup Standard Library +/// +/// Simple, explicit utilities for quantum programming. +/// No magic, no hidden allocations, bounded everything. +/// +/// Usage: +/// std := @import("std"); // imports this file (std/std.zlp) +/// bits := @import("bits.zlp"); // relative import within std/ +/// +/// Type-namespaced constants (preferred): +/// std.f64.pi // pi as f64 (3.14159...) +/// std.a64.quarter_turn // 1/4 turn as a64 angle +/// std.a64.t_angle // T-gate angle (1/8 turn) +/// +/// Available modules: +/// std/f64.zlp - f64 constants (pi, tau, e, sqrt2, etc.) +/// std/a64.zlp - a64 angle constants (quarter_turn, t_angle, etc.) +/// std/bits.zlp - bit operations (popcount, parity, shifts) +/// std/math.zlp - math functions (gcd, lcm, isqrt, log2, factorial, etc.) +/// std/algorithm.zlp - algorithms (search, sort, min/max, hamming distance) +/// std/qec/ - quantum error correction +/// pauli.zlp - Pauli frame tracking +/// syndrome.zlp - syndrome utilities +/// decoder.zlp - simple decoders for small codes +/// errors.zlp - error tracking and statistics + +// ============================================================================= +// Type-Namespaced Modules +// ============================================================================= + +/// f64 constants: std.f64.pi, std.f64.tau, std.f64.e, etc. +pub f64 := @import("f64.zlp"); + +/// a64 angle constants: std.a64.quarter_turn, std.a64.t_angle, etc. +pub a64 := @import("a64.zlp"); + +// ============================================================================= +// Bit Operations (core subset inlined for convenience) +// ============================================================================= + +/// Count set bits in u8 +pub fn popcount_u8(x: u8) -> u8 { + mut count: u8 = 0; + mut n := x; + for _ in 0..8 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Count set bits in u64 +pub fn popcount_u64(x: u64) -> u64 { + mut count: u64 = 0; + mut n := x; + for _ in 0..64 { + if n == 0 { + break; + } + n = n & (n - 1); + count = count + 1; + } + return count; +} + +/// Parity of u8 +pub fn parity_u8(x: u8) -> u8 { + mut p := x; + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} + +/// Parity of u64 +pub fn parity_u64(x: u64) -> u64 { + mut p := x; + p = p ^ (p >> 32); + p = p ^ (p >> 16); + p = p ^ (p >> 8); + p = p ^ (p >> 4); + p = p ^ (p >> 2); + p = p ^ (p >> 1); + return p & 1; +} diff --git a/exp/zlup/std/std.zlup b/exp/zlup/std/std.zlup new file mode 100644 index 000000000..28f53ad2c --- /dev/null +++ b/exp/zlup/std/std.zlup @@ -0,0 +1,51 @@ +/// Zluppy Standard Library +/// +/// The standard library provides common data structures and utilities +/// for quantum programming with NASA Power of 10 compliance. +/// +/// All containers have bounded capacity specified at compile time. +/// +/// Modules: +/// - math: Mathematical constants (pi, tau, e, sqrt2, angle fractions) +/// - bits: Bitwise utilities (popcount, parity, bit extraction) +/// - containers: Stack, Queue, Deque, PriorityQueue +/// - qec: UnionFind, SyndromeBuffer, LookupDecoder, PauliFrame, SparseGraph + +pub math := @import("math.zlup"); +pub bits := @import("bits.zlup"); +pub containers := @import("containers.zlup"); +pub qec := @import("qec.zlup"); + +// Re-export mathematical constants for convenience +pub pi := math.pi; +pub tau := math.tau; +pub e := math.e; +pub sqrt2 := math.sqrt2; +pub inv_sqrt2 := math.inv_sqrt2; +pub pi_2 := math.pi_2; +pub pi_4 := math.pi_4; +pub pi_8 := math.pi_8; +pub deg_to_rad := math.deg_to_rad; +pub rad_to_deg := math.rad_to_deg; + +// Re-export bitwise utilities for convenience +pub popcount_u8 := bits.popcount_u8; +pub popcount_u16 := bits.popcount_u16; +pub popcount_u32 := bits.popcount_u32; +pub popcount_u64 := bits.popcount_u64; +pub parity_u8 := bits.parity_u8; +pub parity_u16 := bits.parity_u16; +pub parity_u32 := bits.parity_u32; +pub parity_u64 := bits.parity_u64; + +// Re-export commonly used types for convenience +pub Stack := containers.Stack; +pub Queue := containers.Queue; +pub Deque := containers.Deque; +pub PriorityQueue := containers.PriorityQueue; + +pub UnionFind := qec.UnionFind; +pub SyndromeBuffer := qec.SyndromeBuffer; +pub LookupDecoder := qec.LookupDecoder; +pub PauliFrame := qec.PauliFrame; +pub SparseGraph := qec.SparseGraph; diff --git a/exp/zlup/tests/cli.rs b/exp/zlup/tests/cli.rs new file mode 100644 index 000000000..2d751c845 --- /dev/null +++ b/exp/zlup/tests/cli.rs @@ -0,0 +1,1037 @@ +//! CLI integration tests for Zlup. + +use std::fs; +use std::process::Command; +use std::path::PathBuf; + +/// Get the path to the zlup binary. +fn zlup_bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_zlup")) +} + +// ============================================================================= +// Help and Version +// ============================================================================= + +#[test] +fn test_help() { + let output = zlup_bin().arg("--help").output().expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Zluppy")); + assert!(stdout.contains("compile")); + assert!(stdout.contains("check")); + assert!(stdout.contains("parse")); + assert!(stdout.contains("analyze")); +} + +#[test] +fn test_version() { + let output = zlup_bin() + .arg("--version") + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("zlup")); +} + +#[test] +fn test_compile_help() { + let output = zlup_bin() + .args(["compile", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("target")); + assert!(stdout.contains("slr")); +} + +#[test] +fn test_check_help() { + let output = zlup_bin() + .args(["check", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("strict")); +} + +// ============================================================================= +// Compile Command +// ============================================================================= + +#[test] +fn test_compile_stdin_bell_state() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success(), "compile failed: {:?}", output); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Verify JSON structure + let json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON output"); + + assert_eq!(json["type"], "Program"); + assert_eq!(json["name"], "main"); + assert!(json["allocator"].is_object()); + assert_eq!(json["allocator"]["name"], "q"); + assert_eq!(json["allocator"]["capacity"], 2); + + // Check body has 2 gates + let body = json["body"].as_array().expect("body should be array"); + assert_eq!(body.len(), 2); + assert_eq!(body[0]["gate"], "H"); + assert_eq!(body[1]["gate"], "CX"); +} + +#[test] +fn test_compile_compact() { + let source = "fn main() -> unit { mut q := qalloc(1); h q[0]; return unit; }"; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "--compact", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Compact output should not have newlines (except possibly at end) + let trimmed = stdout.trim(); + assert!( + !trimmed.contains("\n "), + "compact output should not be pretty-printed" + ); + + // But should still be valid JSON + let _json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); +} + +#[test] +fn test_compile_rotation_gate() { + let source = r#"fn main() -> unit { + mut q := qalloc(1); + rz(1.57) q[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); + + let body = json["body"].as_array().expect("body should be array"); + assert_eq!(body.len(), 1); + assert_eq!(body[0]["gate"], "RZ"); + assert!(!body[0]["params"].as_array().unwrap().is_empty()); +} + +// ============================================================================= +// Check Command +// ============================================================================= + +#[test] +fn test_check_valid_program() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["check", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("OK")); +} + +#[test] +fn test_check_strict_mode() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["check", "-", "--strict"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + // In strict mode, gates on unprepared qubits fail + // But our current implementation doesn't track through function calls + // so this may or may not fail depending on implementation details + // For now, just verify it runs without crashing + let _ = output.status; +} + +// ============================================================================= +// Parse Command +// ============================================================================= + +#[test] +fn test_parse_debug_format() { + let source = "fn main() -> unit { mut q := qalloc(1); return unit; }"; + + let mut child = zlup_bin() + .args(["parse", "-", "--format", "debug"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Program")); + assert!(stdout.contains("FnDecl")); +} + +// ============================================================================= +// Error Handling +// ============================================================================= + +#[test] +fn test_compile_parse_error() { + let source = "fn main() -> unit { h q[0 }"; // Missing closing bracket + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(!output.status.success(), "should fail on parse error"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("parse error") || stderr.contains("expected"), + "should contain error message" + ); +} + +#[test] +fn test_check_parse_error() { + let source = "fn main( unit { }"; // Missing closing paren in params + + let mut child = zlup_bin() + .args(["check", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(!output.status.success(), "should fail on parse error"); +} + +#[test] +fn test_compile_file_not_found() { + let output = zlup_bin() + .args(["compile", "nonexistent_file_12345.zlp"]) + .output() + .expect("failed to run"); + + assert!(!output.status.success()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("read") || stderr.contains("No such file"), + "should report file not found" + ); +} + +// ============================================================================= +// Complex Programs +// ============================================================================= + +#[test] +fn test_compile_child_allocator() { + let source = r#"fn main() -> unit { + mut base := qalloc(4); + mut q := base.child(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); + + // Should have declarations for both allocators + let decls = json["declarations"].as_array().expect("declarations array"); + assert!(decls.len() >= 2, "should have at least 2 declarations"); +} + +#[test] +fn test_compile_conditional() { + let source = r#"fn main() -> unit { + mut q := qalloc(1); + x := 1; + if (x == 1) { + h q[0]; + } + return unit; +}"#; + + let mut child = zlup_bin() + .args(["compile", "-", "--format", "slr", "-o", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("invalid JSON"); + + // Should have if statement in body + let body = json["body"].as_array().expect("body array"); + let has_if = body.iter().any(|stmt| stmt["type"] == "IfStmt"); + assert!(has_if, "should have if statement in body"); +} + +// ============================================================================= +// Init Command +// ============================================================================= + +/// Create a unique temp directory for testing +fn temp_dir(test_name: &str) -> PathBuf { + use std::time::{SystemTime, UNIX_EPOCH}; + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let dir = std::env::temp_dir().join(format!("zlup-test-{}-{}-{}", + test_name, std::process::id(), timestamp)); + fs::create_dir_all(&dir).expect("failed to create temp dir"); + dir +} + +/// Clean up a temp directory +fn cleanup_temp_dir(dir: &PathBuf) { + if dir.exists() { + let _ = fs::remove_dir_all(dir); + } +} + +#[test] +fn test_init_help() { + let output = zlup_bin() + .args(["init", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Initialize")); + assert!(stdout.contains("NAME")); +} + +#[test] +fn test_init_creates_project() { + let temp = temp_dir("init_creates"); + let project_name = "test-quantum-app"; + let project_dir = temp.join(project_name); + + let output = zlup_bin() + .current_dir(&temp) + .args(["init", project_name]) + .output() + .expect("failed to run"); + + assert!(output.status.success(), "init failed: {:?}", String::from_utf8_lossy(&output.stderr)); + + // Check files were created + assert!(project_dir.exists(), "project dir should exist"); + assert!(project_dir.join("zlup.toml").exists(), "zlup.toml should exist"); + assert!(project_dir.join("main.zlp").exists(), "main.zlp should exist"); + + // Check zlup.toml content + let toml_content = fs::read_to_string(project_dir.join("zlup.toml")).expect("read zlup.toml"); + assert!(toml_content.contains("name = \"test-quantum-app\"")); + assert!(toml_content.contains("version = \"0.1.0\"")); + + // Check main.zlp content + let main_content = fs::read_to_string(project_dir.join("main.zlp")).expect("read main.zlp"); + assert!(main_content.contains("fn main()")); + assert!(main_content.contains("qalloc")); + + // Cleanup + cleanup_temp_dir(&temp); +} + +#[test] +fn test_init_project_exists_error() { + let temp = temp_dir("init_exists"); + let project_name = "existing-project"; + let project_dir = temp.join(project_name); + + // Create the directory first + fs::create_dir_all(&project_dir).expect("create dir"); + + let output = zlup_bin() + .current_dir(&temp) + .args(["init", project_name]) + .output() + .expect("failed to run"); + + assert!(!output.status.success(), "should fail when project exists"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("exists"), "should report project exists: {}", stderr); + + // Cleanup + cleanup_temp_dir(&temp); +} + +// ============================================================================= +// Build Command +// ============================================================================= + +#[test] +fn test_build_help() { + let output = zlup_bin() + .args(["build", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Build")); + assert!(stdout.contains("strict")); + assert!(stdout.contains("target")); +} + +#[test] +fn test_build_no_config_error() { + let temp = temp_dir("build_no_config"); + + let output = zlup_bin() + .current_dir(&temp) + .args(["build"]) + .output() + .expect("failed to run"); + + assert!(!output.status.success(), "should fail without zlup.toml"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("config") || stderr.contains("not found"), + "should report config not found: {}", stderr); + + // Cleanup + cleanup_temp_dir(&temp); +} + +#[test] +fn test_init_then_build() { + let temp = temp_dir("init_then_build"); + let project_name = "buildable-project"; + let project_dir = temp.join(project_name); + + // Initialize project + let output = zlup_bin() + .current_dir(&temp) + .args(["init", project_name]) + .output() + .expect("failed to run init"); + + assert!(output.status.success(), "init failed: {:?}", String::from_utf8_lossy(&output.stderr)); + + // Build project + let output = zlup_bin() + .current_dir(&project_dir) + .args(["build"]) + .output() + .expect("failed to run build"); + + assert!(output.status.success(), "build failed: {:?}", String::from_utf8_lossy(&output.stderr)); + + // Check output file was created + let output_file = project_dir.join("build").join("main.slr.json"); + assert!(output_file.exists(), "output file should exist at {:?}", output_file); + + // Verify it's valid JSON + let content = fs::read_to_string(&output_file).expect("read output"); + let json: serde_json::Value = serde_json::from_str(&content).expect("valid JSON"); + assert_eq!(json["type"], "Program"); + + // Cleanup + cleanup_temp_dir(&temp); +} + +#[test] +fn test_build_with_strict_override() { + let temp = temp_dir("build_strict"); + let project_name = "strict-project"; + let project_dir = temp.join(project_name); + + // Initialize project + let output = zlup_bin() + .current_dir(&temp) + .args(["init", project_name]) + .output() + .expect("failed to run init"); + + assert!(output.status.success(), "init failed: {:?}", String::from_utf8_lossy(&output.stderr)); + + // Build with strict override + let output = zlup_bin() + .current_dir(&project_dir) + .args(["build", "--strict", "true"]) + .output() + .expect("failed to run build"); + + // In strict mode, the build should show "strict" in the output + // Note: The actual success/failure depends on semantic analyzer's strict mode behavior + // which may flag qubit operations that aren't fully tracked + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("strict"), "should show strict mode in output: {}", stderr); + + // Cleanup + cleanup_temp_dir(&temp); +} + +// ============================================================================= +// Example Files Integration Tests +// ============================================================================= + +/// Get the examples directory path relative to the project root +fn examples_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples") +} + +/// Test that an example file parses and semantic checks correctly +fn test_example_checks(filename: &str) { + let example_path = examples_dir().join(filename); + assert!(example_path.exists(), "Example file should exist: {:?}", example_path); + + let output = zlup_bin() + .args(["check", example_path.to_str().unwrap()]) + .output() + .expect("failed to run check"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "Example {} should pass semantic check.\nStderr: {}", + filename, + stderr + ); +} + +/// Test that an example file compiles to SLR-AST +fn test_example_compiles_slr(filename: &str) { + let example_path = examples_dir().join(filename); + assert!(example_path.exists(), "Example file should exist: {:?}", example_path); + + let output = zlup_bin() + .args(["compile", example_path.to_str().unwrap(), "--format", "slr", "-o", "-"]) + .output() + .expect("failed to run compile"); + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "Example {} should compile to SLR.\nStderr: {}\nStdout: {}", + filename, + stderr, + stdout + ); + + // Verify output is valid JSON + let json_result: Result = serde_json::from_str(&stdout); + assert!( + json_result.is_ok(), + "Example {} SLR output should be valid JSON: {:?}", + filename, + json_result.err() + ); +} + +/// Test that an example file compiles to QASM +fn test_example_compiles_qasm(filename: &str) { + let example_path = examples_dir().join(filename); + assert!(example_path.exists(), "Example file should exist: {:?}", example_path); + + let output = zlup_bin() + .args(["compile", example_path.to_str().unwrap(), "--format", "qasm", "-o", "-"]) + .output() + .expect("failed to run compile"); + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + output.status.success(), + "Example {} should compile to QASM.\nStderr: {}\nStdout: {}", + filename, + stderr, + stdout + ); + + // Verify output contains QASM header + assert!( + stdout.contains("OPENQASM") || stdout.contains("qreg"), + "Example {} QASM output should contain QASM syntax", + filename + ); +} + +// Bell State Example +#[test] +fn test_example_bell_state_checks() { + test_example_checks("bell_state.zlp"); +} + +#[test] +fn test_example_bell_state_compiles_slr() { + test_example_compiles_slr("bell_state.zlp"); +} + +#[test] +fn test_example_bell_state_compiles_qasm() { + test_example_compiles_qasm("bell_state.zlp"); +} + +// GHZ State Example +#[test] +fn test_example_ghz_state_checks() { + test_example_checks("ghz_state.zlp"); +} + +#[test] +fn test_example_ghz_state_compiles_slr() { + test_example_compiles_slr("ghz_state.zlp"); +} + +#[test] +fn test_example_ghz_state_compiles_qasm() { + test_example_compiles_qasm("ghz_state.zlp"); +} + +// Grover 2-qubit Example +#[test] +fn test_example_grover_2qubit_checks() { + test_example_checks("grover_2qubit.zlp"); +} + +#[test] +fn test_example_grover_2qubit_compiles_slr() { + test_example_compiles_slr("grover_2qubit.zlp"); +} + +#[test] +fn test_example_grover_2qubit_compiles_qasm() { + test_example_compiles_qasm("grover_2qubit.zlp"); +} + +// QFT 3-qubit Example +#[test] +fn test_example_qft_3qubit_checks() { + test_example_checks("qft_3qubit.zlp"); +} + +#[test] +fn test_example_qft_3qubit_compiles_slr() { + test_example_compiles_slr("qft_3qubit.zlp"); +} + +#[test] +fn test_example_qft_3qubit_compiles_qasm() { + test_example_compiles_qasm("qft_3qubit.zlp"); +} + +// Simple QEC Example +#[test] +fn test_example_simple_qec_checks() { + test_example_checks("simple_qec.zlp"); +} + +#[test] +fn test_example_simple_qec_compiles_slr() { + test_example_compiles_slr("simple_qec.zlp"); +} + +#[test] +fn test_example_simple_qec_compiles_qasm() { + test_example_compiles_qasm("simple_qec.zlp"); +} + +// Teleportation Example +#[test] +fn test_example_teleportation_checks() { + test_example_checks("teleportation.zlp"); +} + +#[test] +fn test_example_teleportation_compiles_slr() { + test_example_compiles_slr("teleportation.zlp"); +} + +#[test] +fn test_example_teleportation_compiles_qasm() { + test_example_compiles_qasm("teleportation.zlp"); +} + +// ============================================================================= +// Analyze Command +// ============================================================================= + +#[test] +fn test_analyze_help() { + let output = zlup_bin() + .args(["analyze", "--help"]) + .output() + .expect("failed to run"); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("parallelism")); + assert!(stdout.contains("format")); + assert!(stdout.contains("verbose")); +} + +#[test] +fn test_analyze_stdin_text() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success(), "analyze failed: {:?}", String::from_utf8_lossy(&output.stderr)); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Parallelism Analysis"), "Missing header"); + assert!(stdout.contains("Allocators:"), "Missing allocators section"); + assert!(stdout.contains("q"), "Missing allocator q"); + assert!(stdout.contains("Function Analysis:"), "Missing function analysis"); + assert!(stdout.contains("main"), "Missing main function"); +} + +#[test] +fn test_analyze_stdin_json() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-", "--format", "json"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success(), "analyze failed: {:?}", String::from_utf8_lossy(&output.stderr)); + + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse as JSON to verify structure + let json: serde_json::Value = serde_json::from_str(&stdout) + .expect("Output should be valid JSON"); + + assert!(json["allocators"].is_array(), "Should have allocators array"); + assert!(json["functions"].is_array(), "Should have functions array"); + assert!(json["parallel_layers"].is_array(), "Should have parallel_layers array"); + assert!(json["total_operations"].is_number(), "Should have total_operations"); +} + +#[test] +fn test_analyze_verbose() { + let source = r#"fn main() -> unit { + mut q := qalloc(2); + h q[0]; + cx (q[0], q[1]); + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-", "--verbose"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success(), "analyze failed: {:?}", String::from_utf8_lossy(&output.stderr)); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("Dependency Graph"), "Verbose should include dependency graph"); + assert!(stdout.contains("Parallel Layers"), "Verbose should include parallel layers"); +} + +#[test] +fn test_analyze_disjoint_allocators() { + // Two independent allocators should show parallelism + let source = r#"fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + h q1[0]; + h q2[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-", "--format", "json"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + + // Should have 2 allocators + let allocators = json["allocators"].as_array().expect("allocators array"); + assert_eq!(allocators.len(), 2, "Should have 2 allocators"); + + // Check max parallelism > 1 (H gates on different allocators can run in parallel) + let functions = json["functions"].as_array().expect("functions array"); + let main_func = &functions[0]; + let max_parallelism = main_func["max_parallelism"].as_u64().expect("max_parallelism"); + assert!(max_parallelism >= 2, "Disjoint allocators should enable parallelism"); +} + +#[test] +fn test_analyze_parse_error() { + // Invalid syntax should fail + let source = "fn main( { }"; // Missing closing paren and return type + + let mut child = zlup_bin() + .args(["analyze", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + assert!(!output.status.success(), "Should fail on parse error"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("parse") || stderr.contains("error") || stderr.contains("expected"), + "Should report parse error: {}", + stderr + ); +} + +#[test] +fn test_analyze_file_not_found() { + let output = zlup_bin() + .args(["analyze", "nonexistent_file_12345.zlp"]) + .output() + .expect("failed to run"); + + assert!(!output.status.success(), "Should fail on missing file"); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("read") || stderr.contains("No such file") || stderr.contains("not found"), + "Should report file not found: {}", + stderr + ); +} + +#[test] +fn test_analyze_semantic_error() { + // Reference undefined variable + let source = r#"fn main() -> unit { + h undefined_var[0]; + return unit; +}"#; + + let mut child = zlup_bin() + .args(["analyze", "-"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .expect("failed to spawn"); + + { + use std::io::Write; + let stdin = child.stdin.as_mut().expect("failed to get stdin"); + stdin.write_all(source.as_bytes()).expect("failed to write"); + } + + let output = child.wait_with_output().expect("failed to wait"); + // Note: semantic errors in permissive mode may still allow analysis + // This test verifies the command handles the input without crashing + let _ = output.status; // May or may not succeed depending on strictness +} diff --git a/exp/zlup/tests/proptest.rs b/exp/zlup/tests/proptest.rs new file mode 100644 index 000000000..24cdb60ce --- /dev/null +++ b/exp/zlup/tests/proptest.rs @@ -0,0 +1,3120 @@ +//! Property-based tests for Zlup using proptest. +//! +//! These tests verify invariants that should hold for all inputs: +//! - Parser never panics on any UTF-8 input +//! - Semantic analyzer never panics on any valid AST +//! - Type system invariants are maintained +//! - BitWidth constraints are enforced + +use proptest::prelude::*; +use zlup::semantic::{BitWidth, SemanticAnalyzer, Type}; + +// ============================================================================= +// Parser Properties +// ============================================================================= + +proptest! { + /// The parser should never panic on any UTF-8 string input. + #[test] + fn parser_never_panics(input in ".*") { + // Just call parse - we don't care about the result, only that it doesn't panic + let _ = zlup::parse(&input); + } + + /// The parser should handle strings up to 10KB without issues. + #[test] + fn parser_handles_large_input(input in ".{0,10000}") { + let _ = zlup::parse(&input); + } + + /// Parsing valid function declarations should work. + #[test] + fn parser_valid_function( + name in "[a-z][a-z0-9_]{0,20}", + ret_type in prop_oneof!["unit", "u32", "bool", "f64"] + ) { + let source = format!("fn {}() -> {} {{ return {}; }}", + name, + ret_type, + match ret_type.as_str() { + "unit" => "unit", + "u32" => "0", + "bool" => "true", + "f64" => "0.0", + _ => "unit", + } + ); + let result = zlup::parse(&source); + prop_assert!(result.is_ok(), "Failed to parse: {}", source); + } +} + +// ============================================================================= +// BitWidth Properties +// ============================================================================= + +proptest! { + /// BitWidth::new should accept values 1-128 and reject others. + #[test] + fn bitwidth_valid_range(bits in 1u16..=128) { + let bw = BitWidth::new(bits); + prop_assert!(bw.is_some(), "BitWidth::new({}) should succeed", bits); + prop_assert_eq!(bw.unwrap().get(), bits); + } + + /// BitWidth::new should reject 0. + #[test] + fn bitwidth_rejects_zero(_dummy in 0..1u8) { + let bw = BitWidth::new(0); + prop_assert!(bw.is_none(), "BitWidth::new(0) should fail"); + } + + /// BitWidth::new should reject values > 128. + #[test] + fn bitwidth_rejects_large(bits in 129u16..=u16::MAX) { + let bw = BitWidth::new(bits); + prop_assert!(bw.is_none(), "BitWidth::new({}) should fail", bits); + } +} + +// ============================================================================= +// Type System Properties +// ============================================================================= + +proptest! { + /// A type should be resolved if and only if it contains no Unknown. + #[test] + fn type_resolved_iff_no_unknown(_bits in 1u16..=128) { + // Concrete types should be resolved + let concrete = Type::Bool; + prop_assert!(concrete.is_resolved()); + prop_assert!(!concrete.contains_unknown()); + + // Unknown should not be resolved + let unknown = Type::Unknown; + prop_assert!(!unknown.is_resolved()); + prop_assert!(unknown.contains_unknown()); + } + + /// Nested types with Unknown should not be resolved. + #[test] + fn nested_unknown_not_resolved(_dummy in 0..1u8) { + let nested = Type::Optional { + inner: Box::new(Type::Unknown), + }; + prop_assert!(!nested.is_resolved()); + prop_assert!(nested.contains_unknown()); + } + + /// Array types inherit Unknown status from their element type. + #[test] + fn array_unknown_from_element(size in 0u64..1000) { + // Array with concrete element + let concrete_array = Type::Array { + element: Box::new(Type::Bool), + size: Some(size), + }; + prop_assert!(concrete_array.is_resolved()); + + // Array with Unknown element + let unknown_array = Type::Array { + element: Box::new(Type::Unknown), + size: Some(size), + }; + prop_assert!(!unknown_array.is_resolved()); + } +} + +// ============================================================================= +// Semantic Analyzer Properties +// ============================================================================= + +proptest! { + /// Semantic analysis of valid programs should not panic. + #[test] + fn semantic_valid_program_no_panic( + var_name in "[a-z][a-z0-9_]{0,10}", + value in 0i64..1000 + ) { + let source = format!( + "fn main() -> unit {{ {} := {}; return unit; }}", + var_name, value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + // Should not panic, result doesn't matter + let _ = analyzer.analyze(&program); + } + } + + /// Error recovery should collect all errors without panicking. + #[test] + fn error_recovery_no_panic(input in "[a-zA-Z0-9_ ]{0,100}") { + // Wrap in function to make it potentially parseable + let source = format!("fn main() -> unit {{ {} return unit; }}", input); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + // Should not panic, may have errors + let _ = analyzer.analyze_collecting_errors(&program); + } + } +} + +// ============================================================================= +// Integer Literal Properties +// ============================================================================= + +proptest! { + /// Integer literals should parse correctly. + #[test] + fn integer_literals_parse(value in 0i64..i64::MAX / 2) { + let source = format!("x := {};", value); + let result = zlup::parse(&source); + prop_assert!(result.is_ok(), "Failed to parse integer literal: {}", value); + } + + /// Negative integer literals should parse correctly. + #[test] + fn negative_integers_parse(value in i64::MIN / 2..0i64) { + let source = format!("x := {};", value); + let result = zlup::parse(&source); + prop_assert!(result.is_ok(), "Failed to parse negative literal: {}", value); + } + + /// Float literals should parse correctly. + #[test] + fn float_literals_parse(value in -1e10f64..1e10f64) { + if value.is_finite() { + let source = format!("x := {:.6};", value); + let result = zlup::parse(&source); + // Some edge cases may not parse, that's OK + let _ = result; + } + } +} + +// ============================================================================= +// Identifier Properties +// ============================================================================= + +proptest! { + /// Valid identifiers should be accepted. + #[test] + fn valid_identifiers_accepted( + first in "[a-zA-Z_]", + rest in "[a-zA-Z0-9_]{0,30}" + ) { + let ident = format!("{}{}", first, rest); + let source = format!("{} := 42;", ident); + let result = zlup::parse(&source); + prop_assert!(result.is_ok(), "Failed to parse identifier: {}", ident); + } +} + +// ============================================================================= +// NEGATIVE TESTS - These verify that invalid inputs ARE rejected +// ============================================================================= + +proptest! { + /// Type mismatches should be rejected by semantic analysis. + #[test] + fn type_mismatch_rejected(value in 0i64..1000) { + // Assigning integer to bool variable should fail + let source = format!( + "fn main() -> unit {{ x: bool = {}; return unit; }}", + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Type mismatch should be rejected: {}", source); + } + } + + /// Undefined variables should be rejected. + #[test] + fn undefined_variable_rejected( + var_name in "[a-z][a-z0-9]{2,10}" // At least 2 chars to avoid built-ins like 'e', 'pi' + ) { + // Skip known built-in constants + prop_assume!(!["pi", "tau", "e", "qalloc", "measure", "mz", "mx", "my"].contains(&var_name.as_str())); + + // Using undefined variable should fail + let source = format!( + "fn main() -> unit {{ x := {} + 1; return unit; }}", + var_name + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Undefined variable should be rejected: {}", source); + } + } + + /// Undefined types should be rejected. + #[test] + fn undefined_type_rejected( + type_name in "[A-Z][a-zA-Z0-9]{0,15}" + ) { + // Using undefined type should fail + let source = format!( + "fn main() -> unit {{ x: {} = 42; return unit; }}", + type_name + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Undefined type should be rejected: {}", source); + } + } + + /// Return type mismatches should be rejected. + #[test] + fn return_type_mismatch_rejected(value in 0i64..1000) { + // Returning integer from bool function should fail + let source = format!( + "fn foo() -> bool {{ return {}; }}", + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Return type mismatch should be rejected: {}", source); + } + } + + /// Duplicate function names should be rejected. + #[test] + fn duplicate_function_rejected( + name in "[a-z][a-z0-9]{0,10}" + ) { + let source = format!( + "fn {}() -> unit {{ return unit; }} fn {}() -> unit {{ return unit; }}", + name, name + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Duplicate function should be rejected: {}", source); + } + } + + /// Invalid identifier starting with digit should fail to parse. + #[test] + fn identifier_starting_with_digit_rejected( + digit in 0u8..10, + rest in "[a-zA-Z0-9_]{0,10}" + ) { + let ident = format!("{}{}", digit, rest); + let source = format!("{} := 42;", ident); + // This should fail to parse (identifiers can't start with digits) + let result = zlup::parse(&source); + prop_assert!(result.is_err(), "Identifier starting with digit should be rejected: {}", ident); + } +} + +// ============================================================================= +// Mutation Testing - Mutate valid programs to create invalid ones +// ============================================================================= + +proptest! { + /// Removing return statement from non-unit function should fail in strict mode. + #[test] + fn missing_return_rejected_strict(value in 0i64..1000) { + let source = format!( + "fn foo() -> u32 {{ x := {}; }}", // Missing return + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Missing return should be rejected in strict mode: {}", source); + } + } + + /// Wrong argument count should be rejected. + #[test] + fn wrong_arg_count_rejected( + extra_args in 1usize..5 + ) { + // Calling function with wrong number of arguments + let args = (0..extra_args).map(|i| format!("{}", i)).collect::>().join(", "); + let source = format!( + "fn foo() -> unit {{ return unit; }} fn main() -> unit {{ foo({}); return unit; }}", + args + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Wrong argument count should be rejected: {}", source); + } + } +} + +// ============================================================================= +// Quantum-Specific Negative Tests +// ============================================================================= + +proptest! { + /// Gate on unprepared qubit should fail in strict mode. + #[test] + fn gate_on_unprepared_qubit_rejected_strict(index in 0usize..4) { + let source = format!( + "fn main() -> unit {{ q := qalloc(4); h q[{}]; return unit; }}", // Missing pz + index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Gate on unprepared qubit should be rejected in strict mode: {}", source); + } + } + + /// Qubit index out of bounds should be rejected. + #[test] + fn qubit_out_of_bounds_rejected( + capacity in 1usize..10, + index in 10usize..20 + ) { + let source = format!( + "fn main() -> unit {{ q := qalloc({}); pz q; h q[{}]; return unit; }}", + capacity, index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // Should fail because index >= capacity + prop_assert!(result.is_err(), "Qubit out of bounds should be rejected: {}", source); + } + } +} + +// ============================================================================= +// Mutability Error Tests +// ============================================================================= + +proptest! { + /// Assignment to immutable variable should be rejected. + #[test] + fn immutable_assignment_rejected(value in 0i64..1000) { + // x is immutable (no mut), so x = value should fail + let source = format!( + "fn main() -> unit {{ x := 1; x = {}; return unit; }}", + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Assignment to immutable variable should be rejected: {}", source); + } + } + + /// Reassignment to mutable variable should succeed. + #[test] + fn mutable_assignment_allowed(value in 0i64..1000) { + // mut x is mutable, so x = value should succeed + let source = format!( + "fn main() -> unit {{ mut x := 1; x = {}; return unit; }}", + value + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_ok(), "Assignment to mutable variable should succeed: {:?}", result); + } + } + + /// Mutation via method on immutable allocator should be rejected. + #[test] + fn immutable_allocator_child_rejected(capacity in 1usize..10) { + // base is immutable, so base.child() should fail (child requires mut) + let source = format!( + "fn main() -> unit {{ base := qalloc(8); q := base.child({}); return unit; }}", + capacity + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Child of immutable allocator should be rejected: {}", source); + } + } +} + +// ============================================================================= +// Gate After Measurement Tests (Quantum Safety) +// ============================================================================= + +proptest! { + /// Gate after measurement without re-preparing should be rejected in strict mode. + #[test] + fn gate_after_measurement_rejected_strict(index in 0usize..4) { + // After measurement, qubit returns to unprepared state + // Applying a gate without pz should fail + let source = format!( + r#"fn main() -> unit {{ + mut q := qalloc(4); + pz q; + result := mz q[{}]; + h q[{}]; // Should fail - qubit is unprepared after measurement + return unit; + }}"#, + index, index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Gate after measurement should be rejected in strict mode: {}", source); + } + } + + /// Gate after measurement with re-prepare should succeed. + #[test] + fn gate_after_measurement_with_prepare_allowed(index in 0usize..4) { + // After measurement, re-prepare with pz, then gate should work + let source = format!( + r#"fn main() -> unit {{ + mut q := qalloc(4); + pz q; + result := mz q[{}]; + pz q[{}]; // Re-prepare the qubit + h q[{}]; // Now this should succeed + return unit; + }}"#, + index, index, index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_ok(), "Gate after re-prepare should succeed: {:?}", result); + } + } +} + +// ============================================================================= +// Invalid Gate Arity Tests +// ============================================================================= + +proptest! { + /// Single-qubit gate with tuple target should be rejected. + #[test] + fn single_qubit_gate_with_two_targets_rejected(_dummy in 0..1u8) { + // H is a single-qubit gate, giving it two qubits should fail + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + h (q[0], q[1]); // H takes 1 qubit, not 2 + return unit; + } + "#; + + if let Ok(program) = zlup::parse(source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Single-qubit gate with two targets should be rejected"); + } + } + + /// Two-qubit gate with single target should be rejected. + #[test] + fn two_qubit_gate_with_one_target_rejected(index in 0usize..4) { + // CX is a two-qubit gate, giving it one qubit should fail + let source = format!( + r#"fn main() -> unit {{ + mut q := qalloc(4); + pz q; + cx q[{}]; // CX takes 2 qubits, not 1 + return unit; + }}"#, + index + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Two-qubit gate with one target should be rejected: {}", source); + } + } + + /// Two-qubit gate with correct arity should succeed. + #[test] + fn two_qubit_gate_correct_arity_allowed(_dummy in 0..1u8) { + // CX with two qubits should succeed + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + cx (q[0], q[1]); // CX takes 2 qubits - correct + return unit; + } + "#; + + if let Ok(program) = zlup::parse(source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_ok(), "Two-qubit gate with correct arity should succeed: {:?}", result); + } + } + + /// Three-qubit gate (CCX/Toffoli) with wrong arity should be rejected. + #[test] + fn three_qubit_gate_with_two_targets_rejected(_dummy in 0..1u8) { + // CCX is a three-qubit gate + let source = r#" + fn main() -> unit { + mut q := qalloc(4); + pz q; + ccx (q[0], q[1]); // CCX takes 3 qubits, not 2 + return unit; + } + "#; + + if let Ok(program) = zlup::parse(source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Three-qubit gate with two targets should be rejected"); + } + } +} + +// ============================================================================= +// Recursive Call Tests (NASA Power of 10 Compliance) +// ============================================================================= + +proptest! { + /// Direct recursion is always rejected (NASA Power of 10 compliance). + /// Use FFI with Rust if recursive algorithms are needed. + #[test] + fn direct_recursion_rejected_strict(n in 1i64..10) { + // Function calling itself directly + let source = format!( + r#"fn factorial(n: i64) -> i64 {{ + if n <= 1 {{ + return 1; + }} + return n * factorial(n - {}); + }} + fn main() -> unit {{ x := factorial(5); return unit; }}"#, + n.min(1) // Always subtract at least 1 + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Direct recursion should be rejected: {}", source); + } + } + + /// Mutual recursion is always rejected (NASA Power of 10 compliance). + #[test] + fn mutual_recursion_rejected_strict(_dummy in 0..1u8) { + // Two functions calling each other + let source = r#" + fn is_even(n: i64) -> bool { + if n == 0 { return true; } + return is_odd(n - 1); + } + fn is_odd(n: i64) -> bool { + if n == 0 { return false; } + return is_even(n - 1); + } + fn main() -> unit { x := is_even(10); return unit; } + "#; + + if let Ok(program) = zlup::parse(source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_err(), "Mutual recursion should be rejected"); + } + } + + /// Non-recursive functions should succeed. + #[test] + fn non_recursive_allowed_strict(a in 0i64..100, b in 0i64..100) { + // Regular function calls (no recursion) should be fine + let source = format!( + r#"fn add(x: i64, y: i64) -> i64 {{ return x + y; }} + fn main() -> unit {{ result := add({}, {}); return unit; }}"#, + a, b + ); + + if let Ok(program) = zlup::parse(&source) { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + prop_assert!(result.is_ok(), "Non-recursive function should succeed: {:?}", result); + } + } +} + +/// Recursion is rejected even in permissive mode (no escape hatch). +#[test] +fn recursion_rejected_even_permissive() { + let source = r#" + fn factorial(n: i64) -> i64 { + if n <= 1 { return 1; } + return n * factorial(n - 1); + } + fn main() -> unit { x := factorial(5); return unit; } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Recursion should be rejected even in permissive mode"); +} + +// ============================================================================= +// ============================================================================= +// Performance Regression Tests +// ============================================================================= + +/// Test that deeply nested parens don't cause exponential parsing time. +/// This input was found by fuzzing to be slow. +#[test] +fn slow_input_deeply_nested_parens() { + use std::time::{Duration, Instant}; + + // This input caused 445s parse time in fuzzing + let input = "a:=((Z/[[((((((((((((((((((((((/\n"; + + let start = Instant::now(); + let _ = zlup::parse(input); + let elapsed = start.elapsed(); + + println!("Parse time for deeply nested input: {:?}", elapsed); + + // Should complete in under 1 second, not 445 seconds + assert!( + elapsed < Duration::from_secs(1), + "Parsing took too long: {:?} (expected < 1s)", + elapsed + ); +} + +/// Test another slow input found by fuzzing - deeply nested parens in function call. +#[test] +fn slow_input_nested_call_parens() { + use std::time::{Duration, Instant}; + + // This input caused timeout in fuzzing: k:k=z(((((((((((((((((((((((((/inl... + let input = "k:k=z(((((((((((((((((((((((((/inl\x1a\x00at\r\r:0"; + + let start = Instant::now(); + let _ = zlup::parse(input); + let elapsed = start.elapsed(); + + println!("Parse time for nested call parens: {:?}", elapsed); + + // Should complete in under 1 second + assert!( + elapsed < Duration::from_secs(1), + "Parsing took too long: {:?} (expected < 1s)", + elapsed + ); +} + +// ============================================================================= +// Additional Parser Negative Tests +// ============================================================================= + +/// Unclosed parentheses should fail to parse. +#[test] +fn unclosed_paren_rejected() { + let inputs = [ + "x := (1 + 2;", + "x := ((1);", + "fn foo( {}", + "x := func(a, b;", + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Unclosed paren should be rejected: {}", input); + } +} + +/// Unclosed brackets should fail to parse. +#[test] +fn unclosed_bracket_rejected() { + let inputs = [ + "x := [1, 2, 3;", + "x := a[0;", + "x: [4]u8 = [1, 2;", + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Unclosed bracket should be rejected: {}", input); + } +} + +/// Unclosed braces should fail to parse. +#[test] +fn unclosed_brace_rejected() { + let inputs = [ + "fn foo() {", + "x := Point { x: 1, y: 2;", + "if true { x := 1;", + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Unclosed brace should be rejected: {}", input); + } +} + +/// Reserved keywords as identifiers should fail. +#[test] +fn keyword_as_identifier_rejected() { + let keywords = ["fn", "if", "else", "for", "return", "struct", "enum", "true", "false"]; + for kw in keywords { + let source = format!("{} := 42;", kw); + let result = zlup::parse(&source); + assert!(result.is_err(), "Keyword '{}' as identifier should be rejected", kw); + } +} + +/// Invalid number literals should fail to parse. +#[test] +fn invalid_number_literal_rejected() { + let inputs = [ + "x := 0x;", // hex with no digits + "x := 0b;", // binary with no digits + "x := 0o;", // octal with no digits + "x := 1.;", // trailing dot with no fraction + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Invalid number literal should be rejected: {}", input); + } +} + +/// Deprecated measurement syntax should be rejected. +#[test] +fn deprecated_measurement_syntax_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + r := mz(u1, q[0]); + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Deprecated mz(type, target) syntax should be rejected"); +} + +/// Empty function body is valid but missing semicolon is not. +#[test] +fn missing_semicolon_rejected() { + let inputs = [ + "x := 42", // missing semicolon on binding + "fn foo() -> u32 { return 42 }", // missing semicolon on return + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Missing semicolon should be rejected: {}", input); + } +} + +// ============================================================================= +// Additional Semantic Negative Tests +// ============================================================================= + +/// Batch gate with wrong arity elements should be rejected. +#[test] +fn batch_gate_wrong_element_arity_rejected() { + // H is single-qubit, but we're giving it tuples + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + pz q; + h {(q[0], q[1]), (q[2], q[3])}; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "H gate with tuple elements should be rejected"); +} + +/// CX batch with single qubits instead of pairs should be rejected. +#[test] +fn batch_cx_wrong_element_type_rejected() { + // CX needs pairs, but we're giving single qubits + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + pz q; + cx {q[0], q[1], q[2], q[3]}; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "CX gate with single qubit elements should be rejected"); +} + +/// Using a non-qubit type where qubit is expected should be rejected. +#[test] +fn non_qubit_as_gate_target_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + h x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Integer as gate target should be rejected"); +} + +/// Break outside of loop should be rejected. +#[test] +fn break_outside_loop_rejected() { + let source = r#" + pub fn main() -> unit { + break; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Break outside loop should be rejected"); +} + +/// Continue outside of loop should be rejected. +#[test] +fn continue_outside_loop_rejected() { + let source = r#" + pub fn main() -> unit { + continue; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Continue outside loop should be rejected"); +} + +/// Nested tick blocks should be rejected. +#[test] +fn nested_tick_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + tick { + tick { + h q[0]; + } + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Nested tick blocks should be rejected"); +} + +/// Same qubit used twice in same tick should be rejected. +#[test] +fn duplicate_qubit_in_tick_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + tick { + h q[0]; + x q[0]; + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Same qubit used twice in tick should be rejected"); +} + +/// Catch on non-error type should be rejected. +#[test] +fn catch_on_non_error_type_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := x catch 0; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Catch on non-error type should be rejected"); +} + +/// Array index with non-integer should be rejected. +#[test] +fn array_index_non_integer_rejected() { + let source = r#" + pub fn main() -> unit { + arr: [4]u32 = [1, 2, 3, 4]; + x := arr[true]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Array index with boolean should be rejected"); +} + +/// Field access on non-struct should be rejected. +#[test] +fn field_access_on_non_struct_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := x.field; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Field access on integer should be rejected"); +} + +/// Accessing undefined struct field should be rejected. +#[test] +fn undefined_struct_field_rejected() { + let source = r#" + Point := struct { + x: i32, + y: i32, + }; + pub fn main() -> unit { + p := Point { x: 1, y: 2 }; + z := p.z; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Accessing undefined field should be rejected"); +} + +/// Duplicate struct field in initialization should be rejected. +#[test] +fn duplicate_struct_field_init_rejected() { + let source = r#" + Point := struct { + x: i32, + y: i32, + }; + pub fn main() -> unit { + p := Point { x: 1, x: 2, y: 3 }; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Duplicate field in struct init should be rejected"); +} + +/// Binary operation with incompatible types should be rejected. +#[test] +fn binary_op_type_mismatch_rejected() { + let inputs = [ + ("x := true + 1;", "bool + int"), + ("x := \"hello\" - 5;", "string - int"), + ("x := 1.5 and true;", "float and bool"), + ]; + for (source, desc) in inputs { + let wrapped = format!("pub fn main() -> unit {{ {} }}", source); + let program = zlup::parse(&wrapped).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Binary op {} should be rejected", desc); + } +} + +/// Calling a non-function should be rejected. +#[test] +fn call_non_function_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := x(1, 2); + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Calling an integer should be rejected"); +} + +/// Return with value from unit function should be rejected. +#[test] +fn return_value_from_unit_function_rejected() { + let source = r#" + pub fn main() -> unit { + return 42; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Returning value from unit function should be rejected"); +} + +/// Wrong type in struct field initialization should be rejected. +#[test] +fn struct_field_wrong_type_rejected() { + let source = r#" + Point := struct { + x: i32, + y: i32, + }; + pub fn main() -> unit { + p := Point { x: "hello", y: 2 }; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "String in i32 field should be rejected"); +} + +/// Qubit already prepared should be rejected (double prepare). +#[test] +fn qubit_already_prepared_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + pz q; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + analyzer.set_strict_mode(true); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Double prepare should be rejected in strict mode"); +} + +/// Duplicate qubit in measurement should be rejected. +#[test] +fn duplicate_qubit_in_measurement_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(2); + pz q; + r := mz([2]u1) [q[0], q[0]]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Duplicate qubit in measurement should be rejected"); +} + +/// Measurement size mismatch should be rejected. +#[test] +fn measurement_size_mismatch_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(4); + pz q; + r := mz([2]u1) [q[0], q[1], q[2]]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Measurement size mismatch should be rejected"); +} + +/// Using orelse on non-optional should be rejected. +#[test] +fn orelse_on_non_optional_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := x orelse 0; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "orelse on non-optional should be rejected"); +} + +/// Empty array without type annotation should be rejected. +#[test] +fn empty_array_no_type_rejected() { + let source = r#" + pub fn main() -> unit { + arr := []; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Empty array without type should be rejected"); +} + +/// For loop with non-iterable should be rejected. +#[test] +fn for_loop_non_iterable_rejected() { + let source = r#" + pub fn main() -> unit { + for i in 42 { + x := i; + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "For loop over integer should be rejected"); +} + +/// Comparison between incompatible types should be rejected. +#[test] +fn comparison_type_mismatch_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42 == "hello"; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Comparing int to string should be rejected"); +} + +/// If condition must be boolean. +#[test] +fn if_non_boolean_condition_rejected() { + let source = r#" + pub fn main() -> unit { + if 42 { + x := 1; + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "If with integer condition should be rejected"); +} + +// ============================================================================= +// More Parser Negative Tests +// ============================================================================= + +/// Invalid escape sequences in strings should fail. +#[test] +fn invalid_string_escape_rejected() { + let inputs = [ + r#"x := "\q";"#, // \q is not a valid escape + r#"x := "\u1234";"#, // \u is not supported (use \x) + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Invalid escape should be rejected: {}", input); + } +} + +/// Multiple expressions without separator should fail. +#[test] +fn missing_separator_rejected() { + let inputs = [ + "x := 1 y := 2;", // missing semicolon between statements + "fn foo() {} bar", // junk after function + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Missing separator should be rejected: {}", input); + } +} + +/// Invalid type syntax should fail. +#[test] +fn invalid_type_syntax_rejected() { + let inputs = [ + "x: [;", // incomplete array type + "x: *;", // pointer to nothing + "x: ?;", // optional of nothing + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Invalid type should be rejected: {}", input); + } +} + +/// Empty function parameter list with trailing comma should parse but empty param should fail. +#[test] +fn invalid_param_syntax_rejected() { + let inputs = [ + "fn foo(,) {}", // just a comma + "fn foo(x:) {}", // missing type + "fn foo(: i32) {}", // missing name + ]; + for input in inputs { + let result = zlup::parse(input); + assert!(result.is_err(), "Invalid param should be rejected: {}", input); + } +} + +// ============================================================================= +// More Semantic Negative Tests +// ============================================================================= + +/// Negative array size should be rejected. +#[test] +fn negative_array_size_rejected() { + let source = r#" + pub fn main() -> unit { + arr: [-1]u32 = undefined; + } + "#; + // This might fail at parse or semantic level + let result = zlup::parse(source); + if let Ok(program) = result { + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Negative array size should be rejected"); + } + // If it fails to parse, that's also acceptable +} + +/// Zero-size array should be valid but accessing it should fail. +#[test] +fn zero_array_access_rejected() { + let source = r#" + pub fn main() -> unit { + arr: [0]u32 = []; + x := arr[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Accessing element of zero-size array should be rejected"); +} + +/// Using undefined label with break should be rejected. +#[test] +fn undefined_break_label_rejected() { + let source = r#" + pub fn main() -> unit { + for i in 0..10 { + break :nonexistent; + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Break with undefined label should be rejected"); +} + +/// Assigning to a constant/comptime value should be rejected. +#[test] +fn assign_to_comptime_rejected() { + let source = r#" + SIZE := comptime 10; + pub fn main() -> unit { + SIZE = 20; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Assigning to comptime value should be rejected"); +} + +/// Division by zero at comptime should be rejected. +#[test] +fn comptime_division_by_zero_rejected() { + let source = r#" + X := comptime 10 / 0; + pub fn main() -> unit { + y := X; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Comptime division by zero should be rejected"); +} + +/// Invalid unary operator application should be rejected. +#[test] +fn invalid_unary_op_rejected() { + let inputs = [ + ("x := -true;", "negating bool"), + ("x := !42;", "logical not on int"), + ("x := ~true;", "bitwise not on bool"), + ]; + for (source, desc) in inputs { + let wrapped = format!("pub fn main() -> unit {{ {} }}", source); + let program = zlup::parse(&wrapped).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Invalid unary op {} should be rejected", desc); + } +} + +/// Dereferencing a non-pointer should be rejected. +#[test] +fn deref_non_pointer_rejected() { + let source = r#" + pub fn main() -> unit { + x := 42; + y := *x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Dereferencing non-pointer should be rejected"); +} + +/// Taking address of a literal should be rejected. +#[test] +fn address_of_literal_rejected() { + let source = r#" + pub fn main() -> unit { + x := &42; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // This might be allowed in some contexts, but typically rejected + // If it's allowed, we can remove this test + assert!(result.is_err(), "Address of literal should be rejected"); +} + +/// Mixing qubits from different allocators in same gate should be rejected. +#[test] +fn mixed_allocator_gate_rejected() { + let source = r#" + pub fn main() -> unit { + mut q1 := qalloc(2); + mut q2 := qalloc(2); + pz q1; + pz q2; + cx (q1[0], q2[0]); + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // This might actually be allowed - remove if so + // Cross-allocator gates might be valid in some quantum systems + if result.is_err() { + // Good - it's rejected as expected + } + // Don't assert - this might be architecture-dependent +} + +/// Parameterized gate with wrong parameter type should be rejected. +#[test] +fn gate_wrong_param_type_rejected() { + let source = r#" + pub fn main() -> unit { + mut q := qalloc(1); + pz q; + rx("not an angle") q[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Gate with string parameter should be rejected"); +} + +/// Enum variant that doesn't exist should be rejected. +#[test] +fn undefined_enum_variant_rejected() { + let source = r#" + Color := enum { Red, Green, Blue }; + pub fn main() -> unit { + c := Color.Yellow; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Undefined enum variant should be rejected"); +} + +/// Using a type as a value incorrectly should be rejected. +#[test] +fn type_as_value_rejected() { + let source = r#" + pub fn main() -> unit { + x := u32 + 1; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Using type in arithmetic should be rejected"); +} + +/// Bitwise operations on floats should be rejected. +#[test] +fn bitwise_on_float_rejected() { + let inputs = [ + ("x := 1.0 & 2.0;", "bitwise and on floats"), + ("x := 1.0 | 2.0;", "bitwise or on floats"), + ("x := 1.0 ^ 2.0;", "bitwise xor on floats"), + ("x := 1.0 << 2;", "left shift on float"), + ]; + for (source, desc) in inputs { + let wrapped = format!("pub fn main() -> unit {{ {} }}", source); + let program = zlup::parse(&wrapped).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "{} should be rejected", desc); + } +} + +/// Switch with duplicate integer cases should be rejected. +#[test] +fn switch_duplicate_case_rejected() { + let source = r#" + pub fn main() -> unit { + x := 1; + switch (x) { + 1 => unit, + 1 => unit, + else => unit, + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Duplicate switch case should be rejected"); +} + +/// Switch with duplicate boolean cases should be rejected. +#[test] +fn switch_duplicate_bool_case_rejected() { + let source = r#" + pub fn main() -> unit { + x := true; + switch (x) { + true => unit, + true => unit, + else => unit, + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Duplicate bool switch case should be rejected"); +} + +/// Switch with duplicate string cases should be rejected. +#[test] +fn switch_duplicate_string_case_rejected() { + let source = r#" + pub fn main() -> unit { + x := "hello"; + switch (x) { + "hello" => unit, + "world" => unit, + "hello" => unit, + else => unit, + } + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Duplicate string switch case should be rejected"); +} + +/// Switch with unique cases should be allowed. +#[test] +fn switch_unique_cases_allowed() { + let source = r#" + pub fn main() -> unit { + x := 1; + switch (x) { + 1 => unit, + 2 => unit, + 3 => unit, + else => unit, + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Switch with unique cases should be allowed: {:?}", result); +} + +/// Missing struct fields in initialization should be rejected. +#[test] +fn missing_struct_field_rejected() { + let source = r#" + Point := struct { + x: i32, + y: i32, + }; + pub fn main() -> unit { + p := Point { x: 1 }; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Missing struct field should be rejected"); +} + +// ============================================================================= +// Logging Tests +// ============================================================================= + +/// Standard log levels should parse correctly. +#[test] +fn log_standard_levels_parse() { + let source = r#" + pub fn main() -> unit { + @emit.log.trace(f"trace message"); + @emit.log.debug(f"debug message"); + @emit.log.info(f"info message"); + @emit.log.warn(f"warn message"); + @emit.log.error(f"error message"); + return unit; + } + "#; + let result = zlup::parse(source); + assert!(result.is_ok(), "Standard log levels should parse: {:?}", result.err()); +} + +/// Log with sub-namespace should parse correctly. +#[test] +fn log_with_namespace_parses() { + let source = r#" + pub fn main() -> unit { + @emit.log.debug("subns", f"message with namespace"); + @emit.log.info("my::nested::ns", f"nested namespace"); + return unit; + } + "#; + let result = zlup::parse(source); + assert!(result.is_ok(), "Log with namespace should parse: {:?}", result.err()); +} + +/// Bare log.info without @emit prefix is parsed as method call (not channel). +/// Since `log` is not defined, semantic analysis should fail. +#[test] +fn bare_log_not_channel() { + let source = r#" + pub fn main() -> unit { + log.info(f"missing @emit prefix"); + return unit; + } + "#; + // Parses as method call (log.info), but `log` is undefined + let program = zlup::parse(source).expect("Parses as method call"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // Semantic analysis should fail because `log` is not defined + assert!(result.is_err(), "Undefined variable 'log' should cause semantic error"); +} + +/// Log with data parameter should parse correctly. +#[test] +fn log_with_data_parses() { + let source = r#" + pub fn main() -> unit { + x := 42; + @emit.log.debug(f"value", data: x); + @emit.log.info("ns", f"with both", data: x); + return unit; + } + "#; + let result = zlup::parse(source); + assert!(result.is_ok(), "Log with data should parse: {:?}", result.err()); +} + +/// Custom log level with @emit.log.at should parse correctly. +#[test] +fn log_custom_level_parses() { + let source = r#" + pub fn main() -> unit { + @emit.log.at(15, f"custom numeric level"); + @emit.log.at(25, "perf", f"with namespace"); + return unit; + } + "#; + let result = zlup::parse(source); + assert!(result.is_ok(), "Custom log level should parse: {:?}", result.err()); +} + +/// Log expressions should pass semantic analysis. +#[test] +fn log_passes_semantic_analysis() { + let source = r#" + pub fn main() -> unit { + x := 42; + @emit.log.trace(f"trace"); + @emit.log.debug(f"x = {x}"); + @emit.log.info("ns", f"namespaced"); + @emit.log.warn(f"warning", data: x); + @emit.log.error(f"error"); + @emit.log.at(15, f"custom"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Log expressions should pass semantic analysis: {:?}", result.err()); +} + +/// Log with f-string interpolation should work. +#[test] +fn log_fstring_interpolation_works() { + let source = r#" + pub fn main() -> unit { + name := "world"; + count := 42; + @emit.log.info(f"Hello {name}, count = {count}"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Log with f-string interpolation should work: {:?}", result.err()); +} + +/// Log SLR codegen should emit LogStmt nodes. +#[test] +fn log_slr_codegen_emits_log_stmt() { + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.log.debug(f"test message"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr_program).expect("Should serialize"); + + assert!(json.contains("LogStmt"), "SLR output should contain LogStmt"); + assert!(json.contains("debug"), "SLR output should contain log level"); + assert!(json.contains("test message"), "SLR output should contain message"); +} + +/// Log SLR codegen should handle namespaces. +#[test] +fn log_slr_codegen_handles_namespace() { + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.log.info("myns", f"namespaced"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_module("testmod"); + let slr_program = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr_program).expect("Should serialize"); + + assert!(json.contains("testmod::myns"), "SLR output should contain combined namespace"); +} + +/// Log elision in release mode should remove all logs. +#[test] +fn log_elision_release_removes_all() { + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.log.trace(f"trace"); + @emit.log.debug(f"debug"); + @emit.log.info(f"info"); + @emit.log.warn(f"warn"); + @emit.log.error(f"error"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new_release(); + let slr_program = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr_program).expect("Should serialize"); + + assert!(!json.contains("LogStmt"), "Release mode should elide all logs"); +} + +/// Log elision with custom level should filter appropriately. +#[test] +fn log_elision_custom_level_filters() { + use zlup::codegen::slr::LogElisionLevel; + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.log.trace(f"trace"); + @emit.log.debug(f"debug"); + @emit.log.info(f"info"); + @emit.log.warn(f"warn"); + @emit.log.error(f"error"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_log_elision(LogElisionLevel(Some(200))); // INFO level + let slr_program = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr_program).expect("Should serialize"); + + // trace (0) and debug (10) should be elided + assert!(!json.contains("trace"), "trace should be elided"); + assert!(!json.contains("debug"), "debug should be elided"); + // info (20), warn (30), error (40) should remain + assert!(json.contains("info"), "info should remain"); + assert!(json.contains("warn"), "warn should remain"); + assert!(json.contains("error"), "error should remain"); +} + +// ============================================================================= +// Result Expression Tests +// ============================================================================= + +/// Result expression should parse and type check. +#[test] +fn result_expr_parses() { + let source = r#" + pub fn main() -> unit { + result("measurement", 42); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// Result expression with namespaced tag. +#[test] +fn result_expr_namespaced_tag() { + let source = r#" + pub fn main() -> unit { + result("qec/syndrome", 0); + result("qec/round_1/parity", true); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// Result expression with various value types. +#[test] +fn result_expr_various_types() { + let source = r#" + pub fn main() -> unit { + result("int_result", 42); + result("bool_result", true); + result("float_result", 3.14); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +// ============================================================================= +// Simulator Control Expression Tests +// ============================================================================= + +/// @emit.sim.send should parse. +#[test] +fn sim_send_parses() { + let source = r#" + pub fn main() -> unit { + @emit.sim.send("checkpoint", "before_correction"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// @emit.sim.send with various value types. +#[test] +fn sim_send_various_values() { + let source = r#" + pub fn main() -> unit { + @emit.sim.send("seed", 12345); + @emit.sim.send("checkpoint", "start"); + @emit.sim.send("noise_rate", 0.01); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// @emit.sim.noise_enable should parse. +#[test] +fn sim_noise_enable_parses() { + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_enable(); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// @emit.sim.noise_disable should parse. +#[test] +fn sim_noise_disable_parses() { + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_disable(); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +/// Multiple sim commands in sequence should work. +#[test] +fn sim_multiple_commands() { + let source = r#" + pub fn main() -> unit { + @emit.sim.send("seed", 42); + @emit.sim.send("noise_model", "depolarizing"); + @emit.sim.send("checkpoint", "start"); + q := qalloc(1); + h q[0]; + @emit.sim.noise_disable(); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should type check"); +} + +// ============================================================================= +// SLR Codegen Tests for Result and Sim +// ============================================================================= + +/// Result expressions should generate SendStmt with channel "result". +#[test] +fn result_generates_slr() { + use zlup::codegen::slr::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + result("answer", 42); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify SendStmt with channel "result" is in output + assert!(json.contains("SendStmt"), "Should contain SendStmt: {}", json); + assert!(json.contains("\"channel\": \"result\""), "Should have result channel: {}", json); + assert!(json.contains("\"key\": \"answer\""), "Should contain key: {}", json); +} + +/// Sim commands should generate SendStmt with channel "sim" for simulator target. +#[test] +fn sim_generates_slr_for_simulator() { + use zlup::codegen::slr::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_enable(); + @emit.sim.send("seed", 42); + @emit.sim.noise_disable(); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + // sim_mode defaults to Emit (simulator target) + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify SendStmt with channel "sim" is in output + assert!(json.contains("SendStmt"), "Should contain SendStmt: {}", json); + assert!(json.contains("\"channel\": \"sim\""), "Should have sim channel: {}", json); + assert!(json.contains("\"key\": \"noise_enable\""), "Should contain noise_enable: {}", json); + assert!(json.contains("\"key\": \"seed\""), "Should contain seed key: {}", json); +} + +/// Sim commands should emit barriers for hardware target (default). +#[test] +fn sim_emits_barrier_for_hardware() { + use zlup::codegen::slr::{SlrCodegen, SimMode}; + + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_enable(); + @emit.sim.send("checkpoint", "start"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_sim_mode(SimMode::Barrier); // Hardware target default + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify sim SendStmt is NOT in output, but BarrierOp IS + assert!(!json.contains("\"channel\": \"sim\""), "Should NOT contain sim channel for hardware: {}", json); + assert!(json.contains("BarrierOp"), "Should contain BarrierOp for ordering: {}", json); +} + +/// Sim commands can be completely elided with explicit opt-in. +#[test] +fn sim_fully_elided_when_requested() { + use zlup::codegen::slr::{SlrCodegen, SimMode}; + + let source = r#" + pub fn main() -> unit { + @emit.sim.noise_enable(); + @emit.sim.send("checkpoint", "start"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_sim_mode(SimMode::Elide); // Explicit full elision + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify neither sim SendStmt nor BarrierOp in output + assert!(!json.contains("\"channel\": \"sim\""), "Should NOT contain sim channel: {}", json); + assert!(!json.contains("BarrierOp"), "Should NOT contain BarrierOp: {}", json); +} + +/// Sim barrier is scoped to allocators in current scope. +#[test] +fn sim_barrier_is_scoped_to_allocators() { + use zlup::codegen::slr::{SlrCodegen, SimMode}; + + let source = r#" + pub fn main() -> unit { + q := qalloc(4); + @emit.sim.noise_disable(); + h q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + codegen.set_sim_mode(SimMode::Barrier); + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Verify BarrierOp includes the allocator "q" + assert!(json.contains("BarrierOp"), "Should contain BarrierOp: {}", json); + assert!(json.contains("\"allocators\""), "Should have allocators field: {}", json); + assert!(json.contains("\"q\""), "Should include allocator 'q': {}", json); +} + +/// Result is NEVER elided, even with full log elision in release mode. +#[test] +fn result_never_elided_in_release() { + use zlup::codegen::slr::{SlrCodegen, SimMode}; + + let source = r#" + pub fn main() -> unit { + @emit.log.debug(f"this will be elided"); + result("answer", 42); + @emit.sim.send("checkpoint", "end"); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + + // Configure for hardware release (max elision) + let mut codegen = SlrCodegen::new_release(); + codegen.set_sim_mode(SimMode::Elide); // Full sim elision + + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // Log should be elided + assert!(!json.contains("LogStmt"), "Log should be elided: {}", json); + // Sim should be elided + assert!(!json.contains("\"channel\": \"sim\""), "Sim should be elided: {}", json); + // Result should ALWAYS be present + assert!(json.contains("SendStmt"), "Result should be present: {}", json); + assert!(json.contains("\"channel\": \"result\""), "Result channel should be present: {}", json); + assert!(json.contains("\"key\": \"answer\""), "Result key should be present: {}", json); +} + +/// All three channels work together in a realistic program. +#[test] +fn all_channels_together() { + use zlup::codegen::slr::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + // Simulator setup + @emit.sim.send("seed", 42); + @emit.sim.send("noise_model", "depolarizing"); + + // Allocate qubits + q := qalloc(2); + @emit.log.info(f"allocated qubits"); + + // Quantum operations + h q[0]; + @emit.sim.noise_disable(); + cx (q[0], q[1]); + + // Measure and emit result + m := mz([2]u1) [q[0], q[1]]; + @emit.log.debug(f"measured: {m}"); + result("bell_measurement", m); + + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + analyzer.analyze(&program).expect("Should pass semantic analysis"); + + let mut codegen = SlrCodegen::new(); + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + // All three channel types should be present + assert!(json.contains("LogStmt"), "Should have log statements: {}", json); + assert!(json.contains("\"channel\": \"result\""), "Should have result channel: {}", json); + assert!(json.contains("\"channel\": \"sim\""), "Should have sim channel: {}", json); +} + +// ============================================================================= +// Swap Builtin Tests +// ============================================================================= + +/// @swap should parse correctly. +#[test] +fn swap_builtin_parses() { + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + @swap(&a, &b); + return unit; + } + "#; + let result = zlup::parse(source); + assert!(result.is_ok(), "@swap should parse: {:?}", result.err()); +} + +/// @swap should pass semantic analysis. +#[test] +fn swap_builtin_semantic_analysis() { + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + @swap(&a, &b); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "@swap should pass semantic analysis: {:?}", result.err()); +} + +/// @swap with wrong number of arguments should fail. +#[test] +fn swap_wrong_arg_count_rejected() { + let source = r#" + pub fn main() -> unit { + a := 1; + @swap(&a); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "@swap with 1 arg should fail"); +} + +/// @swap with mismatched types should fail. +#[test] +fn swap_type_mismatch_rejected() { + let source = r#" + pub fn main() -> unit { + a: i64 = 1; + b: f64 = 2.0; + @swap(&a, &b); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "@swap with mismatched types should fail"); +} + +/// @swap should generate SLR SwapOp. +#[test] +fn swap_generates_slr() { + use zlup::codegen::SlrCodegen; + + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + @swap(&a, &b); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut codegen = SlrCodegen::new(); + let slr = codegen.compile(&program).expect("Should compile"); + let json = codegen.to_json(&slr).expect("Should serialize"); + + assert!(json.contains("SwapOp"), "Should contain SwapOp: {}", json); +} + +// ============================================================================= +// Reference Safety Tests (safe-by-constraint memory model) +// ============================================================================= + +/// Returning a reference to a local variable is always rejected (safe-by-constraint). +#[test] +fn return_reference_to_local_rejected() { + let source = r#" + fn bad() -> *i64 { + x := 42; + return &x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Returning reference to local should fail"); + let err = result.unwrap_err(); + assert!( + format!("{}", err).contains("local variable"), + "Error should mention local variable: {}", err + ); +} + +/// Returning a reference to a local is rejected even in permissive mode (no escape hatch). +#[test] +fn return_reference_to_local_rejected_permissive() { + let source = r#" + fn bad() -> *i64 { + x := 42; + return &x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new_permissive(); + let result = analyzer.analyze(&program); + // Safe-by-constraint: no escape hatch for returning dangling references + assert!(result.is_err(), "Returning reference to local should fail even in permissive mode"); +} + +/// Returning a parameter reference should be allowed (caller owns the data). +#[test] +fn return_reference_to_param_allowed() { + let source = r#" + fn ok(x: *i32) -> *i32 { + return x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); // strict mode + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Returning parameter reference should be allowed: {:?}", result.err()); +} + +/// Returning a value (not reference) of a local is fine. +#[test] +fn return_value_of_local_allowed() { + let source = r#" + fn ok() -> i32 { + x := 42; + return x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); // strict mode + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Returning value of local should be allowed: {:?}", result.err()); +} + +/// Returning a slice of a local array should be rejected. +#[test] +fn return_slice_of_local_rejected() { + let source = r#" + fn bad() -> []i32 { + arr: [4]i32 = [1, 2, 3, 4]; + return arr[0..2]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Returning slice of local array should fail"); +} + +/// Slice syntax with open-ended ranges should parse correctly. +#[test] +fn slice_open_ended_parses() { + // arr[..n] - from start to n + let source1 = r#" + fn test() -> unit { + arr: [4]i32 = [1, 2, 3, 4]; + x := arr[..2]; + return unit; + } + "#; + assert!(zlup::parse(source1).is_ok(), "arr[..2] should parse"); + + // arr[n..] - from n to end + let source2 = r#" + fn test() -> unit { + arr: [4]i32 = [1, 2, 3, 4]; + x := arr[2..]; + return unit; + } + "#; + assert!(zlup::parse(source2).is_ok(), "arr[2..] should parse"); + + // arr[..] - entire slice + let source3 = r#" + fn test() -> unit { + arr: [4]i32 = [1, 2, 3, 4]; + x := arr[..]; + return unit; + } + "#; + assert!(zlup::parse(source3).is_ok(), "arr[..] should parse"); +} + +/// Slice of parameter array is allowed (caller owns it). +#[test] +fn slice_of_param_allowed() { + let source = r#" + fn ok(arr: []i32) -> []i32 { + return arr[0..2]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // Slicing a parameter is safe - the caller owns the data + assert!(result.is_ok(), "Slicing parameter array should be allowed: {:?}", result.err()); +} + +/// Returning a tuple containing a reference to local should be rejected. +#[test] +fn return_tuple_with_local_reference_rejected() { + let source = r#" + fn bad() -> (i32, *i64) { + x := 42; + return (1, &x); + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Returning tuple with reference to local should fail"); +} + +/// Returning a struct containing a reference to local should be rejected. +#[test] +fn return_struct_with_local_reference_rejected() { + let source = r#" + Wrapper := struct { ptr: *i64 }; + fn bad() -> Wrapper { + x := 42; + return Wrapper { ptr: &x }; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Returning struct with reference to local should fail"); +} + +/// Returning an array containing references to locals should be rejected. +#[test] +fn return_array_with_local_references_rejected() { + let source = r#" + fn bad() -> [2]*i64 { + x := 1; + y := 2; + return [&x, &y]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Returning array with references to locals should fail"); +} + +/// Taking address of parameter and returning is allowed (caller owns it). +#[test] +fn return_address_of_param_allowed() { + let source = r#" + fn ok(x: i32) -> *i32 { + return &x; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // Parameters are owned by caller, so this is safe + // (the reference is valid for the caller's scope) + assert!(result.is_ok(), "Returning address of parameter should be allowed: {:?}", result.err()); +} + +// ============================================================================= +// @swap Additional Safety Tests +// ============================================================================= + +/// @swap requires pointer arguments. +#[test] +fn swap_requires_pointers() { + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + @swap(a, b); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "@swap without & should fail"); +} + +/// @swap with three arguments should fail. +#[test] +fn swap_too_many_args_rejected() { + let source = r#" + pub fn main() -> unit { + a := 1; + b := 2; + c := 3; + @swap(&a, &b, &c); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "@swap with 3 args should fail"); +} + +// ============================================================================= +// Qubit Safety Tests +// ============================================================================= + +/// Measuring the same qubit twice in a tick should be rejected (strict mode). +#[test] +fn duplicate_qubit_in_tick_measurement_rejected() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + pz q[0]; + pz q[1]; + tick { + mz(u1) q[0]; + mz(u1) q[0]; + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Duplicate qubit in tick should fail in strict mode"); +} + +/// Using the same qubit twice in different gates within a tick should be rejected. +#[test] +fn duplicate_qubit_in_tick_gates_rejected() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + pz q[0]; + pz q[1]; + tick { + h q[0]; + x q[0]; + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Using same qubit in multiple gates within tick should fail"); +} + +/// Gate on unprepared qubit should be rejected in strict mode. +#[test] +fn gate_on_unprepared_qubit_rejected() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + h q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Gate on unprepared qubit should fail in strict mode"); +} + +/// Gate after pz (prepare) should succeed. +#[test] +fn gate_after_prepare_allowed() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + pz q[0]; + h q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Gate after prepare should succeed: {:?}", result.err()); +} + +/// Mixed gate and measurement on same qubit in tick should be rejected. +#[test] +fn mixed_gate_measurement_same_qubit_in_tick_rejected() { + let source = r#" + pub fn main() -> unit { + q := qalloc(2); + pz q[0]; + pz q[1]; + tick { + h q[0]; + mz(u1) q[0]; + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Gate and measurement on same qubit in tick should fail"); +} + +/// Different qubits in tick operations should succeed. +#[test] +fn different_qubits_in_tick_allowed() { + let source = r#" + pub fn main() -> unit { + q := qalloc(4); + pz q[0]; + pz q[1]; + pz q[2]; + pz q[3]; + tick { + h q[0]; + x q[1]; + mz(u1) q[2]; + mz(u1) q[3]; + } + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Different qubits in tick should succeed: {:?}", result.err()); +} + +// ============================================================================= +// Additional Slice Syntax Tests +// ============================================================================= + +/// Slice with variable bounds should parse. +#[test] +fn slice_with_variable_bounds_parses() { + let source = r#" + fn test(start: usize, end: usize) -> unit { + arr: [10]i32 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + x := arr[start..end]; + return unit; + } + "#; + assert!(zlup::parse(source).is_ok(), "Slice with variable bounds should parse"); +} + +/// Slice with expression bounds should parse. +#[test] +fn slice_with_expression_bounds_parses() { + let source = r#" + fn test() -> unit { + arr: [10]i32 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + n := 5; + x := arr[n - 2..n + 2]; + return unit; + } + "#; + assert!(zlup::parse(source).is_ok(), "Slice with expression bounds should parse"); +} + +/// Slice used in assignment should parse. +#[test] +fn slice_assignment_parses() { + let source = r#" + fn test() -> unit { + arr: [4]i32 = [1, 2, 3, 4]; + slice := arr[1..3]; + return unit; + } + "#; + let result = zlup::parse(source); + assert!(result.is_ok(), "Slice assignment should parse: {:?}", result.err()); +} + +/// Multiple slicing operations should parse. +#[test] +fn multiple_slices_parse() { + let source = r#" + fn test() -> unit { + arr: [10]i32 = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + a := arr[0..3]; + b := arr[3..6]; + c := arr[6..]; + d := arr[..4]; + return unit; + } + "#; + let result = zlup::parse(source); + assert!(result.is_ok(), "Multiple slices should parse: {:?}", result.err()); +} + +// ============================================================================= +// Slice Type Semantics Tests +// ============================================================================= + +/// Re-slicing a slice should return a slice type. +/// NOTE: Variable names must not be gate names (s, h, x, y, z, t are gates). +#[test] +fn reslice_returns_slice_type() { + let source = r#" + fn reslice(data: []i32) -> []i32 { + return data[1..3]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Re-slicing should return slice type: {:?}", result.err()); +} + +/// Indexing a slice with an integer should return the element type. +/// NOTE: Variable names must not be gate names (s, h, x, y, z, t are gates). +#[test] +fn slice_index_returns_element_type() { + let source = r#" + fn get_element(data: []i32) -> i32 { + return data[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Slice index should return element type: {:?}", result.err()); +} + +/// Chained slicing: arr[1..5][0..2] should work. +#[test] +fn chained_slicing_allowed() { + let source = r#" + fn chain(arr: []i32) -> []i32 { + return arr[1..5][0..2]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Chained slicing should be allowed: {:?}", result.err()); +} + +/// Array type [N]T is distinct from slice type []T. +#[test] +fn array_type_distinct_from_slice() { + // Returning array when slice expected should fail + let source = r#" + fn bad() -> []i32 { + arr: [3]i32 = [1, 2, 3]; + return arr; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // This should fail because [3]i32 is not []i32 + assert!(result.is_err(), "Array should not be assignable to slice type"); +} + +/// Slicing an array produces a slice type. +#[test] +fn slicing_array_produces_slice() { + let source = r#" + fn to_slice(arr: [5]i32) -> []i32 { + return arr[0..3]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Slicing array should produce slice: {:?}", result.err()); +} + +/// Nested slice types: [][]i32 should work. +#[test] +fn nested_slice_type_allowed() { + let source = r#" + fn nested(matrix: [][]i32) -> []i32 { + return matrix[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Nested slice types should be allowed: {:?}", result.err()); +} + +/// Open-ended slice of parameter is allowed. +#[test] +fn open_slice_of_param_allowed() { + let source = r#" + fn full_slice(arr: []i32) -> []i32 { + return arr[..]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Full slice of param should be allowed: {:?}", result.err()); +} + +/// Slice with start index only. +#[test] +fn slice_from_start_index() { + let source = r#" + fn from_start(arr: []i32) -> []i32 { + return arr[2..]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Slice from start index should work: {:?}", result.err()); +} + +/// Slice with end index only. +#[test] +fn slice_to_end_index() { + let source = r#" + fn to_end(arr: []i32) -> []i32 { + return arr[..5]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Slice to end index should work: {:?}", result.err()); +} + +// ============================================================================= +// Gate Naming Tests +// ============================================================================= + +/// The old 's' gate name should no longer be recognized. +/// 's' is now parsed as a variable identifier, not a gate. +/// Verifies that 's' is no longer valid gate syntax (removed from grammar). +#[test] +fn old_s_gate_name_not_recognized() { + let source = r#" + fn test() -> unit { + q := qalloc(1); + pz q; + s q[0]; + return unit; + } + "#; + // 's' is no longer a valid gate keyword in the grammar, so parsing fails + assert!(zlup::parse(source).is_err()); +} + +/// The 'sz' gate should work correctly. +#[test] +fn sz_gate_works() { + let source = r#" + fn test() -> unit { + q := qalloc(1); + pz q; + sz q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "sz gate should work: {:?}", result.err()); +} + +/// The 'szdg' gate should work correctly. +#[test] +fn szdg_gate_works() { + let source = r#" + fn test() -> unit { + q := qalloc(1); + pz q; + szdg q[0]; + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "szdg gate should work: {:?}", result.err()); +} + +/// Variable named 's' should now be allowed (no longer a gate). +#[test] +fn variable_named_s_allowed() { + let source = r#" + fn test() -> i32 { + s: i32 = 42; + return s; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Variable 's' should be allowed: {:?}", result.err()); +} + +// ============================================================================= +// Optimizer Gate Cancellation Tests +// ============================================================================= + +/// SZ and SZdg should cancel each other. +#[test] +fn optimizer_sz_szdg_cancellation() { + use zlup::optimize::Optimizer; + + let source = r#" + pub fn main() -> unit { + q := qalloc(1); + pz q; + sz q[0]; + szdg q[0]; + return unit; + } + "#; + let ast = zlup::parse(source).expect("Should parse"); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats().gates_cancelled, 2, "SZ and SZdg should cancel"); +} + +/// SZdg followed by SZ should also cancel. +#[test] +fn optimizer_szdg_sz_cancellation() { + use zlup::optimize::Optimizer; + + let source = r#" + pub fn main() -> unit { + q := qalloc(1); + pz q; + szdg q[0]; + sz q[0]; + return unit; + } + "#; + let ast = zlup::parse(source).expect("Should parse"); + let mut optimizer = Optimizer::new(); + let _optimized = optimizer.optimize(ast); + + assert_eq!(optimizer.stats().gates_cancelled, 2, "SZdg and SZ should cancel"); +} + +// ============================================================================= +// Slice Edge Cases Tests +// ============================================================================= + +/// Triple-chained slicing should work. +#[test] +fn triple_chained_slicing() { + let source = r#" + fn triple(arr: []i32) -> []i32 { + return arr[0..10][2..8][1..4]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Triple-chained slicing should work: {:?}", result.err()); +} + +/// Deeply nested slice types should work. +#[test] +fn deeply_nested_slice_types() { + let source = r#" + fn nested3d(cube: [][][]i32) -> [][]i32 { + return cube[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "3D nested slice types should work: {:?}", result.err()); +} + +/// Indexing 3D nested slice returns 2D slice. +#[test] +fn nested_slice_indexing_returns_correct_type() { + let source = r#" + fn get_row(matrix: [][]i32) -> []i32 { + return matrix[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Indexing 2D slice should return 1D slice: {:?}", result.err()); +} + +/// Slicing then indexing should work. +#[test] +fn slice_then_index() { + let source = r#" + fn slice_index(arr: []i32) -> i32 { + return arr[1..5][0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Slice then index should work: {:?}", result.err()); +} + +/// Indexing then slicing should work on nested types. +#[test] +fn index_then_slice_nested() { + let source = r#" + fn index_slice(matrix: [][]i32) -> []i32 { + return matrix[0][1..5]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Index then slice on nested should work: {:?}", result.err()); +} + +// ============================================================================= +// Array vs Slice Coercion Tests +// ============================================================================= + +/// Returning fixed array where slice expected should fail. +#[test] +fn array_not_coercible_to_slice() { + let source = r#" + fn bad() -> []i32 { + arr: [5]i32 = [1, 2, 3, 4, 5]; + return arr; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Array should not be directly coercible to slice"); +} + +/// Slicing an array parameter to get a slice should work. +/// NOTE: Cannot slice local array and return it (escape analysis prevents this). +#[test] +fn array_sliced_to_slice() { + let source = r#" + fn ok(arr: [5]i32) -> []i32 { + return arr[..]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Array param sliced with [..] should produce slice: {:?}", result.err()); +} + +/// Passing array where slice parameter expected should fail. +#[test] +fn array_param_not_coercible_to_slice_param() { + let source = r#" + fn takes_slice(data: []i32) -> unit { + return unit; + } + + fn caller() -> unit { + arr: [3]i32 = [1, 2, 3]; + takes_slice(arr); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_err(), "Array should not be passable where slice expected"); +} + +/// Passing sliced array to slice parameter should work. +#[test] +fn sliced_array_to_slice_param() { + let source = r#" + fn takes_slice(data: []i32) -> unit { + return unit; + } + + fn caller() -> unit { + arr: [3]i32 = [1i32, 2i32, 3i32]; + takes_slice(arr[..]); + return unit; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + assert!(result.is_ok(), "Sliced array should be passable as slice: {:?}", result.err()); +} + +// ============================================================================= +// Error Message Quality Tests +// ============================================================================= + +/// Type mismatch between array and slice should have clear error. +#[test] +fn array_slice_mismatch_error_message() { + let source = r#" + fn bad() -> []i32 { + arr: [3]i32 = [1, 2, 3]; + return arr; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + + match result { + Err(e) => { + let error_msg = format!("{:?}", e); + // Error should mention the type mismatch + assert!( + error_msg.contains("TypeMismatch") || error_msg.contains("mismatch"), + "Error should indicate type mismatch: {}", error_msg + ); + } + Ok(_) => panic!("Should have failed with type mismatch"), + } +} + +/// Using gate name as variable in quantum context should give helpful error. +#[test] +fn gate_name_variable_in_quantum_context() { + // Using 'h' (Hadamard gate) as a variable name + let source = r#" + fn test() -> unit { + q := qalloc(1); + pz q; + h: i32 = 5; + return unit; + } + "#; + // This should parse but 'h' shadows the gate - the question is whether + // this causes issues. Let's just verify it parses and analyzes. + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + // This might work or fail depending on implementation - we're testing behavior + // Either outcome is fine, we're checking it doesn't crash + let _ = result; +} + +/// Return type mismatch with slice element should be clear. +#[test] +fn slice_element_type_mismatch_error() { + let source = r#" + fn bad(arr: []i32) -> bool { + return arr[0]; + } + "#; + let program = zlup::parse(source).expect("Should parse"); + let mut analyzer = SemanticAnalyzer::new(); + let result = analyzer.analyze(&program); + + assert!(result.is_err(), "Should fail: returning i32 where bool expected"); + if let Err(e) = result { + let error_msg = format!("{:?}", e); + assert!( + error_msg.contains("i32") || error_msg.contains("bool"), + "Error should mention the types involved: {}", error_msg + ); + } +} diff --git a/exp/zlup/uv.lock b/exp/zlup/uv.lock new file mode 100644 index 000000000..ec937db65 --- /dev/null +++ b/exp/zlup/uv.lock @@ -0,0 +1,618 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554, upload-time = "2026-02-01T12:30:56.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, +] + +[[package]] +name = "backrefs" +version = "6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/e3/bb3a439d5cb255c4774724810ad8073830fac9c9dee123555820c1bcc806/backrefs-6.1.tar.gz", hash = "sha256:3bba1749aafe1db9b915f00e0dd166cba613b6f788ffd63060ac3485dc9be231", size = 7011962, upload-time = "2025-11-15T14:52:08.323Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ee/c216d52f58ea75b5e1841022bbae24438b19834a29b163cb32aa3a2a7c6e/backrefs-6.1-py310-none-any.whl", hash = "sha256:2a2ccb96302337ce61ee4717ceacfbf26ba4efb1d55af86564b8bbaeda39cac1", size = 381059, upload-time = "2025-11-15T14:51:59.758Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9a/8da246d988ded941da96c7ed945d63e94a445637eaad985a0ed88787cb89/backrefs-6.1-py311-none-any.whl", hash = "sha256:e82bba3875ee4430f4de4b6db19429a27275d95a5f3773c57e9e18abc23fd2b7", size = 392854, upload-time = "2025-11-15T14:52:01.194Z" }, + { url = "https://files.pythonhosted.org/packages/37/c9/fd117a6f9300c62bbc33bc337fd2b3c6bfe28b6e9701de336b52d7a797ad/backrefs-6.1-py312-none-any.whl", hash = "sha256:c64698c8d2269343d88947c0735cb4b78745bd3ba590e10313fbf3f78c34da5a", size = 398770, upload-time = "2025-11-15T14:52:02.584Z" }, + { url = "https://files.pythonhosted.org/packages/eb/95/7118e935b0b0bd3f94dfec2d852fd4e4f4f9757bdb49850519acd245cd3a/backrefs-6.1-py313-none-any.whl", hash = "sha256:4c9d3dc1e2e558965202c012304f33d4e0e477e1c103663fd2c3cc9bb18b0d05", size = 400726, upload-time = "2025-11-15T14:52:04.093Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/6296bad135bfafd3254ae3648cd152980a424bd6fed64a101af00cc7ba31/backrefs-6.1-py314-none-any.whl", hash = "sha256:13eafbc9ccd5222e9c1f0bec563e6d2a6d21514962f11e7fc79872fd56cbc853", size = 412584, upload-time = "2025-11-15T14:52:05.233Z" }, + { url = "https://files.pythonhosted.org/packages/02/e3/a4fa1946722c4c7b063cc25043a12d9ce9b4323777f89643be74cef2993c/backrefs-6.1-py39-none-any.whl", hash = "sha256:a9e99b8a4867852cad177a6430e31b0f6e495d65f8c6c134b68c14c3c95bf4b0", size = 381058, upload-time = "2025-11-15T14:52:06.698Z" }, +] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/b8/6d51fc1d52cbd52cd4ccedd5b5b2f0f6a11bbf6765c782298b0f3e808541/charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d", size = 209709, upload-time = "2025-10-14T04:40:11.385Z" }, + { url = "https://files.pythonhosted.org/packages/5c/af/1f9d7f7faafe2ddfb6f72a2e07a548a629c61ad510fe60f9630309908fef/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8", size = 148814, upload-time = "2025-10-14T04:40:13.135Z" }, + { url = "https://files.pythonhosted.org/packages/79/3d/f2e3ac2bbc056ca0c204298ea4e3d9db9b4afe437812638759db2c976b5f/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad", size = 144467, upload-time = "2025-10-14T04:40:14.728Z" }, + { url = "https://files.pythonhosted.org/packages/ec/85/1bf997003815e60d57de7bd972c57dc6950446a3e4ccac43bc3070721856/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8", size = 162280, upload-time = "2025-10-14T04:40:16.14Z" }, + { url = "https://files.pythonhosted.org/packages/3e/8e/6aa1952f56b192f54921c436b87f2aaf7c7a7c3d0d1a765547d64fd83c13/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d", size = 159454, upload-time = "2025-10-14T04:40:17.567Z" }, + { url = "https://files.pythonhosted.org/packages/36/3b/60cbd1f8e93aa25d1c669c649b7a655b0b5fb4c571858910ea9332678558/charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313", size = 153609, upload-time = "2025-10-14T04:40:19.08Z" }, + { url = "https://files.pythonhosted.org/packages/64/91/6a13396948b8fd3c4b4fd5bc74d045f5637d78c9675585e8e9fbe5636554/charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e", size = 151849, upload-time = "2025-10-14T04:40:20.607Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7a/59482e28b9981d105691e968c544cc0df3b7d6133152fb3dcdc8f135da7a/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93", size = 151586, upload-time = "2025-10-14T04:40:21.719Z" }, + { url = "https://files.pythonhosted.org/packages/92/59/f64ef6a1c4bdd2baf892b04cd78792ed8684fbc48d4c2afe467d96b4df57/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0", size = 145290, upload-time = "2025-10-14T04:40:23.069Z" }, + { url = "https://files.pythonhosted.org/packages/6b/63/3bf9f279ddfa641ffa1962b0db6a57a9c294361cc2f5fcac997049a00e9c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84", size = 163663, upload-time = "2025-10-14T04:40:24.17Z" }, + { url = "https://files.pythonhosted.org/packages/ed/09/c9e38fc8fa9e0849b172b581fd9803bdf6e694041127933934184e19f8c3/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e", size = 151964, upload-time = "2025-10-14T04:40:25.368Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d1/d28b747e512d0da79d8b6a1ac18b7ab2ecfd81b2944c4c710e166d8dd09c/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db", size = 161064, upload-time = "2025-10-14T04:40:26.806Z" }, + { url = "https://files.pythonhosted.org/packages/bb/9a/31d62b611d901c3b9e5500c36aab0ff5eb442043fb3a1c254200d3d397d9/charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6", size = 155015, upload-time = "2025-10-14T04:40:28.284Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/107e008fa2bff0c8b9319584174418e5e5285fef32f79d8ee6a430d0039c/charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f", size = 99792, upload-time = "2025-10-14T04:40:29.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/66/e396e8a408843337d7315bab30dbf106c38966f1819f123257f5520f8a96/charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d", size = 107198, upload-time = "2025-10-14T04:40:30.644Z" }, + { url = "https://files.pythonhosted.org/packages/b5/58/01b4f815bf0312704c267f2ccb6e5d42bcc7752340cd487bc9f8c3710597/charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69", size = 100262, upload-time = "2025-10-14T04:40:32.108Z" }, + { url = "https://files.pythonhosted.org/packages/ed/27/c6491ff4954e58a10f69ad90aca8a1b6fe9c5d3c6f380907af3c37435b59/charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8", size = 206988, upload-time = "2025-10-14T04:40:33.79Z" }, + { url = "https://files.pythonhosted.org/packages/94/59/2e87300fe67ab820b5428580a53cad894272dbb97f38a7a814a2a1ac1011/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0", size = 147324, upload-time = "2025-10-14T04:40:34.961Z" }, + { url = "https://files.pythonhosted.org/packages/07/fb/0cf61dc84b2b088391830f6274cb57c82e4da8bbc2efeac8c025edb88772/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3", size = 142742, upload-time = "2025-10-14T04:40:36.105Z" }, + { url = "https://files.pythonhosted.org/packages/62/8b/171935adf2312cd745d290ed93cf16cf0dfe320863ab7cbeeae1dcd6535f/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc", size = 160863, upload-time = "2025-10-14T04:40:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/09/73/ad875b192bda14f2173bfc1bc9a55e009808484a4b256748d931b6948442/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897", size = 157837, upload-time = "2025-10-14T04:40:38.435Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fc/de9cce525b2c5b94b47c70a4b4fb19f871b24995c728e957ee68ab1671ea/charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381", size = 151550, upload-time = "2025-10-14T04:40:40.053Z" }, + { url = "https://files.pythonhosted.org/packages/55/c2/43edd615fdfba8c6f2dfbd459b25a6b3b551f24ea21981e23fb768503ce1/charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815", size = 149162, upload-time = "2025-10-14T04:40:41.163Z" }, + { url = "https://files.pythonhosted.org/packages/03/86/bde4ad8b4d0e9429a4e82c1e8f5c659993a9a863ad62c7df05cf7b678d75/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0", size = 150019, upload-time = "2025-10-14T04:40:42.276Z" }, + { url = "https://files.pythonhosted.org/packages/1f/86/a151eb2af293a7e7bac3a739b81072585ce36ccfb4493039f49f1d3cae8c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161", size = 143310, upload-time = "2025-10-14T04:40:43.439Z" }, + { url = "https://files.pythonhosted.org/packages/b5/fe/43dae6144a7e07b87478fdfc4dbe9efd5defb0e7ec29f5f58a55aeef7bf7/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4", size = 162022, upload-time = "2025-10-14T04:40:44.547Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/7aab83774f5d2bca81f42ac58d04caf44f0cc2b65fc6db2b3b2e8a05f3b3/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89", size = 149383, upload-time = "2025-10-14T04:40:46.018Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e8/b289173b4edae05c0dde07f69f8db476a0b511eac556dfe0d6bda3c43384/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569", size = 159098, upload-time = "2025-10-14T04:40:47.081Z" }, + { url = "https://files.pythonhosted.org/packages/d8/df/fe699727754cae3f8478493c7f45f777b17c3ef0600e28abfec8619eb49c/charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224", size = 152991, upload-time = "2025-10-14T04:40:48.246Z" }, + { url = "https://files.pythonhosted.org/packages/1a/86/584869fe4ddb6ffa3bd9f491b87a01568797fb9bd8933f557dba9771beaf/charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a", size = 99456, upload-time = "2025-10-14T04:40:49.376Z" }, + { url = "https://files.pythonhosted.org/packages/65/f6/62fdd5feb60530f50f7e38b4f6a1d5203f4d16ff4f9f0952962c044e919a/charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016", size = 106978, upload-time = "2025-10-14T04:40:50.844Z" }, + { url = "https://files.pythonhosted.org/packages/7a/9d/0710916e6c82948b3be62d9d398cb4fcf4e97b56d6a6aeccd66c4b2f2bd5/charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1", size = 99969, upload-time = "2025-10-14T04:40:52.272Z" }, + { url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" }, + { url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" }, + { url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" }, + { url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" }, + { url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" }, + { url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" }, + { url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" }, + { url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" }, + { url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" }, + { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, + { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, + { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, + { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, + { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, + { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, + { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, + { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, + { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, + { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, + { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, + { url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" }, + { url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" }, + { url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" }, + { url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" }, + { url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" }, + { url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" }, + { url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" }, + { url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" }, + { url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "csscompressor" +version = "0.9.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/2a/8c3ac3d8bc94e6de8d7ae270bb5bc437b210bb9d6d9e46630c98f4abd20c/csscompressor-0.9.5.tar.gz", hash = "sha256:afa22badbcf3120a4f392e4d22f9fff485c044a1feda4a950ecc5eba9dd31a05", size = 237808, upload-time = "2017-11-26T21:13:08.238Z" } + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943, upload-time = "2022-05-02T15:47:16.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, +] + +[[package]] +name = "htmlmin2" +version = "0.1.13" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/31/a76f4bfa885f93b8167cb4c85cf32b54d1f64384d0b897d45bc6d19b7b45/htmlmin2-0.1.13-py3-none-any.whl", hash = "sha256:75609f2a42e64f7ce57dbff28a39890363bde9e7e5885db633317efbdf8c79a2", size = 34486, upload-time = "2023-03-14T21:28:30.388Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "jsmin" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/73/e01e4c5e11ad0494f4407a3f623ad4d87714909f50b17a06ed121034ff6e/jsmin-3.0.1.tar.gz", hash = "sha256:c0959a121ef94542e807a674142606f7e90214a2b3d1eb17300244bbb5cc2bfc", size = 13925, upload-time = "2022-01-16T20:35:59.13Z" } + +[[package]] +name = "markdown" +version = "3.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/b1/af95bcae8549f1f3fd70faacb29075826a0d689a27f232e8cee315efa053/markdown-3.10.1.tar.gz", hash = "sha256:1c19c10bd5c14ac948c53d0d762a04e2fa35a6d58a6b7b1e6bfcbe6fefc0001a", size = 365402, upload-time = "2026-01-21T18:09:28.206Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/1b/6ef961f543593969d25b2afe57a3564200280528caa9bd1082eecdd7b3bc/markdown-3.10.1-py3-none-any.whl", hash = "sha256:867d788939fe33e4b736426f5b9f651ad0c0ae0ecf89df0ca5d1176c70812fe3", size = 107684, upload-time = "2026-01-21T18:09:27.203Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661, upload-time = "2021-02-05T18:55:30.623Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354, upload-time = "2021-02-05T18:55:29.583Z" }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159, upload-time = "2024-08-30T12:24:06.899Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451, upload-time = "2024-08-30T12:24:05.054Z" }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/f5/ed29cd50067784976f25ed0ed6fcd3c2ce9eb90650aa3b2796ddf7b6870b/mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c", size = 10239, upload-time = "2023-11-20T17:51:09.981Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/d4/029f984e8d3f3b6b726bd33cafc473b75e9e44c0f7e80a5b29abc466bdea/mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134", size = 9521, upload-time = "2023-11-20T17:51:08.587Z" }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/27/e2/2ffc356cd72f1473d07c7719d82a8f2cbd261666828614ecb95b12169f41/mkdocs_material-9.7.1.tar.gz", hash = "sha256:89601b8f2c3e6c6ee0a918cc3566cb201d40bf37c3cd3c2067e26fadb8cce2b8", size = 4094392, upload-time = "2025-12-18T09:49:00.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/32/ed071cb721aca8c227718cffcf7bd539620e9799bbf2619e90c757bfd030/mkdocs_material-9.7.1-py3-none-any.whl", hash = "sha256:3f6100937d7d731f87f1e3e3b021c97f7239666b9ba1151ab476cabb96c60d5c", size = 9297166, upload-time = "2025-12-18T09:48:56.664Z" }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847, upload-time = "2023-11-22T19:09:45.208Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728, upload-time = "2023-11-22T19:09:43.465Z" }, +] + +[[package]] +name = "mkdocs-minify-plugin" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "csscompressor" }, + { name = "htmlmin2" }, + { name = "jsmin" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/67/fe4b77e7a8ae7628392e28b14122588beaf6078b53eb91c7ed000fd158ac/mkdocs-minify-plugin-0.8.0.tar.gz", hash = "sha256:bc11b78b8120d79e817308e2b11539d790d21445eb63df831e393f76e52e753d", size = 8366, upload-time = "2024-01-29T16:11:32.982Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/cd/2e8d0d92421916e2ea4ff97f10a544a9bd5588eb747556701c983581df13/mkdocs_minify_plugin-0.8.0-py3-none-any.whl", hash = "sha256:5fba1a3f7bd9a2142c9954a6559a57e946587b21f133165ece30ea145c66aee6", size = 6723, upload-time = "2024-01-29T16:11:31.851Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252, upload-time = "2024-08-25T14:17:24.139Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746, upload-time = "2024-08-25T14:17:22.55Z" }, +] + +[[package]] +name = "pathspec" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/36/e27608899f9b8d4dff0617b2d9ab17ca5608956ca44461ac14ac48b44015/pathspec-1.0.4.tar.gz", hash = "sha256:0210e2ae8a21a9137c0d470578cb0e595af87edaa6ebf12ff176f14a02e0e645", size = 131200, upload-time = "2026-01-27T03:59:46.938Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/3c/2c197d226f9ea224a9ab8d197933f9da0ae0aac5b6e0f884e2b8d9c8e9f7/pathspec-1.0.4-py3-none-any.whl", hash = "sha256:fb6ae2fd4e7c921a165808a552060e722767cfa526f99ca5156ed2ce45a5c723", size = 55206, upload-time = "2026-01-27T03:59:45.137Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pymdown-extensions" +version = "10.20.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737, upload-time = "2025-05-13T15:24:01.64Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722, upload-time = "2025-05-13T15:23:59.629Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" }, + { url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" }, + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" }, + { url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" }, +] + +[[package]] +name = "zlup-docs" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "mkdocs" }, + { name = "mkdocs-material" }, +] + +[package.optional-dependencies] +dev = [ + { name = "mkdocs-minify-plugin" }, +] + +[package.metadata] +requires-dist = [ + { name = "mkdocs", specifier = ">=1.6" }, + { name = "mkdocs-material", specifier = ">=9.5" }, + { name = "mkdocs-minify-plugin", marker = "extra == 'dev'" }, +] +provides-extras = ["dev"] diff --git a/exp/zluppy/Cargo.toml b/exp/zluppy/Cargo.toml new file mode 100644 index 000000000..fb3dd3d93 --- /dev/null +++ b/exp/zluppy/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "zluppy-python" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +keywords.workspace = true +categories.workspace = true +description = "Python bindings for Zluppy quantum programming language" +publish = false + +[lib] +name = "_zluppy" +crate-type = ["cdylib", "rlib"] +# Skip doc tests as they won't work properly in this setup +doctest = false +# Skip unit tests - all testing should be done through Python +test = false + +[dependencies] +# The zlup library crate (with HUGR support for compilation to HUGR) +zlup = { path = "../zlup", features = ["hugr"] } + +# PyO3 for Python bindings +pyo3 = { workspace = true, features = ["extension-module", "abi3-py310"] } + +# Serialization +serde_json.workspace = true + +[lints] +workspace = true diff --git a/exp/zluppy/README.md b/exp/zluppy/README.md new file mode 100644 index 000000000..a0e68128d --- /dev/null +++ b/exp/zluppy/README.md @@ -0,0 +1,48 @@ +# Zluppy + +**EXPERIMENTAL** - Python bindings for the Zlup quantum programming language. + +Zlup is an experimental language exploring alternative syntax for quantum programs. +It complements Guppy (the primary quantum programming language in PECOS) and may +serve as a compilation target or alternative syntax for certain workflows. + +## Installation + +```bash +pip install zluppy +``` + +## Usage + +```python +import zluppy + +# Compile Zluppy source to SLR-AST (returns dict) +ast = zluppy.compile_to_slr(""" + fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + } +""") + +# Compile to SLR-AST JSON string +json_str = zluppy.compile_to_slr_json(source) + +# Check source for errors +zluppy.check(source) # Raises ZluppyError on failure +zluppy.check(source, strict=True) # NASA Power of 10 mode + +# Build programs programmatically +# Note: SlrProgram uses uppercase gate names (SLR-AST convention) +# while Zlup source code uses lowercase (h, cx, etc.) +prog = zluppy.SlrProgram("main") +prog.add_allocator("q", 2) +prog.add_gate("H", [("q", 0)]) +prog.add_gate("CX", [("q", 0), ("q", 1)]) +json_str = prog.to_json() +``` + +## License + +Apache-2.0 diff --git a/exp/zluppy/pyproject.toml b/exp/zluppy/pyproject.toml new file mode 100644 index 000000000..a676a9b91 --- /dev/null +++ b/exp/zluppy/pyproject.toml @@ -0,0 +1,62 @@ +[project] +name = "zluppy" +version = "0.1.0" +description = "Python bindings for Zluppy quantum programming language" +authors = [ + {name = "The PECOS Developers"}, +] +maintainers =[ + {name = "Ciaran Ryan-Anderson", email = "ciaranra@gmail.com"}, +] +dependencies = [] +readme = "README.md" +requires-python = ">= 3.10" +license = "Apache-2.0" +classifiers = [ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Operating System :: OS Independent", + "Topic :: Scientific/Engineering :: Physics", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: Implementation :: CPython", + "Programming Language :: Rust", +] + +[build-system] +requires = ["maturin>=1.2,<2.0"] +build-backend = "maturin" + +[tool.maturin] +module-name = "zluppy._zluppy" +python-source = "python" + +[dependency-groups] +dev = [ + "patchelf; platform_system != 'Windows'", + "pytest>=9.0.2", + "quantum-pecos", +] +test = [ + "pytest>=7.0", + "quantum-pecos", +] + +[tool.uv.sources] +zluppy = { workspace = true } +quantum-pecos = { path = "../python/quantum-pecos", editable = true } + +[tool.pytest.ini_options] +markers = [ + "slow: marks tests as slow (deselect with '-m \"not slow\"')", +] + +[tool.ruff] +lint.extend-select = ["S", "B", "PT"] +lint.ignore = ["S101"] diff --git a/exp/zluppy/python/zluppy/__init__.py b/exp/zluppy/python/zluppy/__init__.py new file mode 100644 index 000000000..b20163daa --- /dev/null +++ b/exp/zluppy/python/zluppy/__init__.py @@ -0,0 +1,164 @@ +"""Zluppy: A Zig/SLR/NASA Power of 10 quantum programming language. + +This module provides Python bindings for compiling and running Zluppy programs. + +Example: + >>> import zluppy + >>> from pecos import hugr_engine + >>> + >>> # Compile and run a Bell state program + >>> hugr_bytes = zluppy.ZluppyEngine().source(''' + ... fn main() -> void { + ... var q = qalloc(2); + ... H(q[0]); + ... CX(q[0], q[1]); + ... } + ... ''').to_hugr_bytes() + >>> + >>> result = hugr_engine().hugr_bytes(hugr_bytes).to_sim().run(shots=100) +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +# Re-export everything from the Rust extension +from zluppy._zluppy import ( + ZluppyError, + compile_to_slr, + compile_to_slr_json, + compile_to_hugr, + compile_file, + compile_file_json, + compile_file_hugr, + check, + check_file, + parse_debug, + version, + SlrProgram, + ZlupProgram, +) + +# Import the Rust ZluppyEngine for wrapping +from zluppy._zluppy import ZluppyEngine as _RustZluppyEngine + +if TYPE_CHECKING: + from pecos_rslib import ShotVec + +__all__ = [ + # Exception + "ZluppyError", + # Source compilation + "compile_to_slr", + "compile_to_slr_json", + "compile_to_hugr", + "check", + "parse_debug", + # File compilation + "compile_file", + "compile_file_json", + "compile_file_hugr", + "check_file", + # Utilities + "version", + # Classes + "SlrProgram", + "ZlupProgram", + "ZluppyEngine", +] + + +class ZluppyEngine: + """Engine for compiling and running Zluppy programs. + + Wraps the Rust ZluppyEngine and adds convenience methods for running + through PECOS's hugr_engine. + + Example: + >>> result = zluppy.ZluppyEngine().source(''' + ... fn main() -> void { + ... var q = qalloc(2); + ... H(q[0]); + ... CX(q[0], q[1]); + ... } + ... ''').run(shots=100) + >>> print(result.to_dict()) + + Or with explicit steps: + >>> engine = zluppy.ZluppyEngine().file("bell.zlp") + >>> hugr_bytes = engine.to_hugr_bytes() + >>> result = hugr_engine().hugr_bytes(hugr_bytes).to_sim().run(shots=100) + """ + + def __init__(self, strict: bool = False) -> None: + """Create a new ZluppyEngine. + + Args: + strict: Enable strict mode (NASA Power of 10 checks). + """ + self._rust_engine = _RustZluppyEngine(strict) + + def source(self, code: str) -> ZluppyEngine: + """Compile Zluppy source code. + + Args: + code: Zluppy source code as a string. + + Returns: + self for method chaining. + + Raises: + ZluppyError: If parsing, semantic analysis, or codegen fails. + """ + self._rust_engine = self._rust_engine.source(code) + return self + + def file(self, path: str) -> ZluppyEngine: + """Compile a .zlp file. + + Args: + path: Path to a .zlp file. + + Returns: + self for method chaining. + + Raises: + IOError: If the file cannot be read. + ZluppyError: If parsing, semantic analysis, or codegen fails. + """ + self._rust_engine = self._rust_engine.file(path) + return self + + def to_hugr_bytes(self) -> bytes: + """Return the compiled HUGR bytes. + + Returns: + HUGR in binary envelope format, suitable for hugr_engine(). + + Raises: + ValueError: If no source has been compiled. + """ + return self._rust_engine.to_hugr_bytes() + + def run(self, shots: int = 1) -> ShotVec: + """Run the compiled program through the simulator. + + Convenience method that calls PECOS's hugr_engine with the compiled HUGR. + + Args: + shots: Number of shots to run. + + Returns: + The simulation result from PECOS. + + Raises: + ValueError: If no source has been compiled. + ImportError: If pecos is not installed. + """ + from pecos import hugr_engine + + hugr_bytes = self.to_hugr_bytes() + return hugr_engine().hugr_bytes(hugr_bytes).to_sim().run(shots=shots) + + def __repr__(self) -> str: + return repr(self._rust_engine) diff --git a/exp/zluppy/src/lib.rs b/exp/zluppy/src/lib.rs new file mode 100644 index 000000000..da77bd83e --- /dev/null +++ b/exp/zluppy/src/lib.rs @@ -0,0 +1,1152 @@ +//! Python bindings for Zluppy via PyO3. +//! +//! This module provides Python access to Zluppy's compiler functionality: +//! +//! ```python +//! import zluppy +//! +//! # Compile to SLR-AST (returns dict) +//! ast = zluppy.compile_to_slr(""" +//! fn main() -> void { +//! var q = qalloc(2); +//! H(q[0]); +//! CX(q[0], q[1]); +//! } +//! """) +//! +//! # Compile to SLR-AST JSON string +//! json_str = zluppy.compile_to_slr_json(source) +//! +//! # Check source for errors +//! zluppy.check(source) # Raises ZluppyError on failure +//! zluppy.check(source, strict=True) # NASA Power of 10 mode +//! +//! # Parse and get AST as string (for debugging) +//! ast_str = zluppy.parse_debug(source) +//! ``` + +use std::path::Path; + +use pyo3::exceptions::{PyIOError, PyValueError}; +use pyo3::prelude::*; + +use ::zlup::codegen::{HugrCodegen, SlrCodegen}; +use ::zlup::semantic::SemanticAnalyzer; + +// ============================================================================= +// Error Types +// ============================================================================= + +pyo3::create_exception!(_zluppy, ZluppyError, pyo3::exceptions::PyException); + +/// Convert a parse error to a Python exception. +fn parse_error_to_py(e: ::zlup::parser::ParseError) -> PyErr { + ZluppyError::new_err(format!( + "Parse error at {}:{}: {}", + e.location.line, e.location.column, e.message + )) +} + +/// Convert a semantic error to a Python exception. +fn semantic_error_to_py(e: ::zlup::semantic::SemanticError) -> PyErr { + ZluppyError::new_err(format!("Semantic error: {}", e)) +} + +/// Convert a codegen error to a Python exception. +fn codegen_error_to_py(e: ::zlup::codegen::slr::SlrError) -> PyErr { + ZluppyError::new_err(format!("Codegen error: {}", e)) +} + +/// Convert a HUGR codegen error to a Python exception. +fn hugr_error_to_py(e: ::zlup::codegen::hugr::HugrError) -> PyErr { + ZluppyError::new_err(format!("HUGR error: {}", e)) +} + +// ============================================================================= +// Core Functions +// ============================================================================= + +/// Compile Zluppy source to SLR-AST and return as a Python dict. +/// +/// Args: +/// source: Zluppy source code as a string +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// dict: SLR-AST as a Python dictionary +/// +/// Raises: +/// ZluppyError: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (source, strict = false))] +fn compile_to_slr(py: Python<'_>, source: &str, strict: bool) -> PyResult> { + // Parse + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // Code generation + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + // Convert to JSON then to Python dict + let json_str = codegen + .to_json(&slr_program) + .map_err(codegen_error_to_py)?; + + // Parse JSON into Python object + let json_module = py.import("json")?; + let result = json_module.call_method1("loads", (json_str,))?; + Ok(result.into()) +} + +/// Compile Zluppy source to SLR-AST JSON string. +/// +/// Args: +/// source: Zluppy source code as a string +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// compact: Return compact JSON (no pretty-printing). Default: False +/// +/// Returns: +/// str: SLR-AST as a JSON string +/// +/// Raises: +/// ZluppyError: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (source, strict = false, compact = false))] +fn compile_to_slr_json(source: &str, strict: bool, compact: bool) -> PyResult { + // Parse + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // Code generation + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + // Convert to JSON + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(codegen_error_to_py) + } else { + codegen.to_json(&slr_program).map_err(codegen_error_to_py) + } +} + +/// Check Zluppy source for errors without compiling. +/// +/// Args: +/// source: Zluppy source code as a string +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// None: If the source is valid +/// +/// Raises: +/// ZluppyError: If parsing or semantic analysis fails +#[pyfunction] +#[pyo3(signature = (source, strict = false))] +fn check(source: &str, strict: bool) -> PyResult<()> { + // Parse + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + Ok(()) +} + +/// Parse Zluppy source and return AST as debug string. +/// +/// This is primarily for debugging and inspection purposes. +/// +/// Args: +/// source: Zluppy source code as a string +/// +/// Returns: +/// str: AST in Rust Debug format +/// +/// Raises: +/// ZluppyError: If parsing fails +#[pyfunction] +fn parse_debug(source: &str) -> PyResult { + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + Ok(format!("{:#?}", program)) +} + +/// Get the Zluppy version. +/// +/// Returns: +/// str: Version string +#[pyfunction] +fn version() -> &'static str { + ::zlup::VERSION +} + +// ============================================================================= +// File-based Functions +// ============================================================================= + +/// Read and return the contents of a Zluppy source file. +fn read_file(path: &str) -> PyResult { + std::fs::read_to_string(path).map_err(|e| PyIOError::new_err(format!("Failed to read {}: {}", path, e))) +} + +/// Get the filename from a path for error reporting. +fn filename_from_path(path: &str) -> String { + Path::new(path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| path.to_string()) +} + +/// Compile a Zluppy source file to SLR-AST and return as a Python dict. +/// +/// Args: +/// path: Path to a .zlp file +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// dict: SLR-AST as a Python dictionary +/// +/// Raises: +/// IOError: If the file cannot be read +/// ZluppyError: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (path, strict = false))] +fn compile_file(py: Python<'_>, path: &str, strict: bool) -> PyResult> { + let source = read_file(path)?; + let filename = filename_from_path(path); + + // Parse with filename for better error messages + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // Code generation + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + // Convert to JSON then to Python dict + let json_str = codegen + .to_json(&slr_program) + .map_err(codegen_error_to_py)?; + + let json_module = py.import("json")?; + let result = json_module.call_method1("loads", (json_str,))?; + Ok(result.into()) +} + +/// Compile a Zluppy source file to SLR-AST JSON string. +/// +/// Args: +/// path: Path to a .zlp file +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// compact: Return compact JSON (no pretty-printing). Default: False +/// +/// Returns: +/// str: SLR-AST as a JSON string +/// +/// Raises: +/// IOError: If the file cannot be read +/// ZluppyError: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (path, strict = false, compact = false))] +fn compile_file_json(path: &str, strict: bool, compact: bool) -> PyResult { + let source = read_file(path)?; + let filename = filename_from_path(path); + + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(codegen_error_to_py) + } else { + codegen.to_json(&slr_program).map_err(codegen_error_to_py) + } +} + +/// Check a Zluppy source file for errors without compiling. +/// +/// Args: +/// path: Path to a .zlp file +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// None: If the source is valid +/// +/// Raises: +/// IOError: If the file cannot be read +/// ZluppyError: If parsing or semantic analysis fails +#[pyfunction] +#[pyo3(signature = (path, strict = false))] +fn check_file(path: &str, strict: bool) -> PyResult<()> { + let source = read_file(path)?; + let filename = filename_from_path(path); + + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + Ok(()) +} + +// ============================================================================= +// HUGR Compilation Functions +// ============================================================================= + +/// Compile Zluppy source to HUGR bytes. +/// +/// The returned bytes can be passed directly to hugr_engine() or sim(). +/// +/// Args: +/// source: Zluppy source code as a string +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// bytes: HUGR in binary envelope format +/// +/// Raises: +/// ZluppyError: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (source, strict = false))] +fn compile_to_hugr(py: Python<'_>, source: &str, strict: bool) -> PyResult> { + let program = ::zlup::parse(source).map_err(parse_error_to_py)?; + + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + let bytes = codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?; + + Ok(pyo3::types::PyBytes::new(py, &bytes).into()) +} + +/// Compile a Zluppy source file to HUGR bytes. +/// +/// The returned bytes can be passed directly to hugr_engine() or sim(). +/// +/// Args: +/// path: Path to a .zlp file +/// strict: Enable strict mode (NASA Power of 10 checks). Default: False +/// +/// Returns: +/// bytes: HUGR in binary envelope format +/// +/// Raises: +/// IOError: If the file cannot be read +/// ZluppyError: If parsing, semantic analysis, or codegen fails +#[pyfunction] +#[pyo3(signature = (path, strict = false))] +fn compile_file_hugr(py: Python<'_>, path: &str, strict: bool) -> PyResult> { + let source = read_file(path)?; + let filename = filename_from_path(path); + + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + let mut analyzer = if strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + let bytes = codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?; + + Ok(pyo3::types::PyBytes::new(py, &bytes).into()) +} + +// ============================================================================= +// SLR-AST Types (for direct construction) +// ============================================================================= + +/// SLR Program builder for constructing AST directly from Python. +/// +/// Example: +/// ```python +/// prog = zluppy.SlrProgram("main") +/// prog.add_allocator("q", 2) +/// prog.add_gate("H", [("q", 0)]) +/// prog.add_gate("CX", [("q", 0), ("q", 1)]) +/// json_str = prog.to_json() +/// ``` +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +struct SlrProgram { + inner: ::zlup::codegen::slr::SlrProgram, +} + +#[pymethods] +impl SlrProgram { + /// Create a new SLR program. + #[new] + fn new(name: &str) -> Self { + Self { + inner: ::zlup::codegen::slr::SlrProgram::new(name), + } + } + + /// Add an allocator declaration. + fn add_allocator(&mut self, name: &str, capacity: usize) { + let decl = ::zlup::codegen::slr::SlrAllocatorDecl::new(name, capacity); + if self.inner.allocator.is_none() { + self.inner.allocator = Some(decl.clone()); + } + self.inner + .declarations + .push(::zlup::codegen::slr::SlrDeclaration::Allocator(decl)); + } + + /// Add a gate operation. + /// + /// Args: + /// gate: Gate name (e.g., "H", "CX", "RZ") + /// targets: List of (allocator_name, index) tuples + /// params: Optional list of parameter values (for parameterized gates) + #[pyo3(signature = (gate, targets, params = None))] + fn add_gate( + &mut self, + gate: &str, + targets: Vec<(String, usize)>, + params: Option>, + ) -> PyResult<()> { + let gate_name = match gate { + // Single-qubit Pauli gates + "H" | "h" => "H", + "X" | "x" => "X", + "Y" | "y" => "Y", + "Z" | "z" => "Z", + // Square root gates (single-qubit) + "SX" | "sx" => "SX", + "SY" | "sy" => "SY", + "SZ" | "sz" => "SZ", + "SXdg" | "sxdg" => "SXdg", + "SYdg" | "sydg" => "SYdg", + "SZdg" | "szdg" => "SZdg", + // T gates + "T" | "t" => "T", + "Tdg" | "tdg" => "Tdg", + // F gates (Clifford face rotations) + "F" | "f" => "F", + "Fdg" | "fdg" => "Fdg", + "F4" | "f4" => "F4", + "F4dg" | "f4dg" => "F4dg", + // Two-qubit controlled gates + "CX" | "cx" => "CX", + "CY" | "cy" => "CY", + "CZ" | "cz" => "CZ", + "CH" | "ch" => "CH", + // Two-qubit Ising gates + "SXX" | "sxx" => "SXX", + "SYY" | "syy" => "SYY", + "SZZ" | "szz" => "SZZ", + "SXXdg" | "sxxdg" => "SXXdg", + "SYYdg" | "syydg" => "SYYdg", + "SZZdg" | "szzdg" => "SZZdg", + // Swap gates + "SWAP" | "swap" => "SWAP", + "iSWAP" | "iswap" => "iSWAP", + // Rotation gates (single-qubit, parameterized) + "RX" | "rx" => "RX", + "RY" | "ry" => "RY", + "RZ" | "rz" => "RZ", + // Rotation gates (two-qubit, parameterized) + "CRZ" | "crz" => "CRZ", + "RZZ" | "rzz" => "RZZ", + // Three-qubit gates + "CCX" | "ccx" => "CCX", + _ => { + return Err(PyValueError::new_err(format!("Unknown gate: {}", gate))); + } + }; + + let slot_refs: Vec<_> = targets + .into_iter() + .map(|(alloc, idx)| ::zlup::codegen::slr::SlrSlotRef::new(alloc, idx)) + .collect(); + + let param_exprs: Vec<_> = params + .unwrap_or_default() + .into_iter() + .map(|v| { + ::zlup::codegen::slr::SlrExpression::Literal( + ::zlup::codegen::slr::SlrLiteralExpr::float(v), + ) + }) + .collect(); + + let gate_op = + ::zlup::codegen::slr::SlrGateOp::new(gate_name, slot_refs).with_params(param_exprs); + self.inner + .body + .push(::zlup::codegen::slr::SlrStatement::Gate(gate_op)); + + Ok(()) + } + + /// Add a prepare operation (reset qubits to |0⟩). + /// + /// Args: + /// allocator: Allocator name + /// slots: Optional list of slot indices. If None, prepares all slots. + #[pyo3(signature = (allocator, slots = None))] + fn add_prepare(&mut self, allocator: &str, slots: Option>) { + let prepare_op = match slots { + Some(s) => ::zlup::codegen::slr::SlrPrepareOp::slots(allocator, s), + None => ::zlup::codegen::slr::SlrPrepareOp::all(allocator), + }; + self.inner + .body + .push(::zlup::codegen::slr::SlrStatement::Prepare(prepare_op)); + } + + /// Convert to JSON string. + #[pyo3(signature = (compact = false))] + fn to_json(&self, compact: bool) -> PyResult { + if compact { + serde_json::to_string(&self.inner) + .map_err(|e| PyValueError::new_err(format!("JSON error: {}", e))) + } else { + serde_json::to_string_pretty(&self.inner) + .map_err(|e| PyValueError::new_err(format!("JSON error: {}", e))) + } + } + + /// Convert to Python dict. + fn to_dict(&self, py: Python<'_>) -> PyResult> { + let json_str = self.to_json(false)?; + let json_module = py.import("json")?; + let result = json_module.call_method1("loads", (json_str,))?; + Ok(result.into()) + } + + fn __repr__(&self) -> String { + format!("SlrProgram(name={:?})", self.inner.name) + } +} + +// ============================================================================= +// ZlupProgram Builder (builds Zlup AST directly) +// ============================================================================= + +/// Builder for constructing Zlup programs programmatically. +/// +/// This builds the Zlup AST directly, which can then be compiled to +/// either SLR-AST or HUGR through the normal compilation pipeline. +/// +/// Example: +/// ```python +/// prog = zluppy.ZlupProgram("main") +/// prog.add_allocator("q", 2) +/// prog.add_gate("h", [("q", 0)]) +/// prog.add_gate("cx", [("q", 0), ("q", 1)]) +/// +/// # Compile to SLR +/// slr_json = prog.compile_to_slr() +/// +/// # Or compile to HUGR +/// hugr_bytes = prog.compile_to_hugr() +/// +/// # Or generate source code +/// source = prog.to_source() +/// ``` +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +struct ZlupProgram { + name: String, + statements: Vec<::zlup::ast::Stmt>, + strict: bool, +} + +#[pymethods] +impl ZlupProgram { + /// Create a new Zlup program builder. + /// + /// Args: + /// name: Program/function name (default: "main") + /// strict: Enable strict mode for compilation (default: False) + #[new] + #[pyo3(signature = (name = "main", strict = false))] + fn new(name: &str, strict: bool) -> Self { + Self { + name: name.to_string(), + statements: Vec::new(), + strict, + } + } + + /// Add a qubit allocator declaration. + /// + /// Args: + /// name: Allocator variable name + /// capacity: Number of qubits to allocate + /// + /// Returns: + /// self: For method chaining + fn add_allocator(&mut self, name: &str, capacity: usize) -> Self { + // Build: var {name} = qalloc({capacity}); + let alloc_call = ::zlup::ast::Expr::Call(Box::new(::zlup::ast::CallExpr { + callee: ::zlup::ast::Expr::Ident(::zlup::ast::Ident { + name: "qalloc".to_string(), + location: None, + }), + args: vec![::zlup::ast::Expr::IntLit(::zlup::ast::IntLit { + value: capacity as i128, + suffix: None, + location: None, + })], + location: None, + })); + + let binding = ::zlup::ast::Binding { + name: name.to_string(), + ty: None, + value: Some(alloc_call), + is_mutable: true, + is_pub: false, + doc_comment: None, + location: None, + }; + + self.statements.push(::zlup::ast::Stmt::Binding(binding)); + self.clone() + } + + /// Add a gate operation. + /// + /// Args: + /// gate: Gate name (e.g., "h", "cx", "rz") + /// targets: List of (allocator_name, index) tuples + /// params: Optional list of parameter values (for rotation gates) + /// + /// Returns: + /// self: For method chaining + #[pyo3(signature = (gate, targets, params = None))] + fn add_gate( + &mut self, + gate: &str, + targets: Vec<(String, usize)>, + params: Option>, + ) -> PyResult { + let gate_kind = match gate.to_lowercase().as_str() { + // Single-qubit Paulis + "x" => ::zlup::ast::GateKind::X, + "y" => ::zlup::ast::GateKind::Y, + "z" => ::zlup::ast::GateKind::Z, + "h" => ::zlup::ast::GateKind::H, + // Phase gates + "s" => ::zlup::ast::GateKind::SZ, + "sdg" => ::zlup::ast::GateKind::SZdg, + "t" => ::zlup::ast::GateKind::T, + "tdg" => ::zlup::ast::GateKind::Tdg, + // Square root gates + "sx" => ::zlup::ast::GateKind::SX, + "sy" => ::zlup::ast::GateKind::SY, + "sz" => ::zlup::ast::GateKind::SZ, + "sxdg" => ::zlup::ast::GateKind::SXdg, + "sydg" => ::zlup::ast::GateKind::SYdg, + "szdg" => ::zlup::ast::GateKind::SZdg, + // Rotation gates + "rx" => ::zlup::ast::GateKind::RX, + "ry" => ::zlup::ast::GateKind::RY, + "rz" => ::zlup::ast::GateKind::RZ, + // Two-qubit gates + "cx" => ::zlup::ast::GateKind::CX, + "cy" => ::zlup::ast::GateKind::CY, + "cz" => ::zlup::ast::GateKind::CZ, + "ch" => ::zlup::ast::GateKind::CH, + // Two-qubit Ising gates + "sxx" => ::zlup::ast::GateKind::SXX, + "syy" => ::zlup::ast::GateKind::SYY, + "szz" => ::zlup::ast::GateKind::SZZ, + "sxxdg" => ::zlup::ast::GateKind::SXXdg, + "syydg" => ::zlup::ast::GateKind::SYYdg, + "szzdg" => ::zlup::ast::GateKind::SZZdg, + "rzz" => ::zlup::ast::GateKind::RZZ, + // Face rotations + "f" => ::zlup::ast::GateKind::F, + "fdg" => ::zlup::ast::GateKind::Fdg, + "f4" => ::zlup::ast::GateKind::F4, + "f4dg" => ::zlup::ast::GateKind::F4dg, + _ => return Err(PyValueError::new_err(format!("Unknown gate: {}", gate))), + }; + + // Build slot references + let slot_refs: Vec<_> = targets + .into_iter() + .map(|(alloc, idx)| ::zlup::ast::SlotRef { + allocator: alloc, + index: Box::new(::zlup::ast::Expr::IntLit(::zlup::ast::IntLit { + value: idx as i128, + suffix: None, + location: None, + })), + location: None, + }) + .collect(); + + // Build parameter expressions + let param_exprs: Vec<_> = params + .unwrap_or_default() + .into_iter() + .map(|v| { + ::zlup::ast::Expr::FloatLit(::zlup::ast::FloatLit { + value: v, + suffix: None, + location: None, + }) + }) + .collect(); + + let gate_op = ::zlup::ast::GateOp { + kind: gate_kind, + targets: slot_refs, + params: param_exprs, + attrs: Vec::new(), + location: None, + }; + + self.statements.push(::zlup::ast::Stmt::Gate(gate_op)); + Ok(self.clone()) + } + + /// Add a prepare operation (reset qubits to |0⟩). + /// + /// Args: + /// allocator: Allocator name + /// slots: Optional list of slot indices. If None, prepares all slots. + /// + /// Returns: + /// self: For method chaining + #[pyo3(signature = (allocator, slots = None))] + fn add_prepare(&mut self, allocator: &str, slots: Option>) -> Self { + let prepare_op = ::zlup::ast::PrepareOp { + allocator: allocator.to_string(), + slots, + location: None, + }; + + self.statements + .push(::zlup::ast::Stmt::Prepare(prepare_op)); + self.clone() + } + + /// Add a measure operation. + /// + /// Args: + /// targets: List of (allocator_name, index) tuples to measure + /// + /// Returns: + /// self: For method chaining + fn add_measure(&mut self, targets: Vec<(String, usize)>) -> Self { + let slot_refs: Vec<_> = targets + .into_iter() + .map(|(alloc, idx)| ::zlup::ast::SlotRef { + allocator: alloc, + index: Box::new(::zlup::ast::Expr::IntLit(::zlup::ast::IntLit { + value: idx as i128, + suffix: None, + location: None, + })), + location: None, + }) + .collect(); + + let measure_op = ::zlup::ast::MeasureOp { + targets: slot_refs, + results: Vec::new(), // No explicit result register + location: None, + }; + + self.statements + .push(::zlup::ast::Stmt::Measure(measure_op)); + self.clone() + } + + /// Compile to SLR-AST JSON. + /// + /// Args: + /// compact: Return compact JSON (default: False) + /// + /// Returns: + /// str: SLR-AST as JSON string + #[pyo3(signature = (compact = false))] + fn compile_to_slr(&self, compact: bool) -> PyResult { + let program = self.build_ast(); + + // Semantic analysis + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // SLR codegen + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(codegen_error_to_py) + } else { + codegen.to_json(&slr_program).map_err(codegen_error_to_py) + } + } + + /// Compile to SLR-AST as Python dict. + fn compile_to_slr_dict(&self, py: Python<'_>) -> PyResult> { + let json_str = self.compile_to_slr(false)?; + let json_module = py.import("json")?; + let result = json_module.call_method1("loads", (json_str,))?; + Ok(result.into()) + } + + /// Compile to HUGR bytes. + /// + /// Returns: + /// bytes: HUGR in binary envelope format + fn compile_to_hugr(&self, py: Python<'_>) -> PyResult> { + let program = self.build_ast(); + + // Semantic analysis + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // HUGR codegen + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + let bytes = codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?; + + Ok(pyo3::types::PyBytes::new(py, &bytes).into()) + } + + /// Generate Zlup source code from the built AST. + /// + /// Returns: + /// str: Zlup source code + fn to_source(&self) -> String { + let program = self.build_ast(); + ::zlup::pretty::pretty_print(&program, &::zlup::pretty::PrettyOptions::default()) + } + + /// Save the program source code to a .zlp file. + /// + /// Args: + /// path: Path to write the .zlp file + /// + /// Raises: + /// IOError: If the file cannot be written + fn save(&self, path: &str) -> PyResult<()> { + let source = self.to_source(); + std::fs::write(path, source).map_err(|e| { + PyIOError::new_err(format!("Failed to write {}: {}", path, e)) + }) + } + + /// Compile via source code generation and parsing. + /// + /// This generates source code, parses it back, and compiles to SLR. + /// Useful for testing that the generated source is valid Zlup code. + /// + /// Args: + /// compact: Return compact JSON (default: False) + /// + /// Returns: + /// str: SLR-AST as JSON string + #[pyo3(signature = (compact = false))] + fn compile_via_source_to_slr(&self, compact: bool) -> PyResult { + let source = self.to_source(); + + // Parse the generated source + let program = ::zlup::parse(&source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // SLR codegen + let mut codegen = SlrCodegen::new(); + let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; + + if compact { + codegen + .to_json_compact(&slr_program) + .map_err(codegen_error_to_py) + } else { + codegen.to_json(&slr_program).map_err(codegen_error_to_py) + } + } + + /// Compile via source code generation and parsing to HUGR. + /// + /// This generates source code, parses it back, and compiles to HUGR. + /// Useful for testing that the generated source is valid Zlup code. + /// + /// Returns: + /// bytes: HUGR in binary envelope format + fn compile_via_source_to_hugr(&self, py: Python<'_>) -> PyResult> { + let source = self.to_source(); + + // Parse the generated source + let program = ::zlup::parse(&source).map_err(parse_error_to_py)?; + + // Semantic analysis + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + // HUGR codegen + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + let bytes = codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?; + + Ok(pyo3::types::PyBytes::new(py, &bytes).into()) + } + + fn __repr__(&self) -> String { + format!( + "ZlupProgram(name={:?}, statements={}, strict={})", + self.name, + self.statements.len(), + self.strict + ) + } +} + +// Internal methods for ZlupProgram (not exposed to Python) +impl ZlupProgram { + /// Build the Zlup AST Program. + fn build_ast(&self) -> ::zlup::ast::Program { + // Create main function with all statements + let main_fn = ::zlup::ast::FnDecl { + name: self.name.clone(), + params: Vec::new(), + return_type: Some(::zlup::ast::TypeExpr::Unit), + body: ::zlup::ast::Block { + label: None, + attrs: Vec::new(), + statements: self.statements.clone(), + trailing_expr: None, + location: None, + }, + is_pub: false, + is_inline: false, + error_mode: None, + doc_comment: None, + location: None, + }; + + ::zlup::ast::Program { + name: self.name.clone(), + declarations: vec![::zlup::ast::TopLevelDecl::Fn(main_fn)], + location: None, + } + } +} + +// ============================================================================= +// ZluppyEngine +// ============================================================================= + +/// Engine for compiling and running Zluppy programs. +/// +/// Provides a fluent interface for compiling Zluppy source to HUGR +/// and running it through PECOS's simulator. +/// +/// Example: +/// ```python +/// result = zluppy.ZluppyEngine().source(''' +/// fn main() -> void { +/// var q = qalloc(2); +/// H(q[0]); +/// CX(q[0], q[1]); +/// } +/// ''').run(shots=100) +/// print(result.to_dict()) +/// ``` +#[pyclass(skip_from_py_object)] +#[derive(Clone)] +struct ZluppyEngine { + strict: bool, + hugr_bytes: Option>, +} + +#[pymethods] +impl ZluppyEngine { + /// Create a new ZluppyEngine. + /// + /// Args: + /// strict: Enable strict mode (NASA Power of 10 checks). Default: False + #[new] + #[pyo3(signature = (strict = false))] + fn new(strict: bool) -> Self { + Self { + strict, + hugr_bytes: None, + } + } + + /// Compile Zluppy source code. + /// + /// Args: + /// code: Zluppy source code as a string + /// + /// Returns: + /// self: For method chaining + fn source(&mut self, code: &str) -> PyResult { + let program = ::zlup::parse(code).map_err(parse_error_to_py)?; + + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + self.hugr_bytes = Some(codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?); + + Ok(self.clone()) + } + + /// Compile a .zlp file. + /// + /// Args: + /// path: Path to a .zlp file + /// + /// Returns: + /// self: For method chaining + fn file(&mut self, path: &str) -> PyResult { + let source = read_file(path)?; + let filename = filename_from_path(path); + + let program = ::zlup::parse_file(&source, filename).map_err(parse_error_to_py)?; + + let mut analyzer = if self.strict { + SemanticAnalyzer::new() + } else { + SemanticAnalyzer::new_permissive() + }; + analyzer.analyze(&program).map_err(semantic_error_to_py)?; + + let mut codegen = HugrCodegen::new(); + let hugr = codegen.compile(&program).map_err(hugr_error_to_py)?; + self.hugr_bytes = Some(codegen.to_bytes(&hugr).map_err(hugr_error_to_py)?); + + Ok(self.clone()) + } + + /// Return the compiled HUGR bytes. + fn to_hugr_bytes(&self, py: Python<'_>) -> PyResult> { + let hugr_bytes = self.hugr_bytes.as_ref().ok_or_else(|| { + PyValueError::new_err("No source compiled. Call .source() or .file() first.") + })?; + Ok(pyo3::types::PyBytes::new(py, hugr_bytes).into()) + } + + fn __repr__(&self) -> String { + let status = if self.hugr_bytes.is_some() { + "compiled" + } else { + "not compiled" + }; + format!("ZluppyEngine(strict={}, status={})", self.strict, status) + } +} + +// ============================================================================= +// Module Definition +// ============================================================================= + +/// Zluppy Python module (internal). +/// +/// A Zig/SLR/NASA Power of 10 reflection of Guppy's approach to quantum programming. +#[pymodule] +fn _zluppy(m: &Bound<'_, PyModule>) -> PyResult<()> { + // Add exception + m.add("ZluppyError", m.py().get_type::())?; + + // Add functions + m.add_function(wrap_pyfunction!(compile_to_slr, m)?)?; + m.add_function(wrap_pyfunction!(compile_to_slr_json, m)?)?; + m.add_function(wrap_pyfunction!(check, m)?)?; + m.add_function(wrap_pyfunction!(parse_debug, m)?)?; + m.add_function(wrap_pyfunction!(version, m)?)?; + + // File-based functions + m.add_function(wrap_pyfunction!(compile_file, m)?)?; + m.add_function(wrap_pyfunction!(compile_file_json, m)?)?; + m.add_function(wrap_pyfunction!(check_file, m)?)?; + + // HUGR compilation functions + m.add_function(wrap_pyfunction!(compile_to_hugr, m)?)?; + m.add_function(wrap_pyfunction!(compile_file_hugr, m)?)?; + + // Add classes + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + + Ok(()) +} diff --git a/exp/zluppy/tests/test_zluppy.py b/exp/zluppy/tests/test_zluppy.py new file mode 100644 index 000000000..92f846492 --- /dev/null +++ b/exp/zluppy/tests/test_zluppy.py @@ -0,0 +1,987 @@ +"""Tests for the Zluppy Python bindings.""" + +import json + +import pytest + +import zluppy + + +# ============================================================================= +# Version Tests +# ============================================================================= + + +def test_version(): + """Test that version returns a non-empty string.""" + v = zluppy.version() + assert isinstance(v, str) + assert len(v) > 0 + assert "." in v # Should be semantic version like "0.1.0" + + +# ============================================================================= +# compile_to_slr Tests +# ============================================================================= + + +def test_compile_to_slr_bell_state(): + """Test compiling a Bell state program to SLR-AST dict.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + result = zluppy.compile_to_slr(source) + + assert isinstance(result, dict) + assert result["type"] == "Program" + assert result["name"] == "main" + assert result["allocator"]["name"] == "q" + assert result["allocator"]["capacity"] == 2 + + body = result["body"] + assert len(body) == 2 + assert body[0]["gate"] == "H" + assert body[1]["gate"] == "CX" + + +def test_compile_to_slr_rotation_gate(): + """Test compiling a rotation gate program.""" + source = """fn main() -> void { + var q = qalloc(1); + rz(q[0], 1.57); + }""" + + result = zluppy.compile_to_slr(source) + + body = result["body"] + assert len(body) == 1 + assert body[0]["gate"] == "RZ" + assert len(body[0]["params"]) > 0 + + +def test_compile_to_slr_child_allocator(): + """Test compiling with child allocator.""" + source = """fn main() -> void { + var base = qalloc(4); + var q = base.child(2); + h(q[0]); + }""" + + result = zluppy.compile_to_slr(source) + + # Should have multiple allocator declarations + decls = result["declarations"] + assert len(decls) >= 2 + + +def test_compile_to_slr_strict_mode(): + """Test compiling with strict mode enabled.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + }""" + + # Strict mode should still compile valid code + result = zluppy.compile_to_slr(source, strict=True) + assert result["type"] == "Program" + + +def test_compile_to_slr_parse_error(): + """Test that parse errors raise ZluppyError.""" + source = "fn main() -> void { h(q[0] }" # Missing closing paren + + with pytest.raises(zluppy.ZluppyError) as exc_info: + zluppy.compile_to_slr(source) + + assert "parse error" in str(exc_info.value).lower() or "expected" in str(exc_info.value).lower() + + +def test_compile_to_slr_semantic_error(): + """Test that semantic errors raise ZluppyError.""" + source = """fn main() -> void { + h(undefined_var[0]); + }""" + + with pytest.raises(zluppy.ZluppyError) as exc_info: + zluppy.compile_to_slr(source) + + assert "semantic" in str(exc_info.value).lower() or "undefined" in str(exc_info.value).lower() + + +# ============================================================================= +# compile_to_slr_json Tests +# ============================================================================= + + +def test_compile_to_slr_json_pretty(): + """Test compiling to pretty-printed JSON string.""" + source = """fn main() -> void { + var q = qalloc(1); + h(q[0]); + }""" + + result = zluppy.compile_to_slr_json(source) + + assert isinstance(result, str) + # Pretty-printed JSON has newlines + assert "\n" in result + + # Should be valid JSON + parsed = json.loads(result) + assert parsed["type"] == "Program" + + +def test_compile_to_slr_json_compact(): + """Test compiling to compact JSON string.""" + source = """fn main() -> void { + var q = qalloc(1); + h(q[0]); + }""" + + result = zluppy.compile_to_slr_json(source, compact=True) + + assert isinstance(result, str) + # Compact JSON should not have indented newlines + assert "\n " not in result + + # Should be valid JSON + parsed = json.loads(result) + assert parsed["type"] == "Program" + + +def test_compile_to_slr_json_strict(): + """Test compiling to JSON with strict mode.""" + source = """fn main() -> void { + var q = qalloc(1); + x(q[0]); + }""" + + result = zluppy.compile_to_slr_json(source, strict=True) + parsed = json.loads(result) + assert parsed["type"] == "Program" + + +# ============================================================================= +# check Tests +# ============================================================================= + + +def test_check_valid_program(): + """Test checking a valid program.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + # Should not raise + zluppy.check(source) + + +def test_check_strict_mode(): + """Test checking in strict mode.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + }""" + + # Should not raise for valid code + zluppy.check(source, strict=True) + + +def test_check_parse_error(): + """Test that check raises on parse error.""" + source = "fn main( void { }" # Missing closing paren + + with pytest.raises(zluppy.ZluppyError): + zluppy.check(source) + + +def test_check_semantic_error(): + """Test that check raises on semantic error.""" + source = """fn main() -> void { + UnknownGate(q[0]); + }""" + + with pytest.raises(zluppy.ZluppyError): + zluppy.check(source) + + +# ============================================================================= +# parse_debug Tests +# ============================================================================= + + +def test_parse_debug(): + """Test getting debug AST string.""" + source = "fn main() -> void { var q = qalloc(1); }" + + result = zluppy.parse_debug(source) + + assert isinstance(result, str) + assert "Program" in result + assert "FnDecl" in result + + +def test_parse_debug_error(): + """Test that parse_debug raises on parse error.""" + source = "fn main() -> void { }" # Actually valid, use invalid syntax + + with pytest.raises(zluppy.ZluppyError): + zluppy.parse_debug("fn main( void { }") + + +# ============================================================================= +# SlrProgram Builder Tests +# ============================================================================= + + +def test_slr_program_create(): + """Test creating an SlrProgram.""" + prog = zluppy.SlrProgram("test") + + assert repr(prog) == 'SlrProgram(name="test")' + + +def test_slr_program_add_allocator(): + """Test adding an allocator to SlrProgram.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + + result = prog.to_dict() + + assert result["allocator"]["name"] == "q" + assert result["allocator"]["capacity"] == 2 + + +def test_slr_program_add_gate(): + """Test adding gates to SlrProgram.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + prog.add_gate("H", [("q", 0)]) + prog.add_gate("CX", [("q", 0), ("q", 1)]) + + result = prog.to_dict() + + body = result["body"] + assert len(body) == 2 + assert body[0]["gate"] == "H" + assert body[1]["gate"] == "CX" + + +def test_slr_program_add_rotation_gate(): + """Test adding rotation gate with parameters.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate("RZ", [("q", 0)], [3.14159]) + + result = prog.to_dict() + + body = result["body"] + assert len(body) == 1 + assert body[0]["gate"] == "RZ" + assert len(body[0]["params"]) == 1 + + +def test_slr_program_add_prepare(): + """Test adding prepare operation.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + prog.add_prepare("q") # Prepare all + + result = prog.to_dict() + + body = result["body"] + assert len(body) == 1 + assert body[0]["type"] == "PrepareOp" + + +def test_slr_program_add_prepare_slots(): + """Test adding prepare operation with specific slots.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 3) + prog.add_prepare("q", [0, 1]) # Prepare specific slots + + result = prog.to_dict() + + body = result["body"] + assert len(body) == 1 + assert body[0]["type"] == "PrepareOp" + + +def test_slr_program_to_json(): + """Test converting SlrProgram to JSON.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate("H", [("q", 0)]) + + result = prog.to_json() + + assert isinstance(result, str) + parsed = json.loads(result) + assert parsed["name"] == "test" + + +def test_slr_program_to_json_compact(): + """Test converting SlrProgram to compact JSON.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate("X", [("q", 0)]) + + result = prog.to_json(compact=True) + + assert isinstance(result, str) + assert "\n " not in result # No pretty-printing + + +def test_slr_program_unknown_gate(): + """Test that unknown gates raise ValueError.""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + + with pytest.raises(ValueError) as exc_info: + prog.add_gate("UnknownGate", [("q", 0)]) + + assert "Unknown gate" in str(exc_info.value) + + +# ============================================================================= +# Integration Tests +# ============================================================================= + + +def test_roundtrip_compile_and_build(): + """Test that compiled and built programs produce similar structure.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + # Compile from source + compiled = zluppy.compile_to_slr(source) + + # Build equivalent program + prog = zluppy.SlrProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("H", [("q", 0)]) + prog.add_gate("CX", [("q", 0), ("q", 1)]) + built = prog.to_dict() + + # Both should have same structure + assert compiled["type"] == built["type"] + assert compiled["name"] == built["name"] + assert len(compiled["body"]) == len(built["body"]) + + +def test_all_single_qubit_gates(): + """Test all supported single-qubit gates.""" + gates = ["H", "X", "Y", "Z", "SX", "SY", "SZ", "SXdg", "SYdg", "SZdg", "T", "Tdg", "F", "Fdg", "F4", "F4dg"] + + for gate in gates: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate(gate, [("q", 0)]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == gate + + +def test_all_two_qubit_gates(): + """Test all supported two-qubit gates.""" + gates = ["CX", "CY", "CZ", "CH", "SWAP", "iSWAP", "SXX", "SYY", "SZZ", "SXXdg", "SYYdg", "SZZdg"] + + for gate in gates: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + prog.add_gate(gate, [("q", 0), ("q", 1)]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == gate + + +def test_three_qubit_gates(): + """Test three-qubit gates (CCX/Toffoli).""" + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 3) + prog.add_gate("CCX", [("q", 0), ("q", 1), ("q", 2)]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == "CCX" + + +def test_all_rotation_gates(): + """Test all supported rotation gates.""" + # Single-qubit rotations + for gate in ["RX", "RY", "RZ"]: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 1) + prog.add_gate(gate, [("q", 0)], [1.57]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == gate + assert len(result["body"][0]["params"]) == 1 + + +def test_two_qubit_rotation_gates(): + """Test two-qubit rotation gates.""" + # CRZ and RZZ need 2 qubits + 1 angle + for gate in ["CRZ", "RZZ"]: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + prog.add_gate(gate, [("q", 0), ("q", 1)], [1.57]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == gate + assert len(result["body"][0]["params"]) == 1 + + +def test_lowercase_gate_aliases(): + """Test lowercase gate aliases in builder API.""" + # Lowercase names should map to uppercase SLR-AST output + for gate, expected in [("h", "H"), ("x", "X"), ("cx", "CX"), ("rx", "RX")]: + prog = zluppy.SlrProgram("test") + prog.add_allocator("q", 2) + if gate in ["cx"]: + prog.add_gate(gate, [("q", 0), ("q", 1)]) + elif gate in ["rx"]: + prog.add_gate(gate, [("q", 0)], [0.5]) + else: + prog.add_gate(gate, [("q", 0)]) + + result = prog.to_dict() + assert result["body"][0]["gate"] == expected + + +# ============================================================================= +# ZluppyEngine Tests +# ============================================================================= + + +def test_zluppy_engine_source(): + """Test ZluppyEngine with source code.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + + assert isinstance(hugr_bytes, bytes) + assert len(hugr_bytes) > 0 + + +def test_zluppy_engine_file(tmp_path): + """Test ZluppyEngine with file input.""" + # Create a temporary .zlp file + zlp_file = tmp_path / "test.zlp" + zlp_file.write_text("""fn main() -> void { + var q = qalloc(1); + h(q[0]); + }""") + + engine = zluppy.ZluppyEngine().file(str(zlp_file)) + hugr_bytes = engine.to_hugr_bytes() + + assert isinstance(hugr_bytes, bytes) + assert len(hugr_bytes) > 0 + + +def test_zluppy_engine_run(): + """Test ZluppyEngine.run() executes through simulator.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + cx(q[0], q[1]); + }""" + + result = zluppy.ZluppyEngine().source(source).run(shots=10) + + # Check we got results + assert result is not None + result_dict = result.to_dict() + assert "measurements" in result_dict + assert len(result_dict["measurements"]) == 10 + + # Verify Bell state correlations (q0 == q1 for each shot) + for shot in result_dict["measurements"]: + assert shot[0] == shot[1], f"Bell state violation: {shot}" + + +def test_zluppy_engine_run_single_qubit(): + """Test ZluppyEngine with single qubit circuit.""" + source = """fn main() -> void { + var q = qalloc(1); + x(q[0]); + }""" + + result = zluppy.ZluppyEngine().source(source).run(shots=5) + result_dict = result.to_dict() + + # X gate should always give |1⟩ + for shot in result_dict["measurements"]: + assert shot == [1], f"Expected [1], got {shot}" + + +def test_zluppy_engine_no_source_error(): + """Test that to_hugr_bytes raises without source.""" + engine = zluppy.ZluppyEngine() + + with pytest.raises(ValueError) as exc_info: + engine.to_hugr_bytes() + + assert "No source compiled" in str(exc_info.value) + + +def test_zluppy_engine_parse_error(): + """Test that ZluppyEngine raises on parse error.""" + source = "fn main() -> void { h(q[0] }" # Missing closing paren + + with pytest.raises(zluppy.ZluppyError): + zluppy.ZluppyEngine().source(source) + + +def test_zluppy_engine_semantic_error(): + """Test that ZluppyEngine raises on semantic error.""" + source = """fn main() -> void { + h(undefined_var[0]); + }""" + + with pytest.raises(zluppy.ZluppyError): + zluppy.ZluppyEngine().source(source) + + +def test_zluppy_engine_strict_mode(): + """Test ZluppyEngine with strict mode.""" + source = """fn main() -> void { + var q = qalloc(2); + h(q[0]); + }""" + + # Strict mode should work for valid code + engine = zluppy.ZluppyEngine(strict=True).source(source) + hugr_bytes = engine.to_hugr_bytes() + + assert len(hugr_bytes) > 0 + + +def test_zluppy_engine_chaining(): + """Test that ZluppyEngine methods return self for chaining.""" + source = """fn main() -> void { + var q = qalloc(1); + h(q[0]); + }""" + + # All methods should be chainable + result = zluppy.ZluppyEngine().source(source).run(shots=1) + assert result is not None + + +def test_zluppy_engine_repr(): + """Test ZluppyEngine repr.""" + engine = zluppy.ZluppyEngine() + assert "not compiled" in repr(engine) + + engine.source("fn main() -> void { var q = qalloc(1); }") + assert "compiled" in repr(engine) + + +def test_zluppy_engine_file_not_found(): + """Test ZluppyEngine raises on missing file.""" + with pytest.raises(OSError): + zluppy.ZluppyEngine().file("/nonexistent/path/to/file.zlp") + + +# ============================================================================= +# End-to-End Gate Tests (compile through HUGR) +# ============================================================================= + + +def test_engine_ch_gate(): + """Test CH (controlled Hadamard) gate compiles to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + ch(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_ising_gates(): + """Test Ising gates (SXX, SYY, SZZ) compile to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + sxx(q[0], q[1]); + syy(q[0], q[1]); + szz(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_ising_dagger_gates(): + """Test Ising dagger gates compile to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + sxxdg(q[0], q[1]); + syydg(q[0], q[1]); + szzdg(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_rzz_gate(): + """Test RZZ (ZZ rotation) gate compiles to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + rzz(q[0], q[1], 1.57); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_f_gates(): + """Test F gates (Clifford face rotation) compile to HUGR.""" + source = """fn main() -> void { + var q = qalloc(1); + f(q[0]); + fdg(q[0]); + f4(q[0]); + f4dg(q[0]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_ccx_gate(): + """Test CCX (Toffoli) gate compiles to HUGR.""" + source = """fn main() -> void { + var q = qalloc(3); + ccx(q[0], q[1], q[2]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_engine_swap_gates(): + """Test SWAP and iSWAP gates compile to HUGR.""" + source = """fn main() -> void { + var q = qalloc(2); + swap(q[0], q[1]); + iswap(q[0], q[1]); + }""" + + engine = zluppy.ZluppyEngine().source(source) + hugr_bytes = engine.to_hugr_bytes() + assert len(hugr_bytes) > 0 + + +def test_compile_all_new_gates(): + """Test compiling a program with all new gates to SLR.""" + source = """fn main() -> void { + var q = qalloc(3); + // F gates + f(q[0]); + fdg(q[0]); + f4(q[0]); + f4dg(q[0]); + // Two-qubit controlled + ch(q[0], q[1]); + // Ising gates + sxx(q[0], q[1]); + syy(q[0], q[1]); + szz(q[0], q[1]); + sxxdg(q[0], q[1]); + syydg(q[0], q[1]); + szzdg(q[0], q[1]); + // Swap gates + swap(q[0], q[1]); + iswap(q[0], q[1]); + // Rotation + rzz(q[0], q[1], 0.5); + crz(q[0], q[1], 0.5); + // Three-qubit + ccx(q[0], q[1], q[2]); + }""" + + result = zluppy.compile_to_slr(source) + assert result["type"] == "Program" + # Should have 16 operations in body + assert len(result["body"]) == 16 + + +# ============================================================================= +# ZlupProgram Builder Tests +# ============================================================================= + + +def test_zlup_program_create(): + """Test creating a ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + assert repr(prog) == 'ZlupProgram(name="main", statements=0, strict=false)' + + +def test_zlup_program_add_allocator(): + """Test adding an allocator to ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + + source = prog.to_source() + assert "var q = qalloc(2);" in source + + +def test_zlup_program_add_gate(): + """Test adding a gate to ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + source = prog.to_source() + assert "h(q[0]);" in source + assert "cx(q[0], q[1]);" in source + + +def test_zlup_program_add_rotation_gate(): + """Test adding a rotation gate with parameters.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 1) + prog.add_gate("rz", [("q", 0)], params=[1.57]) + + source = prog.to_source() + assert "rz(q[0], 1.57);" in source + + +def test_zlup_program_compile_to_slr(): + """Test compiling ZlupProgram to SLR-AST.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + slr_json = prog.compile_to_slr() + result = json.loads(slr_json) + + assert result["type"] == "Program" + assert result["name"] == "main" + assert len(result["body"]) == 2 + assert result["body"][0]["gate"] == "H" + assert result["body"][1]["gate"] == "CX" + + +def test_zlup_program_compile_to_slr_dict(): + """Test compiling ZlupProgram to SLR-AST dict.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 1) + prog.add_gate("x", [("q", 0)]) + + result = prog.compile_to_slr_dict() + + assert isinstance(result, dict) + assert result["body"][0]["gate"] == "X" + + +def test_zlup_program_compile_to_hugr(): + """Test compiling ZlupProgram to HUGR.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + hugr_bytes = prog.compile_to_hugr() + + assert isinstance(hugr_bytes, bytes) + assert len(hugr_bytes) > 0 + + +def test_zlup_program_method_chaining(): + """Test that ZlupProgram methods support chaining.""" + prog = ( + zluppy.ZlupProgram("main") + .add_allocator("q", 2) + .add_gate("h", [("q", 0)]) + .add_gate("cx", [("q", 0), ("q", 1)]) + ) + + source = prog.to_source() + assert "var q = qalloc(2);" in source + assert "h(q[0]);" in source + assert "cx(q[0], q[1]);" in source + + +def test_zlup_program_all_single_qubit_gates(): + """Test all single-qubit gates in ZlupProgram.""" + gates = ["h", "x", "y", "z", "s", "sdg", "t", "tdg", "sx", "sxdg", "sy", "sydg", "sz", "szdg"] + + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 1) + for gate in gates: + prog.add_gate(gate, [("q", 0)]) + + result = json.loads(prog.compile_to_slr()) + assert len(result["body"]) == len(gates) + + +def test_zlup_program_all_two_qubit_gates(): + """Test all two-qubit gates in ZlupProgram.""" + gates = ["cx", "cy", "cz", "ch", "sxx", "syy", "szz", "sxxdg", "syydg", "szzdg"] + + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + for gate in gates: + prog.add_gate(gate, [("q", 0), ("q", 1)]) + + result = json.loads(prog.compile_to_slr()) + assert len(result["body"]) == len(gates) + + +def test_zlup_program_rotation_gates(): + """Test rotation gates in ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("rx", [("q", 0)], params=[0.5]) + prog.add_gate("ry", [("q", 0)], params=[0.5]) + prog.add_gate("rz", [("q", 0)], params=[0.5]) + prog.add_gate("rzz", [("q", 0), ("q", 1)], params=[0.5]) + + result = json.loads(prog.compile_to_slr()) + assert len(result["body"]) == 4 + assert result["body"][0]["gate"] == "RX" + assert result["body"][3]["gate"] == "RZZ" + + +def test_zlup_program_prepare(): + """Test prepare operation in ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_prepare("q") # Prepare all + prog.add_gate("h", [("q", 0)]) + + source = prog.to_source() + assert "q.prepare_all();" in source + + +def test_zlup_program_measure(): + """Test measure operation in ZlupProgram.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_measure([("q", 0), ("q", 1)]) + + source = prog.to_source() + assert "measure(q[0], q[1]);" in source + + +def test_zlup_program_unknown_gate_error(): + """Test that unknown gates raise an error.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 1) + + with pytest.raises(ValueError) as exc_info: + prog.add_gate("unknown_gate", [("q", 0)]) + + assert "Unknown gate" in str(exc_info.value) + + +def test_zlup_program_strict_mode(): + """Test ZlupProgram with strict mode.""" + prog = zluppy.ZlupProgram("main", strict=True) + assert "strict=true" in repr(prog) + + +def test_zlup_program_save(tmp_path): + """Test saving ZlupProgram to a .zlp file.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + path = tmp_path / "bell.zlp" + prog.save(str(path)) + + # Verify file contents + content = path.read_text() + assert "fn main() -> void {" in content + assert "var q = qalloc(2);" in content + assert "h(q[0]);" in content + assert "cx(q[0], q[1]);" in content + + # Verify the saved file can be compiled + result = zluppy.compile_file(str(path)) + assert result["type"] == "Program" + + +def test_zlup_program_compile_via_source_to_slr(): + """Test compile_via_source_to_slr round-trips correctly.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + # Compile via AST + slr_ast = prog.compile_to_slr() + + # Compile via source (generate -> parse -> compile) + slr_source = prog.compile_via_source_to_slr() + + # Both should produce equivalent results + ast_data = json.loads(slr_ast) + source_data = json.loads(slr_source) + + assert ast_data["name"] == source_data["name"] + assert len(ast_data["body"]) == len(source_data["body"]) + assert [g["gate"] for g in ast_data["body"]] == [g["gate"] for g in source_data["body"]] + + +def test_zlup_program_compile_via_source_to_hugr(): + """Test compile_via_source_to_hugr works.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 2) + prog.add_gate("h", [("q", 0)]) + prog.add_gate("cx", [("q", 0), ("q", 1)]) + + hugr_bytes = prog.compile_via_source_to_hugr() + + assert isinstance(hugr_bytes, bytes) + assert len(hugr_bytes) > 0 + + +def test_zlup_program_roundtrip_all_gates(): + """Test that all gates round-trip through source generation.""" + prog = zluppy.ZlupProgram("main") + prog.add_allocator("q", 3) + + # Add various gates + single_qubit = ["h", "x", "y", "z", "s", "sdg", "t", "tdg", "sx", "sxdg"] + two_qubit = ["cx", "cy", "cz", "ch", "sxx", "syy", "szz"] + + for gate in single_qubit: + prog.add_gate(gate, [("q", 0)]) + for gate in two_qubit: + prog.add_gate(gate, [("q", 0), ("q", 1)]) + prog.add_gate("rz", [("q", 0)], params=[1.57]) + + # Should compile successfully via source + slr = prog.compile_via_source_to_slr() + data = json.loads(slr) + + expected_count = len(single_qubit) + len(two_qubit) + 1 # +1 for rz + assert len(data["body"]) == expected_count diff --git a/exp/zluppy/uv.lock b/exp/zluppy/uv.lock new file mode 100644 index 000000000..148829767 --- /dev/null +++ b/exp/zluppy/uv.lock @@ -0,0 +1,1140 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" +resolution-markers = [ + "python_full_version >= '3.11'", + "python_full_version < '3.11'", +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "backports-zstd" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138, upload-time = "2025-12-29T17:28:06.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/70/766f6ebbb9db2ed75951f0a671ee15931dc69278c84d9f09b08dd6b67c3e/backports_zstd-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a2db17a6d9bf6b4dc223b3f6414aa9db6d1afe9de9bff61d582c2934ca456a0", size = 435664, upload-time = "2025-12-29T17:25:29.201Z" }, + { url = "https://files.pythonhosted.org/packages/55/f8/7b3fad9c6ee5ff3bcd7c941586675007330197ff4a388f01c73198ecc8bb/backports_zstd-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7f16b98ba81780a9517ce6c493e1aea9b7d72de2b1efa08375136c270e1ecba", size = 362060, upload-time = "2025-12-29T17:25:30.94Z" }, + { url = "https://files.pythonhosted.org/packages/68/9e/cad0f508ed7c3fbd07398f22b5bf25aa0523fcf56c84c3def642909e80ae/backports_zstd-1.3.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:1124a169a647671ccb4654a0ef1d0b42d6735c45ce3d0adf609df22fb1f099db", size = 505958, upload-time = "2025-12-29T17:25:32.694Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/96dc55c043b0d86e53ae9608b496196936244c1ecf7e95cdf66d0dbc0f23/backports_zstd-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8410fda08b36202d01ab4503f6787c763898888cb1a48c19fce94711563d3ee3", size = 475571, upload-time = "2025-12-29T17:25:33.9Z" }, + { url = "https://files.pythonhosted.org/packages/20/48/d9c8c8c2a5ac57fc5697f1945254af31407b0c5f80335a175a7c215b4118/backports_zstd-1.3.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab139d1fc0e91a697e82fa834e6404098802f11b6035607174776173ded9a2cc", size = 581199, upload-time = "2025-12-29T17:25:35.566Z" }, + { url = "https://files.pythonhosted.org/packages/0d/ca/7fe70d2d39ed39e26a6c6f6c1dd229f1ab889500d5c90b17527702b1a21e/backports_zstd-1.3.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f3115d203f387f77c23b5461fb6678d282d4f276f9f39298ad242b00120afc7", size = 640846, upload-time = "2025-12-29T17:25:36.86Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d8/5b8580469e70b72402212885bf19b9d31eaf23549b602e0c294edf380e25/backports_zstd-1.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:116f65cce84e215dfac0414924b051faf8d29dc7188cf3944dd1e5be8dd15a32", size = 491061, upload-time = "2025-12-29T17:25:38.721Z" }, + { url = "https://files.pythonhosted.org/packages/cc/dd/17a752263fccd1ba24184b7e89c14cd31553d512e2e5b065f38e63a0ba86/backports_zstd-1.3.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04def169e4a9ae291298124da4e097c6d6545d0e93164f934b716da04d24630a", size = 565071, upload-time = "2025-12-29T17:25:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/df23d3fe664b2497ab2ec01dc012cb9304e7d568c67f50b1b324fb2d8cbb/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:481b586291ef02a250f03d4c31a37c9881e5e93556568abbd20ca1ad720d443f", size = 481518, upload-time = "2025-12-29T17:25:41.925Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cd/e50dd85fde890c5d79e1ed5dc241f1c45f87b6c12571fdb60add57f2ee66/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0290979eea67f7275fa42d5859cc5bea94f2c08cca6bc36396673476773d2bad", size = 509464, upload-time = "2025-12-29T17:25:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/d3/bb/e429156e4b834837fe78b4f32ed512491aea39415444420c79ccd3aa0526/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01c699d8c803dc9f9c9d6ede21b75ec99f45c3b411821011692befca538928cb", size = 585563, upload-time = "2025-12-29T17:25:45.038Z" }, + { url = "https://files.pythonhosted.org/packages/95/c0/1a0d245325827242aefe76f4f3477ec183b996b8db5105698564f8303481/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2c662912cfc1a5ebd1d2162ac651549d58bd3c97a8096130ec13c703fca355f2", size = 562889, upload-time = "2025-12-29T17:25:46.576Z" }, + { url = "https://files.pythonhosted.org/packages/93/42/126b2bc7540a15452c3ebdf190ebfea8a8644e29b22f4e10e2a6aa2389e4/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3180c8eb085396928e9946167e610aa625922b82c3e2263c5f17000556370168", size = 631423, upload-time = "2025-12-29T17:25:47.81Z" }, + { url = "https://files.pythonhosted.org/packages/dc/32/018e49657411582569032b7d1bb5d62e514aad8b44952de740ec6250588d/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5b9a8c75a294e7ffa18fc8425a763facc366435a8b442e4dffdc19fa9499a22c", size = 495122, upload-time = "2025-12-29T17:25:49.377Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9e/cdd1d2e1d3612bb90d9cf9b23bea06f2155cdafccd8b6f28a1c4d7750004/backports_zstd-1.3.0-cp310-cp310-win32.whl", hash = "sha256:845defdb172385f17123d92a00d2e952d341e9ae310bfa2410c292bf03846034", size = 288573, upload-time = "2025-12-29T17:25:51.167Z" }, + { url = "https://files.pythonhosted.org/packages/55/7c/2e9c80f08375bd14262cefa69297a926134f517c9955c0795eec5e1d470e/backports_zstd-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:43a9fea6299c801da85221e387b32d90a9ad7c62aa2a34edf525359ce5ad8f3a", size = 313506, upload-time = "2025-12-29T17:25:52.778Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5d/fa67e8174f54db44eb33498abb7f98bea4f2329e873b225391bda0113a5e/backports_zstd-1.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:df8473cb117e1316e6c6101f2724e025bd8f50af2dc009d0001c0aabfb5eb57c", size = 288688, upload-time = "2025-12-29T17:25:54.012Z" }, + { url = "https://files.pythonhosted.org/packages/ac/28/ed31a0e35feb4538a996348362051b52912d50f00d25c2d388eccef9242c/backports_zstd-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:249f90b39d3741c48620021a968b35f268ca70e35f555abeea9ff95a451f35f9", size = 435660, upload-time = "2025-12-29T17:25:55.207Z" }, + { url = "https://files.pythonhosted.org/packages/00/0d/3db362169d80442adda9dd563c4f0bb10091c8c1c9a158037f4ecd53988e/backports_zstd-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0e71e83e46154a9d3ced6d4de9a2fea8207ee1e4832aeecf364dc125eda305c", size = 362056, upload-time = "2025-12-29T17:25:56.729Z" }, + { url = "https://files.pythonhosted.org/packages/bd/00/b67ba053a7d6f6dbe2f8a704b7d3a5e01b1d2e2e8edbc9b634f2702ef73c/backports_zstd-1.3.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:cbc6193acd21f96760c94dd71bf32b161223e8503f5277acb0a5ab54e5598957", size = 505957, upload-time = "2025-12-29T17:25:57.941Z" }, + { url = "https://files.pythonhosted.org/packages/6f/3e/2667c0ddb53ddf28667e330bf9fe92e8e17705a481c9b698e283120565f7/backports_zstd-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1df583adc0ae84a8d13d7139f42eade6d90182b1dd3e0d28f7df3c564b9fd55d", size = 475569, upload-time = "2025-12-29T17:25:59.075Z" }, + { url = "https://files.pythonhosted.org/packages/eb/86/4052473217bd954ccdffda5f7264a0e99e7c4ecf70c0f729845c6a45fc5a/backports_zstd-1.3.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d833fc23aa3cc2e05aeffc7cfadd87b796654ad3a7fb214555cda3f1db2d4dc2", size = 581196, upload-time = "2025-12-29T17:26:00.508Z" }, + { url = "https://files.pythonhosted.org/packages/e5/bd/064f6fdb61db3d2c473159ebc844243e650dc032de0f8208443a00127925/backports_zstd-1.3.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:142178fe981061f1d2a57c5348f2cd31a3b6397a35593e7a17dbda817b793a7f", size = 640888, upload-time = "2025-12-29T17:26:02.134Z" }, + { url = "https://files.pythonhosted.org/packages/d8/09/0822403f40932a165a4f1df289d41653683019e4fd7a86b63ed20e9b6177/backports_zstd-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eed0a09a163f3a8125a857cb031be87ed052e4a47bc75085ed7fca786e9bb5b", size = 491100, upload-time = "2025-12-29T17:26:03.418Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/f5ac28d74039b7e182a780809dc66b9dbfc893186f5d5444340bba135389/backports_zstd-1.3.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60aa483fef5843749e993dde01229e5eedebca8c283023d27d6bf6800d1d4ce3", size = 565071, upload-time = "2025-12-29T17:26:05.022Z" }, + { url = "https://files.pythonhosted.org/packages/e1/ac/50209aeb92257a642ee987afa1e61d5b6731ab6bf0bff70905856e5aede6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ea0886c1b619773544546e243ed73f6d6c2b1ae3c00c904ccc9903a352d731e1", size = 481519, upload-time = "2025-12-29T17:26:06.255Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/b06f64199fb4b2e9437cedbf96d0155ca08aeec35fe81d41065acd44762e/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e137657c830a5ce99be40a1d713eb1d246bae488ada28ff0666ac4387aebdd5", size = 509465, upload-time = "2025-12-29T17:26:07.602Z" }, + { url = "https://files.pythonhosted.org/packages/f4/37/2c365196e61c8fffbbc930ffd69f1ada7aa1c7210857b3e565031c787ac6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94048c8089755e482e4b34608029cf1142523a625873c272be2b1c9253871a72", size = 585552, upload-time = "2025-12-29T17:26:08.911Z" }, + { url = "https://files.pythonhosted.org/packages/93/8d/c2c4f448bb6b6c9df17410eaedce415e8db0eb25b60d09a3d22a98294d09/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d339c1ec40485e97e600eb9a285fb13169dbf44c5094b945788a62f38b96e533", size = 562893, upload-time = "2025-12-29T17:26:10.566Z" }, + { url = "https://files.pythonhosted.org/packages/74/e8/2110d4d39115130f7514cbbcec673a885f4052bb68d15e41bc96a7558856/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aeee9210c54cf8bf83f4d263a6d0d6e7a0298aeb5a14a0a95e90487c5c3157c", size = 631462, upload-time = "2025-12-29T17:26:11.99Z" }, + { url = "https://files.pythonhosted.org/packages/b9/a8/d64b59ae0714fdace14e43873f794eff93613e35e3e85eead33a4f44cd80/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba7114a3099e5ea05cbb46568bd0e08bca2ca11e12c6a7b563a24b86b2b4a67f", size = 495125, upload-time = "2025-12-29T17:26:13.218Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d8/bcff0a091fcf27172c57ae463e49d8dec6dc31e01d7e7bf1ae3aad9c3566/backports_zstd-1.3.0-cp311-cp311-win32.whl", hash = "sha256:08dfdfb85da5915383bfae680b6ac10ab5769ab22e690f9a854320720011ae8e", size = 288664, upload-time = "2025-12-29T17:26:14.791Z" }, + { url = "https://files.pythonhosted.org/packages/28/1a/379061e2abf8c3150ad51c1baab9ac723e01cf7538860a6a74c48f8b73ee/backports_zstd-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8aac2e7cdcc8f310c16f98a0062b48d0a081dbb82862794f4f4f5bdafde30a4", size = 313633, upload-time = "2025-12-29T17:26:16.31Z" }, + { url = "https://files.pythonhosted.org/packages/35/e7/eca40858883029fc716660106069b23253e2ec5fd34e86b4101c8cfe864b/backports_zstd-1.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:440ef1be06e82dc0d69dbb57177f2ce98bbd2151013ee7e551e2f2b54caa6120", size = 288814, upload-time = "2025-12-29T17:26:17.571Z" }, + { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972, upload-time = "2025-12-29T17:26:18.752Z" }, + { url = "https://files.pythonhosted.org/packages/30/8f/dbe389e60c7e47af488520f31a4aa14028d66da5bf3c60d3044b571eb906/backports_zstd-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb4c386f38323698991b38edcc9c091d46d4713f5df02a3b5c80a28b40e289ea", size = 362124, upload-time = "2025-12-29T17:26:19.995Z" }, + { url = "https://files.pythonhosted.org/packages/55/4b/173beafc99e99e7276ce008ef060b704471e75124c826bc5e2092815da37/backports_zstd-1.3.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f52523d2bdada29e653261abdc9cfcecd9e5500d305708b7e37caddb24909d4e", size = 506378, upload-time = "2025-12-29T17:26:21.855Z" }, + { url = "https://files.pythonhosted.org/packages/df/c8/3f12a411d9a99d262cdb37b521025eecc2aa7e4a93277be3f4f4889adb74/backports_zstd-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3321d00beaacbd647252a7f581c1e1cdbdbda2407f2addce4bfb10e8e404b7c7", size = 476201, upload-time = "2025-12-29T17:26:23.047Z" }, + { url = "https://files.pythonhosted.org/packages/43/dc/73c090e4a2d5671422512e1b6d276ca6ea0cc0c45ec4634789106adc0d66/backports_zstd-1.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88f94d238ef36c639c0ae17cf41054ce103da9c4d399c6a778ce82690d9f4919", size = 581659, upload-time = "2025-12-29T17:26:24.189Z" }, + { url = "https://files.pythonhosted.org/packages/08/4f/11bfcef534aa2bf3f476f52130217b45337f334d8a287edb2e06744a6515/backports_zstd-1.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97d8c78fe20c7442c810adccfd5e3ea6a4e6f4f1fa4c73da2bc083260ebead17", size = 640388, upload-time = "2025-12-29T17:26:25.47Z" }, + { url = "https://files.pythonhosted.org/packages/71/17/8faea426d4f49b63238bdfd9f211a9f01c862efe0d756d3abeb84265a4e2/backports_zstd-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eefda80c3dbfbd924f1c317e7b0543d39304ee645583cb58bae29e19f42948ed", size = 494173, upload-time = "2025-12-29T17:26:26.736Z" }, + { url = "https://files.pythonhosted.org/packages/ba/9d/901f19ac90f3cd999bdcfb6edb4d7b4dc383dfba537f06f533fc9ac4777b/backports_zstd-1.3.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ab5d3b5a54a674f4f6367bb9e0914063f22cd102323876135e9cc7a8f14f17e", size = 568628, upload-time = "2025-12-29T17:26:28.12Z" }, + { url = "https://files.pythonhosted.org/packages/60/39/4d29788590c2465a570c2fae49dbff05741d1f0c8e4a0fb2c1c310f31804/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7558fb0e8c8197c59a5f80c56bf8f56c3690c45fd62f14e9e2081661556e3e64", size = 482233, upload-time = "2025-12-29T17:26:29.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/4b/24c7c9e8ef384b19d515a7b1644a500ceb3da3baeff6d579687da1a0f62b/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:27744870e38f017159b9c0241ea51562f94c7fefcfa4c5190fb3ec4a65a7fc63", size = 509806, upload-time = "2025-12-29T17:26:30.605Z" }, + { url = "https://files.pythonhosted.org/packages/3f/7e/7ba1aeecf0b5859f1855c0e661b4559566b64000f0627698ebd9e83f2138/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b099750755bb74c280827c7d68de621da0f245189082ab48ff91bda0ec2db9df", size = 586037, upload-time = "2025-12-29T17:26:32.201Z" }, + { url = "https://files.pythonhosted.org/packages/4a/1a/18f0402b36b9cfb0aea010b5df900cfd42c214f37493561dba3abac90c4e/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5434e86f2836d453ae3e19a2711449683b7e21e107686838d12a255ad256ca99", size = 566220, upload-time = "2025-12-29T17:26:33.5Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d9/44c098ab31b948bbfd909ec4ae08e1e44c5025a2d846f62991a62ab3ebea/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:407e451f64e2f357c9218f5be4e372bb6102d7ae88582d415262a9d0a4f9b625", size = 630847, upload-time = "2025-12-29T17:26:35.273Z" }, + { url = "https://files.pythonhosted.org/packages/30/33/e74cb2cfb162d2e9e00dad8bcdf53118ca7786cfd467925d6864732f79cc/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a071f3c198c781b2df801070290b7174e3ff61875454e9df93ab7ea9ea832b", size = 498665, upload-time = "2025-12-29T17:26:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a9/67a24007c333ed22736d5cd79f1aa1d7209f09be772ff82a8fd724c1978e/backports_zstd-1.3.0-cp312-cp312-win32.whl", hash = "sha256:21a9a542ccc7958ddb51ae6e46d8ed25d585b54d0d52aaa1c8da431ea158046a", size = 288809, upload-time = "2025-12-29T17:26:38.373Z" }, + { url = "https://files.pythonhosted.org/packages/42/24/34b816118ea913debb2ea23e71ffd0fb2e2ac738064c4ac32e3fb62c18bb/backports_zstd-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:89ea8281821123b071a06b30b80da8e4d8a2b40a4f57315a19850337a21297ac", size = 313815, upload-time = "2025-12-29T17:26:39.665Z" }, + { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7d/53e8da5950cdfc5e8fe23efd5165ce2f4fed5222f9a3292e0cdb03dd8c0d/backports_zstd-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e86e03e3661900955f01afed6c59cae9baa63574e3b66896d99b7de97eaffce9", size = 435463, upload-time = "2025-12-29T17:26:42.152Z" }, + { url = "https://files.pythonhosted.org/packages/da/78/f98e53870f7404071a41e3d04f2ff514302eeeb3279d931d02b220f437aa/backports_zstd-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41974dcacc9824c1effe1c8d2f9d762bcf47d265ca4581a3c63321c7b06c61f0", size = 361740, upload-time = "2025-12-29T17:26:43.377Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ed/2c64706205a944c9c346d95c17f632d4e3468db3ce60efb6f5caa7c0dcae/backports_zstd-1.3.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:3090a97738d6ce9545d3ca5446df43370928092a962cbc0153e5445a947e98ed", size = 505651, upload-time = "2025-12-29T17:26:44.495Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7b/22998f691dc6e0c7e6fa81d611eb4b1f6a72fb27327f322366d4a7ca8fb3/backports_zstd-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddc874638abf03ea1ff3b0525b4a26a8d0adf7cb46a448c3449f08e4abc276b3", size = 475859, upload-time = "2025-12-29T17:26:45.722Z" }, + { url = "https://files.pythonhosted.org/packages/0b/78/0cde898339a339530e5f932634872d2d64549969535447a48d3b98959e11/backports_zstd-1.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db609e57b8ed88b3472930c87e93c08a4bbd5ffeb94608cd9c7c6f0ac0e166c6", size = 581339, upload-time = "2025-12-29T17:26:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1d/e0973e0eebe678c12c146473af2c54cda8a3e63b179785ca1a20727ad69c/backports_zstd-1.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f13033a3dd95f323c067199f2e61b4589a7880188ef4ef356c7ffbdb78a9f11", size = 642182, upload-time = "2025-12-29T17:26:48.545Z" }, + { url = "https://files.pythonhosted.org/packages/82/a2/ac67e79e137eb98aead66c7162bafe3cffcb82ef9cdeb6367ec18d88fbce/backports_zstd-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c4c7bcda5619a754726e7f5b391827f5efbe4bed8e62e9ec7490d42bff18aa6", size = 490807, upload-time = "2025-12-29T17:26:49.789Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/3514b1d065801ae7dce05246e9389003ed8fb1d7c3d71f85aa07a80f41e6/backports_zstd-1.3.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884a94c40f27affe986f394f219a4fd3cbbd08e1cff2e028d29d467574cd266e", size = 566103, upload-time = "2025-12-29T17:26:51.062Z" }, + { url = "https://files.pythonhosted.org/packages/1b/03/10ddb54cbf032e5fe390c0776d3392611b1fc772d6c3cb5a9bcdff4f915f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497f5765126f11a5b3fd8fedfdae0166d1dd867e7179b8148370a3313d047197", size = 481614, upload-time = "2025-12-29T17:26:52.255Z" }, + { url = "https://files.pythonhosted.org/packages/5c/13/21efa7f94c41447f43aee1563b05fc540a235e61bce4597754f6c11c2e97/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a6ff6769948bb29bba07e1c2e8582d5a9765192a366108e42d6581a458475881", size = 509207, upload-time = "2025-12-29T17:26:53.496Z" }, + { url = "https://files.pythonhosted.org/packages/de/e7/12da9256d9e49e71030f0ff75e9f7c258e76091a4eaf5b5f414409be6a57/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1623e5bff1acd9c8ef90d24fc548110f20df2d14432bfe5de59e76fc036824ef", size = 585765, upload-time = "2025-12-29T17:26:54.99Z" }, + { url = "https://files.pythonhosted.org/packages/24/bf/59ca9cb4e7be1e59331bb792e8ef1331828efe596b1a2f8cbbc4e3f70d75/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:622c28306dcc429c8f2057fc4421d5722b1f22968d299025b35d71b50cfd4e03", size = 563852, upload-time = "2025-12-29T17:26:56.371Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ee/5a3eaed9a73bdf2c35dc0c7adc0616a99588e0de28f5ab52f3e0caaaa96f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09a2785e410ed2e812cb39b684ef5eb55083a5897bfd0e6f5de3bbd2c6345f70", size = 632549, upload-time = "2025-12-29T17:26:57.598Z" }, + { url = "https://files.pythonhosted.org/packages/75/b9/c823633afc48a1ac56d6ad34289c8f51b0234685142531bfa8197ca91777/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ade1f4127fdbe36a02f8067d75aa79c1ea1c8a306bf63c7b818bb7b530e1beaa", size = 495104, upload-time = "2025-12-29T17:26:58.826Z" }, + { url = "https://files.pythonhosted.org/packages/a3/8f/6f7030f18fa7307f87b0f57108a50a3a540b6350e2486d1739c0567629a3/backports_zstd-1.3.0-cp313-cp313-win32.whl", hash = "sha256:668e6fb1805b825cb7504c71436f7b28d4d792bb2663ee901ec9a2bb15804437", size = 288447, upload-time = "2025-12-29T17:27:00.036Z" }, + { url = "https://files.pythonhosted.org/packages/a2/82/b1df1bbbe4e6d3ffd364d0bcffdeb6c4361115c1eccd91238dbdd0c07fec/backports_zstd-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:385bdadf0ea8fe6ba780a95e4c7d7f018db7bafdd630932f0f9f0fad05d608ff", size = 313664, upload-time = "2025-12-29T17:27:01.267Z" }, + { url = "https://files.pythonhosted.org/packages/45/0f/60918fe4d3f2881de8f4088d73be4837df9e4c6567594109d355a2d548b6/backports_zstd-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:4321a8a367537224b3559fe7aeb8012b98aea2a60a737e59e51d86e2e856fe0a", size = 288678, upload-time = "2025-12-29T17:27:02.506Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/35f423c0bcd85020d5e7be6ab8d7517843e3e4441071beb5c3bd8c5216cb/backports_zstd-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:10057d66fa4f0a7d3f6419ffb84b4fe61088da572e3ac4446134a1c8089e4166", size = 436155, upload-time = "2025-12-29T17:27:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/f6/14/e504daea24e8916f14ecbc223c354b558d8410cfc846606668ab91d96b38/backports_zstd-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4abf29d706ba05f658ca0247eb55675bcc00e10f12bca15736e45b05f1f2d2dc", size = 362436, upload-time = "2025-12-29T17:27:05.076Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f7/06e178dbab7edb88c2872aebd68b54137e07a169eba1aeedf614014f7036/backports_zstd-1.3.0-cp313-cp313t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:127b0d73c745b0684da3d95c31c0939570810dad8967dfe8231eea8f0e047b2f", size = 507600, upload-time = "2025-12-29T17:27:06.254Z" }, + { url = "https://files.pythonhosted.org/packages/3e/f1/2ce499b81c4389d6fa1eeea7e76f6e0bad48effdbb239da7cbcdaaf24b76/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0205ef809fb38bb5ca7f59fa03993596f918768b9378fb7fbd8a68889a6ce028", size = 475496, upload-time = "2025-12-29T17:27:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/c82a586f2866aabf3a601a521af3c58756d83d98b724fda200016ac5e7e2/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c389b667b0b07915781aa28beabf2481f11a6062a1a081873c4c443b98601a7", size = 580919, upload-time = "2025-12-29T17:27:09.1Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/eb5d9b7c4cb69d1b8ccd011abe244ba6815693b70bed07ed4b77ddda4535/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8e7ac5ef693d49d6fb35cd7bbb98c4762cfea94a8bd2bf2ab112027004f70b11", size = 639913, upload-time = "2025-12-29T17:27:10.433Z" }, + { url = "https://files.pythonhosted.org/packages/11/2c/7296b99df79d9f31174a99c81c1964a32de8996ce2b3068f5bc66b413615/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d5543945aae2a76a850b23f283249424f535de6a622d6002957b7d971e6a36d", size = 494800, upload-time = "2025-12-29T17:27:11.59Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fc/b8ae6e104ba72d20cd5f9dfd9baee36675e89c81d432434927967114f30f/backports_zstd-1.3.0-cp313-cp313t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38be15ebce82737deda2c9410c1f942f1df9da74121049243a009810432db75", size = 570396, upload-time = "2025-12-29T17:27:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/56/60a7a9de7a5bc951ea1106358b413c95183c93480394f3abc541313c8679/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3e3f58c76f4730607a4e0130d629173aa114ae72a5c8d3d5ad94e1bf51f18d8", size = 481980, upload-time = "2025-12-29T17:27:14.317Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bb/93fc1e8e81b8ecba58b0e53a14f7b44375cf837db6354410998f0c4cb6ff/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b808bf889722d889b792f7894e19c1f904bb0e9092d8c0eb0787b939b08bad9a", size = 511358, upload-time = "2025-12-29T17:27:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0f/b165c2a6080d22306975cd86ce97270208493f31a298867e343110570370/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f7be27d56f2f715bcd252d0c65c232146d8e1e039c7e2835b8a3ad3dc88bc508", size = 585492, upload-time = "2025-12-29T17:27:16.986Z" }, + { url = "https://files.pythonhosted.org/packages/26/76/85b4bde76e982b24a7eb57a2fb9868807887bef4d2114a3654a6530a67ef/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:cbe341c7fcc723893663a37175ba859328b907a4e6d2d40a4c26629cc55efb67", size = 568309, upload-time = "2025-12-29T17:27:18.28Z" }, + { url = "https://files.pythonhosted.org/packages/83/64/9490667827a320766fb883f358a7c19171fdc04f19ade156a8c341c36967/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b4116a9e12dfcd834dd9132cf6a94657bf0d328cba5b295f26de26ea0ae1adc8", size = 630518, upload-time = "2025-12-29T17:27:19.525Z" }, + { url = "https://files.pythonhosted.org/packages/ea/43/258587233b728bbff457bdb0c52b3e08504c485a8642b3daeb0bdd5a76bc/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1049e804cc8754290b24dab383d4d6ed0b7f794ad8338813ddcb3907d15a89d0", size = 499429, upload-time = "2025-12-29T17:27:21.063Z" }, + { url = "https://files.pythonhosted.org/packages/32/04/cfab76878f360f124dbb533779e1e4603c801a0f5ada72ae5c742b7c4d7d/backports_zstd-1.3.0-cp313-cp313t-win32.whl", hash = "sha256:7d3f0f2499d2049ec53d2674c605a4b3052c217cc7ee49c05258046411685adc", size = 289389, upload-time = "2025-12-29T17:27:22.287Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ff/dbcfb6c9c922ab6d98f3d321e7d0c7b34ecfa26f3ca71d930fe1ef639737/backports_zstd-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eb2f8fab0b1ea05148394cb34a9e543a43477178765f2d6e7c84ed332e34935e", size = 314776, upload-time = "2025-12-29T17:27:23.458Z" }, + { url = "https://files.pythonhosted.org/packages/01/4b/82e4baae3117806639fe1c693b1f2f7e6133a7cefd1fa2e38018c8edcd68/backports_zstd-1.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c66ad9eb5bfbe28c2387b7fc58ddcdecfb336d6e4e60bcba1694a906c1f21a6c", size = 289315, upload-time = "2025-12-29T17:27:24.601Z" }, + { url = "https://files.pythonhosted.org/packages/95/b7/e843d32122f25d9568e75d1e7a29c00eae5e5728015604f3f6d02259b3a5/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3ab0d5632b84eff4355c42a04668cfe6466f7d390890f718978582bd1ff36949", size = 409771, upload-time = "2025-12-29T17:27:48.869Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a5/d6a897d4b91732f54b4506858f1da65d7a5b2dc0dbe36a23992a64f09f5a/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b97cea95dbb1a97c02afd718155fad93f747815069722107a429804c355e206", size = 339289, upload-time = "2025-12-29T17:27:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/3f/b0/f0ce566ec221b284508eebbf574a779ba4a8932830db6ea03b6176f336a2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:477895f2642f9397aeba69618df2c91d7f336e02df83d1e623ac37c5d3a5115e", size = 420335, upload-time = "2025-12-29T17:27:51.455Z" }, + { url = "https://files.pythonhosted.org/packages/62/6d/bf55652c84c79b2565d3087265bcb097719540a313dee16359a54d83ab4e/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:330172aaf5fd3bfa53f49318abc6d1d4238cb043c384cf71f7b8f0fe2fb7ce31", size = 393880, upload-time = "2025-12-29T17:27:52.869Z" }, + { url = "https://files.pythonhosted.org/packages/be/e0/d1feebb70ffeb150e2891c6f09700079f4a60085ebc67529eb1ca72fb5c2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32974e71eff15897ed3f8b7766a753d9f3197ea4f1c9025d80f8de099a691b99", size = 413840, upload-time = "2025-12-29T17:27:54.527Z" }, + { url = "https://files.pythonhosted.org/packages/36/28/3b7be27ae51e418d3a724bbc4cb7fea77b6bd38b5007e333a56b0cb165c8/backports_zstd-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:993e3a34eaba5928a2065545e34bf75c65b9c34ecb67e43d5ef49b16cc182077", size = 299685, upload-time = "2025-12-29T17:27:56.149Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d9/8c9c246e5ea79a4f45d551088b11b61f2dc7efcdc5dbe6df3be84a506e0c/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:968167d29f012cee7b112ad031a8925e484e97e99288e55e4d62962c3a1013e3", size = 409666, upload-time = "2025-12-29T17:27:57.37Z" }, + { url = "https://files.pythonhosted.org/packages/a4/4f/a55b33c314ca8c9074e99daab54d04c5d212070ae7dbc435329baf1b139e/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8f6fc7d62b71083b574193dd8fb3a60e6bb34880cc0132aad242943af301f7a", size = 339199, upload-time = "2025-12-29T17:27:58.542Z" }, + { url = "https://files.pythonhosted.org/packages/9d/13/ce31bd048b1c88d0f65d7af60b6cf89cfbed826c7c978f0ebca9a8a71cfc/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e0f2eca6aac280fdb77991ad3362487ee91a7fb064ad40043fb5a0bf5a376943", size = 420332, upload-time = "2025-12-29T17:28:00.332Z" }, + { url = "https://files.pythonhosted.org/packages/cf/80/c0cdbc533d0037b57248588403a3afb050b2a83b8c38aa608e31b3a4d600/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676eb5e177d4ef528cf3baaeea4fffe05f664e4dd985d3ac06960ef4619c81a9", size = 393879, upload-time = "2025-12-29T17:28:01.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/38/c97428867cac058ed196ccaeddfdf82ecd43b8a65965f2950a6e7547e77a/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:199eb9bd8aca6a9d489c41a682fad22c587dffe57b613d0fe6d492d0d38ce7c5", size = 413842, upload-time = "2025-12-29T17:28:03.113Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ec/6247be6536668fe1c7dfae3eaa9c94b00b956b716957c0fc986ba78c3cc4/backports_zstd-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2524bd6777a828d5e7ccd7bd1a57f9e7007ae654fc2bd1bc1a207f6428674e4a", size = 299684, upload-time = "2025-12-29T17:28:04.856Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "graphviz" +version = "0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" }, +] + +[[package]] +name = "guppylang" +version = "0.21.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "guppylang-internals" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "selene-hugr-qis-compiler" }, + { name = "selene-sim" }, + { name = "tqdm" }, + { name = "types-tqdm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/c3/750540bc30941ff7f9e0a3257acc340a4b10be31905b851c99b2fe4c7268/guppylang-0.21.8.tar.gz", hash = "sha256:4fef55ab273c47ada799896e49fadcc9390a02145471d6dd395e68411f126420", size = 63900, upload-time = "2026-01-09T12:02:30.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/0a/611cddcc5402652c6acab63c80550f1cd4f0cab23fd3590de43d6d84bddc/guppylang-0.21.8-py3-none-any.whl", hash = "sha256:d86365524f79d31dc63823f630fa529e3b4153bd06bf0b4c1c69e618376cfac9", size = 61076, upload-time = "2026-01-09T12:02:29.06Z" }, +] + +[[package]] +name = "guppylang-internals" +version = "0.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hugr" }, + { name = "tket-exts" }, + { name = "typing-extensions" }, + { name = "wasmtime" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cf/a1/39887e4c4d3b320bb25f3e441bb80c62127203ce9317d9e4553011f9e3e3/guppylang_internals-0.27.0.tar.gz", hash = "sha256:751cc83cfa74c0f790e75e254db30060b4f7d849f846d9efca76151fb517f23d", size = 190022, upload-time = "2026-01-09T10:58:12.094Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/b9/962eaebfee7256a6845b6ee9f11677cb4323a9524e801122355615cfa062/guppylang_internals-0.27.0-py3-none-any.whl", hash = "sha256:f6d758040768c5c3701e6a98ec16f24fc1ee66e360ab83de7cb5fe7af1b0dc8e", size = 243885, upload-time = "2026-01-09T10:58:10.386Z" }, +] + +[[package]] +name = "hugr" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphviz" }, + { name = "pydantic" }, + { name = "pydantic-extra-types" }, + { name = "pyzstd" }, + { name = "semver" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/65/29/83e6b0cec631dc051baaee7b25196bd659d4e406944cfdbf0be63d81e3c6/hugr-0.15.0.tar.gz", hash = "sha256:2ba5162fe3cc6b67d28c3d5236d6bfe85c6c1680361720fa3bf7cfd52c3f8003", size = 1107523, upload-time = "2026-01-05T15:37:36.459Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/a2/a218acb5415d3917929ff9ee6b54e4d1a76152620f0dbc1c95da2995335d/hugr-0.15.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:582efc1c63cd6f5a4936efa96a78ee87f19839a8b8ad4c2126ce64a39843fd07", size = 3650398, upload-time = "2026-01-05T15:37:18.319Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b2/6a37d4379deeba6cd81ae230b390c977b4ed93b700a153608fca3a3d7cf6/hugr-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:9cb910d8f6a21d8a7b83cd05aaef50ed05080c530ecf24b01f5ec10f223c94f0", size = 3274855, upload-time = "2026-01-05T15:37:14.007Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fd/dcc803aeb443327dfa292f4e3b87fc75be662a48430b2929a7a12a932cd9/hugr-0.15.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14fdcfadc9b7605be12f132834f89ef8748828aa95278b3dc0a7a260f509e5ed", size = 3618978, upload-time = "2026-01-05T15:36:52.667Z" }, + { url = "https://files.pythonhosted.org/packages/53/0e/501c4b1fc018fbeba3bce288e0924f2d54728b4b85b8149004d9ff7568d8/hugr-0.15.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd872be2c18f1b2511e18b61c3dc1d6d7da570d29934a6c16ccd2d0d6f4bf289", size = 3588012, upload-time = "2026-01-05T15:36:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/f2/34/36a84b2a7288c3ec4a05a6e092976d26ac19d080a29cd215c468c3ecd0bd/hugr-0.15.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e560dde767728b1d33d214728804f836b88d9af1b9e83e96a41bff47ae9bb52e", size = 3869793, upload-time = "2026-01-05T15:37:07.305Z" }, + { url = "https://files.pythonhosted.org/packages/bb/82/d9caf62e81b31a3c6dcc01c850b6e7a4996921027ff14d65223e5e714459/hugr-0.15.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c40baaf57c38eb119123c1b1de6a5158f267f6b21521c95076bcc0b74a003a72", size = 4047330, upload-time = "2026-01-05T15:36:59.854Z" }, + { url = "https://files.pythonhosted.org/packages/d0/80/e61e41ac73e901bb435255a98cf3192cb6c849ebcee9760e102c7108057d/hugr-0.15.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b9f1fd41e34c721b3eafdfcb236d70520dfc13ea8067626f00691f1f5440acd", size = 4146426, upload-time = "2026-01-05T15:37:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/34/83/fc58abf23bbea7b2253aa523d6b128309fc90b1f99dc09064dfbcef94639/hugr-0.15.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11f47ba46331c8511c2f56790ea9ac05c76f631488f1ea6148ac15f8b565dd65", size = 3930571, upload-time = "2026-01-05T15:37:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/58/c3/dae90e9646e3bddb6e23541c454d05fe20e84eb7e5bd8e14fce76504e155/hugr-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:774692491afbb7dcff6e6efaea03b0a90e76ebb4109735334d2e9a8ac6380cd6", size = 3838425, upload-time = "2026-01-05T15:37:21.726Z" }, + { url = "https://files.pythonhosted.org/packages/66/bd/75572fd29f3982b440de1febeeaa5256f743d7b8fce0400f849600b421fc/hugr-0.15.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:52b189e2ec0b00af823f26669119ada26e139176dfb4645fb82ffac7d0d5737e", size = 3856597, upload-time = "2026-01-05T15:37:25.622Z" }, + { url = "https://files.pythonhosted.org/packages/b9/18/4b6524aa8bf22374685f6dcd9938d1d453230a53be038c87c1ed97caf705/hugr-0.15.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:afffb871ec30f928714d642c5ef78739b47803ee8a83b55c65e09bd2b9d2211c", size = 3954804, upload-time = "2026-01-05T15:37:29.127Z" }, + { url = "https://files.pythonhosted.org/packages/39/2b/5a893a566861ebec8889b546e8e7d68df337cde60028465bcb1fc3b3f508/hugr-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3a773316c917b43aa50b5446fcb5273c3e27b513293fad13e2b157d0fc290fe0", size = 4173183, upload-time = "2026-01-05T15:37:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/1a/6d/627f8d6a6571239eb5c139f2fa863773d09470fc60aa44c1b225e7214c5f/hugr-0.15.0-cp310-abi3-win32.whl", hash = "sha256:2a2ca91cbe40646c69c5041db51e3ec111846d4f4ed25dd65f402df2d2387fa0", size = 3197911, upload-time = "2026-01-05T15:37:39.874Z" }, + { url = "https://files.pythonhosted.org/packages/e5/56/fc758375451ffcc535e24ec58066e6a2b336ca5035f78f244532369309ac/hugr-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:258fefc321ccf007d05f76c92e6e80f71fcc543189f0f0a0b7095ed2c6dd1aa4", size = 3489634, upload-time = "2026-01-05T15:37:38.147Z" }, + { url = "https://files.pythonhosted.org/packages/d9/28/5618ec62739e3cd925ae5a2a797004b98c4d03d66947ab597027309766b1/hugr-0.15.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:690084a62d02d564bee17704ea3e77a9066ad3a1b09cd75aed86c9c3d8439137", size = 3651038, upload-time = "2026-01-05T15:37:20.049Z" }, + { url = "https://files.pythonhosted.org/packages/a1/da/a02ffac8e5598beb177e7d33e29f82a4864d2c8b980aed8e81dda03deb44/hugr-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ca5a7934ec792bf9b989a75de484dec0d1746b12d6876616ff2642eef1756c13", size = 3275569, upload-time = "2026-01-05T15:37:15.925Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/918663e1dd5021f3fa1ae8db77718ae5ed18d6cd26ab8372ccfdebcddb4d/hugr-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f530b246eb2bb69b5a5aec9c9a0134d120f9ae506cb8cf3a1871128d87b8330", size = 3622144, upload-time = "2026-01-05T15:36:54.662Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f4/e9588b67cf552cc9c7d371c81f7b4e4efaf4a1a730b8f2b9336ecb0a1dab/hugr-0.15.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1ab7a5d33c31f4250af9528552b70169e0789e321a9c51886f42270c9903b89", size = 3591782, upload-time = "2026-01-05T15:36:58.13Z" }, + { url = "https://files.pythonhosted.org/packages/40/95/d8c6e68912867d48058e2658e32ac845835a087906e5f73bb3c1739f46d0/hugr-0.15.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67a962e2003df25b6fcbe2481ff24d5bc41d3da922268692d9bce796a96f8443", size = 3876595, upload-time = "2026-01-05T15:37:08.878Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7c/6544e710095e864855a60de3435009e7c3e78a6186cb8eb1e7ad53981cdb/hugr-0.15.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d58d2d3a0ceb37a48ca3858e7bf288ae1b949f70d5b54de68f8cc53adac9f6f", size = 4052329, upload-time = "2026-01-05T15:37:01.845Z" }, + { url = "https://files.pythonhosted.org/packages/95/b9/f81120477dac3c044b8224a3a781936ffa793664d71c2909c2970161f5be/hugr-0.15.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00fdb45f1d16ea5904496fa4a59b3fd6b8b56befdf812f939e34a654563ff4fa", size = 4147108, upload-time = "2026-01-05T15:37:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ce/8c8848164d2a94f12ee7daa3d8ded9b345ff80dcccae1b725af4c39db1a9/hugr-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54c83434a1fcbe7b0a9828aedbd417a2913d477d1e39eb0dacd5f2a1d022cbd4", size = 3943356, upload-time = "2026-01-05T15:37:12.37Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ad/b1609104ce4d618f1763e35eacae64c7557548e9013c5c194b47ff2bac9e/hugr-0.15.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:644adac47ac849236aad88e51736ced0f0cb2d9425c3cf37e8b7292a89f845a3", size = 3839886, upload-time = "2026-01-05T15:37:23.652Z" }, + { url = "https://files.pythonhosted.org/packages/23/cb/0c766a8ef2dd3bdef66f8363f806c81d6f999db808a4fe9de504e9c03d3d/hugr-0.15.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:1f43c65878fee451dc46e367b233a1ad1ac787fc3d907a91e07af262e2ca825d", size = 3862103, upload-time = "2026-01-05T15:37:27.464Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ab/ad625821cd78399b59ba4b939d6b3c62c3e200c99a9ecda0c7fe19304984/hugr-0.15.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:939463bae24d58f34dbbe0d89aac26ba71db3250dc1eb342f82ef9721881170a", size = 3965228, upload-time = "2026-01-05T15:37:31.119Z" }, + { url = "https://files.pythonhosted.org/packages/01/ec/c4df028a4a741ee85bfd7b1c45c0fc4d5ca218ae09ce7533c47ceae6c1a2/hugr-0.15.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1cd9a00c1a940de029d771df1d6da132b833879a3fee2485559275142db94e80", size = 4189216, upload-time = "2026-01-05T15:37:34.467Z" }, +] + +[[package]] +name = "importlib-resources" +version = "6.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/8c/f834fbf984f691b4f7ff60f50b514cc3de5cc08abfc3295564dd89c5e2e7/importlib_resources-6.5.2.tar.gz", hash = "sha256:185f87adef5bcc288449d98fb4fba07cea78bc036455dd44c5fc4a2fe78fed2c", size = 44693, upload-time = "2025-01-03T18:51:56.698Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/ed/1f1afb2e9e7f38a545d628f864d562a5ae64fe6f7a10e28ffb9b185b4e89/importlib_resources-6.5.2-py3-none-any.whl", hash = "sha256:789cfdc3ed28c78b67a06acb8126751ced69a3d5f79c095a98298cd8a760ccec", size = 37461, upload-time = "2025-01-03T18:51:54.306Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "lief" +version = "0.17.2" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/0e/f5fe72b1e729416fb036241de00a5d67324d122b5511207d4f133865459e/lief-0.17.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:68386290df636c37ac554a48a78a0ca04fc458056ab9751089f6cba7bf35b864", size = 2988239, upload-time = "2026-01-03T16:59:53.26Z" }, + { url = "https://files.pythonhosted.org/packages/06/f3/5508e9251f0892433f374379753ac3a52bb90f46c8223a3ee854b4169376/lief-0.17.2-cp310-cp310-macosx_11_0_x86_64.whl", hash = "sha256:056e7656293d96c86944fb4cf1075e016d036d329cac4a22d81ebfaacfd6bd7c", size = 3097674, upload-time = "2026-01-03T16:59:55.511Z" }, + { url = "https://files.pythonhosted.org/packages/63/02/815728a9dd62109415af37fb0bdad933bcde401a1bb029380f2af57a37b9/lief-0.17.2-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:a4a494415e2f4c3266402d1c3708b6c81f7cfd0fbb82d6f19484cd25c331e449", size = 3668402, upload-time = "2026-01-03T16:59:57.487Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1c/217597faa7227e51d0934ddc741bfe7c6f75c0109ce51006899c4e86ea5a/lief-0.17.2-cp310-cp310-manylinux_2_28_i686.whl", hash = "sha256:be6fdb3d8e987245f7d4c70a5c1c79dc3a39bc5db813345d5711cfdcb1f47a19", size = 3495254, upload-time = "2026-01-03T16:59:59.031Z" }, + { url = "https://files.pythonhosted.org/packages/d7/97/96a752c0b224efd3ee5e662f082f7a23c7ef362a86d5e0c8c0ab782a33c2/lief-0.17.2-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:8b32642fa80776ef57249b5d1f2e2b2fb8df21600d4d73668ffa2ce65c876a8e", size = 3395210, upload-time = "2026-01-03T17:00:01.08Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5b/b736e470df858a33c8887c84248638047384f349d16bd81a602dd7259100/lief-0.17.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c54fd4bc37f71eec24357758b81e231e56b9cd2652225e558cb0f90784bff0cd", size = 3589523, upload-time = "2026-01-03T17:00:02.721Z" }, + { url = "https://files.pythonhosted.org/packages/75/e1/455ce2b3047b919d7f0e132e0783084634b185a271ff31f544bf15ee8181/lief-0.17.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:aab1e95e14d65cda4b6b6eede12d8ebc98d5e29a75ae57d738fa933b2a2dcf5c", size = 3951690, upload-time = "2026-01-03T17:00:04.273Z" }, + { url = "https://files.pythonhosted.org/packages/60/fa/b60840bac4214efe54cfc607cc809118061be1bd4b69c03d22561d76201c/lief-0.17.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8dd3c8197882d5832ed0956b27c5a80754fe3593f0728ed16e2cf478e6ae920e", size = 3699112, upload-time = "2026-01-03T17:00:05.774Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ea/3cdc3a51932c36912028bdc89659e35d88937ba110fc364dd19fbc9d55fa/lief-0.17.2-cp310-cp310-win32.whl", hash = "sha256:630a3eab90ddc1a390e19245f4612079133724e94ec953f35031424565c2a4f4", size = 3450698, upload-time = "2026-01-03T17:00:07.418Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/f488a8bfd3b265b688eabfdfb242049c522f687f40b6eff57b00e5577051/lief-0.17.2-cp310-cp310-win_amd64.whl", hash = "sha256:54371d658de9dc5e227bcb651ddd9647824fbc167679e7b0d2d1f8856c04a061", size = 3628128, upload-time = "2026-01-03T17:00:09.103Z" }, + { url = "https://files.pythonhosted.org/packages/e8/49/0a9167a4e252c97a7ee1d24841f5c98ccd36d03c5532601ab3151e7a53a1/lief-0.17.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b3d594ddd70842c88488760a5bfe902e067585b1da9f9318bf561bf509399e7", size = 2995387, upload-time = "2026-01-03T17:00:11.125Z" }, + { url = "https://files.pythonhosted.org/packages/cd/dd/0b17eb3b194ac136e156fd81fa3a04a7d09dd713c1b0d0b4e79303111482/lief-0.17.2-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:4e8a105005283d16ce0cdfb92a5ad4b4afe836016006bb0b5e54c0d582a5a347", size = 3098994, upload-time = "2026-01-03T17:00:12.571Z" }, + { url = "https://files.pythonhosted.org/packages/fc/dc/d8d39c663ac160332de975d22bb84889c3d2de193a24ed05a41d702893d5/lief-0.17.2-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:f8cb266d2b13f11f298c88640be3cd2a438b8fc84afe282261b1b10474fabe7e", size = 3665654, upload-time = "2026-01-03T17:00:14.533Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bf/406671f1f6669186c01c84321f9e57a4cbecab2d75f3724c5253f529caa6/lief-0.17.2-cp311-cp311-manylinux_2_28_i686.whl", hash = "sha256:75306fe656a536b2f658759b20ca54e02e8d7862e11281f1822ee9e5f3562986", size = 3495740, upload-time = "2026-01-03T17:00:16.148Z" }, + { url = "https://files.pythonhosted.org/packages/8a/18/1a0c225f23055e44379005770ea712e9b09c65ec961bc943a7a1549460d2/lief-0.17.2-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:29c58ecacd54d6b12d55366022a4395c82956ae39711a2c4dbf442d832ec6dee", size = 3395339, upload-time = "2026-01-03T17:00:18.001Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4b/92de92d60d5463b90deaf9d3371c2310ca1d4cf2e469a42a7388160e34e8/lief-0.17.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:be37addb662e5899b295adbac1f00ed1241dca5883554f39152d80c658bf31ba", size = 3588452, upload-time = "2026-01-03T17:00:19.816Z" }, + { url = "https://files.pythonhosted.org/packages/37/1c/40fd5661654ce2f20aeead4480328a161051281c6f9f868fd60ed43d25d7/lief-0.17.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:d985cd7b4637dc7d47f22bdc17053d1cd9094c7643cd02281b76f42b1a9d7d71", size = 3951862, upload-time = "2026-01-03T17:00:22.004Z" }, + { url = "https://files.pythonhosted.org/packages/fd/92/bb2e2a6038303e181287b8d7236db000db76eaccd34fb6c7c88f1b863635/lief-0.17.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:38a4b8570be6f51d845c85e4728fb88b9f956bb0ebca193201cd186ac8fb7e9b", size = 3699309, upload-time = "2026-01-03T17:00:24.081Z" }, + { url = "https://files.pythonhosted.org/packages/ad/64/1099084590cddebb5fad041a4fa36fd27ea7f37a9616fad869821ad2434a/lief-0.17.2-cp311-cp311-win32.whl", hash = "sha256:80acf55757a3e55ca94f83874b139d8aa2f0d07f055cf962e814819cb992b632", size = 3450916, upload-time = "2026-01-03T17:00:25.627Z" }, + { url = "https://files.pythonhosted.org/packages/da/08/0e1caf2af3ace7ea2f60d2e8a7a9746f215c70d16e1c93f8044e2c6abe91/lief-0.17.2-cp311-cp311-win_amd64.whl", hash = "sha256:8e31ebc7ae82d147c12be7694ca59213c6d533327ee7d3ed68a0d6314438e48f", size = 3628501, upload-time = "2026-01-03T17:00:27.548Z" }, + { url = "https://files.pythonhosted.org/packages/b6/d5/f7e39e9103a7304fe058a1413b5bf9c50c1f8f86e39f0ee51f22c2428e3c/lief-0.17.2-cp311-cp311-win_arm64.whl", hash = "sha256:9dff74f7ea8ce7667c809d8ad5e4fd2d85b7aed68ccd21c428c6de6ca925f52a", size = 3469459, upload-time = "2026-01-03T17:00:29.575Z" }, + { url = "https://files.pythonhosted.org/packages/47/09/b1e9339265c36dcf8d858ae48e44c6a7a5664abe13575c50d1687c3fbee7/lief-0.17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f4fc79c30c901a7359810b70d8851183b638dd1da08f2b4830d2581cf0a570c2", size = 2994993, upload-time = "2026-01-03T17:00:31.259Z" }, + { url = "https://files.pythonhosted.org/packages/8b/28/9c604f2ccfeaa5e1c1eac54db4c4b0be331ce57df4bda26e4aeb8ebb3e51/lief-0.17.2-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:4cd64a7a564fd1a18e8a4546d51ba94f42e04b9b5988f23d076b5590974b0814", size = 3107796, upload-time = "2026-01-03T17:00:33.355Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a9/94c856f35d17d35fcbe64623a1e142c86a0261e60a6e38fa67a721884d20/lief-0.17.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:299b8b4617110c55870412e2f7091de2f01d4453590cee211494f9d247b49f4b", size = 3669605, upload-time = "2026-01-03T17:00:34.881Z" }, + { url = "https://files.pythonhosted.org/packages/12/88/9fad956fe8c4878acf93dc8da74f73bcf79917e334172ba3b87f10cebed3/lief-0.17.2-cp312-cp312-manylinux_2_28_i686.whl", hash = "sha256:f486d20e9511874986f7d3031f657cdd4bf5355d54660dbafd849ff082a09004", size = 3502208, upload-time = "2026-01-03T17:00:36.548Z" }, + { url = "https://files.pythonhosted.org/packages/f3/af/f2f646e83381b6955095129e76380a63310bc8964526ba8825e45ae070b0/lief-0.17.2-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:38df6ec1a222faa507e9e5dbdd153384168161a492af88cb9259569809220e4e", size = 3405377, upload-time = "2026-01-03T17:00:38.045Z" }, + { url = "https://files.pythonhosted.org/packages/46/74/3835f85ea351eba1a8a7e0295b67203091c0fbe5de6756bb32414aeade33/lief-0.17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e7a4ea7d882c95a24ec574f16ec6a297a698cc8685f8c5f43d8e2a7c709012a", size = 3591633, upload-time = "2026-01-03T17:00:39.756Z" }, + { url = "https://files.pythonhosted.org/packages/b6/45/74f6f236c67db6cc4458f1e85bbccc9248afde4e09a44636436d8561abe8/lief-0.17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:47d24f09322f94ad425c89cac57a05a1d0c79340363c218680202020f021d727", size = 3959834, upload-time = "2026-01-03T17:00:41.804Z" }, + { url = "https://files.pythonhosted.org/packages/7b/de/20f9cb069a3074429a42e4324106908ec10d4fe943d32b0e54bfdd122003/lief-0.17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cebfa8d2c763771d0f89b06f3b0d47c4670a7dfc05471eed87ae6046c1f0b2bf", size = 3706766, upload-time = "2026-01-03T17:00:43.306Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/1fba8e09c6fe1251ee29dbc90641a8636f9fd19c63c6cc62683ba72ba389/lief-0.17.2-cp312-cp312-win32.whl", hash = "sha256:aa267a4950f9aa721e431c25a4032275b4e879ff9d265465d42db43ba8fefaaa", size = 3460382, upload-time = "2026-01-03T17:00:44.958Z" }, + { url = "https://files.pythonhosted.org/packages/63/a2/54754a402aa50c51e82e16e17223358f56f9608de5505e1520da91d33515/lief-0.17.2-cp312-cp312-win_amd64.whl", hash = "sha256:594ab820b46ad1e411ff476ad8e87bb2f453a0011d16c936912e4774d40891bb", size = 3639558, upload-time = "2026-01-03T17:00:47.075Z" }, + { url = "https://files.pythonhosted.org/packages/9e/60/ccd8b1de1bc5324f795acf47bc791760cde404b198da9652ac6e2fe18bc1/lief-0.17.2-cp312-cp312-win_arm64.whl", hash = "sha256:c0c0332b4c20a0aee9712cf19b2b51188f75755a86431d29a9724fcb2d8a8106", size = 3473393, upload-time = "2026-01-03T17:00:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/ce/0a/0c7f940cd43f0bab208d61f58762433337d190b95e4806ea51eb2c84502c/lief-0.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:534aedbc7ccb38a989e0c9c6029e1c195bc7e7d6ae75c6cf484a6b62eff526b0", size = 3001378, upload-time = "2026-01-03T17:00:51.014Z" }, + { url = "https://files.pythonhosted.org/packages/79/94/f5857447cac22734d75f6711ea8483b2f0ee2ddd287e75cb182755a4b733/lief-0.17.2-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:eb6c49157dc84e23d3eccbfc95491b07d08e81bf590f31237cca173926c27e4a", size = 3107463, upload-time = "2026-01-03T17:00:52.662Z" }, + { url = "https://files.pythonhosted.org/packages/68/2b/2acffe4f48d7092dfa3b7b0a620e424888f3016c5ffabeb1bd1b1a844c37/lief-0.17.2-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:9183ef07238ae5be6d052dbc464a8350568fd4444957ab49646c5e0080774bb9", size = 3674544, upload-time = "2026-01-03T17:00:54.448Z" }, + { url = "https://files.pythonhosted.org/packages/70/f2/494353cc7432ef93229bbed6d0e2dbcb7439e1dfa2e38ad7b5bc43d0fcdf/lief-0.17.2-cp313-cp313-manylinux_2_28_i686.whl", hash = "sha256:57a76fed9eb47d81564e306eb9b34b4fcb765f2a5df85772a217436ed134d19f", size = 3502088, upload-time = "2026-01-03T17:00:56.039Z" }, + { url = "https://files.pythonhosted.org/packages/50/77/6e8d2f972fa365d9e388cb140d9d474ab8b9e569584c3d9877197c4fb348/lief-0.17.2-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:4d218e6681aa6272439f97788fe8855f043d1e4f2d8cddf475d262f9b28dddfa", size = 3404432, upload-time = "2026-01-03T17:00:58.087Z" }, + { url = "https://files.pythonhosted.org/packages/d3/cf/b48c17cec03d1ec49b72dc809b0adb395a9b99f056e0578c77c80e99d5d3/lief-0.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:24d254f9b959e80cc7e989c7cd2cb4a75338a7c6e728118a0326ec260414e2fa", size = 3592586, upload-time = "2026-01-03T17:00:59.741Z" }, + { url = "https://files.pythonhosted.org/packages/45/01/3ffa40ca3afdcea47741fc499be7cefb8ec3df109dbf5ccd6fda207d2649/lief-0.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:56e3f82c94f3a8df71e923e1eda860fc0333ed0749c21e1510e06975557322dc", size = 3959418, upload-time = "2026-01-03T17:01:01.428Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b1/ea7e8ade5a7c28846f753a90c3b8590c85cbbf7353860ac923ec5bd6050b/lief-0.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f08b77b6ca6c03800abf55d2754cc8cdf273fe440ec93e2c67600b3c96541f2e", size = 3706764, upload-time = "2026-01-03T17:01:03.146Z" }, + { url = "https://files.pythonhosted.org/packages/8d/00/d14420c05239d82b3607b0e103fa36e8331f1094ef628e31a916abcf0b5c/lief-0.17.2-cp313-cp313-win32.whl", hash = "sha256:48aa1e97bd532b50ebe168673f9f734dc01bcbf0d6106ecb71eaec3081bea826", size = 3460224, upload-time = "2026-01-03T17:01:04.977Z" }, + { url = "https://files.pythonhosted.org/packages/8e/99/98c012081ae563963156d9128fc6206e0c1620aef56b72843674eedf9b98/lief-0.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:fdee58d603d96ae0d4313d8b17afde8e320a5fb6a21eb005e724d21f1af4601f", size = 3639619, upload-time = "2026-01-03T17:01:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/de/db/1c7b86962d723575702f0a7ea2bb96a18a340baed703a397f79708d98219/lief-0.17.2-cp313-cp313-win_arm64.whl", hash = "sha256:66a02009f0b9bc8caa9850fd01d3b6d20f2f55416cea8fa068b878aa7476295f", size = 3472801, upload-time = "2026-01-03T17:01:08.279Z" }, + { url = "https://files.pythonhosted.org/packages/19/e4/753cc2d19644057325d9fba87cf60100cc6c6fc59ce3fdf9394f0264996f/lief-0.17.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cb3e1c34905926ffd0371b27cfee4879956500680ed082f2e6f612dd73b372f9", size = 3000732, upload-time = "2026-01-03T17:01:10.519Z" }, + { url = "https://files.pythonhosted.org/packages/13/b9/c8dd617ce47e4315717936a025d85c526c9d41f3fdef16ec9d576a04b97c/lief-0.17.2-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:8f61ee95413e82f91721e1f15e8d0b187ebdf1488ac1f0677c16b9778d176875", size = 3109748, upload-time = "2026-01-03T17:01:12.727Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a4/2741c6b26416c0cba6cf5b031c5b9fe6b60565612946a6d498495408e04b/lief-0.17.2-cp314-cp314-manylinux2014_aarch64.whl", hash = "sha256:52f93268a738a8653815e17b8c16b7292cfe509ad3eae397cc4d9df153242779", size = 3674276, upload-time = "2026-01-03T17:01:14.525Z" }, + { url = "https://files.pythonhosted.org/packages/95/ef/92907735807bbfde680c5866925f10a2dd6348071e36b623e0518dce5a5d/lief-0.17.2-cp314-cp314-manylinux_2_28_i686.whl", hash = "sha256:92f49c2159ecb9d3ab266e8e1685e63d223e8a3b2279657f10a8a98821053141", size = 3502277, upload-time = "2026-01-03T17:01:16.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1f/5e2646a779b9124f953385ca8875dff6e28400321ba676407f6d20a5ae72/lief-0.17.2-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:4fe12e50be1c36dbecd637687ed406f6516614a6f0dc2455f84d3527671402f9", size = 3406749, upload-time = "2026-01-03T17:01:18.614Z" }, + { url = "https://files.pythonhosted.org/packages/a4/da/8cc6927edfe6424e7078ed185c6603791f2031e9c73c898613382307c6ed/lief-0.17.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:dc2f1e6eaae28aaebf7309c982775bb62da9ef83fbbb5a97370d99aff746c83f", size = 3591955, upload-time = "2026-01-03T17:01:20.557Z" }, + { url = "https://files.pythonhosted.org/packages/67/3f/878e9bb619e0ed9cb55c69023ab1d9a2386576ec19be76180951080ce890/lief-0.17.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:7fa5855b72da404ba75e2f55a64ba8329b0651bc21129fa1aca2daa76918a249", size = 3959833, upload-time = "2026-01-03T17:01:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ca/a57cc1773d034dd20efa614069fae9580ebef3f8779e31c27c3512106a2c/lief-0.17.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6bb5eefd4b43a421642a1e5aecb386f4c1deda1d03778c5ab4b26d9caf87b2c", size = 3708363, upload-time = "2026-01-03T17:01:24.985Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b3/c7e288278060ef3788dfbbb70698637e296b6f81fafc7ca0bf026cfae8e7/lief-0.17.2-cp314-cp314-win32.whl", hash = "sha256:e0f58a0c382e005c3649f2ba1d8bec2309e75f829427002f354ef60c91abffe9", size = 3460557, upload-time = "2026-01-03T17:01:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b9/d900723175296b3d0f4c80836752092e01d9a85d0c86bff9235ff808ed03/lief-0.17.2-cp314-cp314-win_amd64.whl", hash = "sha256:005a608fb9590929506bbcf368497fc39911368669aaff01544d9cc1b82e2460", size = 3639460, upload-time = "2026-01-03T17:01:28.851Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "networkx" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/1d/06475e1cd5264c0b870ea2cc6fdb3e37177c1e565c43f56ff17a10e3937f/networkx-3.4.2.tar.gz", hash = "sha256:307c3669428c5362aab27c8a1260aa8f47c4e91d3891f48be0141738d8d053e1", size = 2151368, upload-time = "2024-10-21T12:39:38.695Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/54/dd730b32ea14ea797530a4479b2ed46a6fb250f682a9cfb997e968bf0261/networkx-3.4.2-py3-none-any.whl", hash = "sha256:df5d4365b724cf81b8c6a7312509d0c22386097011ad1abe274afd5e9d3bbc5f", size = 1723263, upload-time = "2024-10-21T12:39:36.247Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numpy" +version = "2.2.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/76/21/7d2a95e4bba9dc13d043ee156a356c0a8f0c6309dff6b21b4d71a073b8a8/numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd", size = 20276440, upload-time = "2025-05-17T22:38:04.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3e/ed6db5be21ce87955c0cbd3009f2803f59fa08df21b5df06862e2d8e2bdd/numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb", size = 21165245, upload-time = "2025-05-17T21:27:58.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/c2/4b9221495b2a132cc9d2eb862e21d42a009f5a60e45fc44b00118c174bff/numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90", size = 14360048, upload-time = "2025-05-17T21:28:21.406Z" }, + { url = "https://files.pythonhosted.org/packages/fd/77/dc2fcfc66943c6410e2bf598062f5959372735ffda175b39906d54f02349/numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163", size = 5340542, upload-time = "2025-05-17T21:28:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/1cb5fdc353a5f5cc7feb692db9b8ec2c3d6405453f982435efc52561df58/numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf", size = 6878301, upload-time = "2025-05-17T21:28:41.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/17/96a3acd228cec142fcb8723bd3cc39c2a474f7dcf0a5d16731980bcafa95/numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83", size = 14297320, upload-time = "2025-05-17T21:29:02.78Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/3de6a34ad7ad6646ac7d2f55ebc6ad439dbbf9c4370017c50cf403fb19b5/numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915", size = 16801050, upload-time = "2025-05-17T21:29:27.675Z" }, + { url = "https://files.pythonhosted.org/packages/07/b6/89d837eddef52b3d0cec5c6ba0456c1bf1b9ef6a6672fc2b7873c3ec4e2e/numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680", size = 15807034, upload-time = "2025-05-17T21:29:51.102Z" }, + { url = "https://files.pythonhosted.org/packages/01/c8/dc6ae86e3c61cfec1f178e5c9f7858584049b6093f843bca541f94120920/numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289", size = 18614185, upload-time = "2025-05-17T21:30:18.703Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c5/0064b1b7e7c89137b471ccec1fd2282fceaae0ab3a9550f2568782d80357/numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d", size = 6527149, upload-time = "2025-05-17T21:30:29.788Z" }, + { url = "https://files.pythonhosted.org/packages/a3/dd/4b822569d6b96c39d1215dbae0582fd99954dcbcf0c1a13c61783feaca3f/numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3", size = 12904620, upload-time = "2025-05-17T21:30:48.994Z" }, + { url = "https://files.pythonhosted.org/packages/da/a8/4f83e2aa666a9fbf56d6118faaaf5f1974d456b1823fda0a176eff722839/numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae", size = 21176963, upload-time = "2025-05-17T21:31:19.36Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/64e1affc7972decb74c9e29e5649fac940514910960ba25cd9af4488b66c/numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a", size = 14406743, upload-time = "2025-05-17T21:31:41.087Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/0121e375000b5e50ffdd8b25bf78d8e1a5aa4cca3f185d41265198c7b834/numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42", size = 5352616, upload-time = "2025-05-17T21:31:50.072Z" }, + { url = "https://files.pythonhosted.org/packages/31/0d/b48c405c91693635fbe2dcd7bc84a33a602add5f63286e024d3b6741411c/numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491", size = 6889579, upload-time = "2025-05-17T21:32:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/7f0554d49b565d0171eab6e99001846882000883998e7b7d9f0d98b1f934/numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a", size = 14312005, upload-time = "2025-05-17T21:32:23.332Z" }, + { url = "https://files.pythonhosted.org/packages/b3/dd/2238b898e51bd6d389b7389ffb20d7f4c10066d80351187ec8e303a5a475/numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf", size = 16821570, upload-time = "2025-05-17T21:32:47.991Z" }, + { url = "https://files.pythonhosted.org/packages/83/6c/44d0325722cf644f191042bf47eedad61c1e6df2432ed65cbe28509d404e/numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1", size = 15818548, upload-time = "2025-05-17T21:33:11.728Z" }, + { url = "https://files.pythonhosted.org/packages/ae/9d/81e8216030ce66be25279098789b665d49ff19eef08bfa8cb96d4957f422/numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab", size = 18620521, upload-time = "2025-05-17T21:33:39.139Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fd/e19617b9530b031db51b0926eed5345ce8ddc669bb3bc0044b23e275ebe8/numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47", size = 6525866, upload-time = "2025-05-17T21:33:50.273Z" }, + { url = "https://files.pythonhosted.org/packages/31/0a/f354fb7176b81747d870f7991dc763e157a934c717b67b58456bc63da3df/numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303", size = 12907455, upload-time = "2025-05-17T21:34:09.135Z" }, + { url = "https://files.pythonhosted.org/packages/82/5d/c00588b6cf18e1da539b45d3598d3557084990dcc4331960c15ee776ee41/numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff", size = 20875348, upload-time = "2025-05-17T21:34:39.648Z" }, + { url = "https://files.pythonhosted.org/packages/66/ee/560deadcdde6c2f90200450d5938f63a34b37e27ebff162810f716f6a230/numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c", size = 14119362, upload-time = "2025-05-17T21:35:01.241Z" }, + { url = "https://files.pythonhosted.org/packages/3c/65/4baa99f1c53b30adf0acd9a5519078871ddde8d2339dc5a7fde80d9d87da/numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3", size = 5084103, upload-time = "2025-05-17T21:35:10.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/89/e5a34c071a0570cc40c9a54eb472d113eea6d002e9ae12bb3a8407fb912e/numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282", size = 6625382, upload-time = "2025-05-17T21:35:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/f8/35/8c80729f1ff76b3921d5c9487c7ac3de9b2a103b1cd05e905b3090513510/numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87", size = 14018462, upload-time = "2025-05-17T21:35:42.174Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3d/1e1db36cfd41f895d266b103df00ca5b3cbe965184df824dec5c08c6b803/numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249", size = 16527618, upload-time = "2025-05-17T21:36:06.711Z" }, + { url = "https://files.pythonhosted.org/packages/61/c6/03ed30992602c85aa3cd95b9070a514f8b3c33e31124694438d88809ae36/numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49", size = 15505511, upload-time = "2025-05-17T21:36:29.965Z" }, + { url = "https://files.pythonhosted.org/packages/b7/25/5761d832a81df431e260719ec45de696414266613c9ee268394dd5ad8236/numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de", size = 18313783, upload-time = "2025-05-17T21:36:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/57/0a/72d5a3527c5ebffcd47bde9162c39fae1f90138c961e5296491ce778e682/numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4", size = 6246506, upload-time = "2025-05-17T21:37:07.368Z" }, + { url = "https://files.pythonhosted.org/packages/36/fa/8c9210162ca1b88529ab76b41ba02d433fd54fecaf6feb70ef9f124683f1/numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2", size = 12614190, upload-time = "2025-05-17T21:37:26.213Z" }, + { url = "https://files.pythonhosted.org/packages/f9/5c/6657823f4f594f72b5471f1db1ab12e26e890bb2e41897522d134d2a3e81/numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84", size = 20867828, upload-time = "2025-05-17T21:37:56.699Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9e/14520dc3dadf3c803473bd07e9b2bd1b69bc583cb2497b47000fed2fa92f/numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b", size = 14143006, upload-time = "2025-05-17T21:38:18.291Z" }, + { url = "https://files.pythonhosted.org/packages/4f/06/7e96c57d90bebdce9918412087fc22ca9851cceaf5567a45c1f404480e9e/numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d", size = 5076765, upload-time = "2025-05-17T21:38:27.319Z" }, + { url = "https://files.pythonhosted.org/packages/73/ed/63d920c23b4289fdac96ddbdd6132e9427790977d5457cd132f18e76eae0/numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566", size = 6617736, upload-time = "2025-05-17T21:38:38.141Z" }, + { url = "https://files.pythonhosted.org/packages/85/c5/e19c8f99d83fd377ec8c7e0cf627a8049746da54afc24ef0a0cb73d5dfb5/numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f", size = 14010719, upload-time = "2025-05-17T21:38:58.433Z" }, + { url = "https://files.pythonhosted.org/packages/19/49/4df9123aafa7b539317bf6d342cb6d227e49f7a35b99c287a6109b13dd93/numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f", size = 16526072, upload-time = "2025-05-17T21:39:22.638Z" }, + { url = "https://files.pythonhosted.org/packages/b2/6c/04b5f47f4f32f7c2b0e7260442a8cbcf8168b0e1a41ff1495da42f42a14f/numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868", size = 15503213, upload-time = "2025-05-17T21:39:45.865Z" }, + { url = "https://files.pythonhosted.org/packages/17/0a/5cd92e352c1307640d5b6fec1b2ffb06cd0dabe7d7b8227f97933d378422/numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d", size = 18316632, upload-time = "2025-05-17T21:40:13.331Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3b/5cba2b1d88760ef86596ad0f3d484b1cbff7c115ae2429678465057c5155/numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd", size = 6244532, upload-time = "2025-05-17T21:43:46.099Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3b/d58c12eafcb298d4e6d0d40216866ab15f59e55d148a5658bb3132311fcf/numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c", size = 12610885, upload-time = "2025-05-17T21:44:05.145Z" }, + { url = "https://files.pythonhosted.org/packages/6b/9e/4bf918b818e516322db999ac25d00c75788ddfd2d2ade4fa66f1f38097e1/numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6", size = 20963467, upload-time = "2025-05-17T21:40:44Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/d2de6b291507517ff2e438e13ff7b1e2cdbdb7cb40b3ed475377aece69f9/numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda", size = 14225144, upload-time = "2025-05-17T21:41:05.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/25/480387655407ead912e28ba3a820bc69af9adf13bcbe40b299d454ec011f/numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40", size = 5200217, upload-time = "2025-05-17T21:41:15.903Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4a/6e313b5108f53dcbf3aca0c0f3e9c92f4c10ce57a0a721851f9785872895/numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8", size = 6712014, upload-time = "2025-05-17T21:41:27.321Z" }, + { url = "https://files.pythonhosted.org/packages/b7/30/172c2d5c4be71fdf476e9de553443cf8e25feddbe185e0bd88b096915bcc/numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f", size = 14077935, upload-time = "2025-05-17T21:41:49.738Z" }, + { url = "https://files.pythonhosted.org/packages/12/fb/9e743f8d4e4d3c710902cf87af3512082ae3d43b945d5d16563f26ec251d/numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa", size = 16600122, upload-time = "2025-05-17T21:42:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/12/75/ee20da0e58d3a66f204f38916757e01e33a9737d0b22373b3eb5a27358f9/numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571", size = 15586143, upload-time = "2025-05-17T21:42:37.464Z" }, + { url = "https://files.pythonhosted.org/packages/76/95/bef5b37f29fc5e739947e9ce5179ad402875633308504a52d188302319c8/numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1", size = 18385260, upload-time = "2025-05-17T21:43:05.189Z" }, + { url = "https://files.pythonhosted.org/packages/09/04/f2f83279d287407cf36a7a8053a5abe7be3622a4363337338f2585e4afda/numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff", size = 6377225, upload-time = "2025-05-17T21:43:16.254Z" }, + { url = "https://files.pythonhosted.org/packages/67/0e/35082d13c09c02c011cf21570543d202ad929d961c02a147493cb0c2bdf5/numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06", size = 12771374, upload-time = "2025-05-17T21:43:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/d94a75f4dbf1ef5d321523ecac21ef23a3cd2ac8b78ae2aac40873590229/numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d", size = 21040391, upload-time = "2025-05-17T21:44:35.948Z" }, + { url = "https://files.pythonhosted.org/packages/17/f4/09b2fa1b58f0fb4f7c7963a1649c64c4d315752240377ed74d9cd878f7b5/numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db", size = 6786754, upload-time = "2025-05-17T21:44:47.446Z" }, + { url = "https://files.pythonhosted.org/packages/af/30/feba75f143bdc868a1cc3f44ccfa6c4b9ec522b36458e738cd00f67b573f/numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543", size = 16643476, upload-time = "2025-05-17T21:45:11.871Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/ac2a9584402fb6c0cd5b5d1a91dcf176b15760130dd386bbafdbfe3640bf/numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00", size = 12812666, upload-time = "2025-05-17T21:45:31.426Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.11'", +] +sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/34/2b1bc18424f3ad9af577f6ce23600319968a70575bd7db31ce66731bbef9/numpy-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0cce2a669e3c8ba02ee563c7835f92c153cf02edff1ae05e1823f1dde21b16a5", size = 16944563, upload-time = "2026-01-10T06:42:14.615Z" }, + { url = "https://files.pythonhosted.org/packages/2c/57/26e5f97d075aef3794045a6ca9eada6a4ed70eb9a40e7a4a93f9ac80d704/numpy-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:899d2c18024984814ac7e83f8f49d8e8180e2fbe1b2e252f2e7f1d06bea92425", size = 12645658, upload-time = "2026-01-10T06:42:17.298Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ba/80fc0b1e3cb2fd5c6143f00f42eb67762aa043eaa05ca924ecc3222a7849/numpy-2.4.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:09aa8a87e45b55a1c2c205d42e2808849ece5c484b2aab11fecabec3841cafba", size = 5474132, upload-time = "2026-01-10T06:42:19.637Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0a5b9a397f0e865ec171187c78d9b57e5588afc439a04ba9cab1ebb2c945/numpy-2.4.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:edee228f76ee2dab4579fad6f51f6a305de09d444280109e0f75df247ff21501", size = 6804159, upload-time = "2026-01-10T06:42:21.44Z" }, + { url = "https://files.pythonhosted.org/packages/86/9c/841c15e691c7085caa6fd162f063eff494099c8327aeccd509d1ab1e36ab/numpy-2.4.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a92f227dbcdc9e4c3e193add1a189a9909947d4f8504c576f4a732fd0b54240a", size = 14708058, upload-time = "2026-01-10T06:42:23.546Z" }, + { url = "https://files.pythonhosted.org/packages/5d/9d/7862db06743f489e6a502a3b93136d73aea27d97b2cf91504f70a27501d6/numpy-2.4.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:538bf4ec353709c765ff75ae616c34d3c3dca1a68312727e8f2676ea644f8509", size = 16651501, upload-time = "2026-01-10T06:42:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9c/6fc34ebcbd4015c6e5f0c0ce38264010ce8a546cb6beacb457b84a75dfc8/numpy-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ac08c63cb7779b85e9d5318e6c3518b424bc1f364ac4cb2c6136f12e5ff2dccc", size = 16492627, upload-time = "2026-01-10T06:42:28.938Z" }, + { url = "https://files.pythonhosted.org/packages/aa/63/2494a8597502dacda439f61b3c0db4da59928150e62be0e99395c3ad23c5/numpy-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4f9c360ecef085e5841c539a9a12b883dff005fbd7ce46722f5e9cef52634d82", size = 18585052, upload-time = "2026-01-10T06:42:31.312Z" }, + { url = "https://files.pythonhosted.org/packages/6a/93/098e1162ae7522fc9b618d6272b77404c4656c72432ecee3abc029aa3de0/numpy-2.4.1-cp311-cp311-win32.whl", hash = "sha256:0f118ce6b972080ba0758c6087c3617b5ba243d806268623dc34216d69099ba0", size = 6236575, upload-time = "2026-01-10T06:42:33.872Z" }, + { url = "https://files.pythonhosted.org/packages/8c/de/f5e79650d23d9e12f38a7bc6b03ea0835b9575494f8ec94c11c6e773b1b1/numpy-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:18e14c4d09d55eef39a6ab5b08406e84bc6869c1e34eef45564804f90b7e0574", size = 12604479, upload-time = "2026-01-10T06:42:35.778Z" }, + { url = "https://files.pythonhosted.org/packages/dd/65/e1097a7047cff12ce3369bd003811516b20ba1078dbdec135e1cd7c16c56/numpy-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:6461de5113088b399d655d45c3897fa188766415d0f568f175ab071c8873bd73", size = 10578325, upload-time = "2026-01-10T06:42:38.518Z" }, + { url = "https://files.pythonhosted.org/packages/78/7f/ec53e32bf10c813604edf07a3682616bd931d026fcde7b6d13195dfb684a/numpy-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d3703409aac693fa82c0aee023a1ae06a6e9d065dba10f5e8e80f642f1e9d0a2", size = 16656888, upload-time = "2026-01-10T06:42:40.913Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e0/1f9585d7dae8f14864e948fd7fa86c6cb72dee2676ca2748e63b1c5acfe0/numpy-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7211b95ca365519d3596a1d8688a95874cc94219d417504d9ecb2df99fa7bfa8", size = 12373956, upload-time = "2026-01-10T06:42:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/8e/43/9762e88909ff2326f5e7536fa8cb3c49fb03a7d92705f23e6e7f553d9cb3/numpy-2.4.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:5adf01965456a664fc727ed69cc71848f28d063217c63e1a0e200a118d5eec9a", size = 5202567, upload-time = "2026-01-10T06:42:45.107Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ee/34b7930eb61e79feb4478800a4b95b46566969d837546aa7c034c742ef98/numpy-2.4.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:26f0bcd9c79a00e339565b303badc74d3ea2bd6d52191eeca5f95936cad107d0", size = 6549459, upload-time = "2026-01-10T06:42:48.152Z" }, + { url = "https://files.pythonhosted.org/packages/79/e3/5f115fae982565771be994867c89bcd8d7208dbfe9469185497d70de5ddf/numpy-2.4.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0093e85df2960d7e4049664b26afc58b03236e967fb942354deef3208857a04c", size = 14404859, upload-time = "2026-01-10T06:42:49.947Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7d/9c8a781c88933725445a859cac5d01b5871588a15969ee6aeb618ba99eee/numpy-2.4.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad270f438cbdd402c364980317fb6b117d9ec5e226fff5b4148dd9aa9fc6e02", size = 16371419, upload-time = "2026-01-10T06:42:52.409Z" }, + { url = "https://files.pythonhosted.org/packages/a6/d2/8aa084818554543f17cf4162c42f162acbd3bb42688aefdba6628a859f77/numpy-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:297c72b1b98100c2e8f873d5d35fb551fce7040ade83d67dd51d38c8d42a2162", size = 16182131, upload-time = "2026-01-10T06:42:54.694Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/0425216684297c58a8df35f3284ef56ec4a043e6d283f8a59c53562caf1b/numpy-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf6470d91d34bf669f61d515499859fa7a4c2f7c36434afb70e82df7217933f9", size = 18295342, upload-time = "2026-01-10T06:42:56.991Z" }, + { url = "https://files.pythonhosted.org/packages/31/4c/14cb9d86240bd8c386c881bafbe43f001284b7cce3bc01623ac9475da163/numpy-2.4.1-cp312-cp312-win32.whl", hash = "sha256:b6bcf39112e956594b3331316d90c90c90fb961e39696bda97b89462f5f3943f", size = 5959015, upload-time = "2026-01-10T06:42:59.631Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/52a703dbeb0c65807540d29699fef5fda073434ff61846a564d5c296420f/numpy-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:e1a27bb1b2dee45a2a53f5ca6ff2d1a7f135287883a1689e930d44d1ff296c87", size = 12310730, upload-time = "2026-01-10T06:43:01.627Z" }, + { url = "https://files.pythonhosted.org/packages/69/80/a828b2d0ade5e74a9fe0f4e0a17c30fdc26232ad2bc8c9f8b3197cf7cf18/numpy-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:0e6e8f9d9ecf95399982019c01223dc130542960a12edfa8edd1122dfa66a8a8", size = 10312166, upload-time = "2026-01-10T06:43:03.673Z" }, + { url = "https://files.pythonhosted.org/packages/04/68/732d4b7811c00775f3bd522a21e8dd5a23f77eb11acdeb663e4a4ebf0ef4/numpy-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d797454e37570cfd61143b73b8debd623c3c0952959adb817dd310a483d58a1b", size = 16652495, upload-time = "2026-01-10T06:43:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/20/ca/857722353421a27f1465652b2c66813eeeccea9d76d5f7b74b99f298e60e/numpy-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:82c55962006156aeef1629b953fd359064aa47e4d82cfc8e67f0918f7da3344f", size = 12368657, upload-time = "2026-01-10T06:43:09.094Z" }, + { url = "https://files.pythonhosted.org/packages/81/0d/2377c917513449cc6240031a79d30eb9a163d32a91e79e0da47c43f2c0c8/numpy-2.4.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:71abbea030f2cfc3092a0ff9f8c8fdefdc5e0bf7d9d9c99663538bb0ecdac0b9", size = 5197256, upload-time = "2026-01-10T06:43:13.634Z" }, + { url = "https://files.pythonhosted.org/packages/17/39/569452228de3f5de9064ac75137082c6214be1f5c532016549a7923ab4b5/numpy-2.4.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:5b55aa56165b17aaf15520beb9cbd33c9039810e0d9643dd4379e44294c7303e", size = 6545212, upload-time = "2026-01-10T06:43:15.661Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/77333f4d1e4dac4395385482557aeecf4826e6ff517e32ca48e1dafbe42a/numpy-2.4.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0faba4a331195bfa96f93dd9dfaa10b2c7aa8cda3a02b7fd635e588fe821bf5", size = 14402871, upload-time = "2026-01-10T06:43:17.324Z" }, + { url = "https://files.pythonhosted.org/packages/ba/87/d341e519956273b39d8d47969dd1eaa1af740615394fe67d06f1efa68773/numpy-2.4.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e3087f53e2b4428766b54932644d148613c5a595150533ae7f00dab2f319a8", size = 16359305, upload-time = "2026-01-10T06:43:19.376Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/789132c6666288eaa20ae8066bb99eba1939362e8f1a534949a215246e97/numpy-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:49e792ec351315e16da54b543db06ca8a86985ab682602d90c60ef4ff4db2a9c", size = 16181909, upload-time = "2026-01-10T06:43:21.808Z" }, + { url = "https://files.pythonhosted.org/packages/cf/b8/090b8bd27b82a844bb22ff8fdf7935cb1980b48d6e439ae116f53cdc2143/numpy-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79e9e06c4c2379db47f3f6fc7a8652e7498251789bf8ff5bd43bf478ef314ca2", size = 18284380, upload-time = "2026-01-10T06:43:23.957Z" }, + { url = "https://files.pythonhosted.org/packages/67/78/722b62bd31842ff029412271556a1a27a98f45359dea78b1548a3a9996aa/numpy-2.4.1-cp313-cp313-win32.whl", hash = "sha256:3d1a100e48cb266090a031397863ff8a30050ceefd798f686ff92c67a486753d", size = 5957089, upload-time = "2026-01-10T06:43:27.535Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/cf32198b0b6e18d4fbfa9a21a992a7fca535b9bb2b0cdd217d4a3445b5ca/numpy-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:92a0e65272fd60bfa0d9278e0484c2f52fe03b97aedc02b357f33fe752c52ffb", size = 12307230, upload-time = "2026-01-10T06:43:29.298Z" }, + { url = "https://files.pythonhosted.org/packages/44/6c/534d692bfb7d0afe30611320c5fb713659dcb5104d7cc182aff2aea092f5/numpy-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:20d4649c773f66cc2fc36f663e091f57c3b7655f936a4c681b4250855d1da8f5", size = 10313125, upload-time = "2026-01-10T06:43:31.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/a1/354583ac5c4caa566de6ddfbc42744409b515039e085fab6e0ff942e0df5/numpy-2.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f93bc6892fe7b0663e5ffa83b61aab510aacffd58c16e012bb9352d489d90cb7", size = 12496156, upload-time = "2026-01-10T06:43:34.237Z" }, + { url = "https://files.pythonhosted.org/packages/51/b0/42807c6e8cce58c00127b1dc24d365305189991f2a7917aa694a109c8d7d/numpy-2.4.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:178de8f87948163d98a4c9ab5bee4ce6519ca918926ec8df195af582de28544d", size = 5324663, upload-time = "2026-01-10T06:43:36.211Z" }, + { url = "https://files.pythonhosted.org/packages/fe/55/7a621694010d92375ed82f312b2f28017694ed784775269115323e37f5e2/numpy-2.4.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:98b35775e03ab7f868908b524fc0a84d38932d8daf7b7e1c3c3a1b6c7a2c9f15", size = 6645224, upload-time = "2026-01-10T06:43:37.884Z" }, + { url = "https://files.pythonhosted.org/packages/50/96/9fa8635ed9d7c847d87e30c834f7109fac5e88549d79ef3324ab5c20919f/numpy-2.4.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:941c2a93313d030f219f3a71fd3d91a728b82979a5e8034eb2e60d394a2b83f9", size = 14462352, upload-time = "2026-01-10T06:43:39.479Z" }, + { url = "https://files.pythonhosted.org/packages/03/d1/8cf62d8bb2062da4fb82dd5d49e47c923f9c0738032f054e0a75342faba7/numpy-2.4.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:529050522e983e00a6c1c6b67411083630de8b57f65e853d7b03d9281b8694d2", size = 16407279, upload-time = "2026-01-10T06:43:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/86/1c/95c86e17c6b0b31ce6ef219da00f71113b220bcb14938c8d9a05cee0ff53/numpy-2.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2302dc0224c1cbc49bb94f7064f3f923a971bfae45c33870dcbff63a2a550505", size = 16248316, upload-time = "2026-01-10T06:43:44.121Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/e7f5ff8697274c9d0fa82398b6a372a27e5cef069b37df6355ccb1f1db1a/numpy-2.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9171a42fcad32dcf3fa86f0a4faa5e9f8facefdb276f54b8b390d90447cff4e2", size = 18329884, upload-time = "2026-01-10T06:43:46.613Z" }, + { url = "https://files.pythonhosted.org/packages/37/a4/b073f3e9d77f9aec8debe8ca7f9f6a09e888ad1ba7488f0c3b36a94c03ac/numpy-2.4.1-cp313-cp313t-win32.whl", hash = "sha256:382ad67d99ef49024f11d1ce5dcb5ad8432446e4246a4b014418ba3a1175a1f4", size = 6081138, upload-time = "2026-01-10T06:43:48.854Z" }, + { url = "https://files.pythonhosted.org/packages/16/16/af42337b53844e67752a092481ab869c0523bc95c4e5c98e4dac4e9581ac/numpy-2.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:62fea415f83ad8fdb6c20840578e5fbaf5ddd65e0ec6c3c47eda0f69da172510", size = 12447478, upload-time = "2026-01-10T06:43:50.476Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f8/fa85b2eac68ec631d0b631abc448552cb17d39afd17ec53dcbcc3537681a/numpy-2.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a7870e8c5fc11aef57d6fea4b4085e537a3a60ad2cdd14322ed531fdca68d261", size = 10382981, upload-time = "2026-01-10T06:43:52.575Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a7/ef08d25698e0e4b4efbad8d55251d20fe2a15f6d9aa7c9b30cd03c165e6f/numpy-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3869ea1ee1a1edc16c29bbe3a2f2a4e515cc3a44d43903ad41e0cacdbaf733dc", size = 16652046, upload-time = "2026-01-10T06:43:54.797Z" }, + { url = "https://files.pythonhosted.org/packages/8f/39/e378b3e3ca13477e5ac70293ec027c438d1927f18637e396fe90b1addd72/numpy-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e867df947d427cdd7a60e3e271729090b0f0df80f5f10ab7dd436f40811699c3", size = 12378858, upload-time = "2026-01-10T06:43:57.099Z" }, + { url = "https://files.pythonhosted.org/packages/c3/74/7ec6154f0006910ed1fdbb7591cf4432307033102b8a22041599935f8969/numpy-2.4.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e3bd2cb07841166420d2fa7146c96ce00cb3410664cbc1a6be028e456c4ee220", size = 5207417, upload-time = "2026-01-10T06:43:59.037Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b7/053ac11820d84e42f8feea5cb81cc4fcd1091499b45b1ed8c7415b1bf831/numpy-2.4.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:f0a90aba7d521e6954670550e561a4cb925713bd944445dbe9e729b71f6cabee", size = 6542643, upload-time = "2026-01-10T06:44:01.852Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c4/2e7908915c0e32ca636b92e4e4a3bdec4cb1e7eb0f8aedf1ed3c68a0d8cd/numpy-2.4.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d558123217a83b2d1ba316b986e9248a1ed1971ad495963d555ccd75dcb1556", size = 14418963, upload-time = "2026-01-10T06:44:04.047Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/3ed5083d94e7ffd7c404e54619c088e11f2e1939a9544f5397f4adb1b8ba/numpy-2.4.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f44de05659b67d20499cbc96d49f2650769afcb398b79b324bb6e297bfe3844", size = 16363811, upload-time = "2026-01-10T06:44:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/0e/68/42b66f1852bf525050a67315a4fb94586ab7e9eaa541b1bef530fab0c5dd/numpy-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:69e7419c9012c4aaf695109564e3387f1259f001b4326dfa55907b098af082d3", size = 16197643, upload-time = "2026-01-10T06:44:08.33Z" }, + { url = "https://files.pythonhosted.org/packages/d2/40/e8714fc933d85f82c6bfc7b998a0649ad9769a32f3494ba86598aaf18a48/numpy-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ffd257026eb1b34352e749d7cc1678b5eeec3e329ad8c9965a797e08ccba205", size = 18289601, upload-time = "2026-01-10T06:44:10.841Z" }, + { url = "https://files.pythonhosted.org/packages/80/9a/0d44b468cad50315127e884802351723daca7cf1c98d102929468c81d439/numpy-2.4.1-cp314-cp314-win32.whl", hash = "sha256:727c6c3275ddefa0dc078524a85e064c057b4f4e71ca5ca29a19163c607be745", size = 6005722, upload-time = "2026-01-10T06:44:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bb/c6513edcce5a831810e2dddc0d3452ce84d208af92405a0c2e58fd8e7881/numpy-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:7d5d7999df434a038d75a748275cd6c0094b0ecdb0837342b332a82defc4dc4d", size = 12438590, upload-time = "2026-01-10T06:44:15.006Z" }, + { url = "https://files.pythonhosted.org/packages/e9/da/a598d5cb260780cf4d255102deba35c1d072dc028c4547832f45dd3323a8/numpy-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:ce9ce141a505053b3c7bce3216071f3bf5c182b8b28930f14cd24d43932cd2df", size = 10596180, upload-time = "2026-01-10T06:44:17.386Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ea3f2c96fcb382311827231f911723aeff596364eb6e1b6d1d91128aa29b/numpy-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e53170557d37ae404bf8d542ca5b7c629d6efa1117dac6a83e394142ea0a43f", size = 12498774, upload-time = "2026-01-10T06:44:19.467Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ab/ef9d939fe4a812648c7a712610b2ca6140b0853c5efea361301006c02ae5/numpy-2.4.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:a73044b752f5d34d4232f25f18160a1cc418ea4507f5f11e299d8ac36875f8a0", size = 5327274, upload-time = "2026-01-10T06:44:23.189Z" }, + { url = "https://files.pythonhosted.org/packages/bd/31/d381368e2a95c3b08b8cf7faac6004849e960f4a042d920337f71cef0cae/numpy-2.4.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:fb1461c99de4d040666ca0444057b06541e5642f800b71c56e6ea92d6a853a0c", size = 6648306, upload-time = "2026-01-10T06:44:25.012Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e5/0989b44ade47430be6323d05c23207636d67d7362a1796ccbccac6773dd2/numpy-2.4.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:423797bdab2eeefbe608d7c1ec7b2b4fd3c58d51460f1ee26c7500a1d9c9ee93", size = 14464653, upload-time = "2026-01-10T06:44:26.706Z" }, + { url = "https://files.pythonhosted.org/packages/10/a7/cfbe475c35371cae1358e61f20c5f075badc18c4797ab4354140e1d283cf/numpy-2.4.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:52b5f61bdb323b566b528899cc7db2ba5d1015bda7ea811a8bcf3c89c331fa42", size = 16405144, upload-time = "2026-01-10T06:44:29.378Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a3/0c63fe66b534888fa5177cc7cef061541064dbe2b4b60dcc60ffaf0d2157/numpy-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42d7dd5fa36d16d52a84f821eb96031836fd405ee6955dd732f2023724d0aa01", size = 16247425, upload-time = "2026-01-10T06:44:31.721Z" }, + { url = "https://files.pythonhosted.org/packages/6b/2b/55d980cfa2c93bd40ff4c290bf824d792bd41d2fe3487b07707559071760/numpy-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7b6b5e28bbd47b7532698e5db2fe1db693d84b58c254e4389d99a27bb9b8f6b", size = 18330053, upload-time = "2026-01-10T06:44:34.617Z" }, + { url = "https://files.pythonhosted.org/packages/23/12/8b5fc6b9c487a09a7957188e0943c9ff08432c65e34567cabc1623b03a51/numpy-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:5de60946f14ebe15e713a6f22850c2372fa72f4ff9a432ab44aa90edcadaa65a", size = 6152482, upload-time = "2026-01-10T06:44:36.798Z" }, + { url = "https://files.pythonhosted.org/packages/00/a5/9f8ca5856b8940492fc24fbe13c1bc34d65ddf4079097cf9e53164d094e1/numpy-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:8f085da926c0d491ffff3096f91078cc97ea67e7e6b65e490bc8dcda65663be2", size = 12627117, upload-time = "2026-01-10T06:44:38.828Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0d/eca3d962f9eef265f01a8e0d20085c6dd1f443cbffc11b6dede81fd82356/numpy-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6436cffb4f2bf26c974344439439c95e152c9a527013f26b3577be6c2ca64295", size = 10667121, upload-time = "2026-01-10T06:44:41.644Z" }, + { url = "https://files.pythonhosted.org/packages/1e/48/d86f97919e79314a1cdee4c832178763e6e98e623e123d0bada19e92c15a/numpy-2.4.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8ad35f20be147a204e28b6a0575fbf3540c5e5f802634d4258d55b1ff5facce1", size = 16822202, upload-time = "2026-01-10T06:44:43.738Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/1e62a7f77e0f37dcfb0ad6a9744e65df00242b6ea37dfafb55debcbf5b55/numpy-2.4.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8097529164c0f3e32bb89412a0905d9100bf434d9692d9fc275e18dcf53c9344", size = 12569985, upload-time = "2026-01-10T06:44:45.945Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7e/914d54f0c801342306fdcdce3e994a56476f1b818c46c47fc21ae968088c/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:ea66d2b41ca4a1630aae5507ee0a71647d3124d1741980138aa8f28f44dac36e", size = 5398484, upload-time = "2026-01-10T06:44:48.012Z" }, + { url = "https://files.pythonhosted.org/packages/1c/d8/9570b68584e293a33474e7b5a77ca404f1dcc655e40050a600dee81d27fb/numpy-2.4.1-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:d3f8f0df9f4b8be57b3bf74a1d087fec68f927a2fab68231fdb442bf2c12e426", size = 6713216, upload-time = "2026-01-10T06:44:49.725Z" }, + { url = "https://files.pythonhosted.org/packages/33/9b/9dd6e2db8d49eb24f86acaaa5258e5f4c8ed38209a4ee9de2d1a0ca25045/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2023ef86243690c2791fd6353e5b4848eedaa88ca8a2d129f462049f6d484696", size = 14538937, upload-time = "2026-01-10T06:44:51.498Z" }, + { url = "https://files.pythonhosted.org/packages/53/87/d5bd995b0f798a37105b876350d346eea5838bd8f77ea3d7a48392f3812b/numpy-2.4.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8361ea4220d763e54cff2fbe7d8c93526b744f7cd9ddab47afeff7e14e8503be", size = 16479830, upload-time = "2026-01-10T06:44:53.931Z" }, + { url = "https://files.pythonhosted.org/packages/5b/c7/b801bf98514b6ae6475e941ac05c58e6411dd863ea92916bfd6d510b08c1/numpy-2.4.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4f1b68ff47680c2925f8063402a693ede215f0257f02596b1318ecdfb1d79e33", size = 12492579, upload-time = "2026-01-10T06:44:57.094Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "patchelf" +version = "0.17.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/a3/fdd3fa938c864aa2f11dd0b7f08befeda983d2dcdee44da493c6977a653f/patchelf-0.17.2.4.tar.gz", hash = "sha256:970ee5cd8af33e5ea2099510b2f9013fa1b8d5cd763bf3fd3961281c18101a09", size = 149629, upload-time = "2025-07-23T21:16:32.071Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/a7/8c4f86c78ec03db954d05fd9c57a114cc3a172a2d3e4a8b949cd5ff89471/patchelf-0.17.2.4-py3-none-macosx_10_9_universal2.whl", hash = "sha256:343bb1b94e959f9070ca9607453b04390e36bbaa33c88640b989cefad0aa049e", size = 184436, upload-time = "2025-07-23T21:16:20.578Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6d/2e9f5483cdb352fab36b8076667b062b2d79cb09d2e3fd09b6fca5771cb6/patchelf-0.17.2.4-py3-none-manylinux1_i686.manylinux_2_5_i686.musllinux_1_1_i686.whl", hash = "sha256:09fd848d625a165fc7b7e07745508c24077129b019c4415a882938781d43adf8", size = 547318, upload-time = "2025-07-23T21:16:22.135Z" }, + { url = "https://files.pythonhosted.org/packages/7e/19/f7821ef31aab01fa7dc8ebe697ece88ec4f7a0fdd3155dab2dfee4b00e5c/patchelf-0.17.2.4-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:d9b35ebfada70c02679ad036407d9724ffe1255122ba4ac5e4be5868618a5689", size = 482846, upload-time = "2025-07-23T21:16:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/d1/50/107fea848ecfd851d473b079cab79107487d72c4c3cdb25b9d2603a24ca2/patchelf-0.17.2.4-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2931a1b5b85f3549661898af7bf746afbda7903c7c9a967cfc998a3563f84fad", size = 477811, upload-time = "2025-07-23T21:16:25.145Z" }, + { url = "https://files.pythonhosted.org/packages/89/a9/a9a2103e159fd65bffbc21ecc5c8c36e44eb34fe53b4ef85fb6d08c2a635/patchelf-0.17.2.4-py3-none-manylinux2014_armv7l.manylinux_2_17_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:ae44cb3c857d50f54b99e5697aa978726ada33a8a6129d4b8b7ffd28b996652d", size = 431226, upload-time = "2025-07-23T21:16:26.765Z" }, + { url = "https://files.pythonhosted.org/packages/87/93/897d612f6df7cfd987bdf668425127efeff8d8e4ad8bfbab1c69d2a0d861/patchelf-0.17.2.4-py3-none-manylinux2014_ppc64le.manylinux_2_17_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:680a266a70f60a7a4f4c448482c5bdba80cc8e6bb155a49dcc24238ba49927b0", size = 540276, upload-time = "2025-07-23T21:16:27.983Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b8/2b92d11533482bac9ee989081d6880845287751b5f528adbd6bb27667fbd/patchelf-0.17.2.4-py3-none-manylinux2014_s390x.manylinux_2_17_s390x.musllinux_1_1_s390x.whl", hash = "sha256:d842b51f0401460f3b1f3a3a67d2c266a8f515a5adfbfa6e7b656cb3ac2ed8bc", size = 596632, upload-time = "2025-07-23T21:16:29.253Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/975d4bdb418f942b53e6187b95bd9e0d5e0488b7bc214685a1e43e2c2751/patchelf-0.17.2.4-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:7076d9e127230982e20a81a6e2358d3343004667ba510d9f822d4fdee29b0d71", size = 508281, upload-time = "2025-07-23T21:16:30.865Z" }, +] + +[[package]] +name = "pecos-rslib" +version = "0.8.0.dev0" +source = { editable = "../pecos-rslib" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [{ name = "patchelf", marker = "sys_platform != 'win32'" }] +numpy-compat = [ + { name = "numpy", specifier = ">=1.20" }, + { name = "scipy", specifier = ">=1.7" }, +] +test = [{ name = "pytest", specifier = ">=7.0" }] + +[[package]] +name = "phir" +version = "0.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/34/89dc01d1e6dd8c7f56c17c4a8dd7e5afb60184057daf860dda995b951be0/phir-0.3.3.tar.gz", hash = "sha256:18159b1ac342d4af7e6cca7a3b949371b325a7f3c4f20ac1f5e3173a33a56033", size = 24517, upload-time = "2024-05-06T10:49:47.926Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/1d/ed9b8b43ee55c2fcb47dfc13415db1534d521a08b0ba30780d619c1b8523/phir-0.3.3-py3-none-any.whl", hash = "sha256:af6d64088279fb22320815f4fa8e42880fcc32416bedbcd8a5adc6c0eee3e567", size = 12356, upload-time = "2024-05-06T10:49:46.349Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" }, +] + +[[package]] +name = "pydot" +version = "4.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/35/b17cb89ff865484c6a20ef46bf9d95a5f07328292578de0b295f4a6beec2/pydot-4.0.1.tar.gz", hash = "sha256:c2148f681c4a33e08bf0e26a9e5f8e4099a82e0e2a068098f32ce86577364ad5", size = 162594, upload-time = "2025-06-17T20:09:56.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c1/1d9de9aeaa1b89b0186e5fe23294ff6517fce1bc69149185577cd31016b2/pyparsing-3.3.1.tar.gz", hash = "sha256:47fad0f17ac1e2cad3de3b458570fbc9b03560aa029ed5e16ee5554da9a2251c", size = 1550512, upload-time = "2025-12-23T03:14:04.391Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "pyzstd" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-zstd", marker = "python_full_version < '3.14'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4c/66/59fed71d0d2065e02974b40296f836a237c364c8bbe07295f2d0dc33c278/pyzstd-0.19.1.tar.gz", hash = "sha256:36723d3c915b3981de9198d0a2c82b2f5fe3eaa36e4d8d586937830a8afc7d72", size = 69531, upload-time = "2025-12-13T08:15:33.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/62/f55d1363512d1b1cc122af6ca5951008e42605b63675c7d6781affc7c475/pyzstd-0.19.1-py3-none-any.whl", hash = "sha256:267e2eb0de0291dfcb7ccfebc4ffe75b2f9d84e7007ac38844f6ccafc6dce081", size = 23779, upload-time = "2025-12-13T08:15:31.979Z" }, +] + +[[package]] +name = "quantum-pecos" +version = "0.8.0.dev0" +source = { editable = "../quantum-pecos" } +dependencies = [ + { name = "guppylang" }, + { name = "hugr" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pecos-rslib" }, + { name = "phir" }, + { name = "selene-sim" }, +] + +[package.metadata] +requires-dist = [ + { name = "cupy-cuda13x", marker = "python_full_version >= '3.11' and extra == 'cuda'", specifier = ">=13.0.0" }, + { name = "cuquantum-python-cu13", marker = "python_full_version >= '3.11' and extra == 'cuda'", specifier = ">=25.3.0" }, + { name = "guppylang", specifier = ">=0.21.0" }, + { name = "hugr", specifier = ">=0.13.0" }, + { name = "matplotlib", marker = "extra == 'visualization'", specifier = ">=2.2.0" }, + { name = "networkx", specifier = ">=2.1.0" }, + { name = "pecos-rslib", editable = "../pecos-rslib" }, + { name = "phir", specifier = ">=0.3.3" }, + { name = "plotly", marker = "extra == 'visualization'", specifier = "~=5.9.0" }, + { name = "pytket-cutensornet", marker = "python_full_version >= '3.11' and extra == 'cuda'", specifier = ">=0.12.0" }, + { name = "quantum-pecos", extras = ["stim"], marker = "extra == 'all'" }, + { name = "quantum-pecos", extras = ["visualization"], marker = "extra == 'all'" }, + { name = "selene-sim", specifier = "~=0.2.0" }, + { name = "stim", marker = "extra == 'stim'", specifier = ">=1.12.0" }, +] +provides-extras = ["stim", "visualization", "all", "cuda"] + +[package.metadata.requires-dev] +numpy-compat = [{ name = "numpy", specifier = ">=1.15.0" }] +test = [] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "selene-core" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pydot" }, + { name = "pyyaml" }, + { name = "typing-extensions" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/16/677704c1037e65b979880c2b394b3e41dd7ac7cee97f86abee644a0cec8a/selene_core-0.2.2-py3-none-any.whl", hash = "sha256:1ebc8c14c268aab9529fe58ecf352164a49bda4a31b82008df22eaf1aee93362", size = 27548, upload-time = "2025-11-07T13:41:39.738Z" }, +] + +[[package]] +name = "selene-hugr-qis-compiler" +version = "0.2.10" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/aa/6b3f287f9330bf077c95bfef3ab350ab1eac0707f7111c38596a22554ecf/selene_hugr_qis_compiler-0.2.10-cp310-abi3-macosx_13_0_arm64.whl", hash = "sha256:2912702073aa37f1bfd1d42678b972b3b10525ed3f5f003091519512331142bd", size = 29532801, upload-time = "2025-11-10T14:48:39.26Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9c/77a1b81e1cedbde6f401fbdc2302bc2221c8cbc46c924ac0bd9cda979799/selene_hugr_qis_compiler-0.2.10-cp310-abi3-macosx_13_0_x86_64.whl", hash = "sha256:60ece08d7ffb792149029f8aad483c546d2f2db8a5d265906bed41479bdc5a9e", size = 32232684, upload-time = "2025-11-10T14:48:42.701Z" }, + { url = "https://files.pythonhosted.org/packages/9d/d0/569e59983549fcf4c050f892927a55af92e0b0ab0622610c58ebbd72c03f/selene_hugr_qis_compiler-0.2.10-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:fde73e325ad51bb00c40978a4a17ecb31ff968854d39259cee5a600bf2964b54", size = 32893561, upload-time = "2025-11-10T14:48:46.504Z" }, + { url = "https://files.pythonhosted.org/packages/92/b0/abf9cfe11934100a2210468545c7a8b8508534c1c90b882484a9b59fbf96/selene_hugr_qis_compiler-0.2.10-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9ecb054a543a41ecadcbfdd3d49ba9ada99e5d0806fb1b10638d3e9c1d6e6216", size = 33989527, upload-time = "2025-11-10T14:48:49.734Z" }, + { url = "https://files.pythonhosted.org/packages/12/36/476ac6ffdd5e8edc7d1f4772fb1eb3679e6e38105aa529a3d59a5d211bfa/selene_hugr_qis_compiler-0.2.10-cp310-abi3-win_amd64.whl", hash = "sha256:bf013a2b6910dbac1a811d95822df25d4bdcd96f15d9b1aa263b578834a45c27", size = 29199534, upload-time = "2025-11-10T14:48:52.545Z" }, +] + +[[package]] +name = "selene-sim" +version = "0.2.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hugr" }, + { name = "lief" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pydot" }, + { name = "pyyaml" }, + { name = "selene-core" }, + { name = "selene-hugr-qis-compiler" }, + { name = "tqdm" }, + { name = "ziglang" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/37/9fb5edc594c983fe352dfa9147643faaf86af4510a42018ae81aa40bdb32/selene_sim-0.2.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a846c854c1591831f92a47c75a22d60554bcf2d742d994d0e5b0d9fc01a5f574", size = 3906356, upload-time = "2026-01-08T21:06:07.467Z" }, + { url = "https://files.pythonhosted.org/packages/b4/af/5d3843877b120340a526af5106c80ab3f3417c77c66dc38458d312a75a71/selene_sim-0.2.7-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:3b8c01cb3a89397d04decfa65ce771bc4e6f6bdf52067768c683d3a13988fd34", size = 4009827, upload-time = "2026-01-08T21:06:08.988Z" }, + { url = "https://files.pythonhosted.org/packages/2b/50/c16e19c6895b53e21f67eb1c409030bd90836c7c75c15ba72dba8c0edb58/selene_sim-0.2.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:0062eb50380df41876937e8478f4dca0d87fa5443f6981c66b010deada7706a5", size = 4372378, upload-time = "2026-01-08T21:06:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/e9/17/afefb21fa21bbc719a053b62a1c8df5f521c9bc5544c2efe6c946b582b17/selene_sim-0.2.7-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:f2eb770b5224cf8a20d0622bdc4ee09251e8db71472acf58f949509eb7157a1f", size = 4416747, upload-time = "2026-01-08T21:06:11.899Z" }, + { url = "https://files.pythonhosted.org/packages/b6/de/3a09afbade0b6e2d283670469d9e0d787ba0b1f52a94a3864f12e0295e9a/selene_sim-0.2.7-py3-none-win_amd64.whl", hash = "sha256:4d9b4c0aab196a1d17d724771495152384313d724543b17522199004272f5a98", size = 2774835, upload-time = "2026-01-08T21:06:13.224Z" }, +] + +[[package]] +name = "semver" +version = "3.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, +] + +[[package]] +name = "tket-exts" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hugr" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/c7/62e7b82d86251bcb6fa455e0707aec038a2141983d9e4974693dc6de470f/tket_exts-0.12.1.tar.gz", hash = "sha256:df2720b97fc9aa16142bdc2724aef66dc69db852a18c2d9df89e48c9b9dcbcd2", size = 21222, upload-time = "2026-01-06T14:18:11.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/c2/d57e12b14e2a466c548f21e9b8a91266008a49cb5faa248cb51d1e89251c/tket_exts-0.12.1-py3-none-any.whl", hash = "sha256:8f58308b1f25423f3cf933fa6087800cc1fa5f7919bf26a20cfbebfb82c0786b", size = 34092, upload-time = "2026-01-06T14:18:10.153Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540, upload-time = "2024-11-24T20:12:19.698Z" }, +] + +[[package]] +name = "types-requests" +version = "2.32.4.20260107" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165, upload-time = "2026-01-07T03:20:54.091Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676, upload-time = "2026-01-07T03:20:52.929Z" }, +] + +[[package]] +name = "types-tqdm" +version = "4.67.0.20250809" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d0/cf498fc630d9fdaf2428b93e60b0e67b08008fec22b78716b8323cf644dc/types_tqdm-4.67.0.20250809.tar.gz", hash = "sha256:02bf7ab91256080b9c4c63f9f11b519c27baaf52718e5fdab9e9606da168d500", size = 17200, upload-time = "2025-08-09T03:17:43.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/13/3ff0781445d7c12730befce0fddbbc7a76e56eb0e7029446f2853238360a/types_tqdm-4.67.0.20250809-py3-none-any.whl", hash = "sha256:1a73053b31fcabf3c1f3e2a9d5ecdba0f301bde47a418cd0e0bdf774827c5c57", size = 24020, upload-time = "2025-08-09T03:17:42.453Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "wasmtime" +version = "38.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-resources" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/79/3d055ce7a0a237941d1910f32a1b6692dee4021d0cb709a97d2feb3f1ef3/wasmtime-38.0.0.tar.gz", hash = "sha256:75d38a075571756543266df782979fc2204cafd1fb7f3ebbb901e05df916dd34", size = 147265, upload-time = "2025-10-20T21:03:50.452Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/7a/2714d2f9424b952d71ca88ac264352f0c3d3282cf62d93a3ce337b50624e/wasmtime-38.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:71acc1f48ba6addbd4aee72bb249025709b31bb6a655cbecf2972f3412a8c3b0", size = 7786963, upload-time = "2025-10-20T21:03:29.124Z" }, + { url = "https://files.pythonhosted.org/packages/cf/fa/52965b32fa204dde06585641ba367313db46149c3ee15f05e21babcb2d96/wasmtime-38.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:c3dac04170a3fa4257ba0b0a04f360c09ddae4e67c1ceca144d757a372004e2c", size = 8596069, upload-time = "2025-10-20T21:03:31.694Z" }, + { url = "https://files.pythonhosted.org/packages/5c/56/1ba8709083dbdbd0f734921704513d45fcd855e4406ae0f4a7d06c78c7e5/wasmtime-38.0.0-py3-none-any.whl", hash = "sha256:df99e75296a504d37053bc0cae3a73cc5ec5c1942d8f086a4195afaa26274457", size = 7160166, upload-time = "2025-10-20T21:03:34.949Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/c470e5be91cccf7fbc4db4ce42eafb8f0f34b45aeb92c5bfbf963d100e0b/wasmtime-38.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:1c46037114350f25b23eb928085b29dc8acfe4de7ba6a59584b51bfc8533e14c", size = 8340326, upload-time = "2025-10-20T21:03:37.414Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/83d598af08eb297f1a644e678ce2d6c335f015c9f3c8c2532e289801293b/wasmtime-38.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:479529425f68cd69f8f4c369abf50d6f44a0e2395f5ce2b9a506c8a695aef65b", size = 7457077, upload-time = "2025-10-20T21:03:39.876Z" }, + { url = "https://files.pythonhosted.org/packages/54/de/7b8898c9e7f73a2f4c20b621279143cfc27836dba0db676e9a923cd0a461/wasmtime-38.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:f6c87bfe9de9a35de5b28fbdac192e2526486e3d0a3be48e107789cf117925fd", size = 8676002, upload-time = "2025-10-20T21:03:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/8e6f9670ea1476735517b28b99b2595e9000d46ee0a276d0e0b547cc928c/wasmtime-38.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:d5455bd83e82b04db32c226524b1c084a71d5d9d4ada3c5d0cf5cccaf2563253", size = 7725439, upload-time = "2025-10-20T21:03:44.34Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/b2b024d203dd676811729ad8de232608b2e727a89cd5ad16fbdeeac98dd7/wasmtime-38.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:3864469deb899eda7f32193d8cc8922d6b320cc016d08e601236dbfd77b096fe", size = 8706404, upload-time = "2025-10-20T21:03:45.756Z" }, + { url = "https://files.pythonhosted.org/packages/66/99/6da689c6534b16b2e6959321533f0000228854e5f4211eb00bfa7b6bcebc/wasmtime-38.0.0-py3-none-win_amd64.whl", hash = "sha256:c0f7ac800575e592e74ce7d111e04ad7b3dbbff421bfac00c94432300ee39bfe", size = 7160172, upload-time = "2025-10-20T21:03:47.503Z" }, + { url = "https://files.pythonhosted.org/packages/a1/85/fbba5850c65ab91312a68871dec4e36abbb7ad717f3df6c790bff330b054/wasmtime-38.0.0-py3-none-win_arm64.whl", hash = "sha256:79fd37228ded91e84ee6748894ef4686bd6232e635e150e6a70beabd99c67868", size = 6272297, upload-time = "2025-10-20T21:03:48.882Z" }, +] + +[[package]] +name = "ziglang" +version = "0.15.1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/4d/cf640c988231f36c796f7310cf54e89866c96e63cd6599bac4bbbdcf7014/ziglang-0.15.1-py3-none-macosx_12_0_arm64.whl", hash = "sha256:f2f92404599822152eff2ef2830632e9ebeb18e55168dddd5f68f6bfeb2c5f4d", size = 93002414, upload-time = "2025-08-30T03:57:24.477Z" }, + { url = "https://files.pythonhosted.org/packages/c7/3d/4375a5c27ad3fd72d7a7ba2f013bdd9a99f9e0dae847cbfdfe56f9cb0f0b/ziglang-0.15.1-py3-none-macosx_12_0_x86_64.whl", hash = "sha256:77348af083caf1c364466b931b5e7f9608687c88670bdda77c237570096bde09", size = 97045379, upload-time = "2025-08-30T03:57:36.813Z" }, + { url = "https://files.pythonhosted.org/packages/26/da/d6fd19f278745ae187ca46e907f83b4c83142f5f6c5de8f159879cc70d4b/ziglang-0.15.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:129c6b9b9e428ae48a6949ea6da55239f8bd6480656df1eb0b6947f75f851fdf", size = 97295023, upload-time = "2025-08-30T03:57:48.76Z" }, + { url = "https://files.pythonhosted.org/packages/30/a0/8aabb5f4e0862340ebb4d86f614567a5492e4e1ac09639c9c759404babe1/ziglang-0.15.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:4e45994a0e608d9b16ecad255698f5557a2e24de0bd7ba9efb156ab3f3683d9a", size = 93506712, upload-time = "2025-08-30T03:58:00.996Z" }, + { url = "https://files.pythonhosted.org/packages/fd/0b/ea4ac1a1242c478c7ccbf0c031c9f6f58290c5ea910490fc0b5a3772648e/ziglang-0.15.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6c32697f9e165b7b6c5950ab0a1cd2e2bc3e72f4ff2d59bc5121b2b71955a77a", size = 90536567, upload-time = "2025-08-30T03:58:12.841Z" }, + { url = "https://files.pythonhosted.org/packages/bf/12/89d3ac45c7072de940328c5bf52e9846797237423ec4415b5c7e7775a2e1/ziglang-0.15.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:f9d2493ff7c44967c584212100ce57bb00800ec9545527acfce677b4b3225242", size = 91572198, upload-time = "2025-09-11T21:51:29.45Z" }, + { url = "https://files.pythonhosted.org/packages/08/a2/89539bbe0ad375cb72b788ff11724f408f870a045c7d8dc9451066bd6526/ziglang-0.15.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:b261fe992100fdfb3e61cdd0758335ac8514c8aa4029e3604490648c6a337466", size = 99939660, upload-time = "2025-08-30T03:58:27.382Z" }, + { url = "https://files.pythonhosted.org/packages/cc/19/95c05b330c70275c79cd1964e9651d87c67876ebc70d148432748f629b95/ziglang-0.15.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.musllinux_1_1_s390x.whl", hash = "sha256:9118903a47bbcc747ce47b1456c552a04bb6a0e1be28275ab20bbccf8104e474", size = 99578982, upload-time = "2025-08-30T03:58:39.312Z" }, + { url = "https://files.pythonhosted.org/packages/f0/10/e1f17be5cdbaae6932c110b1ce3d877fbaeeb40b34a18390324219110da1/ziglang-0.15.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6a49c03d692e31a9a312ec45c0829bc281572196a9df52318bb0be0d05ae20ea", size = 94114681, upload-time = "2025-08-30T03:58:51.877Z" }, + { url = "https://files.pythonhosted.org/packages/b6/9b/5b11a0f78ab3475e25c0e53e240e39b2c2fcfe1e651f639834860755d7cf/ziglang-0.15.1-py3-none-win32.whl", hash = "sha256:b8ba52adc1401c470707a420f2e5e199fce142436717aa822e00a93a18a9ea25", size = 96132500, upload-time = "2025-08-30T03:59:04.321Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f3/7b0fb6f1f7705221694f90c4756480646c1a08f6ae94e9f567ac4d1c4d17/ziglang-0.15.1-py3-none-win_amd64.whl", hash = "sha256:dae4c6aef5bf9d64f6eb71ae57603e2fd0ad5e79efdd5ca3ea058fb1e738d961", size = 94047515, upload-time = "2025-08-30T03:59:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0a/6b15293c30f5f555f1313010855d588004db1166af51bbc697a271cc1ce2/ziglang-0.15.1-py3-none-win_arm64.whl", hash = "sha256:5965248dd7f72769ff339a04bd8e29e13fa205758c64766ef9cc55eaafbaedb8", size = 89692347, upload-time = "2025-08-30T03:59:31.195Z" }, +] + +[[package]] +name = "zluppy" +version = "0.1.0" +source = { editable = "." } + +[package.dev-dependencies] +dev = [ + { name = "patchelf", marker = "sys_platform != 'win32'" }, + { name = "pytest" }, + { name = "quantum-pecos" }, +] +test = [ + { name = "pytest" }, + { name = "quantum-pecos" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "patchelf", marker = "sys_platform != 'win32'" }, + { name = "pytest", specifier = ">=9.0.2" }, + { name = "quantum-pecos", editable = "../quantum-pecos" }, +] +test = [ + { name = "pytest", specifier = ">=7.0" }, + { name = "quantum-pecos", editable = "../quantum-pecos" }, +] From 632046ce442f96b4b22fa071e2eac8b5c41ed60f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 16 Jun 2026 15:20:11 -0600 Subject: [PATCH 197/388] Document the full neo-translated noise surface on SimStack::Neo --- crates/pecos/src/unified_sim.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 09f5dc8ad..c855d060c 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -36,11 +36,12 @@ pub enum SimStack { /// Requires building pecos with the `neo` cargo feature. Currently /// routes QASM programs with the default quantum backend (HUGR runs but /// does not yet match the engines result contract, so it is rejected). - /// Depolarizing-family noise (`PassThroughNoise`, `DepolarizingNoise`, - /// `DepolarizingNoiseModel`) is translated with identical conventions; - /// other noise types, explicit `.classical()`, and explicit - /// `.quantum()` configuration are not yet translated and are rejected - /// with an error at `run()`. + /// The translated noise surface is the depolarizing family + /// (`PassThroughNoise`, `DepolarizingNoise`, `BiasedDepolarizingNoise`, + /// and their builders) and the `GeneralNoiseModel` simple-probability + /// subset, including angle-dependent two-qubit scaling. Other noise + /// configurations, explicit `.classical()`, and explicit `.quantum()` + /// are not yet translated and are rejected with an error at `run()`. Neo, } From 1a14ebfea353ee58bebef0261beeff712215f013 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 16 Jun 2026 18:22:13 -0600 Subject: [PATCH 198/388] Route HUGR through the PHIR engine on the neo stack so its results use the named-register contract --- crates/pecos/src/unified_sim.rs | 66 +++++----- crates/pecos/tests/neo_hugr_routing_test.rs | 136 ++++++++++++++++++++ crates/pecos/tests/neo_routing_test.rs | 21 --- python/pecos-rslib/src/sim.rs | 2 +- 4 files changed, 169 insertions(+), 56 deletions(-) create mode 100644 crates/pecos/tests/neo_hugr_routing_test.rs diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index c855d060c..72812e510 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -33,9 +33,10 @@ pub enum SimStack { Engines, /// The data-oriented `pecos-neo` stack (experimental). /// - /// Requires building pecos with the `neo` cargo feature. Currently - /// routes QASM programs with the default quantum backend (HUGR runs but - /// does not yet match the engines result contract, so it is rejected). + /// Requires building pecos with the `neo` cargo feature. Routes QASM and + /// HUGR programs with the default quantum backend (HUGR runs through the + /// PHIR engine so its results use the same named-register contract as the + /// engines/QASM path, with no Selene/LLVM dependency). /// The translated noise surface is the depolarizing family /// (`PassThroughNoise`, `DepolarizingNoise`, `BiasedDepolarizingNoise`, /// and their builders) and the `GeneralNoiseModel` simple-probability @@ -200,7 +201,7 @@ impl ProgrammedSimBuilder { /// Run the program on the pecos-neo stack. #[cfg(feature = "neo")] fn run_neo(self, shots: usize) -> Result { - use pecos_neo::tool::{monte_carlo, sim_neo}; + use pecos_neo::tool::{monte_carlo, sim_neo, sim_neo_builder}; if self.override_classical { return Err(PecosError::Input( @@ -220,35 +221,6 @@ impl ProgrammedSimBuilder { .to_string(), )); } - match &self.program { - Program::Qasm(_) => {} - Program::Hugr(_) => { - // HUGR runs on neo via pecos_hugr::hugr_engine, whose result - // contract differs from the engines/QASM path: for HUGR programs - // without explicit result() captures it emits per-qubit `q0`/`q1` - // values and a `measurements` array instead of the program's - // named classical register (e.g. "c"). Returning that silently - // would break consumers that index `shot.data["c"]`, so reject - // until the neo HUGR result contract is reconciled (the physics - // is already correct; only the register naming diverges). - return Err(PecosError::Input( - "HUGR programs are not yet routed to the neo stack: the neo HUGR \ - engine emits a different result contract (per-qubit q0/q1 plus a \ - `measurements` array) than the engines and QASM paths (the \ - program's named classical register, e.g. \"c\") for programs \ - without explicit result() captures, so neo HUGR results are not \ - yet drop-in compatible. Use .stack(SimStack::Engines) for HUGR." - .to_string(), - )); - } - _ => { - return Err(PecosError::Input( - "Only QASM programs are routed to the neo stack so far; \ - use .stack(SimStack::Engines) for other program types." - .to_string(), - )); - } - } let mut sampler = monte_carlo(shots); if let Some(workers) = self.routed.workers { @@ -258,7 +230,33 @@ impl ProgrammedSimBuilder { sampler = sampler.auto_workers(); } - let mut builder = sim_neo(self.program).auto().sampling(sampler); + // QASM auto-selects the QASM engine. HUGR is routed through the PHIR + // engine (HUGR -> PHIR), which emits the program's NAMED classical + // register (e.g. "c") -- matching the engines/QASM result contract -- + // and needs no Selene/LLVM. (neo's own `hugr_engine` would instead emit + // per-qubit `q0`/`q1` and a `measurements` array, which is not + // drop-in compatible; the named-register PHIR path is, so it is the one + // routed here.) + let configured = match self.program { + Program::Qasm(qasm) => sim_neo(qasm).auto(), + Program::Hugr(hugr) => { + let phir_engine = pecos_phir::phir_engine() + .from_hugr_bytes(&hugr.hugr) + .map_err(|e| { + PecosError::Generic(format!("Failed to load HUGR program: {e}")) + })?; + sim_neo_builder().with_engine(phir_engine).auto() + } + _ => { + return Err(PecosError::Input( + "Only QASM and HUGR programs are routed to the neo stack so far; \ + use .stack(SimStack::Engines) for other program types." + .to_string(), + )); + } + }; + + let mut builder = configured.sampling(sampler); if let Some(seed) = self.routed.seed { builder = builder.seed(seed); } diff --git a/crates/pecos/tests/neo_hugr_routing_test.rs b/crates/pecos/tests/neo_hugr_routing_test.rs new file mode 100644 index 000000000..34968da73 --- /dev/null +++ b/crates/pecos/tests/neo_hugr_routing_test.rs @@ -0,0 +1,136 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! HUGR program routing to the neo stack. +//! +//! The neo stack runs HUGR through the PHIR engine (HUGR -> PHIR), so its +//! results use the same NAMED classical register contract (`c`) as the +//! engines/QASM path -- not the per-qubit `q0`/`q1` + `measurements` shape that +//! `pecos_hugr::hugr_engine` would emit -- and needs no Selene/LLVM. These +//! tests lock that, using the Guppy-generated fixtures shared with +//! `hugr_execution_tests.rs`, and cross-check against the engines PHIR engine. + +#![cfg(feature = "neo")] + +use std::collections::BTreeSet; + +use pecos::prelude::Data; +use pecos::{SimStack, sim}; +use pecos_programs::Hugr; + +/// Run a HUGR fixture on the neo stack and return the `c` register of each shot. +/// Panics if a shot has no `c` register (the contract this test guards). +fn neo_hugr_c(bytes: &[u8], seed: u64, shots: usize) -> Vec { + let results = sim(Hugr::from_bytes(bytes.to_vec())) + .stack(SimStack::Neo) + .seed(seed) + .run(shots) + .expect("neo HUGR run"); + results + .shots + .iter() + .map(|shot| { + shot.data + .get("c") + .and_then(Data::as_u32) + .expect("neo HUGR results must expose the named `c` register") + }) + .collect() +} + +/// The engines-side reference: the same HUGR through `pecos_phir`'s engine on +/// the engines `sim_builder` (the `hugr_execution_tests.rs` path). +fn engines_phir_c(bytes: &[u8], seed: u64, shots: usize) -> Vec { + let builder = pecos_phir::phir_engine() + .from_hugr_bytes(bytes) + .expect("HUGR -> PhirEngineBuilder"); + let results = pecos_engines::sim_builder() + .classical(builder) + .seed(seed) + .run(shots) + .expect("engines PHIR run"); + results + .shots + .iter() + .filter_map(|shot| shot.data.get("c").and_then(Data::as_u32)) + .collect() +} + +#[test] +fn neo_hugr_results_use_the_named_c_register() { + // The contract: the only register key is `c`, NOT `q0`/`q1`/`measurements`. + let bytes = include_bytes!("test_data/hugr/bell_state.hugr"); + let results = sim(Hugr::from_bytes(bytes.to_vec())) + .stack(SimStack::Neo) + .seed(42) + .run(5) + .expect("neo HUGR run"); + let keys: Vec<&String> = results.shots[0].data.keys().collect(); + assert!( + results.shots[0].data.contains_key("c"), + "neo HUGR must expose `c`; got keys {keys:?}" + ); + assert!( + !results.shots[0].data.contains_key("measurements") + && !results.shots[0].data.contains_key("q0"), + "neo HUGR must NOT expose the raw q0/measurements shape; got keys {keys:?}" + ); +} + +#[test] +fn neo_hugr_bell_state_correlations() { + let results = neo_hugr_c(include_bytes!("test_data/hugr/bell_state.hugr"), 42, 200); + assert_eq!(results.len(), 200); + for &v in &results { + assert!(v == 0 || v == 3, "Bell on neo must be 00 or 11, got {v}"); + } + assert!(results.contains(&0), "expected some 00"); + assert!(results.contains(&3), "expected some 11"); +} + +#[test] +fn neo_hugr_ghz_state_correlations() { + let results = neo_hugr_c(include_bytes!("test_data/hugr/ghz_state.hugr"), 42, 200); + assert_eq!(results.len(), 200); + for &v in &results { + assert!(v == 0 || v == 7, "GHZ on neo must be 000 or 111, got {v}"); + } +} + +#[test] +fn neo_hugr_rz_x_is_deterministic() { + // Rz(pi)|0> stays |0>, X|0> -> |1>: result 0b10 = 2 every shot. + let results = neo_hugr_c(include_bytes!("test_data/hugr/rz_x.hugr"), 42, 20); + assert_eq!(results.len(), 20); + for &v in &results { + assert_eq!( + v, 2, + "Rz(pi)|0> + X|0> on neo should give 0b10 = 2, got {v}" + ); + } +} + +#[test] +fn neo_hugr_support_matches_engines_phir() { + // Cross-check the neo facade route against the engines PHIR engine (the + // hugr_execution_tests path): both run the HUGR via PHIR, so the outcome + // SUPPORT must agree. Independent seeds -- agreement is from the shared + // contract, not a shared RNG stream. + let bytes = include_bytes!("test_data/hugr/bell_state.hugr"); + let neo: BTreeSet = neo_hugr_c(bytes, 1, 400).into_iter().collect(); + let engines: BTreeSet = engines_phir_c(bytes, 2, 400).into_iter().collect(); + assert_eq!( + neo, engines, + "neo-facade HUGR and engines-PHIR HUGR must explore the same Bell support" + ); + assert_eq!(neo, BTreeSet::from([0, 3])); +} diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index 5bf09c4f5..f10dc8fef 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -276,27 +276,6 @@ fn neo_stack_rejects_unrouted_quantum_backend() { assert!(err.to_string().contains("not yet routed to the neo stack")); } -#[test] -fn neo_stack_rejects_hugr_until_result_contract_reconciled() { - // HUGR runs on neo with correct physics, but the neo HUGR engine emits a - // different result contract (per-qubit q0/q1 plus a `measurements` array) - // than the engines/QASM path (the program's named classical register) for - // programs without explicit result() captures. Rather than silently return - // a non-drop-in ShotVec, the neo route rejects HUGR until that contract is - // reconciled (see `run_neo` in unified_sim.rs). - let hugr = - pecos_programs::Hugr::from_bytes(include_bytes!("test_data/hugr/bell_state.hugr").to_vec()); - let err = sim(hugr) - .stack(SimStack::Neo) - .run(5) - .expect_err("HUGR is not yet contract-compatible on the neo stack"); - assert!( - err.to_string() - .contains("HUGR programs are not yet routed to the neo stack"), - "unexpected error: {err}" - ); -} - #[test] fn neo_stack_rejects_build() { let Err(err) = sim(deterministic_conditional_qasm()) diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 1e37adfbb..53bdee2a3 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -412,7 +412,7 @@ impl PySimBuilder { | SimBuilderInner::Phir(_) => { if parsed == PySimStack::Neo { return Err(PyValueError::new_err( - "Only QASM programs are routed to the neo stack so far; \ + "Only QASM and HUGR programs are routed to the neo stack so far; \ this program type runs on the engines stack", )); } From ba9f4c9e0ff9576dd5b7991256ed19093b8736f3 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 16 Jun 2026 18:24:05 -0600 Subject: [PATCH 199/388] Add uniform Clifford frame SZZ DEM plumbing --- .../src/pecos/qec/surface/__init__.py | 19 ++ .../qec/surface/_clifford_deformation.py | 277 ++++++++++++++++++ .../src/pecos/qec/surface/circuit_builder.py | 186 ++++++++++-- .../src/pecos/qec/surface/decode.py | 40 ++- .../qec/surface/test_clifford_deformation.py | 159 ++++++++++ 5 files changed, 660 insertions(+), 21 deletions(-) create mode 100644 python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py create mode 100644 python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index 925d93684..4aba263df 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -18,6 +18,16 @@ """ # Circuit generation from geometry (unified abstraction) +from pecos.qec.surface._clifford_deformation import ( + LocalCliffordFrame, + ResolvedPauliCheck, + ResolvedPauliLogical, + ResolvedSurfaceCliffordFrame, + SignedPauli, + global_surface_frame, + normalize_surface_frame_policy, + resolve_surface_clifford_frame, +) from pecos.qec.surface._detection_events import extract_detection_events_and_observables from pecos.qec.surface._twirl_config import GuppyRngMaskConfig, TwirlConfig from pecos.qec.surface.circuit_builder import ( @@ -117,6 +127,15 @@ # Twirling config (Pauli-frame randomization) "GuppyRngMaskConfig", "TwirlConfig", + # Clifford-deformed surface-code metadata + "LocalCliffordFrame", + "ResolvedPauliCheck", + "ResolvedPauliLogical", + "ResolvedSurfaceCliffordFrame", + "SignedPauli", + "global_surface_frame", + "normalize_surface_frame_policy", + "resolve_surface_clifford_frame", # Rotated lattice (most common, default) "compute_rotated_x_stabilizers", "compute_rotated_z_stabilizers", diff --git a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py new file mode 100644 index 000000000..8f1b0051b --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py @@ -0,0 +1,277 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Surface-code Clifford-deformation metadata. + +This module resolves source-level surface-code checks and logical operators +through a concrete local Clifford frame. It intentionally stops before circuit +emission: renderers should consume the resolved Pauli checks instead of +guessing whether a frame can be represented by the legacy CSS X/Z helper. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +if TYPE_CHECKING: + from collections.abc import Sequence + + from pecos.qec.surface.patch import Stabilizer, SurfacePatch + +PauliAxis = Literal["X", "Y", "Z"] +SurfaceFramePolicy = Literal[ + "identity", + "global_h", + "global_axis_cycle_f", + "global_axis_cycle_f2", +] + +_SUPPORTED_GLOBAL_FRAME_POLICIES = frozenset( + { + "identity", + "global_h", + "global_axis_cycle_f", + "global_axis_cycle_f2", + }, +) + + +@dataclass(frozen=True, order=True) +class SignedPauli: + """One signed single-qubit Pauli image.""" + + axis: PauliAxis + sign: int = 1 + + def __post_init__(self) -> None: + axis = str(self.axis).upper() + if axis not in {"X", "Y", "Z"}: + msg = f"Pauli axis must be 'X', 'Y', or 'Z', got {self.axis!r}" + raise ValueError(msg) + sign = int(self.sign) + if sign not in {-1, 1}: + msg = f"Pauli sign must be +/-1, got {self.sign!r}" + raise ValueError(msg) + object.__setattr__(self, "axis", axis) + object.__setattr__(self, "sign", sign) + + def label(self) -> str: + """Return a compact signed label such as ``X`` or ``-Y``.""" + return self.axis if self.sign > 0 else f"-{self.axis}" + + +@dataclass(frozen=True) +class LocalCliffordFrame: + """Images of source X and Z under one local Clifford frame.""" + + x_image: SignedPauli + z_image: SignedPauli + + def image(self, source_axis: str) -> SignedPauli: + """Return the signed physical Pauli image for source ``X`` or ``Z``.""" + axis = source_axis.upper() + if axis == "X": + return self.x_image + if axis == "Z": + return self.z_image + msg = f"source_axis must be 'X' or 'Z', got {source_axis!r}" + raise ValueError(msg) + + +@dataclass(frozen=True) +class ResolvedPauliCheck: + """A source stabilizer resolved to concrete physical Pauli axes.""" + + source_kind: PauliAxis + stabilizer_index: int + data_qubits: tuple[int, ...] + paulis: tuple[SignedPauli, ...] + is_boundary: bool + + @property + def axes(self) -> tuple[PauliAxis, ...]: + """Physical Pauli axes in data-qubit order.""" + return tuple(pauli.axis for pauli in self.paulis) + + @property + def signs(self) -> tuple[int, ...]: + """Physical Pauli signs in data-qubit order.""" + return tuple(pauli.sign for pauli in self.paulis) + + @property + def is_uniform_axis(self) -> bool: + """Whether every data qubit is checked in the same physical axis.""" + return len(set(self.axes)) <= 1 + + @property + def uniform_axis(self) -> PauliAxis | None: + """Return the uniform physical axis, or ``None`` for mixed checks.""" + if not self.is_uniform_axis or not self.axes: + return None + return self.axes[0] + + @property + def requires_deformed_check_synthesis(self) -> bool: + """Whether the legacy CSS helper cannot synthesize this check.""" + return self.uniform_axis not in {"X", "Z"} or not self.is_uniform_axis + + +@dataclass(frozen=True) +class ResolvedPauliLogical: + """A source logical operator resolved to concrete physical Pauli axes.""" + + source_kind: PauliAxis + data_qubits: tuple[int, ...] + paulis: tuple[SignedPauli, ...] + + @property + def axes(self) -> tuple[PauliAxis, ...]: + """Physical Pauli axes in data-qubit order.""" + return tuple(pauli.axis for pauli in self.paulis) + + @property + def is_uniform_axis(self) -> bool: + """Whether every data qubit is measured in the same physical axis.""" + return len(set(self.axes)) <= 1 + + @property + def uniform_axis(self) -> PauliAxis | None: + """Return the uniform physical axis, or ``None`` for mixed logicals.""" + if not self.is_uniform_axis or not self.axes: + return None + return self.axes[0] + + +@dataclass(frozen=True) +class ResolvedSurfaceCliffordFrame: + """Resolved source checks/logicals for one concrete surface-code frame.""" + + policy: str + data_frames: tuple[LocalCliffordFrame, ...] + x_checks: tuple[ResolvedPauliCheck, ...] + z_checks: tuple[ResolvedPauliCheck, ...] + logical_x: ResolvedPauliLogical + logical_z: ResolvedPauliLogical + + @property + def checks(self) -> tuple[ResolvedPauliCheck, ...]: + """All resolved checks in source X-then-Z order.""" + return (*self.x_checks, *self.z_checks) + + @property + def requires_deformed_check_synthesis(self) -> bool: + """Whether any check requires the generic deformed-check path.""" + return any(check.requires_deformed_check_synthesis for check in self.checks) + + def css_physical_memory_basis(self, source_basis: str) -> PauliAxis: + """Return the physical X/Z basis if the CSS helper can represent this frame. + + This is intentionally stricter than asking only where the logical memory + axis maps. A global ``F`` frame maps source-Z memory to physical-X + readout, but it also maps source-X stabilizers to physical-Y checks. + Such a circuit is not representable by the current CSS helper and must + use a deformed-check renderer. + """ + basis = source_basis.upper() + if basis == "X": + logical_axis = self.logical_x.uniform_axis + elif basis == "Z": + logical_axis = self.logical_z.uniform_axis + else: + msg = f"source_basis must be 'X' or 'Z', got {source_basis!r}" + raise ValueError(msg) + + if self.requires_deformed_check_synthesis: + msg = ( + f"Frame policy {self.policy!r} requires deformed check synthesis " + "and cannot be represented by the legacy CSS surface helper." + ) + raise NotImplementedError(msg) + if logical_axis not in {"X", "Z"}: + msg = ( + f"Frame policy {self.policy!r} maps source {basis}-memory to " + f"physical {logical_axis}; the CSS helper supports only X/Z." + ) + raise NotImplementedError(msg) + return logical_axis + + +def normalize_surface_frame_policy(policy: str) -> str: + """Normalize and validate a named surface Clifford frame policy.""" + normalized = str(policy).lower().replace("-", "_") + if normalized not in _SUPPORTED_GLOBAL_FRAME_POLICIES: + msg = ( + f"unknown surface Clifford frame policy {policy!r}; expected one of " + f"{sorted(_SUPPORTED_GLOBAL_FRAME_POLICIES)}" + ) + raise ValueError(msg) + return normalized + + +def global_surface_frame(policy: str, num_data: int) -> tuple[LocalCliffordFrame, ...]: + """Return one of the supported parameter-free global frame maps.""" + if num_data < 0: + msg = f"num_data must be non-negative, got {num_data}" + raise ValueError(msg) + normalized = normalize_surface_frame_policy(policy) + frame = _global_frame_element(normalized) + return tuple(frame for _ in range(num_data)) + + +def resolve_surface_clifford_frame( + patch: SurfacePatch, + *, + policy: str = "identity", + data_frames: Sequence[LocalCliffordFrame] | None = None, +) -> ResolvedSurfaceCliffordFrame: + """Resolve source surface checks/logicals through a local Clifford frame.""" + normalized = normalize_surface_frame_policy(policy) + frames = tuple(data_frames) if data_frames is not None else global_surface_frame(normalized, patch.num_data) + if len(frames) != patch.num_data: + msg = f"data frame length {len(frames)} does not match patch.num_data={patch.num_data}" + raise ValueError(msg) + + def resolve_check(stabilizer: Stabilizer) -> ResolvedPauliCheck: + return ResolvedPauliCheck( + source_kind=stabilizer.stab_type, # type: ignore[arg-type] + stabilizer_index=stabilizer.index, + data_qubits=tuple(stabilizer.data_qubits), + paulis=tuple(frames[q].image(stabilizer.stab_type) for q in stabilizer.data_qubits), + is_boundary=stabilizer.is_boundary, + ) + + geom = patch.geometry + if geom.logical_x is None or geom.logical_z is None: + msg = "Surface patch must have both X and Z logical operators" + raise ValueError(msg) + + return ResolvedSurfaceCliffordFrame( + policy=normalized, + data_frames=frames, + x_checks=tuple(resolve_check(stabilizer) for stabilizer in patch.x_stabilizers), + z_checks=tuple(resolve_check(stabilizer) for stabilizer in patch.z_stabilizers), + logical_x=ResolvedPauliLogical( + source_kind="X", + data_qubits=tuple(geom.logical_x.data_qubits), + paulis=tuple(frames[q].image("X") for q in geom.logical_x.data_qubits), + ), + logical_z=ResolvedPauliLogical( + source_kind="Z", + data_qubits=tuple(geom.logical_z.data_qubits), + paulis=tuple(frames[q].image("Z") for q in geom.logical_z.data_qubits), + ), + ) + + +def _global_frame_element(policy: str) -> LocalCliffordFrame: + if policy == "identity": + return LocalCliffordFrame(SignedPauli("X"), SignedPauli("Z")) + if policy == "global_h": + return LocalCliffordFrame(SignedPauli("Z"), SignedPauli("X")) + if policy == "global_axis_cycle_f": + return LocalCliffordFrame(SignedPauli("Y"), SignedPauli("X")) + if policy == "global_axis_cycle_f2": + return LocalCliffordFrame(SignedPauli("Z"), SignedPauli("Y")) + msg = f"unknown surface Clifford frame policy {policy!r}" + raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 9524b51b3..bc219d43b 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -36,6 +36,9 @@ normalize_ancilla_budget as _normalize_ancilla_budget, ) from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan +from pecos.qec.surface._clifford_deformation import ( + resolve_surface_clifford_frame, +) # Stabilizer geometry helpers live in the low-level patch module (single # source of truth). Only the two used by the circuit renderer are imported @@ -48,6 +51,11 @@ if TYPE_CHECKING: from pecos.qec.surface._check_plan import ResolvedSurfaceCheckPlan + from pecos.qec.surface._clifford_deformation import ( + PauliAxis, + ResolvedPauliCheck, + ResolvedSurfaceCliffordFrame, + ) from pecos.qec.surface._twirl_config import TwirlConfig from pecos.qec.surface.patch import ( LogicalDescriptor, @@ -445,6 +453,58 @@ def _szz_residual_plan_for_check_plan( raise NotImplementedError(msg) +def _resolve_szz_clifford_frame_for_builder( + patch: SurfacePatch, + *, + interaction_basis: str, + clifford_frame_policy: str | None, +) -> ResolvedSurfaceCliffordFrame | None: + """Resolve an optional source-level Clifford frame for SZZ rendering.""" + if clifford_frame_policy is None: + return None + if interaction_basis != "szz": + msg = "clifford_frame_policy currently requires interaction_basis='szz'" + raise NotImplementedError(msg) + resolved_frame = resolve_surface_clifford_frame(patch, policy=clifford_frame_policy) + unsupported = [check for check in resolved_frame.checks if not check.is_uniform_axis] + if unsupported: + preview = ", ".join(f"{check.source_kind}{check.stabilizer_index}:{check.axes}" for check in unsupported[:5]) + suffix = f", ... {len(unsupported) - 5} more" if len(unsupported) > 5 else "" + msg = ( + "clifford_frame_policy currently supports only uniform-axis " + f"stabilizer checks; mixed checks: {preview}{suffix}" + ) + raise NotImplementedError(msg) + return resolved_frame + + +def _szz_memory_physical_axis( + basis: str, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None, +) -> PauliAxis: + """Return the physical data-measurement axis for a source memory basis.""" + source_basis = basis.upper() + if source_basis not in {"X", "Z"}: + msg = f"basis must be 'X' or 'Z', got {basis!r}" + raise ValueError(msg) + if resolved_clifford_frame is None: + return source_basis # type: ignore[return-value] + + logical = ( + resolved_clifford_frame.logical_x + if source_basis == "X" + else resolved_clifford_frame.logical_z + ) + if not logical.is_uniform_axis or logical.uniform_axis is None: + msg = ( + f"clifford frame policy {resolved_clifford_frame.policy!r} maps " + f"source {source_basis}-memory to a mixed logical measurement " + f"axis {logical.axes}; mixed final readout is not implemented yet" + ) + raise NotImplementedError(msg) + return logical.uniform_axis + + def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: """Propagate local Pauli-frame bits through uncompensated SZZ/SZZdg.""" common = x_a ^ x_b @@ -804,6 +864,7 @@ def build_surface_code_circuit( twirl: TwirlConfig | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. @@ -833,6 +894,9 @@ def build_surface_code_circuit( check_plan: Named surface check-plan preset. This is the source of truth when supplied; ``interaction_basis`` must agree if also supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy. Currently supported only by the SZZ renderer for global + uniform-axis frames. Returns: Tuple of (operations list, qubit allocation info) @@ -854,6 +918,11 @@ def build_surface_code_circuit( context="abstract surface-code circuit generation", ) interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) + resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) if twirl is not None: twirl.validate_runtime_supported() twirl_site_schedule = None if twirl is None else twirl.site_schedule @@ -954,6 +1023,7 @@ def emit_gate_local_twirl_layer( allocation, cnot_rounds, _szz_residual_plan_for_check_plan(patch, resolved_plan), + resolved_clifford_frame, ), allocation, ) @@ -1233,28 +1303,27 @@ def _build_surface_code_circuit_szz( allocation: QubitAllocation, cnot_rounds: list[list[tuple[str, int, int]]], residual_plan: SzzResidualPlan, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None = None, ) -> list[SurfaceCircuitStep]: """Build the abstract SZZ/SZZdg surface-memory template.""" geom = patch.geometry num_data = geom.num_data + check_by_key: dict[tuple[str, int], ResolvedPauliCheck] = {} + if resolved_clifford_frame is not None: + check_by_key.update({("X", check.stabilizer_index): check for check in resolved_clifford_frame.x_checks}) + check_by_key.update({("Z", check.stabilizer_index): check for check in resolved_clifford_frame.z_checks}) sign_by_touch = { (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): entry.sign for entry in residual_plan.signs } - data_compensation_by_touch = { - (entry.stabilizer_type, entry.stabilizer_index, entry.data_qubit): { - ("X", 1): OpType.SXDG, - ("X", -1): OpType.SX, - ("Z", 1): OpType.SZDG, - ("Z", -1): OpType.SZ, - }[(entry.stabilizer_type, entry.sign)] - for entry in residual_plan.signs - } gate_name_by_type = { OpType.SX: "SX", OpType.SXDG: "SXDG", + OpType.SY: "SY", + OpType.SYDG: "SYDG", OpType.SZ: "SZ", OpType.SZDG: "SZDG", } + basis_axis = _szz_memory_physical_axis(basis, resolved_clifford_frame) def data_q(i: int) -> int: return allocation.data_qubits[i] @@ -1268,6 +1337,55 @@ def z_anc_q(stab_idx: int) -> int: def anc_q(stabilizer_type: str, stab_idx: int) -> int: return x_anc_q(stab_idx) if stabilizer_type == "X" else z_anc_q(stab_idx) + def physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_idx: int) -> PauliAxis: + if resolved_clifford_frame is None: + return "X" if stabilizer_type == "X" else "Z" + check = check_by_key[(stabilizer_type, stab_idx)] + try: + offset = check.data_qubits.index(data_idx) + except ValueError as exc: + msg = f"data qubit {data_idx} is not in resolved check {stabilizer_type}{stab_idx}" + raise ValueError(msg) from exc + return check.paulis[offset].axis + + def append_axis_rotation_to_z( + target_ops: list[SurfaceCircuitStep], + axis: PauliAxis, + qubit: int, + label_prefix: str, + ) -> None: + gate = { + "X": OpType.H, + "Y": OpType.SXDG, + "Z": None, + }[axis] + if gate is not None: + target_ops.append(SurfaceCircuitStep(gate, [qubit], f"{label_prefix}:to_z")) + + def append_axis_rotation_from_z( + target_ops: list[SurfaceCircuitStep], + axis: PauliAxis, + qubit: int, + label_prefix: str, + ) -> None: + gate = { + "X": OpType.H, + "Y": OpType.SX, + "Z": None, + }[axis] + if gate is not None: + target_ops.append(SurfaceCircuitStep(gate, [qubit], f"{label_prefix}:from_z")) + + def szz_touch_compensation(axis: PauliAxis, sign: int) -> OpType: + return { + ("X", 1): OpType.SXDG, + ("X", -1): OpType.SX, + ("Y", 1): OpType.SYDG, + ("Y", -1): OpType.SY, + ("Z", 1): OpType.SZDG, + ("Z", -1): OpType.SZ, + }[(axis, sign)] + def stabilizer_batches_for(selected_type: str | None = None) -> list[list[tuple[str, int]]]: """Return the same ancilla-reuse batches used by the CX template.""" total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) @@ -1333,9 +1451,14 @@ def append_szz_layer( layer_gates: list[tuple[str, int, int]], ) -> None: target_ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"SZZ round {rnd_idx + 1}")) - x_touches = [(stab_idx, data_idx) for stab_type, stab_idx, data_idx in layer_gates if stab_type == "X"] - for _stab_idx, data_idx in x_touches: - target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_pre_d{data_idx}")) + for stab_type, stab_idx, data_idx in layer_gates: + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + append_axis_rotation_to_z( + target_ops, + axis, + data_q(data_idx), + f"szz_{axis.lower()}_touch_pre:{stab_type}{stab_idx}:d{data_idx}", + ) for stab_type, stab_idx, data_idx in layer_gates: sign = sign_by_touch[(stab_type, stab_idx, data_idx)] @@ -1348,16 +1471,24 @@ def append_szz_layer( ), ) - for _stab_idx, data_idx in x_touches: - target_ops.append(SurfaceCircuitStep(OpType.H, [data_q(data_idx)], f"szz_x_touch_post_d{data_idx}")) + for stab_type, stab_idx, data_idx in layer_gates: + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + append_axis_rotation_from_z( + target_ops, + axis, + data_q(data_idx), + f"szz_{axis.lower()}_touch_post:{stab_type}{stab_idx}:d{data_idx}", + ) for stab_type, stab_idx, data_idx in layer_gates: - compensation = data_compensation_by_touch[(stab_type, stab_idx, data_idx)] + axis = physical_axis_for_touch(stab_type, stab_idx, data_idx) + sign = sign_by_touch[(stab_type, stab_idx, data_idx)] + compensation = szz_touch_compensation(axis, sign) target_ops.append( SurfaceCircuitStep( compensation, [data_q(data_idx)], - f"szz_touch_comp:{gate_name_by_type[compensation]}:{stab_type}{stab_idx}:d{data_idx}", + f"szz_touch_comp:{gate_name_by_type[compensation]}:{axis}:{stab_type}{stab_idx}:d{data_idx}", ), ) @@ -1368,8 +1499,13 @@ def append_szz_layer( # ========================================================================= ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) ops.extend(SurfaceCircuitStep(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) - if basis.upper() == "X": - ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) + for i in range(num_data): + append_axis_rotation_to_z( + ops, + basis_axis, + data_q(i), + f"prep_{basis_axis.lower()}_basis_d{i}", + ) ops.append(SurfaceCircuitStep(OpType.TICK)) # ========================================================================= @@ -1429,8 +1565,13 @@ def append_szz_layer( # measure_z_basis / measure_x_basis # ========================================================================= ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) - if basis.upper() == "X": - ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) + for i in range(num_data): + append_axis_rotation_from_z( + ops, + basis_axis, + data_q(i), + f"measure_{basis_axis.lower()}_basis_d{i}", + ) ops.extend(SurfaceCircuitStep(OpType.MEASURE, [data_q(i)], f"final[{i}]") for i in range(num_data)) _analyze_szz_forward_flow(ops) @@ -2738,6 +2879,7 @@ def generate_tick_circuit_from_patch( interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, ) -> TickCircuit: """Generate PECOS TickCircuit from SurfacePatch. @@ -2769,6 +2911,9 @@ def generate_tick_circuit_from_patch( check_plan: Named surface check-plan preset. szz_physical_prefixes: If true, lower the abstract SZZ single-qubit scaffold into physical prefix pulses for native DEM analysis. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ generation. Currently supports global uniform-axis + frames. Returns: PECOS TickCircuit instance @@ -2793,6 +2938,7 @@ def generate_tick_circuit_from_patch( twirl=twirl, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, ) if szz_physical_prefixes: ops = _lower_szz_forward_flow_ops(ops) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index de5ae6ba7..a510b4a7a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1340,9 +1340,10 @@ def capture_guppy_operation_trace( runtime: object | None = None, ) -> list[dict[str, Any]]: """Capture a Guppy/QIS program's Selene operation trace chunks.""" - import pecos import pecos_rslib + import pecos + # Trace capture records the runtime-lowered QIS operations and result tags; # DEM validation/fault propagation happens after replay. Use a permissive # trace backend instead of asking stabilizer evolution to validate every @@ -1503,6 +1504,7 @@ def _build_surface_tick_circuit_for_native_model( interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch @@ -1521,6 +1523,13 @@ def _build_surface_tick_circuit_for_native_model( if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" raise ValueError(msg) + if clifford_frame_policy is not None and circuit_source != "abstract": + msg = ( + "clifford_frame_policy currently requires circuit_source='abstract'; " + "traced-QIS support must generate the Guppy program from the same " + "concrete Clifford-deformed checks before binding result-tag metadata" + ) + raise NotImplementedError(msg) abstract_tc = generate_tick_circuit_from_patch( patch, @@ -1532,6 +1541,7 @@ def _build_surface_tick_circuit_for_native_model( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) if circuit_source == "abstract": @@ -1595,6 +1605,7 @@ def build_memory_circuit( twirl: TwirlConfig | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1619,6 +1630,8 @@ def build_memory_circuit( the circuit and can lose canonical result-id provenance. interaction_basis: Surface-memory two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1653,6 +1666,7 @@ def build_memory_circuit( twirl=twirl, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) @@ -1872,6 +1886,7 @@ def _surface_native_topology( interaction_basis: str | None = None, check_plan: str | None = None, szz_physical_prefixes: bool = False, + clifford_frame_policy: str | None = None, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1902,6 +1917,7 @@ def _surface_native_topology( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -1975,6 +1991,7 @@ def _cached_surface_native_topology( check_plan: str | None = None, szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", + clifford_frame_policy: str | None = None, ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" _ = resolved_check_plan_hash @@ -1989,6 +2006,7 @@ def _cached_surface_native_topology( interaction_basis=interaction_basis, check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) @@ -2074,6 +2092,7 @@ def _cached_surface_native_dem_string( interaction_basis: str = "cx", check_plan: str | None = None, resolved_check_plan_hash: str = "", + clifford_frame_policy: str | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" _ = resolved_check_plan_hash @@ -2111,6 +2130,7 @@ def _cached_surface_native_dem_string( check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_check_plan_hash, + clifford_frame_policy=clifford_frame_policy, ) return _dem_string_from_cached_surface_topology( topology, @@ -2224,6 +2244,7 @@ def generate_circuit_level_dem_from_builder( twirl: TwirlConfig | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -2275,6 +2296,10 @@ def generate_circuit_level_dem_from_builder( model: Z/SZ/SZdg frame updates are p1-free. That is a device assumption keyed from the resolved plan, not a general claim about CX hardware. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. Traced-QIS support + requires Guppy to emit the same concrete deformed checks and is + rejected until that path is implemented. Returns: DEM string in standard format @@ -2308,6 +2333,7 @@ def generate_circuit_level_dem_from_builder( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, + clifford_frame_policy=clifford_frame_policy, ) return _dem_string_from_cached_surface_topology( topology, @@ -2338,6 +2364,7 @@ def generate_circuit_level_dem_from_builder( "interaction_basis": interaction_basis, "check_plan": resolved_plan.plan_id, "resolved_check_plan_hash": resolved_plan.resolved_hash, + "clifford_frame_policy": clifford_frame_policy, } if dem_decomposition != "source_graphlike": cache_kwargs["dem_decomposition"] = dem_decomposition @@ -3765,6 +3792,7 @@ def surface_code_memory( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -3793,6 +3821,8 @@ def surface_code_memory( check_plan: Named surface check-plan preset. This is the source of truth when supplied; ``interaction_basis`` must agree if also supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -3832,6 +3862,7 @@ def surface_code_memory( circuit_source=circuit_source, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, ) batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(shots, seed) num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) @@ -4132,6 +4163,7 @@ def build_native_sampler( "mnm", ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> NativeSampler: """Build a PECOS native sampler for threshold estimation. @@ -4170,6 +4202,8 @@ def build_native_sampler( check_plan: Named surface check-plan preset. This is the source of truth when supplied; ``interaction_basis`` must agree if also supplied. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for native abstract SZZ generation. Returns: NativeSampler that can generate samples for threshold estimation @@ -4202,6 +4236,7 @@ def build_native_sampler( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -4239,6 +4274,7 @@ def build_native_sampler( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( @@ -4274,6 +4310,7 @@ def build_native_sampler_from_dem( twirl: TwirlConfig | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> NativeSampler: """Build a native sampler from a caller-supplied decomposed DEM string. @@ -4300,6 +4337,7 @@ def build_native_sampler_from_dem( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, + clifford_frame_policy=clifford_frame_policy, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py new file mode 100644 index 000000000..21195fa36 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import pytest +from pecos.qec.surface import ( + LocalCliffordFrame, + NoiseModel, + OpType, + SignedPauli, + SurfacePatch, + build_memory_circuit, + build_surface_code_circuit, + generate_tick_circuit_from_patch, + global_surface_frame, + resolve_surface_clifford_frame, +) +from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + +def test_identity_frame_resolves_to_css_surface_checks() -> None: + patch = SurfacePatch.create(distance=3) + + resolved = resolve_surface_clifford_frame(patch, policy="identity") + + assert not resolved.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved.x_checks} == {"X"} + assert {check.uniform_axis for check in resolved.z_checks} == {"Z"} + assert resolved.logical_x.uniform_axis == "X" + assert resolved.logical_z.uniform_axis == "Z" + assert resolved.css_physical_memory_basis("X") == "X" + assert resolved.css_physical_memory_basis("Z") == "Z" + + +def test_global_h_frame_resolves_to_css_basis_swap() -> None: + patch = SurfacePatch.create(distance=3) + + resolved = resolve_surface_clifford_frame(patch, policy="global-h") + + assert not resolved.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved.x_checks} == {"Z"} + assert {check.uniform_axis for check in resolved.z_checks} == {"X"} + assert resolved.logical_x.uniform_axis == "Z" + assert resolved.logical_z.uniform_axis == "X" + assert resolved.css_physical_memory_basis("X") == "Z" + assert resolved.css_physical_memory_basis("Z") == "X" + + +def test_axis_cycle_frames_resolve_but_require_deformed_checks() -> None: + patch = SurfacePatch.create(distance=3) + + resolved_f = resolve_surface_clifford_frame(patch, policy="global_axis_cycle_f") + resolved_f2 = resolve_surface_clifford_frame(patch, policy="global_axis_cycle_f2") + + assert resolved_f.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved_f.x_checks} == {"Y"} + assert {check.uniform_axis for check in resolved_f.z_checks} == {"X"} + assert resolved_f.logical_x.uniform_axis == "Y" + assert resolved_f.logical_z.uniform_axis == "X" + + assert resolved_f2.requires_deformed_check_synthesis + assert {check.uniform_axis for check in resolved_f2.x_checks} == {"Z"} + assert {check.uniform_axis for check in resolved_f2.z_checks} == {"Y"} + assert resolved_f2.logical_x.uniform_axis == "Z" + assert resolved_f2.logical_z.uniform_axis == "Y" + + with pytest.raises(NotImplementedError, match="deformed check synthesis"): + resolved_f.css_physical_memory_basis("Z") + with pytest.raises(NotImplementedError, match="deformed check synthesis"): + resolved_f2.css_physical_memory_basis("X") + + +def test_explicit_mixed_local_frame_marks_only_mixed_checks_deformed() -> None: + patch = SurfacePatch.create(distance=3) + frames = list(global_surface_frame("identity", patch.num_data)) + frames[0] = LocalCliffordFrame(SignedPauli("Z"), SignedPauli("X")) + + resolved = resolve_surface_clifford_frame( + patch, + policy="identity", + data_frames=frames, + ) + + assert resolved.requires_deformed_check_synthesis + assert any(check.requires_deformed_check_synthesis for check in resolved.checks) + assert any(not check.is_uniform_axis for check in resolved.checks) + assert any(check.axes == ("Z", "X") for check in resolved.x_checks if len(check.axes) == 2) + + +def test_rejects_frame_map_with_wrong_length() -> None: + patch = SurfacePatch.create(distance=3) + frames = global_surface_frame("identity", patch.num_data - 1) + + with pytest.raises(ValueError, match=r"does not match patch\.num_data"): + resolve_surface_clifford_frame(patch, data_frames=frames) + + +def test_global_axis_cycle_f_emits_uniform_y_szz_check_scaffold() -> None: + patch = SurfacePatch.create(distance=3) + + ops, _allocation = build_surface_code_circuit( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert any(op.op_type == OpType.SXDG and "szz_y_touch_pre:X" in op.label for op in ops) + assert any(op.op_type == OpType.SX and "szz_y_touch_post:X" in op.label for op in ops) + assert any(op.op_type == OpType.SYDG and "szz_touch_comp:SYDG:Y:X" in op.label for op in ops) + assert any(op.op_type == OpType.H and op.label == "prep_x_basis_d0:to_z" for op in ops) + assert any(op.op_type == OpType.H and op.label == "measure_x_basis_d0:from_z" for op in ops) + assert any(op.op_type == OpType.MEASURE and op.label.startswith("sx") for op in ops) + assert any(op.op_type == OpType.MEASURE and op.label.startswith("sz") for op in ops) + + +def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + detectors = tick_circuit.get_meta("detectors") + observables = tick_circuit.get_meta("observables") + assert detectors + assert observables + assert tick_circuit.get_meta("basis") == "Z" + + +def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> None: + patch = SurfacePatch.create(distance=3) + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + basis="Z", + circuit_source="abstract", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert isinstance(dem, str) + + +def test_clifford_frame_policy_rejects_traced_qis_until_guppy_matches_frame() -> None: + with pytest.raises(NotImplementedError, match="requires circuit_source='abstract'"): + build_memory_circuit( + distance=3, + rounds=1, + basis="Z", + circuit_source="traced_qis", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) From 3ada9b170368f2b933b06242c784666f869d3991 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Tue, 16 Jun 2026 18:58:28 -0600 Subject: [PATCH 200/388] Support traced SZZ Clifford-frame surface programs --- .../quantum-pecos/src/pecos/guppy/surface.py | 176 ++++++++++++++++-- .../src/pecos/qec/surface/circuit_builder.py | 10 +- .../src/pecos/qec/surface/decode.py | 22 +-- .../tests/guppy/test_surface_twirl_render.py | 35 ++++ .../qec/surface/test_clifford_deformation.py | 24 ++- 5 files changed, 224 insertions(+), 43 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index d62ea3a54..4aba2eab9 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -34,7 +34,7 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None], dict]] = {} _state = _ModuleState() @@ -142,6 +142,7 @@ def generate_guppy_source( num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate Guppy source code for a surface code patch. @@ -193,6 +194,8 @@ def generate_guppy_source( truth when supplied; ``interaction_basis`` must agree if also supplied. Current Guppy generation maps the resolved plan to the corresponding CX or SZZ/SZZdg concrete template. + clifford_frame_policy: Optional source-level Clifford-deformation + policy. Currently supported for global uniform-axis SZZ frames. Returns: Python/Guppy source code as a string. @@ -202,7 +205,11 @@ def generate_guppy_source( """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer - from pecos.qec.surface.circuit_builder import _szz_residual_plan_for_check_plan + from pecos.qec.surface.circuit_builder import ( + _resolve_szz_clifford_frame_for_builder, + _szz_memory_physical_axis, + _szz_residual_plan_for_check_plan, + ) resolved_plan = _resolve_surface_check_plan( interaction_basis=interaction_basis, @@ -213,6 +220,11 @@ def generate_guppy_source( context="Guppy surface-code source generation", ) interaction_basis = resolved_plan.interaction_basis + resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) if (twirl is None) != (rng is None): msg = f"twirl and rng must be supplied together; got twirl={twirl!r} rng={rng!r}" raise ValueError(msg) @@ -266,7 +278,7 @@ def generate_guppy_source( "from guppylang.std.angles import angle", "from guppylang.std.builtins import array, owned, result", "from guppylang.std.qsystem.functional import zz_phase", - "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, z", + "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", ] else: imports = [ @@ -334,11 +346,90 @@ def _append_szz_data_unpack(target: list[str], indent: str) -> None: def _szz_data_expr(data_q: int) -> str: return f"d{data_q}" + szz_check_by_key = {} + if resolved_clifford_frame is not None: + szz_check_by_key.update({("X", check.stabilizer_index): check for check in resolved_clifford_frame.x_checks}) + szz_check_by_key.update({("Z", check.stabilizer_index): check for check in resolved_clifford_frame.z_checks}) + + def _szz_physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_q: int) -> str: + if resolved_clifford_frame is None: + return "X" if stabilizer_type == "X" else "Z" + check = szz_check_by_key[(stabilizer_type, stab_idx)] + try: + offset = check.data_qubits.index(data_q) + except ValueError as exc: + msg = f"data qubit {data_q} is not in resolved check {stabilizer_type}{stab_idx}" + raise ValueError(msg) from exc + return check.paulis[offset].axis + + def _szz_physical_axis_for_memory_basis(source_basis: str) -> str: + return _szz_memory_physical_axis(source_basis, resolved_clifford_frame) + + def _szz_physical_axis_for_logical(source_logical: str) -> str: + if resolved_clifford_frame is None: + return source_logical + logical = resolved_clifford_frame.logical_x if source_logical == "X" else resolved_clifford_frame.logical_z + if not logical.is_uniform_axis or logical.uniform_axis is None: + msg = ( + f"clifford frame policy {resolved_clifford_frame.policy!r} maps " + f"source logical {source_logical} to mixed axes {logical.axes}; " + "mixed logical operator rendering is not implemented yet" + ) + raise NotImplementedError(msg) + return logical.uniform_axis + + def _append_szz_axis_rotation_to_z(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}h({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}vdg({qubit_expr})") + elif axis != "Z": + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_axis_rotation_from_z(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}h({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}v({qubit_expr})") + elif axis != "Z": + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_y_compensation(target: list[str], indent: str, qubit_expr: str, *, dagger: bool) -> None: + target.append(f"{indent}sdg({qubit_expr})") + target.append(f"{indent}{'vdg' if dagger else 'v'}({qubit_expr})") + target.append(f"{indent}s({qubit_expr})") + + def _append_szz_touch_compensation(target: list[str], indent: str, axis: str, sign: int, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}{'vdg' if sign > 0 else 'v'}({qubit_expr})") + elif axis == "Y": + _append_szz_y_compensation(target, indent, qubit_expr, dagger=sign > 0) + elif axis == "Z": + target.append(f"{indent}{'sdg' if sign > 0 else 's'}({qubit_expr})") + else: + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: + if axis == "X": + target.append(f"{indent}x({qubit_expr})") + elif axis == "Y": + target.append(f"{indent}y({qubit_expr})") + elif axis == "Z": + target.append(f"{indent}z({qubit_expr})") + else: + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + # Generate state preparation functions lines.extend(["# === State Preparation ===", "", "@guppy", f"def prep_z_basis() -> SurfaceCode_{dx}x{dz}:"]) lines.append(' """Prepare logical |0_L> state."""') if interaction_basis == "szz": lines.extend(f" d{i} = qubit()" for i in range(num_data)) + for i in range(num_data): + _append_szz_axis_rotation_to_z(lines, " ", _szz_physical_axis_for_memory_basis("Z"), f"d{i}") lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") else: lines.append(f" data = array(qubit() for _ in range({num_data}))") @@ -347,7 +438,8 @@ def _szz_data_expr(data_q: int) -> str: lines.append(' """Prepare logical |+_L> state."""') if interaction_basis == "szz": lines.extend(f" d{i} = qubit()" for i in range(num_data)) - lines.extend(f" h(d{i})" for i in range(num_data)) + for i in range(num_data): + _append_szz_axis_rotation_to_z(lines, " ", _szz_physical_axis_for_memory_basis("X"), f"d{i}") lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") else: lines.append(f" data = array(qubit() for _ in range({num_data}))") @@ -379,9 +471,9 @@ def _append_szz_layer( ) -> None: target.append("") target.append(f"{indent}# SZZ round {rnd_idx + 1}") - x_touches = [(stab_idx, data_q) for stab_type, stab_idx, data_q in layer_gates if stab_type == "X"] - for _stab_idx, data_q in x_touches: - target.append(f"{indent}h({data_expr(data_q)})") + for stab_type, stab_idx, data_q in layer_gates: + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + _append_szz_axis_rotation_to_z(target, indent, axis, data_expr(data_q)) for stab_type, stab_idx, data_q in layer_gates: sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] @@ -391,18 +483,14 @@ def _append_szz_layer( f"zz_phase({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)}, angle({half_turns}))", ) - for _stab_idx, data_q in x_touches: - target.append(f"{indent}h({data_expr(data_q)})") + for stab_type, stab_idx, data_q in layer_gates: + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + _append_szz_axis_rotation_from_z(target, indent, axis, data_expr(data_q)) for stab_type, stab_idx, data_q in layer_gates: sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] - compensation = { - ("X", 1): "vdg", - ("X", -1): "v", - ("Z", 1): "sdg", - ("Z", -1): "s", - }[(stab_type, sign)] - target.append(f"{indent}{compensation}({data_expr(data_q)})") + axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) + _append_szz_touch_compensation(target, indent, axis, sign, data_expr(data_q)) lines.extend( [ @@ -801,6 +889,8 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") + for i in range(num_data): + _append_szz_axis_rotation_from_z(lines, " ", _szz_physical_axis_for_memory_basis("Z"), f"d{i}") z_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) lines.append(f" return array({z_meas})") else: @@ -816,7 +906,8 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") - lines.extend(f" h(d{i})" for i in range(num_data)) + for i in range(num_data): + _append_szz_axis_rotation_from_z(lines, " ", _szz_physical_axis_for_memory_basis("X"), f"d{i}") x_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) lines.append(f" return array({x_meas})") else: @@ -839,7 +930,9 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) if interaction_basis == "szz": - lines.extend(f" x(surf.d{q})" for q in logical_x_qubits) + logical_x_axis = _szz_physical_axis_for_logical("X") + for q in logical_x_qubits: + _append_szz_logical_pauli(lines, " ", logical_x_axis, f"surf.d{q}") else: lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) @@ -853,7 +946,9 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) if interaction_basis == "szz": - lines.extend(f" z(surf.d{q})" for q in logical_z_qubits) + logical_z_axis = _szz_physical_axis_for_logical("Z") + for q in logical_z_qubits: + _append_szz_logical_pauli(lines, " ", logical_z_axis, f"surf.d{q}") else: lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) @@ -1523,6 +1618,7 @@ def _guppy_module_cache_key( num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1544,9 +1640,10 @@ def _guppy_module_cache_key( interaction_basis = resolved_plan.interaction_basis interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" + frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" base = ( f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" - f"_b{effective_budget}{interaction_part}{check_plan_part}" + f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}" ) if twirl is None: return base @@ -1571,6 +1668,7 @@ def _load_guppy_module( num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1591,6 +1689,8 @@ def _load_guppy_module( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Module dictionary with generated functions @@ -1613,6 +1713,7 @@ def _load_guppy_module( num_rounds, interaction_basis, resolved_plan.plan_id if check_plan is not None else None, + clifford_frame_policy, ) if cache_key in _state.module_cache: @@ -1626,6 +1727,7 @@ def _load_guppy_module( num_rounds=num_rounds, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, ) # Write to temp file (required for Guppy introspection). @@ -1658,6 +1760,7 @@ def generate_memory_experiment( rng: "GuppyRngMaskConfig | None" = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> object: """Generate a memory experiment for a patch. @@ -1671,6 +1774,8 @@ def generate_memory_experiment( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Guppy function for the experiment @@ -1683,6 +1788,7 @@ def generate_memory_experiment( num_rounds=num_rounds if twirl is not None else None, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) if basis.upper() == "Z": @@ -1704,6 +1810,7 @@ def get_num_qubits( twirl: "TwirlConfig | None" = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> int: """Get the peak simultaneously-live qubit count for a surface-code program. @@ -1747,6 +1854,19 @@ def get_num_qubits( num_data = d * d total_ancilla = d * d - 1 + if clifford_frame_policy is not None: + if patch is None: + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=d) + from pecos.qec.surface.circuit_builder import _resolve_szz_clifford_frame_for_builder + + _resolve_szz_clifford_frame_for_builder( + patch, + interaction_basis=interaction_basis, + clifford_frame_policy=clifford_frame_policy, + ) + if interaction_basis == "szz" and twirl is not None: msg = "interaction_basis='szz' Guppy runtime twirl integration is staged later" raise ValueError(msg) @@ -1761,6 +1881,7 @@ def generate_surface_code_module( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate source code for a distance-d surface code module. @@ -1771,6 +1892,8 @@ def generate_surface_code_module( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Python/Guppy source code as a string @@ -1785,6 +1908,7 @@ def generate_surface_code_module( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) @@ -1794,6 +1918,7 @@ def _surface_code_module_for_patch( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -1821,6 +1946,7 @@ def _surface_code_module_for_patch( effective_budget, interaction_basis, resolved_plan.plan_id, + None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), ) if cache_key in _state.distance_module_cache: @@ -1831,6 +1957,7 @@ def _surface_code_module_for_patch( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, + clifford_frame_policy=clifford_frame_policy, ) # Metadata derived from the actual patch geometry. @@ -1840,6 +1967,7 @@ def _surface_code_module_for_patch( module["ancilla_budget"] = effective_budget module["interaction_basis"] = interaction_basis module["check_plan"] = resolved_plan.plan_id + module["clifford_frame_policy"] = clifford_frame_policy module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash @@ -1853,6 +1981,7 @@ def get_surface_code_module( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> dict: """Get a loaded surface code module for distance d. @@ -1862,6 +1991,8 @@ def get_surface_code_module( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Dictionary with module contents and metadata @@ -1875,6 +2006,7 @@ def get_surface_code_module( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) @@ -1886,6 +2018,7 @@ def make_surface_code( ancilla_budget: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> object: """Create a surface code memory experiment. @@ -1901,6 +2034,8 @@ def make_surface_code( interaction_basis: Backward-compatible selector for the default ``check_plan`` of a two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Compiled Guppy program @@ -1914,6 +2049,7 @@ def make_surface_code( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index bc219d43b..6ecee2cfc 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -2807,6 +2807,7 @@ def generate_guppy_from_patch( *, interaction_basis: str | None = None, check_plan: str | None = None, + clifford_frame_policy: str | None = None, ) -> str: """Generate Guppy code from SurfacePatch. @@ -2824,13 +2825,20 @@ def generate_guppy_from_patch( _basis: Unused (module includes both Z and X basis functions) interaction_basis: Surface-memory two-qubit interaction basis. check_plan: Named surface check-plan preset. + clifford_frame_policy: Optional source-level Clifford-deformation + policy for SZZ/SZZdg surface-code generation. Returns: Guppy source code string (full module) """ from pecos.guppy.surface import generate_guppy_source - return generate_guppy_source(patch, interaction_basis=interaction_basis, check_plan=check_plan) + return generate_guppy_source( + patch, + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + ) def generate_dag_circuit_from_patch( diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index a510b4a7a..b76f9ff13 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1431,6 +1431,7 @@ def _generate_traced_surface_tick_circuit( interaction_basis: str | None = None, check_plan: str | None = None, runtime: object | None = None, + clifford_frame_policy: str | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -1454,6 +1455,7 @@ def _generate_traced_surface_tick_circuit( interaction_basis=interaction_basis, check_plan=check_plan, runtime=runtime, + clifford_frame_policy=clifford_frame_policy, ) return tc @@ -1467,6 +1469,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( interaction_basis: str | None = None, check_plan: str | None = None, runtime: object | None = None, + clifford_frame_policy: str | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" from pecos.guppy.surface import generate_memory_experiment, get_num_qubits @@ -1478,6 +1481,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ) return trace_guppy_into_tick_circuit_with_result_traces( program, @@ -1486,6 +1490,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, ), seed=0, runtime=runtime, @@ -1523,14 +1528,6 @@ def _build_surface_tick_circuit_for_native_model( if szz_physical_prefixes and (interaction_basis != "szz" or circuit_source != "abstract"): msg = "SZZ physical-prefix lowering requires interaction_basis='szz' and circuit_source='abstract'" raise ValueError(msg) - if clifford_frame_policy is not None and circuit_source != "abstract": - msg = ( - "clifford_frame_policy currently requires circuit_source='abstract'; " - "traced-QIS support must generate the Guppy program from the same " - "concrete Clifford-deformed checks before binding result-tag metadata" - ) - raise NotImplementedError(msg) - abstract_tc = generate_tick_circuit_from_patch( patch, num_rounds, @@ -1562,6 +1559,7 @@ def _build_surface_tick_circuit_for_native_model( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, runtime=runtime, + clifford_frame_policy=clifford_frame_policy, ) measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) @@ -2297,9 +2295,9 @@ def generate_circuit_level_dem_from_builder( assumption keyed from the resolved plan, not a general claim about CX hardware. clifford_frame_policy: Optional source-level Clifford-deformation - policy for native abstract SZZ generation. Traced-QIS support - requires Guppy to emit the same concrete deformed checks and is - rejected until that path is implemented. + policy for native SZZ generation. For ``circuit_source="traced_qis"``, + the Guppy program is generated from the same concrete deformed + checks before runtime result tags are bound to surface metadata. Returns: DEM string in standard format @@ -3822,7 +3820,7 @@ def surface_code_memory( truth when supplied; ``interaction_basis`` must agree if also supplied. clifford_frame_policy: Optional source-level Clifford-deformation - policy for native abstract SZZ generation. + policy for native SZZ generation. Returns: ``SimulationResult`` with logical and raw error counts/rates. diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 16a4551e1..abff4e129 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -75,10 +75,34 @@ def test_szz_source_rejects_staged_later_runtime_shapes(patch: SurfacePatch) -> def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: cx_key = _guppy_module_cache_key(patch, effective_budget=8) szz_key = _guppy_module_cache_key(patch, effective_budget=8, interaction_basis="szz") + framed_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) assert cx_key != szz_key + assert framed_key != szz_key assert "_ibcx" not in cx_key assert "_ibszz" in szz_key + assert "_cfglobal_axis_cycle_f" in framed_key + + +def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert "from guppylang.std.quantum import" in src + assert ", x, y, z" in src + assert "sdg(d0)\n vdg(d0)\n s(d0)" in src + assert "def apply_logical_x" in src + assert "y(surf.d" in src + assert "def apply_logical_z" in src + assert "x(surf.d" in src def test_szz_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: @@ -91,6 +115,17 @@ def test_szz_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) - assert fn is not None +def test_szz_axis_cycle_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + assert fn is not None + + def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py index 21195fa36..eecd067a7 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -147,13 +147,17 @@ def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> assert isinstance(dem, str) -def test_clifford_frame_policy_rejects_traced_qis_until_guppy_matches_frame() -> None: - with pytest.raises(NotImplementedError, match="requires circuit_source='abstract'"): - build_memory_circuit( - distance=3, - rounds=1, - basis="Z", - circuit_source="traced_qis", - interaction_basis="szz", - clifford_frame_policy="global_axis_cycle_f", - ) +def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: + tick_circuit = build_memory_circuit( + distance=3, + rounds=1, + basis="Z", + circuit_source="traced_qis", + interaction_basis="szz", + clifford_frame_policy="global_axis_cycle_f", + ) + + assert tick_circuit.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") From 9573aa062aa64523280377b061e63221b122e346 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 16 Jun 2026 19:03:44 -0600 Subject: [PATCH 201/388] Reject control-flow HUGR in the PHIR converter and guard against empty neo results --- crates/pecos-phir/src/hugr_parser.rs | 35 +++++++++++++++++++++ crates/pecos/src/unified_sim.rs | 35 +++++++++++++++++---- crates/pecos/tests/neo_hugr_routing_test.rs | 25 +++++++++++++++ 3 files changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/pecos-phir/src/hugr_parser.rs b/crates/pecos-phir/src/hugr_parser.rs index f4521fba9..c03cca3ca 100644 --- a/crates/pecos-phir/src/hugr_parser.rs +++ b/crates/pecos-phir/src/hugr_parser.rs @@ -155,6 +155,13 @@ impl HugrToPhirConverter { /// Top-level conversion: HUGR -> PHIR Module. fn convert(&mut self, hugr: &Hugr) -> Result { + // This converter is straight-line only. `find_operations_container` + // keeps just the FIRST `DataflowBlock` of a CFG, so a HUGR with real + // control flow would silently lose every other block (commonly + // dropping the measurements -> empty results). Reject it up front so + // callers fall back to `HugrEngine` instead of getting wrong output. + Self::reject_control_flow(hugr)?; + let mut module = ModuleOp::new("hugr_module"); // Find the container node (DFG or DataflowBlock) holding operations @@ -241,6 +248,34 @@ impl HugrToPhirConverter { Ok(module) } + /// Reject HUGR that uses classical control flow (loops, conditionals). + /// + /// Guppy compiles a straight-line body to a CFG with a single + /// `DataflowBlock` (plus an `ExitBlock`). More than one `DataflowBlock` + /// under any CFG means real control flow, which this straight-line + /// converter cannot represent: it keeps only the first block. Returning an + /// error here turns that silent data loss into a clean failure. + fn reject_control_flow(hugr: &Hugr) -> Result<()> { + for node in hugr.nodes() { + if matches!(hugr.get_optype(node), OpType::CFG(_)) { + let blocks = hugr + .children(node) + .filter(|&child| matches!(hugr.get_optype(child), OpType::DataflowBlock(_))) + .count(); + if blocks > 1 { + return Err(PhirError::Parse(Box::new( + crate::error::ParseError::Unsupported { + feature: format!("classical control flow ({blocks} basic blocks)"), + format: "HUGR".to_string(), + location: crate::error::SourceLocation::unknown(), + }, + ))); + } + } + } + Ok(()) + } + /// Find the container node whose children are the quantum operations. /// /// Guppy-compiled HUGRs use: `Module -> FuncDefn -> CFG -> DataflowBlock`. diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 72812e510..c9d2f0c25 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -34,9 +34,13 @@ pub enum SimStack { /// The data-oriented `pecos-neo` stack (experimental). /// /// Requires building pecos with the `neo` cargo feature. Routes QASM and - /// HUGR programs with the default quantum backend (HUGR runs through the - /// PHIR engine so its results use the same named-register contract as the - /// engines/QASM path, with no Selene/LLVM dependency). + /// HUGR programs with the default quantum backend. HUGR runs through the + /// PHIR engine, so its results use the same named-register contract as the + /// engines/QASM path with no Selene/LLVM dependency -- but only for the + /// PHIR converter's STRAIGHT-LINE subset; HUGR with classical control flow + /// (loops, conditionals) is rejected (use `SimStack::Engines` for those). + /// (Note: the engines stack runs HUGR through QIS/Selene, a different and + /// broader HUGR engine -- a consideration for the eventual default flip.) /// The translated noise surface is the depolarizing family /// (`PassThroughNoise`, `DepolarizingNoise`, `BiasedDepolarizingNoise`, /// and their builders) and the `GeneralNoiseModel` simple-probability @@ -236,7 +240,10 @@ impl ProgrammedSimBuilder { // and needs no Selene/LLVM. (neo's own `hugr_engine` would instead emit // per-qubit `q0`/`q1` and a `measurements` array, which is not // drop-in compatible; the named-register PHIR path is, so it is the one - // routed here.) + // routed here.) The PHIR converter is STRAIGHT-LINE only: HUGR with + // classical control flow is rejected by `from_hugr_bytes` below (and + // any residual empty-result shape is caught by the contract guard after + // `run`). let configured = match self.program { Program::Qasm(qasm) => sim_neo(qasm).auto(), Program::Hugr(hugr) => { @@ -268,13 +275,29 @@ impl ProgrammedSimBuilder { } let results = builder.run(); - results.shots.ok_or_else(|| { + let shot_vec = results.shots.ok_or_else(|| { PecosError::Generic( "The neo stack produced no register results for a classical-engine program; \ this is a bug in the neo routing." .to_string(), ) - }) + })?; + + // Result-contract guard. A HUGR shape the straight-line PHIR converter + // cannot represent can yield shots with NO register data instead of a + // clean load error (e.g. an op silently skipped during conversion). + // Surface that as an error rather than returning empty results that + // look like a successful run. (QASM always carries its cregs, so this + // never trips there.) + if !shot_vec.shots.is_empty() && shot_vec.shots.iter().all(|shot| shot.data.is_empty()) { + return Err(PecosError::Input( + "The neo stack produced empty results (no register data) for this program. \ + If it is a HUGR program, it likely uses features the straight-line PHIR \ + route does not support; use .stack(SimStack::Engines)." + .to_string(), + )); + } + Ok(shot_vec) } /// Stub when pecos is built without the `neo` feature. diff --git a/crates/pecos/tests/neo_hugr_routing_test.rs b/crates/pecos/tests/neo_hugr_routing_test.rs index 34968da73..c26b75b96 100644 --- a/crates/pecos/tests/neo_hugr_routing_test.rs +++ b/crates/pecos/tests/neo_hugr_routing_test.rs @@ -134,3 +134,28 @@ fn neo_hugr_support_matches_engines_phir() { ); assert_eq!(neo, BTreeSet::from([0, 3])); } + +#[test] +fn neo_hugr_control_flow_is_rejected() { + // The PHIR HUGR converter is straight-line only. HUGR with classical + // control flow (loops, conditionals) must be REJECTED up front, not + // silently converted into empty/partial results that look like a + // successful run. Each of these fixtures compiles to a CFG with multiple + // basic blocks. + for fixture in [ + &include_bytes!("test_data/hugr/simple_while_loop.hugr")[..], + &include_bytes!("test_data/hugr/forloop_h_test.hugr")[..], + &include_bytes!("test_data/hugr/simple_conditional.hugr")[..], + &include_bytes!("test_data/hugr/conditional_x.hugr")[..], + ] { + let err = sim(Hugr::from_bytes(fixture.to_vec())) + .stack(SimStack::Neo) + .seed(42) + .run(4) + .expect_err("control-flow HUGR must be rejected on the neo stack"); + assert!( + err.to_string().contains("classical control flow"), + "expected a control-flow rejection, got: {err}" + ); + } +} From 11fedffaf25fa141822af4bb1ba9d6397dcb6b7d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 16 Jun 2026 23:43:51 -0600 Subject: [PATCH 202/388] Add neo determinism and worker-count-invariance regression tests --- crates/pecos/tests/neo_routing_test.rs | 59 ++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index f10dc8fef..3afd3025b 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -78,6 +78,65 @@ fn neo_stack_parallel_matches_engines() { assert_eq!(engines, neo); } +#[test] +fn neo_stack_worker_count_invariant_for_noisy_program() { + // The neo determinism guarantee (V4): each shot's RNG is derived from its + // GLOBAL shot index, so a STOCHASTIC program's results must be bit-identical + // regardless of how the shots are split across workers. The depolarizing + // noise is what makes this a real RNG test -- a deterministic program would + // pass trivially. (Same seed throughout; only the worker count varies.) + let noise = pecos_engines::DepolarizingNoise { p: 0.3 }; + let run = |workers: usize| { + sim(x_measure_qasm()) + .stack(SimStack::Neo) + .noise(noise) + .seed(42) + .workers(workers) + .run(128) + .expect("neo run") + }; + let w1 = run(1); + // Self-check: the noise must actually produce a MIX of outcomes, or + // worker-invariance would hold trivially (all shots identical). + let rate0 = rate_of(&w1, "0"); + assert!( + rate0 > 0.0 && rate0 < 1.0, + "noisy program should produce varied outcomes (got rate(0)={rate0}), \ + otherwise worker-invariance is vacuous" + ); + assert_eq!( + w1, + run(2), + "neo noisy results must be invariant to worker count (1 vs 2)" + ); + assert_eq!( + w1, + run(4), + "neo noisy results must be invariant to worker count (1 vs 4)" + ); +} + +#[test] +fn neo_stack_same_seed_is_reproducible() { + // V3 reproducibility: identical config + identical seed -> bit-identical + // ShotVec on neo across independent runs (a noisy program, so the RNG is + // genuinely exercised). + let noise = pecos_engines::DepolarizingNoise { p: 0.1 }; + let run = || { + sim(x_measure_qasm()) + .stack(SimStack::Neo) + .noise(noise) + .seed(123) + .run(64) + .expect("neo run") + }; + assert_eq!( + run(), + run(), + "neo must reproduce identical results for a fixed seed" + ); +} + /// One-qubit program whose only error source is what the noise model adds. fn x_measure_qasm() -> Qasm { Qasm::from_string( From c42311c34750fe46810d9249fed224104e7e8468 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 00:09:41 -0600 Subject: [PATCH 203/388] Make cross-stack engines baselines explicit so they stay meaningful after the default flip --- crates/pecos/tests/neo_routing_test.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index 3afd3025b..f59825009 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -40,6 +40,7 @@ fn deterministic_conditional_qasm() -> Qasm { #[test] fn neo_stack_matches_engines_for_deterministic_qasm() { let engines = sim(deterministic_conditional_qasm()) + .stack(SimStack::Engines) .seed(42) .run(5) .expect("engines run"); @@ -63,6 +64,7 @@ fn neo_stack_matches_engines_for_deterministic_qasm() { #[test] fn neo_stack_parallel_matches_engines() { let engines = sim(deterministic_conditional_qasm()) + .stack(SimStack::Engines) .seed(7) .workers(2) .run(6) @@ -174,6 +176,7 @@ fn neo_stack_measurement_noise_rate_matches_engines() { .with_p2_probability(0.0); let engines = sim(x_measure_qasm()) + .stack(SimStack::Engines) .noise(noise.clone()) .seed(42) .run(shots) From cc68913ec04512c819a884b85f6baf16fdee72d3 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 08:25:02 -0600 Subject: [PATCH 204/388] Make neo single-qubit emission gate-removing to match engines (apply the gate dagger) --- crates/pecos/tests/neo_emission_test.rs | 133 ++++++++++++++++++++++++ exp/pecos-neo/src/command.rs | 64 ++++++++++++ exp/pecos-neo/src/noise/single_qubit.rs | 60 +++++++++-- 3 files changed, 250 insertions(+), 7 deletions(-) create mode 100644 crates/pecos/tests/neo_emission_test.rs diff --git a/crates/pecos/tests/neo_emission_test.rs b/crates/pecos/tests/neo_emission_test.rs new file mode 100644 index 000000000..5c39eea68 --- /dev/null +++ b/crates/pecos/tests/neo_emission_test.rs @@ -0,0 +1,133 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Differential test for the `GeneralNoiseModel` EMISSION channel across stacks. +//! +//! Engines models spontaneous emission as REPLACING the gate (the gate is +//! dropped on an emission fault). neo now matches this by undoing the gate +//! (applying its dagger) before the emission error. The facade does not map +//! emission yet, so neo is configured directly via `sim_neo`. +//! +//! Analytic for `x; measure` with uniform Pauli and emission weights, gate +//! error `p1` and emission ratio `e`: an emission fault (probability `p1*e`) +//! drops the X so the qubit is `|0>` and a uniform Pauli reads `0` only on Z +//! (`P(0) = 1/3`); a Pauli fault (probability `p1*(1-e)`) keeps the X so the +//! qubit is `|1>` and a uniform Pauli reads `0` on X or Y (`P(0) = 2/3`). Hence +//! `P(0) = p1 * (e/3 + (1-e)*2/3)`. At `p1 = 0.3`, `e = 0.5` this is `0.15` (the +//! gate-PRESERVING model would give `0.2`). + +#![cfg(feature = "neo")] + +use pecos::{SimStack, sim}; +use pecos_num::jeffreys_interval; +use pecos_programs::Qasm; + +const SHOTS: usize = 20_000; +const CONFIDENCE: f64 = 0.99999; +const P1: f64 = 0.3; +const EMISSION: f64 = 0.5; + +const X_MEASURE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[1]; + creg c[1]; + x q[0]; + measure q[0] -> c[0]; +"#; + +#[allow(clippy::cast_precision_loss)] +fn rate_zero(shots: &pecos_engines::shot_results::ShotVec) -> (u64, f64) { + let zeros = shots + .shots + .iter() + .filter(|s| s.data["c"].to_bitstring().as_deref() == Some("0")) + .count() as u64; + (zeros, zeros as f64 / SHOTS as f64) +} + +/// Engines with a `GeneralNoiseModel`: gate error `p1`, emission ratio +/// `EMISSION`, everything else (prep, meas, p2, leakage, idle) zeroed so the +/// only physics is the single-qubit emission/Pauli channel on the X gate. +fn engines_zero_count() -> u64 { + let noise = pecos_engines::noise::GeneralNoiseModel::builder() + .with_p1_probability(P1) + .with_p1_emission_ratio(EMISSION) + .with_p2_probability(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + let results = sim(Qasm::from_string(X_MEASURE)) + .stack(SimStack::Engines) + .noise(noise) + .seed(42) + .run(SHOTS) + .expect("engines run"); + rate_zero(&results).0 +} + +/// neo configured directly (the facade does not map emission yet): the +/// `GeneralNoiseModelBuilder` mirrors the same single-qubit emission channel. +fn neo_zero_count() -> u64 { + use pecos_neo::noise::GeneralNoiseModelBuilder; + use pecos_neo::tool::{monte_carlo, sim_neo}; + + let noise = GeneralNoiseModelBuilder::new() + .with_p1(P1) + .with_p1_emission_ratio(EMISSION) + .with_p2(0.0) + .with_p_prep(0.0) + .with_p_meas_symmetric(0.0); + let results = sim_neo(Qasm::from_string(X_MEASURE)) + .auto() + .sampling(monte_carlo(SHOTS)) + .noise(noise) + .seed(99) // independent of the engines seed; agreement must be physical + .run(); + let shots = results.shots.expect("neo produced shots"); + rate_zero(&shots).0 +} + +#[test] +fn emission_is_gate_removing_and_matches_engines() { + let analytic = P1 * (EMISSION / 3.0 + (1.0 - EMISSION) * 2.0 / 3.0); // 0.15 + + let engines = engines_zero_count(); + let neo = neo_zero_count(); + let engines_ci = jeffreys_interval(engines, SHOTS as u64, CONFIDENCE); + let neo_ci = jeffreys_interval(neo, SHOTS as u64, CONFIDENCE); + println!( + "emission: engines {engines}/{SHOTS} CI [{:.4}, {:.4}], neo {neo}/{SHOTS} CI [{:.4}, {:.4}], \ + gate-removing analytic {analytic:.4} (gate-preserving would be {:.4})", + engines_ci.0, + engines_ci.1, + neo_ci.0, + neo_ci.1, + P1 * 2.0 / 3.0 + ); + + // Both stacks contain the gate-REMOVING analytic (0.15), proving the gate + // is dropped on emission; the gate-PRESERVING value (0.2) is excluded. + for (name, ci) in [("engines", engines_ci), ("neo", neo_ci)] { + assert!( + ci.0 <= analytic && analytic <= ci.1, + "{name} P(0) excludes the gate-removing analytic {analytic}" + ); + } + // And the two stacks agree. + assert!( + engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, + "engines and neo emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" + ); +} diff --git a/exp/pecos-neo/src/command.rs b/exp/pecos-neo/src/command.rs index a26c8b3a4..3917a8a86 100644 --- a/exp/pecos-neo/src/command.rs +++ b/exp/pecos-neo/src/command.rs @@ -248,6 +248,70 @@ impl GateCommand { Self::new(GateType::I, smallvec::smallvec![qubit]) } + /// The inverse (dagger) of this single-qubit gate, if it is an invertible + /// single-qubit unitary; `None` otherwise (measurement, prep, idle, + /// resource management, or a multi-qubit gate). + /// + /// Used by the gate-removing emission noise model: when a single-qubit + /// gate suffers a spontaneous-emission error, undoing the gate (`G dagger`) + /// and then applying the emission error reproduces engines' "the emission + /// replaces the gate" semantics, since `G * G_dagger = I`. + /// + /// Rotation inverses use the standard conventions + /// (`RX/RY/RZ(theta)` -> negate `theta`; `R1XY(theta, phi)` -> + /// `R1XY(-theta, phi)`; `U(theta, phi, lambda)` -> + /// `U(-theta, -lambda, -phi)`). + #[must_use] + pub fn single_qubit_dagger(&self) -> Option { + let q = self.qubits.clone(); + let same = |t: GateType| Some(Self::new(t, q.clone())); + let neg_first = |t: GateType| -> Option { + let theta = *self.angles.first()?; + Some(Self::with_angles(t, q.clone(), smallvec::smallvec![-theta])) + }; + match self.gate_type { + // Self-inverse single-qubit gates. + GateType::I | GateType::X | GateType::Y | GateType::Z | GateType::H => { + same(self.gate_type) + } + // Dagger pairs. + GateType::F => same(GateType::Fdg), + GateType::Fdg => same(GateType::F), + GateType::SX => same(GateType::SXdg), + GateType::SXdg => same(GateType::SX), + GateType::SY => same(GateType::SYdg), + GateType::SYdg => same(GateType::SY), + GateType::SZ => same(GateType::SZdg), + GateType::SZdg => same(GateType::SZ), + GateType::T => same(GateType::Tdg), + GateType::Tdg => same(GateType::T), + // Single-angle rotations: negate the angle. + GateType::RX | GateType::RY | GateType::RZ => neg_first(self.gate_type), + // R1XY(theta, phi) dagger = R1XY(-theta, phi). + GateType::R1XY => { + let theta = *self.angles.first()?; + let phi = *self.angles.get(1)?; + Some(Self::with_angles( + GateType::R1XY, + q, + smallvec::smallvec![-theta, phi], + )) + } + // U(theta, phi, lambda) dagger = U(-theta, -lambda, -phi). + GateType::U => { + let theta = *self.angles.first()?; + let phi = *self.angles.get(1)?; + let lambda = *self.angles.get(2)?; + Some(Self::with_angles( + GateType::U, + q, + smallvec::smallvec![-theta, -lambda, -phi], + )) + } + _ => None, + } + } + /// Create a Pauli-X gate on a qubit. #[must_use] pub fn x(qubit: QubitId) -> Self { diff --git a/exp/pecos-neo/src/noise/single_qubit.rs b/exp/pecos-neo/src/noise/single_qubit.rs index 1cfa6342c..e16555d31 100644 --- a/exp/pecos-neo/src/noise/single_qubit.rs +++ b/exp/pecos-neo/src/noise/single_qubit.rs @@ -37,7 +37,8 @@ use super::{ NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse, PauliWeights, SingleQubitEmissionResult, SingleQubitEmissionWeights, }; -use crate::command::GateCommand; +use crate::command::{GateCommand, GateType}; +use pecos_core::Angle64; use pecos_random::PecosRng; use rand::RngExt; use smallvec::SmallVec; @@ -245,13 +246,16 @@ impl NoiseChannel for SingleQubitChannel { Self::handle_before_gate(qubits, ctx, rng) } NoiseEvent::AfterGate { - gate_type, qubits, .. + gate_type, + qubits, + angles, + .. } => { // Skip noise for noiseless gates if ctx.is_noiseless(*gate_type) { return NoiseResponse::None; } - self.handle_after_gate(qubits, ctx, rng) + self.handle_after_gate(*gate_type, qubits, angles, ctx, rng) } _ => NoiseResponse::None, } @@ -284,7 +288,10 @@ impl NoiseChannel for SingleQubitChannel { Some(Self::handle_before_gate(qubits, ctx, rng)) } NoiseEvent::AfterGate { - gate_type, qubits, .. + gate_type, + qubits, + angles, + .. } => { if !gate_type.is_single_qubit() || !gate_type.is_unitary_gate() { return None; @@ -293,7 +300,7 @@ impl NoiseChannel for SingleQubitChannel { if ctx.is_noiseless(*gate_type) { return Some(NoiseResponse::None); } - Some(self.handle_after_gate(qubits, ctx, rng)) + Some(self.handle_after_gate(*gate_type, qubits, angles, ctx, rng)) } _ => None, } @@ -329,9 +336,15 @@ impl SingleQubitChannel { } /// Handle `AfterGate` event - apply Pauli or emission errors. + /// + /// `gate_type`/`angles` describe the gate just applied; an emission error + /// undoes it (applies its dagger) before the emission error, matching + /// engines' gate-replacing emission semantics. fn handle_after_gate( &self, + gate_type: GateType, qubits: &[pecos_core::QubitId], + angles: &[Angle64], ctx: &mut NoiseContext, rng: &mut PecosRng, ) -> NoiseResponse { @@ -362,6 +375,18 @@ impl SingleQubitChannel { if rng.check_probability(self.error_threshold) { // Determine if this is an emission or Pauli error if self.emission_ratio > 0.0 && rng.check_probability(self.emission_threshold) { + // Emission REPLACES the gate: undo it (apply its dagger) so + // the net effect is the gate removed, then apply the + // emission error. (Matches engines' apply_sq_faults, which + // drops the original gate on a spontaneous-emission fault.) + let original = GateCommand::with_angles( + gate_type, + smallvec::smallvec![qubit], + angles.iter().copied().collect::>(), + ); + if let Some(dagger) = original.single_qubit_dagger() { + gates.push(dagger); + } // Emission error - sample from emission weights match self.emission_weights.sample(rng.random::()) { SingleQubitEmissionResult::Pauli(pauli) => { @@ -458,8 +483,29 @@ mod tests { let mut rng = PecosRng::seed_from_u64(42); let response = channel.apply(&event, &mut ctx, &mut rng); - // With emission_ratio=1.0 and leakage_probability=1.0, should cause leakage - assert!(matches!(response, NoiseResponse::MarkLeaked(_))); + // emission_ratio=1.0 + leakage=1.0 marks the qubit leaked. Emission also + // REPLACES the gate, so the response now also injects the gate's dagger + // (H dagger = H) -> the combined response is `Multiple`. Assert it both + // marks the qubit leaked AND undoes the gate. + let (marks_leaked, undoes_gate) = match &response { + NoiseResponse::MarkLeaked(_) => (true, false), + NoiseResponse::Multiple(rs) => ( + rs.iter().any(|r| matches!(r, NoiseResponse::MarkLeaked(_))), + rs.iter().any(|r| { + matches!(r, NoiseResponse::InjectGates(gs) + if gs.iter().any(|g| g.gate_type == GateType::H)) + }), + ), + _ => (false, false), + }; + assert!( + marks_leaked, + "emission with leakage should mark the qubit leaked, got {response:?}" + ); + assert!( + undoes_gate, + "emission should undo the gate (inject its dagger), got {response:?}" + ); } #[test] From 8c669231a1c5c0466ed768a43b3aa3f7875e48d0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 08:40:27 -0600 Subject: [PATCH 205/388] Make neo two-qubit emission gate-removing and generalize the gate dagger to 2q gates --- crates/pecos/tests/neo_emission_test.rs | 88 +++++++++++++++++++++++++ exp/pecos-neo/src/command.rs | 50 +++++++++----- exp/pecos-neo/src/noise/single_qubit.rs | 2 +- exp/pecos-neo/src/noise/two_qubit.rs | 12 ++++ 4 files changed, 135 insertions(+), 17 deletions(-) diff --git a/crates/pecos/tests/neo_emission_test.rs b/crates/pecos/tests/neo_emission_test.rs index 5c39eea68..8fb096e26 100644 --- a/crates/pecos/tests/neo_emission_test.rs +++ b/crates/pecos/tests/neo_emission_test.rs @@ -131,3 +131,91 @@ fn emission_is_gate_removing_and_matches_engines() { "engines and neo emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" ); } + +// --- Two-qubit emission --------------------------------------------------- + +const P2: f64 = 0.6; + +/// `x q0; cx q0,q1; measure q1`. Pure two-qubit emission (`emission=1.0`, +/// `p1=0`) on the CX. If the CX is DROPPED, q1 stays 0 and a uniform two-qubit +/// Pauli flips it on 8/15 -> `P(q1=0) = p2 * 7/15`. If the CX is KEPT, q1 is 1 +/// and the Pauli flips it on 8/15 -> `P(q1=0) = p2 * 8/15`. At `p2=0.6` that is +/// `0.28` (gate-removing) vs `0.32` (gate-preserving). +const CX_MEASURE: &str = r#" + OPENQASM 2.0; + include "qelib1.inc"; + qreg q[2]; + creg c[1]; + x q[0]; + cx q[0], q[1]; + measure q[1] -> c[0]; +"#; + +fn engines_2q_zero_count() -> u64 { + let noise = pecos_engines::noise::GeneralNoiseModel::builder() + .with_p1_probability(0.0) + .with_p2_probability(P2) + .with_p2_emission_ratio(1.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + let results = sim(Qasm::from_string(CX_MEASURE)) + .stack(SimStack::Engines) + .noise(noise) + .seed(42) + .run(SHOTS) + .expect("engines run"); + rate_zero(&results).0 +} + +fn neo_2q_zero_count() -> u64 { + use pecos_neo::noise::GeneralNoiseModelBuilder; + use pecos_neo::tool::{monte_carlo, sim_neo}; + + let noise = GeneralNoiseModelBuilder::new() + .with_p1(0.0) + .with_p2(P2) + .with_p2_emission_ratio(1.0) + .with_p_prep(0.0) + .with_p_meas_symmetric(0.0); + let results = sim_neo(Qasm::from_string(CX_MEASURE)) + .auto() + .sampling(monte_carlo(SHOTS)) + .noise(noise) + .seed(99) + .run(); + let shots = results.shots.expect("neo produced shots"); + rate_zero(&shots).0 +} + +#[test] +fn two_qubit_emission_is_gate_removing_and_matches_engines() { + let analytic = P2 * 7.0 / 15.0; // 0.28 (gate-preserving would be P2*8/15 = 0.32) + + let engines = engines_2q_zero_count(); + let neo = neo_2q_zero_count(); + let engines_ci = jeffreys_interval(engines, SHOTS as u64, CONFIDENCE); + let neo_ci = jeffreys_interval(neo, SHOTS as u64, CONFIDENCE); + println!( + "2q emission: engines {engines}/{SHOTS} CI [{:.4}, {:.4}], neo {neo}/{SHOTS} CI \ + [{:.4}, {:.4}], gate-removing analytic {analytic:.4} (gate-preserving would be {:.4})", + engines_ci.0, + engines_ci.1, + neo_ci.0, + neo_ci.1, + P2 * 8.0 / 15.0 + ); + + for (name, ci) in [("engines", engines_ci), ("neo", neo_ci)] { + assert!( + ci.0 <= analytic && analytic <= ci.1, + "{name} P(q1=0) excludes the gate-removing analytic {analytic}" + ); + } + assert!( + engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, + "engines and neo 2q emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" + ); +} diff --git a/exp/pecos-neo/src/command.rs b/exp/pecos-neo/src/command.rs index 3917a8a86..f66e05862 100644 --- a/exp/pecos-neo/src/command.rs +++ b/exp/pecos-neo/src/command.rs @@ -248,21 +248,20 @@ impl GateCommand { Self::new(GateType::I, smallvec::smallvec![qubit]) } - /// The inverse (dagger) of this single-qubit gate, if it is an invertible - /// single-qubit unitary; `None` otherwise (measurement, prep, idle, - /// resource management, or a multi-qubit gate). + /// The inverse (dagger) of this gate, if it is an invertible unitary; + /// `None` otherwise (measurement, prep, idle, resource management). /// - /// Used by the gate-removing emission noise model: when a single-qubit - /// gate suffers a spontaneous-emission error, undoing the gate (`G dagger`) - /// and then applying the emission error reproduces engines' "the emission - /// replaces the gate" semantics, since `G * G_dagger = I`. + /// Used by the gate-removing emission noise model: when a gate suffers a + /// spontaneous-emission error, undoing the gate (`G dagger`) and then + /// applying the emission error reproduces engines' "the emission replaces + /// the gate" semantics, since `G * G_dagger = I`. /// /// Rotation inverses use the standard conventions - /// (`RX/RY/RZ(theta)` -> negate `theta`; `R1XY(theta, phi)` -> - /// `R1XY(-theta, phi)`; `U(theta, phi, lambda)` -> + /// (`RX/RY/RZ(theta)` and `CRZ/RXX/RYY/RZZ(theta)` -> negate `theta`; + /// `R1XY(theta, phi)` -> `R1XY(-theta, phi)`; `U(theta, phi, lambda)` -> /// `U(-theta, -lambda, -phi)`). #[must_use] - pub fn single_qubit_dagger(&self) -> Option { + pub fn dagger(&self) -> Option { let q = self.qubits.clone(); let same = |t: GateType| Some(Self::new(t, q.clone())); let neg_first = |t: GateType| -> Option { @@ -270,10 +269,17 @@ impl GateCommand { Some(Self::with_angles(t, q.clone(), smallvec::smallvec![-theta])) }; match self.gate_type { - // Self-inverse single-qubit gates. - GateType::I | GateType::X | GateType::Y | GateType::Z | GateType::H => { - same(self.gate_type) - } + // Self-inverse gates (1q, 2q, 3q). + GateType::I + | GateType::X + | GateType::Y + | GateType::Z + | GateType::H + | GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SWAP + | GateType::CCX => same(self.gate_type), // Dagger pairs. GateType::F => same(GateType::Fdg), GateType::Fdg => same(GateType::F), @@ -285,8 +291,20 @@ impl GateCommand { GateType::SZdg => same(GateType::SZ), GateType::T => same(GateType::Tdg), GateType::Tdg => same(GateType::T), - // Single-angle rotations: negate the angle. - GateType::RX | GateType::RY | GateType::RZ => neg_first(self.gate_type), + GateType::SZZ => same(GateType::SZZdg), + GateType::SZZdg => same(GateType::SZZ), + GateType::SXX => same(GateType::SXXdg), + GateType::SXXdg => same(GateType::SXX), + GateType::SYY => same(GateType::SYYdg), + GateType::SYYdg => same(GateType::SYY), + // Single-angle rotations (1q and 2q): negate the angle. + GateType::RX + | GateType::RY + | GateType::RZ + | GateType::CRZ + | GateType::RXX + | GateType::RYY + | GateType::RZZ => neg_first(self.gate_type), // R1XY(theta, phi) dagger = R1XY(-theta, phi). GateType::R1XY => { let theta = *self.angles.first()?; diff --git a/exp/pecos-neo/src/noise/single_qubit.rs b/exp/pecos-neo/src/noise/single_qubit.rs index e16555d31..a2a1116a7 100644 --- a/exp/pecos-neo/src/noise/single_qubit.rs +++ b/exp/pecos-neo/src/noise/single_qubit.rs @@ -384,7 +384,7 @@ impl SingleQubitChannel { smallvec::smallvec![qubit], angles.iter().copied().collect::>(), ); - if let Some(dagger) = original.single_qubit_dagger() { + if let Some(dagger) = original.dagger() { gates.push(dagger); } // Emission error - sample from emission weights diff --git a/exp/pecos-neo/src/noise/two_qubit.rs b/exp/pecos-neo/src/noise/two_qubit.rs index c5770993c..857964ec1 100644 --- a/exp/pecos-neo/src/noise/two_qubit.rs +++ b/exp/pecos-neo/src/noise/two_qubit.rs @@ -607,6 +607,18 @@ impl TwoQubitChannel { // Determine if this is an emission or Pauli error (using precomputed threshold) if self.emission_ratio > 0.0 && rng.check_probability(self.emission_threshold) { + // Emission REPLACES the gate: undo it (apply its dagger) so the net + // effect is the gate removed, then apply the emission error. (Matches + // engines' apply_tq_faults, which drops the original two-qubit gate + // on a spontaneous-emission fault.) + let original = GateCommand::with_angles( + gate_type, + smallvec::smallvec![qubit0, qubit1], + angles.iter().copied().collect::>(), + ); + if let Some(dagger) = original.dagger() { + gates.push(dagger); + } // Emission error - sample from emission weights let idx = self.emission_weights.sample(rng.random::()); let result = TwoQubitEmissionWeights::get_result(idx); From 555101add8cf5a0eb0fd9a814672ad232978f8a7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 08:42:41 -0600 Subject: [PATCH 206/388] Phase 1 step 2c-5a: widen WindowedLogicalSubgraphDecoder to ObsMask (decode_obs override, u64 path narrows + errors on >64), removing the interim >64 hard reject --- .../src/logical_subgraph_windowed.rs | 36 +++++++++---------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index 8150b6e94..6fd4a4eb2 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -35,6 +35,7 @@ use pecos_decoder_core::ObservableDecoder; use pecos_decoder_core::dem::DemMatchingGraph; use pecos_decoder_core::errors::DecoderError; +use pecos_decoder_core::obs_mask::ObsMask; use pecos_decoder_core::logical_subgraph::window_plan::LogicalSubgraphWindowPlan; use pecos_decoder_core::logical_subgraph::{ MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, @@ -95,16 +96,6 @@ impl WindowedLogicalSubgraphDecoder { let mut subgraphs = Vec::with_capacity(plan.num_observables()); let mut max_local = 0usize; for entry in plan.entries() { - // The windowed path packs observable flips into a u64 mask; reject - // >64 here (the non-windowed `LogicalSubgraphDecoder` supports more - // via `decode_obs`, but the windowed core-commit path does not yet). - if entry.observable_idx >= 64 { - return Err(DecoderError::InvalidConfiguration(format!( - "WindowedLogicalSubgraphDecoder packs flips into a u64 mask and supports at \ - most 64 observables, but saw observable index {}", - entry.observable_idx, - ))); - } let decoder = OverlappingWindowedDecoder::from_dem(&entry.sub_dem, window_config, |wdem| { UfDecoder::from_dem(wdem, UfDecoderConfig::windowed()) @@ -139,8 +130,20 @@ impl WindowedLogicalSubgraphDecoder { } impl ObservableDecoder for WindowedLogicalSubgraphDecoder { + /// Narrowing wrapper over [`Self::decode_obs`]; errors (rather than + /// truncating) above 64 observables. fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = 0u64; + self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + ) + }) + } + + /// Decode every windowed per-observable subgraph and pack the flips into a + /// wide [`ObsMask`] at each subgraph's GLOBAL observable index (no >64 cap). + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = ObsMask::new(); for sg in &mut self.subgraphs { let n = sg.num_local; for (local, &global) in sg.detector_map.iter().enumerate() { @@ -151,17 +154,10 @@ impl ObservableDecoder for WindowedLogicalSubgraphDecoder { }; } // The subgraph decodes a single observable as its local bit 0; map - // that back to this observable's global bit. `observable_idx < 64` is - // guaranteed by the hard reject in `from_dem`; assert it locally where - // the u64 shift consumes it. - debug_assert!( - sg.observable_idx < 64, - "observable index {} exceeds u64 observable-mask capacity", - sg.observable_idx - ); + // that back to this observable's global bit. let sub_obs = sg.decoder.decode_to_observables(&self.local_syn[..n])?; if sub_obs & 1 != 0 { - obs_mask |= 1u64 << sg.observable_idx; + obs_mask.set(sg.observable_idx); } } Ok(obs_mask) From afaf6efb6ee43beb34d470cf89f8d49112c24dfd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 08:43:58 -0600 Subject: [PATCH 207/388] Add pecos-phir-pliron: validated Bell port proving build-PHIR-on-pliron end-to-end (round-2 gate) --- Cargo.lock | 199 ++++++++++++ exp/pecos-phir-pliron/Cargo.toml | 18 ++ exp/pecos-phir-pliron/src/main.rs | 507 ++++++++++++++++++++++++++++++ 3 files changed, 724 insertions(+) create mode 100644 exp/pecos-phir-pliron/Cargo.toml create mode 100644 exp/pecos-phir-pliron/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index d1e1f3a80..49e47dbd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -330,6 +330,81 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "awint" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48b7360a36d335663e8f20b7f439029debf52b5a0a749d6e936405f25045288" +dependencies = [ + "awint_core", + "awint_dag", + "awint_ext", + "awint_macro_internals", + "awint_macros", +] + +[[package]] +name = "awint_core" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8280079e78217ace721501f35dbf551dadf0ab63863504169316cea1bbac872" +dependencies = [ + "awint_internals", + "const_fn", +] + +[[package]] +name = "awint_dag" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73977ba1786a5e127272f08b865b60f5d548576a0925de1daa5703cc9ba78a8d" +dependencies = [ + "awint_ext", + "awint_macro_internals", + "awint_macros", + "smallvec", +] + +[[package]] +name = "awint_ext" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1b30d3640d9f94dd9f55f655773c5238c11be6f95ab36d24308b7ae34c9bbc" +dependencies = [ + "awint_core", + "const_fn", +] + +[[package]] +name = "awint_internals" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173937e3f4e233d93362fc1e28ab23808cb31671a1923033706cb44ed4e5d10c" +dependencies = [ + "const_fn", +] + +[[package]] +name = "awint_macro_internals" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c6a23f64f14c3b566c2c04124015f78c5fb93e8275a6574e0118747ee60de6" +dependencies = [ + "awint_ext", + "proc-macro2", + "triple_arena", +] + +[[package]] +name = "awint_macros" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e50a2862c6002a424fc9bb4982ec33ab679cb4ad04a174e2a52c2ecc3ea4c7" +dependencies = [ + "awint_internals", + "awint_macro_internals", +] + [[package]] name = "aws-lc-rs" version = "1.16.3" @@ -934,6 +1009,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const_fn" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413d67b29ef1021b4d60f4aa1e925ca031751e213832b4b1d588fae623c05c60" + [[package]] name = "convert_case" version = "0.4.0" @@ -949,6 +1030,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "convert_case" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -2392,6 +2482,8 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -2448,6 +2540,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "hi_sparse_bitset" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "244cabe6369d69e97280fccda8e43c82387aa4af83a2c491791ec421cfe94e45" +dependencies = [ + "wide 1.4.0", +] + [[package]] name = "highs" version = "1.6.1" @@ -3273,6 +3374,26 @@ dependencies = [ "cc", ] +[[package]] +name = "linkme" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83272d46373fb8decca684579ac3e7c8f3d71d4cc3aa693df8759e260ae41cf" +dependencies = [ + "linkme-impl", +] + +[[package]] +name = "linkme-impl" +version = "0.3.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32d59e20403c7d08fe62b4376edfe5c7fb2ef1e6b1465379686d0f21c8df444b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4567,6 +4688,16 @@ dependencies = [ "wat", ] +[[package]] +name = "pecos-phir-pliron" +version = "0.2.0-dev.0" +dependencies = [ + "awint", + "pecos-core", + "pecos-engines", + "pliron", +] + [[package]] name = "pecos-programs" version = "0.2.0-dev.0" @@ -5094,6 +5225,40 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "pliron" +version = "0.15.0" +dependencies = [ + "awint", + "combine", + "downcast-rs", + "dyn-clone", + "hashbrown 0.17.1", + "hi_sparse_bitset", + "inventory", + "linkme", + "log", + "pliron-derive", + "regex", + "rustc-hash 2.1.2", + "rustc_apfloat", + "slotmap", + "spin", + "thiserror 2.0.18", +] + +[[package]] +name = "pliron-derive" +version = "0.15.0" +dependencies = [ + "combine", + "convert_case 0.11.0", + "proc-macro2", + "quote", + "rustc-hash 2.1.2", + "syn 2.0.117", +] + [[package]] name = "plotters" version = "0.3.7" @@ -5803,6 +5968,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "recasting" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70aeee1a07922bfe7c79e148553dee262248d2493d9778eee7551b9b5d7cc651" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -6100,6 +6271,16 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +[[package]] +name = "rustc_apfloat" +version = "0.2.3+llvm-462a31f5a5ab" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "486c2179b4796f65bfe2ee33679acf0927ac83ecf583ad6c91c3b4570911b9ad" +dependencies = [ + "bitflags 2.11.1", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -6756,6 +6937,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1527984ca054dfca79333baec451042863f485fbee01b7bf6d911de915cac865" +dependencies = [ + "lock_api", +] + [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -7541,6 +7731,15 @@ dependencies = [ "once_cell", ] +[[package]] +name = "triple_arena" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a99051ec05383f70bcab71884d39083a455257795e71cb780048a4bc890f3b8" +dependencies = [ + "recasting", +] + [[package]] name = "try-lock" version = "0.2.5" diff --git a/exp/pecos-phir-pliron/Cargo.toml b/exp/pecos-phir-pliron/Cargo.toml new file mode 100644 index 000000000..f25aacf39 --- /dev/null +++ b/exp/pecos-phir-pliron/Cargo.toml @@ -0,0 +1,18 @@ +# Experimental: the round-2 decision gate for build-PHIR-on-pliron. +# Drives a Bell program built as pliron IR through the UNCHANGED pecos-engines sim seam. +# See pecos-docs design/slr-phir-vision.md §7.1 + slr-phir-vision-qis-port-sketch.md. +[package] +name = "pecos-phir-pliron" +version.workspace = true +edition.workspace = true +authors.workspace = true +homepage.workspace = true +repository.workspace = true +license.workspace = true +publish = false + +[dependencies] +pecos-engines.workspace = true +pecos-core.workspace = true +pliron = { path = "/home/ciaranra/Repos/pliron" } +awint = "0.18" diff --git a/exp/pecos-phir-pliron/src/main.rs b/exp/pecos-phir-pliron/src/main.rs new file mode 100644 index 000000000..b2629f9b0 --- /dev/null +++ b/exp/pecos-phir-pliron/src/main.rs @@ -0,0 +1,507 @@ +//! Bell port — the round-2 decision gate for build-PHIR-on-pliron. +//! See pecos-docs design/slr-phir-vision.md §7.1 + slr-phir-vision-qis-port-sketch.md. +//! +//! Milestone 0: prove the pecos-engines sim seam with a hand-built Bell ByteMessage. +//! Milestone 1: build the SAME Bell program as pliron `qec` IR (allocator/slot model), +//! emit the ByteMessage from a pliron op-walk using an EXPLICIT `Value -> qubit index` +//! side-table (NOT the `SSAValue`-as-u32 overload that pecos-phir uses), then run it +//! through the SAME unchanged pecos-engines StateVecEngine and assert the Bell invariant. +//! +//! This is the concrete proof the round-2 decision is gated on: typed pliron ops feed the +//! real backend through explicit qubit/result maps, and the ByteMessage seam is untouched. + +use std::any::Any; +use std::collections::{BTreeSet, HashMap}; + +use awint::bw; +use pecos_engines::byte_message::ByteMessage; +use pecos_engines::quantum::StateVecEngine; +use pecos_engines::{ClassicalEngine, ControlEngine, Data, Engine, EngineStage, PecosError, Shot}; +use pliron::{ + builtin::{ + attributes::IntegerAttr, + op_interfaces::{ + IsTerminatorInterface, NOpdsInterface, NResultsInterface, OneResultInterface, + SingleBlockRegionInterface, + }, + ops::{FuncOp, ModuleOp}, + types::{FunctionType, IntegerType, Signedness}, + }, + attribute::AttrObj, + basic_block::BasicBlock, + common_traits::Verify, + context::{Context, Ptr}, + derive::{pliron_op, pliron_type}, + linked_list::ContainsLinkedList, + op::{verify_op, Op}, + operation::Operation, + printable::Printable, + result::Result, + r#type::{TypeObj, Typed}, + utils::apint::APInt, + value::Value, + verify_err, +}; + +// ===================== Milestone 0: hand-built Bell ByteMessage ===================== + +fn bell_message() -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + b.h(&[0]); + b.cx(&[(0, 1)]); + b.mz(&[0]); + b.mz(&[1]); + b.build() +} + +/// Run a Bell ByteMessage through the real state-vector simulator; assert each shot is 00 or 11. +fn run_and_check(label: &str, msg: ByteMessage, shots: usize) { + let mut engine = StateVecEngine::new(2); + let (mut saw0, mut saw3) = (false, false); + for _ in 0..shots { + engine.reset().unwrap(); + let out = engine.process(msg.clone()).unwrap(); + let o = out.outcomes().unwrap(); + assert_eq!(o.len(), 2, "expected 2 measurement outcomes"); + let combined = (o[1] << 1) | o[0]; // q1 q0 + assert!(combined == 0 || combined == 3, "{label}: Bell must be 00 or 11, got {combined}"); + saw0 |= combined == 0; + saw3 |= combined == 3; + } + assert!(saw0 && saw3, "{label}: expected both 00 and 11 over {shots} shots"); + println!("[{label}] OK -- all {shots} shots in {{0,3}}, saw both 00 and 11"); +} + +// ===================== the qec pliron dialect (allocator/slot model) ===================== + +#[pliron_type(name = "qec.alloc", generate_get = true, format, verifier = "succ")] +#[derive(Hash, PartialEq, Eq, Debug)] +pub struct AllocType; + +#[pliron_type(name = "qec.qubitref", generate_get = true, format, verifier = "succ")] +#[derive(Hash, PartialEq, Eq, Debug)] +pub struct QubitRefType; + +fn alloc_ty(ctx: &Context) -> Ptr { + AllocType::get(ctx).into() +} +fn qubitref_ty(ctx: &Context) -> Ptr { + QubitRefType::get(ctx).into() +} + +mod slot_attr { + use pliron::dict_key; + dict_key!(INDEX, "qec_slot_index"); +} + +#[pliron_op(name = "qec.qalloc", format, interfaces = [NOpdsInterface<0>, OneResultInterface, NResultsInterface<1>], verifier = "succ")] +pub struct QallocOp; +impl QallocOp { + pub fn new(ctx: &mut Context) -> Self { + let a = alloc_ty(ctx); + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![a], vec![], vec![], 0); + QallocOp { op } + } +} + +#[pliron_op(name = "qec.slot", format, interfaces = [NOpdsInterface<1>, OneResultInterface, NResultsInterface<1>])] +pub struct SlotOp; +impl SlotOp { + pub fn new(ctx: &mut Context, alloc: Value, index: u64) -> Self { + let r = qubitref_ty(ctx); + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![r], vec![alloc], vec![], 0); + let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless); + let attr = IntegerAttr::new(i64_ty, APInt::from_u64(index, bw(64))); + op.deref_mut(ctx).attributes.0.insert(slot_attr::INDEX.clone(), Box::new(attr)); + SlotOp { op } + } + pub fn index(&self, ctx: &Context) -> u64 { + let op = self.get_operation().deref(ctx); + let attr: AttrObj = op.attributes.0.get(&*slot_attr::INDEX).expect("slot index attr").clone(); + let int_attr = attr.downcast::().unwrap_or_else(|_| panic!("not IntegerAttr")); + Into::::into(*int_attr).to_u64() + } +} +impl Verify for SlotOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != alloc_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.slot operand must be a qec.alloc"); + } + Ok(()) + } +} + +#[pliron_op(name = "qec.prepare", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct PrepareOp; +impl PrepareOp { + pub fn new(ctx: &mut Context, qref: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![qref], vec![], 0); + PrepareOp { op } + } +} +impl Verify for PrepareOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.prepare operand must be a qec.qubitref"); + } + Ok(()) + } +} + +#[pliron_op(name = "qec.h", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct HOp; +impl HOp { + pub fn new(ctx: &mut Context, qref: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![qref], vec![], 0); + HOp { op } + } +} +impl Verify for HOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.h operand must be a qec.qubitref"); + } + Ok(()) + } +} + +#[pliron_op(name = "qec.cx", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] +pub struct CxOp; +impl CxOp { + pub fn new(ctx: &mut Context, ctrl: Value, tgt: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![ctrl, tgt], vec![], 0); + CxOp { op } + } +} +impl Verify for CxOp { + fn verify(&self, ctx: &Context) -> Result<()> { + for i in 0..2 { + if self.get_operation().deref(ctx).get_operand(i).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.cx operands must be qec.qubitref"); + } + } + Ok(()) + } +} + +#[pliron_op(name = "qec.measure", format, interfaces = [NOpdsInterface<1>, OneResultInterface, NResultsInterface<1>])] +pub struct MeasureOp; +impl MeasureOp { + pub fn new(ctx: &mut Context, qref: Value) -> Self { + let i1 = IntegerType::get(ctx, 1, Signedness::Signless); + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![i1.into()], vec![qref], vec![], 0); + MeasureOp { op } + } +} +impl Verify for MeasureOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.measure operand must be a qec.qubitref"); + } + Ok(()) + } +} + +#[pliron_op(name = "qec.end", format, interfaces = [IsTerminatorInterface, NOpdsInterface<0>, NResultsInterface<0>], verifier = "succ")] +pub struct EndOp; +impl EndOp { + pub fn new(ctx: &mut Context) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![], vec![], 0); + EndOp { op } + } +} + +// ===================== Milestone 1: build Bell as pliron IR, emit ByteMessage ===================== + +/// Build `fn main() { q = qalloc; q0 = slot(q,0); q1 = slot(q,1); prepare q0; prepare q1; +/// h q0; cx q0,q1; m0 = measure q0; m1 = measure q1; end }` as pliron IR; return the func block. +fn build_bell_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { + let module = ModuleOp::new(ctx, "bell".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + + macro_rules! push { + ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + } + + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let s0 = push!(SlotOp::new(ctx, qv, 0)); + let s0v = s0.get_result(ctx); + let s1 = push!(SlotOp::new(ctx, qv, 1)); + let s1v = s1.get_result(ctx); + push!(PrepareOp::new(ctx, s0v)); + push!(PrepareOp::new(ctx, s1v)); + push!(HOp::new(ctx, s0v)); + push!(CxOp::new(ctx, s0v, s1v)); + push!(MeasureOp::new(ctx, s0v)); + push!(MeasureOp::new(ctx, s1v)); + push!(EndOp::new(ctx)); + (module, bb) +} + +/// Walk the pliron func block and emit a ByteMessage. The ONLY qubit-identity source is an +/// explicit `Value(qubitref) -> qubit index` map populated from each `qec.slot`'s index +/// attribute -- pliron Values are op-defined handles, never reused as dense qubit indices. +fn emit_bytemessage(ctx: &Context, block: Ptr) -> ByteMessage { + let mut qubit_of: HashMap = HashMap::new(); + let mut b = ByteMessage::quantum_operations_builder(); + let ops: Vec> = block.deref(ctx).iter(ctx).collect(); + for op in ops { + if let Some(s) = Operation::get_op::(op, ctx) { + qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); + } else if let Some(p) = Operation::get_op::(op, ctx) { + let q = qubit_of[&p.get_operation().deref(ctx).get_operand(0)]; + b.pz(&[q]); + } else if let Some(h) = Operation::get_op::(op, ctx) { + let q = qubit_of[&h.get_operation().deref(ctx).get_operand(0)]; + b.h(&[q]); + } else if let Some(cx) = Operation::get_op::(op, ctx) { + let opn = cx.get_operation(); + let c = qubit_of[&opn.deref(ctx).get_operand(0)]; + let t = qubit_of[&opn.deref(ctx).get_operand(1)]; + b.cx(&[(c, t)]); + } else if let Some(m) = Operation::get_op::(op, ctx) { + let q = qubit_of[&m.get_operation().deref(ctx).get_operand(0)]; + b.mz(&[q]); // outcome order = mz-emission order + } + // qalloc / end: nothing to emit + } + b.build() +} + +fn run_milestone_1() { + let ctx = &mut Context::new(); + let (module, bb) = build_bell_ir(ctx); + println!("=== Bell as pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + match verify_op(&module, ctx) { + Ok(()) => println!("[milestone-1 verify] OK"), + Err(e) => panic!("[milestone-1 verify] FAILED: {}", e.disp(ctx)), + } + let msg = emit_bytemessage(ctx, bb); + run_and_check("milestone-1 pliron-emitted Bell", msg, 200); +} + +// ===================== Milestone 2: pliron op-walk AS a real ClassicalEngine ===================== + +/// A `ClassicalControlEngine` whose command stream is the ByteMessage emitted from the pliron +/// `qec` IR walk. Bell is straight-line (one batch, no classical feedback), so it sends the +/// whole program once, stores the outcomes, and records register "c" = (q1<<1)|q0 as a Shot. +#[derive(Clone)] +struct PlironBellEngine { + msg: ByteMessage, + num_qubits: usize, + sent: bool, + outcomes: Vec, +} + +impl Engine for PlironBellEngine { + type Input = (); + type Output = Shot; + fn process(&mut self, _input: ()) -> std::result::Result { + self.get_results() + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + self.sent = false; + self.outcomes.clear(); + Ok(()) + } +} + +impl ClassicalEngine for PlironBellEngine { + fn num_qubits(&self) -> usize { + self.num_qubits + } + fn generate_commands(&mut self) -> std::result::Result { + if self.sent { + Ok(ByteMessage::create_empty()) + } else { + self.sent = true; + Ok(self.msg.clone()) + } + } + fn handle_measurements(&mut self, message: ByteMessage) -> std::result::Result<(), PecosError> { + self.outcomes = message.outcomes()?; + Ok(()) + } + fn get_results(&self) -> std::result::Result { + let combined = if self.outcomes.len() >= 2 { + (self.outcomes[1] << 1) | self.outcomes[0] + } else { + 0 + }; + let mut shot = Shot::default(); + shot.add_register("c", combined, 2); + Ok(shot) + } + fn compile(&self) -> std::result::Result<(), PecosError> { + Ok(()) + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +impl ControlEngine for PlironBellEngine { + type Input = (); + type Output = Shot; + type EngineInput = ByteMessage; + type EngineOutput = ByteMessage; + fn start(&mut self, _input: ()) -> std::result::Result, PecosError> { + self.sent = false; + self.outcomes.clear(); + let cmds = self.generate_commands()?; + if cmds.as_bytes().is_empty() { + Ok(EngineStage::Complete(self.get_results()?)) + } else { + Ok(EngineStage::NeedsProcessing(cmds)) + } + } + fn continue_processing(&mut self, measurements: ByteMessage) -> std::result::Result, PecosError> { + self.handle_measurements(measurements)?; + // Bell is a single batch with no classical feedback: once the (only) batch's measurements + // are in, we are done. (A general engine would loop on generate_commands + a `finished` + // flag like PhirEngine; `create_empty().as_bytes()` is NOT byte-empty, so it is not a + // reliable termination signal on its own.) + Ok(EngineStage::Complete(self.get_results()?)) + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } +} + +fn run_milestone_2() { + let ctx = &mut Context::new(); + let (module, bb) = build_bell_ir(ctx); + verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-2 verify] FAILED: {}", e.disp(ctx))); + let msg = emit_bytemessage(ctx, bb); // pliron-emitted Bell ByteMessage + + let mut engine = PlironBellEngine { msg, num_qubits: 2, sent: false, outcomes: Vec::new() }; + let mut qsys = StateVecEngine::new(2); + + // Drive the ControlEngine protocol by hand (start -> {process, continue_processing} loop -> + // Complete) -- this IS the engine seam HybridEngine automates; doing it explicitly proves the + // pliron-backed ClassicalControlEngine feeds the real StateVecEngine and yields a Shot. + let (mut saw0, mut saw3) = (false, false); + for _ in 0..200 { + qsys.reset().unwrap(); + let mut stage = ControlEngine::start(&mut engine, ()).unwrap(); + let shot = loop { + match stage { + EngineStage::Complete(s) => break s, + EngineStage::NeedsProcessing(cmd) => { + let meas = qsys.process(cmd).unwrap(); + stage = engine.continue_processing(meas).unwrap(); + } + } + }; + let v = shot.data.get("c").and_then(Data::as_u32).expect("register c"); + assert!(v == 0 || v == 3, "milestone-2: Bell via ClassicalEngine must be 00 or 11, got {v}"); + saw0 |= v == 0; + saw3 |= v == 3; + } + assert!(saw0 && saw3, "milestone-2: expected both 00 and 11 over 200 shots"); + println!("[milestone-2 pliron ClassicalControlEngine -> StateVecEngine] OK -- 200 shots in {{0,3}}, saw both (register \"c\")"); +} + +// ===================== Milestone 3: parse a real bell.ll into pliron qec IR ===================== + +/// Minimal QIS-LLVM-IR -> pliron `qec` IR parser for the Bell fixture (`examples/llvm/bell.ll`). +/// Recognizes `__quantum__qis__{h,cx,m}__body` calls with integer qubit args. This proves the +/// LLVM-IR frontend ports onto pliron ops (not just hand-built IR) -- the last gate-fidelity item. +fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { + fn i64_args(line: &str) -> Vec { + match (line.find('('), line.rfind(')')) { + (Some(l), Some(r)) if r > l => line[l + 1..r] + .split(',') + .filter_map(|t| t.trim().strip_prefix("i64 ").and_then(|n| n.trim().parse::().ok())) + .collect(), + _ => Vec::new(), + } + } + // pass 1: collect the gate/measure stream + the set of qubits referenced + let mut parsed: Vec<(&str, Vec)> = Vec::new(); + let mut qubits: BTreeSet = BTreeSet::new(); + for line in src.lines() { + let l = line.trim(); + if !(l.starts_with("call ") || l.contains("= call ")) { + continue; + } + if l.contains("__quantum__qis__h__body") { + let a = i64_args(l); + qubits.insert(a[0]); + parsed.push(("h", a)); + } else if l.contains("__quantum__qis__cx__body") { + let a = i64_args(l); + qubits.insert(a[0]); + qubits.insert(a[1]); + parsed.push(("cx", a)); + } else if l.contains("__quantum__qis__m__body") { + let a = i64_args(l); + qubits.insert(a[0]); + parsed.push(("m", a)); + } + } + // pass 2: build the pliron qec IR + let module = ModuleOp::new(ctx, "bell_from_ll".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "qmain".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + } + + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let mut slot_of: HashMap = HashMap::new(); + for &idx in &qubits { + let s = push!(SlotOp::new(ctx, qv, idx as u64)); + let sv = s.get_result(ctx); + slot_of.insert(idx, sv); + push!(PrepareOp::new(ctx, sv)); // QIS qubits start |0>; that implicit init IS a prepare in the qec model + } + for (op, a) in &parsed { + match *op { + "h" => { + push!(HOp::new(ctx, slot_of[&a[0]])); + } + "cx" => { + push!(CxOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); + } + "m" => { + push!(MeasureOp::new(ctx, slot_of[&a[0]])); + } + _ => {} + } + } + push!(EndOp::new(ctx)); + (module, bb) +} + +fn run_milestone_3() { + let ctx = &mut Context::new(); + let src = include_str!("../../../examples/llvm/bell.ll"); + let (module, bb) = parse_bell_ll(ctx, src); + println!("=== bell.ll parsed into pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-3 verify] FAILED: {}", e.disp(ctx))); + let msg = emit_bytemessage(ctx, bb); + run_and_check("milestone-3 bell.ll -> pliron qec -> sim", msg, 200); +} + +fn main() { + run_and_check("milestone-0 hand-built Bell", bell_message(), 200); + run_milestone_1(); + run_milestone_2(); + run_milestone_3(); +} From a944a1a573896af6f6242a64a5cd38b01df762af Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 08:48:48 -0600 Subject: [PATCH 208/388] Phase 1 step 2c-5b: widen the LogicalCircuit/LogicalAlgorithm decode path to ObsMask (DecodeStrategy::decode_obs + FullCircuit/Windowed strategy overrides + decoder decode_obs overrides), removing the last core >64 reject; u64 paths narrow and fail loud --- .../pecos-decoder-core/src/decode_budget.rs | 11 ++++ .../src/logical_algorithm.rs | 52 ++++++++++++++----- 2 files changed, 50 insertions(+), 13 deletions(-) diff --git a/crates/pecos-decoder-core/src/decode_budget.rs b/crates/pecos-decoder-core/src/decode_budget.rs index 140b89582..34a194c7c 100644 --- a/crates/pecos-decoder-core/src/decode_budget.rs +++ b/crates/pecos-decoder-core/src/decode_budget.rs @@ -188,6 +188,17 @@ pub trait DecodeStrategy: Send + Sync { /// which portion to decode based on its internal state and budget. fn decode(&mut self, syndrome: &[u8]) -> Result; + /// Decode and return a wide [`ObsMask`](crate::obs_mask::ObsMask) (supports + /// more than 64 observables). Default-bridges [`Self::decode`]; strategies + /// that aggregate more than 64 observables override this. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if decoding fails. + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.decode(syndrome)?)) + } + /// Commit corrections for a detector region. /// /// After commitment, detectors in this region are excluded from diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index 12220e5a0..883f75cb9 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -25,6 +25,7 @@ use crate::ObservableDecoder; use crate::errors::DecoderError; +use crate::obs_mask::ObsMask; /// One segment of a logical algorithm. pub struct SegmentDescriptor { @@ -239,6 +240,10 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { self.decode_shot(syndrome) } + + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + self.full_decoder.decode_obs(syndrome) + } } // ============================================================================ @@ -579,6 +584,13 @@ impl ObservableDecoder for LogicalCircuitDecoder { fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { self.decode_shot(syndrome) } + + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + self.reset(); + let len = syndrome.len().min(self.total_detectors); + self.syndrome[..len].copy_from_slice(&syndrome[..len]); + self.strategy.decode_obs(&self.syndrome) + } } // ============================================================================ @@ -607,6 +619,10 @@ impl DecodeStrategy for FullCircuitStrategy { self.inner.decode_to_observables(syndrome) } + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + self.inner.decode_obs(syndrome) + } + fn commit(&mut self, _region: &DetectorRegion) -> Result { // Full circuit doesn't commit incrementally Ok(0) @@ -681,13 +697,6 @@ impl WindowedLogicalSubgraphStrategy { observable_indices.len(), ))); } - if let Some(&bad) = observable_indices.iter().find(|&&o| o >= 64) { - return Err(DecoderError::InvalidConfiguration(format!( - "WindowedLogicalSubgraphStrategy: observable index {bad} >= 64 \ - exceeds the u64 observable-mask capacity" - ))); - } - let mut decoders = Vec::with_capacity(num); let mut sub_syndromes = Vec::with_capacity(num); for (i, dem_str) in subgraph_dems.iter().enumerate() { @@ -707,8 +716,17 @@ impl WindowedLogicalSubgraphStrategy { } impl DecodeStrategy for WindowedLogicalSubgraphStrategy { + /// Narrowing wrapper over [`Self::decode_obs`]; errors above 64 observables. fn decode(&mut self, syndrome: &[u8]) -> Result { - let mut obs_mask = 0u64; + self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + ) + }) + } + + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = ObsMask::new(); for (i, (dec, dmap)) in self .subgraph_decoders @@ -736,7 +754,7 @@ impl DecodeStrategy for WindowedLogicalSubgraphStrategy { // position `i`, which differs once empty observables are filtered). let sub_obs = dec.decode_to_observables(&buf[..n])?; if sub_obs & 1 != 0 { - obs_mask |= 1u64 << self.observable_indices[i]; + obs_mask.set(self.observable_indices[i]); } } @@ -812,14 +830,22 @@ mod tests { } #[test] - fn windowed_strategy_rejects_observable_index_over_63() { - let r = WindowedLogicalSubgraphStrategy::new( + fn windowed_strategy_supports_observable_index_over_63() { + use crate::decode_budget::DecodeStrategy; + // Observable index 64 was previously rejected; it now constructs and the + // wide `decode_obs` represents bit 64 with no truncation. + let mut s = WindowedLogicalSubgraphStrategy::new( vec!["error(0.1) D0 L0".to_string()], vec![vec![0usize]], vec![64usize], |_dem| Ok(Box::new(FixedDecoder(1)) as Box), - ); - assert!(r.is_err()); + ) + .unwrap(); + let wide = s.decode_obs(&[1]).unwrap(); + assert!(wide.get(64)); + assert_eq!(wide.to_u64(), None); + // The narrowing u64 path errors rather than truncating. + assert!(s.decode(&[1]).is_err()); } #[test] From 47e484fee8755796ce52b31d3252aa90319d27b9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 08:58:59 -0600 Subject: [PATCH 209/388] Commit Cargo.lock smallvec entry for pecos-decoder-core (lockfile was left inconsistent with the Cargo.toml dep added in 45b612b3) --- Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/Cargo.lock b/Cargo.lock index ffffaffbf..b2f714673 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3920,6 +3920,7 @@ dependencies = [ "ndarray 0.17.2", "pecos-random", "rayon", + "smallvec", "thiserror 2.0.18", ] From 4b1b04478d849c43219a821661bbb0614d5402d5 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 09:05:20 -0600 Subject: [PATCH 210/388] Map GeneralNoiseModel emission ratios through the sim().stack(Neo) facade now that neo emission is gate-removing --- crates/pecos-engines/src/noise/general.rs | 13 ++ .../src/noise/general/builder.rs | 127 ++++++++++++++---- crates/pecos/src/unified_sim.rs | 36 +++-- crates/pecos/tests/neo_emission_test.rs | 106 +++++++++++---- crates/pecos/tests/neo_routing_test.rs | 9 +- 5 files changed, 225 insertions(+), 66 deletions(-) diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index 7976130a4..e00dc3066 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -526,6 +526,19 @@ impl GeneralNoiseModel { self.p2_angle_power } + /// Get the single-qubit spontaneous-emission ratio (fraction of 1q gate + /// errors that are emission faults, which replace the gate). + #[must_use] + pub fn p1_emission_ratio(&self) -> f64 { + self.p1_emission_ratio + } + + /// Get the two-qubit spontaneous-emission ratio. + #[must_use] + pub fn p2_emission_ratio(&self) -> f64 { + self.p2_emission_ratio + } + /// Apply noise at the start of `QuantumSystem` processing (typically a collection of gates) /// /// # Panics diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index 6d22939a0..c66f1ac58 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -6,12 +6,25 @@ use crate::noise::{ use std::collections::{BTreeMap, BTreeSet}; /// The plain-Pauli probabilities plus optional angle-dependent two-qubit -/// scaling, as returned by +/// scaling and the spontaneous-emission ratios, as returned by /// [`GeneralNoiseModelBuilder::pauli_with_angle_scaling`]. /// -/// Layout: `(p_prep, p_meas_0, p_meas_1, p1, p2, angle)` where `angle` is -/// `Some((a, b, c, d, power))` when angle scaling is configured. -pub type PauliWithAngleScaling = (f64, f64, f64, f64, f64, Option<(f64, f64, f64, f64, f64)>); +/// Layout: +/// `(p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission_ratio, p2_emission_ratio)` +/// where `angle` is `Some((a, b, c, d, power))` when angle scaling is +/// configured. The emission ratios use the model default (0.5) when unset, and +/// the emission DISTRIBUTION is required to be the default (uniform Pauli) -- +/// custom emission models keep the config out of this subset. +pub type PauliWithAngleScaling = ( + f64, + f64, + f64, + f64, + f64, + Option<(f64, f64, f64, f64, f64)>, + f64, + f64, +); /// Builder for creating general noise models #[derive(Debug, Clone)] @@ -768,7 +781,12 @@ impl GeneralNoiseModelBuilder { /// common configuration without re-deriving probability conventions. #[must_use] pub fn simple_probabilities(&self) -> Option<(f64, f64, f64, f64, f64)> { - if self.is_plain_pauli_except_angle() && self.resolved_angle_scaling().is_none() { + let emission_off = + self.p1_emission_ratio == Some(0.0) && self.p2_emission_ratio == Some(0.0); + if self.is_plain_pauli_except_angle_and_emission() + && self.resolved_angle_scaling().is_none() + && emission_off + { Some(self.resolved_base_probabilities()) } else { None @@ -778,23 +796,31 @@ impl GeneralNoiseModelBuilder { /// Like [`Self::simple_probabilities`], but ALSO permits angle-dependent /// two-qubit scaling and returns it alongside the base probabilities. /// - /// Returns `(p_prep, p_meas_0, p_meas_1, p1, p2, angle)` where `angle` is - /// `Some((a, b, c, d, power))` when any `p2_angle_*` parameter is - /// configured (the unset components take their model defaults), and `None` - /// otherwise. This lets other simulation stacks translate the common - /// "plain Pauli, plus optional angle-dependent two-qubit gate noise" - /// configuration — the angle-dependent error rate is + /// Returns `(p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission, + /// p2_emission)` where `angle` is `Some((a, b, c, d, power))` when any + /// `p2_angle_*` parameter is configured (the unset components take their + /// model defaults), and `None` otherwise. This lets other simulation stacks + /// translate the common "plain Pauli, plus optional angle-dependent + /// two-qubit gate noise" configuration — the angle-dependent error rate is /// `p2 * (coeff * |theta/pi|^power + offset)` with separate /// `(a, b)` for negative and `(c, d)` for positive angles /// (see [`GeneralNoiseModel::p2_angle_error_rate`]). /// - /// All the non-angle feature requirements of [`Self::simple_probabilities`] - /// still apply (leakage, emission, idle, crosstalk, scales, custom - /// samplers, and noiseless gates must be off). + /// The two `*_emission` ratios are the resolved spontaneous-emission + /// fractions (unset components take the model default). Emission is + /// gate-removing in both engines and neo, so a downstream stack can + /// reproduce it exactly by carrying these ratios with the default uniform + /// emission distribution. + /// + /// All the OTHER non-angle feature requirements of + /// [`Self::simple_probabilities`] still apply (leakage, idle, crosstalk, + /// scales, custom samplers including custom emission distributions, and + /// noiseless gates must be off). #[must_use] pub fn pauli_with_angle_scaling(&self) -> Option { - if self.is_plain_pauli_except_angle() { + if self.is_plain_pauli_except_angle_and_emission() { let (p_prep, p_meas_0, p_meas_1, p1, p2) = self.resolved_base_probabilities(); + let (p1_emission, p2_emission) = self.resolved_emission_ratios(); Some(( p_prep, p_meas_0, @@ -802,6 +828,8 @@ impl GeneralNoiseModelBuilder { p1, p2, self.resolved_angle_scaling(), + p1_emission, + p2_emission, )) } else { None @@ -809,20 +837,24 @@ impl GeneralNoiseModelBuilder { } /// True when every non-Pauli feature is off EXCEPT possibly the - /// angle-dependent two-qubit scaling. Shared by `simple_probabilities` - /// (which additionally requires the angle scaling to be unset) and - /// `pauli_with_angle_scaling` (which extracts it). - fn is_plain_pauli_except_angle(&self) -> bool { + /// angle-dependent two-qubit scaling and the spontaneous-emission ratios. + /// Shared by `simple_probabilities` (which additionally requires both the + /// angle scaling unset and emission explicitly off) and + /// `pauli_with_angle_scaling` (which extracts them). The emission DISTRIBUTION + /// must still be the default uniform model (`p1/p2_emission_model` unset) -- + /// custom emission samplers are NOT in this subset. + fn is_plain_pauli_except_angle_and_emission(&self) -> bool { let explicitly_zero = |v: Option| v == Some(0.0); let zero_or_unset = |v: Option| v.is_none() || v == Some(0.0); let one_or_unset = |v: Option| v.is_none() || v == Some(1.0); // Non-neutral model defaults: unset means the default applies, so // these must be explicitly zeroed for the physics to be plain Pauli. - let defaulted_features_off = explicitly_zero(self.p1_emission_ratio) - && explicitly_zero(self.p2_emission_ratio) - && explicitly_zero(self.p_prep_leak_ratio) - && explicitly_zero(self.p_idle_linear_rate); + // (Emission ratios are intentionally NOT required off here -- they are + // handled separately, since neo now matches engines' gate-removing + // emission with the default uniform distribution.) + let defaulted_features_off = + explicitly_zero(self.p_prep_leak_ratio) && explicitly_zero(self.p_idle_linear_rate); // Neutral model defaults: unset is fine. let optional_features_off = zero_or_unset(self.p_idle_quadratic_rate) @@ -892,6 +924,20 @@ impl GeneralNoiseModelBuilder { Some((a, b, c, d, power)) } + /// The resolved `(p1, p2)` spontaneous-emission ratios, taking the model + /// default (read from `GeneralNoiseModel::default()`) for unset values. + /// Only meaningful alongside the default uniform emission distribution + /// (enforced by [`Self::is_plain_pauli_except_angle_and_emission`]). + fn resolved_emission_ratios(&self) -> (f64, f64) { + let default = GeneralNoiseModel::default(); + ( + self.p1_emission_ratio + .unwrap_or_else(|| default.p1_emission_ratio()), + self.p2_emission_ratio + .unwrap_or_else(|| default.p2_emission_ratio()), + ) + } + // scaling // ========================================================================================== // @@ -1047,11 +1093,13 @@ mod tests { .with_p_idle_linear_rate(0.0); let simple = builder.simple_probabilities().expect("simple config"); - let (p_prep, p_meas_0, p_meas_1, p1, p2, angle) = builder + let (p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission, p2_emission) = builder .pauli_with_angle_scaling() .expect("simple config is also pauli-with-angle"); assert_eq!((p_prep, p_meas_0, p_meas_1, p1, p2), simple); assert!(angle.is_none()); + // Emission was explicitly zeroed to land in the strict simple subset. + assert_eq!((p1_emission, p2_emission), (0.0, 0.0)); } /// Setting angle parameters keeps the config out of the strict simple @@ -1075,7 +1123,7 @@ mod tests { // Angle scaling is outside the STRICT simple subset. assert!(builder.simple_probabilities().is_none()); - let (_, _, _, _, p2, angle) = builder + let (_, _, _, _, p2, angle, _, _) = builder .pauli_with_angle_scaling() .expect("plain Pauli plus angle is in the angle-aware subset"); assert!((p2 - 0.3).abs() < 1e-12); @@ -1097,7 +1145,7 @@ mod tests { .with_meas_0_probability(0.0) .with_meas_1_probability(0.0); - let (_, _, _, _, _, angle) = builder + let (_, _, _, _, _, angle, _, _) = builder .pauli_with_angle_scaling() .expect("angle-aware subset"); let default_power = GeneralNoiseModel::default().p2_angle_power(); @@ -1108,10 +1156,35 @@ mod tests { /// angle configured. #[test] fn pauli_with_angle_scaling_rejects_non_angle_features() { - // Default emission ratio (0.5) is left in place -> beyond the subset. + // Prep-leakage and linear idling keep their (non-zero) model defaults + // because they are never explicitly zeroed -> beyond the subset. + // (Emission ratios are NOT a blocker -- they are part of the subset.) let builder = GeneralNoiseModelBuilder::new() .with_p2_probability(0.3) .with_p2_angle_params(1.5, 0.0, 1.0, 0.0); assert!(builder.pauli_with_angle_scaling().is_none()); } + + /// Emission ratios are surfaced verbatim when they are in the subset, so a + /// downstream stack can reproduce engines' gate-removing emission channel. + #[test] + fn pauli_with_angle_scaling_extracts_emission_ratios() { + let builder = GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .with_average_p2_probability(0.4) + .with_p1_emission_ratio(0.25) + .with_p2_emission_ratio(0.75) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + + // Non-zero emission is OUTSIDE the strict simple subset... + assert!(builder.simple_probabilities().is_none()); + // ...but inside the angle-aware subset, with the ratios surfaced. + let (.., angle, p1_emission, p2_emission) = builder + .pauli_with_angle_scaling() + .expect("plain Pauli plus emission is in the subset"); + assert!(angle.is_none()); + assert!((p1_emission - 0.25).abs() < 1e-12); + assert!((p2_emission - 0.75).abs() < 1e-12); + } } diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index c9d2f0c25..f98f573a8 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -44,9 +44,12 @@ pub enum SimStack { /// The translated noise surface is the depolarizing family /// (`PassThroughNoise`, `DepolarizingNoise`, `BiasedDepolarizingNoise`, /// and their builders) and the `GeneralNoiseModel` simple-probability - /// subset, including angle-dependent two-qubit scaling. Other noise - /// configurations, explicit `.classical()`, and explicit `.quantum()` - /// are not yet translated and are rejected with an error at `run()`. + /// subset, including angle-dependent two-qubit scaling and the + /// gate-removing spontaneous-emission ratios (with the default uniform + /// emission distribution). Other noise configurations (leakage, idle, + /// crosstalk, custom emission distributions, ...), explicit + /// `.classical()`, and explicit `.quantum()` are not yet translated and + /// are rejected with an error at `run()`. Neo, } @@ -398,25 +401,34 @@ fn map_noise_to_neo( if let Some(builder) = noise.downcast_ref::() { // The stored p1/p2 are already in standard depolarizing convention // (the with_average_* setters convert on the way in), so they map - // one-to-one onto neo's builder. Angle-dependent two-qubit scaling, if - // present, is translated below; everything else outside the simple - // Pauli subset is still rejected. - let Some((p_prep, p_meas_0, p_meas_1, p1, p2, angle)) = builder.pauli_with_angle_scaling() + // one-to-one onto neo's builder. Angle-dependent two-qubit scaling and + // the spontaneous-emission ratios, if present, are translated below; + // everything else outside the simple Pauli subset is still rejected. + let Some((p_prep, p_meas_0, p_meas_1, p1, p2, angle, p1_emission, p2_emission)) = + builder.pauli_with_angle_scaling() else { return Err(PecosError::Input( "This GeneralNoiseModel configuration uses features beyond the simple \ - probability subset (leakage, emission, seepage, idle, crosstalk, scales, \ - or noiseless gates), which are not yet mapped to the neo stack. Use \ - .stack(SimStack::Engines) or configure sim_neo() directly with a neo \ - noise model." + probability subset (leakage, seepage, idle, crosstalk, scales, custom \ + emission distributions, or noiseless gates), which are not yet mapped to \ + the neo stack. Use .stack(SimStack::Engines) or configure sim_neo() \ + directly with a neo noise model." .to_string(), )); }; + // Emission is gate-removing in both stacks with the default uniform + // emission distribution, so carrying the resolved ratios reproduces it + // exactly. The ratios are set unconditionally (with the engines-resolved + // values, defaults included) so neo cannot silently fall back to its own + // default emission fraction. (Locked by the facade emission differential + // test in `neo_emission_test.rs`.) let mut neo_builder = GeneralNoiseModelBuilder::new() .with_p_prep(p_prep) .with_p_meas(p_meas_0, p_meas_1) .with_p1(p1) - .with_p2(p2); + .with_p2(p2) + .with_p1_emission_ratio(p1_emission) + .with_p2_emission_ratio(p2_emission); if let Some((a, b, c, d, power)) = angle { // Engines' angle-dependent two-qubit error rate is // p2 * (a*|theta/pi|^power + b) for theta < 0 diff --git a/crates/pecos/tests/neo_emission_test.rs b/crates/pecos/tests/neo_emission_test.rs index 8fb096e26..7194918a4 100644 --- a/crates/pecos/tests/neo_emission_test.rs +++ b/crates/pecos/tests/neo_emission_test.rs @@ -14,8 +14,11 @@ //! //! Engines models spontaneous emission as REPLACING the gate (the gate is //! dropped on an emission fault). neo now matches this by undoing the gate -//! (applying its dagger) before the emission error. The facade does not map -//! emission yet, so neo is configured directly via `sim_neo`. +//! (applying its dagger) before the emission error. Each test pins three +//! configurations against the gate-removing analytic and against one another: +//! engines, neo configured DIRECTLY via `sim_neo`, and neo reached through the +//! `sim().stack(Neo)` FACADE (which now maps the engines emission ratios onto +//! neo's builder). //! //! Analytic for `x; measure` with uniform Pauli and emission weights, gate //! error `p1` and emission ratio `e`: an emission fault (probability `p1*e`) @@ -55,11 +58,12 @@ fn rate_zero(shots: &pecos_engines::shot_results::ShotVec) -> (u64, f64) { (zeros, zeros as f64 / SHOTS as f64) } -/// Engines with a `GeneralNoiseModel`: gate error `p1`, emission ratio +/// The single-qubit emission noise: gate error `p1`, emission ratio /// `EMISSION`, everything else (prep, meas, p2, leakage, idle) zeroed so the -/// only physics is the single-qubit emission/Pauli channel on the X gate. -fn engines_zero_count() -> u64 { - let noise = pecos_engines::noise::GeneralNoiseModel::builder() +/// only physics is the single-qubit emission/Pauli channel on the X gate. A +/// fresh builder each call since `.noise()` consumes it. +fn emission_noise_1q() -> pecos_engines::noise::GeneralNoiseModelBuilder { + pecos_engines::noise::GeneralNoiseModel::builder() .with_p1_probability(P1) .with_p1_emission_ratio(EMISSION) .with_p2_probability(0.0) @@ -67,16 +71,31 @@ fn engines_zero_count() -> u64 { .with_meas_0_probability(0.0) .with_meas_1_probability(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_p_idle_linear_rate(0.0) +} + +fn engines_zero_count() -> u64 { let results = sim(Qasm::from_string(X_MEASURE)) .stack(SimStack::Engines) - .noise(noise) + .noise(emission_noise_1q()) .seed(42) .run(SHOTS) .expect("engines run"); rate_zero(&results).0 } +/// neo reached through the `sim().stack(Neo)` FACADE with the SAME engines +/// `GeneralNoiseModel`: the facade maps the emission ratio onto neo's builder. +fn neo_facade_zero_count() -> u64 { + let results = sim(Qasm::from_string(X_MEASURE)) + .stack(SimStack::Neo) + .noise(emission_noise_1q()) + .seed(7) // independent seed; agreement must be physical + .run(SHOTS) + .expect("neo facade run"); + rate_zero(&results).0 +} + /// neo configured directly (the facade does not map emission yet): the /// `GeneralNoiseModelBuilder` mirrors the same single-qubit emission channel. fn neo_zero_count() -> u64 { @@ -105,30 +124,45 @@ fn emission_is_gate_removing_and_matches_engines() { let engines = engines_zero_count(); let neo = neo_zero_count(); + let facade = neo_facade_zero_count(); let engines_ci = jeffreys_interval(engines, SHOTS as u64, CONFIDENCE); let neo_ci = jeffreys_interval(neo, SHOTS as u64, CONFIDENCE); + let facade_ci = jeffreys_interval(facade, SHOTS as u64, CONFIDENCE); println!( - "emission: engines {engines}/{SHOTS} CI [{:.4}, {:.4}], neo {neo}/{SHOTS} CI [{:.4}, {:.4}], \ - gate-removing analytic {analytic:.4} (gate-preserving would be {:.4})", + "emission: engines {engines}/{SHOTS} CI [{:.4}, {:.4}], neo-direct {neo}/{SHOTS} CI \ + [{:.4}, {:.4}], neo-facade {facade}/{SHOTS} CI [{:.4}, {:.4}], gate-removing analytic \ + {analytic:.4} (gate-preserving would be {:.4})", engines_ci.0, engines_ci.1, neo_ci.0, neo_ci.1, + facade_ci.0, + facade_ci.1, P1 * 2.0 / 3.0 ); - // Both stacks contain the gate-REMOVING analytic (0.15), proving the gate - // is dropped on emission; the gate-PRESERVING value (0.2) is excluded. - for (name, ci) in [("engines", engines_ci), ("neo", neo_ci)] { + // All three configurations contain the gate-REMOVING analytic (0.15), + // proving the gate is dropped on emission; the gate-PRESERVING value (0.2) + // is excluded. The facade route additionally proves the engines->neo + // emission-ratio mapping is wired through `sim().stack(Neo)`. + for (name, ci) in [ + ("engines", engines_ci), + ("neo-direct", neo_ci), + ("neo-facade", facade_ci), + ] { assert!( ci.0 <= analytic && analytic <= ci.1, "{name} P(0) excludes the gate-removing analytic {analytic}" ); } - // And the two stacks agree. + // And every pair of stacks agrees. assert!( engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, - "engines and neo emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" + "engines and neo-direct emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" + ); + assert!( + engines_ci.0 <= facade_ci.1 && facade_ci.0 <= engines_ci.1, + "engines and neo-facade emission rates disagree: {engines}/{SHOTS} vs {facade}/{SHOTS}" ); } @@ -151,8 +185,8 @@ const CX_MEASURE: &str = r#" measure q[1] -> c[0]; "#; -fn engines_2q_zero_count() -> u64 { - let noise = pecos_engines::noise::GeneralNoiseModel::builder() +fn emission_noise_2q() -> pecos_engines::noise::GeneralNoiseModelBuilder { + pecos_engines::noise::GeneralNoiseModel::builder() .with_p1_probability(0.0) .with_p2_probability(P2) .with_p2_emission_ratio(1.0) @@ -160,16 +194,29 @@ fn engines_2q_zero_count() -> u64 { .with_meas_0_probability(0.0) .with_meas_1_probability(0.0) .with_prep_leak_ratio(0.0) - .with_p_idle_linear_rate(0.0); + .with_p_idle_linear_rate(0.0) +} + +fn engines_2q_zero_count() -> u64 { let results = sim(Qasm::from_string(CX_MEASURE)) .stack(SimStack::Engines) - .noise(noise) + .noise(emission_noise_2q()) .seed(42) .run(SHOTS) .expect("engines run"); rate_zero(&results).0 } +fn neo_facade_2q_zero_count() -> u64 { + let results = sim(Qasm::from_string(CX_MEASURE)) + .stack(SimStack::Neo) + .noise(emission_noise_2q()) + .seed(7) + .run(SHOTS) + .expect("neo facade run"); + rate_zero(&results).0 +} + fn neo_2q_zero_count() -> u64 { use pecos_neo::noise::GeneralNoiseModelBuilder; use pecos_neo::tool::{monte_carlo, sim_neo}; @@ -196,19 +243,28 @@ fn two_qubit_emission_is_gate_removing_and_matches_engines() { let engines = engines_2q_zero_count(); let neo = neo_2q_zero_count(); + let facade = neo_facade_2q_zero_count(); let engines_ci = jeffreys_interval(engines, SHOTS as u64, CONFIDENCE); let neo_ci = jeffreys_interval(neo, SHOTS as u64, CONFIDENCE); + let facade_ci = jeffreys_interval(facade, SHOTS as u64, CONFIDENCE); println!( - "2q emission: engines {engines}/{SHOTS} CI [{:.4}, {:.4}], neo {neo}/{SHOTS} CI \ - [{:.4}, {:.4}], gate-removing analytic {analytic:.4} (gate-preserving would be {:.4})", + "2q emission: engines {engines}/{SHOTS} CI [{:.4}, {:.4}], neo-direct {neo}/{SHOTS} CI \ + [{:.4}, {:.4}], neo-facade {facade}/{SHOTS} CI [{:.4}, {:.4}], gate-removing analytic \ + {analytic:.4} (gate-preserving would be {:.4})", engines_ci.0, engines_ci.1, neo_ci.0, neo_ci.1, + facade_ci.0, + facade_ci.1, P2 * 8.0 / 15.0 ); - for (name, ci) in [("engines", engines_ci), ("neo", neo_ci)] { + for (name, ci) in [ + ("engines", engines_ci), + ("neo-direct", neo_ci), + ("neo-facade", facade_ci), + ] { assert!( ci.0 <= analytic && analytic <= ci.1, "{name} P(q1=0) excludes the gate-removing analytic {analytic}" @@ -216,6 +272,10 @@ fn two_qubit_emission_is_gate_removing_and_matches_engines() { } assert!( engines_ci.0 <= neo_ci.1 && neo_ci.0 <= engines_ci.1, - "engines and neo 2q emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" + "engines and neo-direct 2q emission rates disagree: {engines}/{SHOTS} vs {neo}/{SHOTS}" + ); + assert!( + engines_ci.0 <= facade_ci.1 && facade_ci.0 <= engines_ci.1, + "engines and neo-facade 2q emission rates disagree: {engines}/{SHOTS} vs {facade}/{SHOTS}" ); } diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index f59825009..144604aaa 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -310,10 +310,11 @@ fn neo_stack_general_noise_average_convention_matches() { #[test] fn neo_stack_rejects_unmapped_noise() { - // A bare GeneralNoiseModel keeps its realistic defaults (emission - // ratio 0.5, prep leak 0.5, idle 0.001) — physics beyond the simple - // Pauli subset, so the mapping must refuse rather than silently - // change the model. + // A bare GeneralNoiseModel keeps its realistic defaults for prep leak + // (0.5) and linear idling (0.001) — physics beyond the simple Pauli + // subset, so the mapping must refuse rather than silently change the + // model. (Spontaneous emission IS now mapped, so it is the prep-leak + // and idle defaults that force the rejection here.) let general = pecos_engines::noise::GeneralNoiseModel::builder().with_average_p1_probability(0.01); let err = sim(deterministic_conditional_qasm()) From d62721defb586569f177fcf8fb52cbf86278790e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 09:10:32 -0600 Subject: [PATCH 211/388] Apply rustfmt to the ObsMask/wide-decode changes and pre-existing re-export drift; just fmt clean --- crates/pecos-decoder-core/src/logical_subgraph.rs | 1 - crates/pecos-decoder-core/src/obs_mask.rs | 2 +- crates/pecos-decoders/src/lib.rs | 8 +++----- .../pecos-qec/src/fault_tolerance/dem_builder/sampler.rs | 6 +++++- crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs | 2 +- python/pecos-rslib/src/fault_tolerance_bindings.rs | 8 ++------ 6 files changed, 12 insertions(+), 15 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 8e0b90fd1..5da515a07 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -340,7 +340,6 @@ pub fn subgraphs_from_membership( sdem: &SparseDem, membership: &[Vec], ) -> Result, DecoderError> { - let mut subgraphs = Vec::with_capacity(membership.len()); for (obs_idx, detectors) in membership.iter().enumerate() { diff --git a/crates/pecos-decoder-core/src/obs_mask.rs b/crates/pecos-decoder-core/src/obs_mask.rs index 7a86d0705..44576ccbf 100644 --- a/crates/pecos-decoder-core/src/obs_mask.rs +++ b/crates/pecos-decoder-core/src/obs_mask.rs @@ -26,7 +26,7 @@ //! storage, so `ObsMask::from_u64(0)`, `ObsMask::new()`, and a two-word `[5, 0]` //! vs one-word `[5]` all compare as expected. -use smallvec::{smallvec, SmallVec}; +use smallvec::{SmallVec, smallvec}; use std::ops::BitXorAssign; const WORD_BITS: usize = u64::BITS as usize; diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 1b7ad8b46..31b60cf46 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -22,9 +22,8 @@ pub use pecos_decoder_core::{ // Re-export observable subgraph decoder (for transversal gates) pub use pecos_decoder_core::logical_subgraph::{ - DetectorGroup, LogicalSubgraph, LogicalSubgraphDecoder, - ParallelLogicalSubgraphDecoder, QubitStabCoords, StabCoords, StabType, - partition_dem_by_logical, + DetectorGroup, LogicalSubgraph, LogicalSubgraphDecoder, ParallelLogicalSubgraphDecoder, + QubitStabCoords, StabCoords, StabType, partition_dem_by_logical, }; // Re-export LDPC decoders when feature is enabled @@ -103,8 +102,7 @@ pub use pecos_uf_decoder::{ AStarConfig, AStarDecoder, BeamSearchConfig, BeamSearchWindowedDecoder, BpSchedule as UfBpSchedule, BpUfConfig, BpUfDecoder, CssUfDecoder, OverlappingWindowedDecoder, QubitEdgeMapping, SandwichWindowedDecoder, StreamingWindowedDecoder, UfDecoder, - UfDecoderConfig, WindowedConfig, WindowedDecoder, - WindowedLogicalSubgraphDecoder, + UfDecoderConfig, WindowedConfig, WindowedDecoder, WindowedLogicalSubgraphDecoder, }; // Re-export Relay BP decoder when feature is enabled diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 2e0d9f596..3ce4f36c0 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -760,7 +760,11 @@ impl DemSampler { /// [`Self::observable_dem_output_mask`] (validated to fit a `u64`); pass it /// in so the per-shot path neither recomputes it nor needs to re-validate. #[must_use] - pub fn observable_mask_from_dem_output_flips(&self, flips: &[bool], observable_mask: u64) -> u64 { + pub fn observable_mask_from_dem_output_flips( + &self, + flips: &[bool], + observable_mask: u64, + ) -> u64 { flips .iter() .enumerate() diff --git a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs index 6fd4a4eb2..c5c071879 100644 --- a/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs +++ b/crates/pecos-uf-decoder/src/logical_subgraph_windowed.rs @@ -35,11 +35,11 @@ use pecos_decoder_core::ObservableDecoder; use pecos_decoder_core::dem::DemMatchingGraph; use pecos_decoder_core::errors::DecoderError; -use pecos_decoder_core::obs_mask::ObsMask; use pecos_decoder_core::logical_subgraph::window_plan::LogicalSubgraphWindowPlan; use pecos_decoder_core::logical_subgraph::{ MaxTimeRadius, StabCoords, partition_dem_by_logical_windowed, }; +use pecos_decoder_core::obs_mask::ObsMask; use crate::decoder::{UfDecoder, UfDecoderConfig}; use crate::windowed::{OverlappingWindowedDecoder, WindowedConfig}; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 2d48389c3..4df5164f7 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -5071,9 +5071,7 @@ impl PyLogicalAlgorithmDecoder { // Parse stab_coords from the first segment (original orientation) let first_seg = seg_list.first().ok_or_else(|| { - PyErr::new::( - "algorithm descriptor has no segments", - ) + PyErr::new::("algorithm descriptor has no segments") })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? @@ -5326,9 +5324,7 @@ impl PyLogicalCircuitDecoder { // Parse stab_coords from first segment let first_seg = seg_list.first().ok_or_else(|| { - PyErr::new::( - "algorithm descriptor has no segments", - ) + PyErr::new::("algorithm descriptor has no segments") })?; let sc_list: Vec> = first_seg .get_item("stab_coords")? From 6faa8d7c839839c8fa5992039bedd6760fee57d7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 09:39:49 -0600 Subject: [PATCH 212/388] Fix clippy cast_possible_wrap in the >64-observable sampler test (i64::try_from instead of as i64) --- crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 3ce4f36c0..41c486e71 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -1946,8 +1946,12 @@ mod tests { circuit.mz(&[i]); } circuit.set_attr("num_measurements", Attribute::String(n.to_string())); + let n_i64 = i64::try_from(n).unwrap(); let obs: Vec = (0..n) - .map(|i| format!(r#"{{"id":{i},"records":[{}]}}"#, i as i64 - n as i64)) + .map(|i| { + let rec = i64::try_from(i).unwrap() - n_i64; + format!(r#"{{"id":{i},"records":[{rec}]}}"#) + }) .collect(); circuit.set_attr( "observables", From 949b2a853fafdf53e7bfd4dba4b2512e4eab6cc7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 10:20:35 -0600 Subject: [PATCH 213/388] Apply black formatting + ruff E741 rename across touched Python files (pre-merge lint pass) --- examples/surface/inner_decoder_study.py | 39 ++++++++++------- .../results/inner_decoder_study_report.md | 1 - .../src/pecos/qec/reliable_observables.py | 9 ++-- .../tests/qec/surface/test_circuit_fuzz.py | 7 ++- ...test_logical_subgraph_lomatching_parity.py | 12 ++---- ...test_logical_subgraph_region_comparison.py | 43 ++++++++----------- .../tests/qec/test_wide_observables.py | 8 ++-- 7 files changed, 58 insertions(+), 61 deletions(-) diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py index e51a69123..79288552e 100644 --- a/examples/surface/inner_decoder_study.py +++ b/examples/surface/inner_decoder_study.py @@ -41,7 +41,6 @@ import argparse import json -import math import time from dataclasses import asdict, dataclass from pathlib import Path @@ -187,7 +186,7 @@ def measure_cell( ler=wrong / n, build_seconds=t1 - t0, decode_seconds=t2 - t1, - ) + ), ) return cells @@ -222,7 +221,14 @@ def run_suppress(path: Path) -> None: done = {(c.family, c.distance, c.p, c.seed, c.inner) for c in _load(path)} plan = [ ("memory", [3, 5, 7], [0.002, 0.003, 0.005], CANDIDATES, [1, 2, 3], 100_000), - ("cx", [3, 5, 7], [0.002, 0.003, 0.005], ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"], [1, 2, 3], 50_000), + ( + "cx", + [3, 5, 7], + [0.002, 0.003, 0.005], + ["fusion_blossom_serial", "pecos_uf:bp", "belief_matching"], + [1, 2, 3], + 50_000, + ), ] for family, ds, ps, inners, seeds, n in plan: for d in ds: @@ -433,8 +439,10 @@ def w(s: str = "") -> None: rlo, rhi = jeffreys_ci(rk, rn) sep = "DISJOINT" if bhi < rlo else "overlap" ratio = (rk / rn) / (bk / bn) if bk else float("inf") - w(f"- d={d}: best **{bi}** {bk / bn:.2e} vs {bench} {rk / rn:.2e} " - f"({ratio:.1f}x) -- Jeffreys intervals {sep}") + w( + f"- d={d}: best **{bi}** {bk / bn:.2e} vs {bench} {rk / rn:.2e} " + f"({ratio:.1f}x) -- Jeffreys intervals {sep}", + ) w() # Suppression check + exponent per inner (pooled across seeds). w(f"### {fam}: suppression exponent (LER ~ (p/p_th)^((d+1)/2))") @@ -446,16 +454,14 @@ def w(s: str = "") -> None: if len(seq) < 2: continue suppresses = all( - seq[i + 1][1][0] / seq[i + 1][1][1] < seq[i][1][0] / seq[i][1][1] - for i in range(len(seq) - 1) + seq[i + 1][1][0] / seq[i + 1][1][1] < seq[i][1][0] / seq[i][1][1] for i in range(len(seq) - 1) ) ratios = [ (seq[i][1][0] / seq[i][1][1]) / (seq[i + 1][1][0] / seq[i + 1][1][1]) for i in range(len(seq) - 1) ] tag = "suppresses" if suppresses else "NOT monotone" - w(f"- p={p} {inner}: {tag}; per-step LER ratio " - + ", ".join(f"{r:.1f}x" for r in ratios)) + w(f"- p={p} {inner}: {tag}; per-step LER ratio " + ", ".join(f"{r:.1f}x" for r in ratios)) w() if thr: @@ -486,8 +492,10 @@ def w(s: str = "") -> None: cross = p break w() - w(f"- threshold estimate (d={d_hi} stops beating d={d_lo}): " - + (f"~{cross}" if cross else f"above {ps[-1]} (not reached)")) + w( + f"- threshold estimate (d={d_hi} stops beating d={d_lo}): " + + (f"~{cross}" if cross else f"above {ps[-1]} (not reached)"), + ) w() if spd: @@ -497,8 +505,7 @@ def w(s: str = "") -> None: w("|---|---|---:|---:|---:|") for c in sorted(spd, key=lambda c: (c.family, c.decode_seconds)): us = c.decode_seconds / c.num_shots * 1e6 - w(f"| {c.family} | {c.inner} | {c.build_seconds * 1e3:.1f} | " - f"{c.decode_seconds:.2f} | {us:.1f} |") + w(f"| {c.family} | {c.inner} | {c.build_seconds * 1e3:.1f} | {c.decode_seconds:.2f} | {us:.1f} |") w() return "\n".join(lines) @@ -521,8 +528,10 @@ def main() -> None: cells = measure_cell("memory", 3, 3, 0.005, 1, ["fusion_blossom_serial", "pecos_uf:bp"], 2000) for c in cells: lo, hi = jeffreys_ci(c.num_errors, c.num_shots) - print(f"smoke {c.inner}: {c.num_errors}/{c.num_shots} ler={c.ler:.4f} " - f"CI=[{lo:.4f},{hi:.4f}] build={c.build_seconds * 1e3:.1f}ms decode={c.decode_seconds:.3f}s") + print( + f"smoke {c.inner}: {c.num_errors}/{c.num_shots} ler={c.ler:.4f} " + f"CI=[{lo:.4f},{hi:.4f}] build={c.build_seconds * 1e3:.1f}ms decode={c.decode_seconds:.3f}s", + ) cx = measure_cell("cx", 3, 3, 0.005, 1, ["fusion_blossom_serial"], 2000) print(f"smoke cx: {cx[0].num_errors}/{cx[0].num_shots} ler={cx[0].ler:.4f}") return diff --git a/examples/surface/results/inner_decoder_study_report.md b/examples/surface/results/inner_decoder_study_report.md index 0925e703d..82f40a73d 100644 --- a/examples/surface/results/inner_decoder_study_report.md +++ b/examples/surface/results/inner_decoder_study_report.md @@ -180,4 +180,3 @@ binomial per (family, d, p, inner). `k/n` = failures / shots. | memory | belief_matching | 27.7 | 24.13 | 482.5 | | memory | tesseract | 16.9 | 44.44 | 888.8 | | memory | pecos_uf:bp | 26.3 | 49.61 | 992.2 | - diff --git a/python/quantum-pecos/src/pecos/qec/reliable_observables.py b/python/quantum-pecos/src/pecos/qec/reliable_observables.py index f8d9433b4..7bf5b4723 100644 --- a/python/quantum-pecos/src/pecos/qec/reliable_observables.py +++ b/python/quantum-pecos/src/pecos/qec/reliable_observables.py @@ -147,12 +147,15 @@ def _observing_region(circuit: stim.Circuit, observable: int) -> PauliRegion: continue new_circuit.append( stim.CircuitInstruction( - name="OBSERVABLE_INCLUDE", gate_args=[0], targets=instr.targets_copy() - ) + name="OBSERVABLE_INCLUDE", + gate_args=[0], + targets=instr.targets_copy(), + ), ) target = stim.DemTarget("L0") regions = new_circuit.detecting_regions( - targets=[target], ignore_anticommutation_errors=True + targets=[target], + ignore_anticommutation_errors=True, ) return regions.get(target, {}) diff --git a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py index 88bd2c0a9..e8fd46fb6 100644 --- a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py +++ b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py @@ -852,12 +852,15 @@ def test_logical_subgraph_better_than_naive_on_cx(self, patch, nq): subgraph_errors = sum( 1 for i in range(20000) - if decoder.decode(det_events[i].tolist()) != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) + if decoder.decode(det_events[i].tolist()) + != sum((1 << j) for j in range(obs_flips.shape[1]) if obs_flips[i, j]) ) subgraph_ler = subgraph_errors / 20000 # logical-subgraph decoder should be at least as good (usually much better) - assert subgraph_ler <= naive_ler * 1.5 + 0.001, f"logical-subgraph decoder ({subgraph_ler:.5f}) much worse than naive ({naive_ler:.5f})" + assert ( + subgraph_ler <= naive_ler * 1.5 + 0.001 + ), f"logical-subgraph decoder ({subgraph_ler:.5f}) much worse than naive ({naive_ler:.5f})" # --------------------------------------------------------------------------- diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py index 4975b2ca9..ae41cff8c 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_lomatching_parity.py @@ -44,9 +44,7 @@ def _lomatching_reference_membership(dem_str: str, stab_coords) -> list[list[int """ dem = stim.DetectorErrorModel(dem_str).flattened() - det_to_coords = { - d: tuple(map(float, c)) for d, c in dem.get_detector_coordinates().items() - } + det_to_coords = {d: tuple(map(float, c)) for d, c in dem.get_detector_coordinates().items()} coords_to_det = {c: d for d, c in det_to_coords.items()} # spatial coord (all but the trailing time element) -> (logical qubit, stab type) @@ -68,9 +66,7 @@ def _lomatching_reference_membership(dem_str: str, stab_coords) -> list[list[int bd_edges_obs[o] += dets # (logical qubit, stab type, time) seeds for each observable - lst_obs: dict[int, set[tuple[int, str, float]]] = { - o: set() for o in range(dem.num_observables) - } + lst_obs: dict[int, set[tuple[int, str, float]]] = {o: set() for o in range(dem.num_observables)} for obs, dets in bd_edges_obs.items(): for det in dets: coords = det_to_coords[det] @@ -94,9 +90,7 @@ def _assert_parity(dem_str: str, stab_coords) -> None: pecos = [sorted(r) for r in decoder.observing_regions()] reference = _lomatching_reference_membership(dem_str, stab_coords) - assert len(pecos) == len(reference), ( - f"observable count mismatch: PECOS {len(pecos)} vs lomatching {len(reference)}" - ) + assert len(pecos) == len(reference), f"observable count mismatch: PECOS {len(pecos)} vs lomatching {len(reference)}" for obs, (p, r) in enumerate(zip(pecos, reference, strict=True)): assert p == r, f"observable {obs} membership differs:\n PECOS={p}\n lomatching={r}" diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index 80f97001e..28394cb96 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -82,8 +82,7 @@ def _groupfill_membership(dem_str: str, stab_coords, *, seed_all: bool) -> list[ elif ln.startswith("error("): toks = ln[ln.index(")") + 1 :].split() mechs.append( - ([int(t[1:]) for t in toks if t.startswith("D")], - [int(t[1:]) for t in toks if t.startswith("L")]), + ([int(t[1:]) for t in toks if t.startswith("D")], [int(t[1:]) for t in toks if t.startswith("L")]), ) coords_to_stab: dict[tuple, tuple[int, str]] = {} @@ -165,14 +164,12 @@ def test_coordinate_region_beats_raw_backprop_region(): # The coordinate group-fill region is dramatically better. The gap is large # and stable (~20x at d=3, p=0.001); assert a conservative margin. - assert coord_ler < backprop_ler, ( - f"expected coordinate region to beat raw back-prop: " - f"coord={coord_ler:.5f} backprop={backprop_ler:.5f}" - ) - assert coord_ler * 5 < backprop_ler, ( - f"expected a large gap (group-fill essential): " - f"coord={coord_ler:.5f} backprop={backprop_ler:.5f}" - ) + assert ( + coord_ler < backprop_ler + ), f"expected coordinate region to beat raw back-prop: coord={coord_ler:.5f} backprop={backprop_ler:.5f}" + assert ( + coord_ler * 5 < backprop_ler + ), f"expected a large gap (group-fill essential): coord={coord_ler:.5f} backprop={backprop_ler:.5f}" def test_coordinate_seeding_reproduces_shipping_path(): @@ -211,9 +208,7 @@ def test_coordinate_beats_backprop_seeded_groupfill(): backprop = LogicalSubgraphDecoder.from_membership(dem, backprop_membership, "pecos_uf:fast") coord_ler = coord.decode_count(batch) / n backprop_ler = backprop.decode_count(batch) / n - assert coord_ler < backprop_ler, ( - f"coord={coord_ler:.5f} backprop-seeds={backprop_ler:.5f}" - ) + assert coord_ler < backprop_ler, f"coord={coord_ler:.5f} backprop-seeds={backprop_ler:.5f}" def _mem_ler(d, p, n, seed, inner=None): @@ -237,9 +232,7 @@ def test_distance_suppression_memory(): ler_d3 = _mem_ler(3, p, n, seed=1) ler_d5 = _mem_ler(5, p, n, seed=1) # Below threshold, d=5 must beat d=3 by a clear margin (lomatching: ~13x). - assert ler_d5 < ler_d3 * 0.7, ( - f"no distance suppression with default inner: d3={ler_d3:.5f} d5={ler_d5:.5f}" - ) + assert ler_d5 < ler_d3 * 0.7, f"no distance suppression with default inner: d3={ler_d3:.5f} d5={ler_d5:.5f}" def test_native_bp_uf_inner_suppresses(): @@ -265,9 +258,7 @@ def test_native_bp_uf_inner_suppresses(): uf_d3 = _mem_ler(3, p, n, seed=1, inner="pecos_uf:bp") uf_d5 = _mem_ler(5, p, n, seed=1, inner="pecos_uf:bp") # Below threshold, d=5 must beat d=3 by a clear margin. - assert uf_d5 < uf_d3 * 0.7, ( - f"default bp+uf inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" - ) + assert uf_d5 < uf_d3 * 0.7, f"default bp+uf inner no longer suppresses: d3={uf_d3:.5f} d5={uf_d5:.5f}" def _mem_dem_batch(d, p, n, seed): @@ -348,9 +339,10 @@ def test_windowed_logical_subgraph_single_window_matches_nonwindowed(): non = _nonwindowed_mem_ler(d, rounds, p, n, seed=7, inner="pecos_uf:fast") assert nwin == 1, f"expected a single window, got {nwin}" # Same decode up to negligible tie-breaking differences. - assert abs(win - non) <= max(0.0005, 0.15 * non), ( - f"single-window windowed != non-windowed at d={d}: win={win:.5f} non={non:.5f}" - ) + assert abs(win - non) <= max( + 0.0005, + 0.15 * non, + ), f"single-window windowed != non-windowed at d={d}: win={win:.5f} non={non:.5f}" def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): @@ -395,10 +387,9 @@ def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): # Guard against regressing to the old catastrophic anti-suppression (the # naive-XOR decoder reached ~0.1-0.25 here); the core-commit rewrite keeps it # well below that across distances. - assert max(ler_d3, ler_d5, ler_d7) < 0.1, ( - f"windowed LER regressed toward catastrophic: " - f"d3={ler_d3:.5f} d5={ler_d5:.5f} d7={ler_d7:.5f}" - ) + assert ( + max(ler_d3, ler_d5, ler_d7) < 0.1 + ), f"windowed LER regressed toward catastrophic: d3={ler_d3:.5f} d5={ler_d5:.5f} d7={ler_d7:.5f}" def test_decode_each_matches_decode_count(): diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index 5b08d06d7..de89b00b8 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -15,11 +15,9 @@ def _wide_dem(n: int) -> tuple[str, list[list[int]]]: - """A DEM with ``n`` observables, each on its own detector ``D_l``/``L_l``.""" - dem = "".join(f"detector({l},0,0) D{l}\n" for l in range(n)) + "".join( - f"error(0.1) D{l} L{l}\n" for l in range(n) - ) - membership = [[l] for l in range(n)] + """A DEM with ``n`` observables, each on its own detector ``D_k``/``L_k``.""" + dem = "".join(f"detector({k},0,0) D{k}\n" for k in range(n)) + "".join(f"error(0.1) D{k} L{k}\n" for k in range(n)) + membership = [[k] for k in range(n)] return dem, membership From 8bb6eda67801fad6f4fa16e73dd2eb3a9e6af996 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 10:32:47 -0600 Subject: [PATCH 214/388] pecos-phir-pliron M4: adaptive multi-batch measurement-conditioned control through real HybridEngine --- exp/pecos-phir-pliron/src/main.rs | 257 ++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) diff --git a/exp/pecos-phir-pliron/src/main.rs b/exp/pecos-phir-pliron/src/main.rs index b2629f9b0..a330a774f 100644 --- a/exp/pecos-phir-pliron/src/main.rs +++ b/exp/pecos-phir-pliron/src/main.rs @@ -15,6 +15,7 @@ use std::collections::{BTreeSet, HashMap}; use awint::bw; use pecos_engines::byte_message::ByteMessage; +use pecos_engines::hybrid::builder::HybridEngineBuilder; use pecos_engines::quantum::StateVecEngine; use pecos_engines::{ClassicalEngine, ControlEngine, Data, Engine, EngineStage, PecosError, Shot}; use pliron::{ @@ -202,6 +203,27 @@ impl Verify for MeasureOp { } } +/// `qec.cond_x(cond: i1, target: qubitref)` -- apply X to `target` iff the measurement result +/// `cond` is 1. The measurement-conditioned gate that forces a classical decision between batches. +#[pliron_op(name = "qec.cond_x", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] +pub struct CondXOp; +impl CondXOp { + pub fn new(ctx: &mut Context, cond: Value, target: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![cond, target], vec![], 0); + CondXOp { op } + } +} +impl Verify for CondXOp { + fn verify(&self, ctx: &Context) -> Result<()> { + // target (operand 1) must be a qubitref; the condition (operand 0) is a measurement-result + // i1 by construction (constructing the i1 type needs &mut Context, unavailable in verify). + if self.get_operation().deref(ctx).get_operand(1).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.cond_x target must be a qec.qubitref"); + } + Ok(()) + } +} + #[pliron_op(name = "qec.end", format, interfaces = [IsTerminatorInterface, NOpdsInterface<0>, NResultsInterface<0>], verifier = "succ")] pub struct EndOp; impl EndOp { @@ -499,9 +521,244 @@ fn run_milestone_3() { run_and_check("milestone-3 bell.ll -> pliron qec -> sim", msg, 200); } +// ===================== Milestone 4: adaptive, multi-batch, measurement-conditioned ===================== +// Program: prepare q0,q1; h q0; m0 = measure q0; cond_x(m0, q1); mf1 = measure q1. +// The engine must send batch1 (gates + mz q0), get m0, make a CLASSICAL decision, then send a +// SECOND batch (conditionally x q1, then mz q1). Invariant: mf1 == m0 (q1 flips iff m0==1) -- which +// only holds if the measurement-conditioned feedback works across the two quantum batches. + +#[derive(Clone, Copy)] +enum Cmd { + Pz(usize), + H(usize), + Cx(usize, usize), + Mz(usize), +} + +#[derive(Clone)] +struct AdaptivePlan { + batch1: Vec, // gates + the conditioning Mz + cond_outcome_idx: usize, // index in batch1's mz-outcomes that gates cond_x + cond_target: usize, // qubit to X iff that outcome == 1 + batch2: Vec, // post-condition ops (final Mz) +} + +/// Walk a `qec` block once and split it into the two-batch adaptive plan at the `cond_x` boundary. +/// Qubit identity comes only from the explicit slot-index map (same discipline as the Bell port). +fn plan_from_ir(ctx: &Context, block: Ptr) -> AdaptivePlan { + let mut qubit_of: HashMap = HashMap::new(); + let (mut batch1, mut batch2): (Vec, Vec) = (Vec::new(), Vec::new()); + let mut after_cond = false; + let mut mz_b1 = 0usize; + let (mut cond_outcome_idx, mut cond_target) = (0usize, 0usize); + let ops: Vec> = block.deref(ctx).iter(ctx).collect(); + for op in ops { + if let Some(s) = Operation::get_op::(op, ctx) { + qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); + } else if let Some(p) = Operation::get_op::(op, ctx) { + let q = qubit_of[&p.get_operation().deref(ctx).get_operand(0)]; + if after_cond { batch2.push(Cmd::Pz(q)) } else { batch1.push(Cmd::Pz(q)) } + } else if let Some(h) = Operation::get_op::(op, ctx) { + let q = qubit_of[&h.get_operation().deref(ctx).get_operand(0)]; + if after_cond { batch2.push(Cmd::H(q)) } else { batch1.push(Cmd::H(q)) } + } else if let Some(cx) = Operation::get_op::(op, ctx) { + let opn = cx.get_operation(); + let c = qubit_of[&opn.deref(ctx).get_operand(0)]; + let t = qubit_of[&opn.deref(ctx).get_operand(1)]; + if after_cond { batch2.push(Cmd::Cx(c, t)) } else { batch1.push(Cmd::Cx(c, t)) } + } else if let Some(m) = Operation::get_op::(op, ctx) { + let q = qubit_of[&m.get_operation().deref(ctx).get_operand(0)]; + if after_cond { + batch2.push(Cmd::Mz(q)); + } else { + cond_outcome_idx = mz_b1; + mz_b1 += 1; + batch1.push(Cmd::Mz(q)); + } + } else if let Some(c) = Operation::get_op::(op, ctx) { + cond_target = qubit_of[&c.get_operation().deref(ctx).get_operand(1)]; + after_cond = true; + } + } + AdaptivePlan { batch1, cond_outcome_idx, cond_target, batch2 } +} + +fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: &[Cmd]) { + for c in cmds { + match *c { + Cmd::Pz(q) => { b.pz(&[q]); } + Cmd::H(q) => { b.h(&[q]); } + Cmd::Cx(c0, t) => { b.cx(&[(c0, t)]); } + Cmd::Mz(q) => { b.mz(&[q]); } + } + } +} + +#[derive(Clone)] +struct PlironAdaptiveEngine { + plan: AdaptivePlan, + stage: u8, // 0 fresh, 1 batch1 sent, 2 batch2 sent + b1: Vec, + b2: Vec, +} + +impl PlironAdaptiveEngine { + fn batch1_msg(&self) -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + emit_cmds(&mut b, &self.plan.batch1); + b.build() + } + fn batch2_msg(&self) -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + // the classical decision: apply X iff the conditioning measurement was 1 + if self.b1.get(self.plan.cond_outcome_idx).copied() == Some(1) { + b.x(&[self.plan.cond_target]); + } + emit_cmds(&mut b, &self.plan.batch2); + b.build() + } +} + +impl Engine for PlironAdaptiveEngine { + type Input = (); + type Output = Shot; + fn process(&mut self, _i: ()) -> std::result::Result { + self.get_results() + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + self.stage = 0; + self.b1.clear(); + self.b2.clear(); + Ok(()) + } +} + +impl ClassicalEngine for PlironAdaptiveEngine { + fn num_qubits(&self) -> usize { + 2 + } + fn generate_commands(&mut self) -> std::result::Result { + if self.stage == 0 { + self.stage = 1; + Ok(self.batch1_msg()) + } else { + Ok(ByteMessage::create_empty()) + } + } + fn handle_measurements(&mut self, m: ByteMessage) -> std::result::Result<(), PecosError> { + let o = m.outcomes()?; + if self.stage == 1 { self.b1 = o } else { self.b2 = o } + Ok(()) + } + fn get_results(&self) -> std::result::Result { + let mut shot = Shot::default(); + shot.add_register("mid", self.b1.first().copied().unwrap_or(0), 1); + shot.add_register("final", self.b2.first().copied().unwrap_or(0), 1); + Ok(shot) + } + fn compile(&self) -> std::result::Result<(), PecosError> { + Ok(()) + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } +} + +impl ControlEngine for PlironAdaptiveEngine { + type Input = (); + type Output = Shot; + type EngineInput = ByteMessage; + type EngineOutput = ByteMessage; + fn start(&mut self, _i: ()) -> std::result::Result, PecosError> { + self.stage = 0; + self.b1.clear(); + self.b2.clear(); + self.stage = 1; + Ok(EngineStage::NeedsProcessing(self.batch1_msg())) + } + fn continue_processing(&mut self, meas: ByteMessage) -> std::result::Result, PecosError> { + if self.stage == 1 { + self.b1 = meas.outcomes()?; + self.stage = 2; + Ok(EngineStage::NeedsProcessing(self.batch2_msg())) + } else { + self.b2 = meas.outcomes()?; + Ok(EngineStage::Complete(self.get_results()?)) + } + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } +} + +fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { + let module = ModuleOp::new(ctx, "adaptive".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + } + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let s0 = push!(SlotOp::new(ctx, qv, 0)); + let s0v = s0.get_result(ctx); + let s1 = push!(SlotOp::new(ctx, qv, 1)); + let s1v = s1.get_result(ctx); + push!(PrepareOp::new(ctx, s0v)); + push!(PrepareOp::new(ctx, s1v)); + push!(HOp::new(ctx, s0v)); + let m0 = push!(MeasureOp::new(ctx, s0v)); + let m0v = m0.get_result(ctx); + push!(CondXOp::new(ctx, m0v, s1v)); // X q1 iff m0 == 1 + push!(MeasureOp::new(ctx, s1v)); + push!(EndOp::new(ctx)); + (module, bb) +} + +fn run_milestone_4() { + let ctx = &mut Context::new(); + let (module, bb) = build_adaptive_ir(ctx); + println!("=== adaptive (mid-measure -> cond_x -> final) pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-4 verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_ir(ctx, bb); + let engine = PlironAdaptiveEngine { plan, stage: 0, b1: Vec::new(), b2: Vec::new() }; + + // Drive through the REAL HybridEngine this time (closes the "wrapper unrun" gap from round 3). + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + + let (mut all_eq, mut saw_mid0, mut saw_mid1) = (true, false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let mid = shot.data.get("mid").and_then(Data::as_u32).expect("mid"); + let fin = shot.data.get("final").and_then(Data::as_u32).expect("final"); + if fin != mid { + all_eq = false; + } + saw_mid0 |= mid == 0; + saw_mid1 |= mid == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(all_eq, "milestone-4: final must equal mid in every shot (measurement-conditioned X feedback)"); + assert!(saw_mid0 && saw_mid1, "milestone-4: expected both mid=0 and mid=1 over 200 shots"); + println!("[milestone-4 adaptive multi-batch via HybridEngine] OK -- final==mid in all 200 shots, saw mid 0 and 1"); +} + fn main() { run_and_check("milestone-0 hand-built Bell", bell_message(), 200); run_milestone_1(); run_milestone_2(); run_milestone_3(); + run_milestone_4(); } From 6baa8b3d84ea802e08fa8d8cf81bd613d9ccc0a1 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 10:49:09 -0600 Subject: [PATCH 215/388] Complete >64 observable support across all Python decoder entry points (LogicalCircuit/Algorithm/Windowed decode big-ints + wide decode_count), widen the pecos-qec sampler truth path and SampleBatch big-int constructor (ObsMask BitAndAssign + py_to_obsmask), fix example-script ruff nits --- .../src/logical_algorithm.rs | 12 ++ crates/pecos-decoder-core/src/obs_mask.rs | 28 ++- .../fault_tolerance/dem_builder/sampler.rs | 118 ++++------- examples/surface/inner_decoder_study.py | 10 +- .../src/fault_tolerance_bindings.rs | 190 +++++++++++------- .../tests/qec/test_wide_observables.py | 24 ++- 6 files changed, 219 insertions(+), 163 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index 883f75cb9..5367aebf6 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -354,6 +354,18 @@ impl StreamingLogicalDecoder { Ok(obs) } + /// Wide variant of [`Self::flush`]: returns an [`ObsMask`] supporting more + /// than 64 observables (no truncation). + pub fn flush_obs(&mut self) -> Result { + self.inner.decode_obs(&self.syndrome) + } + + /// Wide variant of [`Self::decode_shot`]: feed + flush as an [`ObsMask`]. + pub fn decode_shot_obs(&mut self, syndrome: &[u8]) -> Result { + self.feed_dense(syndrome); + self.flush_obs() + } + /// Decode a full syndrome at once (convenience for batch mode). pub fn decode_shot(&mut self, syndrome: &[u8]) -> Result { self.feed_dense(syndrome); diff --git a/crates/pecos-decoder-core/src/obs_mask.rs b/crates/pecos-decoder-core/src/obs_mask.rs index 44576ccbf..0e10f6aea 100644 --- a/crates/pecos-decoder-core/src/obs_mask.rs +++ b/crates/pecos-decoder-core/src/obs_mask.rs @@ -27,7 +27,7 @@ //! vs one-word `[5]` all compare as expected. use smallvec::{SmallVec, smallvec}; -use std::ops::BitXorAssign; +use std::ops::{BitAndAssign, BitXorAssign}; const WORD_BITS: usize = u64::BITS as usize; @@ -140,6 +140,16 @@ impl BitXorAssign<&ObsMask> for ObsMask { } } +impl BitAndAssign<&ObsMask> for ObsMask { + fn bitand_assign(&mut self, rhs: &ObsMask) { + // Keep only bits set in both; words beyond `rhs` become zero (rhs has no + // bits there). Value equality ignores the resulting trailing zero words. + for (i, w) in self.words.iter_mut().enumerate() { + *w &= rhs.words.get(i).copied().unwrap_or(0); + } + } +} + impl PartialEq for ObsMask { fn eq(&self, other: &Self) -> bool { // Value equality: compare every word, treating missing words as zero, so @@ -226,6 +236,22 @@ mod tests { assert!(w.is_zero()); } + #[test] + fn and_restricts_to_common_bits() { + let mut a = ObsMask::new(); + a.set(1); + a.set(64); + a.set(130); + let mut mask = ObsMask::new(); + mask.set(1); + mask.set(64); // mask omits 130 + a &= &mask; + assert!(a.get(1)); + assert!(a.get(64)); + assert!(!a.get(130), "bit outside the mask is cleared"); + assert_eq!(a.count_ones(), 2); + } + #[test] fn equality_ignores_trailing_zero_words() { assert_eq!(ObsMask::new(), ObsMask::from_u64(0)); diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs index 41c486e71..9738ac7af 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -39,6 +39,7 @@ use super::dem_sampler::SamplingEngine; use super::types::{DemOutput, NoiseConfig, PerGateTypeNoise}; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, DemOutputKind}; use pecos_core::prelude::GateType; +use pecos_decoder_core::obs_mask::ObsMask; use pecos_num::z2_linalg::z2_rank_from_records; use pecos_random::RngProbabilityExt; use rand_core::Rng; @@ -145,44 +146,6 @@ impl std::fmt::Display for TrackedPauliSamplingError { impl std::error::Error for TrackedPauliSamplingError {} -/// Error returned when a sampler's observables do not fit the `u64` decoder mask. -/// -/// Decoder-facing observable masks are packed into a `u64` (bit `i` = observable -/// `i`), so observable id `>= 64` cannot be represented. Rather than silently -/// dropping those observables from the truth mask (which would under-count -/// logical failures), the sampler errors. Lifting this limit is the -/// wider-observable-mask follow-up (see the `ObsMask` plan). -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ObservableMaskCapacityError { - num_observables: usize, -} - -impl ObservableMaskCapacityError { - fn new(num_observables: usize) -> Self { - Self { num_observables } - } - - /// Number of observables the sampler carries (exceeds the 64-bit capacity). - #[must_use] - pub fn num_observables(&self) -> usize { - self.num_observables - } -} - -impl std::fmt::Display for ObservableMaskCapacityError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "sampler has {} observables, but decoder observable masks are packed \ - into a u64 and support at most 64. Observable ids >= 64 cannot be \ - represented; refusing to silently drop them.", - self.num_observables - ) - } -} - -impl std::error::Error for ObservableMaskCapacityError {} - /// Output mode for the unified sampler. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OutputMode { @@ -735,43 +698,38 @@ impl DemSampler { /// Bit mask selecting observable outputs. /// - /// Bitmask of which DEM outputs are decoder-facing observables. - /// - /// # Errors + /// Wide bitmask of which DEM outputs are decoder-facing observables. /// - /// Returns [`ObservableMaskCapacityError`] if any observable id is `>= 64`: - /// decoder observable masks are packed into a `u64`, so such ids are not - /// representable. Erroring (rather than silently dropping them) keeps the - /// logical-failure count correct. Compute this once, up front, and pass the - /// result to [`Self::observable_mask_from_dem_output_flips`] per shot. - pub fn observable_dem_output_mask(&self) -> Result { - let ids = self.observable_ids(); - if let Some(&max_id) = ids.iter().max() - && max_id >= u64::BITS as usize - { - return Err(ObservableMaskCapacityError::new(ids.len())); + /// Returns an [`ObsMask`], so more than 64 observables are represented with + /// no truncation. Compute this once, up front, and pass it to + /// [`Self::observable_mask_from_dem_output_flips`] per shot. + #[must_use] + pub fn observable_dem_output_mask(&self) -> ObsMask { + let mut mask = ObsMask::new(); + for idx in self.observable_ids() { + mask.set(idx); } - Ok(ids.into_iter().fold(0u64, |acc, idx| acc | (1u64 << idx))) + mask } - /// Converts a sampled DEM-output flip vector into an observable-only mask. + /// Converts a sampled DEM-output flip vector into an observable-only wide mask. /// /// `observable_mask` is the value returned by - /// [`Self::observable_dem_output_mask`] (validated to fit a `u64`); pass it - /// in so the per-shot path neither recomputes it nor needs to re-validate. + /// [`Self::observable_dem_output_mask`]; pass it in so the per-shot path does + /// not recompute it. #[must_use] pub fn observable_mask_from_dem_output_flips( &self, flips: &[bool], - observable_mask: u64, - ) -> u64 { - flips - .iter() - .enumerate() - .filter(|(idx, flipped)| { - **flipped && *idx < u64::BITS as usize && (observable_mask & (1u64 << *idx)) != 0 - }) - .fold(0u64, |acc, (idx, _)| acc | (1u64 << idx)) + observable_mask: &ObsMask, + ) -> ObsMask { + let mut mask = ObsMask::new(); + for (idx, flipped) in flips.iter().enumerate() { + if *flipped && observable_mask.get(idx) { + mask.set(idx); + } + } + mask } /// Number of mechanisms in the sampler. @@ -1930,10 +1888,9 @@ mod tests { } #[test] - fn observable_dem_output_mask_errors_above_64_observables() { - // >64 observables cannot be packed into a u64 mask. The sampler must - // ERROR rather than silently dropping observables with id >= 64 (which - // would under-count logical failures). Pins the Phase-0 fail-loud fix. + fn observable_dem_output_mask_supports_above_64_observables() { + // >64 observables are now represented in a wide ObsMask with no + // truncation (the old u64 cap is lifted; observable 64 is present). use super::super::builder::DemBuilder; use pecos_quantum::Attribute; @@ -1962,10 +1919,10 @@ mod tests { let sampler = DemSampler::from_detector_error_model(&dem); assert_eq!(sampler.num_dem_outputs(), n); - let err = sampler - .observable_dem_output_mask() - .expect_err("65 observables must not fit a u64 mask"); - assert_eq!(err.num_observables(), n); + let mask = sampler.observable_dem_output_mask(); + assert_eq!(mask.count_ones(), u32::try_from(n).unwrap()); + assert!(mask.get(64), "observable 64 must be representable"); + assert_eq!(mask.to_u64(), None, "65 observables do not fit a u64"); } #[test] @@ -2015,15 +1972,16 @@ mod tests { .num_tracked_paulis(), 1 ); - let obs_mask = sampler.observable_dem_output_mask().unwrap(); - assert_eq!(obs_mask, 1); - assert_eq!( - sampler.observable_mask_from_dem_output_flips(&[false], obs_mask), - 0 + let obs_mask = sampler.observable_dem_output_mask(); + assert_eq!(obs_mask, ObsMask::from_u64(1)); + assert!( + sampler + .observable_mask_from_dem_output_flips(&[false], &obs_mask) + .is_zero() ); assert_eq!( - sampler.observable_mask_from_dem_output_flips(&[true], obs_mask), - 1 + sampler.observable_mask_from_dem_output_flips(&[true], &obs_mask), + ObsMask::from_u64(1) ); } diff --git a/examples/surface/inner_decoder_study.py b/examples/surface/inner_decoder_study.py index 79288552e..9e6ba9a84 100644 --- a/examples/surface/inner_decoder_study.py +++ b/examples/surface/inner_decoder_study.py @@ -201,11 +201,7 @@ def _append(path: Path, cells: list[Cell]) -> None: def _load(path: Path) -> list[Cell]: if not path.exists(): return [] - out = [] - for line in path.read_text().splitlines(): - if line.strip(): - out.append(Cell(**json.loads(line))) - return out + return [Cell(**json.loads(line)) for line in path.read_text().splitlines() if line.strip()] # --------------------------------------------------------------------------- # @@ -435,8 +431,8 @@ def w(s: str = "") -> None: if (fam, d, p, bench) not in agg or bi == bench: continue rk, rn = agg[(fam, d, p, bench)] - blo, bhi = jeffreys_ci(bk, bn) - rlo, rhi = jeffreys_ci(rk, rn) + _blo, bhi = jeffreys_ci(bk, bn) + rlo, _rhi = jeffreys_ci(rk, rn) sep = "DISJOINT" if bhi < rlo else "overlap" ratio = (rk / rn) / (bk / bn) if bk else float("inf") w( diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 4df5164f7..18cf07c1b 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2656,8 +2656,12 @@ impl PySampleBatch { } } - /// Build from row-major data (from Python constructor). - fn from_row_major(detection_events: Vec>, observable_masks: Vec) -> Self { + /// Build from row-major data (from Python constructor). Observable masks are + /// wide [`ObsMask`]es, so more than 64 observables are stored without loss. + fn from_row_major( + detection_events: Vec>, + observable_masks: &[pecos_decoder_core::obs_mask::ObsMask], + ) -> Self { let num_shots = detection_events.len(); let num_detectors = detection_events.first().map_or(0, Vec::len); let num_words = num_shots.div_ceil(64); @@ -2674,20 +2678,19 @@ impl PySampleBatch { } } - // Find max observable index + // One observable column per observable index; sized to the highest set + // bit across all shots (supports >64 observables). let max_obs = observable_masks .iter() - .map(|m| 64 - m.leading_zeros() as usize) + .filter_map(|m| m.iter_set_bits().max()) .max() - .unwrap_or(0); + .map_or(0, |b| b + 1); let mut obs_columns = vec![vec![0u64; num_words]; max_obs]; - for (shot, &mask) in observable_masks.iter().enumerate() { + for (shot, mask) in observable_masks.iter().enumerate() { let word_idx = shot / 64; let bit_mask = 1u64 << (shot % 64); - for (obs_idx, obs_column) in obs_columns.iter_mut().enumerate().take(max_obs) { - if mask & (1u64 << obs_idx) != 0 { - obs_column[word_idx] |= bit_mask; - } + for obs_idx in mask.iter_set_bits() { + obs_columns[obs_idx][word_idx] |= bit_mask; } } @@ -2706,10 +2709,15 @@ impl PySampleBatch { /// /// Args: /// detection_events: List of syndromes, each a list of u8 (0/1). - /// observable_masks: List of u64 true observable flip masks. + /// observable_masks: List of true observable flip masks as Python ints + /// (arbitrary precision; bit ``i`` = observable ``i``, so more than 64 + /// observables are supported). #[new] #[pyo3(signature = (detection_events, observable_masks))] - fn new(detection_events: Vec>, observable_masks: Vec) -> PyResult { + fn new( + detection_events: Vec>, + observable_masks: Vec>, + ) -> PyResult { if detection_events.len() != observable_masks.len() { return Err(pyo3::exceptions::PyValueError::new_err(format!( "detection_events ({}) and observable_masks ({}) must have same length", @@ -2727,7 +2735,11 @@ impl PySampleBatch { ))); } } - Ok(Self::from_row_major(detection_events, observable_masks)) + let masks: Vec = observable_masks + .iter() + .map(py_to_obsmask) + .collect::>()?; + Ok(Self::from_row_major(detection_events, &masks)) } /// Number of shots in this batch. @@ -3640,10 +3652,7 @@ impl PyDemSampler { let mut rng = PecosRng::seed_from_u64(actual_seed); let mut decoder = create_observable_decoder(dem, decoder_type)?; - let observable_mask = self - .inner - .observable_dem_output_mask() - .map_err(|e| PyErr::new::(e.to_string()))?; + let observable_mask = self.inner.observable_dem_output_mask(); // Tight sample+decode loop -- no Python involvement. // Single-threaded: sample and decode sequentially. @@ -3651,11 +3660,14 @@ impl PyDemSampler { for _ in 0..num_shots { let (det_events, obs_flips) = self.inner.sample(&mut rng); let syndrome: Vec = det_events.iter().map(|&b| u8::from(b)).collect(); - let predicted_mask = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); + let mut predicted = decoder + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + predicted &= &observable_mask; let true_mask = self .inner - .observable_mask_from_dem_output_flips(&obs_flips, observable_mask); - if (predicted_mask & observable_mask) != true_mask { + .observable_mask_from_dem_output_flips(&obs_flips, &observable_mask); + if predicted != true_mask { errors += 1; } } @@ -3702,9 +3714,7 @@ impl PyDemSampler { let remainder = num_shots % n_workers; let sampler = &self.inner; - let observable_mask = sampler - .observable_dem_output_mask() - .map_err(|e| PyErr::new::(e.to_string()))?; + let observable_mask = sampler.observable_dem_output_mask(); let dem_str = dem.to_string(); let dt = decoder_type.to_string(); @@ -3729,11 +3739,13 @@ impl PyDemSampler { for _ in 0..my_shots { let (det_events, obs_flips) = my_sampler.sample(&mut my_rng); let syndrome: Vec = det_events.iter().map(|&b| u8::from(b)).collect(); - let predicted = - decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); + let mut predicted = decoder + .decode_obs(&syndrome) + .unwrap_or_else(|_| observable_mask.clone()); + predicted &= &observable_mask; let truth = my_sampler - .observable_mask_from_dem_output_flips(&obs_flips, observable_mask); - if (predicted & observable_mask) != truth { + .observable_mask_from_dem_output_flips(&obs_flips, &observable_mask); + if predicted != truth { errors += 1; } } @@ -4514,6 +4526,29 @@ fn obsmask_to_py( .unbind()) } +/// Convert a Python integer (arbitrary precision) to a wide observable mask. +/// +/// Inverse of [`obsmask_to_py`]: reads the int's little-endian bytes and packs +/// them into `u64` words, so observable indices >= 64 are preserved. +fn py_to_obsmask( + value: &pyo3::Bound<'_, pyo3::PyAny>, +) -> PyResult { + let bit_length: usize = value.call_method0("bit_length")?.extract()?; + let nbytes = bit_length.div_ceil(8).max(1); + let bytes: Vec = value + .call_method1("to_bytes", (nbytes, "little"))? + .extract()?; + let words: Vec = bytes + .chunks(8) + .map(|chunk| { + let mut buf = [0u8; 8]; + buf[..chunk.len()].copy_from_slice(chunk); + u64::from_le_bytes(buf) + }) + .collect(); + Ok(pecos_decoder_core::obs_mask::ObsMask::from_words(&words)) +} + #[pyclass(name = "LogicalSubgraphDecoder", module = "pecos_rslib.qec")] pub struct PyLogicalSubgraphDecoder { inner: pecos_decoder_core::logical_subgraph::LogicalSubgraphDecoder, @@ -4961,11 +4996,13 @@ impl PyWindowedLogicalSubgraphDecoder { Ok(Self { inner }) } - fn decode(&mut self, syndrome: Vec) -> PyResult { + fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { use pecos_decoder_core::ObservableDecoder; - self.inner - .decode_to_observables(&syndrome) - .map_err(|e| PyErr::new::(e.to_string())) + let mask = self + .inner + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + obsmask_to_py(py, &mask) } fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { @@ -4976,9 +5013,9 @@ impl PyWindowedLogicalSubgraphDecoder { batch.extract_syndrome(i, &mut syndrome); let predicted = self .inner - .decode_to_observables(&syndrome) + .decode_obs(&syndrome) .map_err(|e| PyErr::new::(e.to_string()))?; - if predicted != batch.extract_obs_mask(i) { + if predicted != batch.extract_obs_mask_wide(i) { errors += 1; } } @@ -5184,32 +5221,33 @@ impl PyLogicalAlgorithmDecoder { // -- Batch mode -- - /// Decode a single syndrome and return observable flip mask. - fn decode(&mut self, syndrome: Vec) -> PyResult { + /// Decode a single syndrome and return the observable flip mask as a Python + /// ``int`` (arbitrary precision; more than 64 observables are not truncated). + fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { self.inner.reset(); - self.inner - .decode_shot(&syndrome) - .map_err(|e| PyErr::new::(e.to_string())) + let mask = self + .inner + .decode_shot_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + obsmask_to_py(py, &mask) } - /// Decode a batch of samples and count logical errors. + /// Decode a batch of samples and count logical errors (wide observable masks). fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { - let detection_events: Vec> = (0..batch.num_shots) - .map(|i| { - let mut s = vec![0u8; batch.num_detectors]; - batch.extract_syndrome(i, &mut s); - s - }) - .collect(); - let observable_masks: Vec = (0..batch.num_shots) - .map(|i| batch.extract_obs_mask(i)) - .collect(); - pecos_decoder_core::logical_algorithm::streaming_decode_count( - &mut self.inner, - &detection_events, - &observable_masks, - ) - .map_err(|e| PyErr::new::(e.to_string())) + let mut errors = 0usize; + let mut syndrome = vec![0u8; batch.num_detectors]; + for i in 0..batch.num_shots { + batch.extract_syndrome(i, &mut syndrome); + self.inner.reset(); + let predicted = self + .inner + .decode_shot_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + if predicted != batch.extract_obs_mask_wide(i) { + errors += 1; + } + } + Ok(errors) } // -- Streaming mode -- @@ -5589,29 +5627,33 @@ impl PyLogicalCircuitDecoder { self.can_window } - /// Decode a single syndrome. - fn decode(&mut self, syndrome: Vec) -> PyResult { + /// Decode a single syndrome. Returns a Python ``int`` (arbitrary precision; + /// more than 64 observables are not truncated). + fn decode(&mut self, py: Python<'_>, syndrome: Vec) -> PyResult> { use pecos_decoder_core::ObservableDecoder; - self.inner - .decode_to_observables(&syndrome) - .map_err(|e| PyErr::new::(e.to_string())) + let mask = self + .inner + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + obsmask_to_py(py, &mask) } - /// Decode a batch and count errors. + /// Decode a batch and count errors (wide observable masks). fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { - let detection_events: Vec> = (0..batch.num_shots) - .map(|i| { - let mut s = vec![0u8; batch.num_detectors]; - batch.extract_syndrome(i, &mut s); - s - }) - .collect(); - let observable_masks: Vec = (0..batch.num_shots) - .map(|i| batch.extract_obs_mask(i)) - .collect(); - self.inner - .decode_count(&detection_events, &observable_masks) - .map_err(|e| PyErr::new::(e.to_string())) + use pecos_decoder_core::ObservableDecoder; + let mut errors = 0usize; + let mut syndrome = vec![0u8; batch.num_detectors]; + for i in 0..batch.num_shots { + batch.extract_syndrome(i, &mut syndrome); + let predicted = self + .inner + .decode_obs(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + if predicted != batch.extract_obs_mask_wide(i) { + errors += 1; + } + } + Ok(errors) } /// Number of segments. diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index de89b00b8..7cc153729 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -11,7 +11,7 @@ from __future__ import annotations -from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem +from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem, SampleBatch def _wide_dem(n: int) -> tuple[str, list[list[int]]]: @@ -55,6 +55,28 @@ def test_decode_batch_above_64_observables() -> None: assert results[1] == 1 +def test_sample_batch_big_int_truth_masks() -> None: + # The SampleBatch constructor accepts arbitrary-precision Python ints as + # observable masks (bit 64 = observable 64), and decode_count compares the + # wide truth mask against the wide prediction with no truncation. + n = 65 + dem, membership = _wide_dem(n) + dec = LogicalSubgraphDecoder.from_membership(dem, membership, "pecos_uf:fast") + + # Two shots, each flipping detector 64 (so the decoder predicts observable 64). + syn = [0] * n + syn[64] = 1 + detection_events = [syn, syn] + + # Truth that MATCHES the prediction (obs 64) -> zero logical errors. + matching = SampleBatch(detection_events, [1 << 64, 1 << 64]) + assert dec.decode_count(matching) == 0 + + # Truth that MISMATCHES (obs 0, not 64) -> both shots are errors. + mismatching = SampleBatch(detection_events, [1, 1]) + assert dec.decode_count(mismatching) == 2 + + def test_decode_count_above_64_observables() -> None: # The wide compare path (predicted vs truth ObsMask) must run end-to-end on a # >64-observable batch without erroring or truncating. From 2cd76e8eef8f781ef54d7b87949823258e7480b2 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 10:55:02 -0600 Subject: [PATCH 216/388] pecos-phir-pliron M5+M6: region-based qec.if + literal qprog.ll port (rotations + CFG-lift) via real HybridEngine --- exp/pecos-phir-pliron/src/main.rs | 534 +++++++++++++++++++++++++++++- 1 file changed, 532 insertions(+), 2 deletions(-) diff --git a/exp/pecos-phir-pliron/src/main.rs b/exp/pecos-phir-pliron/src/main.rs index a330a774f..0bff52e3c 100644 --- a/exp/pecos-phir-pliron/src/main.rs +++ b/exp/pecos-phir-pliron/src/main.rs @@ -14,6 +14,7 @@ use std::any::Any; use std::collections::{BTreeSet, HashMap}; use awint::bw; +use pecos_core::Angle64; use pecos_engines::byte_message::ByteMessage; use pecos_engines::hybrid::builder::HybridEngineBuilder; use pecos_engines::quantum::StateVecEngine; @@ -22,8 +23,8 @@ use pliron::{ builtin::{ attributes::IntegerAttr, op_interfaces::{ - IsTerminatorInterface, NOpdsInterface, NResultsInterface, OneResultInterface, - SingleBlockRegionInterface, + IsTerminatorInterface, NOpdsInterface, NRegionsInterface, NResultsInterface, + NoTerminatorInterface, OneResultInterface, SingleBlockRegionInterface, }, ops::{FuncOp, ModuleOp}, types::{FunctionType, IntegerType, Signedness}, @@ -95,6 +96,24 @@ mod slot_attr { dict_key!(INDEX, "qec_slot_index"); } +mod angle_attr { + use pliron::dict_key; + dict_key!(BITS, "qec_angle_bits"); +} + +/// Store an f64 angle on an op as the bit pattern in an IntegerAttr (pliron has no float attr here). +fn set_angle(ctx: &mut Context, op: Ptr, theta: f64) { + let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless); + let attr = IntegerAttr::new(i64_ty, APInt::from_u64(theta.to_bits(), bw(64))); + op.deref_mut(ctx).attributes.0.insert(angle_attr::BITS.clone(), Box::new(attr)); +} +fn get_angle(ctx: &Context, op: Ptr) -> f64 { + let o = op.deref(ctx); + let a: AttrObj = o.attributes.0.get(&*angle_attr::BITS).expect("angle attr").clone(); + let ia = a.downcast::().unwrap_or_else(|_| panic!("angle not IntegerAttr")); + f64::from_bits(Into::::into(*ia).to_u64()) +} + #[pliron_op(name = "qec.qalloc", format, interfaces = [NOpdsInterface<0>, OneResultInterface, NResultsInterface<1>], verifier = "succ")] pub struct QallocOp; impl QallocOp { @@ -224,6 +243,80 @@ impl Verify for CondXOp { } } +/// `qec.x(qubitref)` -- plain Pauli-X (used inside `qec.if` region blocks). +#[pliron_op(name = "qec.x", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct XOp; +impl XOp { + pub fn new(ctx: &mut Context, qref: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![qref], vec![], 0); + XOp { op } + } +} +impl Verify for XOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.x operand must be a qec.qubitref"); + } + Ok(()) + } +} + +/// `qec.if(cond: i1) { then } { else }` -- region-based conditional control flow (vision-aligned; +/// no CFG flattening). Two single-block regions of qec ops, no terminators. +#[pliron_op(name = "qec.if", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>, NRegionsInterface<2>, SingleBlockRegionInterface, NoTerminatorInterface], verifier = "succ")] +pub struct IfOp; +impl IfOp { + pub fn new(ctx: &mut Context, cond: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![cond], vec![], 2); + IfOp { op } + } + /// Create + attach a fresh single block for region `idx` and return it (for building). + pub fn make_region_block(&self, ctx: &mut Context, idx: usize) -> Ptr { + let region = self.get_operation().deref(ctx).get_region(idx); + let block = BasicBlock::new(ctx, None, vec![]); + block.insert_at_front(region, ctx); + block + } +} + +/// Single-qubit rotations carrying an f64 angle (qprog.ll's rz/rx/ry). +#[pliron_op(name = "qec.rz", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] +pub struct RzOp; +impl RzOp { + pub fn new(ctx: &mut Context, q: Value, theta: f64) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); + set_angle(ctx, op, theta); + RzOp { op } + } +} +#[pliron_op(name = "qec.rx", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] +pub struct RxOp; +impl RxOp { + pub fn new(ctx: &mut Context, q: Value, theta: f64) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); + set_angle(ctx, op, theta); + RxOp { op } + } +} +#[pliron_op(name = "qec.ry", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] +pub struct RyOp; +impl RyOp { + pub fn new(ctx: &mut Context, q: Value, theta: f64) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); + set_angle(ctx, op, theta); + RyOp { op } + } +} +/// Two-qubit sqrt-ZZ entangler (PECOS `SZZ`; qprog.ll's no-angle `zz` maps here). +#[pliron_op(name = "qec.szz", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>], verifier = "succ")] +pub struct SzzOp; +impl SzzOp { + pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![a, b], vec![], 0); + SzzOp { op } + } +} + #[pliron_op(name = "qec.end", format, interfaces = [IsTerminatorInterface, NOpdsInterface<0>, NResultsInterface<0>], verifier = "succ")] pub struct EndOp; impl EndOp { @@ -531,6 +624,11 @@ fn run_milestone_3() { enum Cmd { Pz(usize), H(usize), + X(usize), + Rz(usize, f64), + Rx(usize, f64), + Ry(usize, f64), + Szz(usize, usize), Cx(usize, usize), Mz(usize), } @@ -588,6 +686,11 @@ fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: &[Cm match *c { Cmd::Pz(q) => { b.pz(&[q]); } Cmd::H(q) => { b.h(&[q]); } + Cmd::X(q) => { b.x(&[q]); } + Cmd::Rz(q, t) => { b.rz(Angle64::from_radians(t), &[q]); } + Cmd::Rx(q, t) => { b.rx(Angle64::from_radians(t), &[q]); } + Cmd::Ry(q, t) => { b.ry(Angle64::from_radians(t), &[q]); } + Cmd::Szz(a, c0) => { b.szz(&[(a, c0)]); } Cmd::Cx(c0, t) => { b.cx(&[(c0, t)]); } Cmd::Mz(q) => { b.mz(&[q]); } } @@ -755,10 +858,437 @@ fn run_milestone_4() { println!("[milestone-4 adaptive multi-batch via HybridEngine] OK -- final==mid in all 200 shots, saw mid 0 and 1"); } +// ===================== Milestone 5: region-based conditional control flow (qec.if) ===================== +// Same adaptive program as M4, but the conditional is now a real pliron REGION op +// (`qec.if(cond) { then } { else }`), not a single cond_x op. The engine interprets the chosen +// region across batches. This proves region-based control flow (vision-aligned; no CFG flattening). + +/// Lower the ops of a single block (a `qec.if` region body) to a Cmd list, reusing the slot map. +fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap) -> Vec { + let mut cmds = Vec::new(); + for op in block.deref(ctx).iter(ctx).collect::>() { + if let Some(x) = Operation::get_op::(op, ctx) { + cmds.push(Cmd::X(qubit_of[&x.get_operation().deref(ctx).get_operand(0)])); + } else if let Some(h) = Operation::get_op::(op, ctx) { + cmds.push(Cmd::H(qubit_of[&h.get_operation().deref(ctx).get_operand(0)])); + } else if let Some(p) = Operation::get_op::(op, ctx) { + cmds.push(Cmd::Pz(qubit_of[&p.get_operation().deref(ctx).get_operand(0)])); + } else if let Some(cx) = Operation::get_op::(op, ctx) { + let opn = cx.get_operation(); + cmds.push(Cmd::Cx(qubit_of[&opn.deref(ctx).get_operand(0)], qubit_of[&opn.deref(ctx).get_operand(1)])); + } else if let Some(m) = Operation::get_op::(op, ctx) { + cmds.push(Cmd::Mz(qubit_of[&m.get_operation().deref(ctx).get_operand(0)])); + } + } + cmds +} + +struct IfPlan { + batch1: Vec, + cond_outcome_idx: usize, + then_cmds: Vec, + else_cmds: Vec, + post: Vec, +} + +/// Walk the func block; split at the `qec.if` boundary, reading the two region bodies as the +/// then/else command lists. +fn plan_from_if_ir(ctx: &Context, block: Ptr) -> IfPlan { + let mut qubit_of: HashMap = HashMap::new(); + let (mut batch1, mut post, mut then_cmds, mut else_cmds) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let mut after_if = false; + let mut mz_b1 = 0usize; + let mut cond_outcome_idx = 0usize; + for op in block.deref(ctx).iter(ctx).collect::>() { + if let Some(s) = Operation::get_op::(op, ctx) { + qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); + } else if let Some(p) = Operation::get_op::(op, ctx) { + let q = qubit_of[&p.get_operation().deref(ctx).get_operand(0)]; + if after_if { post.push(Cmd::Pz(q)) } else { batch1.push(Cmd::Pz(q)) } + } else if let Some(h) = Operation::get_op::(op, ctx) { + let q = qubit_of[&h.get_operation().deref(ctx).get_operand(0)]; + if after_if { post.push(Cmd::H(q)) } else { batch1.push(Cmd::H(q)) } + } else if let Some(cx) = Operation::get_op::(op, ctx) { + let opn = cx.get_operation(); + let c = qubit_of[&opn.deref(ctx).get_operand(0)]; + let t = qubit_of[&opn.deref(ctx).get_operand(1)]; + if after_if { post.push(Cmd::Cx(c, t)) } else { batch1.push(Cmd::Cx(c, t)) } + } else if let Some(r) = Operation::get_op::(op, ctx) { + let q = qubit_of[&r.get_operation().deref(ctx).get_operand(0)]; + let t = get_angle(ctx, r.get_operation()); + if after_if { post.push(Cmd::Rz(q, t)) } else { batch1.push(Cmd::Rz(q, t)) } + } else if let Some(r) = Operation::get_op::(op, ctx) { + let q = qubit_of[&r.get_operation().deref(ctx).get_operand(0)]; + let t = get_angle(ctx, r.get_operation()); + if after_if { post.push(Cmd::Rx(q, t)) } else { batch1.push(Cmd::Rx(q, t)) } + } else if let Some(r) = Operation::get_op::(op, ctx) { + let q = qubit_of[&r.get_operation().deref(ctx).get_operand(0)]; + let t = get_angle(ctx, r.get_operation()); + if after_if { post.push(Cmd::Ry(q, t)) } else { batch1.push(Cmd::Ry(q, t)) } + } else if let Some(z) = Operation::get_op::(op, ctx) { + let opn = z.get_operation(); + let a = qubit_of[&opn.deref(ctx).get_operand(0)]; + let bq = qubit_of[&opn.deref(ctx).get_operand(1)]; + if after_if { post.push(Cmd::Szz(a, bq)) } else { batch1.push(Cmd::Szz(a, bq)) } + } else if let Some(m) = Operation::get_op::(op, ctx) { + let q = qubit_of[&m.get_operation().deref(ctx).get_operand(0)]; + if after_if { + post.push(Cmd::Mz(q)); + } else { + cond_outcome_idx = mz_b1; + mz_b1 += 1; + batch1.push(Cmd::Mz(q)); + } + } else if let Some(ifop) = Operation::get_op::(op, ctx) { + then_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 0), &qubit_of); + else_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 1), &qubit_of); + after_if = true; + } + } + IfPlan { batch1, cond_outcome_idx, then_cmds, else_cmds, post } +} + +#[derive(Clone)] +struct PlironIfEngine { + batch1: Vec, + cond_outcome_idx: usize, + then_cmds: Vec, + else_cmds: Vec, + post: Vec, + stage: u8, + b1: Vec, + b2: Vec, +} +impl PlironIfEngine { + fn b1_msg(&self) -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + emit_cmds(&mut b, &self.batch1); + b.build() + } + fn b2_msg(&self) -> ByteMessage { + let mut b = ByteMessage::quantum_operations_builder(); + let taken = if self.b1.get(self.cond_outcome_idx).copied() == Some(1) { &self.then_cmds } else { &self.else_cmds }; + emit_cmds(&mut b, taken); + emit_cmds(&mut b, &self.post); + b.build() + } +} +impl Engine for PlironIfEngine { + type Input = (); + type Output = Shot; + fn process(&mut self, _i: ()) -> std::result::Result { self.get_results() } + fn reset(&mut self) -> std::result::Result<(), PecosError> { self.stage = 0; self.b1.clear(); self.b2.clear(); Ok(()) } +} +impl ClassicalEngine for PlironIfEngine { + fn num_qubits(&self) -> usize { 2 } + fn generate_commands(&mut self) -> std::result::Result { + if self.stage == 0 { self.stage = 1; Ok(self.b1_msg()) } else { Ok(ByteMessage::create_empty()) } + } + fn handle_measurements(&mut self, m: ByteMessage) -> std::result::Result<(), PecosError> { + let o = m.outcomes()?; + if self.stage == 1 { self.b1 = o } else { self.b2 = o } + Ok(()) + } + fn get_results(&self) -> std::result::Result { + let mut s = Shot::default(); + s.add_register("mid", self.b1.first().copied().unwrap_or(0), 1); + s.add_register("final", self.b2.first().copied().unwrap_or(0), 1); + s.add_register("final1", self.b2.get(1).copied().unwrap_or(0), 1); + Ok(s) + } + fn compile(&self) -> std::result::Result<(), PecosError> { Ok(()) } + fn reset(&mut self) -> std::result::Result<(), PecosError> { Engine::reset(self) } + fn as_any(&self) -> &dyn Any { self } + fn as_any_mut(&mut self) -> &mut dyn Any { self } +} +impl ControlEngine for PlironIfEngine { + type Input = (); + type Output = Shot; + type EngineInput = ByteMessage; + type EngineOutput = ByteMessage; + fn start(&mut self, _i: ()) -> std::result::Result, PecosError> { + self.stage = 1; + self.b1.clear(); + self.b2.clear(); + Ok(EngineStage::NeedsProcessing(self.b1_msg())) + } + fn continue_processing(&mut self, meas: ByteMessage) -> std::result::Result, PecosError> { + if self.stage == 1 { + self.b1 = meas.outcomes()?; + self.stage = 2; + Ok(EngineStage::NeedsProcessing(self.b2_msg())) + } else { + self.b2 = meas.outcomes()?; + Ok(EngineStage::Complete(self.get_results()?)) + } + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { Engine::reset(self) } +} + +fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { + let module = ModuleOp::new(ctx, "adaptive_if".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + } + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let s0 = push!(SlotOp::new(ctx, qv, 0)); + let s0v = s0.get_result(ctx); + let s1 = push!(SlotOp::new(ctx, qv, 1)); + let s1v = s1.get_result(ctx); + push!(PrepareOp::new(ctx, s0v)); + push!(PrepareOp::new(ctx, s1v)); + push!(HOp::new(ctx, s0v)); + let m0 = push!(MeasureOp::new(ctx, s0v)); + let m0v = m0.get_result(ctx); + let ifop = push!(IfOp::new(ctx, m0v)); // if m0 { x q1 } else { } + let then_bb = ifop.make_region_block(ctx, 0); + XOp::new(ctx, s1v).get_operation().insert_at_back(then_bb, ctx); + let _else_bb = ifop.make_region_block(ctx, 1); + push!(MeasureOp::new(ctx, s1v)); + push!(EndOp::new(ctx)); + (module, bb) +} + +fn run_milestone_5() { + let ctx = &mut Context::new(); + let (module, bb) = build_if_ir(ctx); + println!("=== region-based conditional (qec.if) pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-5 verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_if_ir(ctx, bb); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + let (mut all_eq, mut saw0, mut saw1) = (true, false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let mid = shot.data.get("mid").and_then(Data::as_u32).expect("mid"); + let fin = shot.data.get("final").and_then(Data::as_u32).expect("final"); + if fin != mid { + all_eq = false; + } + saw0 |= mid == 0; + saw1 |= mid == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(all_eq, "milestone-5: final must equal mid (region-based qec.if feedback)"); + assert!(saw0 && saw1, "milestone-5: expected both mid=0 and mid=1"); + println!("[milestone-5 region-based qec.if via HybridEngine] OK -- final==mid in all 200 shots, saw mid 0 and 1"); +} + +// ===================== Milestone 6: parse the literal qprog.ll (adaptive) ===================== +// qprog.ll: rz/rx/ry/zz; mid-measure; icmp+br (diamond CFG); conditional x; final measures. +// We lift the diamond CFG into a `qec.if` during the parse, producing real rotations + region +// control flow, and run it end-to-end through the real HybridEngine. + +#[derive(Clone, Copy)] +enum ParsedOp { + Rz(usize, f64), + Rx(usize, f64), + Ry(usize, f64), + Szz(usize, usize), + X(usize), + M(usize), +} + +fn inner_parens(l: &str) -> &str { + match (l.find('('), l.rfind(')')) { + (Some(a), Some(b)) if b > a => &l[a + 1..b], + _ => "", + } +} +fn doubles_and_ints(inner: &str) -> (Vec, Vec) { + let (mut ds, mut is) = (Vec::new(), Vec::new()); + for t in inner.split(',') { + let t = t.trim(); + if let Some(d) = t.strip_prefix("double ") { + if let Ok(v) = d.trim().parse::() { ds.push(v); } + } else if let Some(n) = t.strip_prefix("i64 ") { + if let Ok(v) = n.trim().parse::() { is.push(v); } + } + } + (ds, is) +} +fn br_labels(l: &str) -> Vec { + l.split("label %") + .skip(1) + .filter_map(|s| s.split([',', ' ']).next().filter(|x| !x.is_empty()).map(str::to_string)) + .collect() +} +fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { + for p in ops { + match *p { + ParsedOp::Rz(q, _) | ParsedOp::Rx(q, _) | ParsedOp::Ry(q, _) | ParsedOp::X(q) | ParsedOp::M(q) => { set.insert(q); } + ParsedOp::Szz(a, b) => { set.insert(a); set.insert(b); } + } + } +} +fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap) { + match *p { + ParsedOp::Rz(q, t) => { RzOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } + ParsedOp::Rx(q, t) => { RxOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } + ParsedOp::Ry(q, t) => { RyOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } + ParsedOp::Szz(a, b) => { SzzOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } + ParsedOp::X(q) => { XOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } + ParsedOp::M(q) => { MeasureOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } + } +} + +/// Parse qprog.ll's diamond CFG, lifting the conditional branch into a `qec.if`. +fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { + // pass 1: collect ops per block label (entry = "") and the conditional-branch targets. + let mut blocks: Vec<(String, Vec)> = Vec::new(); + let mut cur_label = String::new(); + let mut cur_ops: Vec = Vec::new(); + let (mut then_label, mut else_label) = (None::, None::); + let mut in_func = false; + for raw in src.lines() { + let l = raw.trim(); + if l.starts_with("define ") && l.contains("@qmain") { in_func = true; continue; } + if !in_func { continue; } + if l == "}" { blocks.push((std::mem::take(&mut cur_label), std::mem::take(&mut cur_ops))); break; } + if l.ends_with(':') && !l.contains(' ') { + blocks.push((std::mem::take(&mut cur_label), std::mem::take(&mut cur_ops))); + cur_label = l.trim_end_matches(':').to_string(); + continue; + } + let (ds, is) = doubles_and_ints(inner_parens(l)); + if l.contains("__quantum__qis__rz__body") { + cur_ops.push(ParsedOp::Rz(is[0], ds[0])); + } else if l.contains("__quantum__qis__rx__body") { + cur_ops.push(ParsedOp::Rx(is[0], ds[0])); + } else if l.contains("__quantum__qis__ry__body") { + cur_ops.push(ParsedOp::Ry(is[0], ds[0])); + } else if l.contains("__quantum__qis__zz__body") { + cur_ops.push(ParsedOp::Szz(is[0], is[1])); + } else if l.contains("__quantum__qis__x__body") { + cur_ops.push(ParsedOp::X(is[0])); + } else if l.contains("__quantum__qis__m__body") { + cur_ops.push(ParsedOp::M(is[0])); + } else if l.starts_with("br ") && l.contains("label %") { + let labels = br_labels(l); + if labels.len() == 2 { + then_label = Some(labels[0].clone()); + else_label = Some(labels[1].clone()); + } + } + } + let find = |lab: &str| blocks.iter().find(|(l, _)| l == lab).map(|(_, o)| o.clone()).unwrap_or_default(); + let entry_ops = find(""); + let then_ops = then_label.as_deref().map(find).unwrap_or_default(); + let else_ops = else_label.as_deref().map(find).unwrap_or_default(); + let merge_ops = blocks + .iter() + .find(|(l, _)| !l.is_empty() && Some(l) != then_label.as_ref() && Some(l) != else_label.as_ref()) + .map(|(_, o)| o.clone()) + .unwrap_or_default(); + + // pass 2: build the pliron qec IR. + let mut qubits = BTreeSet::new(); + for ops in [&entry_ops, &then_ops, &else_ops, &merge_ops] { collect_qubits(ops, &mut qubits); } + + let module = ModuleOp::new(ctx, "qprog".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "qmain".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + + let q = QallocOp::new(ctx); + q.get_operation().insert_at_back(bb, ctx); + let qv = q.get_result(ctx); + let mut slot_of: HashMap = HashMap::new(); + for &idx in &qubits { + let s = SlotOp::new(ctx, qv, idx as u64); + s.get_operation().insert_at_back(bb, ctx); + let sv = s.get_result(ctx); + slot_of.insert(idx, sv); + PrepareOp::new(ctx, sv).get_operation().insert_at_back(bb, ctx); + } + // entry gates (everything before the trailing mid-measure), then the mid measure (if cond). + let mid_q = match entry_ops.last() { + Some(ParsedOp::M(q)) => *q, + _ => panic!("expected entry block to end with a measurement (qprog mid-measure)"), + }; + for p in &entry_ops[..entry_ops.len() - 1] { emit_parsed(ctx, p, bb, &slot_of); } + let m0 = MeasureOp::new(ctx, slot_of[&mid_q]); + m0.get_operation().insert_at_back(bb, ctx); + let m0v = m0.get_result(ctx); + // lift the diamond into qec.if(mid) { then } { else } + let ifop = IfOp::new(ctx, m0v); + ifop.get_operation().insert_at_back(bb, ctx); + let then_bb = ifop.make_region_block(ctx, 0); + for p in &then_ops { emit_parsed(ctx, p, then_bb, &slot_of); } + let else_bb = ifop.make_region_block(ctx, 1); + for p in &else_ops { emit_parsed(ctx, p, else_bb, &slot_of); } + // final measurements + for p in &merge_ops { emit_parsed(ctx, p, bb, &slot_of); } + EndOp::new(ctx).get_operation().insert_at_back(bb, ctx); + (module, bb) +} + +fn run_milestone_6() { + let ctx = &mut Context::new(); + let src = include_str!("../../../examples/llvm/qprog.ll"); + let (module, bb) = parse_qprog_ll(ctx, src); + println!("=== qprog.ll parsed into pliron qec IR (rotations + qec.if) ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-6 verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_if_ir(ctx, bb); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + let mut seen: BTreeSet<(u32, u32, u32)> = BTreeSet::new(); + let mut n = 0; + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let mid = shot.data.get("mid").and_then(Data::as_u32).expect("mid"); + let f0 = shot.data.get("final").and_then(Data::as_u32).expect("final"); + let f1 = shot.data.get("final1").and_then(Data::as_u32).expect("final1"); + seen.insert((mid, f0, f1)); + n += 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert_eq!(n, 200, "milestone-6: expected 200 shots"); + assert!(!seen.is_empty(), "milestone-6: qprog.ll must produce results"); + // qprog's q0 only sees Z-diagonal ops (rz, szz) so its mid/final measure is deterministically 0 + // (the conditional branch is therefore never taken -- branch-firing is exercised by M4/M5); the + // quantum variety is on q1 (rx(pi)+ry+szz). We just require the program runs and produces results. + println!("[milestone-6 qprog.ll -> pliron qec (rotations + qec.if) -> HybridEngine] OK -- {n} shots, observed (mid,final_q0,final_q1): {seen:?}"); +} + fn main() { run_and_check("milestone-0 hand-built Bell", bell_message(), 200); run_milestone_1(); run_milestone_2(); run_milestone_3(); run_milestone_4(); + run_milestone_5(); + run_milestone_6(); } From 5de7d9c90feaab4e2795e6b5c6eef93364ec2fac Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 10:56:46 -0600 Subject: [PATCH 217/388] Flatten the circuit once in reliable_observables instead of per-observable (helpers take the already-flattened circuit) --- .../src/pecos/qec/reliable_observables.py | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/reliable_observables.py b/python/quantum-pecos/src/pecos/qec/reliable_observables.py index 7bf5b4723..7b3554ae1 100644 --- a/python/quantum-pecos/src/pecos/qec/reliable_observables.py +++ b/python/quantum-pecos/src/pecos/qec/reliable_observables.py @@ -72,9 +72,10 @@ def reliable_observables(circuit: stim.Circuit) -> list[set[int]]: msg = f"`circuit` must be a stim.Circuit, got {type(circuit)}" raise TypeError(msg) - resets = _reset_pauli_regions(circuit) + flat = circuit.flattened() + resets = _reset_pauli_regions(flat) num_obs = circuit.num_observables - obs_regions = {o: _observing_region(circuit, o) for o in range(num_obs)} + obs_regions = {o: _observing_region(flat, o) for o in range(num_obs)} # A[reset, obs] = 1 iff the reset and the observable's region anticommute. a = np.zeros((len(resets), num_obs), dtype=np.uint8) @@ -92,11 +93,12 @@ def is_reliable(circuit: stim.Circuit, observable: set[int] | int) -> bool: A combination is reliable iff its region commutes with every reset. """ obs = {observable} if isinstance(observable, int) else set(observable) - resets = _reset_pauli_regions(circuit) + flat = circuit.flattened() + resets = _reset_pauli_regions(flat) # Combine the regions of the chosen observables by tick-wise Pauli product. combined: PauliRegion = {} for o in obs: - for tick, ps in _observing_region(circuit, o).items(): + for tick, ps in _observing_region(flat, o).items(): combined[tick] = combined[tick] * ps if tick in combined else ps return all(not _anticommute(r, combined) for r in resets.values()) @@ -106,9 +108,12 @@ def is_reliable(circuit: stim.Circuit, observable: set[int] | int) -> bool: # --------------------------------------------------------------------------- # -def _reset_pauli_regions(circuit: stim.Circuit) -> dict[int, PauliRegion]: - """Per-reset single-tick Pauli region: the reset's Pauli on its qubit.""" - flat = circuit.flattened() +def _reset_pauli_regions(flat: stim.Circuit) -> dict[int, PauliRegion]: + """Per-reset single-tick Pauli region: the reset's Pauli on its qubit. + + ``flat`` must already be flattened (``circuit.flattened()``); callers flatten + once and share it across observables to avoid repeated flattening. + """ n = flat.num_qubits resets: dict[int, PauliRegion] = {} reset_idx = 0 @@ -132,14 +137,15 @@ def _reset_pauli_regions(circuit: stim.Circuit) -> dict[int, PauliRegion]: return resets -def _observing_region(circuit: stim.Circuit, observable: int) -> PauliRegion: +def _observing_region(flat: stim.Circuit, observable: int) -> PauliRegion: """Back-propagated observing region of one observable, via stim. - Rewrites the circuit so only `observable` survives, renamed to L0, then uses - `stim.Circuit.detecting_regions` to get its {tick: PauliString} region. + Rewrites the (already-flattened) circuit so only `observable` survives, + renamed to L0, then uses `stim.Circuit.detecting_regions` to get its + {tick: PauliString} region. """ new_circuit = stim.Circuit() - for instr in circuit.flattened(): + for instr in flat: if instr.name != "OBSERVABLE_INCLUDE": new_circuit.append(instr) continue From f1d88c609e286180ddea2fe4f9a45beab0a7f8bb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 11:14:08 -0600 Subject: [PATCH 218/388] Extract the shared Monte Carlo sampling vocabulary into pecos-engines and un-deprecate neo's .shots() shortcut --- crates/pecos-engines/src/lib.rs | 2 + crates/pecos-engines/src/sampling.rs | 163 +++++++++++++++++++++++++++ crates/pecos/src/lib.rs | 4 +- exp/pecos-neo/src/tool/simulation.rs | 145 ++++++++---------------- 4 files changed, 213 insertions(+), 101 deletions(-) create mode 100644 crates/pecos-engines/src/sampling.rs diff --git a/crates/pecos-engines/src/lib.rs b/crates/pecos-engines/src/lib.rs index e24ac46ce..4214ff405 100644 --- a/crates/pecos-engines/src/lib.rs +++ b/crates/pecos-engines/src/lib.rs @@ -10,6 +10,7 @@ pub mod prelude; pub mod quantum; pub mod quantum_engine_builder; pub mod quantum_system; +pub mod sampling; pub mod shot_results; pub mod sim_builder; @@ -40,6 +41,7 @@ pub use quantum_engine_builder::{ state_vector, }; pub use quantum_system::QuantumSystem; +pub use sampling::MonteCarloBuilder; pub use shot_results::data_vec::DataVecType; pub use shot_results::{ BitVecDisplayFormat, Data, DataVec, Shot, ShotMap, ShotMapDisplay, ShotMapDisplayExt, diff --git a/crates/pecos-engines/src/sampling.rs b/crates/pecos-engines/src/sampling.rs new file mode 100644 index 000000000..40b6c19b2 --- /dev/null +++ b/crates/pecos-engines/src/sampling.rs @@ -0,0 +1,163 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License. You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Cross-stack sampling vocabulary. +//! +//! [`monte_carlo()`] builds a [`MonteCarloBuilder`] -- the stack-agnostic +//! run-spec for Monte Carlo sampling (shot count plus optional worker +//! parallelism). It is the single source of truth shared by both simulation +//! stacks: the engines [`MonteCarloEngine`](crate::MonteCarloEngine) consumes +//! it directly, and the `pecos-neo` stack converts it into its own `Sampling` +//! strategy. The unified facade (`pecos::sim().stack(...).sampling(...)`) and +//! the neo builder (`sim_neo().sampling(...)`) therefore accept the SAME +//! `monte_carlo(n).workers(m)` spelling. +//! +//! Monte Carlo is the only strategy both stacks share; richer rare-event +//! strategies (importance sampling, subset simulation) are `pecos-neo`-only and +//! live there. + +use std::num::NonZero; + +/// Builder for the Monte Carlo sampling strategy. +/// +/// Created by [`monte_carlo()`]. The shot count is the defining argument; +/// worker parallelism is optional and unset by default (sequential). Worker +/// resolution is deferred to [`resolved_workers()`](Self::resolved_workers) so +/// an unset count does not silently override a worker count configured +/// elsewhere on a builder. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MonteCarloBuilder { + shots: usize, + /// Explicit worker count, or `None` when unset. `Some` from `.workers(n)`. + workers: Option, + /// `true` from `.auto_workers()`: resolve to available parallelism at use. + auto_workers: bool, +} + +impl MonteCarloBuilder { + /// Set the number of parallel workers. + /// + /// Parallel execution distributes shots across workers, each with its own + /// simulator, command source, and noise model built from the shared + /// configuration. Per-shot seeding uses global shot indices on the neo + /// stack, so neo results are identical for any worker count (the engines + /// stack does not make that guarantee). + #[must_use] + pub fn workers(mut self, workers: usize) -> Self { + self.workers = Some(workers); + self.auto_workers = false; + self + } + + /// Request a worker count derived from the machine's available parallelism, + /// resolved when the spec is consumed (see [`workers()`](Self::workers)). + #[must_use] + pub fn auto_workers(mut self) -> Self { + self.auto_workers = true; + self + } + + /// The configured shot count. + #[must_use] + pub fn shots(&self) -> usize { + self.shots + } + + /// The explicitly-set worker count, or `None` when unset. + /// + /// `None` means neither `.workers(n)` nor `.auto_workers()` was called; + /// callers that need a concrete count should use + /// [`resolved_workers()`](Self::resolved_workers). + #[must_use] + pub fn worker_count(&self) -> Option { + self.workers + } + + /// Whether [`auto_workers()`](Self::auto_workers) was requested. + #[must_use] + pub fn auto_workers_requested(&self) -> bool { + self.auto_workers + } + + /// The concrete worker count: available parallelism when `.auto_workers()` + /// was requested, the explicit `.workers(n)` otherwise, and `1` when unset. + #[must_use] + pub fn resolved_workers(&self) -> usize { + if self.auto_workers { + std::thread::available_parallelism().map_or(1, NonZero::get) + } else { + self.workers.unwrap_or(1) + } + } +} + +/// Create a Monte Carlo sampling spec running `shots` shots. +/// +/// This is the standard execution strategy: each shot runs the program once +/// and records its outcomes. Sequential by default; add +/// [`workers(n)`](MonteCarloBuilder::workers) or +/// [`auto_workers()`](MonteCarloBuilder::auto_workers) for parallel execution. +#[must_use] +pub fn monte_carlo(shots: usize) -> MonteCarloBuilder { + MonteCarloBuilder { + shots, + workers: None, + auto_workers: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn monte_carlo_defaults_to_sequential_unset_workers() { + let mc = monte_carlo(100); + assert_eq!(mc.shots(), 100); + assert_eq!(mc.worker_count(), None); + assert!(!mc.auto_workers_requested()); + // Unset resolves to a single worker. + assert_eq!(mc.resolved_workers(), 1); + } + + #[test] + fn workers_sets_explicit_count() { + let mc = monte_carlo(100).workers(8); + assert_eq!(mc.worker_count(), Some(8)); + assert_eq!(mc.resolved_workers(), 8); + assert!(!mc.auto_workers_requested()); + } + + #[test] + fn auto_workers_resolves_to_available_parallelism() { + let mc = monte_carlo(100).auto_workers(); + assert!(mc.auto_workers_requested()); + assert_eq!(mc.worker_count(), None); + let expected = std::thread::available_parallelism().map_or(1, NonZero::get); + assert_eq!(mc.resolved_workers(), expected); + } + + #[test] + fn last_worker_setter_wins() { + // auto then explicit -> explicit + assert_eq!( + monte_carlo(1).auto_workers().workers(3).resolved_workers(), + 3 + ); + // explicit then auto -> auto + let auto = std::thread::available_parallelism().map_or(1, NonZero::get); + assert_eq!( + monte_carlo(1).workers(3).auto_workers().resolved_workers(), + auto + ); + } +} diff --git a/crates/pecos/src/lib.rs b/crates/pecos/src/lib.rs index 5792a8cd6..52a90f5cf 100644 --- a/crates/pecos/src/lib.rs +++ b/crates/pecos/src/lib.rs @@ -194,11 +194,13 @@ pub use engine_type::{DynamicEngineBuilder, EngineType, sim_dynamic}; #[cfg(feature = "cppsparsestab")] pub use pecos_cppsparsestab::CppSparseStab; #[cfg(feature = "sim")] +pub use pecos_engines::sampling::monte_carlo; +#[cfg(feature = "sim")] pub use pecos_engines::{ BiasedDepolarizingNoise, DepolarizingNoise, GeneralNoiseModelBuilder, PassThroughNoiseModel, }; #[cfg(feature = "sim")] -pub use pecos_engines::{SimInput, sim_builder}; +pub use pecos_engines::{MonteCarloBuilder, SimInput, sim_builder}; #[cfg(feature = "sim")] pub use pecos_engines::{ coin_toss, density_matrix, sparse_stab, stab_vec, stabilizer, state_vector, diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 352e33eec..01bcbb8a5 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -966,83 +966,22 @@ pub fn importance_sampling(shots: usize) -> ImportanceSamplingBuilder { ImportanceSamplingBuilder::new(shots) } -/// Builder for the Monte Carlo sampling strategy. -/// -/// Created by [`monte_carlo()`]. Shots is the defining argument; workers -/// defaults to 1 (sequential). -#[derive(Debug, Clone)] -pub struct MonteCarloBuilder { - shots: usize, - workers: usize, -} - -impl MonteCarloBuilder { - /// Set the number of parallel workers. - /// - /// Parallel execution distributes shots across workers using rayon, - /// with each worker getting its own simulator, command source, and - /// noise model built from the shared configuration. Per-shot seeding - /// uses global shot indices, so results are identical for any worker - /// count. - /// - /// Requires a per-worker construction path: a static circuit or a - /// classical engine builder source, on any backend (built-in, adapted, - /// or custom factory). Pre-built dynamic command sources cannot build - /// per-worker state; `.build()` rejects that combination. - #[must_use] - pub fn workers(mut self, workers: usize) -> Self { - self.workers = workers; - self - } - - /// Set the worker count from available parallelism. - /// - /// See [`workers()`](Self::workers) for requirements. - #[must_use] - pub fn auto_workers(mut self) -> Self { - self.workers = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); - self - } -} +// The Monte Carlo sampling vocabulary (`monte_carlo()` + `MonteCarloBuilder`) +// is shared with the engines stack and lives in `pecos-engines` so BOTH stacks +// accept the same `monte_carlo(n).workers(m)` spelling (the engines +// `MonteCarloEngine`'s run-spec). Re-exported here so `pecos_neo::tool::*` +// paths are unchanged; neo maps it into its own `Sampling` strategy below. +pub use pecos_engines::sampling::{MonteCarloBuilder, monte_carlo}; impl From for Sampling { fn from(builder: MonteCarloBuilder) -> Self { Sampling::MonteCarlo { - shots: builder.shots, - workers: builder.workers, + shots: builder.shots(), + workers: builder.resolved_workers(), } } } -/// Create a Monte Carlo sampling strategy builder running `shots` shots. -/// -/// This is the standard execution strategy: each shot runs the program -/// once and records its outcomes. Sequential by default; add -/// `.workers(n)` or `.auto_workers()` for parallel execution. -/// -/// # Example -/// -/// ```no_run -/// use pecos_neo::tool::{monte_carlo, sim_neo}; -/// use pecos_neo::prelude::*; -/// -/// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); -/// -/// // Sequential -/// let results = sim_neo(circuit.clone()).auto() -/// .sampling(monte_carlo(1000)) -/// .run(); -/// -/// // Parallel with 8 workers -/// let results = sim_neo(circuit).auto() -/// .sampling(monte_carlo(1000).workers(8)) -/// .run(); -/// ``` -#[must_use] -pub fn monte_carlo(shots: usize) -> MonteCarloBuilder { - MonteCarloBuilder { shots, workers: 1 } -} - /// Builder for the path enumeration sampling strategy. /// /// Created by [`path_enumeration()`]. @@ -1908,12 +1847,13 @@ pub struct SimNeoBuilder { config: SimConfig, /// Sampling strategy (data). None until `.sampling()` is called. sampling: Option, - /// Shot count from the deprecated top-level `.shots()` forwarder. - legacy_shots: Option, + /// Shot count from the top-level `.shots()` shortcut (sugar for the + /// default Monte Carlo sampler). Mutually exclusive with `.sampling()`. + shots_shortcut: Option, /// Worker count from the deprecated top-level `.workers()` forwarder. legacy_workers: Option, /// Auto worker-count request from `.auto()`/deprecated `.auto_workers()`, - /// honored only on the legacy `.shots()` path. + /// honored only on the top-level `.shots()` shortcut path. auto_workers_hint: bool, /// Backend auto-selection opt-in from `.auto()`. auto_backend: bool, @@ -1940,7 +1880,7 @@ impl SimNeoBuilder { definitions: None, config: SimConfig::default(), sampling: None, - legacy_shots: None, + shots_shortcut: None, legacy_workers: None, auto_workers_hint: false, auto_backend: false, @@ -2210,14 +2150,20 @@ impl SimNeoBuilder { self } - /// Set the number of shots. - #[deprecated( - since = "0.2.0", - note = "shots lives on the sampler builder: use .sampling(monte_carlo(shots))" - )] + /// Set the number of Monte Carlo shots to run. + /// + /// This is the shorthand for the common case: `.shots(n)` is exactly + /// `.sampling(monte_carlo(n))`. Reach for [`sampling()`](Self::sampling) + /// directly when you need worker parallelism or a non-Monte-Carlo strategy + /// (e.g. `.sampling(monte_carlo(n).workers(8))` or + /// `.sampling(importance_sampling(n))`). Setting both `.shots()` and + /// `.sampling()` is rejected at build time rather than silently picking one. + /// + /// The facade (`pecos::sim().stack(...)`) exposes the same `.shots(n)`, so + /// both stacks share the `.shots(n).run()` shape. #[must_use] pub fn shots(mut self, shots: usize) -> Self { - self.legacy_shots = Some(shots); + self.shots_shortcut = Some(shots); self } @@ -2575,9 +2521,10 @@ impl SimNeoBuilder { }; // Resolve the sampling strategy: the .sampling() path carries its own - // shot count; the deprecated top-level .shots()/.workers() forwarders - // map onto Monte Carlo. Mixing the two is ambiguous and rejected. - let sampling = match (self.sampling, self.legacy_shots) { + // shot count; the top-level .shots() shortcut (and the deprecated + // .workers() forwarder) map onto Monte Carlo. Mixing .shots()/.workers() + // with .sampling() is ambiguous and rejected. + let sampling = match (self.sampling, self.shots_shortcut) { (Some(sampling), None) => { assert!( self.legacy_workers.is_none(), @@ -2588,9 +2535,9 @@ impl SimNeoBuilder { sampling } (Some(_), Some(_)) => panic!( - "Conflicting sampling configuration: deprecated .shots() cannot be combined \ - with .sampling(). Set shots on the sampler builder, e.g. \ - .sampling(monte_carlo(1000))." + "Conflicting sampling configuration: .shots() cannot be combined with \ + .sampling() (both set the shot count). Use one: .shots(1000) for the common \ + case, or .sampling(monte_carlo(1000).workers(8)) for parallel/other strategies." ), (None, Some(shots)) => { let workers = self.legacy_workers.unwrap_or_else(|| { @@ -4919,10 +4866,9 @@ mod tests { } #[test] - #[should_panic(expected = "deprecated .shots() cannot be combined")] - fn test_sim_neo_legacy_shots_conflicts_with_sampling() { + #[should_panic(expected = ".shots() cannot be combined with")] + fn test_sim_neo_shots_conflicts_with_sampling() { let circuit = CommandBuilder::new().pz(&[0]).mz(&[0]).build(); - #[allow(deprecated)] let _ = sim_neo(circuit) .auto() .sampling(monte_carlo(10)) @@ -4959,30 +4905,29 @@ mod tests { } #[test] - fn test_sim_neo_legacy_shots_forwarder_matches_new_api() { - // Deprecated .shots(n) must behave exactly like - // .sampling(monte_carlo(n)) during the transition window. + fn test_sim_neo_shots_shortcut_matches_sampling() { + // .shots(n) must behave exactly like .sampling(monte_carlo(n)) -- it is + // the blessed shorthand for that common case. let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); - #[allow(deprecated)] - let legacy = sim_neo(circuit.clone()).auto().shots(40).seed(11).run(); - let new = sim_neo(circuit) + let shortcut = sim_neo(circuit.clone()).auto().shots(40).seed(11).run(); + let explicit = sim_neo(circuit) .auto() .sampling(monte_carlo(40)) .seed(11) .run(); - assert_eq!(legacy.outcomes.len(), 40); - assert_eq!(legacy.outcomes.len(), new.outcomes.len()); - for (o1, o2) in legacy.outcomes.iter().zip(new.outcomes.iter()) { + assert_eq!(shortcut.outcomes.len(), 40); + assert_eq!(shortcut.outcomes.len(), explicit.outcomes.len()); + for (o1, o2) in shortcut.outcomes.iter().zip(explicit.outcomes.iter()) { assert_eq!(o1.get_bit(QubitId(0)), o2.get_bit(QubitId(0))); } } #[test] - fn test_sim_neo_legacy_shots_workers_combo_still_parallel() { - // Old-style .workers(n).shots(m) (both deprecated) maps onto - // MonteCarlo { shots: m, workers: n } and still runs. + fn test_sim_neo_shots_with_deprecated_workers_still_parallel() { + // Blessed .shots(m) combined with the deprecated .workers(n) forwarder + // maps onto MonteCarlo { shots: m, workers: n } and still runs. let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); #[allow(deprecated)] From a76712419da5ad355b1c84d595eb19110403f83d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 11:15:55 -0600 Subject: [PATCH 219/388] Split .shots(n) from an argless fail-fast .run() and add .sampling(monte_carlo()) to the facade --- crates/pecos/benches/stack_comparison.rs | 2 +- crates/pecos/examples/sim_api_examples.rs | 18 +++- crates/pecos/examples/sim_api_final.rs | 6 +- .../examples/unified_sim_auto_selection.rs | 9 +- crates/pecos/examples/unified_sim_demo.rs | 6 +- crates/pecos/examples/unified_sim_reusable.rs | 2 +- crates/pecos/src/prelude.rs | 3 +- crates/pecos/src/unified_sim.rs | 65 ++++++++++- crates/pecos/tests/neo_emission_test.rs | 12 ++- .../tests/neo_equivalence_matrix_test.rs | 20 ++-- crates/pecos/tests/neo_hugr_routing_test.rs | 9 +- crates/pecos/tests/neo_routing_test.rs | 101 +++++++++++++++--- crates/pecos/tests/neo_surface_ler_test.rs | 6 +- .../pecos/tests/neo_v6_example_sweep_test.rs | 5 +- crates/pecos/tests/unified_sim_api_test.rs | 18 ++-- python/pecos-rslib/src/sim.rs | 4 +- 16 files changed, 226 insertions(+), 60 deletions(-) diff --git a/crates/pecos/benches/stack_comparison.rs b/crates/pecos/benches/stack_comparison.rs index 8c3bc30b8..79343f61e 100644 --- a/crates/pecos/benches/stack_comparison.rs +++ b/crates/pecos/benches/stack_comparison.rs @@ -81,7 +81,7 @@ fn run_stack(qasm: &str, stack: SimStack, noisy: bool) { if noisy { builder = builder.noise(pecos_engines::DepolarizingNoise { p: 0.001 }); } - let results = builder.run(SHOTS).expect("run"); + let results = builder.shots(SHOTS).run().expect("run"); assert_eq!(results.shots.len(), SHOTS); } diff --git a/crates/pecos/examples/sim_api_examples.rs b/crates/pecos/examples/sim_api_examples.rs index f2045c86f..a0c43f146 100644 --- a/crates/pecos/examples/sim_api_examples.rs +++ b/crates/pecos/examples/sim_api_examples.rs @@ -15,7 +15,8 @@ fn main() -> Result<(), PecosError> { .quantum(state_vector()) .noise(DepolarizingNoise { p: 0.01 }) .seed(42) - .run(50)?; + .shots(50) + .run()?; println!(" Results: {} shots", results.len()); // Example 2: Different program types @@ -23,7 +24,11 @@ fn main() -> Result<(), PecosError> { // QASM program let qasm_prog = Qasm::from_string("OPENQASM 2.0; qreg q[2]; h q[0]; cx q[0],q[1];"); - let results = sim(qasm_prog).quantum(sparse_stab()).seed(42).run(100)?; + let results = sim(qasm_prog) + .quantum(sparse_stab()) + .seed(42) + .shots(100) + .run()?; println!(" QASM: {} shots", results.len()); // LLVM program @@ -41,7 +46,8 @@ fn main() -> Result<(), PecosError> { ); let results = sim(llvm_prog) .qubits(1) // LLVM programs need explicit qubit count - .run(50)?; + .shots(50) + .run()?; println!(" LLVM: {} shots", results.len()); // Example 3: Using sim_builder() for empty builder @@ -76,7 +82,8 @@ fn main() -> Result<(), PecosError> { // QASM program but use LLVM engine let results = sim(qasm_prog) .classical(qis_engine().program(llvm_prog)) - .run(20)?; + .shots(20) + .run()?; println!(" Results: {} shots", results.len()); // Example 5: Build once, run multiple times @@ -112,7 +119,8 @@ fn main() -> Result<(), PecosError> { Qasm::from_string("OPENQASM 2.0; qreg q[3]; h q[0]; cx q[0],q[1]; cx q[1],q[2];"); let results = sim(qasm_prog) .auto_workers() // Use all available CPU cores - .run(1000)?; + .shots(1000) + .run()?; println!(" Results: {} shots with auto workers", results.len()); // Example 7: Using engine builder with sim_from() diff --git a/crates/pecos/examples/sim_api_final.rs b/crates/pecos/examples/sim_api_final.rs index 5924dd905..5acb6f1ec 100644 --- a/crates/pecos/examples/sim_api_final.rs +++ b/crates/pecos/examples/sim_api_final.rs @@ -30,7 +30,8 @@ fn main() -> Result<(), PecosError> { .noise(DepolarizingNoise { p: 0.01 }) .seed(42) .workers(4) - .run(1000)?; + .shots(1000) + .run()?; println!( " Bell state simulation: {} shots completed", @@ -101,7 +102,8 @@ fn main() -> Result<(), PecosError> { let results = sim(qasm_prog) .classical(qis_engine().program(llvm_prog)) .qubits(1) - .run(10)?; + .shots(10) + .run()?; println!(" Override engine: {} shots", results.len()); diff --git a/crates/pecos/examples/unified_sim_auto_selection.rs b/crates/pecos/examples/unified_sim_auto_selection.rs index a193caac5..72c362b50 100644 --- a/crates/pecos/examples/unified_sim_auto_selection.rs +++ b/crates/pecos/examples/unified_sim_auto_selection.rs @@ -22,7 +22,11 @@ fn main() -> Result<(), Box> { "#, ); - let results = sim(qasm_prog).seed(42).quantum(state_vector()).run(100)?; + let results = sim(qasm_prog) + .seed(42) + .quantum(state_vector()) + .shots(100) + .run()?; println!(" Ran {} shots for QASM program", results.len()); @@ -63,7 +67,8 @@ fn main() -> Result<(), Box> { .workers(2) .verbose(false) .quantum(sparse_stab()) - .run(200)?; + .shots(200) + .run()?; println!(" Ran {} shots with custom configuration", results4.len()); diff --git a/crates/pecos/examples/unified_sim_demo.rs b/crates/pecos/examples/unified_sim_demo.rs index 58d4d369d..047082347 100644 --- a/crates/pecos/examples/unified_sim_demo.rs +++ b/crates/pecos/examples/unified_sim_demo.rs @@ -52,7 +52,8 @@ fn main() -> Result<(), Box> { .seed(123) .workers(4) .quantum(sparse_stab()) - .run(500)?; + .shots(500) + .run()?; println!(" Ran {} shots", results2.len()); @@ -74,7 +75,8 @@ fn main() -> Result<(), Box> { let results3 = sim(qasm3.clone()) .classical(qasm_engine().program(qasm3)) .verbose(true) - .run(100)?; + .shots(100) + .run()?; println!(" Ran {} shots", results3.len()); diff --git a/crates/pecos/examples/unified_sim_reusable.rs b/crates/pecos/examples/unified_sim_reusable.rs index 83e2698e7..fb3216f4d 100644 --- a/crates/pecos/examples/unified_sim_reusable.rs +++ b/crates/pecos/examples/unified_sim_reusable.rs @@ -88,7 +88,7 @@ fn main() -> Result<(), Box> { // Direct run (builds each time) let start = Instant::now(); for _ in 0..5 { - let _ = sim(qasm3.clone()).run(100)?; + let _ = sim(qasm3.clone()).shots(100).run()?; } let direct_time = start.elapsed(); println!(" Direct run 5 times: {direct_time:?}"); diff --git a/crates/pecos/src/prelude.rs b/crates/pecos/src/prelude.rs index d2e35d6b7..072e34f38 100644 --- a/crates/pecos/src/prelude.rs +++ b/crates/pecos/src/prelude.rs @@ -32,7 +32,8 @@ //! let results = sim(program) //! .quantum(sparse_stab()) //! .seed(42) -//! .run(1000)?; +//! .shots(1000) +//! .run()?; //! # Ok::<(), pecos_core::errors::PecosError>(()) //! ``` //! diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index f98f573a8..8f9e65547 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -4,7 +4,10 @@ //! from pecos-engines, adding automatic engine selection based on program type. use pecos_core::errors::PecosError; -use pecos_engines::{ClassicalControlEngineBuilder, MonteCarloEngine, SimBuilder, sim_builder}; +use pecos_engines::sampling::monte_carlo; +use pecos_engines::{ + ClassicalControlEngineBuilder, MonteCarloBuilder, MonteCarloEngine, SimBuilder, sim_builder, +}; use pecos_programs::Program; use pecos_qasm::qasm_engine; #[cfg(feature = "qis")] @@ -91,6 +94,10 @@ struct RoutedConfig { workers: Option, auto_workers: bool, qubits: Option, + /// Monte Carlo shot count, set via `.shots(n)` and consumed by the argless + /// `.run()`. `None` until configured -- `.run()` fails fast rather than + /// defaulting silently. + shots: Option, /// The noise config as passed, for translation to the neo stack. /// Type-erased because `.noise()` is generic; the neo route downcasts /// against the known engines noise types. @@ -183,22 +190,69 @@ impl ProgrammedSimBuilder { pub fn build(self) -> Result { if self.stack == SimStack::Neo { return Err(PecosError::Input( - "The neo stack does not expose a MonteCarloEngine; call .run(shots) directly." + "The neo stack does not expose a MonteCarloEngine; call .shots(n).run() directly." .to_string(), )); } self.configure_engine()?.build() } - /// Build and run the simulation with automatic engine selection + /// Set the number of Monte Carlo shots to run. + /// + /// Shorthand for [`sampling(monte_carlo(shots))`](Self::sampling). Shots are + /// a builder concern, not a `run()` argument: configure the count here, then + /// call the argless [`run()`](Self::run). Both stacks (and the neo + /// `sim_neo()` builder) share this `.shots(n).run()` shape. + #[must_use] + pub fn shots(self, shots: usize) -> Self { + self.sampling(monte_carlo(shots)) + } + + /// Set the Monte Carlo sampling strategy (shot count plus optional worker + /// parallelism), e.g. `.sampling(monte_carlo(1000).workers(8))`. + /// + /// [`monte_carlo()`](pecos_engines::sampling::monte_carlo) is the shared + /// cross-stack run-spec, so the SAME spelling works on both the engines and + /// neo stacks. The shot count is required; worker settings are applied only + /// when explicitly configured on the spec, so this never silently overrides + /// a separate [`workers()`](Self::workers) call unless the spec sets workers + /// too. (Richer rare-event strategies -- importance sampling, subset + /// simulation -- are neo-only and configured via `sim_neo()` directly.) + #[must_use] + pub fn sampling(mut self, sampling: impl Into) -> Self { + let mc = sampling.into(); + self.routed.shots = Some(mc.shots()); + if mc.auto_workers_requested() { + self.routed.auto_workers = true; + self.base_builder = self.base_builder.auto_workers(); + } else if let Some(workers) = mc.worker_count() { + self.routed.workers = Some(workers); + self.base_builder = self.base_builder.workers(workers); + } + self + } + + /// Build and run the simulation with automatic engine selection. + /// + /// The shot count must be configured first via [`shots()`](Self::shots); + /// `run()` takes no argument and fails fast if no count was set, rather than + /// defaulting silently. /// /// # Errors /// /// Returns an error if: + /// - No shot count was configured via [`shots()`](Self::shots) /// - The program type is not yet supported (WASM, WAT, PHIR JSON, `SeleneInterface`) /// - Engine building or running fails /// - The neo stack is selected with configuration it cannot route yet - pub fn run(self, shots: usize) -> Result { + pub fn run(self) -> Result { + let shots = self.routed.shots.ok_or_else(|| { + PecosError::Input( + "No shot count configured; set one with .shots(n) before .run(). \ + Example: sim(program).shots(1000).run()." + .to_string(), + ) + })?; match self.stack { SimStack::Engines => self.configure_engine()?.run(shots), SimStack::Neo => self.run_neo(shots), @@ -567,7 +621,8 @@ impl ProgrammedSimBuilder { /// .quantum(sparse_stab()) /// .noise(DepolarizingNoise { p: 0.01 }) /// .seed(42) -/// .run(100)?; +/// .shots(100) +/// .run()?; /// # Ok::<(), pecos_core::errors::PecosError>(()) /// ``` pub fn sim>(program: P) -> ProgrammedSimBuilder { diff --git a/crates/pecos/tests/neo_emission_test.rs b/crates/pecos/tests/neo_emission_test.rs index 7194918a4..aedca3019 100644 --- a/crates/pecos/tests/neo_emission_test.rs +++ b/crates/pecos/tests/neo_emission_test.rs @@ -79,7 +79,8 @@ fn engines_zero_count() -> u64 { .stack(SimStack::Engines) .noise(emission_noise_1q()) .seed(42) - .run(SHOTS) + .shots(SHOTS) + .run() .expect("engines run"); rate_zero(&results).0 } @@ -91,7 +92,8 @@ fn neo_facade_zero_count() -> u64 { .stack(SimStack::Neo) .noise(emission_noise_1q()) .seed(7) // independent seed; agreement must be physical - .run(SHOTS) + .shots(SHOTS) + .run() .expect("neo facade run"); rate_zero(&results).0 } @@ -202,7 +204,8 @@ fn engines_2q_zero_count() -> u64 { .stack(SimStack::Engines) .noise(emission_noise_2q()) .seed(42) - .run(SHOTS) + .shots(SHOTS) + .run() .expect("engines run"); rate_zero(&results).0 } @@ -212,7 +215,8 @@ fn neo_facade_2q_zero_count() -> u64 { .stack(SimStack::Neo) .noise(emission_noise_2q()) .seed(7) - .run(SHOTS) + .shots(SHOTS) + .run() .expect("neo facade run"); rate_zero(&results).0 } diff --git a/crates/pecos/tests/neo_equivalence_matrix_test.rs b/crates/pecos/tests/neo_equivalence_matrix_test.rs index 0793f13c2..4ab1fc34f 100644 --- a/crates/pecos/tests/neo_equivalence_matrix_test.rs +++ b/crates/pecos/tests/neo_equivalence_matrix_test.rs @@ -166,13 +166,14 @@ impl NoiseCell { .with_p2_probability(p2) }; let results = match *self { - Self::Meas(p) => builder.noise(depol(0.0, p, 0.0, 0.0)).run(SHOTS), - Self::Prep(p) => builder.noise(depol(p, 0.0, 0.0, 0.0)).run(SHOTS), - Self::P1(p) => builder.noise(depol(0.0, 0.0, p, 0.0)).run(SHOTS), - Self::P2(p) => builder.noise(depol(0.0, 0.0, 0.0, p)).run(SHOTS), + Self::Meas(p) => builder.noise(depol(0.0, p, 0.0, 0.0)).shots(SHOTS).run(), + Self::Prep(p) => builder.noise(depol(p, 0.0, 0.0, 0.0)).shots(SHOTS).run(), + Self::P1(p) => builder.noise(depol(0.0, 0.0, p, 0.0)).shots(SHOTS).run(), + Self::P2(p) => builder.noise(depol(0.0, 0.0, 0.0, p)).shots(SHOTS).run(), Self::Uniform(p) => builder .noise(pecos_engines::DepolarizingNoise { p }) - .run(SHOTS), + .shots(SHOTS) + .run(), Self::GnmSimple { average_p1, p_meas } => builder .noise( // GeneralNoiseModel has realistic non-zero defaults; @@ -189,7 +190,8 @@ impl NoiseCell { .with_meas_0_probability(p_meas) .with_meas_1_probability(p_meas), ) - .run(SHOTS), + .shots(SHOTS) + .run(), Self::GnmAngle { p2, angle_params: (a, b, c, d), @@ -212,7 +214,8 @@ impl NoiseCell { .with_meas_0_probability(0.0) .with_meas_1_probability(0.0), ) - .run(SHOTS), + .shots(SHOTS) + .run(), Self::BiasedMeas { p_meas_0, p_meas_1 } => builder .noise( // Asymmetric record-flip measurement, no gate/prep noise. @@ -223,7 +226,8 @@ impl NoiseCell { .with_single_qubit_probability(0.0) .with_two_qubit_probability(0.0), ) - .run(SHOTS), + .shots(SHOTS) + .run(), }; results.expect("simulation run") } diff --git a/crates/pecos/tests/neo_hugr_routing_test.rs b/crates/pecos/tests/neo_hugr_routing_test.rs index c26b75b96..add224e1f 100644 --- a/crates/pecos/tests/neo_hugr_routing_test.rs +++ b/crates/pecos/tests/neo_hugr_routing_test.rs @@ -33,7 +33,8 @@ fn neo_hugr_c(bytes: &[u8], seed: u64, shots: usize) -> Vec { let results = sim(Hugr::from_bytes(bytes.to_vec())) .stack(SimStack::Neo) .seed(seed) - .run(shots) + .shots(shots) + .run() .expect("neo HUGR run"); results .shots @@ -72,7 +73,8 @@ fn neo_hugr_results_use_the_named_c_register() { let results = sim(Hugr::from_bytes(bytes.to_vec())) .stack(SimStack::Neo) .seed(42) - .run(5) + .shots(5) + .run() .expect("neo HUGR run"); let keys: Vec<&String> = results.shots[0].data.keys().collect(); assert!( @@ -151,7 +153,8 @@ fn neo_hugr_control_flow_is_rejected() { let err = sim(Hugr::from_bytes(fixture.to_vec())) .stack(SimStack::Neo) .seed(42) - .run(4) + .shots(4) + .run() .expect_err("control-flow HUGR must be rejected on the neo stack"); assert!( err.to_string().contains("classical control flow"), diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index 144604aaa..d61d917fd 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -18,7 +18,7 @@ #![cfg(feature = "neo")] -use pecos::{SimStack, sim}; +use pecos::{SimStack, monte_carlo, sim}; use pecos_programs::Qasm; /// Deterministic program exercising measurement feedback: c ends as "11". @@ -42,13 +42,15 @@ fn neo_stack_matches_engines_for_deterministic_qasm() { let engines = sim(deterministic_conditional_qasm()) .stack(SimStack::Engines) .seed(42) - .run(5) + .shots(5) + .run() .expect("engines run"); let neo = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .seed(42) - .run(5) + .shots(5) + .run() .expect("neo run"); assert_eq!(engines.shots.len(), 5); @@ -67,14 +69,16 @@ fn neo_stack_parallel_matches_engines() { .stack(SimStack::Engines) .seed(7) .workers(2) - .run(6) + .shots(6) + .run() .expect("engines run"); let neo = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .seed(7) .workers(2) - .run(6) + .shots(6) + .run() .expect("neo run"); assert_eq!(engines, neo); @@ -94,7 +98,8 @@ fn neo_stack_worker_count_invariant_for_noisy_program() { .noise(noise) .seed(42) .workers(workers) - .run(128) + .shots(128) + .run() .expect("neo run") }; let w1 = run(1); @@ -129,7 +134,8 @@ fn neo_stack_same_seed_is_reproducible() { .stack(SimStack::Neo) .noise(noise) .seed(123) - .run(64) + .shots(64) + .run() .expect("neo run") }; assert_eq!( @@ -179,13 +185,15 @@ fn neo_stack_measurement_noise_rate_matches_engines() { .stack(SimStack::Engines) .noise(noise.clone()) .seed(42) - .run(shots) + .shots(shots) + .run() .expect("engines run"); let neo = sim(x_measure_qasm()) .stack(SimStack::Neo) .noise(noise) .seed(42) - .run(shots) + .shots(shots) + .run() .expect("neo run"); let engines_rate = rate_of(&engines, "0"); @@ -220,7 +228,8 @@ fn neo_stack_uniform_depolarizing_rate_matches_engines() { .stack(stack) .noise(pecos_engines::DepolarizingNoise { p: 0.1 }) .seed(seed) - .run(shots) + .shots(shots) + .run() .expect("run") }; @@ -249,7 +258,8 @@ fn neo_stack_biased_depolarizing_struct_rate_matches_engines() { .stack(stack) .noise(pecos_engines::BiasedDepolarizingNoise { p: 0.1 }) .seed(seed) - .run(shots) + .shots(shots) + .run() .expect("run") }; @@ -291,7 +301,8 @@ fn neo_stack_general_noise_average_convention_matches() { .stack(stack) .noise(noise) .seed(11) - .run(shots) + .shots(shots) + .run() .expect("run") }; @@ -320,7 +331,8 @@ fn neo_stack_rejects_unmapped_noise() { let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .noise(general) - .run(5) + .shots(5) + .run() .expect_err("beyond-subset GeneralNoiseModel configs are not mapped"); assert!( err.to_string() @@ -334,7 +346,8 @@ fn neo_stack_rejects_unrouted_quantum_backend() { let err = sim(deterministic_conditional_qasm()) .stack(SimStack::Neo) .quantum(pecos_engines::state_vector()) - .run(5) + .shots(5) + .run() .expect_err("explicit quantum backends are not yet routed"); assert!(err.to_string().contains("not yet routed to the neo stack")); } @@ -349,3 +362,63 @@ fn neo_stack_rejects_build() { }; assert!(err.to_string().contains("MonteCarloEngine")); } + +// --- Shared sampling vocabulary (.sampling(monte_carlo(n))) ---------------- + +/// The shared `monte_carlo()` run-spec drives BOTH stacks through the facade, +/// and `.shots(n)` is exactly its shorthand. A deterministic program lets us +/// assert exact `ShotVec` equality across the two spellings and the two stacks. +#[test] +fn facade_sampling_monte_carlo_drives_both_stacks() { + for stack in [SimStack::Engines, SimStack::Neo] { + let via_sampling = sim(deterministic_conditional_qasm()) + .stack(stack) + .seed(42) + .sampling(monte_carlo(5)) + .run() + .expect("sampling run"); + let via_shots = sim(deterministic_conditional_qasm()) + .stack(stack) + .seed(42) + .shots(5) + .run() + .expect("shots run"); + + assert_eq!(via_sampling.shots.len(), 5); + assert_eq!( + via_sampling, via_shots, + "{stack:?}: .sampling(monte_carlo(5)) must equal .shots(5)" + ); + for shot in &via_sampling.shots { + assert_eq!(shot.data["c"].to_bitstring().unwrap(), "11"); + } + } +} + +/// `monte_carlo(n).workers(w)` carries worker parallelism through the facade on +/// both stacks; the deterministic program's results are worker-count invariant. +#[test] +fn facade_sampling_workers_runs_parallel_on_both_stacks() { + for stack in [SimStack::Engines, SimStack::Neo] { + let serial = sim(deterministic_conditional_qasm()) + .stack(stack) + .seed(7) + .sampling(monte_carlo(6)) + .run() + .expect("serial run"); + let parallel = sim(deterministic_conditional_qasm()) + .stack(stack) + .seed(7) + .sampling(monte_carlo(6).workers(2)) + .run() + .expect("parallel run"); + + assert_eq!(parallel.shots.len(), 6); + // A deterministic program yields identical outcomes regardless of how + // shots are split across workers, on either stack. + assert_eq!( + serial, parallel, + "{stack:?}: worker count must not change deterministic results" + ); + } +} diff --git a/crates/pecos/tests/neo_surface_ler_test.rs b/crates/pecos/tests/neo_surface_ler_test.rs index ef042eb9a..419751109 100644 --- a/crates/pecos/tests/neo_surface_ler_test.rs +++ b/crates/pecos/tests/neo_surface_ler_test.rs @@ -305,7 +305,8 @@ fn run_stack( .noise(depolarizing_noise(p)) .seed(seed) .workers(4) - .run(shots) + .shots(shots) + .run() .expect("simulation run") } @@ -351,7 +352,8 @@ fn noiseless_surface_memory_is_silent_on_both_stacks() { let results = sim(Qasm::from_string(&experiment.qasm)) .stack(stack) .seed(11) - .run(25) + .shots(25) + .run() .expect("noiseless run"); let (syndromes, masks) = shots_to_syndromes(&results, &experiment); for (shot_idx, syndrome) in syndromes.iter().enumerate() { diff --git a/crates/pecos/tests/neo_v6_example_sweep_test.rs b/crates/pecos/tests/neo_v6_example_sweep_test.rs index 74806c167..ba50b6f5f 100644 --- a/crates/pecos/tests/neo_v6_example_sweep_test.rs +++ b/crates/pecos/tests/neo_v6_example_sweep_test.rs @@ -92,8 +92,9 @@ fn run(program: &str, stack: SimStack, seed: u64, noise: Option) -> ShotVec let results = match noise { Some(p) => builder .noise(pecos_engines::DepolarizingNoise { p }) - .run(SHOTS), - None => builder.run(SHOTS), + .shots(SHOTS) + .run(), + None => builder.shots(SHOTS).run(), }; results.expect("simulation run") } diff --git a/crates/pecos/tests/unified_sim_api_test.rs b/crates/pecos/tests/unified_sim_api_test.rs index a27699e72..cf4f6e862 100644 --- a/crates/pecos/tests/unified_sim_api_test.rs +++ b/crates/pecos/tests/unified_sim_api_test.rs @@ -102,14 +102,16 @@ mod tests { let _results2 = sim(Qasm::from_string("OPENQASM 2.0; qreg q[1];")) .seed(42) .quantum(sparse_stab()) - .run(100); + .shots(100) + .run(); // Pattern 3: Override auto-selection with explicit .classical() let _results3 = sim(Qis::from_string("define void @main() { ret void }")) .classical( qis_engine().program(Qis::from_string("define void @main() { ret void }")), ) - .run(100); + .shots(100) + .run(); // Pattern 4: Various configuration options work with new API let _results4 = sim(Qasm::from_string("OPENQASM 2.0; qreg q[2];")) @@ -119,7 +121,8 @@ mod tests { .verbose(true) .qubits(2) .quantum(state_vector()) - .run(1000); + .shots(1000) + .run(); }; } @@ -134,18 +137,21 @@ mod tests { // QASM -> QASM engine let _qasm_results = sim(Qasm::from_string("OPENQASM 2.0; qreg q[1];")) .quantum(state_vector()) - .run(10); + .shots(10) + .run(); // LLVM -> LLVM engine let _llvm_results = sim(Qis::from_string("define void @main() { ret void }")) .quantum(state_vector()) - .run(10); + .shots(10) + .run(); // HUGR -> Selene engine let _hugr_results = sim(Hugr::from_bytes(vec![0x00, 0x01, 0x02])) .quantum(state_vector()) .qubits(1) - .run(10); + .shots(10) + .run(); }; } } diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 53bdee2a3..e6429033e 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -1764,7 +1764,7 @@ fn run_qasm_via_facade( if let Some(ref noise_py) = builder.noise_builder { facade = apply_noise_to_facade(facade, noise_py)?; } - match facade.run(shots) { + match facade.shots(shots).run() { Ok(shot_vec) => Ok(crate::shot_results_bindings::PyShotVec::new(shot_vec)), Err(e) => Err(PyRuntimeError::new_err(format!("Simulation failed: {e}"))), } @@ -1890,7 +1890,7 @@ fn run_program_neo( // listing the accepted constructors. facade = apply_noise_to_facade(facade, noise_py)?; } - match facade.run(shots) { + match facade.shots(shots).run() { Ok(shot_vec) => Ok(crate::shot_results_bindings::PyShotVec::new(shot_vec)), Err(e) => Err(PyRuntimeError::new_err(format!("Simulation failed: {e}"))), } From dbe9e9bea2e75b76be70d667925c94a00b0db5ac Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 15:25:41 -0600 Subject: [PATCH 220/388] Phase 1: pecos-phir-pliron M7 -- library, explicit measurement-SSA/export mapping, branch-taken fixture, negative tests --- .../fixtures/adaptive_branch.ll | 28 ++ exp/pecos-phir-pliron/src/{main.rs => lib.rs} | 419 ++++++++++++++---- 2 files changed, 353 insertions(+), 94 deletions(-) create mode 100644 exp/pecos-phir-pliron/fixtures/adaptive_branch.ll rename exp/pecos-phir-pliron/src/{main.rs => lib.rs} (75%) diff --git a/exp/pecos-phir-pliron/fixtures/adaptive_branch.ll b/exp/pecos-phir-pliron/fixtures/adaptive_branch.ll new file mode 100644 index 000000000..7c430b1c9 --- /dev/null +++ b/exp/pecos-phir-pliron/fixtures/adaptive_branch.ll @@ -0,0 +1,28 @@ +; Adaptive program whose conditional branch IS taken (unlike qprog.ll, whose q0 is deterministic). +; h q0 -> measure q0 (random) -> if q0==1 { x q1 } -> measure q1. Invariant: final_q1 == mid_q0. +declare void @__quantum__qis__h__body(i64) +declare void @__quantum__qis__x__body(i64) +declare i32 @__quantum__qis__m__body(i64, i64) +declare void @__quantum__rt__result_record_output(i64, i8*) + +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x + +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final + +skip_x: + br label %final + +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 2, i8* null) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} + +attributes #0 = { "EntryPoint" } diff --git a/exp/pecos-phir-pliron/src/main.rs b/exp/pecos-phir-pliron/src/lib.rs similarity index 75% rename from exp/pecos-phir-pliron/src/main.rs rename to exp/pecos-phir-pliron/src/lib.rs index 0bff52e3c..4077e87ce 100644 --- a/exp/pecos-phir-pliron/src/main.rs +++ b/exp/pecos-phir-pliron/src/lib.rs @@ -11,7 +11,7 @@ //! real backend through explicit qubit/result maps, and the ByteMessage seam is untouched. use std::any::Any; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use awint::bw; use pecos_core::Angle64; @@ -47,7 +47,7 @@ use pliron::{ // ===================== Milestone 0: hand-built Bell ByteMessage ===================== -fn bell_message() -> ByteMessage { +pub fn bell_message() -> ByteMessage { let mut b = ByteMessage::quantum_operations_builder(); b.h(&[0]); b.cx(&[(0, 1)]); @@ -57,7 +57,7 @@ fn bell_message() -> ByteMessage { } /// Run a Bell ByteMessage through the real state-vector simulator; assert each shot is 00 or 11. -fn run_and_check(label: &str, msg: ByteMessage, shots: usize) { +pub fn run_and_check(label: &str, msg: ByteMessage, shots: usize) { let mut engine = StateVecEngine::new(2); let (mut saw0, mut saw3) = (false, false); for _ in 0..shots { @@ -84,10 +84,10 @@ pub struct AllocType; #[derive(Hash, PartialEq, Eq, Debug)] pub struct QubitRefType; -fn alloc_ty(ctx: &Context) -> Ptr { +pub fn alloc_ty(ctx: &Context) -> Ptr { AllocType::get(ctx).into() } -fn qubitref_ty(ctx: &Context) -> Ptr { +pub fn qubitref_ty(ctx: &Context) -> Ptr { QubitRefType::get(ctx).into() } @@ -101,13 +101,18 @@ mod angle_attr { dict_key!(BITS, "qec_angle_bits"); } +mod result_attr { + use pliron::dict_key; + dict_key!(ID, "qec_result_id"); +} + /// Store an f64 angle on an op as the bit pattern in an IntegerAttr (pliron has no float attr here). -fn set_angle(ctx: &mut Context, op: Ptr, theta: f64) { +pub fn set_angle(ctx: &mut Context, op: Ptr, theta: f64) { let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless); let attr = IntegerAttr::new(i64_ty, APInt::from_u64(theta.to_bits(), bw(64))); op.deref_mut(ctx).attributes.0.insert(angle_attr::BITS.clone(), Box::new(attr)); } -fn get_angle(ctx: &Context, op: Ptr) -> f64 { +pub fn get_angle(ctx: &Context, op: Ptr) -> f64 { let o = op.deref(ctx); let a: AttrObj = o.attributes.0.get(&*angle_attr::BITS).expect("angle attr").clone(); let ia = a.downcast::().unwrap_or_else(|_| panic!("angle not IntegerAttr")); @@ -204,14 +209,26 @@ impl Verify for CxOp { } } +/// `%result = qec.measure(qubitref) [result_id=N]` -- a measurement. Its result `%result` IS the +/// measurement-SSA value; `result_id` is the QIS export label (the 2nd `i64` of `m__body`), carried +/// so a later `qec.record` can name exactly which measurement becomes a program output. #[pliron_op(name = "qec.measure", format, interfaces = [NOpdsInterface<1>, OneResultInterface, NResultsInterface<1>])] pub struct MeasureOp; impl MeasureOp { - pub fn new(ctx: &mut Context, qref: Value) -> Self { + pub fn new(ctx: &mut Context, qref: Value, result_id: u64) -> Self { let i1 = IntegerType::get(ctx, 1, Signedness::Signless); let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![i1.into()], vec![qref], vec![], 0); + let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless); + let attr = IntegerAttr::new(i64_ty, APInt::from_u64(result_id, bw(64))); + op.deref_mut(ctx).attributes.0.insert(result_attr::ID.clone(), Box::new(attr)); MeasureOp { op } } + pub fn result_id(&self, ctx: &Context) -> u64 { + let op = self.get_operation().deref(ctx); + let attr: AttrObj = op.attributes.0.get(&*result_attr::ID).expect("measure result-id attr").clone(); + let ia = attr.downcast::().unwrap_or_else(|_| panic!("result-id not IntegerAttr")); + Into::::into(*ia).to_u64() + } } impl Verify for MeasureOp { fn verify(&self, ctx: &Context) -> Result<()> { @@ -222,6 +239,28 @@ impl Verify for MeasureOp { } } +/// `qec.record(measurement-SSA)` -- mark a `qec.measure` result as a recorded program output +/// (lowered from QIS `__quantum__rt__result_record_output`). The textual order of these ops IS the +/// program's classical-output order; the operand makes the recorded measurement-SSA explicit. +#[pliron_op(name = "qec.record", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] +pub struct RecordOp; +impl RecordOp { + pub fn new(ctx: &mut Context, result: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![result], vec![], 0); + RecordOp { op } + } +} +impl Verify for RecordOp { + fn verify(&self, ctx: &Context) -> Result<()> { + let opnd = self.get_operation().deref(ctx).get_operand(0); + let records_a_measure = opnd.defining_op().is_some_and(|o| Operation::get_op::(o, ctx).is_some()); + if !records_a_measure { + return verify_err!(self.loc(ctx), "qec.record operand must be a qec.measure result"); + } + Ok(()) + } +} + /// `qec.cond_x(cond: i1, target: qubitref)` -- apply X to `target` iff the measurement result /// `cond` is 1. The measurement-conditioned gate that forces a classical decision between batches. #[pliron_op(name = "qec.cond_x", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] @@ -330,7 +369,7 @@ impl EndOp { /// Build `fn main() { q = qalloc; q0 = slot(q,0); q1 = slot(q,1); prepare q0; prepare q1; /// h q0; cx q0,q1; m0 = measure q0; m1 = measure q1; end }` as pliron IR; return the func block. -fn build_bell_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { +pub fn build_bell_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { let module = ModuleOp::new(ctx, "bell".try_into().unwrap()); let func_ty = FunctionType::get(ctx, vec![], vec![]); let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); @@ -351,8 +390,8 @@ fn build_bell_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { push!(PrepareOp::new(ctx, s1v)); push!(HOp::new(ctx, s0v)); push!(CxOp::new(ctx, s0v, s1v)); - push!(MeasureOp::new(ctx, s0v)); - push!(MeasureOp::new(ctx, s1v)); + push!(MeasureOp::new(ctx, s0v, 0)); + push!(MeasureOp::new(ctx, s1v, 1)); push!(EndOp::new(ctx)); (module, bb) } @@ -360,7 +399,7 @@ fn build_bell_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { /// Walk the pliron func block and emit a ByteMessage. The ONLY qubit-identity source is an /// explicit `Value(qubitref) -> qubit index` map populated from each `qec.slot`'s index /// attribute -- pliron Values are op-defined handles, never reused as dense qubit indices. -fn emit_bytemessage(ctx: &Context, block: Ptr) -> ByteMessage { +pub fn emit_bytemessage(ctx: &Context, block: Ptr) -> ByteMessage { let mut qubit_of: HashMap = HashMap::new(); let mut b = ByteMessage::quantum_operations_builder(); let ops: Vec> = block.deref(ctx).iter(ctx).collect(); @@ -387,7 +426,7 @@ fn emit_bytemessage(ctx: &Context, block: Ptr) -> ByteMessage { b.build() } -fn run_milestone_1() { +pub fn run_milestone_1() { let ctx = &mut Context::new(); let (module, bb) = build_bell_ir(ctx); println!("=== Bell as pliron qec IR ==="); @@ -406,7 +445,7 @@ fn run_milestone_1() { /// `qec` IR walk. Bell is straight-line (one batch, no classical feedback), so it sends the /// whole program once, stores the outcomes, and records register "c" = (q1<<1)|q0 as a Shot. #[derive(Clone)] -struct PlironBellEngine { +pub struct PlironBellEngine { msg: ByteMessage, num_qubits: usize, sent: bool, @@ -494,7 +533,7 @@ impl ControlEngine for PlironBellEngine { } } -fn run_milestone_2() { +pub fn run_milestone_2() { let ctx = &mut Context::new(); let (module, bb) = build_bell_ir(ctx); verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-2 verify] FAILED: {}", e.disp(ctx))); @@ -533,7 +572,7 @@ fn run_milestone_2() { /// Minimal QIS-LLVM-IR -> pliron `qec` IR parser for the Bell fixture (`examples/llvm/bell.ll`). /// Recognizes `__quantum__qis__{h,cx,m}__body` calls with integer qubit args. This proves the /// LLVM-IR frontend ports onto pliron ops (not just hand-built IR) -- the last gate-fidelity item. -fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { +pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { fn i64_args(line: &str) -> Vec { match (line.find('('), line.rfind(')')) { (Some(l), Some(r)) if r > l => line[l + 1..r] @@ -594,7 +633,7 @@ fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { push!(CxOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); } "m" => { - push!(MeasureOp::new(ctx, slot_of[&a[0]])); + push!(MeasureOp::new(ctx, slot_of[&a[0]], a[1] as u64)); } _ => {} } @@ -603,7 +642,7 @@ fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { (module, bb) } -fn run_milestone_3() { +pub fn run_milestone_3() { let ctx = &mut Context::new(); let src = include_str!("../../../examples/llvm/bell.ll"); let (module, bb) = parse_bell_ll(ctx, src); @@ -621,7 +660,7 @@ fn run_milestone_3() { // only holds if the measurement-conditioned feedback works across the two quantum batches. #[derive(Clone, Copy)] -enum Cmd { +pub enum Cmd { Pz(usize), H(usize), X(usize), @@ -630,11 +669,11 @@ enum Cmd { Ry(usize, f64), Szz(usize, usize), Cx(usize, usize), - Mz(usize), + Mz(usize, u64), // (qubit, QIS result-id); the id rides through to the export/register mapping } #[derive(Clone)] -struct AdaptivePlan { +pub struct AdaptivePlan { batch1: Vec, // gates + the conditioning Mz cond_outcome_idx: usize, // index in batch1's mz-outcomes that gates cond_x cond_target: usize, // qubit to X iff that outcome == 1 @@ -643,7 +682,7 @@ struct AdaptivePlan { /// Walk a `qec` block once and split it into the two-batch adaptive plan at the `cond_x` boundary. /// Qubit identity comes only from the explicit slot-index map (same discipline as the Bell port). -fn plan_from_ir(ctx: &Context, block: Ptr) -> AdaptivePlan { +pub fn plan_from_ir(ctx: &Context, block: Ptr) -> AdaptivePlan { let mut qubit_of: HashMap = HashMap::new(); let (mut batch1, mut batch2): (Vec, Vec) = (Vec::new(), Vec::new()); let mut after_cond = false; @@ -666,12 +705,13 @@ fn plan_from_ir(ctx: &Context, block: Ptr) -> AdaptivePlan { if after_cond { batch2.push(Cmd::Cx(c, t)) } else { batch1.push(Cmd::Cx(c, t)) } } else if let Some(m) = Operation::get_op::(op, ctx) { let q = qubit_of[&m.get_operation().deref(ctx).get_operand(0)]; + let rid = m.result_id(ctx); if after_cond { - batch2.push(Cmd::Mz(q)); + batch2.push(Cmd::Mz(q, rid)); } else { cond_outcome_idx = mz_b1; mz_b1 += 1; - batch1.push(Cmd::Mz(q)); + batch1.push(Cmd::Mz(q, rid)); } } else if let Some(c) = Operation::get_op::(op, ctx) { cond_target = qubit_of[&c.get_operation().deref(ctx).get_operand(1)]; @@ -681,7 +721,7 @@ fn plan_from_ir(ctx: &Context, block: Ptr) -> AdaptivePlan { AdaptivePlan { batch1, cond_outcome_idx, cond_target, batch2 } } -fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: &[Cmd]) { +pub fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: &[Cmd]) { for c in cmds { match *c { Cmd::Pz(q) => { b.pz(&[q]); } @@ -692,13 +732,13 @@ fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: &[Cm Cmd::Ry(q, t) => { b.ry(Angle64::from_radians(t), &[q]); } Cmd::Szz(a, c0) => { b.szz(&[(a, c0)]); } Cmd::Cx(c0, t) => { b.cx(&[(c0, t)]); } - Cmd::Mz(q) => { b.mz(&[q]); } + Cmd::Mz(q, _rid) => { b.mz(&[q]); } // result-id is bookkeeping, not a simulator op } } } #[derive(Clone)] -struct PlironAdaptiveEngine { +pub struct PlironAdaptiveEngine { plan: AdaptivePlan, stage: u8, // 0 fresh, 1 batch1 sent, 2 batch2 sent b1: Vec, @@ -800,7 +840,7 @@ impl ControlEngine for PlironAdaptiveEngine { } } -fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { +pub fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { let module = ModuleOp::new(ctx, "adaptive".try_into().unwrap()); let func_ty = FunctionType::get(ctx, vec![], vec![]); let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); @@ -818,15 +858,15 @@ fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { push!(PrepareOp::new(ctx, s0v)); push!(PrepareOp::new(ctx, s1v)); push!(HOp::new(ctx, s0v)); - let m0 = push!(MeasureOp::new(ctx, s0v)); + let m0 = push!(MeasureOp::new(ctx, s0v, 0)); // mid measure (conditioning), result-id 0 let m0v = m0.get_result(ctx); push!(CondXOp::new(ctx, m0v, s1v)); // X q1 iff m0 == 1 - push!(MeasureOp::new(ctx, s1v)); + push!(MeasureOp::new(ctx, s1v, 1)); // final measure, result-id 1 push!(EndOp::new(ctx)); (module, bb) } -fn run_milestone_4() { +pub fn run_milestone_4() { let ctx = &mut Context::new(); let (module, bb) = build_adaptive_ir(ctx); println!("=== adaptive (mid-measure -> cond_x -> final) pliron qec IR ==="); @@ -864,7 +904,7 @@ fn run_milestone_4() { // region across batches. This proves region-based control flow (vision-aligned; no CFG flattening). /// Lower the ops of a single block (a `qec.if` region body) to a Cmd list, reusing the slot map. -fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap) -> Vec { +pub fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap) -> Vec { let mut cmds = Vec::new(); for op in block.deref(ctx).iter(ctx).collect::>() { if let Some(x) = Operation::get_op::(op, ctx) { @@ -877,28 +917,30 @@ fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap(op, ctx) { - cmds.push(Cmd::Mz(qubit_of[&m.get_operation().deref(ctx).get_operand(0)])); + cmds.push(Cmd::Mz(qubit_of[&m.get_operation().deref(ctx).get_operand(0)], m.result_id(ctx))); } } cmds } -struct IfPlan { +pub struct IfPlan { batch1: Vec, cond_outcome_idx: usize, then_cmds: Vec, else_cmds: Vec, post: Vec, + export: Vec, // QIS result-ids in `qec.record` (result_record_output) order } /// Walk the func block; split at the `qec.if` boundary, reading the two region bodies as the /// then/else command lists. -fn plan_from_if_ir(ctx: &Context, block: Ptr) -> IfPlan { +pub fn plan_from_if_ir(ctx: &Context, block: Ptr) -> IfPlan { let mut qubit_of: HashMap = HashMap::new(); let (mut batch1, mut post, mut then_cmds, mut else_cmds) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); let mut after_if = false; let mut mz_b1 = 0usize; let mut cond_outcome_idx = 0usize; + let mut export: Vec = Vec::new(); for op in block.deref(ctx).iter(ctx).collect::>() { if let Some(s) = Operation::get_op::(op, ctx) { qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); @@ -932,29 +974,37 @@ fn plan_from_if_ir(ctx: &Context, block: Ptr) -> IfPlan { if after_if { post.push(Cmd::Szz(a, bq)) } else { batch1.push(Cmd::Szz(a, bq)) } } else if let Some(m) = Operation::get_op::(op, ctx) { let q = qubit_of[&m.get_operation().deref(ctx).get_operand(0)]; + let rid = m.result_id(ctx); if after_if { - post.push(Cmd::Mz(q)); + post.push(Cmd::Mz(q, rid)); } else { cond_outcome_idx = mz_b1; mz_b1 += 1; - batch1.push(Cmd::Mz(q)); + batch1.push(Cmd::Mz(q, rid)); } } else if let Some(ifop) = Operation::get_op::(op, ctx) { then_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 0), &qubit_of); else_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 1), &qubit_of); after_if = true; + } else if let Some(rec) = Operation::get_op::(op, ctx) { + // export order = textual order of qec.record; resolve the recorded measurement-SSA back + // to its QIS result-id via the operand's defining qec.measure. + let recorded = rec.get_operation().deref(ctx).get_operand(0); + let mop = recorded.defining_op().and_then(|o| Operation::get_op::(o, ctx)).expect("qec.record operand must be a qec.measure result"); + export.push(mop.result_id(ctx)); } } - IfPlan { batch1, cond_outcome_idx, then_cmds, else_cmds, post } + IfPlan { batch1, cond_outcome_idx, then_cmds, else_cmds, post, export } } #[derive(Clone)] -struct PlironIfEngine { +pub struct PlironIfEngine { batch1: Vec, cond_outcome_idx: usize, then_cmds: Vec, else_cmds: Vec, post: Vec, + export: Vec, // QIS result-ids to record, in result_record_output order stage: u8, b1: Vec, b2: Vec, @@ -965,13 +1015,36 @@ impl PlironIfEngine { emit_cmds(&mut b, &self.batch1); b.build() } + fn taken_cmds(&self) -> &[Cmd] { + if self.b1.get(self.cond_outcome_idx).copied() == Some(1) { &self.then_cmds } else { &self.else_cmds } + } fn b2_msg(&self) -> ByteMessage { let mut b = ByteMessage::quantum_operations_builder(); - let taken = if self.b1.get(self.cond_outcome_idx).copied() == Some(1) { &self.then_cmds } else { &self.else_cmds }; - emit_cmds(&mut b, taken); + emit_cmds(&mut b, self.taken_cmds()); emit_cmds(&mut b, &self.post); b.build() } + /// Reconstruct `result-id -> outcome` by replaying the emission order: batch1's `Mz`s consume + /// `b1` in order; batch2 (the runtime-taken branch, then `post`) consume `b2` in order. This is + /// the explicit measurement-SSA mapping -- each measurement's QIS result-id keyed to its value. + fn outcome_by_result_id(&self) -> BTreeMap { + let mut map = BTreeMap::new(); + let mut i = 0; + for c in &self.batch1 { + if let Cmd::Mz(_, rid) = *c { + map.insert(rid, self.b1.get(i).copied().unwrap_or(0)); + i += 1; + } + } + let mut j = 0; + for c in self.taken_cmds().iter().chain(self.post.iter()) { + if let Cmd::Mz(_, rid) = *c { + map.insert(rid, self.b2.get(j).copied().unwrap_or(0)); + j += 1; + } + } + map + } } impl Engine for PlironIfEngine { type Input = (); @@ -990,10 +1063,12 @@ impl ClassicalEngine for PlironIfEngine { Ok(()) } fn get_results(&self) -> std::result::Result { + let map = self.outcome_by_result_id(); let mut s = Shot::default(); - s.add_register("mid", self.b1.first().copied().unwrap_or(0), 1); - s.add_register("final", self.b2.first().copied().unwrap_or(0), 1); - s.add_register("final1", self.b2.get(1).copied().unwrap_or(0), 1); + // one 1-bit register per recorded result-id ("r"), in result_record_output order. + for &rid in &self.export { + s.add_register(&format!("r{rid}"), map.get(&rid).copied().unwrap_or(0), 1); + } Ok(s) } fn compile(&self) -> std::result::Result<(), PecosError> { Ok(()) } @@ -1025,7 +1100,7 @@ impl ControlEngine for PlironIfEngine { fn reset(&mut self) -> std::result::Result<(), PecosError> { Engine::reset(self) } } -fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { +pub fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { let module = ModuleOp::new(ctx, "adaptive_if".try_into().unwrap()); let func_ty = FunctionType::get(ctx, vec![], vec![]); let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); @@ -1043,18 +1118,21 @@ fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { push!(PrepareOp::new(ctx, s0v)); push!(PrepareOp::new(ctx, s1v)); push!(HOp::new(ctx, s0v)); - let m0 = push!(MeasureOp::new(ctx, s0v)); + let m0 = push!(MeasureOp::new(ctx, s0v, 0)); // mid measure (conditioning), result-id 0 let m0v = m0.get_result(ctx); let ifop = push!(IfOp::new(ctx, m0v)); // if m0 { x q1 } else { } let then_bb = ifop.make_region_block(ctx, 0); XOp::new(ctx, s1v).get_operation().insert_at_back(then_bb, ctx); let _else_bb = ifop.make_region_block(ctx, 1); - push!(MeasureOp::new(ctx, s1v)); + let m1 = push!(MeasureOp::new(ctx, s1v, 1)); // final measure, result-id 1 + let m1v = m1.get_result(ctx); + push!(RecordOp::new(ctx, m0v)); // record mid -> register r0 + push!(RecordOp::new(ctx, m1v)); // record final -> register r1 push!(EndOp::new(ctx)); (module, bb) } -fn run_milestone_5() { +pub fn run_milestone_5() { let ctx = &mut Context::new(); let (module, bb) = build_if_ir(ctx); println!("=== region-based conditional (qec.if) pliron qec IR ==="); @@ -1067,6 +1145,7 @@ fn run_milestone_5() { then_cmds: plan.then_cmds, else_cmds: plan.else_cmds, post: plan.post, + export: plan.export, stage: 0, b1: Vec::new(), b2: Vec::new(), @@ -1078,8 +1157,9 @@ fn run_milestone_5() { let (mut all_eq, mut saw0, mut saw1) = (true, false, false); for _ in 0..200 { let shot = hybrid.run_shot().unwrap(); - let mid = shot.data.get("mid").and_then(Data::as_u32).expect("mid"); - let fin = shot.data.get("final").and_then(Data::as_u32).expect("final"); + // explicit export mapping: r0 = mid (result-id 0), r1 = final (result-id 1) + let mid = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (mid)"); + let fin = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (final)"); if fin != mid { all_eq = false; } @@ -1089,7 +1169,7 @@ fn run_milestone_5() { } assert!(all_eq, "milestone-5: final must equal mid (region-based qec.if feedback)"); assert!(saw0 && saw1, "milestone-5: expected both mid=0 and mid=1"); - println!("[milestone-5 region-based qec.if via HybridEngine] OK -- final==mid in all 200 shots, saw mid 0 and 1"); + println!("[milestone-5 region-based qec.if via HybridEngine] OK -- r1(final)==r0(mid) in all 200 shots, saw mid 0 and 1"); } // ===================== Milestone 6: parse the literal qprog.ll (adaptive) ===================== @@ -1098,60 +1178,78 @@ fn run_milestone_5() { // control flow, and run it end-to-end through the real HybridEngine. #[derive(Clone, Copy)] -enum ParsedOp { +pub enum ParsedOp { + H(usize), Rz(usize, f64), Rx(usize, f64), Ry(usize, f64), Szz(usize, usize), X(usize), - M(usize), + M(usize, u64), // (qubit, QIS result-id = 2nd i64 of m__body) + Record(u64), // result_record_output(result-id): export this measurement-SSA, in this order } -fn inner_parens(l: &str) -> &str { +pub fn inner_parens(l: &str) -> &str { match (l.find('('), l.rfind(')')) { (Some(a), Some(b)) if b > a => &l[a + 1..b], _ => "", } } -fn doubles_and_ints(inner: &str) -> (Vec, Vec) { +pub fn doubles_and_ints(inner: &str) -> (Vec, Vec) { let (mut ds, mut is) = (Vec::new(), Vec::new()); for t in inner.split(',') { let t = t.trim(); - if let Some(d) = t.strip_prefix("double ") { - if let Ok(v) = d.trim().parse::() { ds.push(v); } - } else if let Some(n) = t.strip_prefix("i64 ") { - if let Ok(v) = n.trim().parse::() { is.push(v); } + if let Some(d) = t.strip_prefix("double ") + && let Ok(v) = d.trim().parse::() + { + ds.push(v); + } else if let Some(n) = t.strip_prefix("i64 ") + && let Ok(v) = n.trim().parse::() + { + is.push(v); } } (ds, is) } -fn br_labels(l: &str) -> Vec { +pub fn br_labels(l: &str) -> Vec { l.split("label %") .skip(1) .filter_map(|s| s.split([',', ' ']).next().filter(|x| !x.is_empty()).map(str::to_string)) .collect() } -fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { +pub fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { for p in ops { match *p { - ParsedOp::Rz(q, _) | ParsedOp::Rx(q, _) | ParsedOp::Ry(q, _) | ParsedOp::X(q) | ParsedOp::M(q) => { set.insert(q); } + ParsedOp::H(q) | ParsedOp::Rz(q, _) | ParsedOp::Rx(q, _) | ParsedOp::Ry(q, _) | ParsedOp::X(q) | ParsedOp::M(q, _) => { set.insert(q); } ParsedOp::Szz(a, b) => { set.insert(a); set.insert(b); } + ParsedOp::Record(_) => {} } } } -fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap) { +/// Emit one parsed op into `block`. `measured` accumulates `result-id -> measurement-SSA Value` +/// so a later `ParsedOp::Record` resolves exactly which `qec.measure` becomes a program output. +pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap, measured: &mut HashMap) { match *p { + ParsedOp::H(q) => { HOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } ParsedOp::Rz(q, t) => { RzOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } ParsedOp::Rx(q, t) => { RxOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } ParsedOp::Ry(q, t) => { RyOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } ParsedOp::Szz(a, b) => { SzzOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } ParsedOp::X(q) => { XOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } - ParsedOp::M(q) => { MeasureOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } + ParsedOp::M(q, rid) => { + let m = MeasureOp::new(ctx, slot_of[&q], rid); + m.get_operation().insert_at_back(block, ctx); + measured.insert(rid, m.get_result(ctx)); + } + ParsedOp::Record(rid) => { + let v = *measured.get(&rid).unwrap_or_else(|| panic!("result_record_output references unknown result-id {rid}")); + RecordOp::new(ctx, v).get_operation().insert_at_back(block, ctx); + } } } /// Parse qprog.ll's diamond CFG, lifting the conditional branch into a `qec.if`. -fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { +pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { // pass 1: collect ops per block label (entry = "") and the conditional-branch targets. let mut blocks: Vec<(String, Vec)> = Vec::new(); let mut cur_label = String::new(); @@ -1169,7 +1267,9 @@ fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { continue; } let (ds, is) = doubles_and_ints(inner_parens(l)); - if l.contains("__quantum__qis__rz__body") { + if l.contains("__quantum__qis__h__body") { + cur_ops.push(ParsedOp::H(is[0])); + } else if l.contains("__quantum__qis__rz__body") { cur_ops.push(ParsedOp::Rz(is[0], ds[0])); } else if l.contains("__quantum__qis__rx__body") { cur_ops.push(ParsedOp::Rx(is[0], ds[0])); @@ -1180,7 +1280,9 @@ fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { } else if l.contains("__quantum__qis__x__body") { cur_ops.push(ParsedOp::X(is[0])); } else if l.contains("__quantum__qis__m__body") { - cur_ops.push(ParsedOp::M(is[0])); + cur_ops.push(ParsedOp::M(is[0], is[1] as u64)); // m__body(i64 qubit, i64 result_id) + } else if l.contains("__quantum__rt__result_record_output") { + cur_ops.push(ParsedOp::Record(is[0] as u64)); // result_record_output(i64 result_id, i8* null) } else if l.starts_with("br ") && l.contains("label %") { let labels = br_labels(l); if labels.len() == 2 { @@ -1220,29 +1322,32 @@ fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { slot_of.insert(idx, sv); PrepareOp::new(ctx, sv).get_operation().insert_at_back(bb, ctx); } - // entry gates (everything before the trailing mid-measure), then the mid measure (if cond). - let mid_q = match entry_ops.last() { - Some(ParsedOp::M(q)) => *q, + // result-id -> measurement-SSA Value, so result_record_output can name the recorded measurement. + let mut measured: HashMap = HashMap::new(); + // entry gates (everything before the trailing mid-measure), then the mid measure (the cond). + let (mid_q, mid_rid) = match entry_ops.last() { + Some(ParsedOp::M(q, rid)) => (*q, *rid), _ => panic!("expected entry block to end with a measurement (qprog mid-measure)"), }; - for p in &entry_ops[..entry_ops.len() - 1] { emit_parsed(ctx, p, bb, &slot_of); } - let m0 = MeasureOp::new(ctx, slot_of[&mid_q]); + for p in &entry_ops[..entry_ops.len() - 1] { emit_parsed(ctx, p, bb, &slot_of, &mut measured); } + let m0 = MeasureOp::new(ctx, slot_of[&mid_q], mid_rid); m0.get_operation().insert_at_back(bb, ctx); let m0v = m0.get_result(ctx); + measured.insert(mid_rid, m0v); // lift the diamond into qec.if(mid) { then } { else } let ifop = IfOp::new(ctx, m0v); ifop.get_operation().insert_at_back(bb, ctx); let then_bb = ifop.make_region_block(ctx, 0); - for p in &then_ops { emit_parsed(ctx, p, then_bb, &slot_of); } + for p in &then_ops { emit_parsed(ctx, p, then_bb, &slot_of, &mut measured); } let else_bb = ifop.make_region_block(ctx, 1); - for p in &else_ops { emit_parsed(ctx, p, else_bb, &slot_of); } - // final measurements - for p in &merge_ops { emit_parsed(ctx, p, bb, &slot_of); } + for p in &else_ops { emit_parsed(ctx, p, else_bb, &slot_of, &mut measured); } + // final measurements + result_record_output ops (the export list) + for p in &merge_ops { emit_parsed(ctx, p, bb, &slot_of, &mut measured); } EndOp::new(ctx).get_operation().insert_at_back(bb, ctx); (module, bb) } -fn run_milestone_6() { +pub fn run_milestone_6() { let ctx = &mut Context::new(); let src = include_str!("../../../examples/llvm/qprog.ll"); let (module, bb) = parse_qprog_ll(ctx, src); @@ -1256,6 +1361,7 @@ fn run_milestone_6() { then_cmds: plan.then_cmds, else_cmds: plan.else_cmds, post: plan.post, + export: plan.export, stage: 0, b1: Vec::new(), b2: Vec::new(), @@ -1264,31 +1370,156 @@ fn run_milestone_6() { .with_classical_engine(Box::new(engine)) .with_quantum_engine(Box::new(StateVecEngine::new(2))) .build(); + // qprog records result-ids 0,1,2 -> registers r0=final_q0, r1=final_q1, r2=mid (export order). let mut seen: BTreeSet<(u32, u32, u32)> = BTreeSet::new(); let mut n = 0; for _ in 0..200 { let shot = hybrid.run_shot().unwrap(); - let mid = shot.data.get("mid").and_then(Data::as_u32).expect("mid"); - let f0 = shot.data.get("final").and_then(Data::as_u32).expect("final"); - let f1 = shot.data.get("final1").and_then(Data::as_u32).expect("final1"); + let f0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (final_q0)"); + let f1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (final_q1)"); + let mid = shot.data.get("r2").and_then(Data::as_u32).expect("r2 (mid)"); + // q0 only sees Z-diagonal ops (rz, szz), so its mid and final measures are deterministically 0. + assert_eq!(mid, 0, "milestone-6: q0 is Z-diagonal, mid (r2) must be 0"); + assert_eq!(f0, 0, "milestone-6: q0 is Z-diagonal, final_q0 (r0) must be 0"); seen.insert((mid, f0, f1)); n += 1; Engine::reset(&mut hybrid).unwrap(); } assert_eq!(n, 200, "milestone-6: expected 200 shots"); assert!(!seen.is_empty(), "milestone-6: qprog.ll must produce results"); - // qprog's q0 only sees Z-diagonal ops (rz, szz) so its mid/final measure is deterministically 0 - // (the conditional branch is therefore never taken -- branch-firing is exercised by M4/M5); the - // quantum variety is on q1 (rx(pi)+ry+szz). We just require the program runs and produces results. - println!("[milestone-6 qprog.ll -> pliron qec (rotations + qec.if) -> HybridEngine] OK -- {n} shots, observed (mid,final_q0,final_q1): {seen:?}"); -} - -fn main() { - run_and_check("milestone-0 hand-built Bell", bell_message(), 200); - run_milestone_1(); - run_milestone_2(); - run_milestone_3(); - run_milestone_4(); - run_milestone_5(); - run_milestone_6(); + // The conditional branch is never taken here (mid is deterministically 0) -- branch-firing is + // exercised by M5/M7; the quantum variety is on q1 (rx(pi)+ry+szz), so r1 varies across shots. + println!("[milestone-6 qprog.ll -> pliron qec (rotations + qec.if) -> HybridEngine] OK -- {n} shots, observed (r2_mid,r0_final_q0,r1_final_q1): {seen:?}"); +} + +/// M7 step 2: a branch-*taken* fixture parsed from `.ll` — proves `qec.if` firing from parsed input +/// (unlike qprog.ll, whose q0 is deterministic). h q0 -> measure -> if 1 { x q1 } -> measure q1. +pub fn run_milestone_7() { + let ctx = &mut Context::new(); + let src = include_str!("../fixtures/adaptive_branch.ll"); + let (module, bb) = parse_qprog_ll(ctx, src); + println!("=== adaptive_branch.ll parsed into pliron qec IR ==="); + println!("{}", module.get_operation().disp(ctx)); + verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-7 verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_if_ir(ctx, bb); + assert_eq!(plan.export, vec![2, 1], "milestone-7: result_record_output order must be [2,1] (mid, final_q1)"); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + // adaptive_branch records result-ids 2,1 -> r2 = mid (q0 after h), r1 = final_q1. Invariant r1==r2. + let (mut all_eq, mut saw0, mut saw1) = (true, false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let mid = shot.data.get("r2").and_then(Data::as_u32).expect("r2 (mid)"); + let fin = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (final_q1)"); + if fin != mid { + all_eq = false; + } + saw0 |= mid == 0; + saw1 |= mid == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(all_eq, "milestone-7: final_q1 (r1) must equal mid (r2) -- qec.if firing from parsed input"); + assert!(saw0 && saw1, "milestone-7: branch must both fire (mid=1) and not (mid=0)"); + println!("[milestone-7 adaptive_branch.ll -> qec.if branch TAKEN from parsed input] OK -- r1(final_q1)==r2(mid) all 200 shots, mid 0 and 1 both seen"); +} + +// ===================== regression tests (the milestones, run via `cargo test`) ===================== +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn m0_hand_built_bell_seam() { + run_and_check("milestone-0 hand-built Bell", bell_message(), 200); + } + #[test] + fn m1_pliron_emitted_bell() { + run_milestone_1(); + } + #[test] + fn m2_pliron_classical_control_engine() { + run_milestone_2(); + } + #[test] + fn m3_bell_ll_parse() { + run_milestone_3(); + } + #[test] + fn m4_adaptive_multi_batch() { + run_milestone_4(); + } + #[test] + fn m5_region_based_qec_if() { + run_milestone_5(); + } + #[test] + fn m6_literal_qprog_ll() { + run_milestone_6(); + } + #[test] + fn m7_branch_taken_qec_if() { + run_milestone_7(); + } + + // ---- negative tests: prove the verifiers and seam invariants actually bite ---- + + /// `qec.h` on a `qec.alloc` handle (an alloc, not a qubitref) must be rejected by verification. + /// Guards against the gate verifiers silently regressing to `verifier="succ"` no-ops. + #[test] + fn negative_h_on_alloc_rejected() { + let ctx = &mut Context::new(); + let alloc = QallocOp::new(ctx); + let bad = HOp::new(ctx, alloc.get_result(ctx)); + let res = verify_op(&bad, ctx); + assert!(res.is_err(), "qec.h on a qec.alloc handle must fail verification, got Ok"); + } + + /// `ByteMessage::create_empty()` is *semantically* empty (`is_empty() == Ok(true)`) even though + /// its `as_bytes()` is not byte-empty -- the M2-era footgun that made single-batch engines loop + /// forever until they signalled `EngineStage::Complete` explicitly. Lock the invariant down. + #[test] + fn empty_message_is_semantically_empty() { + let empty = ByteMessage::create_empty(); + assert!(empty.is_empty().unwrap(), "create_empty() must be semantically empty (is_empty()==Ok(true))"); + assert!(!empty.as_bytes().is_empty(), "create_empty().as_bytes() is NOT byte-empty -- that is the footgun"); + } + + /// A `result_record_output` that names a result-id no measurement produced must fail loud, not + /// silently record a default 0. Guards the explicit measurement-SSA -> export resolution. + #[test] + #[should_panic(expected = "unknown result-id 9")] + fn negative_record_of_unknown_result_id_panics() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 9, i8* null) + ret i64 0 +} +"; + let ctx = &mut Context::new(); + let _ = parse_qprog_ll(ctx, BAD); // records result-id 9, which no measurement defines + } } From 6fe9b1f1e6d9cd2a1029fa020c987461d9461959 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 16:18:05 -0600 Subject: [PATCH 221/388] Phase 1: pecos-phir-pliron measurement-SSA registry -- side-table metadata, lightweight measure ops --- exp/pecos-phir-pliron/src/lib.rs | 200 ++++++++++++++++++++----------- 1 file changed, 133 insertions(+), 67 deletions(-) diff --git a/exp/pecos-phir-pliron/src/lib.rs b/exp/pecos-phir-pliron/src/lib.rs index 4077e87ce..8025ef01d 100644 --- a/exp/pecos-phir-pliron/src/lib.rs +++ b/exp/pecos-phir-pliron/src/lib.rs @@ -101,9 +101,49 @@ mod angle_attr { dict_key!(BITS, "qec_angle_bits"); } -mod result_attr { - use pliron::dict_key; - dict_key!(ID, "qec_result_id"); +// ===================== measurement-SSA registry (measurement-id-system.md, Phase 1) ===================== +// A `qec.measure` op produces an SSA `Value` -- that value IS the measurement's identity. Per the +// design note, all per-measurement metadata (qubit, basis, export label) lives in a side table +// keyed by that Value, NOT bolted onto the op as attributes: ops stay lightweight, and the hot +// path (plan/engine) keys lookups by Value. No position-dependent indices anywhere. + +/// Measurement basis. Every measurement in the current QIS path is a Z measurement (`mz`); the field +/// gives the side-table a home for basis so a future X/Y measurement needs no schema change. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Basis { + X, + Y, + Z, +} + +/// Side-table metadata for one measurement-SSA value (kept off the op, per the design note). +#[derive(Clone, Copy, Debug)] +pub struct MeasurementInfo { + pub qubit: usize, + pub basis: Basis, + pub export_label: u64, // QIS result-id: the name this measurement records under +} + +/// The circuit's measurement side-table: `measurement-SSA Value -> MeasurementInfo`. Populated as +/// `qec.measure` ops are built; the plan/engine look measurements up by Value. `HashMap` (not +/// `BTreeMap`) because pliron `Value` is not `Ord`, and the table is only point-queried -- never +/// iterated for output, so there is no determinism concern (export order comes from `qec.record`). +#[derive(Clone, Default)] +pub struct MeasurementRegistry { + info: HashMap, +} +impl MeasurementRegistry { + /// Record a freshly built measurement-SSA value and its metadata. + pub fn record(&mut self, value: Value, info: MeasurementInfo) { + self.info.insert(value, info); + } + /// Look up a measurement-SSA value; panics (fail loud) if the value was never registered. + pub fn get(&self, value: Value) -> MeasurementInfo { + *self + .info + .get(&value) + .unwrap_or_else(|| panic!("measurement-SSA value not in the measurement registry")) + } } /// Store an f64 angle on an op as the bit pattern in an IntegerAttr (pliron has no float attr here). @@ -209,26 +249,17 @@ impl Verify for CxOp { } } -/// `%result = qec.measure(qubitref) [result_id=N]` -- a measurement. Its result `%result` IS the -/// measurement-SSA value; `result_id` is the QIS export label (the 2nd `i64` of `m__body`), carried -/// so a later `qec.record` can name exactly which measurement becomes a program output. +/// `%result = qec.measure(qubitref)` -- a measurement. Its result `%result` IS the measurement-SSA +/// value (the identity); all per-measurement metadata (qubit, basis, export label) lives in the +/// `MeasurementRegistry` side-table keyed by that value, not on the op (ops stay lightweight). #[pliron_op(name = "qec.measure", format, interfaces = [NOpdsInterface<1>, OneResultInterface, NResultsInterface<1>])] pub struct MeasureOp; impl MeasureOp { - pub fn new(ctx: &mut Context, qref: Value, result_id: u64) -> Self { + pub fn new(ctx: &mut Context, qref: Value) -> Self { let i1 = IntegerType::get(ctx, 1, Signedness::Signless); let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![i1.into()], vec![qref], vec![], 0); - let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless); - let attr = IntegerAttr::new(i64_ty, APInt::from_u64(result_id, bw(64))); - op.deref_mut(ctx).attributes.0.insert(result_attr::ID.clone(), Box::new(attr)); MeasureOp { op } } - pub fn result_id(&self, ctx: &Context) -> u64 { - let op = self.get_operation().deref(ctx); - let attr: AttrObj = op.attributes.0.get(&*result_attr::ID).expect("measure result-id attr").clone(); - let ia = attr.downcast::().unwrap_or_else(|_| panic!("result-id not IntegerAttr")); - Into::::into(*ia).to_u64() - } } impl Verify for MeasureOp { fn verify(&self, ctx: &Context) -> Result<()> { @@ -390,8 +421,8 @@ pub fn build_bell_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { push!(PrepareOp::new(ctx, s1v)); push!(HOp::new(ctx, s0v)); push!(CxOp::new(ctx, s0v, s1v)); - push!(MeasureOp::new(ctx, s0v, 0)); - push!(MeasureOp::new(ctx, s1v, 1)); + push!(MeasureOp::new(ctx, s0v)); + push!(MeasureOp::new(ctx, s1v)); push!(EndOp::new(ctx)); (module, bb) } @@ -633,7 +664,7 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr push!(CxOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); } "m" => { - push!(MeasureOp::new(ctx, slot_of[&a[0]], a[1] as u64)); + push!(MeasureOp::new(ctx, slot_of[&a[0]])); } _ => {} } @@ -681,8 +712,9 @@ pub struct AdaptivePlan { } /// Walk a `qec` block once and split it into the two-batch adaptive plan at the `cond_x` boundary. -/// Qubit identity comes only from the explicit slot-index map (same discipline as the Bell port). -pub fn plan_from_ir(ctx: &Context, block: Ptr) -> AdaptivePlan { +/// Gate qubits come from the explicit slot-index map; measurement metadata (qubit + export label) +/// comes from the `MeasurementRegistry`, keyed by the measure op's SSA value. +pub fn plan_from_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegistry) -> AdaptivePlan { let mut qubit_of: HashMap = HashMap::new(); let (mut batch1, mut batch2): (Vec, Vec) = (Vec::new(), Vec::new()); let mut after_cond = false; @@ -704,14 +736,13 @@ pub fn plan_from_ir(ctx: &Context, block: Ptr) -> AdaptivePlan { let t = qubit_of[&opn.deref(ctx).get_operand(1)]; if after_cond { batch2.push(Cmd::Cx(c, t)) } else { batch1.push(Cmd::Cx(c, t)) } } else if let Some(m) = Operation::get_op::(op, ctx) { - let q = qubit_of[&m.get_operation().deref(ctx).get_operand(0)]; - let rid = m.result_id(ctx); + let info = reg.get(m.get_result(ctx)); if after_cond { - batch2.push(Cmd::Mz(q, rid)); + batch2.push(Cmd::Mz(info.qubit, info.export_label)); } else { cond_outcome_idx = mz_b1; mz_b1 += 1; - batch1.push(Cmd::Mz(q, rid)); + batch1.push(Cmd::Mz(info.qubit, info.export_label)); } } else if let Some(c) = Operation::get_op::(op, ctx) { cond_target = qubit_of[&c.get_operation().deref(ctx).get_operand(1)]; @@ -840,7 +871,7 @@ impl ControlEngine for PlironAdaptiveEngine { } } -pub fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { +pub fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr, MeasurementRegistry) { let module = ModuleOp::new(ctx, "adaptive".try_into().unwrap()); let func_ty = FunctionType::get(ctx, vec![], vec![]); let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); @@ -858,21 +889,24 @@ pub fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { push!(PrepareOp::new(ctx, s0v)); push!(PrepareOp::new(ctx, s1v)); push!(HOp::new(ctx, s0v)); - let m0 = push!(MeasureOp::new(ctx, s0v, 0)); // mid measure (conditioning), result-id 0 + let mut reg = MeasurementRegistry::default(); + let m0 = push!(MeasureOp::new(ctx, s0v)); // mid measure (conditioning) let m0v = m0.get_result(ctx); + reg.record(m0v, MeasurementInfo { qubit: 0, basis: Basis::Z, export_label: 0 }); push!(CondXOp::new(ctx, m0v, s1v)); // X q1 iff m0 == 1 - push!(MeasureOp::new(ctx, s1v, 1)); // final measure, result-id 1 + let m1 = push!(MeasureOp::new(ctx, s1v)); // final measure + reg.record(m1.get_result(ctx), MeasurementInfo { qubit: 1, basis: Basis::Z, export_label: 1 }); push!(EndOp::new(ctx)); - (module, bb) + (module, bb, reg) } pub fn run_milestone_4() { let ctx = &mut Context::new(); - let (module, bb) = build_adaptive_ir(ctx); + let (module, bb, reg) = build_adaptive_ir(ctx); println!("=== adaptive (mid-measure -> cond_x -> final) pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-4 verify] FAILED: {}", e.disp(ctx))); - let plan = plan_from_ir(ctx, bb); + let plan = plan_from_ir(ctx, bb, ®); let engine = PlironAdaptiveEngine { plan, stage: 0, b1: Vec::new(), b2: Vec::new() }; // Drive through the REAL HybridEngine this time (closes the "wrapper unrun" gap from round 3). @@ -904,7 +938,8 @@ pub fn run_milestone_4() { // region across batches. This proves region-based control flow (vision-aligned; no CFG flattening). /// Lower the ops of a single block (a `qec.if` region body) to a Cmd list, reusing the slot map. -pub fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap) -> Vec { +/// Gate qubits come from `qubit_of`; measurement qubit + export label come from the registry. +pub fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap, reg: &MeasurementRegistry) -> Vec { let mut cmds = Vec::new(); for op in block.deref(ctx).iter(ctx).collect::>() { if let Some(x) = Operation::get_op::(op, ctx) { @@ -917,7 +952,8 @@ pub fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap(op, ctx) { - cmds.push(Cmd::Mz(qubit_of[&m.get_operation().deref(ctx).get_operand(0)], m.result_id(ctx))); + let info = reg.get(m.get_result(ctx)); + cmds.push(Cmd::Mz(info.qubit, info.export_label)); } } cmds @@ -933,8 +969,8 @@ pub struct IfPlan { } /// Walk the func block; split at the `qec.if` boundary, reading the two region bodies as the -/// then/else command lists. -pub fn plan_from_if_ir(ctx: &Context, block: Ptr) -> IfPlan { +/// then/else command lists. Measurement metadata + export order come from the registry. +pub fn plan_from_if_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegistry) -> IfPlan { let mut qubit_of: HashMap = HashMap::new(); let (mut batch1, mut post, mut then_cmds, mut else_cmds) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); let mut after_if = false; @@ -973,25 +1009,23 @@ pub fn plan_from_if_ir(ctx: &Context, block: Ptr) -> IfPlan { let bq = qubit_of[&opn.deref(ctx).get_operand(1)]; if after_if { post.push(Cmd::Szz(a, bq)) } else { batch1.push(Cmd::Szz(a, bq)) } } else if let Some(m) = Operation::get_op::(op, ctx) { - let q = qubit_of[&m.get_operation().deref(ctx).get_operand(0)]; - let rid = m.result_id(ctx); + let info = reg.get(m.get_result(ctx)); if after_if { - post.push(Cmd::Mz(q, rid)); + post.push(Cmd::Mz(info.qubit, info.export_label)); } else { cond_outcome_idx = mz_b1; mz_b1 += 1; - batch1.push(Cmd::Mz(q, rid)); + batch1.push(Cmd::Mz(info.qubit, info.export_label)); } } else if let Some(ifop) = Operation::get_op::(op, ctx) { - then_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 0), &qubit_of); - else_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 1), &qubit_of); + then_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 0), &qubit_of, reg); + else_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 1), &qubit_of, reg); after_if = true; } else if let Some(rec) = Operation::get_op::(op, ctx) { - // export order = textual order of qec.record; resolve the recorded measurement-SSA back - // to its QIS result-id via the operand's defining qec.measure. + // export order = textual order of qec.record; resolve the recorded measurement-SSA value + // to its export label via the registry (the value is the identity). let recorded = rec.get_operation().deref(ctx).get_operand(0); - let mop = recorded.defining_op().and_then(|o| Operation::get_op::(o, ctx)).expect("qec.record operand must be a qec.measure result"); - export.push(mop.result_id(ctx)); + export.push(reg.get(recorded).export_label); } } IfPlan { batch1, cond_outcome_idx, then_cmds, else_cmds, post, export } @@ -1100,7 +1134,7 @@ impl ControlEngine for PlironIfEngine { fn reset(&mut self) -> std::result::Result<(), PecosError> { Engine::reset(self) } } -pub fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { +pub fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr, MeasurementRegistry) { let module = ModuleOp::new(ctx, "adaptive_if".try_into().unwrap()); let func_ty = FunctionType::get(ctx, vec![], vec![]); let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); @@ -1118,27 +1152,30 @@ pub fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { push!(PrepareOp::new(ctx, s0v)); push!(PrepareOp::new(ctx, s1v)); push!(HOp::new(ctx, s0v)); - let m0 = push!(MeasureOp::new(ctx, s0v, 0)); // mid measure (conditioning), result-id 0 + let mut reg = MeasurementRegistry::default(); + let m0 = push!(MeasureOp::new(ctx, s0v)); // mid measure (conditioning) let m0v = m0.get_result(ctx); + reg.record(m0v, MeasurementInfo { qubit: 0, basis: Basis::Z, export_label: 0 }); let ifop = push!(IfOp::new(ctx, m0v)); // if m0 { x q1 } else { } let then_bb = ifop.make_region_block(ctx, 0); XOp::new(ctx, s1v).get_operation().insert_at_back(then_bb, ctx); let _else_bb = ifop.make_region_block(ctx, 1); - let m1 = push!(MeasureOp::new(ctx, s1v, 1)); // final measure, result-id 1 + let m1 = push!(MeasureOp::new(ctx, s1v)); // final measure let m1v = m1.get_result(ctx); + reg.record(m1v, MeasurementInfo { qubit: 1, basis: Basis::Z, export_label: 1 }); push!(RecordOp::new(ctx, m0v)); // record mid -> register r0 push!(RecordOp::new(ctx, m1v)); // record final -> register r1 push!(EndOp::new(ctx)); - (module, bb) + (module, bb, reg) } pub fn run_milestone_5() { let ctx = &mut Context::new(); - let (module, bb) = build_if_ir(ctx); + let (module, bb, reg) = build_if_ir(ctx); println!("=== region-based conditional (qec.if) pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-5 verify] FAILED: {}", e.disp(ctx))); - let plan = plan_from_if_ir(ctx, bb); + let plan = plan_from_if_ir(ctx, bb, ®); let engine = PlironIfEngine { batch1: plan.batch1, cond_outcome_idx: plan.cond_outcome_idx, @@ -1226,9 +1263,10 @@ pub fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { } } } -/// Emit one parsed op into `block`. `measured` accumulates `result-id -> measurement-SSA Value` -/// so a later `ParsedOp::Record` resolves exactly which `qec.measure` becomes a program output. -pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap, measured: &mut HashMap) { +/// Emit one parsed op into `block`. `measured` accumulates `result-id -> measurement-SSA Value` so a +/// later `ParsedOp::Record` resolves exactly which `qec.measure` becomes a program output; `reg` +/// gets each measurement's metadata (qubit, basis, export label) keyed by its SSA value. +pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap, measured: &mut HashMap, reg: &mut MeasurementRegistry) { match *p { ParsedOp::H(q) => { HOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } ParsedOp::Rz(q, t) => { RzOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } @@ -1237,9 +1275,11 @@ pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot ParsedOp::Szz(a, b) => { SzzOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } ParsedOp::X(q) => { XOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } ParsedOp::M(q, rid) => { - let m = MeasureOp::new(ctx, slot_of[&q], rid); + let m = MeasureOp::new(ctx, slot_of[&q]); m.get_operation().insert_at_back(block, ctx); - measured.insert(rid, m.get_result(ctx)); + let v = m.get_result(ctx); + measured.insert(rid, v); + reg.record(v, MeasurementInfo { qubit: q, basis: Basis::Z, export_label: rid }); } ParsedOp::Record(rid) => { let v = *measured.get(&rid).unwrap_or_else(|| panic!("result_record_output references unknown result-id {rid}")); @@ -1249,7 +1289,7 @@ pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot } /// Parse qprog.ll's diamond CFG, lifting the conditional branch into a `qec.if`. -pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { +pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr, MeasurementRegistry) { // pass 1: collect ops per block label (entry = "") and the conditional-branch targets. let mut blocks: Vec<(String, Vec)> = Vec::new(); let mut cur_label = String::new(); @@ -1324,37 +1364,39 @@ pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr measurement-SSA Value, so result_record_output can name the recorded measurement. let mut measured: HashMap = HashMap::new(); + let mut reg = MeasurementRegistry::default(); // entry gates (everything before the trailing mid-measure), then the mid measure (the cond). let (mid_q, mid_rid) = match entry_ops.last() { Some(ParsedOp::M(q, rid)) => (*q, *rid), _ => panic!("expected entry block to end with a measurement (qprog mid-measure)"), }; - for p in &entry_ops[..entry_ops.len() - 1] { emit_parsed(ctx, p, bb, &slot_of, &mut measured); } - let m0 = MeasureOp::new(ctx, slot_of[&mid_q], mid_rid); + for p in &entry_ops[..entry_ops.len() - 1] { emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg); } + let m0 = MeasureOp::new(ctx, slot_of[&mid_q]); m0.get_operation().insert_at_back(bb, ctx); let m0v = m0.get_result(ctx); measured.insert(mid_rid, m0v); + reg.record(m0v, MeasurementInfo { qubit: mid_q, basis: Basis::Z, export_label: mid_rid }); // lift the diamond into qec.if(mid) { then } { else } let ifop = IfOp::new(ctx, m0v); ifop.get_operation().insert_at_back(bb, ctx); let then_bb = ifop.make_region_block(ctx, 0); - for p in &then_ops { emit_parsed(ctx, p, then_bb, &slot_of, &mut measured); } + for p in &then_ops { emit_parsed(ctx, p, then_bb, &slot_of, &mut measured, &mut reg); } let else_bb = ifop.make_region_block(ctx, 1); - for p in &else_ops { emit_parsed(ctx, p, else_bb, &slot_of, &mut measured); } + for p in &else_ops { emit_parsed(ctx, p, else_bb, &slot_of, &mut measured, &mut reg); } // final measurements + result_record_output ops (the export list) - for p in &merge_ops { emit_parsed(ctx, p, bb, &slot_of, &mut measured); } + for p in &merge_ops { emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg); } EndOp::new(ctx).get_operation().insert_at_back(bb, ctx); - (module, bb) + (module, bb, reg) } pub fn run_milestone_6() { let ctx = &mut Context::new(); let src = include_str!("../../../examples/llvm/qprog.ll"); - let (module, bb) = parse_qprog_ll(ctx, src); + let (module, bb, reg) = parse_qprog_ll(ctx, src); println!("=== qprog.ll parsed into pliron qec IR (rotations + qec.if) ==="); println!("{}", module.get_operation().disp(ctx)); verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-6 verify] FAILED: {}", e.disp(ctx))); - let plan = plan_from_if_ir(ctx, bb); + let plan = plan_from_if_ir(ctx, bb, ®); let engine = PlironIfEngine { batch1: plan.batch1, cond_outcome_idx: plan.cond_outcome_idx, @@ -1397,11 +1439,11 @@ pub fn run_milestone_6() { pub fn run_milestone_7() { let ctx = &mut Context::new(); let src = include_str!("../fixtures/adaptive_branch.ll"); - let (module, bb) = parse_qprog_ll(ctx, src); + let (module, bb, reg) = parse_qprog_ll(ctx, src); println!("=== adaptive_branch.ll parsed into pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-7 verify] FAILED: {}", e.disp(ctx))); - let plan = plan_from_if_ir(ctx, bb); + let plan = plan_from_if_ir(ctx, bb, ®); assert_eq!(plan.export, vec![2, 1], "milestone-7: result_record_output order must be [2,1] (mid, final_q1)"); let engine = PlironIfEngine { batch1: plan.batch1, @@ -1474,6 +1516,30 @@ mod tests { run_milestone_7(); } + /// The measurement-SSA registry is the metadata home: every `qec.measure` value resolves to its + /// (qubit, basis, export-label) via the side-table, with no attribute on the op. Keyed by the + /// SSA value -- the value IS the identity. + #[test] + fn registry_holds_measurement_metadata() { + let ctx = &mut Context::new(); + let src = include_str!("../../../examples/llvm/qprog.ll"); + let (_module, bb, reg) = parse_qprog_ll(ctx, src); + let mut infos: Vec<(usize, Basis, u64)> = bb + .deref(ctx) + .iter(ctx) + .collect::>() + .into_iter() + .filter_map(|op| Operation::get_op::(op, ctx)) + .map(|m| { + let i = reg.get(m.get_result(ctx)); + (i.qubit, i.basis, i.export_label) + }) + .collect(); + infos.sort_by_key(|&(q, _, label)| (label, q)); + // qprog.ll: result-id 0 = final q0, 1 = final q1, 2 = mid q0 -- all Z measurements. + assert_eq!(infos, vec![(0, Basis::Z, 0), (1, Basis::Z, 1), (0, Basis::Z, 2)]); + } + // ---- negative tests: prove the verifiers and seam invariants actually bite ---- /// `qec.h` on a `qec.alloc` handle (an alloc, not a qubitref) must be rejected by verification. From 5013c079cde9d3f1f77b5f4f87e4e83c2839bc17 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 14:32:52 -0600 Subject: [PATCH 222/388] Add .shots(n) and an argless .run() to the Python sim() facade --- python/pecos-rslib/src/engine_builders.rs | 10 ++++ python/pecos-rslib/src/sim.rs | 55 ++++++++++++++++++- .../tests/pecos/test_sim_stack_routing.py | 26 +++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 09166704f..c880c4b1b 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -79,6 +79,7 @@ impl PyQasmEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -192,6 +193,7 @@ impl PyQisEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -240,6 +242,7 @@ impl PyPhirJsonEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -256,6 +259,7 @@ pub struct PyQasmSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, @@ -354,6 +358,7 @@ pub struct PyQisControlSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, @@ -423,6 +428,7 @@ pub struct PyPhirJsonSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, @@ -461,6 +467,7 @@ impl PyPhirEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -474,6 +481,7 @@ pub struct PyPhirSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, @@ -574,6 +582,7 @@ impl PyHugrEngineBuilder { engine_builder: Arc::new(Mutex::new(Some(self.inner.clone()))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -591,6 +600,7 @@ pub struct PyHugrSimBuilder { pub(crate) engine_builder: Arc>>, pub(crate) seed: Option, pub(crate) workers: Option, + pub(crate) shots: Option, pub(crate) quantum_engine_builder: Option>, pub(crate) noise_builder: Option>, pub(crate) explicit_num_qubits: Option, diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index e6429033e..4413c85a2 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -102,6 +102,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { engine_builder: Arc::new(Mutex::new(Some(engine_builder))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -148,6 +149,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { engine_builder: Arc::new(Mutex::new(Some(engine_builder))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -173,6 +175,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { engine_builder: Arc::new(Mutex::new(Some(engine_builder))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -190,6 +193,7 @@ pub fn sim(py: Python, program: Py) -> PyResult { engine_builder: Arc::new(Mutex::new(Some(engine_builder))), seed: None, workers: None, + shots: None, quantum_engine_builder: None, noise_builder: None, explicit_num_qubits: None, @@ -335,6 +339,7 @@ impl PySimBuilder { engine_builder: Arc::new(Mutex::new(Some(qis_engine))), seed: sim_builder.seed, workers: sim_builder.workers, + shots: sim_builder.shots, quantum_engine_builder: clone_py_any_option( py, sim_builder.quantum_engine_builder.as_ref(), @@ -450,6 +455,25 @@ impl PySimBuilder { self.workers(workers) } + /// Set the number of Monte Carlo shots to run. + /// + /// Mirrors the Rust facade's `.shots(n)`: configure the shot count on the + /// builder, then call `.run()` with no argument. The legacy `.run(shots)` + /// still works and, when given, overrides this. + fn shots(&mut self, shots: usize) -> PyResult { + match &mut self.inner { + SimBuilderInner::Qasm(builder) => builder.shots = Some(shots), + SimBuilderInner::QisControl(builder) => builder.shots = Some(shots), + SimBuilderInner::Hugr(builder) => builder.shots = Some(shots), + SimBuilderInner::PhirJson(builder) => builder.shots = Some(shots), + SimBuilderInner::Phir(builder) => builder.shots = Some(shots), + SimBuilderInner::Empty => {} + } + Ok(PySimBuilder { + inner: self.inner.clone(), + }) + } + /// Set quantum simulator/engine fn quantum(&mut self, engine: Py) -> PyResult { match &mut self.inner { @@ -745,9 +769,14 @@ impl PySimBuilder { } } - /// Run the simulation + /// Run the simulation. + /// + /// `shots` may be passed here (`run(1000)`) or configured on the builder + /// first (`.shots(1000).run()`); a `run()` argument overrides `.shots(n)`. + /// With neither set, this fails fast rather than defaulting silently. + #[pyo3(signature = (shots=None))] #[allow(clippy::too_many_lines)] // Complex simulation dispatch with multiple engine types - fn run(&self, shots: usize) -> PyResult { + fn run(&self, shots: Option) -> PyResult { use crate::engine_builders::{ PyBiasedDepolarizingNoiseModelBuilder, PyDepolarizingNoiseModelBuilder, PyGeneralNoiseModelBuilder, @@ -759,6 +788,23 @@ impl PySimBuilder { use crate::shot_results_bindings::PyShotVec; use pyo3::exceptions::PyRuntimeError; + // Resolve the shot count: an explicit `run(shots)` argument wins, then + // the builder's `.shots(n)`, else fail fast (no silent default). + let configured = match &self.inner { + SimBuilderInner::Qasm(b) => b.shots, + SimBuilderInner::QisControl(b) => b.shots, + SimBuilderInner::Hugr(b) => b.shots, + SimBuilderInner::PhirJson(b) => b.shots, + SimBuilderInner::Phir(b) => b.shots, + SimBuilderInner::Empty => None, + }; + let shots = shots.or(configured).ok_or_else(|| { + PyValueError::new_err( + "No shot count configured; pass run(shots) or set .shots(n) before .run(). \ + Example: sim(program).shots(1000).run()", + ) + })?; + log::debug!("PySimBuilder::run() called with {shots} shots"); match &self.inner { @@ -1904,6 +1950,7 @@ impl Clone for SimBuilderInner { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() @@ -1919,6 +1966,7 @@ impl Clone for SimBuilderInner { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() @@ -1934,6 +1982,7 @@ impl Clone for SimBuilderInner { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() @@ -1945,6 +1994,7 @@ impl Clone for SimBuilderInner { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() @@ -1960,6 +2010,7 @@ impl Clone for SimBuilderInner { engine_builder: builder.engine_builder.clone(), seed: builder.seed, workers: builder.workers, + shots: builder.shots, quantum_engine_builder: builder .quantum_engine_builder .as_ref() diff --git a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py index ecb2946ef..23954c2af 100644 --- a/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py +++ b/python/quantum-pecos/tests/pecos/test_sim_stack_routing.py @@ -143,3 +143,29 @@ def test_missing_source_wins_over_classical_override_on_neo() -> None: for stack in ["engines", "neo"]: with pytest.raises(RuntimeError, match="No QASM source specified"): qasm_engine().to_sim().classical(qasm_engine()).stack(stack).run(1) + + +# --- Unified .shots(n) / argless .run() (mirrors the Rust facade) ---------- + + +@pytest.mark.parametrize("stack", ["engines", "neo"]) +def test_shots_builder_matches_run_argument(stack: str) -> None: + """`.shots(n).run()` must equal `.run(n)` on both stacks: shots is a + builder concern, and the argless run is the unified spelling.""" + via_shots = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack(stack).seed(42).shots(5).run() + via_arg = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).stack(stack).seed(42).run(5) + assert list(via_shots["c"]) == list(via_arg["c"]) + assert len(list(via_shots["c"])) == 5 + + +def test_run_argument_overrides_shots_builder() -> None: + """A `run(shots)` argument wins over a prior `.shots(n)`.""" + results = sim(Qasm.from_string(DETERMINISTIC_CONDITIONAL)).seed(42).shots(99).run(5) + assert len(list(results["c"])) == 5 + + +def test_run_without_shots_fails_fast() -> None: + """Neither `.shots(n)` nor a `run()` argument -> a loud error, never a + silent default.""" + with pytest.raises(ValueError, match="No shot count configured"): + sim(Qasm.from_string(X_MEASURE)).seed(42).run() From 9c92b13c62f693f0bf62873d62df6ef4c8cb963f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 16:39:05 -0600 Subject: [PATCH 223/388] Phase 1: pecos-phir-pliron differential vs pecos-phir + measure-in-branch coverage --- exp/pecos-phir-pliron/Cargo.toml | 4 + .../fixtures/branch_measure.ll | 37 ++++++ exp/pecos-phir-pliron/src/lib.rs | 119 ++++++++++++++++++ 3 files changed, 160 insertions(+) create mode 100644 exp/pecos-phir-pliron/fixtures/branch_measure.ll diff --git a/exp/pecos-phir-pliron/Cargo.toml b/exp/pecos-phir-pliron/Cargo.toml index f25aacf39..f29d31f2c 100644 --- a/exp/pecos-phir-pliron/Cargo.toml +++ b/exp/pecos-phir-pliron/Cargo.toml @@ -16,3 +16,7 @@ pecos-engines.workspace = true pecos-core.workspace = true pliron = { path = "/home/ciaranra/Repos/pliron" } awint = "0.18" + +[dev-dependencies] +# The existing QIS->PHIR->sim path, for the strangler differential (compare this port against it). +pecos-phir.workspace = true diff --git a/exp/pecos-phir-pliron/fixtures/branch_measure.ll b/exp/pecos-phir-pliron/fixtures/branch_measure.ll new file mode 100644 index 000000000..363e8c499 --- /dev/null +++ b/exp/pecos-phir-pliron/fixtures/branch_measure.ll @@ -0,0 +1,37 @@ +; Adaptive program with a measurement INSIDE the conditional branch. Coverage for the engine's +; outcome reconstruction when b2's length depends on which branch ran (taken: [m1, f0, f1]; +; skipped: [f0, f1]) -- the j-index must walk the actually-emitted measures, not a fixed layout. +; h q0 -> mid = m(q0) -> if mid==1 { x q1; m1 = m(q1, rid 3) } -> final m(q0, rid 0); m(q1, rid 1) +; The in-branch m1 is measured for side effect only and is NOT recorded: recording a value defined +; inside a qec.if region from the outer block is a cross-region SSA escape that needs block-args / +; yield (measurement-SSA Phase 2), out of scope here. Recorded outputs are the unconditional finals. +; Invariants: final_q0 (r0) == mid and final_q1 (r1) == mid in every shot (the in-branch x+measure +; on q1 leaves q1 == mid, so the later final measure still reads mid). +declare void @__quantum__qis__h__body(i64) +declare void @__quantum__qis__x__body(i64) +declare i32 @__quantum__qis__m__body(i64, i64) +declare void @__quantum__rt__result_record_output(i64, i8*) + +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x + +apply_x: + call void @__quantum__qis__x__body(i64 1) + %m1 = call i32 @__quantum__qis__m__body(i64 1, i64 3) + br label %final + +skip_x: + br label %final + +final: + %f0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} + +attributes #0 = { "EntryPoint" } diff --git a/exp/pecos-phir-pliron/src/lib.rs b/exp/pecos-phir-pliron/src/lib.rs index 8025ef01d..2a2e7a77a 100644 --- a/exp/pecos-phir-pliron/src/lib.rs +++ b/exp/pecos-phir-pliron/src/lib.rs @@ -1478,11 +1478,126 @@ pub fn run_milestone_7() { println!("[milestone-7 adaptive_branch.ll -> qec.if branch TAKEN from parsed input] OK -- r1(final_q1)==r2(mid) all 200 shots, mid 0 and 1 both seen"); } +/// Coverage for a measurement INSIDE the conditional branch: `b2`'s length varies with the taken +/// branch (taken: `[m1, f0, f1]`; skipped: `[f0, f1]`), so the engine's outcome reconstruction must +/// walk the actually-emitted measures, not a fixed layout. The in-branch measure is not recorded +/// (a cross-region SSA escape needs yield -- Phase 2); recorded outputs are the unconditional finals. +pub fn run_branch_measure() { + let ctx = &mut Context::new(); + let src = include_str!("../fixtures/branch_measure.ll"); + let (module, bb, reg) = parse_qprog_ll(ctx, src); + verify_op(&module, ctx).unwrap_or_else(|e| panic!("[branch_measure verify] FAILED: {}", e.disp(ctx))); + let plan = plan_from_if_ir(ctx, bb, ®); + // structural: the then-branch really does contain a measurement (this is what we are covering), + // and the in-branch measure (result-id 3) is NOT in the export list. + assert!(plan.then_cmds.iter().any(|c| matches!(c, Cmd::Mz(..))), "then-branch must contain a measurement"); + assert_eq!(plan.export, vec![0, 1], "only the unconditional finals are recorded (in-branch m1 is not)"); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::new(2))) + .build(); + let (mut all_eq, mut saw0, mut saw1) = (true, false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + // r0 = final_q0 (== mid), r1 = final_q1 (== mid). The in-branch measure of q1 leaves q1 == mid. + let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (final_q0)"); + let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (final_q1)"); + if r0 != r1 { + all_eq = false; + } + saw0 |= r0 == 0; + saw1 |= r0 == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(all_eq, "branch_measure: final_q0 (r0) must equal final_q1 (r1) -- variable-length b2 reconstructed correctly"); + assert!(saw0 && saw1, "branch_measure: branch must both fire and not (r0 both 0 and 1)"); + println!("[branch_measure measure-inside-branch -> variable-length b2 reconstruction] OK -- r0==r1 all 200 shots, both 0 and 1 seen"); +} + // ===================== regression tests (the milestones, run via `cargo test`) ===================== #[cfg(test)] mod tests { use super::*; + // ---- strangler differential vs the existing pecos-phir QIS->PHIR->sim path ---- + // Not an equivalence proof: on these fixtures pecos-phir produces nothing / errors, so the + // honest result is a *characterization* of where the pliron port supersedes the murky path. + // These assert pecos-phir's CURRENT behavior so a future change there trips the test and we + // re-examine the cutover criterion. + + /// bell.ll export divergence: the port lowers `result_record_output` to `r{id}` registers and + /// produces the Bell pair; pecos-phir *elides* `result_record_output` (and bell.ll has no other + /// export source), so its `Shot` is empty. + #[test] + fn differential_bell_ll_export_convention() { + let bell = include_str!("../../../examples/llvm/bell.ll"); + + // port side: bell.ll -> pliron qec -> ByteMessage -> seeded StateVecEngine -> Bell pair. + let ctx = &mut Context::new(); + let (_m, bb) = parse_bell_ll(ctx, bell); + let msg = emit_bytemessage(ctx, bb); + let mut sim = StateVecEngine::with_seed(2, 7); + let (mut saw0, mut saw3) = (false, false); + for _ in 0..200 { + sim.reset().unwrap(); + let o = sim.process(msg.clone()).unwrap().outcomes().unwrap(); + let c = (o[1] << 1) | o[0]; // register "c": q1 q0 + assert!(c == 0 || c == 3, "port bell.ll must be Bell-correlated (00/11), got {c}"); + saw0 |= c == 0; + saw3 |= c == 3; + } + assert!(saw0 && saw3, "port bell.ll must see both 00 and 11"); + + // pecos-phir side: same bell.ll, run through its real engine -> empty Shot (no exports). + let module = pecos_phir::parse_qis_to_quantum(bell).expect("pecos-phir parses bell.ll"); + let pe = pecos_phir::PhirEngine::new(module).expect("pecos-phir builds an engine"); + let mut hybrid = pecos_engines::hybrid::HybridEngineBuilder::new() + .with_classical_engine(Box::new(pe)) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(2, 7))) + .build(); + let shot = hybrid.run_shot().unwrap(); + assert!( + shot.data.is_empty(), + "DIVERGENCE: pecos-phir elides result_record_output, so bell.ll yields no exported \ + registers; the port exports r0/r1. pecos-phir gave: {:?}", + shot.data.keys().collect::>() + ); + println!("[differential bell.ll] port -> register \"c\" Bell pair; pecos-phir -> empty Shot (record_output elided)"); + } + + /// qprog.ll rotation divergence: the port lowers `rz/rx/ry/zz` (M6 runs it end-to-end), while + /// pecos-phir's `qis_to_quantum` currently cannot resolve the `rz` angle to a constant and errors. + #[test] + fn differential_qprog_ll_rotation_support() { + let qprog = include_str!("../../../examples/llvm/qprog.ll"); + + // port side: lowers without error (full end-to-end physics is m6). + let ctx = &mut Context::new(); + let (module, _bb, _reg) = parse_qprog_ll(ctx, qprog); + verify_op(&module, ctx).expect("port lowers qprog.ll (rotations + qec.if) and verifies"); + + // pecos-phir side: errors lowering the rotation angle. + let err = pecos_phir::parse_qis_to_quantum(qprog) + .expect_err("DIVERGENCE: pecos-phir is expected to fail lowering qprog.ll's rotations"); + let msg = format!("{err:?}"); + assert!( + msg.contains("angle") || msg.contains("rz"), + "expected the divergence to be the rz-angle resolution; got: {msg}" + ); + println!("[differential qprog.ll] port lowers rotations + qec.if; pecos-phir errors: {msg}"); + } + #[test] fn m0_hand_built_bell_seam() { run_and_check("milestone-0 hand-built Bell", bell_message(), 200); @@ -1515,6 +1630,10 @@ mod tests { fn m7_branch_taken_qec_if() { run_milestone_7(); } + #[test] + fn measure_inside_branch_variable_length_b2() { + run_branch_measure(); + } /// The measurement-SSA registry is the metadata home: every `qec.measure` value resolves to its /// (qubit, basis, export-label) via the side-table, with no attribute on the op. Keyed by the From add0617b4d3e49741455afa34cc1f30b4daf4e13 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 16:43:35 -0600 Subject: [PATCH 224/388] Wrap over-long sim_neo() chains in qec/analysis.py to fix E501 --- .../quantum-pecos/src/pecos/qec/analysis.py | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/analysis.py b/python/quantum-pecos/src/pecos/qec/analysis.py index 94df65691..fb91ac8ce 100644 --- a/python/quantum-pecos/src/pecos/qec/analysis.py +++ b/python/quantum-pecos/src/pecos/qec/analysis.py @@ -587,10 +587,24 @@ def empirical_correlation_table( ) if backend == "meas_sampling": - results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).sampling(monte_carlo(shots)).seed(seed).run() + results = ( + sim_neo(tick_circuit) + .quantum(meas_sampling()) + .noise(noise_builder) + .sampling(monte_carlo(shots)) + .seed(seed) + .run() + ) elif backend in ("stabilizer", "statevec"): backend_obj = stabilizer() if backend == "stabilizer" else statevec() - results = sim_neo(tick_circuit).quantum(backend_obj).noise(noise_builder).sampling(monte_carlo(shots)).seed(seed).run() + results = ( + sim_neo(tick_circuit) + .quantum(backend_obj) + .noise(noise_builder) + .sampling(monte_carlo(shots)) + .seed(seed) + .run() + ) else: supported = "'stabilizer', 'statevec', 'meas_sampling'" msg = f"Unknown backend {backend!r}. Supported: {supported}." @@ -738,10 +752,24 @@ def fit_dem_from_simulation( num_dets = len(det_json) if backend == "meas_sampling": - results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).sampling(monte_carlo(shots)).seed(seed).run() + results = ( + sim_neo(tick_circuit) + .quantum(meas_sampling()) + .noise(noise_builder) + .sampling(monte_carlo(shots)) + .seed(seed) + .run() + ) elif backend in ("stabilizer", "statevec"): backend_obj = stabilizer() if backend == "stabilizer" else statevec() - results = sim_neo(tick_circuit).quantum(backend_obj).noise(noise_builder).sampling(monte_carlo(shots)).seed(seed).run() + results = ( + sim_neo(tick_circuit) + .quantum(backend_obj) + .noise(noise_builder) + .sampling(monte_carlo(shots)) + .seed(seed) + .run() + ) else: supported = "'stabilizer', 'statevec', 'meas_sampling'" msg = f"Unknown backend {backend!r}. Supported: {supported}." From d215826a0c919c27ca66dee34b0e2a802a13fe55 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 18:09:29 -0600 Subject: [PATCH 225/388] pecos-phir-pliron: Bell via qec.record export + region-scope record verifier (round-5 increment) --- exp/pecos-phir-pliron/src/lib.rs | 133 ++++++++++++++++++++++++++----- 1 file changed, 111 insertions(+), 22 deletions(-) diff --git a/exp/pecos-phir-pliron/src/lib.rs b/exp/pecos-phir-pliron/src/lib.rs index 2a2e7a77a..5e37c0122 100644 --- a/exp/pecos-phir-pliron/src/lib.rs +++ b/exp/pecos-phir-pliron/src/lib.rs @@ -288,6 +288,22 @@ impl Verify for RecordOp { if !records_a_measure { return verify_err!(self.loc(ctx), "qec.record operand must be a qec.measure result"); } + // Region scope: the recorded measurement must be visible from this record op -- i.e. its + // defining block must be the record's block or an enclosing one. Recording a measurement + // defined inside a `qec.if` region from the outer block is a cross-region SSA escape that + // needs block-args/yield (measurement-SSA Phase 2); reject it rather than silently allow it. + let meas_block = opnd.get_defining_block(ctx); + let mut cur = self.get_operation().deref(ctx).get_parent_block(); + let visible = loop { + match cur { + Some(b) if Some(b) == meas_block => break true, + Some(b) => cur = b.deref(ctx).get_parent_block(ctx), + None => break false, + } + }; + if !visible { + return verify_err!(self.loc(ctx), "qec.record operand is defined in a non-enclosing region (cross-region escape needs yield)"); + } Ok(()) } } @@ -603,7 +619,7 @@ pub fn run_milestone_2() { /// Minimal QIS-LLVM-IR -> pliron `qec` IR parser for the Bell fixture (`examples/llvm/bell.ll`). /// Recognizes `__quantum__qis__{h,cx,m}__body` calls with integer qubit args. This proves the /// LLVM-IR frontend ports onto pliron ops (not just hand-built IR) -- the last gate-fidelity item. -pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr) { +pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr, MeasurementRegistry) { fn i64_args(line: &str) -> Vec { match (line.find('('), line.rfind(')')) { (Some(l), Some(r)) if r > l => line[l + 1..r] @@ -613,7 +629,7 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr _ => Vec::new(), } } - // pass 1: collect the gate/measure stream + the set of qubits referenced + // pass 1: collect the gate/measure/record stream + the set of qubits referenced let mut parsed: Vec<(&str, Vec)> = Vec::new(); let mut qubits: BTreeSet = BTreeSet::new(); for line in src.lines() { @@ -631,12 +647,14 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr qubits.insert(a[1]); parsed.push(("cx", a)); } else if l.contains("__quantum__qis__m__body") { - let a = i64_args(l); + let a = i64_args(l); // (qubit, result_id) qubits.insert(a[0]); parsed.push(("m", a)); + } else if l.contains("__quantum__rt__result_record_output") { + parsed.push(("record", i64_args(l))); // (result_id) } } - // pass 2: build the pliron qec IR + // pass 2: build the pliron qec IR + the measurement-SSA registry let module = ModuleOp::new(ctx, "bell_from_ll".try_into().unwrap()); let func_ty = FunctionType::get(ctx, vec![], vec![]); let func = FuncOp::new(ctx, "qmain".try_into().unwrap(), func_ty); @@ -655,6 +673,8 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr slot_of.insert(idx, sv); push!(PrepareOp::new(ctx, sv)); // QIS qubits start |0>; that implicit init IS a prepare in the qec model } + let mut reg = MeasurementRegistry::default(); + let mut measured: HashMap = HashMap::new(); // result-id -> measurement-SSA Value for (op, a) in &parsed { match *op { "h" => { @@ -664,19 +684,28 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr push!(CxOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); } "m" => { - push!(MeasureOp::new(ctx, slot_of[&a[0]])); + let m = push!(MeasureOp::new(ctx, slot_of[&a[0]])); + let v = m.get_result(ctx); + let rid = a[1] as u64; + measured.insert(rid, v); + reg.record(v, MeasurementInfo { qubit: a[0], basis: Basis::Z, export_label: rid }); + } + "record" => { + let rid = a[0] as u64; + let v = *measured.get(&rid).unwrap_or_else(|| panic!("result_record_output references unknown result-id {rid}")); + push!(RecordOp::new(ctx, v)); } _ => {} } } push!(EndOp::new(ctx)); - (module, bb) + (module, bb, reg) } pub fn run_milestone_3() { let ctx = &mut Context::new(); let src = include_str!("../../../examples/llvm/bell.ll"); - let (module, bb) = parse_bell_ll(ctx, src); + let (module, bb, _reg) = parse_bell_ll(ctx, src); println!("=== bell.ll parsed into pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-3 verify] FAILED: {}", e.disp(ctx))); @@ -1536,28 +1565,47 @@ mod tests { // These assert pecos-phir's CURRENT behavior so a future change there trips the test and we // re-examine the cutover criterion. - /// bell.ll export divergence: the port lowers `result_record_output` to `r{id}` registers and - /// produces the Bell pair; pecos-phir *elides* `result_record_output` (and bell.ll has no other + /// bell.ll export divergence: the port lowers `result_record_output` to `qec.record` ops and + /// exports `r{id}` registers through the measurement registry (here exercising that real export + /// path, not a manual pack); pecos-phir *elides* `result_record_output` (and bell.ll has no other /// export source), so its `Shot` is empty. #[test] fn differential_bell_ll_export_convention() { let bell = include_str!("../../../examples/llvm/bell.ll"); - // port side: bell.ll -> pliron qec -> ByteMessage -> seeded StateVecEngine -> Bell pair. + // port side: bell.ll -> pliron qec (incl. qec.record + registry) -> the SAME export path as + // qprog (plan_from_if_ir + PlironIfEngine) -> r0/r1 registers. No IfOp -> a single batch. let ctx = &mut Context::new(); - let (_m, bb) = parse_bell_ll(ctx, bell); - let msg = emit_bytemessage(ctx, bb); - let mut sim = StateVecEngine::with_seed(2, 7); - let (mut saw0, mut saw3) = (false, false); + let (module, bb, reg) = parse_bell_ll(ctx, bell); + verify_op(&module, ctx).expect("port lowers bell.ll (with qec.record) and verifies"); + let plan = plan_from_if_ir(ctx, bb, ®); + assert_eq!(plan.export, vec![0, 1], "bell.ll records result-ids 0 (q0) then 1 (q1)"); + let engine = PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; + let mut hybrid = pecos_engines::hybrid::HybridEngineBuilder::new() + .with_classical_engine(Box::new(engine)) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(2, 7))) + .build(); + let (mut saw0, mut saw1) = (false, false); for _ in 0..200 { - sim.reset().unwrap(); - let o = sim.process(msg.clone()).unwrap().outcomes().unwrap(); - let c = (o[1] << 1) | o[0]; // register "c": q1 q0 - assert!(c == 0 || c == 3, "port bell.ll must be Bell-correlated (00/11), got {c}"); - saw0 |= c == 0; - saw3 |= c == 3; + let shot = hybrid.run_shot().unwrap(); + let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (q0)"); + let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (q1)"); + assert_eq!(r0, r1, "port bell.ll via qec.record export must be Bell-correlated, got r0={r0} r1={r1}"); + saw0 |= r0 == 0; + saw1 |= r0 == 1; + Engine::reset(&mut hybrid).unwrap(); } - assert!(saw0 && saw3, "port bell.ll must see both 00 and 11"); + assert!(saw0 && saw1, "port bell.ll must see both 00 and 11"); // pecos-phir side: same bell.ll, run through its real engine -> empty Shot (no exports). let module = pecos_phir::parse_qis_to_quantum(bell).expect("pecos-phir parses bell.ll"); @@ -1573,7 +1621,7 @@ mod tests { registers; the port exports r0/r1. pecos-phir gave: {:?}", shot.data.keys().collect::>() ); - println!("[differential bell.ll] port -> register \"c\" Bell pair; pecos-phir -> empty Shot (record_output elided)"); + println!("[differential bell.ll] port -> r0==r1 Bell pair via qec.record export; pecos-phir -> empty Shot (record_output elided)"); } /// qprog.ll rotation divergence: the port lowers `rz/rx/ry/zz` (M6 runs it end-to-end), while @@ -1672,6 +1720,47 @@ mod tests { assert!(res.is_err(), "qec.h on a qec.alloc handle must fail verification, got Ok"); } + /// Recording a measurement defined *inside* a `qec.if` region from the OUTER block is a + /// cross-region SSA escape (it needs block-args/yield -- Phase 2). `qec.record`'s verifier must + /// reject it, not silently accept it. + #[test] + fn negative_record_of_in_region_measurement_rejected() { + let ctx = &mut Context::new(); + let module = ModuleOp::new(ctx, "bad_region".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + } + let q = push!(QallocOp::new(ctx)); + let qv = q.get_result(ctx); + let s0v = push!(SlotOp::new(ctx, qv, 0)).get_result(ctx); + let s1v = push!(SlotOp::new(ctx, qv, 1)).get_result(ctx); + push!(PrepareOp::new(ctx, s0v)); + push!(PrepareOp::new(ctx, s1v)); + push!(HOp::new(ctx, s0v)); + let m0v = push!(MeasureOp::new(ctx, s0v)).get_result(ctx); + let ifop = push!(IfOp::new(ctx, m0v)); + let then_bb = ifop.make_region_block(ctx, 0); + let m1 = MeasureOp::new(ctx, s1v); // measure INSIDE the then-region + m1.get_operation().insert_at_back(then_bb, ctx); + let m1v = m1.get_result(ctx); + let _else_bb = ifop.make_region_block(ctx, 1); + push!(RecordOp::new(ctx, m1v)); // record it from the OUTER block -- the cross-region escape + push!(EndOp::new(ctx)); + let err = verify_op(&module, ctx).expect_err( + "recording an in-qec.if-region measurement from the outer block must fail verification", + ); + // bite for the RIGHT reason: the region-scope check, not some incidental failure. + assert!( + format!("{}", err.disp(ctx)).contains("cross-region escape"), + "expected the cross-region-escape rejection, got: {}", + err.disp(ctx) + ); + } + /// `ByteMessage::create_empty()` is *semantically* empty (`is_empty() == Ok(true)`) even though /// its `as_bytes()` is not byte-empty -- the M2-era footgun that made single-batch engines loop /// forever until they signalled `EngineStage::Complete` explicitly. Lock the invariant down. From acff9c2f1e8236a0b774b87a91bf81b02906dc4e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 18:40:38 -0600 Subject: [PATCH 226/388] pecos-phir-pliron: native Angle64 fixed-point angle attr + cond_x i1 verifier --- exp/pecos-phir-pliron/src/lib.rs | 102 ++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 30 deletions(-) diff --git a/exp/pecos-phir-pliron/src/lib.rs b/exp/pecos-phir-pliron/src/lib.rs index 5e37c0122..95c1d9390 100644 --- a/exp/pecos-phir-pliron/src/lib.rs +++ b/exp/pecos-phir-pliron/src/lib.rs @@ -33,13 +33,13 @@ use pliron::{ basic_block::BasicBlock, common_traits::Verify, context::{Context, Ptr}, - derive::{pliron_op, pliron_type}, + derive::{pliron_attr, pliron_op, pliron_type}, linked_list::ContainsLinkedList, op::{verify_op, Op}, operation::Operation, printable::Printable, result::Result, - r#type::{TypeObj, Typed}, + r#type::{TypeObj, TypePtr, Typed}, utils::apint::APInt, value::Value, verify_err, @@ -98,7 +98,23 @@ mod slot_attr { mod angle_attr { use pliron::dict_key; - dict_key!(BITS, "qec_angle_bits"); + dict_key!(ANGLE, "qec_angle"); +} + +/// A PECOS-native angle attribute: stores `Angle64`'s fixed-point *fraction* (a fraction of a full +/// turn, full circle = `2^64`) exactly -- no lossy f64 round-trip in the IR. `qec.rz/rx/ry` carry one. +#[pliron_attr(name = "qec.angle", format = "$0", verifier = "succ")] +#[derive(PartialEq, Eq, Clone, Debug, Hash)] +pub struct Angle64Attr(u64); +impl From for Angle64Attr { + fn from(a: Angle64) -> Self { + Angle64Attr(a.fraction()) + } +} +impl From for Angle64 { + fn from(a: Angle64Attr) -> Self { + Angle64::new(a.0) + } } // ===================== measurement-SSA registry (measurement-id-system.md, Phase 1) ===================== @@ -146,17 +162,15 @@ impl MeasurementRegistry { } } -/// Store an f64 angle on an op as the bit pattern in an IntegerAttr (pliron has no float attr here). -pub fn set_angle(ctx: &mut Context, op: Ptr, theta: f64) { - let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless); - let attr = IntegerAttr::new(i64_ty, APInt::from_u64(theta.to_bits(), bw(64))); - op.deref_mut(ctx).attributes.0.insert(angle_attr::BITS.clone(), Box::new(attr)); +/// Store a fixed-point `Angle64` on an op as a `qec.angle` attribute. +pub fn set_angle(ctx: &Context, op: Ptr, angle: Angle64) { + op.deref_mut(ctx).attributes.0.insert(angle_attr::ANGLE.clone(), Box::new(Angle64Attr::from(angle))); } -pub fn get_angle(ctx: &Context, op: Ptr) -> f64 { +pub fn get_angle(ctx: &Context, op: Ptr) -> Angle64 { let o = op.deref(ctx); - let a: AttrObj = o.attributes.0.get(&*angle_attr::BITS).expect("angle attr").clone(); - let ia = a.downcast::().unwrap_or_else(|_| panic!("angle not IntegerAttr")); - f64::from_bits(Into::::into(*ia).to_u64()) + let a: AttrObj = o.attributes.0.get(&*angle_attr::ANGLE).expect("angle attr").clone(); + let aa = a.downcast::().unwrap_or_else(|_| panic!("angle not Angle64Attr")); + Angle64::from(*aa) } #[pliron_op(name = "qec.qalloc", format, interfaces = [NOpdsInterface<0>, OneResultInterface, NResultsInterface<1>], verifier = "succ")] @@ -320,8 +334,14 @@ impl CondXOp { } impl Verify for CondXOp { fn verify(&self, ctx: &Context) -> Result<()> { - // target (operand 1) must be a qubitref; the condition (operand 0) is a measurement-result - // i1 by construction (constructing the i1 type needs &mut Context, unavailable in verify). + // condition (operand 0) must be an i1 (a measurement result); target (operand 1) a qubitref. + // We inspect the operand's actual type read-only (TypePtr::from_ptr + width) rather than + // construct the expected i1 -- constructing a parameterized type needs &mut Context. + let cond_ty = self.get_operation().deref(ctx).get_operand(0).get_type(ctx); + let cond_is_i1 = TypePtr::::from_ptr(cond_ty, ctx).is_ok_and(|tp| tp.deref(ctx).width() == 1); + if !cond_is_i1 { + return verify_err!(self.loc(ctx), "qec.cond_x condition (operand 0) must be an i1 measurement result"); + } if self.get_operation().deref(ctx).get_operand(1).get_type(ctx) != qubitref_ty(ctx) { return verify_err!(self.loc(ctx), "qec.cond_x target must be a qec.qubitref"); } @@ -365,31 +385,31 @@ impl IfOp { } } -/// Single-qubit rotations carrying an f64 angle (qprog.ll's rz/rx/ry). +/// Single-qubit rotations carrying a fixed-point `Angle64` (qprog.ll's rz/rx/ry). #[pliron_op(name = "qec.rz", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] pub struct RzOp; impl RzOp { - pub fn new(ctx: &mut Context, q: Value, theta: f64) -> Self { + pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); - set_angle(ctx, op, theta); + set_angle(ctx, op, angle); RzOp { op } } } #[pliron_op(name = "qec.rx", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] pub struct RxOp; impl RxOp { - pub fn new(ctx: &mut Context, q: Value, theta: f64) -> Self { + pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); - set_angle(ctx, op, theta); + set_angle(ctx, op, angle); RxOp { op } } } #[pliron_op(name = "qec.ry", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] pub struct RyOp; impl RyOp { - pub fn new(ctx: &mut Context, q: Value, theta: f64) -> Self { + pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); - set_angle(ctx, op, theta); + set_angle(ctx, op, angle); RyOp { op } } } @@ -724,9 +744,9 @@ pub enum Cmd { Pz(usize), H(usize), X(usize), - Rz(usize, f64), - Rx(usize, f64), - Ry(usize, f64), + Rz(usize, Angle64), + Rx(usize, Angle64), + Ry(usize, Angle64), Szz(usize, usize), Cx(usize, usize), Mz(usize, u64), // (qubit, QIS result-id); the id rides through to the export/register mapping @@ -787,9 +807,9 @@ pub fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: Cmd::Pz(q) => { b.pz(&[q]); } Cmd::H(q) => { b.h(&[q]); } Cmd::X(q) => { b.x(&[q]); } - Cmd::Rz(q, t) => { b.rz(Angle64::from_radians(t), &[q]); } - Cmd::Rx(q, t) => { b.rx(Angle64::from_radians(t), &[q]); } - Cmd::Ry(q, t) => { b.ry(Angle64::from_radians(t), &[q]); } + Cmd::Rz(q, a) => { b.rz(a, &[q]); } + Cmd::Rx(q, a) => { b.rx(a, &[q]); } + Cmd::Ry(q, a) => { b.ry(a, &[q]); } Cmd::Szz(a, c0) => { b.szz(&[(a, c0)]); } Cmd::Cx(c0, t) => { b.cx(&[(c0, t)]); } Cmd::Mz(q, _rid) => { b.mz(&[q]); } // result-id is bookkeeping, not a simulator op @@ -1298,9 +1318,10 @@ pub fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap, measured: &mut HashMap, reg: &mut MeasurementRegistry) { match *p { ParsedOp::H(q) => { HOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } - ParsedOp::Rz(q, t) => { RzOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } - ParsedOp::Rx(q, t) => { RxOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } - ParsedOp::Ry(q, t) => { RyOp::new(ctx, slot_of[&q], t).get_operation().insert_at_back(block, ctx); } + // .ll angles are f64 radians (the wire format); convert to fixed-point Angle64 at the boundary. + ParsedOp::Rz(q, t) => { RzOp::new(ctx, slot_of[&q], Angle64::from_radians(t)).get_operation().insert_at_back(block, ctx); } + ParsedOp::Rx(q, t) => { RxOp::new(ctx, slot_of[&q], Angle64::from_radians(t)).get_operation().insert_at_back(block, ctx); } + ParsedOp::Ry(q, t) => { RyOp::new(ctx, slot_of[&q], Angle64::from_radians(t)).get_operation().insert_at_back(block, ctx); } ParsedOp::Szz(a, b) => { SzzOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } ParsedOp::X(q) => { XOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } ParsedOp::M(q, rid) => { @@ -1720,6 +1741,27 @@ mod tests { assert!(res.is_err(), "qec.h on a qec.alloc handle must fail verification, got Ok"); } + /// The `qec.angle` attribute round-trips an `Angle64` through the IR exactly (fixed-point, no + /// f64 bit-pattern hack): the fraction stored on a `qec.rz` reads back bit-identical. + #[test] + fn angle_attr_roundtrips_fixed_point() { + let ctx = &mut Context::new(); + let module = ModuleOp::new(ctx, "ang".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + } + let q = push!(QallocOp::new(ctx)); + let sv = push!(SlotOp::new(ctx, q.get_result(ctx), 0)).get_result(ctx); + let angle = Angle64::from_radians(1.07); + let rz = push!(RzOp::new(ctx, sv, angle)); + let got = get_angle(ctx, rz.get_operation()); + assert_eq!(got.fraction(), angle.fraction(), "qec.angle must round-trip the Angle64 fixed-point fraction exactly"); + } + /// Recording a measurement defined *inside* a `qec.if` region from the OUTER block is a /// cross-region SSA escape (it needs block-args/yield -- Phase 2). `qec.record`'s verifier must /// reject it, not silently accept it. From ef946e072c8427b77b245979de11ac7e53ac2e51 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 20:48:40 -0600 Subject: [PATCH 227/388] Reject non-unit emission_scale in the neo facade subset and make facade worker setters last-writer-wins --- .../src/noise/general/builder.rs | 28 +++++++++++++++++- crates/pecos/src/unified_sim.rs | 10 +++++++ crates/pecos/tests/neo_routing_test.rs | 29 +++++++++++++++++++ 3 files changed, 66 insertions(+), 1 deletion(-) diff --git a/crates/pecos-engines/src/noise/general/builder.rs b/crates/pecos-engines/src/noise/general/builder.rs index c66f1ac58..b6662620a 100644 --- a/crates/pecos-engines/src/noise/general/builder.rs +++ b/crates/pecos-engines/src/noise/general/builder.rs @@ -880,7 +880,13 @@ impl GeneralNoiseModelBuilder { && one_or_unset(self.p1_scale) && one_or_unset(self.p2_scale) && one_or_unset(self.p_prep_crosstalk_scale) - && one_or_unset(self.p_meas_crosstalk_scale); + && one_or_unset(self.p_meas_crosstalk_scale) + // `emission_scale` multiplies the emission ratios in `build()` + // (`scale_parameters`), but the resolved ratios surfaced here are + // RAW. Require it neutral so a non-unit scale falls out of the + // subset and is rejected, rather than silently mapping the + // un-scaled ratio to neo (cross-stack mismatch). + && one_or_unset(self.emission_scale); let gates_default = self.noiseless_gates.as_ref().is_none_or(BTreeSet::is_empty); @@ -1187,4 +1193,24 @@ mod tests { assert!((p1_emission - 0.25).abs() < 1e-12); assert!((p2_emission - 0.75).abs() < 1e-12); } + + /// A non-unit `emission_scale` multiplies the emission ratios at `build()`, + /// but the subset surfaces the RAW ratios. It must therefore be rejected + /// from the subset rather than silently mapping the un-scaled ratio. + #[test] + fn pauli_with_angle_scaling_rejects_emission_scale() { + let builder = GeneralNoiseModelBuilder::new() + .with_average_p1_probability(0.2) + .with_p1_emission_ratio(0.25) + .with_emission_scale(2.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + + // The built model applies the scale (0.25 * 2.0 = 0.5)... + let built = builder.clone().build(); + assert!((built.p1_emission_ratio() - 0.5).abs() < 1e-12); + // ...so surfacing the raw 0.25 would be a cross-stack mismatch: the + // subset must refuse it. + assert!(builder.pauli_with_angle_scaling().is_none()); + } } diff --git a/crates/pecos/src/unified_sim.rs b/crates/pecos/src/unified_sim.rs index 8f9e65547..89caf04dc 100644 --- a/crates/pecos/src/unified_sim.rs +++ b/crates/pecos/src/unified_sim.rs @@ -222,11 +222,16 @@ impl ProgrammedSimBuilder { pub fn sampling(mut self, sampling: impl Into) -> Self { let mc = sampling.into(); self.routed.shots = Some(mc.shots()); + // Worker settings are mutually exclusive and last-writer-wins (see + // `workers`/`auto_workers`): apply only what the spec sets, clearing the + // other, so the neo route can't end up with both flags live. if mc.auto_workers_requested() { self.routed.auto_workers = true; + self.routed.workers = None; self.base_builder = self.base_builder.auto_workers(); } else if let Some(workers) = mc.worker_count() { self.routed.workers = Some(workers); + self.routed.auto_workers = false; self.base_builder = self.base_builder.workers(workers); } self @@ -544,7 +549,11 @@ impl ProgrammedSimBuilder { /// Set the number of worker threads (delegates to base builder) #[must_use] pub fn workers(mut self, workers: usize) -> Self { + // Last-writer-wins, matching the engines `SimBuilder`: an explicit + // count clears a prior `.auto_workers()` so the two never both apply on + // the neo route (where they are resolved separately). self.routed.workers = Some(workers); + self.routed.auto_workers = false; self.base_builder = self.base_builder.workers(workers); self } @@ -553,6 +562,7 @@ impl ProgrammedSimBuilder { #[must_use] pub fn auto_workers(mut self) -> Self { self.routed.auto_workers = true; + self.routed.workers = None; self.base_builder = self.base_builder.auto_workers(); self } diff --git a/crates/pecos/tests/neo_routing_test.rs b/crates/pecos/tests/neo_routing_test.rs index d61d917fd..abfd77804 100644 --- a/crates/pecos/tests/neo_routing_test.rs +++ b/crates/pecos/tests/neo_routing_test.rs @@ -341,6 +341,35 @@ fn neo_stack_rejects_unmapped_noise() { ); } +#[test] +fn neo_stack_rejects_nonunit_emission_scale() { + // `with_emission_scale` multiplies the emission ratios at build() but the + // facade subset surfaces the RAW ratios, so a non-unit scale would map a + // DIFFERENT emission rate to neo than engines runs. The facade must reject + // it rather than silently diverge. (Codex batch-4 finding 1.) + let general = pecos_engines::noise::GeneralNoiseModel::builder() + .with_p1_probability(0.3) + .with_p1_emission_ratio(0.25) + .with_emission_scale(2.0) + .with_p2_probability(0.0) + .with_prep_probability(0.0) + .with_meas_0_probability(0.0) + .with_meas_1_probability(0.0) + .with_prep_leak_ratio(0.0) + .with_p_idle_linear_rate(0.0); + let err = sim(deterministic_conditional_qasm()) + .stack(SimStack::Neo) + .noise(general) + .shots(5) + .run() + .expect_err("non-unit emission_scale must not be silently mapped to neo"); + assert!( + err.to_string() + .contains("beyond the simple probability subset"), + "unexpected error: {err}" + ); +} + #[test] fn neo_stack_rejects_unrouted_quantum_backend() { let err = sim(deterministic_conditional_qasm()) From 662f5108038beb486793d8fd22b4aa6d1ba09a1d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 20:48:56 -0600 Subject: [PATCH 228/388] Migrate the Rust doc-test snippets to the argless .shots(n).run() facade API --- crates/pecos/README.md | 2 +- docs/README.md | 2 +- docs/user-guide/qasm-simulation.md | 24 +++++++++---------- docs/user-guide/simulators.md | 12 +++++----- .../tests/docs/rust_crate/tests/readme.rs | 2 +- .../tests/user_guide_qasm_simulation.rs | 24 +++++++++---------- .../rust_crate/tests/user_guide_simulators.rs | 12 +++++----- 7 files changed, 39 insertions(+), 39 deletions(-) diff --git a/crates/pecos/README.md b/crates/pecos/README.md index 2c01e426d..4a8dc3d2f 100644 --- a/crates/pecos/README.md +++ b/crates/pecos/README.md @@ -8,7 +8,7 @@ Provides a unified API for PECOS users. Most users should depend on this crate r ## Key Features -- **Unified simulation API**: `sim(program).seed(42).run(100)` +- **Unified simulation API**: `sim(program).seed(42).shots(100).run()` - **Re-exports**: Core types, engines, programs, quantum backends - **Feature-gated**: Enable only what you need (qasm, qis, hugr, etc.) diff --git a/docs/README.md b/docs/README.md index d0db09e6f..8f78eb46b 100644 --- a/docs/README.md +++ b/docs/README.md @@ -84,7 +84,7 @@ Simulate a distance-3 repetition code with syndrome extraction using [Guppy](htt "#); // Run 10 shots - let results = sim(circuit).seed(42).run(10)?; + let results = sim(circuit).seed(42).shots(10).run()?; println!("{:?}", results); // 0 = both |0⟩, 3 = both |1⟩ (always correlated!) Ok(()) diff --git a/docs/user-guide/qasm-simulation.md b/docs/user-guide/qasm-simulation.md index dda6c93d3..4b8adaf03 100644 --- a/docs/user-guide/qasm-simulation.md +++ b/docs/user-guide/qasm-simulation.md @@ -89,10 +89,10 @@ Now, let's run this code using PECOS's unified `sim()` function: let program = Qasm::from_string(qasm_code); // Simple simulation - let results = sim(program.clone()).run(1000)?; + let results = sim(program.clone()).shots(1000).run()?; // With configuration - let results = sim(program).seed(42).run(1000)?; + let results = sim(program).seed(42).shots(1000).run()?; ``` ## Using the Builder API @@ -146,7 +146,7 @@ The `sim()` function returns a builder that provides flexibility through method let program = Qasm::from_string(qasm_code); // Simple simulation with builder pattern - let results = sim(program.clone()).run(1000)?; + let results = sim(program.clone()).shots(1000).run()?; // With more configuration options let results = sim(program) @@ -154,7 +154,7 @@ The `sim()` function returns a builder that provides flexibility through method .noise(DepolarizingNoiseModel::builder().with_uniform_probability(0.01)) .workers(4) // Explicitly set number of threads // .auto_workers() // Or use all available CPU cores - .run(1000)?; + .shots(1000).run()?; ``` ## Running Multiple Shots @@ -302,7 +302,7 @@ For research or to match specific hardware characteristics, you can create detai .with_seed(42); // Deterministic noise // Use with sim() - let results = sim(program).noise(noise).run(1000)?; + let results = sim(program).noise(noise).shots(1000).run()?; ``` The builder provides many configuration options including idle noise rates, leakage probabilities, @@ -357,12 +357,12 @@ PECOS provides different engines optimized for different types of circuits: // Sparse stabilizer (default, efficient for Clifford circuits) let results = sim(program.clone()) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; // State vector (for non-Clifford circuits) let results = sim(program) .quantum(state_vector()) - .run(1000)?; + .shots(1000).run()?; ``` ## Understanding Your Results @@ -419,7 +419,7 @@ Simulation results come back as measurement outcomes for each shot. These can be "#; let program = Qasm::from_string(qasm_code); - let results = sim(program).run(1000)?; + let results = sim(program).shots(1000).run()?; // Results come as ShotVec println!("Got {} shots", results.len()); @@ -568,7 +568,7 @@ Here's how to simulate a GHZ state with realistic noise: .with_seed(12345); // Deterministic noise // Run simulation - let results = sim(program).noise(noise).seed(42).run(1000)?; + let results = sim(program).noise(noise).seed(42).shots(1000).run()?; println!("GHZ state results: {:?}", results); Ok(()) @@ -624,13 +624,13 @@ For many shots, you can use multiple CPU cores to speed up simulation: let program = Qasm::from_string(qasm_code); // Single threaded (default) - let results = sim(program.clone()).workers(1).run(1000)?; + let results = sim(program.clone()).workers(1).shots(1000).run()?; // Explicit thread count - let results = sim(program.clone()).workers(4).run(1000)?; + let results = sim(program.clone()).workers(4).shots(1000).run()?; // Automatically use all available cores - let results = sim(program).auto_workers().run(1000)?; + let results = sim(program).auto_workers().shots(1000).run()?; ``` ### Choosing the Right Engine diff --git a/docs/user-guide/simulators.md b/docs/user-guide/simulators.md index 397a497d7..42ce75d97 100644 --- a/docs/user-guide/simulators.md +++ b/docs/user-guide/simulators.md @@ -140,12 +140,12 @@ The default simulator, optimized for QEC workloads with sparse stabilizer tablea ```rust // SparseStab is used by default - let results = sim(program.clone()).run(1000)?; + let results = sim(program.clone()).shots(1000).run()?; // Or explicitly select it let results = sim(program) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; ``` **Strengths:** @@ -209,7 +209,7 @@ Pure Rust state vector implementation. ```rust let results = sim(program) .quantum(state_vector()) - .run(100)?; + .shots(100).run()?; ``` **Strengths:** @@ -430,16 +430,16 @@ The `sim()` API lets you switch simulators easily: "#); // Default (sparse stabilizer for Clifford circuits) - let results = sim(circuit.clone()).run(1000)?; + let results = sim(circuit.clone()).shots(1000).run()?; // Explicit simulator selection let results = sim(circuit.clone()) .quantum(state_vector()) - .run(1000)?; + .shots(1000).run()?; let results = sim(circuit) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; ``` ## Direct Simulator Access diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/readme.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/readme.rs index f0bb90df0..0a457e3a4 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/readme.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/readme.rs @@ -18,7 +18,7 @@ fn test_readme_rust_1() -> Result<(), Box> { "#); // Run 10 shots - let results = sim(circuit).seed(42).run(10)?; + let results = sim(circuit).seed(42).shots(10).run()?; println!("{:?}", results); // 0 = both |0⟩, 3 = both |1⟩ (always correlated!) Ok(()) diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs index bd606961e..b143bfa86 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_qasm_simulation.rs @@ -33,10 +33,10 @@ let qasm_code = r#" let program = Qasm::from_string(qasm_code); // Simple simulation -let results = sim(program.clone()).run(1000)?; +let results = sim(program.clone()).shots(1000).run()?; // With configuration -let results = sim(program).seed(42).run(1000)?; +let results = sim(program).seed(42).shots(1000).run()?; Ok(()) } @@ -71,7 +71,7 @@ let qasm_code = r#" let program = Qasm::from_string(qasm_code); // Simple simulation with builder pattern -let results = sim(program.clone()).run(1000)?; +let results = sim(program.clone()).shots(1000).run()?; // With more configuration options let results = sim(program) @@ -79,7 +79,7 @@ let results = sim(program) .noise(DepolarizingNoiseModel::builder().with_uniform_probability(0.01)) .workers(4) // Explicitly set number of threads // .auto_workers() // Or use all available CPU cores - .run(1000)?; + .shots(1000).run()?; Ok(()) } @@ -191,7 +191,7 @@ let noise = GeneralNoiseModelBuilder::new() .with_seed(42); // Deterministic noise // Use with sim() -let results = sim(program).noise(noise).run(1000)?; +let results = sim(program).noise(noise).shots(1000).run()?; Ok(()) } @@ -228,12 +228,12 @@ let program = Qasm::from_string(qasm_code); // Sparse stabilizer (default, efficient for Clifford circuits) let results = sim(program.clone()) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; // State vector (for non-Clifford circuits) let results = sim(program) .quantum(state_vector()) - .run(1000)?; + .shots(1000).run()?; Ok(()) } @@ -266,7 +266,7 @@ let qasm_code = r#" "#; let program = Qasm::from_string(qasm_code); -let results = sim(program).run(1000)?; +let results = sim(program).shots(1000).run()?; // Results come as ShotVec println!("Got {} shots", results.len()); @@ -371,7 +371,7 @@ fn ghz_noise_example() -> Result<(), PecosError> { .with_seed(12345); // Deterministic noise // Run simulation - let results = sim(program).noise(noise).seed(42).run(1000)?; + let results = sim(program).noise(noise).seed(42).shots(1000).run()?; println!("GHZ state results: {:?}", results); Ok(()) @@ -410,13 +410,13 @@ let qasm_code = r#" let program = Qasm::from_string(qasm_code); // Single threaded (default) -let results = sim(program.clone()).workers(1).run(1000)?; +let results = sim(program.clone()).workers(1).shots(1000).run()?; // Explicit thread count -let results = sim(program.clone()).workers(4).run(1000)?; +let results = sim(program.clone()).workers(4).shots(1000).run()?; // Automatically use all available cores -let results = sim(program).auto_workers().run(1000)?; +let results = sim(program).auto_workers().shots(1000).run()?; Ok(()) } diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_simulators.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_simulators.rs index 14d81fe98..98eed26cf 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_simulators.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_simulators.rs @@ -17,12 +17,12 @@ fn test_user_guide_simulators_rust_2() -> Result<(), Box> "#; let program = Qasm::from_string(qasm_code); // SparseStab is used by default -let results = sim(program.clone()).run(1000)?; +let results = sim(program.clone()).shots(1000).run()?; // Or explicitly select it let results = sim(program) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; Ok(()) } @@ -43,7 +43,7 @@ fn test_user_guide_simulators_rust_3() -> Result<(), Box> let program = Qasm::from_string(qasm_code); let results = sim(program) .quantum(state_vector()) - .run(100)?; + .shots(100).run()?; Ok(()) } @@ -129,16 +129,16 @@ fn test_user_guide_simulators_rust_6() -> Result<(), Box> "#); // Default (sparse stabilizer for Clifford circuits) -let results = sim(circuit.clone()).run(1000)?; +let results = sim(circuit.clone()).shots(1000).run()?; // Explicit simulator selection let results = sim(circuit.clone()) .quantum(state_vector()) - .run(1000)?; + .shots(1000).run()?; let results = sim(circuit) .quantum(sparse_stab()) - .run(1000)?; + .shots(1000).run()?; Ok(()) } From b25003c2f610f1be035d42df1cb2cc410e1b8118 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 22:38:42 -0600 Subject: [PATCH 229/388] pecos-phir-pliron: add non-i1 cond_x verifier negative test (round-6) --- exp/pecos-phir-pliron/src/lib.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/exp/pecos-phir-pliron/src/lib.rs b/exp/pecos-phir-pliron/src/lib.rs index 95c1d9390..c08d07fbb 100644 --- a/exp/pecos-phir-pliron/src/lib.rs +++ b/exp/pecos-phir-pliron/src/lib.rs @@ -1741,6 +1741,24 @@ mod tests { assert!(res.is_err(), "qec.h on a qec.alloc handle must fail verification, got Ok"); } + /// `qec.cond_x`'s condition (operand 0) must be an `i1` measurement result. A non-`i1` condition + /// (here a qubitref) must be rejected -- guards the read-only type-inspection check in verify. + #[test] + fn negative_cond_x_non_i1_condition_rejected() { + let ctx = &mut Context::new(); + let alloc = QallocOp::new(ctx); + let s0 = SlotOp::new(ctx, alloc.get_result(ctx), 0); + let s1 = SlotOp::new(ctx, alloc.get_result(ctx), 1); + // use a qubitref (operand 0) as the condition -- it is not an i1 measurement result. + let bad = CondXOp::new(ctx, s0.get_result(ctx), s1.get_result(ctx)); + let err = verify_op(&bad, ctx).expect_err("qec.cond_x with a non-i1 condition must fail verification"); + assert!( + format!("{}", err.disp(ctx)).contains("i1"), + "expected the i1-condition rejection, got: {}", + err.disp(ctx) + ); + } + /// The `qec.angle` attribute round-trips an `Angle64` through the IR exactly (fixed-point, no /// f64 bit-pattern hack): the fraction stored on a `qec.rz` reads back bit-identical. #[test] From 2b4e24874cf8d3cf14054754e22e5b1dc4051d9b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 22:55:45 -0600 Subject: [PATCH 230/388] pecos-phir-pliron: promote exp -> crates, depend on crates.io pliron 0.15 (strangler first step) --- Cargo.lock | 28 ++++++++++--------- {exp => crates}/pecos-phir-pliron/Cargo.toml | 9 +++--- .../fixtures/adaptive_branch.ll | 0 .../fixtures/branch_measure.ll | 0 {exp => crates}/pecos-phir-pliron/src/lib.rs | 0 5 files changed, 20 insertions(+), 17 deletions(-) rename {exp => crates}/pecos-phir-pliron/Cargo.toml (55%) rename {exp => crates}/pecos-phir-pliron/fixtures/adaptive_branch.ll (100%) rename {exp => crates}/pecos-phir-pliron/fixtures/branch_measure.ll (100%) rename {exp => crates}/pecos-phir-pliron/src/lib.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 49e47dbd2..140653f88 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2482,8 +2482,6 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "allocator-api2", - "equivalent", "foldhash 0.2.0", ] @@ -4695,6 +4693,7 @@ dependencies = [ "awint", "pecos-core", "pecos-engines", + "pecos-phir", "pliron", ] @@ -5228,12 +5227,13 @@ checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] name = "pliron" version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f3650a6ccb88c14a2c493bc469c43d157093307cccd46bc8c1daf241d8b2e4" dependencies = [ "awint", "combine", "downcast-rs", "dyn-clone", - "hashbrown 0.17.1", "hi_sparse_bitset", "inventory", "linkme", @@ -5243,13 +5243,15 @@ dependencies = [ "rustc-hash 2.1.2", "rustc_apfloat", "slotmap", - "spin", "thiserror 2.0.18", + "utf8-chars", ] [[package]] name = "pliron-derive" version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7c51af14f1d04076965ef02bd24394515433223a614b5279d63b3372f6d8a32" dependencies = [ "combine", "convert_case 0.11.0", @@ -6937,15 +6939,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spin" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1527984ca054dfca79333baec451042863f485fbee01b7bf6d911de915cac865" -dependencies = [ - "lock_api", -] - [[package]] name = "spirv" version = "0.4.0+sdk-1.4.341.0" @@ -7941,6 +7934,15 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" +[[package]] +name = "utf8-chars" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92498c67ea3511b37eccff13b573ed871faf0ce9c4056ab032bd01a681f445a0" +dependencies = [ + "arrayvec 0.7.6", +] + [[package]] name = "utf8-width" version = "0.1.8" diff --git a/exp/pecos-phir-pliron/Cargo.toml b/crates/pecos-phir-pliron/Cargo.toml similarity index 55% rename from exp/pecos-phir-pliron/Cargo.toml rename to crates/pecos-phir-pliron/Cargo.toml index f29d31f2c..c32d7f468 100644 --- a/exp/pecos-phir-pliron/Cargo.toml +++ b/crates/pecos-phir-pliron/Cargo.toml @@ -1,6 +1,7 @@ -# Experimental: the round-2 decision gate for build-PHIR-on-pliron. -# Drives a Bell program built as pliron IR through the UNCHANGED pecos-engines sim seam. -# See pecos-docs design/slr-phir-vision.md §7.1 + slr-phir-vision-qis-port-sketch.md. +# Parallel, non-default QIS-LLVM-IR -> pliron-PHIR path (the strangler crate, scoped to the covered +# QIS-LLVM subset). Lowers to a pliron `qec` dialect and runs through the UNCHANGED pecos-engines +# seam. pliron comes from crates.io, pinned by Cargo.lock. +# See pecos-docs design/slr-phir-pliron-strangler-scope.md + slr-phir-vision.md. [package] name = "pecos-phir-pliron" version.workspace = true @@ -14,7 +15,7 @@ publish = false [dependencies] pecos-engines.workspace = true pecos-core.workspace = true -pliron = { path = "/home/ciaranra/Repos/pliron" } +pliron = "0.15" awint = "0.18" [dev-dependencies] diff --git a/exp/pecos-phir-pliron/fixtures/adaptive_branch.ll b/crates/pecos-phir-pliron/fixtures/adaptive_branch.ll similarity index 100% rename from exp/pecos-phir-pliron/fixtures/adaptive_branch.ll rename to crates/pecos-phir-pliron/fixtures/adaptive_branch.ll diff --git a/exp/pecos-phir-pliron/fixtures/branch_measure.ll b/crates/pecos-phir-pliron/fixtures/branch_measure.ll similarity index 100% rename from exp/pecos-phir-pliron/fixtures/branch_measure.ll rename to crates/pecos-phir-pliron/fixtures/branch_measure.ll diff --git a/exp/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs similarity index 100% rename from exp/pecos-phir-pliron/src/lib.rs rename to crates/pecos-phir-pliron/src/lib.rs From 6edc487e6e61c8a4d366d9ad02f6c0b7b1db9bc0 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 17 Jun 2026 23:02:25 -0600 Subject: [PATCH 231/388] Support checkerboard Clifford surface frames --- .../quantum-pecos/src/pecos/guppy/surface.py | 61 +++++++++---- .../qec/surface/_clifford_deformation.py | 64 +++++++++++++- .../src/pecos/qec/surface/circuit_builder.py | 69 ++++++++++----- .../qec/surface/test_clifford_deformation.py | 87 +++++++++++++++++++ 4 files changed, 235 insertions(+), 46 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 4aba2eab9..2cd2f4e9a 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -195,7 +195,8 @@ def generate_guppy_source( supplied. Current Guppy generation maps the resolved plan to the corresponding CX or SZZ/SZZdg concrete template. clifford_frame_policy: Optional source-level Clifford-deformation - policy. Currently supported for global uniform-axis SZZ frames. + policy. Currently supported for SZZ global axis-cycle and + checkerboard XZZX/ZXXZ deformed-check frames. Returns: Python/Guppy source code as a string. @@ -207,7 +208,7 @@ def generate_guppy_source( from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer from pecos.qec.surface.circuit_builder import ( _resolve_szz_clifford_frame_for_builder, - _szz_memory_physical_axis, + _szz_memory_physical_axis_for_data, _szz_residual_plan_for_check_plan, ) @@ -362,21 +363,23 @@ def _szz_physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_q: in raise ValueError(msg) from exc return check.paulis[offset].axis - def _szz_physical_axis_for_memory_basis(source_basis: str) -> str: - return _szz_memory_physical_axis(source_basis, resolved_clifford_frame) + def _szz_physical_axis_for_memory_data(source_basis: str, data_q: int) -> str: + return _szz_memory_physical_axis_for_data( + source_basis, + resolved_clifford_frame, + data_q, + ) - def _szz_physical_axis_for_logical(source_logical: str) -> str: + def _szz_physical_axis_for_logical_data(source_logical: str, data_q: int) -> str: if resolved_clifford_frame is None: return source_logical logical = resolved_clifford_frame.logical_x if source_logical == "X" else resolved_clifford_frame.logical_z - if not logical.is_uniform_axis or logical.uniform_axis is None: - msg = ( - f"clifford frame policy {resolved_clifford_frame.policy!r} maps " - f"source logical {source_logical} to mixed axes {logical.axes}; " - "mixed logical operator rendering is not implemented yet" - ) - raise NotImplementedError(msg) - return logical.uniform_axis + try: + offset = logical.data_qubits.index(data_q) + except ValueError as exc: + msg = f"data qubit {data_q} is not in source logical {source_logical}" + raise ValueError(msg) from exc + return logical.paulis[offset].axis def _append_szz_axis_rotation_to_z(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: if axis == "X": @@ -429,7 +432,12 @@ def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_e if interaction_basis == "szz": lines.extend(f" d{i} = qubit()" for i in range(num_data)) for i in range(num_data): - _append_szz_axis_rotation_to_z(lines, " ", _szz_physical_axis_for_memory_basis("Z"), f"d{i}") + _append_szz_axis_rotation_to_z( + lines, + " ", + _szz_physical_axis_for_memory_data("Z", i), + f"d{i}", + ) lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") else: lines.append(f" data = array(qubit() for _ in range({num_data}))") @@ -439,7 +447,12 @@ def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_e if interaction_basis == "szz": lines.extend(f" d{i} = qubit()" for i in range(num_data)) for i in range(num_data): - _append_szz_axis_rotation_to_z(lines, " ", _szz_physical_axis_for_memory_basis("X"), f"d{i}") + _append_szz_axis_rotation_to_z( + lines, + " ", + _szz_physical_axis_for_memory_data("X", i), + f"d{i}", + ) lines.append(f" return SurfaceCode_{dx}x{dz}({szz_data_args})") else: lines.append(f" data = array(qubit() for _ in range({num_data}))") @@ -890,7 +903,12 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") for i in range(num_data): - _append_szz_axis_rotation_from_z(lines, " ", _szz_physical_axis_for_memory_basis("Z"), f"d{i}") + _append_szz_axis_rotation_from_z( + lines, + " ", + _szz_physical_axis_for_memory_data("Z", i), + f"d{i}", + ) z_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) lines.append(f" return array({z_meas})") else: @@ -907,7 +925,12 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") for i in range(num_data): - _append_szz_axis_rotation_from_z(lines, " ", _szz_physical_axis_for_memory_basis("X"), f"d{i}") + _append_szz_axis_rotation_from_z( + lines, + " ", + _szz_physical_axis_for_memory_data("X", i), + f"d{i}", + ) x_meas = ", ".join(f"measure(d{i})" for i in range(num_data)) lines.append(f" return array({x_meas})") else: @@ -930,8 +953,8 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) if interaction_basis == "szz": - logical_x_axis = _szz_physical_axis_for_logical("X") for q in logical_x_qubits: + logical_x_axis = _szz_physical_axis_for_logical_data("X", q) _append_szz_logical_pauli(lines, " ", logical_x_axis, f"surf.d{q}") else: lines.extend(f" x(surf.data[{q}])" for q in logical_x_qubits) @@ -946,8 +969,8 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) if interaction_basis == "szz": - logical_z_axis = _szz_physical_axis_for_logical("Z") for q in logical_z_qubits: + logical_z_axis = _szz_physical_axis_for_logical_data("Z", q) _append_szz_logical_pauli(lines, " ", logical_z_axis, f"surf.d{q}") else: lines.extend(f" z(surf.data[{q}])" for q in logical_z_qubits) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py index 8f1b0051b..5269a4dd3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py @@ -25,6 +25,8 @@ "global_h", "global_axis_cycle_f", "global_axis_cycle_f2", + "checkerboard_xzzx", + "checkerboard_zxxz", ] _SUPPORTED_GLOBAL_FRAME_POLICIES = frozenset( @@ -35,6 +37,15 @@ "global_axis_cycle_f2", }, ) +_SUPPORTED_CHECKERBOARD_FRAME_POLICIES = frozenset( + { + "checkerboard_xzzx", + "checkerboard_zxxz", + }, +) +_SUPPORTED_FRAME_POLICIES = ( + _SUPPORTED_GLOBAL_FRAME_POLICIES | _SUPPORTED_CHECKERBOARD_FRAME_POLICIES +) @dataclass(frozen=True, order=True) @@ -200,10 +211,10 @@ def css_physical_memory_basis(self, source_basis: str) -> PauliAxis: def normalize_surface_frame_policy(policy: str) -> str: """Normalize and validate a named surface Clifford frame policy.""" normalized = str(policy).lower().replace("-", "_") - if normalized not in _SUPPORTED_GLOBAL_FRAME_POLICIES: + if normalized not in _SUPPORTED_FRAME_POLICIES: msg = ( f"unknown surface Clifford frame policy {policy!r}; expected one of " - f"{sorted(_SUPPORTED_GLOBAL_FRAME_POLICIES)}" + f"{sorted(_SUPPORTED_FRAME_POLICIES)}" ) raise ValueError(msg) return normalized @@ -215,6 +226,12 @@ def global_surface_frame(policy: str, num_data: int) -> tuple[LocalCliffordFrame msg = f"num_data must be non-negative, got {num_data}" raise ValueError(msg) normalized = normalize_surface_frame_policy(policy) + if normalized not in _SUPPORTED_GLOBAL_FRAME_POLICIES: + msg = ( + f"surface Clifford frame policy {policy!r} is local; call " + "resolve_surface_clifford_frame(...) with a patch instead" + ) + raise ValueError(msg) frame = _global_frame_element(normalized) return tuple(frame for _ in range(num_data)) @@ -227,7 +244,11 @@ def resolve_surface_clifford_frame( ) -> ResolvedSurfaceCliffordFrame: """Resolve source surface checks/logicals through a local Clifford frame.""" normalized = normalize_surface_frame_policy(policy) - frames = tuple(data_frames) if data_frames is not None else global_surface_frame(normalized, patch.num_data) + frames = ( + tuple(data_frames) + if data_frames is not None + else _surface_frame_for_policy(patch, normalized) + ) if len(frames) != patch.num_data: msg = f"data frame length {len(frames)} does not match patch.num_data={patch.num_data}" raise ValueError(msg) @@ -275,3 +296,40 @@ def _global_frame_element(policy: str) -> LocalCliffordFrame: return LocalCliffordFrame(SignedPauli("Z"), SignedPauli("Y")) msg = f"unknown surface Clifford frame policy {policy!r}" raise ValueError(msg) + + +def _surface_frame_for_policy( + patch: SurfacePatch, + policy: str, +) -> tuple[LocalCliffordFrame, ...]: + """Return the resolved data-qubit frame for a named policy.""" + if policy in _SUPPORTED_GLOBAL_FRAME_POLICIES: + return global_surface_frame(policy, patch.num_data) + if policy in _SUPPORTED_CHECKERBOARD_FRAME_POLICIES: + return _checkerboard_h_frame( + patch, + h_on_even=(policy == "checkerboard_xzzx"), + ) + msg = f"unknown surface Clifford frame policy {policy!r}" + raise ValueError(msg) + + +def _checkerboard_h_frame( + patch: SurfacePatch, + *, + h_on_even: bool, +) -> tuple[LocalCliffordFrame, ...]: + """Return a checkerboard H deformation for rotated surface checks. + + With H on even-parity data sites, source X and Z checks become XZZX in the + data-qubit order used by the rotated-patch stabilizer supports. Flipping the + parity gives the paired ZXXZ orientation. + """ + identity = _global_frame_element("identity") + hadamard = _global_frame_element("global_h") + frames: list[LocalCliffordFrame] = [] + for data_idx in range(patch.num_data): + row, col = patch.geometry.id_to_pos[data_idx] + even = (row + col) % 2 == 0 + frames.append(hadamard if even == h_on_even else identity) + return tuple(frames) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 6ecee2cfc..36c7612f7 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -465,24 +465,14 @@ def _resolve_szz_clifford_frame_for_builder( if interaction_basis != "szz": msg = "clifford_frame_policy currently requires interaction_basis='szz'" raise NotImplementedError(msg) - resolved_frame = resolve_surface_clifford_frame(patch, policy=clifford_frame_policy) - unsupported = [check for check in resolved_frame.checks if not check.is_uniform_axis] - if unsupported: - preview = ", ".join(f"{check.source_kind}{check.stabilizer_index}:{check.axes}" for check in unsupported[:5]) - suffix = f", ... {len(unsupported) - 5} more" if len(unsupported) > 5 else "" - msg = ( - "clifford_frame_policy currently supports only uniform-axis " - f"stabilizer checks; mixed checks: {preview}{suffix}" - ) - raise NotImplementedError(msg) - return resolved_frame + return resolve_surface_clifford_frame(patch, policy=clifford_frame_policy) def _szz_memory_physical_axis( basis: str, resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None, ) -> PauliAxis: - """Return the physical data-measurement axis for a source memory basis.""" + """Return the uniform physical axis for a source memory basis, if any.""" source_basis = basis.upper() if source_basis not in {"X", "Z"}: msg = f"basis must be 'X' or 'Z', got {basis!r}" @@ -490,19 +480,41 @@ def _szz_memory_physical_axis( if resolved_clifford_frame is None: return source_basis # type: ignore[return-value] - logical = ( - resolved_clifford_frame.logical_x - if source_basis == "X" - else resolved_clifford_frame.logical_z - ) - if not logical.is_uniform_axis or logical.uniform_axis is None: + axes = { + frame.image(source_basis).axis + for frame in resolved_clifford_frame.data_frames + } + if len(axes) != 1: msg = ( f"clifford frame policy {resolved_clifford_frame.policy!r} maps " - f"source {source_basis}-memory to a mixed logical measurement " - f"axis {logical.axes}; mixed final readout is not implemented yet" + f"source {source_basis}-memory to mixed data measurement axes " + f"{sorted(axes)}; call _szz_memory_physical_axis_for_data instead" ) raise NotImplementedError(msg) - return logical.uniform_axis + return next(iter(axes)) + + +def _szz_memory_physical_axis_for_data( + basis: str, + resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None, + data_idx: int, +) -> PauliAxis: + """Return the physical prep/readout axis for one source-basis data qubit.""" + source_basis = basis.upper() + if source_basis not in {"X", "Z"}: + msg = f"basis must be 'X' or 'Z', got {basis!r}" + raise ValueError(msg) + if resolved_clifford_frame is None: + return source_basis # type: ignore[return-value] + try: + frame = resolved_clifford_frame.data_frames[data_idx] + except IndexError as exc: + msg = ( + f"data qubit {data_idx} is outside resolved frame with " + f"{len(resolved_clifford_frame.data_frames)} data frames" + ) + raise ValueError(msg) from exc + return frame.image(source_basis).axis def _propagate_szz_frame_bits(x_a: bool, z_a: bool, x_b: bool, z_b: bool) -> tuple[bool, bool, bool, bool]: @@ -895,8 +907,9 @@ def build_surface_code_circuit( truth when supplied; ``interaction_basis`` must agree if also supplied. clifford_frame_policy: Optional source-level Clifford-deformation - policy. Currently supported only by the SZZ renderer for global - uniform-axis frames. + policy. Currently supported only by the SZZ renderer. Global + axis-cycle frames and checkerboard XZZX/ZXXZ frames are rendered + as concrete deformed checks. Returns: Tuple of (operations list, qubit allocation info) @@ -1323,7 +1336,6 @@ def _build_surface_code_circuit_szz( OpType.SZ: "SZ", OpType.SZDG: "SZDG", } - basis_axis = _szz_memory_physical_axis(basis, resolved_clifford_frame) def data_q(i: int) -> int: return allocation.data_qubits[i] @@ -1348,6 +1360,13 @@ def physical_axis_for_touch(stabilizer_type: str, stab_idx: int, data_idx: int) raise ValueError(msg) from exc return check.paulis[offset].axis + def physical_axis_for_memory_data(data_idx: int) -> PauliAxis: + return _szz_memory_physical_axis_for_data( + basis, + resolved_clifford_frame, + data_idx, + ) + def append_axis_rotation_to_z( target_ops: list[SurfaceCircuitStep], axis: PauliAxis, @@ -1500,6 +1519,7 @@ def append_szz_layer( ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) ops.extend(SurfaceCircuitStep(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) for i in range(num_data): + basis_axis = physical_axis_for_memory_data(i) append_axis_rotation_to_z( ops, basis_axis, @@ -1566,6 +1586,7 @@ def append_szz_layer( # ========================================================================= ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) for i in range(num_data): + basis_axis = physical_axis_for_memory_data(i) append_axis_rotation_from_z( ops, basis_axis, diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py index eecd067a7..61e0d1308 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -68,6 +68,26 @@ def test_axis_cycle_frames_resolve_but_require_deformed_checks() -> None: resolved_f2.css_physical_memory_basis("X") +def test_checkerboard_frames_resolve_to_mixed_xzzx_zxxz_checks() -> None: + patch = SurfacePatch.create(distance=3) + + xzzx = resolve_surface_clifford_frame(patch, policy="checkerboard-xzzx") + zxxz = resolve_surface_clifford_frame(patch, policy="checkerboard_zxxz") + + assert xzzx.requires_deformed_check_synthesis + assert zxxz.requires_deformed_check_synthesis + assert {check.axes for check in xzzx.checks if len(check.axes) == 4} == { + ("X", "Z", "Z", "X"), + } + assert {check.axes for check in zxxz.checks if len(check.axes) == 4} == { + ("Z", "X", "X", "Z"), + } + assert not xzzx.logical_x.is_uniform_axis + assert not xzzx.logical_z.is_uniform_axis + assert not zxxz.logical_x.is_uniform_axis + assert not zxxz.logical_z.is_uniform_axis + + def test_explicit_mixed_local_frame_marks_only_mixed_checks_deformed() -> None: patch = SurfacePatch.create(distance=3) frames = list(global_surface_frame("identity", patch.num_data)) @@ -113,6 +133,25 @@ def test_global_axis_cycle_f_emits_uniform_y_szz_check_scaffold() -> None: assert any(op.op_type == OpType.MEASURE and op.label.startswith("sz") for op in ops) +def test_checkerboard_xzzx_emits_mixed_szz_check_scaffold() -> None: + patch = SurfacePatch.create(distance=3) + + ops, _allocation = build_surface_code_circuit( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="checkerboard_xzzx", + ) + + assert any(op.op_type == OpType.H and op.label == "prep_x_basis_d0:to_z" for op in ops) + assert any(op.op_type == OpType.H and op.label == "measure_x_basis_d0:from_z" for op in ops) + assert not any(op.label == "prep_x_basis_d1:to_z" for op in ops) + assert any("szz_x_touch_pre:X1:d1:to_z" in op.label for op in ops) + assert any("szz_touch_comp:SXDG:X:X1:d1" in op.label for op in ops) + assert any("szz_touch_comp:SZDG:Z:X1:d2" in op.label for op in ops) + + def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> None: patch = SurfacePatch.create(distance=3) @@ -131,6 +170,22 @@ def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> No assert tick_circuit.get_meta("basis") == "Z" +def test_checkerboard_xzzx_tick_circuit_keeps_source_detector_metadata() -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = generate_tick_circuit_from_patch( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + clifford_frame_policy="checkerboard_xzzx", + ) + + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") + + def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> None: patch = SurfacePatch.create(distance=3) @@ -147,6 +202,22 @@ def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> assert isinstance(dem, str) +def test_checkerboard_xzzx_native_abstract_dem_path_accepts_frame_policy() -> None: + patch = SurfacePatch.create(distance=3) + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0), + basis="Z", + circuit_source="abstract", + interaction_basis="szz", + clifford_frame_policy="checkerboard_xzzx", + ) + + assert isinstance(dem, str) + + def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: tick_circuit = build_memory_circuit( distance=3, @@ -161,3 +232,19 @@ def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: assert tick_circuit.get_meta("basis") == "Z" assert tick_circuit.get_meta("detectors") assert tick_circuit.get_meta("observables") + + +def test_checkerboard_xzzx_traced_qis_binds_runtime_result_tags() -> None: + tick_circuit = build_memory_circuit( + distance=3, + rounds=1, + basis="Z", + circuit_source="traced_qis", + interaction_basis="szz", + clifford_frame_policy="checkerboard_xzzx", + ) + + assert tick_circuit.get_meta("surface_metadata_record_binding") == "runtime_result_tags" + assert tick_circuit.get_meta("basis") == "Z" + assert tick_circuit.get_meta("detectors") + assert tick_circuit.get_meta("observables") From 0883629a9b256c1afad11eb0fbd467f369f9ab0e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 23:28:10 -0600 Subject: [PATCH 232/388] pecos-phir-pliron: add opt-in from_qis_llvm_ir_pliron adapter + real call-path test --- crates/pecos-phir-pliron/src/lib.rs | 105 +++++++++++++++++++++++++--- 1 file changed, 95 insertions(+), 10 deletions(-) diff --git a/crates/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs index c08d07fbb..4031cd32a 100644 --- a/crates/pecos-phir-pliron/src/lib.rs +++ b/crates/pecos-phir-pliron/src/lib.rs @@ -1,14 +1,14 @@ -//! Bell port — the round-2 decision gate for build-PHIR-on-pliron. -//! See pecos-docs design/slr-phir-vision.md §7.1 + slr-phir-vision-qis-port-sketch.md. +//! Parallel, non-default QIS-LLVM-IR -> pliron-PHIR path (the strangler crate, scoped to the +//! covered QIS-LLVM subset). See pecos-docs design/slr-phir-pliron-strangler-scope.md. //! -//! Milestone 0: prove the pecos-engines sim seam with a hand-built Bell ByteMessage. -//! Milestone 1: build the SAME Bell program as pliron `qec` IR (allocator/slot model), -//! emit the ByteMessage from a pliron op-walk using an EXPLICIT `Value -> qubit index` -//! side-table (NOT the `SSAValue`-as-u32 overload that pecos-phir uses), then run it -//! through the SAME unchanged pecos-engines StateVecEngine and assert the Bell invariant. +//! A QIS-LLVM-IR program is lowered to a pliron `qec` dialect (allocator/slot model; measurements +//! are SSA values with metadata in a side-table registry; `result_record_output -> qec.record` +//! export) and run through the UNCHANGED pecos-engines seam (`ClassicalControlEngine` / +//! `ByteMessage` / `Shot`) -- no pliron type leaks into the engine API. Qubit identity comes from an +//! explicit `Value -> qubit index` map, NOT the `SSAValue`-as-u32 overload the incumbent uses. //! -//! This is the concrete proof the round-2 decision is gated on: typed pliron ops feed the -//! real backend through explicit qubit/result maps, and the ByteMessage seam is untouched. +//! [`from_qis_llvm_ir_pliron`] is the narrow opt-in entry point: `.ll` text -> a boxed engine ready +//! for `HybridEngineBuilder`. The milestones M0-M7 + differential remain as the regression suite. use std::any::Any; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -18,7 +18,7 @@ use pecos_core::Angle64; use pecos_engines::byte_message::ByteMessage; use pecos_engines::hybrid::builder::HybridEngineBuilder; use pecos_engines::quantum::StateVecEngine; -use pecos_engines::{ClassicalEngine, ControlEngine, Data, Engine, EngineStage, PecosError, Shot}; +use pecos_engines::{ClassicalControlEngine, ClassicalEngine, ControlEngine, Data, Engine, EngineStage, PecosError, Shot}; use pliron::{ builtin::{ attributes::IntegerAttr, @@ -1575,6 +1575,50 @@ pub fn run_branch_measure() { println!("[branch_measure measure-inside-branch -> variable-length b2 reconstruction] OK -- r0==r1 all 200 shots, both 0 and 1 seen"); } +// ===================== public adapter: the opt-in QIS-LLVM-IR -> pliron call path ===================== + +/// True if the program has a conditional branch (`br i1 ...`) -- i.e. the adaptive single-diamond +/// shape (qprog-style). Its absence means straight-line (bell-style). This is the dispatch over the +/// covered QIS-LLVM subset; richer CFGs are out of scope (see the strangler scope doc). +fn has_conditional_branch(src: &str) -> bool { + src.lines().any(|l| l.trim().starts_with("br i1")) +} + +/// Lower a QIS-LLVM-IR program to the pliron `qec` dialect and return a boxed +/// `ClassicalControlEngine` ready for `HybridEngineBuilder` -- the narrow, opt-in entry point for the +/// pliron path. The incumbent `pecos-phir` stays the default; callers select this explicitly. +/// +/// Scope: the covered QIS-LLVM subset -- Bell-style straight-line and the single-diamond adaptive +/// shape (`h`/`x`/`cx`/`rz`/`rx`/`ry`/`zz`/`m`, one `icmp`+`br` lifted to `qec.if`, +/// `result_record_output` export). The returned engine reports its own `num_qubits()` for sizing the +/// quantum backend. Returns a structured error if the lowered IR fails verification. +/// +/// Known limits (tracked in the scope doc, not yet closed): the parser still panics on malformed +/// input outside the covered subset (parse-path hardening to structured errors is a separate item), +/// and `num_qubits()` is currently fixed at 2 (correct for the covered fixtures). +pub fn from_qis_llvm_ir_pliron(src: &str) -> std::result::Result, PecosError> { + let ctx = &mut Context::new(); + let (module, bb, reg) = if has_conditional_branch(src) { + parse_qprog_ll(ctx, src) + } else { + parse_bell_ll(ctx, src) + }; + verify_op(&module, ctx) + .map_err(|e| PecosError::Compilation(format!("pliron qec verification failed: {}", e.disp(ctx))))?; + let plan = plan_from_if_ir(ctx, bb, ®); + Ok(Box::new(PlironIfEngine { + batch1: plan.batch1, + cond_outcome_idx: plan.cond_outcome_idx, + then_cmds: plan.then_cmds, + else_cmds: plan.else_cmds, + post: plan.post, + export: plan.export, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + })) +} + // ===================== regression tests (the milestones, run via `cargo test`) ===================== #[cfg(test)] mod tests { @@ -1667,6 +1711,47 @@ mod tests { println!("[differential qprog.ll] port lowers rotations + qec.if; pecos-phir errors: {msg}"); } + /// The real call path: drive the PUBLIC `from_qis_llvm_ir_pliron` adapter (not the internal + /// parse/plan/engine pieces) through `HybridEngine`, for both the straight-line (bell) and + /// diamond (qprog) shapes -- proving the opt-in entry point produces a usable engine. + #[test] + fn adapter_real_call_path_bell_and_qprog() { + use pecos_engines::hybrid::HybridEngineBuilder; + + // bell.ll (straight-line) -> r0==r1 Bell pair via the registry/qec.record export. + let eng = from_qis_llvm_ir_pliron(include_str!("../../../examples/llvm/bell.ll")).expect("adapter lowers bell.ll"); + let n = eng.num_qubits(); + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(eng) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(n, 11))) + .build(); + let (mut saw0, mut saw1) = (false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0"); + let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1"); + assert_eq!(r0, r1, "bell via adapter must be Bell-correlated, got r0={r0} r1={r1}"); + saw0 |= r0 == 0; + saw1 |= r0 == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(saw0 && saw1, "bell via adapter must see both 00 and 11"); + + // qprog.ll (diamond) -> records r0/r1/r2; q0 is Z-diagonal so mid (r2) and final_q0 (r0) are 0. + let eng = from_qis_llvm_ir_pliron(include_str!("../../../examples/llvm/qprog.ll")).expect("adapter lowers qprog.ll"); + let n = eng.num_qubits(); + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(eng) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(n, 11))) + .build(); + for _ in 0..50 { + let shot = hybrid.run_shot().unwrap(); + assert_eq!(shot.data.get("r2").and_then(Data::as_u32), Some(0), "qprog mid (r2) deterministically 0"); + assert_eq!(shot.data.get("r0").and_then(Data::as_u32), Some(0), "qprog final_q0 (r0) deterministically 0"); + assert!(shot.data.get("r1").and_then(Data::as_u32).is_some(), "qprog final_q1 (r1) present"); + Engine::reset(&mut hybrid).unwrap(); + } + } #[test] fn m0_hand_built_bell_seam() { run_and_check("milestone-0 hand-built Bell", bell_message(), 200); From 7dbba9426a94e345a4d25976bbd25e60712a119b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 23:34:06 -0600 Subject: [PATCH 233/388] pecos-phir-pliron: tighten rz/rx/ry/if verifiers + registry/IR-drift assertion (cutover items 1-2) --- crates/pecos-phir-pliron/src/lib.rs | 126 +++++++++++++++++++++++++--- 1 file changed, 114 insertions(+), 12 deletions(-) diff --git a/crates/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs index 4031cd32a..2328de6ca 100644 --- a/crates/pecos-phir-pliron/src/lib.rs +++ b/crates/pecos-phir-pliron/src/lib.rs @@ -369,7 +369,7 @@ impl Verify for XOp { /// `qec.if(cond: i1) { then } { else }` -- region-based conditional control flow (vision-aligned; /// no CFG flattening). Two single-block regions of qec ops, no terminators. -#[pliron_op(name = "qec.if", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>, NRegionsInterface<2>, SingleBlockRegionInterface, NoTerminatorInterface], verifier = "succ")] +#[pliron_op(name = "qec.if", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>, NRegionsInterface<2>, SingleBlockRegionInterface, NoTerminatorInterface])] pub struct IfOp; impl IfOp { pub fn new(ctx: &mut Context, cond: Value) -> Self { @@ -384,9 +384,21 @@ impl IfOp { block } } +impl Verify for IfOp { + fn verify(&self, ctx: &Context) -> Result<()> { + // the condition (operand 0) must be an i1 measurement result (region count is enforced by + // NRegionsInterface<2>); inspect the operand's type read-only, do not construct it. + let cond_ty = self.get_operation().deref(ctx).get_operand(0).get_type(ctx); + let is_i1 = TypePtr::::from_ptr(cond_ty, ctx).is_ok_and(|tp| tp.deref(ctx).width() == 1); + if !is_i1 { + return verify_err!(self.loc(ctx), "qec.if condition (operand 0) must be an i1 measurement result"); + } + Ok(()) + } +} /// Single-qubit rotations carrying a fixed-point `Angle64` (qprog.ll's rz/rx/ry). -#[pliron_op(name = "qec.rz", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] +#[pliron_op(name = "qec.rz", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] pub struct RzOp; impl RzOp { pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { @@ -395,7 +407,15 @@ impl RzOp { RzOp { op } } } -#[pliron_op(name = "qec.rx", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] +impl Verify for RzOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.rz operand must be a qec.qubitref"); + } + Ok(()) + } +} +#[pliron_op(name = "qec.rx", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] pub struct RxOp; impl RxOp { pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { @@ -404,7 +424,15 @@ impl RxOp { RxOp { op } } } -#[pliron_op(name = "qec.ry", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>], verifier = "succ")] +impl Verify for RxOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.rx operand must be a qec.qubitref"); + } + Ok(()) + } +} +#[pliron_op(name = "qec.ry", format, interfaces = [NOpdsInterface<1>, NResultsInterface<0>])] pub struct RyOp; impl RyOp { pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { @@ -413,6 +441,14 @@ impl RyOp { RyOp { op } } } +impl Verify for RyOp { + fn verify(&self, ctx: &Context) -> Result<()> { + if self.get_operation().deref(ctx).get_operand(0).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.ry operand must be a qec.qubitref"); + } + Ok(()) + } +} /// Two-qubit sqrt-ZZ entangler (PECOS `SZZ`; qprog.ll's no-angle `zz` maps here). #[pliron_op(name = "qec.szz", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>], verifier = "succ")] pub struct SzzOp; @@ -760,6 +796,21 @@ pub struct AdaptivePlan { batch2: Vec, // post-condition ops (final Mz) } +/// Build the `Cmd::Mz` for a `qec.measure`, asserting the registry's qubit agrees with the IR slot +/// index. The registry is a side-table, so `verify` can't catch a mismatch; this fails loud at +/// plan-build time if the registry and the IR ever drift apart. +fn measure_to_mz(ctx: &Context, m: MeasureOp, qubit_of: &HashMap, reg: &MeasurementRegistry) -> Cmd { + let operand = m.get_operation().deref(ctx).get_operand(0); + let slot_qubit = qubit_of[&operand]; + let info = reg.get(m.get_result(ctx)); + assert_eq!( + info.qubit, slot_qubit, + "measurement registry qubit ({}) disagrees with the IR slot index ({}) -- registry/IR drift", + info.qubit, slot_qubit + ); + Cmd::Mz(info.qubit, info.export_label) +} + /// Walk a `qec` block once and split it into the two-batch adaptive plan at the `cond_x` boundary. /// Gate qubits come from the explicit slot-index map; measurement metadata (qubit + export label) /// comes from the `MeasurementRegistry`, keyed by the measure op's SSA value. @@ -785,13 +836,13 @@ pub fn plan_from_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegi let t = qubit_of[&opn.deref(ctx).get_operand(1)]; if after_cond { batch2.push(Cmd::Cx(c, t)) } else { batch1.push(Cmd::Cx(c, t)) } } else if let Some(m) = Operation::get_op::(op, ctx) { - let info = reg.get(m.get_result(ctx)); + let mz = measure_to_mz(ctx, m, &qubit_of, reg); if after_cond { - batch2.push(Cmd::Mz(info.qubit, info.export_label)); + batch2.push(mz); } else { cond_outcome_idx = mz_b1; mz_b1 += 1; - batch1.push(Cmd::Mz(info.qubit, info.export_label)); + batch1.push(mz); } } else if let Some(c) = Operation::get_op::(op, ctx) { cond_target = qubit_of[&c.get_operation().deref(ctx).get_operand(1)]; @@ -1001,8 +1052,7 @@ pub fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap(op, ctx) { - let info = reg.get(m.get_result(ctx)); - cmds.push(Cmd::Mz(info.qubit, info.export_label)); + cmds.push(measure_to_mz(ctx, m, qubit_of, reg)); } } cmds @@ -1058,13 +1108,13 @@ pub fn plan_from_if_ir(ctx: &Context, block: Ptr, reg: &MeasurementR let bq = qubit_of[&opn.deref(ctx).get_operand(1)]; if after_if { post.push(Cmd::Szz(a, bq)) } else { batch1.push(Cmd::Szz(a, bq)) } } else if let Some(m) = Operation::get_op::(op, ctx) { - let info = reg.get(m.get_result(ctx)); + let mz = measure_to_mz(ctx, m, &qubit_of, reg); if after_if { - post.push(Cmd::Mz(info.qubit, info.export_label)); + post.push(mz); } else { cond_outcome_idx = mz_b1; mz_b1 += 1; - batch1.push(Cmd::Mz(info.qubit, info.export_label)); + batch1.push(mz); } } else if let Some(ifop) = Operation::get_op::(op, ctx) { then_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 0), &qubit_of, reg); @@ -1844,6 +1894,58 @@ mod tests { ); } + /// `qec.rz` (and rx/ry) must reject a non-qubitref operand -- guards the rotation verifiers that + /// were tightened from `verifier = "succ"`. + #[test] + fn negative_rz_on_alloc_rejected() { + let ctx = &mut Context::new(); + let alloc = QallocOp::new(ctx); + // rz on a qec.alloc handle (not a qubitref). + let bad = RzOp::new(ctx, alloc.get_result(ctx), Angle64::from_radians(0.5)); + assert!(verify_op(&bad, ctx).is_err(), "qec.rz on a qec.alloc handle must fail verification"); + } + + /// `qec.if`'s condition must be an `i1`; a non-`i1` (here a qubitref) must be rejected -- guards + /// the IfOp verifier tightened from `verifier = "succ"`. + #[test] + fn negative_if_non_i1_condition_rejected() { + let ctx = &mut Context::new(); + let alloc = QallocOp::new(ctx); + let s0v = SlotOp::new(ctx, alloc.get_result(ctx), 0).get_result(ctx); + let ifop = IfOp::new(ctx, s0v); // qubitref condition -- not an i1 + ifop.make_region_block(ctx, 0); + ifop.make_region_block(ctx, 1); + let err = verify_op(&ifop, ctx).expect_err("qec.if with a non-i1 condition must fail verification"); + assert!( + format!("{}", err.disp(ctx)).contains("i1"), + "expected the i1-condition rejection, got: {}", + err.disp(ctx) + ); + } + + /// The plan-build assertion catches registry/IR drift: a measurement whose registry qubit + /// disagrees with its IR slot index must panic (the registry is a side-table `verify` can't check). + #[test] + #[should_panic(expected = "registry/IR drift")] + fn registry_ir_drift_panics_at_plan_build() { + let ctx = &mut Context::new(); + let module = ModuleOp::new(ctx, "drift".try_into().unwrap()); + let func_ty = FunctionType::get(ctx, vec![], vec![]); + let func = FuncOp::new(ctx, "main".try_into().unwrap(), func_ty); + module.append_operation(ctx, func.get_operation(), 0); + let bb = func.get_entry_block(ctx); + macro_rules! push { + ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + } + let qv = push!(QallocOp::new(ctx)).get_result(ctx); + let sv = push!(SlotOp::new(ctx, qv, 0)).get_result(ctx); // slot index 0 + push!(PrepareOp::new(ctx, sv)); + let m = push!(MeasureOp::new(ctx, sv)); + let mut reg = MeasurementRegistry::default(); + reg.record(m.get_result(ctx), MeasurementInfo { qubit: 1, basis: Basis::Z, export_label: 0 }); // qubit 1 != slot 0 + let _ = plan_from_if_ir(ctx, bb, ®); // measure_to_mz must assert and panic + } + /// The `qec.angle` attribute round-trips an `Angle64` through the IR exactly (fixed-point, no /// f64 bit-pattern hack): the fraction stored on a `qec.rz` reads back bit-identical. #[test] From 38c13019e19d2931b1816967491907bba3ffc2e5 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 17 Jun 2026 23:42:08 -0600 Subject: [PATCH 234/388] pecos-phir-pliron: dynamic qubit count + 3-qubit GHZ fixture (cutover items 3,5) --- crates/pecos-phir-pliron/fixtures/ghz3.ll | 22 +++++++++++ crates/pecos-phir-pliron/src/lib.rs | 48 ++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) create mode 100644 crates/pecos-phir-pliron/fixtures/ghz3.ll diff --git a/crates/pecos-phir-pliron/fixtures/ghz3.ll b/crates/pecos-phir-pliron/fixtures/ghz3.ll new file mode 100644 index 000000000..3c3a4d518 --- /dev/null +++ b/crates/pecos-phir-pliron/fixtures/ghz3.ll @@ -0,0 +1,22 @@ +; 3-qubit GHZ (straight-line, no conditional branch) -- exercises dynamic qubit count past the +; 2-qubit fixtures: h q0; cx q0,q1; cx q1,q2; measure all; record. State is (|000>+|111>)/sqrt(2), +; so the three recorded results are perfectly correlated: r0 == r1 == r2 in every shot. +declare void @__quantum__qis__h__body(i64) +declare void @__quantum__qis__cx__body(i64, i64) +declare i32 @__quantum__qis__m__body(i64, i64) +declare void @__quantum__rt__result_record_output(i64, i8*) + +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + call void @__quantum__qis__cx__body(i64 0, i64 1) + call void @__quantum__qis__cx__body(i64 1, i64 2) + %r0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + %r1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + %r2 = call i32 @__quantum__qis__m__body(i64 2, i64 2) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + call void @__quantum__rt__result_record_output(i64 2, i8* null) + ret i64 0 +} + +attributes #0 = { "EntryPoint" } diff --git a/crates/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs index 2328de6ca..fd57d8971 100644 --- a/crates/pecos-phir-pliron/src/lib.rs +++ b/crates/pecos-phir-pliron/src/lib.rs @@ -852,6 +852,22 @@ pub fn plan_from_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegi AdaptivePlan { batch1, cond_outcome_idx, cond_target, batch2 } } +/// Number of qubits a Cmd stream touches = max referenced qubit index + 1 (0 if it touches none). +/// Lets an engine report its real `num_qubits()` instead of a hard-coded value. +pub fn cmds_num_qubits(batches: &[&[Cmd]]) -> usize { + let mut n = 0; + for cmds in batches { + for c in *cmds { + let hi = match *c { + Cmd::Pz(q) | Cmd::H(q) | Cmd::X(q) | Cmd::Rz(q, _) | Cmd::Rx(q, _) | Cmd::Ry(q, _) | Cmd::Mz(q, _) => q, + Cmd::Szz(a, b) | Cmd::Cx(a, b) => a.max(b), + }; + n = n.max(hi + 1); + } + } + n +} + pub fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: &[Cmd]) { for c in cmds { match *c { @@ -909,7 +925,8 @@ impl Engine for PlironAdaptiveEngine { impl ClassicalEngine for PlironAdaptiveEngine { fn num_qubits(&self) -> usize { - 2 + // gates/measures across both batches, plus the conditional-X target qubit. + cmds_num_qubits(&[&self.plan.batch1, &self.plan.batch2]).max(self.plan.cond_target + 1) } fn generate_commands(&mut self) -> std::result::Result { if self.stage == 0 { @@ -1186,7 +1203,9 @@ impl Engine for PlironIfEngine { fn reset(&mut self) -> std::result::Result<(), PecosError> { self.stage = 0; self.b1.clear(); self.b2.clear(); Ok(()) } } impl ClassicalEngine for PlironIfEngine { - fn num_qubits(&self) -> usize { 2 } + fn num_qubits(&self) -> usize { + cmds_num_qubits(&[&self.batch1, &self.then_cmds, &self.else_cmds, &self.post]) + } fn generate_commands(&mut self) -> std::result::Result { if self.stage == 0 { self.stage = 1; Ok(self.b1_msg()) } else { Ok(ByteMessage::create_empty()) } } @@ -1802,6 +1821,31 @@ mod tests { Engine::reset(&mut hybrid).unwrap(); } } + /// Dynamic qubit count: a 3-qubit GHZ through the adapter must report `num_qubits()==3` (not the + /// old hard-coded 2) and produce a perfectly correlated triple (r0==r1==r2). + #[test] + fn adapter_ghz3_dynamic_qubit_count() { + use pecos_engines::hybrid::HybridEngineBuilder; + let eng = from_qis_llvm_ir_pliron(include_str!("../fixtures/ghz3.ll")).expect("adapter lowers ghz3.ll"); + assert_eq!(eng.num_qubits(), 3, "GHZ-3 engine must report 3 qubits (dynamic, not hard-coded 2)"); + let n = eng.num_qubits(); + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(eng) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(n, 13))) + .build(); + let (mut saw0, mut saw1) = (false, false); + for _ in 0..200 { + let shot = hybrid.run_shot().unwrap(); + let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0"); + let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1"); + let r2 = shot.data.get("r2").and_then(Data::as_u32).expect("r2"); + assert!(r0 == r1 && r1 == r2, "GHZ-3 must be fully correlated, got r0={r0} r1={r1} r2={r2}"); + saw0 |= r0 == 0; + saw1 |= r0 == 1; + Engine::reset(&mut hybrid).unwrap(); + } + assert!(saw0 && saw1, "GHZ-3 must see both 000 and 111"); + } #[test] fn m0_hand_built_bell_seam() { run_and_check("milestone-0 hand-built Bell", bell_message(), 200); From 5dfc8b640d4c41711a910d34473a367a350032dc Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 18 Jun 2026 10:09:16 -0600 Subject: [PATCH 235/388] Cover both checkerboard surface frame orientations --- .../qec/surface/test_clifford_deformation.py | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py index 61e0d1308..e2c09de11 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -133,7 +133,20 @@ def test_global_axis_cycle_f_emits_uniform_y_szz_check_scaffold() -> None: assert any(op.op_type == OpType.MEASURE and op.label.startswith("sz") for op in ops) -def test_checkerboard_xzzx_emits_mixed_szz_check_scaffold() -> None: +@pytest.mark.parametrize( + ("policy", "rotated_data", "unrotated_data", "x_touch_data", "z_touch_data"), + [ + ("checkerboard_xzzx", 0, 1, 1, 2), + ("checkerboard_zxxz", 1, 0, 2, 1), + ], +) +def test_checkerboard_frames_emit_mixed_szz_check_scaffold( + policy: str, + rotated_data: int, + unrotated_data: int, + x_touch_data: int, + z_touch_data: int, +) -> None: patch = SurfacePatch.create(distance=3) ops, _allocation = build_surface_code_circuit( @@ -141,15 +154,23 @@ def test_checkerboard_xzzx_emits_mixed_szz_check_scaffold() -> None: num_rounds=1, basis="Z", interaction_basis="szz", - clifford_frame_policy="checkerboard_xzzx", + clifford_frame_policy=policy, ) - assert any(op.op_type == OpType.H and op.label == "prep_x_basis_d0:to_z" for op in ops) - assert any(op.op_type == OpType.H and op.label == "measure_x_basis_d0:from_z" for op in ops) - assert not any(op.label == "prep_x_basis_d1:to_z" for op in ops) - assert any("szz_x_touch_pre:X1:d1:to_z" in op.label for op in ops) - assert any("szz_touch_comp:SXDG:X:X1:d1" in op.label for op in ops) - assert any("szz_touch_comp:SZDG:Z:X1:d2" in op.label for op in ops) + assert any( + op.op_type == OpType.H + and op.label == f"prep_x_basis_d{rotated_data}:to_z" + for op in ops + ) + assert any( + op.op_type == OpType.H + and op.label == f"measure_x_basis_d{rotated_data}:from_z" + for op in ops + ) + assert not any(op.label == f"prep_x_basis_d{unrotated_data}:to_z" for op in ops) + assert any(f"szz_x_touch_pre:X1:d{x_touch_data}:to_z" in op.label for op in ops) + assert any(f"szz_touch_comp:SXDG:X:X1:d{x_touch_data}" in op.label for op in ops) + assert any(f"szz_touch_comp:SZDG:Z:X1:d{z_touch_data}" in op.label for op in ops) def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> None: @@ -170,7 +191,8 @@ def test_global_axis_cycle_f_tick_circuit_keeps_source_detector_metadata() -> No assert tick_circuit.get_meta("basis") == "Z" -def test_checkerboard_xzzx_tick_circuit_keeps_source_detector_metadata() -> None: +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_tick_circuit_keeps_source_detector_metadata(policy: str) -> None: patch = SurfacePatch.create(distance=3) tick_circuit = generate_tick_circuit_from_patch( @@ -178,7 +200,7 @@ def test_checkerboard_xzzx_tick_circuit_keeps_source_detector_metadata() -> None num_rounds=1, basis="Z", interaction_basis="szz", - clifford_frame_policy="checkerboard_xzzx", + clifford_frame_policy=policy, ) assert tick_circuit.get_meta("basis") == "Z" @@ -202,7 +224,8 @@ def test_global_axis_cycle_f_native_abstract_dem_path_accepts_frame_policy() -> assert isinstance(dem, str) -def test_checkerboard_xzzx_native_abstract_dem_path_accepts_frame_policy() -> None: +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_native_abstract_dem_path_accepts_frame_policy(policy: str) -> None: patch = SurfacePatch.create(distance=3) dem = generate_circuit_level_dem_from_builder( @@ -212,7 +235,7 @@ def test_checkerboard_xzzx_native_abstract_dem_path_accepts_frame_policy() -> No basis="Z", circuit_source="abstract", interaction_basis="szz", - clifford_frame_policy="checkerboard_xzzx", + clifford_frame_policy=policy, ) assert isinstance(dem, str) @@ -234,14 +257,15 @@ def test_clifford_frame_policy_traced_qis_binds_runtime_result_tags() -> None: assert tick_circuit.get_meta("observables") -def test_checkerboard_xzzx_traced_qis_binds_runtime_result_tags() -> None: +@pytest.mark.parametrize("policy", ["checkerboard_xzzx", "checkerboard_zxxz"]) +def test_checkerboard_traced_qis_binds_runtime_result_tags(policy: str) -> None: tick_circuit = build_memory_circuit( distance=3, rounds=1, basis="Z", circuit_source="traced_qis", interaction_basis="szz", - clifford_frame_policy="checkerboard_xzzx", + clifford_frame_policy=policy, ) assert tick_circuit.get_meta("surface_metadata_record_binding") == "runtime_result_tags" From 692861465dfd3e97a15a43b93c5225403ca24bc8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 18 Jun 2026 15:51:53 -0600 Subject: [PATCH 236/388] pecos-phir-pliron: parse -> Result, reject out-of-subset input with structured errors (final cutover item) --- crates/pecos-phir-pliron/src/lib.rs | 175 +++++++++++++++++++++------- 1 file changed, 132 insertions(+), 43 deletions(-) diff --git a/crates/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs index fd57d8971..1487894a8 100644 --- a/crates/pecos-phir-pliron/src/lib.rs +++ b/crates/pecos-phir-pliron/src/lib.rs @@ -672,10 +672,16 @@ pub fn run_milestone_2() { // ===================== Milestone 3: parse a real bell.ll into pliron qec IR ===================== -/// Minimal QIS-LLVM-IR -> pliron `qec` IR parser for the Bell fixture (`examples/llvm/bell.ll`). -/// Recognizes `__quantum__qis__{h,cx,m}__body` calls with integer qubit args. This proves the -/// LLVM-IR frontend ports onto pliron ops (not just hand-built IR) -- the last gate-fidelity item. -pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr, MeasurementRegistry) { +/// Structured rejection for anything outside the covered QIS-LLVM subset (see the strangler scope +/// doc) -- used instead of silently dropping unrecognized calls or panicking on malformed structure. +fn unsupported_qis(msg: impl Into) -> PecosError { + PecosError::Feature(format!("pecos-phir-pliron: unsupported QIS-LLVM-IR -- {}", msg.into())) +} + +/// Minimal QIS-LLVM-IR -> pliron `qec` IR parser for the Bell-style straight-line subset. Recognizes +/// `__quantum__qis__{h,cx,m}__body` + `__quantum__rt__result_record_output`; any other `__quantum__` +/// call (or a malformed operand list) is rejected with a structured error rather than dropped. +pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { fn i64_args(line: &str) -> Vec { match (line.find('('), line.rfind(')')) { (Some(l), Some(r)) if r > l => line[l + 1..r] @@ -693,21 +699,31 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr if !(l.starts_with("call ") || l.contains("= call ")) { continue; } + let a = i64_args(l); if l.contains("__quantum__qis__h__body") { - let a = i64_args(l); - qubits.insert(a[0]); + let q = *a.first().ok_or_else(|| unsupported_qis("h__body missing qubit operand"))?; + qubits.insert(q); parsed.push(("h", a)); } else if l.contains("__quantum__qis__cx__body") { - let a = i64_args(l); + if a.len() < 2 { + return Err(unsupported_qis("cx__body needs two qubit operands")); + } qubits.insert(a[0]); qubits.insert(a[1]); parsed.push(("cx", a)); } else if l.contains("__quantum__qis__m__body") { - let a = i64_args(l); // (qubit, result_id) + if a.len() < 2 { + return Err(unsupported_qis("m__body needs (qubit, result_id) operands")); + } qubits.insert(a[0]); parsed.push(("m", a)); } else if l.contains("__quantum__rt__result_record_output") { - parsed.push(("record", i64_args(l))); // (result_id) + if a.is_empty() { + return Err(unsupported_qis("result_record_output missing result_id operand")); + } + parsed.push(("record", a)); + } else if l.contains("__quantum__") { + return Err(unsupported_qis(format!("operation not in the covered subset: {l}"))); } } // pass 2: build the pliron qec IR + the measurement-SSA registry @@ -748,20 +764,22 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr } "record" => { let rid = a[0] as u64; - let v = *measured.get(&rid).unwrap_or_else(|| panic!("result_record_output references unknown result-id {rid}")); + let v = *measured + .get(&rid) + .ok_or_else(|| unsupported_qis(format!("result_record_output references unknown result-id {rid}")))?; push!(RecordOp::new(ctx, v)); } _ => {} } } push!(EndOp::new(ctx)); - (module, bb, reg) + Ok((module, bb, reg)) } pub fn run_milestone_3() { let ctx = &mut Context::new(); let src = include_str!("../../../examples/llvm/bell.ll"); - let (module, bb, _reg) = parse_bell_ll(ctx, src); + let (module, bb, _reg) = parse_bell_ll(ctx, src).expect("milestone-3: parse bell.ll"); println!("=== bell.ll parsed into pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-3 verify] FAILED: {}", e.disp(ctx))); @@ -1384,7 +1402,7 @@ pub fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { /// Emit one parsed op into `block`. `measured` accumulates `result-id -> measurement-SSA Value` so a /// later `ParsedOp::Record` resolves exactly which `qec.measure` becomes a program output; `reg` /// gets each measurement's metadata (qubit, basis, export label) keyed by its SSA value. -pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap, measured: &mut HashMap, reg: &mut MeasurementRegistry) { +pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap, measured: &mut HashMap, reg: &mut MeasurementRegistry) -> std::result::Result<(), PecosError> { match *p { ParsedOp::H(q) => { HOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } // .ll angles are f64 radians (the wire format); convert to fixed-point Angle64 at the boundary. @@ -1401,14 +1419,19 @@ pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot reg.record(v, MeasurementInfo { qubit: q, basis: Basis::Z, export_label: rid }); } ParsedOp::Record(rid) => { - let v = *measured.get(&rid).unwrap_or_else(|| panic!("result_record_output references unknown result-id {rid}")); + let v = *measured + .get(&rid) + .ok_or_else(|| unsupported_qis(format!("result_record_output references unknown result-id {rid}")))?; RecordOp::new(ctx, v).get_operation().insert_at_back(block, ctx); } } + Ok(()) } -/// Parse qprog.ll's diamond CFG, lifting the conditional branch into a `qec.if`. -pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr, MeasurementRegistry) { +/// Parse the adaptive single-diamond QIS-LLVM subset, lifting the conditional branch into a `qec.if`. +/// Rejects (structured error, not silent-drop/panic) anything outside the subset: unrecognized +/// `__quantum__` calls, malformed operand lists, and more than one conditional branch. +pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { // pass 1: collect ops per block label (entry = "") and the conditional-branch targets. let mut blocks: Vec<(String, Vec)> = Vec::new(); let mut cur_label = String::new(); @@ -1426,28 +1449,35 @@ pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr (ModuleOp, Ptr (*q, *rid), - _ => panic!("expected entry block to end with a measurement (qprog mid-measure)"), + _ => return Err(unsupported_qis("entry block must end with a mid-measurement (single-diamond adaptive shape)")), }; - for p in &entry_ops[..entry_ops.len() - 1] { emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg); } + for p in &entry_ops[..entry_ops.len() - 1] { emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg)?; } let m0 = MeasureOp::new(ctx, slot_of[&mid_q]); m0.get_operation().insert_at_back(bb, ctx); let m0v = m0.get_result(ctx); @@ -1499,19 +1529,19 @@ pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> (ModuleOp, Ptr bool { pub fn from_qis_llvm_ir_pliron(src: &str) -> std::result::Result, PecosError> { let ctx = &mut Context::new(); let (module, bb, reg) = if has_conditional_branch(src) { - parse_qprog_ll(ctx, src) + parse_qprog_ll(ctx, src)? } else { - parse_bell_ll(ctx, src) + parse_bell_ll(ctx, src)? }; verify_op(&module, ctx) .map_err(|e| PecosError::Compilation(format!("pliron qec verification failed: {}", e.disp(ctx))))?; @@ -1710,7 +1740,7 @@ mod tests { // port side: bell.ll -> pliron qec (incl. qec.record + registry) -> the SAME export path as // qprog (plan_from_if_ir + PlironIfEngine) -> r0/r1 registers. No IfOp -> a single batch. let ctx = &mut Context::new(); - let (module, bb, reg) = parse_bell_ll(ctx, bell); + let (module, bb, reg) = parse_bell_ll(ctx, bell).expect("port lowers bell.ll"); verify_op(&module, ctx).expect("port lowers bell.ll (with qec.record) and verifies"); let plan = plan_from_if_ir(ctx, bb, ®); assert_eq!(plan.export, vec![0, 1], "bell.ll records result-ids 0 (q0) then 1 (q1)"); @@ -1766,7 +1796,7 @@ mod tests { // port side: lowers without error (full end-to-end physics is m6). let ctx = &mut Context::new(); - let (module, _bb, _reg) = parse_qprog_ll(ctx, qprog); + let (module, _bb, _reg) = parse_qprog_ll(ctx, qprog).expect("port lowers qprog.ll"); verify_op(&module, ctx).expect("port lowers qprog.ll (rotations + qec.if) and verifies"); // pecos-phir side: errors lowering the rotation angle. @@ -1890,7 +1920,7 @@ mod tests { fn registry_holds_measurement_metadata() { let ctx = &mut Context::new(); let src = include_str!("../../../examples/llvm/qprog.ll"); - let (_module, bb, reg) = parse_qprog_ll(ctx, src); + let (_module, bb, reg) = parse_qprog_ll(ctx, src).expect("parse qprog.ll"); let mut infos: Vec<(usize, Basis, u64)> = bb .deref(ctx) .iter(ctx) @@ -2062,11 +2092,10 @@ mod tests { assert!(!empty.as_bytes().is_empty(), "create_empty().as_bytes() is NOT byte-empty -- that is the footgun"); } - /// A `result_record_output` that names a result-id no measurement produced must fail loud, not - /// silently record a default 0. Guards the explicit measurement-SSA -> export resolution. + /// A `result_record_output` that names a result-id no measurement produced must return a + /// structured error (not silently record a default 0, not panic). Guards the export resolution. #[test] - #[should_panic(expected = "unknown result-id 9")] - fn negative_record_of_unknown_result_id_panics() { + fn negative_record_of_unknown_result_id_errors() { const BAD: &str = "\ define i64 @qmain(i64 %arg) #0 { call void @__quantum__qis__h__body(i64 0) @@ -2085,6 +2114,66 @@ final: } "; let ctx = &mut Context::new(); - let _ = parse_qprog_ll(ctx, BAD); // records result-id 9, which no measurement defines + let Err(err) = parse_qprog_ll(ctx, BAD) else { + panic!("recording an unknown result-id must error, but the parse succeeded"); + }; + assert!( + format!("{err}").contains("unknown result-id 9"), + "expected the unknown-result-id rejection, got: {err}" + ); + } + + /// A QIS call outside the covered subset (here an `s` gate) must be rejected with a structured + /// error, NOT silently dropped (the old behavior). + #[test] + fn negative_unsupported_gate_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + call void @__quantum__qis__s__body(i64 0) + %r0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + ret i64 0 +} +"; + let ctx = &mut Context::new(); + let Err(err) = parse_bell_ll(ctx, BAD) else { + panic!("an unsupported gate must error, but the parse succeeded"); + }; + assert!( + format!("{err}").contains("not in the covered subset"), + "expected the unsupported-operation rejection, got: {err}" + ); + } + + /// More than one conditional branch is outside the single-diamond subset and must be rejected + /// (not misparsed by taking only the first branch). + #[test] + fn negative_multiple_conditional_branches_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %m0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + %c0 = icmp eq i32 %m0, 1 + br i1 %c0, label %a, label %b +a: + %m1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + %c1 = icmp eq i32 %m1, 1 + br i1 %c1, label %c, label %d +b: + br label %d +c: + br label %d +d: + ret i64 0 +} +"; + let ctx = &mut Context::new(); + let Err(err) = parse_qprog_ll(ctx, BAD) else { + panic!("more than one conditional branch must error, but the parse succeeded"); + }; + assert!( + format!("{err}").contains("more than one conditional branch"), + "expected the unsupported-control-flow rejection, got: {err}" + ); } } From a3198927f9c18d8d2095047cebd94a3e7934960b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 18 Jun 2026 21:31:54 -0600 Subject: [PATCH 237/388] pecos-phir-pliron: adopt CFG-shape classifier + add branch-condition dataflow validation (round-7) --- crates/pecos-phir-pliron/src/lib.rs | 564 +++++++++++++++++++++++++++- 1 file changed, 548 insertions(+), 16 deletions(-) diff --git a/crates/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs index 1487894a8..fa8e40115 100644 --- a/crates/pecos-phir-pliron/src/lib.rs +++ b/crates/pecos-phir-pliron/src/lib.rs @@ -682,6 +682,8 @@ fn unsupported_qis(msg: impl Into) -> PecosError { /// `__quantum__qis__{h,cx,m}__body` + `__quantum__rt__result_record_output`; any other `__quantum__` /// call (or a malformed operand list) is rejected with a structured error rather than dropped. pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { + validate_straight_line_qis_shape(src)?; + fn i64_args(line: &str) -> Vec { match (line.find('('), line.rfind(')')) { (Some(l), Some(r)) if r > l => line[l + 1..r] @@ -694,8 +696,19 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modul // pass 1: collect the gate/measure/record stream + the set of qubits referenced let mut parsed: Vec<(&str, Vec)> = Vec::new(); let mut qubits: BTreeSet = BTreeSet::new(); + let mut in_func = false; for line in src.lines() { let l = line.trim(); + if l.starts_with("define ") && l.contains("@qmain") { + in_func = true; + continue; + } + if !in_func { + continue; + } + if l == "}" { + break; + } if !(l.starts_with("call ") || l.contains("= call ")) { continue; } @@ -1390,6 +1403,281 @@ pub fn br_labels(l: &str) -> Vec { .filter_map(|s| s.split([',', ' ']).next().filter(|x| !x.is_empty()).map(str::to_string)) .collect() } + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum QisLlShape { + StraightLine, + SingleDiamond, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum LastQuantumOp { + Measure, + Other, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum LlTerminator { + Ret, + Br(String), + CondBr { cond: String, then_label: String, else_label: String }, +} + +/// A parsed `%result = icmp i32 , ` -- enough to prove the branch condition's dataflow. +#[derive(Clone, Debug, PartialEq, Eq)] +struct IcmpShape { + result: String, // the `%name` the icmp defines + measure_ref: Option, // the `%ssa` operand (the measurement result it compares), if any + rhs_const: Option, // the integer-literal operand, if any + pred_eq: bool, // predicate is `eq` +} + +#[derive(Clone, Debug, Default)] +struct LlBlockShape { + label: String, + terminator: Option, + last_quantum_before_terminator: Option, + measures: Vec, // SSA result names of `m__body` calls, in order + icmp: Option, // the last `icmp` defined in the block +} + +/// `%name = ...` -> `Some("%name")` (the SSA value a line defines), else `None`. +fn ssa_lhs(l: &str) -> Option<&str> { + let (lhs, _rhs) = l.split_once('=')?; + let lhs = lhs.trim(); + lhs.starts_with('%').then_some(lhs) +} + +/// Parse the RHS of `%result = icmp eq i32 %mid, 1` into an [`IcmpShape`]. +fn parse_icmp(result: &str, rhs: &str) -> Option { + let rest = rhs.trim().strip_prefix("icmp ")?; // "eq i32 %mid, 1" + let mut it = rest.split_whitespace(); + let pred = it.next()?; // "eq" + let _ty = it.next()?; // "i32" + let operands = it.collect::>().join(" "); // "%mid, 1" + let (mut measure_ref, mut rhs_const) = (None, None); + for op in operands.split(',') { + let op = op.trim(); + if op.starts_with('%') { + measure_ref = Some(op.to_string()); + } else if let Ok(k) = op.parse::() { + rhs_const = Some(k); + } + } + Some(IcmpShape { result: result.to_string(), measure_ref, rhs_const, pred_eq: pred == "eq" }) +} + +fn block_name(label: &str) -> &str { + if label.is_empty() { "entry" } else { label } +} + +fn collect_qmain_cfg(src: &str) -> std::result::Result, PecosError> { + let mut blocks = Vec::new(); + let mut cur = LlBlockShape::default(); + let mut in_func = false; + let mut saw_qmain = false; + for raw in src.lines() { + let l = raw.trim(); + if !in_func { + if l.starts_with("define ") && l.contains("@qmain") { + in_func = true; + saw_qmain = true; + cur = LlBlockShape::default(); + } + continue; + } + if l.is_empty() || l.starts_with(';') { + continue; + } + if l == "}" { + blocks.push(cur); + return Ok(blocks); + } + if l.ends_with(':') && !l.contains(' ') { + blocks.push(cur); + cur = LlBlockShape { label: l.trim_end_matches(':').to_string(), ..Default::default() }; + continue; + } + if cur.terminator.is_some() { + return Err(unsupported_qis(format!("instructions after terminator in block {}", block_name(&cur.label)))); + } + if l.starts_with("br ") { + let labels = br_labels(l); + cur.terminator = Some(if l.starts_with("br i1 ") { + if labels.len() != 2 { + return Err(unsupported_qis(format!("conditional branch must name two labels: {l}"))); + } + let cond = l + .strip_prefix("br i1 ") + .and_then(|r| r.split(',').next()) + .map(str::trim) + .unwrap_or_default() + .to_string(); + LlTerminator::CondBr { cond, then_label: labels[0].clone(), else_label: labels[1].clone() } + } else if labels.len() == 1 { + LlTerminator::Br(labels[0].clone()) + } else { + return Err(unsupported_qis(format!("unsupported branch form: {l}"))); + }); + } else if l.starts_with("ret ") { + cur.terminator = Some(LlTerminator::Ret); + } else if l.contains("__quantum__qis__m__body") { + cur.last_quantum_before_terminator = Some(LastQuantumOp::Measure); + if let Some(name) = ssa_lhs(l) { + cur.measures.push(name.to_string()); // record the measurement-result SSA name + } + } else if l.contains("__quantum__") { + cur.last_quantum_before_terminator = Some(LastQuantumOp::Other); + } else if let Some(lhs) = ssa_lhs(l) + && let Some((_, rhs)) = l.split_once('=') + && rhs.trim().starts_with("icmp ") + { + cur.icmp = parse_icmp(lhs, rhs.trim()); // the branch-condition icmp + } + } + if saw_qmain { + Err(unsupported_qis("qmain function missing closing brace")) + } else { + Err(unsupported_qis("missing @qmain function")) + } +} + +fn validate_straight_line_blocks(blocks: &[LlBlockShape]) -> std::result::Result<(), PecosError> { + if blocks.len() != 1 || blocks.first().is_some_and(|b| !b.label.is_empty()) { + return Err(unsupported_qis("straight-line subset must have exactly one basic block; control-flow labels/branches are unsupported")); + } + match blocks.first().and_then(|b| b.terminator.as_ref()) { + Some(LlTerminator::Br(_)) | Some(LlTerminator::CondBr { .. }) => Err(unsupported_qis("straight-line subset cannot contain branch control flow")), + Some(LlTerminator::Ret) | None => Ok(()), + } +} + +fn validate_straight_line_qis_shape(src: &str) -> std::result::Result<(), PecosError> { + let blocks = collect_qmain_cfg(src)?; + validate_straight_line_blocks(&blocks) +} + +fn single_diamond_branch_target(block: &LlBlockShape) -> std::result::Result<&str, PecosError> { + match &block.terminator { + Some(LlTerminator::Br(target)) => Ok(target.as_str()), + _ => Err(unsupported_qis(format!("then/else block {} must end with an unconditional branch to the common merge block", block_name(&block.label)))), + } +} + +fn validate_single_diamond_blocks(blocks: &[LlBlockShape]) -> std::result::Result<(), PecosError> { + let cond_blocks: Vec<&LlBlockShape> = blocks + .iter() + .filter(|b| matches!(&b.terminator, Some(LlTerminator::CondBr { .. }))) + .collect(); + if cond_blocks.len() > 1 { + return Err(unsupported_qis("more than one conditional branch -- only a single diamond is supported")); + } + if cond_blocks.is_empty() { + return Err(unsupported_qis("single-diamond subset requires one conditional branch")); + } + + let entry = blocks.iter().find(|b| b.label.is_empty()).ok_or_else(|| unsupported_qis("missing entry block"))?; + if !cond_blocks[0].label.is_empty() { + return Err(unsupported_qis("single-diamond conditional branch must be in the entry block")); + } + if entry.last_quantum_before_terminator != Some(LastQuantumOp::Measure) { + return Err(unsupported_qis("entry conditional branch must follow a mid-measurement")); + } + + let (cond, then_label, else_label) = match &entry.terminator { + Some(LlTerminator::CondBr { cond, then_label, else_label }) => (cond, then_label, else_label), + _ => return Err(unsupported_qis("entry block must terminate with the single conditional branch")), + }; + if then_label == else_label { + return Err(unsupported_qis("conditional branch arms must target distinct blocks")); + } + + // Branch-condition dataflow: prove the branch is driven by the trailing mid-measurement, i.e. + // %mid = qis.m ... ; %c = icmp eq i32 %mid, 1 ; br i1 %c, ... + // The lowering treats the entry's last measure as the `qec.if` condition and the then-arm as the + // `mid==1` case, so we require exactly that: the branch uses an `icmp eq , 1`. + // (Without this, a constant condition like `icmp eq i32 0, 1` or a branch on an *earlier* + // measurement would pass the shape check and be silently mis-lowered.) + let icmp = entry + .icmp + .as_ref() + .ok_or_else(|| unsupported_qis("conditional branch condition must be produced by an `icmp` on the mid-measurement"))?; + if icmp.result != *cond { + return Err(unsupported_qis("conditional branch does not use the entry `icmp` result")); + } + if !icmp.pred_eq || icmp.rhs_const != Some(1) { + return Err(unsupported_qis("only `icmp eq , 1` branch conditions are supported")); + } + let measure_ref = icmp + .measure_ref + .as_deref() + .ok_or_else(|| unsupported_qis("branch condition must compare a measurement result, not a constant"))?; + let last_measure = entry + .measures + .last() + .map(String::as_str) + .ok_or_else(|| unsupported_qis("entry block has no named measurement to condition on"))?; + if measure_ref != last_measure { + return Err(unsupported_qis("conditional branch must condition on the trailing mid-measurement, not an earlier one")); + } + + let mut by_label: HashMap<&str, &LlBlockShape> = HashMap::new(); + for block in blocks { + if by_label.insert(block.label.as_str(), block).is_some() { + return Err(unsupported_qis(format!("duplicate basic-block label {}", block_name(&block.label)))); + } + } + + let then_block = by_label + .get(then_label.as_str()) + .copied() + .ok_or_else(|| unsupported_qis(format!("conditional branch target {then_label} is missing")))?; + let else_block = by_label + .get(else_label.as_str()) + .copied() + .ok_or_else(|| unsupported_qis(format!("conditional branch target {else_label} is missing")))?; + + let then_merge = single_diamond_branch_target(then_block)?; + let else_merge = single_diamond_branch_target(else_block)?; + if then_merge != else_merge { + return Err(unsupported_qis("then/else blocks must branch to the same merge block")); + } + if then_merge.is_empty() || then_merge == then_label || then_merge == else_label { + return Err(unsupported_qis("single-diamond merge target must be a distinct non-entry block")); + } + + let merge_block = by_label.get(then_merge).copied().ok_or_else(|| unsupported_qis(format!("merge block {then_merge} is missing")))?; + if !matches!(merge_block.terminator, Some(LlTerminator::Ret) | None) { + return Err(unsupported_qis("single-diamond merge block must not branch again")); + } + + for block in blocks { + if !block.label.is_empty() && block.label != *then_label && block.label != *else_label && block.label != then_merge { + return Err(unsupported_qis(format!("extra basic block {} outside the single-diamond shape", block_name(&block.label)))); + } + } + Ok(()) +} + +fn validate_single_diamond_qis_shape(src: &str) -> std::result::Result<(), PecosError> { + let blocks = collect_qmain_cfg(src)?; + validate_single_diamond_blocks(&blocks) +} + +fn classify_covered_qis_shape(src: &str) -> std::result::Result { + let blocks = collect_qmain_cfg(src)?; + let conditional_branches = blocks + .iter() + .filter(|b| matches!(&b.terminator, Some(LlTerminator::CondBr { .. }))) + .count(); + if conditional_branches == 0 { + validate_straight_line_blocks(&blocks)?; + Ok(QisLlShape::StraightLine) + } else { + validate_single_diamond_blocks(&blocks)?; + Ok(QisLlShape::SingleDiamond) + } +} pub fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { for p in ops { match *p { @@ -1432,6 +1720,8 @@ pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot /// Rejects (structured error, not silent-drop/panic) anything outside the subset: unrecognized /// `__quantum__` calls, malformed operand lists, and more than one conditional branch. pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { + validate_single_diamond_qis_shape(src)?; + // pass 1: collect ops per block label (entry = "") and the conditional-branch targets. let mut blocks: Vec<(String, Vec)> = Vec::new(); let mut cur_label = String::new(); @@ -1676,13 +1966,6 @@ pub fn run_branch_measure() { // ===================== public adapter: the opt-in QIS-LLVM-IR -> pliron call path ===================== -/// True if the program has a conditional branch (`br i1 ...`) -- i.e. the adaptive single-diamond -/// shape (qprog-style). Its absence means straight-line (bell-style). This is the dispatch over the -/// covered QIS-LLVM subset; richer CFGs are out of scope (see the strangler scope doc). -fn has_conditional_branch(src: &str) -> bool { - src.lines().any(|l| l.trim().starts_with("br i1")) -} - /// Lower a QIS-LLVM-IR program to the pliron `qec` dialect and return a boxed /// `ClassicalControlEngine` ready for `HybridEngineBuilder` -- the narrow, opt-in entry point for the /// pliron path. The incumbent `pecos-phir` stays the default; callers select this explicitly. @@ -1690,17 +1973,13 @@ fn has_conditional_branch(src: &str) -> bool { /// Scope: the covered QIS-LLVM subset -- Bell-style straight-line and the single-diamond adaptive /// shape (`h`/`x`/`cx`/`rz`/`rx`/`ry`/`zz`/`m`, one `icmp`+`br` lifted to `qec.if`, /// `result_record_output` export). The returned engine reports its own `num_qubits()` for sizing the -/// quantum backend. Returns a structured error if the lowered IR fails verification. -/// -/// Known limits (tracked in the scope doc, not yet closed): the parser still panics on malformed -/// input outside the covered subset (parse-path hardening to structured errors is a separate item), -/// and `num_qubits()` is currently fixed at 2 (correct for the covered fixtures). +/// quantum backend. Returns a structured error if the source is outside the covered subset or the +/// lowered IR fails verification. pub fn from_qis_llvm_ir_pliron(src: &str) -> std::result::Result, PecosError> { let ctx = &mut Context::new(); - let (module, bb, reg) = if has_conditional_branch(src) { - parse_qprog_ll(ctx, src)? - } else { - parse_bell_ll(ctx, src)? + let (module, bb, reg) = match classify_covered_qis_shape(src)? { + QisLlShape::SingleDiamond => parse_qprog_ll(ctx, src)?, + QisLlShape::StraightLine => parse_bell_ll(ctx, src)?, }; verify_op(&module, ctx) .map_err(|e| PecosError::Compilation(format!("pliron qec verification failed: {}", e.disp(ctx))))?; @@ -2176,4 +2455,257 @@ d: "expected the unsupported-control-flow rejection, got: {err}" ); } + + /// A CFG with only unconditional branches used to route through the straight-line parser and get + /// flattened. It must now be rejected by the adapter classifier before lowering. + #[test] + fn negative_unconditional_cfg_without_conditional_branch_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + br label %tail +tail: + %r0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("unconditional CFG must be rejected, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("straight-line subset"), + "expected the straight-line CFG rejection, got: {err}" + ); + } + + /// Both conditional branch labels must name real blocks; missing labels must not become empty + /// branch bodies via `unwrap_or_default`. + #[test] + fn negative_missing_conditional_branch_label_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %missing +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("missing branch target must error, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("target missing is missing"), + "expected the missing-label rejection, got: {err}" + ); + } + + /// The single-diamond subset requires both arms to merge to the same block; otherwise the qec.if + /// lift would silently pick one post block and misrepresent the CFG. + #[test] + fn negative_diamond_arms_with_different_merges_error() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final_a +skip_x: + br label %final_b +final_a: + ret i64 0 +final_b: + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("diamond with two merge blocks must error, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("same merge block"), + "expected the wrong-merge rejection, got: {err}" + ); + } + + /// The adaptive branch must be driven by the entry block's trailing mid-measurement, not by an + /// unrelated condition that the current qec.if lowering cannot model. + #[test] + fn negative_conditional_branch_without_mid_measure_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + %cond = icmp eq i32 0, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("conditional branch without mid-measurement must error, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("mid-measurement"), + "expected the mid-measurement-shape rejection, got: {err}" + ); + } + + /// Extra blocks are outside the one-diamond contract and must not be ignored while choosing the + /// first non-arm label as the merge block. + #[test] + fn negative_extra_block_outside_single_diamond_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + ret i64 0 +dead: + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("extra basic block must error, but adapter lowering succeeded"); + }; + assert!( + format!("{err}").contains("extra basic block dead"), + "expected the extra-block rejection, got: {err}" + ); + } + + // ---- branch-condition dataflow negatives (round-7: prove the branch uses the trailing mid-measure) ---- + + /// A constant branch condition (`icmp eq i32 0, 1`) passes the CFG-shape check but is NOT driven + /// by the measurement; it must be rejected, not mis-lowered as `qec.if(mid)`. + #[test] + fn negative_branch_condition_is_constant_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 0, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("a constant branch condition must error, but lowering succeeded"); + }; + assert!( + format!("{err}").contains("must compare a measurement result"), + "expected the constant-condition rejection, got: {err}" + ); + } + + /// Two measurements, branch on the *earlier* one: the lowering conditions on the trailing + /// measure, so this would mis-lower. Must be rejected. + #[test] + fn negative_branch_on_earlier_measurement_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %m0 = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %m1 = call i32 @__quantum__qis__m__body(i64 1, i64 3) + %cond = icmp eq i32 %m0, 1 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("branching on an earlier measurement must error, but lowering succeeded"); + }; + assert!( + format!("{err}").contains("trailing mid-measurement"), + "expected the wrong-measurement rejection, got: {err}" + ); + } + + /// The conditional branch uses a value other than the entry `icmp` result -- reject. + #[test] + fn negative_branch_not_using_icmp_result_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 1 + br i1 %arg, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("a branch not using the icmp result must error, but lowering succeeded"); + }; + assert!( + format!("{err}").contains("does not use the entry `icmp` result"), + "expected the branch-not-using-icmp rejection, got: {err}" + ); + } + + /// `icmp eq , 0` would swap the then/else sense (the lowering assumes mid==1 -> then-arm); + /// only `eq ..., 1` is supported. + #[test] + fn negative_branch_condition_compares_zero_errors() { + const BAD: &str = "\ +define i64 @qmain(i64 %arg) #0 { + %mid = call i32 @__quantum__qis__m__body(i64 0, i64 2) + %cond = icmp eq i32 %mid, 0 + br i1 %cond, label %apply_x, label %skip_x +apply_x: + call void @__quantum__qis__x__body(i64 1) + br label %final +skip_x: + br label %final +final: + %f1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + ret i64 0 +} +"; + let Err(err) = from_qis_llvm_ir_pliron(BAD) else { + panic!("an `icmp eq , 0` condition must error, but lowering succeeded"); + }; + assert!( + format!("{err}").contains("icmp eq , 1"), + "expected the only-eq-1-supported rejection, got: {err}" + ); + } } From 71078e5a210576e44c67cf8a82cdd3295d938763 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 18 Jun 2026 23:49:52 -0600 Subject: [PATCH 238/388] pecos-phir-pliron: widen gates (cz/swap/x) + shared gate_op_to_cmd helper (fix walker drift) --- crates/pecos-phir-pliron/fixtures/cz_swap.ll | 28 +++ crates/pecos-phir-pliron/src/lib.rs | 184 ++++++++++++++----- 2 files changed, 162 insertions(+), 50 deletions(-) create mode 100644 crates/pecos-phir-pliron/fixtures/cz_swap.ll diff --git a/crates/pecos-phir-pliron/fixtures/cz_swap.ll b/crates/pecos-phir-pliron/fixtures/cz_swap.ll new file mode 100644 index 000000000..166d0e969 --- /dev/null +++ b/crates/pecos-phir-pliron/fixtures/cz_swap.ll @@ -0,0 +1,28 @@ +; Straight-line program exercising the widened gate set (cz, swap, x) with a deterministic outcome. +; h q0; x q1; cz q0,q1; h q0; swap q1,q2; measure all +; Walkthrough: q0=|+>; q1=|1>; cz with control q1=|1> applies Z to q0 (|+> -> |->); h q0 maps |-> -> |1> +; (so q0 measures 1); swap q1,q2 moves the |1> from q1 to q2 (q1 -> 0, q2 -> 1). +; Deterministic: r0 == 1, r1 == 0, r2 == 1. +declare void @__quantum__qis__h__body(i64) +declare void @__quantum__qis__x__body(i64) +declare void @__quantum__qis__cz__body(i64, i64) +declare void @__quantum__qis__swap__body(i64, i64) +declare i32 @__quantum__qis__m__body(i64, i64) +declare void @__quantum__rt__result_record_output(i64, i8*) + +define i64 @qmain(i64 %arg) #0 { + call void @__quantum__qis__h__body(i64 0) + call void @__quantum__qis__x__body(i64 1) + call void @__quantum__qis__cz__body(i64 0, i64 1) + call void @__quantum__qis__h__body(i64 0) + call void @__quantum__qis__swap__body(i64 1, i64 2) + %r0 = call i32 @__quantum__qis__m__body(i64 0, i64 0) + %r1 = call i32 @__quantum__qis__m__body(i64 1, i64 1) + %r2 = call i32 @__quantum__qis__m__body(i64 2, i64 2) + call void @__quantum__rt__result_record_output(i64 0, i8* null) + call void @__quantum__rt__result_record_output(i64 1, i8* null) + call void @__quantum__rt__result_record_output(i64 2, i8* null) + ret i64 0 +} + +attributes #0 = { "EntryPoint" } diff --git a/crates/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs index fa8e40115..357a1ae63 100644 --- a/crates/pecos-phir-pliron/src/lib.rs +++ b/crates/pecos-phir-pliron/src/lib.rs @@ -263,6 +263,44 @@ impl Verify for CxOp { } } +#[pliron_op(name = "qec.cz", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] +pub struct CzOp; +impl CzOp { + pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![a, b], vec![], 0); + CzOp { op } + } +} +impl Verify for CzOp { + fn verify(&self, ctx: &Context) -> Result<()> { + for i in 0..2 { + if self.get_operation().deref(ctx).get_operand(i).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.cz operands must be qec.qubitref"); + } + } + Ok(()) + } +} + +#[pliron_op(name = "qec.swap", format, interfaces = [NOpdsInterface<2>, NResultsInterface<0>])] +pub struct SwapOp; +impl SwapOp { + pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { + let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![a, b], vec![], 0); + SwapOp { op } + } +} +impl Verify for SwapOp { + fn verify(&self, ctx: &Context) -> Result<()> { + for i in 0..2 { + if self.get_operation().deref(ctx).get_operand(i).get_type(ctx) != qubitref_ty(ctx) { + return verify_err!(self.loc(ctx), "qec.swap operands must be qec.qubitref"); + } + } + Ok(()) + } +} + /// `%result = qec.measure(qubitref)` -- a measurement. Its result `%result` IS the measurement-SSA /// value (the identity); all per-measurement metadata (qubit, basis, export label) lives in the /// `MeasurementRegistry` side-table keyed by that value, not on the op (ops stay lightweight). @@ -717,6 +755,10 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modul let q = *a.first().ok_or_else(|| unsupported_qis("h__body missing qubit operand"))?; qubits.insert(q); parsed.push(("h", a)); + } else if l.contains("__quantum__qis__x__body") { + let q = *a.first().ok_or_else(|| unsupported_qis("x__body missing qubit operand"))?; + qubits.insert(q); + parsed.push(("x", a)); } else if l.contains("__quantum__qis__cx__body") { if a.len() < 2 { return Err(unsupported_qis("cx__body needs two qubit operands")); @@ -724,6 +766,20 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modul qubits.insert(a[0]); qubits.insert(a[1]); parsed.push(("cx", a)); + } else if l.contains("__quantum__qis__cz__body") { + if a.len() < 2 { + return Err(unsupported_qis("cz__body needs two qubit operands")); + } + qubits.insert(a[0]); + qubits.insert(a[1]); + parsed.push(("cz", a)); + } else if l.contains("__quantum__qis__swap__body") { + if a.len() < 2 { + return Err(unsupported_qis("swap__body needs two qubit operands")); + } + qubits.insert(a[0]); + qubits.insert(a[1]); + parsed.push(("swap", a)); } else if l.contains("__quantum__qis__m__body") { if a.len() < 2 { return Err(unsupported_qis("m__body needs (qubit, result_id) operands")); @@ -765,9 +821,18 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modul "h" => { push!(HOp::new(ctx, slot_of[&a[0]])); } + "x" => { + push!(XOp::new(ctx, slot_of[&a[0]])); + } "cx" => { push!(CxOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); } + "cz" => { + push!(CzOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); + } + "swap" => { + push!(SwapOp::new(ctx, slot_of[&a[0]], slot_of[&a[1]])); + } "m" => { let m = push!(MeasureOp::new(ctx, slot_of[&a[0]])); let v = m.get_result(ctx); @@ -816,6 +881,8 @@ pub enum Cmd { Ry(usize, Angle64), Szz(usize, usize), Cx(usize, usize), + Cz(usize, usize), + Swap(usize, usize), Mz(usize, u64), // (qubit, QIS result-id); the id rides through to the export/register mapping } @@ -855,17 +922,6 @@ pub fn plan_from_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegi for op in ops { if let Some(s) = Operation::get_op::(op, ctx) { qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); - } else if let Some(p) = Operation::get_op::(op, ctx) { - let q = qubit_of[&p.get_operation().deref(ctx).get_operand(0)]; - if after_cond { batch2.push(Cmd::Pz(q)) } else { batch1.push(Cmd::Pz(q)) } - } else if let Some(h) = Operation::get_op::(op, ctx) { - let q = qubit_of[&h.get_operation().deref(ctx).get_operand(0)]; - if after_cond { batch2.push(Cmd::H(q)) } else { batch1.push(Cmd::H(q)) } - } else if let Some(cx) = Operation::get_op::(op, ctx) { - let opn = cx.get_operation(); - let c = qubit_of[&opn.deref(ctx).get_operand(0)]; - let t = qubit_of[&opn.deref(ctx).get_operand(1)]; - if after_cond { batch2.push(Cmd::Cx(c, t)) } else { batch1.push(Cmd::Cx(c, t)) } } else if let Some(m) = Operation::get_op::(op, ctx) { let mz = measure_to_mz(ctx, m, &qubit_of, reg); if after_cond { @@ -878,6 +934,8 @@ pub fn plan_from_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegi } else if let Some(c) = Operation::get_op::(op, ctx) { cond_target = qubit_of[&c.get_operation().deref(ctx).get_operand(1)]; after_cond = true; + } else if let Some(cmd) = gate_op_to_cmd(ctx, op, &qubit_of) { + if after_cond { batch2.push(cmd) } else { batch1.push(cmd) } } } AdaptivePlan { batch1, cond_outcome_idx, cond_target, batch2 } @@ -891,7 +949,7 @@ pub fn cmds_num_qubits(batches: &[&[Cmd]]) -> usize { for c in *cmds { let hi = match *c { Cmd::Pz(q) | Cmd::H(q) | Cmd::X(q) | Cmd::Rz(q, _) | Cmd::Rx(q, _) | Cmd::Ry(q, _) | Cmd::Mz(q, _) => q, - Cmd::Szz(a, b) | Cmd::Cx(a, b) => a.max(b), + Cmd::Szz(a, b) | Cmd::Cx(a, b) | Cmd::Cz(a, b) | Cmd::Swap(a, b) => a.max(b), }; n = n.max(hi + 1); } @@ -910,6 +968,8 @@ pub fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: Cmd::Ry(q, a) => { b.ry(a, &[q]); } Cmd::Szz(a, c0) => { b.szz(&[(a, c0)]); } Cmd::Cx(c0, t) => { b.cx(&[(c0, t)]); } + Cmd::Cz(a, c0) => { b.cz(&[(a, c0)]); } + Cmd::Swap(a, c0) => { b.swap(&[(a, c0)]); } Cmd::Mz(q, _rid) => { b.mz(&[q]); } // result-id is bookkeeping, not a simulator op } } @@ -1087,18 +1147,40 @@ pub fn run_milestone_4() { /// Lower the ops of a single block (a `qec.if` region body) to a Cmd list, reusing the slot map. /// Gate qubits come from `qubit_of`; measurement qubit + export label come from the registry. +/// Translate a single `qec` *gate* op to its runtime `Cmd`, or `None` if `op` is not a gate (a +/// `qec.slot`/`qec.if`/`qec.record`/`qec.qalloc`/`qec.end`/`qec.measure`). Shared by every plan +/// walker so none of them can silently drop a gate another handles (the round-7 drift bug). +/// Measurement is deliberately excluded -- it needs the registry + per-walker bookkeeping. +fn gate_op_to_cmd(ctx: &Context, op: Ptr, qubit_of: &HashMap) -> Option { + let q = |i: usize, o: Ptr| qubit_of[&o.deref(ctx).get_operand(i)]; + if let Some(p) = Operation::get_op::(op, ctx) { + Some(Cmd::Pz(q(0, p.get_operation()))) + } else if let Some(h) = Operation::get_op::(op, ctx) { + Some(Cmd::H(q(0, h.get_operation()))) + } else if let Some(x) = Operation::get_op::(op, ctx) { + Some(Cmd::X(q(0, x.get_operation()))) + } else if let Some(cx) = Operation::get_op::(op, ctx) { + Some(Cmd::Cx(q(0, cx.get_operation()), q(1, cx.get_operation()))) + } else if let Some(cz) = Operation::get_op::(op, ctx) { + Some(Cmd::Cz(q(0, cz.get_operation()), q(1, cz.get_operation()))) + } else if let Some(sw) = Operation::get_op::(op, ctx) { + Some(Cmd::Swap(q(0, sw.get_operation()), q(1, sw.get_operation()))) + } else if let Some(r) = Operation::get_op::(op, ctx) { + Some(Cmd::Rz(q(0, r.get_operation()), get_angle(ctx, r.get_operation()))) + } else if let Some(r) = Operation::get_op::(op, ctx) { + Some(Cmd::Rx(q(0, r.get_operation()), get_angle(ctx, r.get_operation()))) + } else if let Some(r) = Operation::get_op::(op, ctx) { + Some(Cmd::Ry(q(0, r.get_operation()), get_angle(ctx, r.get_operation()))) + } else { + Operation::get_op::(op, ctx).map(|z| Cmd::Szz(q(0, z.get_operation()), q(1, z.get_operation()))) + } +} + pub fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap, reg: &MeasurementRegistry) -> Vec { let mut cmds = Vec::new(); for op in block.deref(ctx).iter(ctx).collect::>() { - if let Some(x) = Operation::get_op::(op, ctx) { - cmds.push(Cmd::X(qubit_of[&x.get_operation().deref(ctx).get_operand(0)])); - } else if let Some(h) = Operation::get_op::(op, ctx) { - cmds.push(Cmd::H(qubit_of[&h.get_operation().deref(ctx).get_operand(0)])); - } else if let Some(p) = Operation::get_op::(op, ctx) { - cmds.push(Cmd::Pz(qubit_of[&p.get_operation().deref(ctx).get_operand(0)])); - } else if let Some(cx) = Operation::get_op::(op, ctx) { - let opn = cx.get_operation(); - cmds.push(Cmd::Cx(qubit_of[&opn.deref(ctx).get_operand(0)], qubit_of[&opn.deref(ctx).get_operand(1)])); + if let Some(cmd) = gate_op_to_cmd(ctx, op, qubit_of) { + cmds.push(cmd); } else if let Some(m) = Operation::get_op::(op, ctx) { cmds.push(measure_to_mz(ctx, m, qubit_of, reg)); } @@ -1127,34 +1209,6 @@ pub fn plan_from_if_ir(ctx: &Context, block: Ptr, reg: &MeasurementR for op in block.deref(ctx).iter(ctx).collect::>() { if let Some(s) = Operation::get_op::(op, ctx) { qubit_of.insert(s.get_result(ctx), s.index(ctx) as usize); - } else if let Some(p) = Operation::get_op::(op, ctx) { - let q = qubit_of[&p.get_operation().deref(ctx).get_operand(0)]; - if after_if { post.push(Cmd::Pz(q)) } else { batch1.push(Cmd::Pz(q)) } - } else if let Some(h) = Operation::get_op::(op, ctx) { - let q = qubit_of[&h.get_operation().deref(ctx).get_operand(0)]; - if after_if { post.push(Cmd::H(q)) } else { batch1.push(Cmd::H(q)) } - } else if let Some(cx) = Operation::get_op::(op, ctx) { - let opn = cx.get_operation(); - let c = qubit_of[&opn.deref(ctx).get_operand(0)]; - let t = qubit_of[&opn.deref(ctx).get_operand(1)]; - if after_if { post.push(Cmd::Cx(c, t)) } else { batch1.push(Cmd::Cx(c, t)) } - } else if let Some(r) = Operation::get_op::(op, ctx) { - let q = qubit_of[&r.get_operation().deref(ctx).get_operand(0)]; - let t = get_angle(ctx, r.get_operation()); - if after_if { post.push(Cmd::Rz(q, t)) } else { batch1.push(Cmd::Rz(q, t)) } - } else if let Some(r) = Operation::get_op::(op, ctx) { - let q = qubit_of[&r.get_operation().deref(ctx).get_operand(0)]; - let t = get_angle(ctx, r.get_operation()); - if after_if { post.push(Cmd::Rx(q, t)) } else { batch1.push(Cmd::Rx(q, t)) } - } else if let Some(r) = Operation::get_op::(op, ctx) { - let q = qubit_of[&r.get_operation().deref(ctx).get_operand(0)]; - let t = get_angle(ctx, r.get_operation()); - if after_if { post.push(Cmd::Ry(q, t)) } else { batch1.push(Cmd::Ry(q, t)) } - } else if let Some(z) = Operation::get_op::(op, ctx) { - let opn = z.get_operation(); - let a = qubit_of[&opn.deref(ctx).get_operand(0)]; - let bq = qubit_of[&opn.deref(ctx).get_operand(1)]; - if after_if { post.push(Cmd::Szz(a, bq)) } else { batch1.push(Cmd::Szz(a, bq)) } } else if let Some(m) = Operation::get_op::(op, ctx) { let mz = measure_to_mz(ctx, m, &qubit_of, reg); if after_if { @@ -1168,6 +1222,8 @@ pub fn plan_from_if_ir(ctx: &Context, block: Ptr, reg: &MeasurementR then_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 0), &qubit_of, reg); else_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 1), &qubit_of, reg); after_if = true; + } else if let Some(cmd) = gate_op_to_cmd(ctx, op, &qubit_of) { + if after_if { post.push(cmd) } else { batch1.push(cmd) } } else if let Some(rec) = Operation::get_op::(op, ctx) { // export order = textual order of qec.record; resolve the recorded measurement-SSA value // to its export label via the registry (the value is the identity). @@ -1370,6 +1426,8 @@ pub enum ParsedOp { Rx(usize, f64), Ry(usize, f64), Szz(usize, usize), + Cz(usize, usize), + Swap(usize, usize), X(usize), M(usize, u64), // (qubit, QIS result-id = 2nd i64 of m__body) Record(u64), // result_record_output(result-id): export this measurement-SSA, in this order @@ -1682,7 +1740,7 @@ pub fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { for p in ops { match *p { ParsedOp::H(q) | ParsedOp::Rz(q, _) | ParsedOp::Rx(q, _) | ParsedOp::Ry(q, _) | ParsedOp::X(q) | ParsedOp::M(q, _) => { set.insert(q); } - ParsedOp::Szz(a, b) => { set.insert(a); set.insert(b); } + ParsedOp::Szz(a, b) | ParsedOp::Cz(a, b) | ParsedOp::Swap(a, b) => { set.insert(a); set.insert(b); } ParsedOp::Record(_) => {} } } @@ -1698,6 +1756,8 @@ pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot ParsedOp::Rx(q, t) => { RxOp::new(ctx, slot_of[&q], Angle64::from_radians(t)).get_operation().insert_at_back(block, ctx); } ParsedOp::Ry(q, t) => { RyOp::new(ctx, slot_of[&q], Angle64::from_radians(t)).get_operation().insert_at_back(block, ctx); } ParsedOp::Szz(a, b) => { SzzOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } + ParsedOp::Cz(a, b) => { CzOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } + ParsedOp::Swap(a, b) => { SwapOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } ParsedOp::X(q) => { XOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } ParsedOp::M(q, rid) => { let m = MeasureOp::new(ctx, slot_of[&q]); @@ -1751,6 +1811,10 @@ pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modu cur_ops.push(ParsedOp::Ry(iq(0)?, da(0)?)); } else if l.contains("__quantum__qis__zz__body") { cur_ops.push(ParsedOp::Szz(iq(0)?, iq(1)?)); + } else if l.contains("__quantum__qis__cz__body") { + cur_ops.push(ParsedOp::Cz(iq(0)?, iq(1)?)); + } else if l.contains("__quantum__qis__swap__body") { + cur_ops.push(ParsedOp::Swap(iq(0)?, iq(1)?)); } else if l.contains("__quantum__qis__x__body") { cur_ops.push(ParsedOp::X(iq(0)?)); } else if l.contains("__quantum__qis__m__body") { @@ -2132,6 +2196,26 @@ mod tests { } /// Dynamic qubit count: a 3-qubit GHZ through the adapter must report `num_qubits()==3` (not the /// old hard-coded 2) and produce a perfectly correlated triple (r0==r1==r2). + /// Widened gate set: `cz` + `swap` (+ `x`) lower and run through the adapter with a deterministic + /// result (`r0=1, r1=0, r2=1`). + #[test] + fn adapter_cz_swap_gates() { + use pecos_engines::hybrid::HybridEngineBuilder; + let eng = from_qis_llvm_ir_pliron(include_str!("../fixtures/cz_swap.ll")).expect("adapter lowers cz_swap.ll"); + let n = eng.num_qubits(); + assert_eq!(n, 3, "cz_swap uses qubits 0,1,2"); + let mut hybrid = HybridEngineBuilder::new() + .with_classical_engine(eng) + .with_quantum_engine(Box::new(StateVecEngine::with_seed(n, 5))) + .build(); + for _ in 0..50 { + let shot = hybrid.run_shot().unwrap(); + assert_eq!(shot.data.get("r0").and_then(Data::as_u32), Some(1), "cz+h must drive q0 to 1"); + assert_eq!(shot.data.get("r1").and_then(Data::as_u32), Some(0), "swap moves the 1 off q1"); + assert_eq!(shot.data.get("r2").and_then(Data::as_u32), Some(1), "swap moves the 1 onto q2"); + Engine::reset(&mut hybrid).unwrap(); + } + } #[test] fn adapter_ghz3_dynamic_qubit_count() { use pecos_engines::hybrid::HybridEngineBuilder; From e9a7d7a3e3c5efb8e3bc568873b80ce1e66c5a6b Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 22 Jun 2026 17:54:22 -0600 Subject: [PATCH 239/388] Add balanced surface ancilla scheduling --- .../quantum-pecos/src/pecos/guppy/surface.py | 20 ++- .../pecos/qec/surface/_ancilla_batching.py | 152 ++++++++++++++++-- .../src/pecos/qec/surface/_check_plan.py | 114 ++++++++++++- .../src/pecos/qec/surface/circuit_builder.py | 33 +++- .../qec/surface/test_ancilla_batching.py | 64 ++++++++ .../tests/qec/surface/test_check_plan.py | 64 +++++++- 6 files changed, 426 insertions(+), 21 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 2cd2f4e9a..ff0e2ab90 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -205,7 +205,10 @@ def generate_guppy_source( ValueError: If exactly one of ``twirl`` / ``rng`` is supplied. """ from pecos.qec.surface._ancilla_batching import batched_stabilizers, normalize_ancilla_budget - from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer + from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + ) from pecos.qec.surface.circuit_builder import ( _resolve_szz_clifford_frame_for_builder, _szz_memory_physical_axis_for_data, @@ -220,6 +223,7 @@ def generate_guppy_source( resolved_plan, context="Guppy surface-code source generation", ) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) interaction_basis = resolved_plan.interaction_basis resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( patch, @@ -614,9 +618,13 @@ def _append_szz_layer( idx += 1 else: # Constrained: stabilizer-batched. The batch sequence is the - # shared `batched_stabilizers(patch, effective_budget)` so the + # shared `batched_stabilizers(patch, effective_budget, schedule=...)` so the # abstract circuit's measurement order matches by construction. - batches = batched_stabilizers(patch, effective_budget) + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) lines.append( f' """Extract full syndrome in {len(batches)} ancilla-reuse batches (budget={effective_budget})."""', ) @@ -804,7 +812,11 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: lines.append(f" sz{stab.index} = measure(az{stab.index})") lines.append(f' result("sz{stab.index}:init:meas:{idx}", sz{stab.index})') else: - batches = batched_stabilizers(patch, effective_budget) + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) idx = 0 for batch_idx, batch in enumerate(batches): init_batch = [(t, i) for t, i in batch if t == stab_type] diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index 256b8a565..71314b8fa 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -23,12 +23,41 @@ from __future__ import annotations +from math import ceil from typing import TYPE_CHECKING if TYPE_CHECKING: + from collections.abc import Iterable + from pecos.qec.surface.geometry import SurfacePatch +DEFAULT_ANCILLA_SCHEDULE = "default" +BALANCED_DATA_ANCILLA_SCHEDULE = "balanced-data-v1" +SUPPORTED_ANCILLA_SCHEDULES = frozenset( + {DEFAULT_ANCILLA_SCHEDULE, BALANCED_DATA_ANCILLA_SCHEDULE}, +) + + +def normalize_ancilla_schedule(ancilla_schedule: str | None = None) -> str: + """Return the canonical named ancilla-reuse schedule. + + ``None`` is the historical default batching policy. Non-default policies + must be explicit in source-level check-plan metadata so DEM caches and + traced programs cannot accidentally collide with the legacy schedule. + """ + if ancilla_schedule is None: + return DEFAULT_ANCILLA_SCHEDULE + normalized = str(ancilla_schedule).lower().replace("_", "-") + if normalized not in SUPPORTED_ANCILLA_SCHEDULES: + msg = ( + f"ancilla_schedule must be one of {sorted(SUPPORTED_ANCILLA_SCHEDULES)}, " + f"got {ancilla_schedule!r}" + ) + raise ValueError(msg) + return normalized + + def normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> int: """Clamp an ancilla budget to the valid range for a patch. @@ -59,6 +88,8 @@ def normalize_ancilla_budget(total_ancilla: int, ancilla_budget: int | None) -> def batched_stabilizers( patch: SurfacePatch, ancilla_budget: int, + *, + ancilla_schedule: str | None = None, ) -> list[list[tuple[str, int]]]: """Partition stabilizers into ancilla-reuse batches. @@ -68,13 +99,21 @@ def batched_stabilizers( ``ancilla_budget`` stabilizers each; within each batch every stabilizer is measured concurrently using one ancilla qubit. - The stabilizer order is **load-bearing** production semantics shared by - the abstract circuit and the Guppy emitter: ascending stabilizer index, - X before Z on ties. Note the traced-vs-traced Selene parity tests cannot - catch a regression here -- both sides import this one helper, so a policy - change moves them together. The concrete batch-order and source-level - CX-emission pins (``tests/qec/surface/test_ancilla_batching.py``) are what - actually guard this order; preserve it. + The default stabilizer order is **load-bearing** production semantics + shared by the abstract circuit and the Guppy emitter: ascending stabilizer + index, X before Z on ties. Note the traced-vs-traced Selene parity tests + cannot catch a regression here -- both sides import this one helper, so a + policy change moves them together. The concrete batch-order and + source-level CX-emission pins + (``tests/qec/surface/test_ancilla_batching.py``) are what actually guard + this order; preserve it. + + ``ancilla_schedule="balanced-data-v1"`` is an explicit non-default policy + for constrained-ancilla programs. It greedily spreads stabilizer supports + across each batch so data qubits see check interactions more uniformly + through the batched sequence. This does not change result tags or detector + semantics; callers must still record the chosen schedule in check-plan + metadata so cached DEMs do not collide with the default schedule. ``ancilla_budget`` is validated through :func:`normalize_ancilla_budget` (rejects ``None``, ``bool``, @@ -83,12 +122,107 @@ def batched_stabilizers( public ``ancilla_budget`` API surface, not an opaque ``range()`` or silent-empty failure. """ + schedule = normalize_ancilla_schedule(ancilla_schedule) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) + stabilizers = _canonical_stabilizer_order(patch) + if schedule == BALANCED_DATA_ANCILLA_SCHEDULE: + return _balanced_data_batches(patch, stabilizers, effective_budget) + + return [stabilizers[start : start + effective_budget] for start in range(0, len(stabilizers), effective_budget)] + + +def _canonical_stabilizer_order(patch: SurfacePatch) -> list[tuple[str, int]]: + geom = patch.geometry stabilizers = [("X", stab.index) for stab in geom.x_stabilizers] stabilizers.extend(("Z", stab.index) for stab in geom.z_stabilizers) - stabilizers.sort(key=lambda stab: (stab[1], 0 if stab[0] == "X" else 1)) + stabilizers.sort(key=_canonical_stabilizer_sort_key) + return stabilizers - return [stabilizers[start : start + effective_budget] for start in range(0, len(stabilizers), effective_budget)] + +def _canonical_stabilizer_sort_key(stabilizer: tuple[str, int]) -> tuple[int, int]: + stab_type, stab_idx = stabilizer + return (stab_idx, 0 if stab_type == "X" else 1) + + +def _balanced_data_batches( + patch: SurfacePatch, + stabilizers: list[tuple[str, int]], + effective_budget: int, +) -> list[list[tuple[str, int]]]: + """Greedily spread data-qubit supports inside each constrained batch.""" + if effective_budget >= len(stabilizers): + return [stabilizers] + + by_stabilizer = _stabilizer_support_lookup(patch) + canonical_order = {stabilizer: index for index, stabilizer in enumerate(stabilizers)} + remaining = set(stabilizers) + batches: list[list[tuple[str, int]]] = [] + for batch_index, target_size in enumerate(_balanced_batch_sizes(len(stabilizers), effective_budget)): + batch: list[tuple[str, int]] = [] + touched_data: set[int] = set() + x_count = 0 + z_count = 0 + + while remaining and len(batch) < target_size: + score_state = (frozenset(touched_data), x_count, z_count, batch_index) + + def score( + stabilizer: tuple[str, int], + *, + state: tuple[frozenset[int], int, int, int] = score_state, + ) -> tuple[int, int, float, float, int]: + bound_touched_data, bound_x_count, bound_z_count, bound_batch_index = state + support, row, col = by_stabilizer[stabilizer] + overlap = sum(data_qubit in bound_touched_data for data_qubit in support) + next_x = bound_x_count + (1 if stabilizer[0] == "X" else 0) + next_z = bound_z_count + (1 if stabilizer[0] == "Z" else 0) + type_imbalance = abs(next_x - next_z) + # Alternate the spatial sweep direction between batches so the + # deterministic tie-break does not repeatedly privilege the + # same edge of the patch. + row_key = row if bound_batch_index % 2 == 0 else -row + col_key = col if bound_batch_index % 2 == 0 else -col + return (overlap, type_imbalance, row_key, col_key, canonical_order[stabilizer]) + + selected = min(remaining, key=score) + remaining.remove(selected) + batch.append(selected) + support, _row, _col = by_stabilizer[selected] + touched_data.update(support) + if selected[0] == "X": + x_count += 1 + else: + z_count += 1 + + batches.append(batch) + + return batches + + +def _balanced_batch_sizes(total: int, effective_budget: int) -> list[int]: + """Return near-equal batch sizes, each at most ``effective_budget``.""" + num_batches = ceil(total / effective_budget) + base_size, remainder = divmod(total, num_batches) + return [base_size + (1 if index < remainder else 0) for index in range(num_batches)] + + +def _stabilizer_support_lookup( + patch: SurfacePatch, +) -> dict[tuple[str, int], tuple[tuple[int, ...], float, float]]: + geom = patch.geometry + lookup: dict[tuple[str, int], tuple[tuple[int, ...], float, float]] = {} + + def add(stabilizers: Iterable[object], stab_type: str) -> None: + for stabilizer in stabilizers: + support = tuple(int(q) for q in stabilizer.data_qubits) + positions = [geom.id_to_pos[q] for q in support] + row = sum(pos[0] for pos in positions) / len(positions) + col = sum(pos[1] for pos in positions) / len(positions) + lookup[(stab_type, int(stabilizer.index))] = (support, row, col) + + add(geom.x_stabilizers, "X") + add(geom.z_stabilizers, "Z") + return lookup diff --git a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py index 3876ab7d1..7cd4b355a 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_check_plan.py @@ -10,6 +10,12 @@ from dataclasses import dataclass from typing import Any +from pecos.qec.surface._ancilla_batching import ( + BALANCED_DATA_ANCILLA_SCHEDULE, + DEFAULT_ANCILLA_SCHEDULE, + normalize_ancilla_schedule, +) + CHECK_PLAN_METADATA_FORMAT = "pecos.surface.check_plan" CHECK_PLAN_METADATA_VERSION = 1 CHECK_PLAN_HASH_ALGORITHM = "sha256" @@ -63,6 +69,31 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "none", }, + "cx_balanced_data_v1": { + "plan_id": "cx_balanced_data_v1", + "interaction_basis": "cx", + "synthesis_identity": { + "family": "cx", + "szz_phase_pattern": "none", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_cx_x_check_v1", + "measurement_sign_policy": "none", + }, + "z_check": { + "template": "current_cx_z_check_v1", + "measurement_sign_policy": "none", + }, + "prefix_policy": "none", + }, "szz_current_v1": { "plan_id": "szz_current_v1", "interaction_basis": "szz", @@ -119,6 +150,64 @@ def _normalize_check_plan_id(check_plan: str) -> str: }, "prefix_policy": "forward_flow_virtual_z_v1", }, + "szz_balanced_data_v1": { + "plan_id": "szz_balanced_data_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "standard", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "default_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, + "szz_boundary_first_balanced_data_v1": { + "plan_id": "szz_boundary_first_balanced_data_v1", + "interaction_basis": "szz", + "synthesis_identity": { + "family": "szz", + "szz_phase_pattern": "boundary-first", + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + }, + "schedule": { + "round_policy": "constant", + "site_policy": "global", + "edge_order": "current_surface_cnot_schedule_v1", + "ancilla_batch_policy": "balanced-data-v1", + }, + "x_check": { + "template": "current_szz_x_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "z_check": { + "template": "current_szz_z_check_v1", + "sign_policy": "boundary_first_szz_sign_vector_v1", + "residual_policy": "per_touch_compensated", + "measurement_sign_policy": "explicit_template_metadata", + }, + "prefix_policy": "forward_flow_virtual_z_v1", + }, } @@ -200,6 +289,18 @@ def resolve_surface_check_plan( ) +def ancilla_schedule_for_check_plan(resolved_plan: ResolvedSurfaceCheckPlan) -> str: + """Return the concrete ancilla-reuse schedule encoded by a check plan.""" + return normalize_ancilla_schedule( + str( + resolved_plan.synthesis_identity.get( + "ancilla_schedule", + DEFAULT_ANCILLA_SCHEDULE, + ), + ), + ) + + def require_current_surface_check_plan_renderer( resolved_plan: ResolvedSurfaceCheckPlan, *, @@ -215,20 +316,21 @@ def require_current_surface_check_plan_renderer( semantic = resolved_plan.semantic_content synthesis = resolved_plan.synthesis_identity schedule = semantic.get("schedule", {}) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) if resolved_plan.interaction_basis == "cx": expected_synthesis = { "family": "cx", "szz_phase_pattern": "none", "interaction_order": "pecos-default", - "ancilla_schedule": "default", + "ancilla_schedule": ancilla_schedule, } else: expected_synthesis = { "family": "szz", "szz_phase_pattern": synthesis.get("szz_phase_pattern"), "interaction_order": "pecos-default", - "ancilla_schedule": "default", + "ancilla_schedule": ancilla_schedule, } if synthesis.get("szz_phase_pattern") not in {"standard", "boundary-first"}: msg = ( @@ -236,11 +338,19 @@ def require_current_surface_check_plan_renderer( f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; synthesis_identity={synthesis!r}" ) raise NotImplementedError(msg) + if ancilla_schedule not in {DEFAULT_ANCILLA_SCHEDULE, BALANCED_DATA_ANCILLA_SCHEDULE}: + msg = ( + f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " + f"with {CURRENT_SURFACE_CHECK_PLAN_RENDERER}; ancilla_schedule={ancilla_schedule!r}" + ) + raise NotImplementedError(msg) expected_schedule = { "round_policy": "constant", "site_policy": "global", "edge_order": "current_surface_cnot_schedule_v1", } + if ancilla_schedule != DEFAULT_ANCILLA_SCHEDULE: + expected_schedule["ancilla_batch_policy"] = ancilla_schedule if synthesis != expected_synthesis or schedule != expected_schedule: msg = ( f"{context} cannot realize check_plan={resolved_plan.plan_id!r} " diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index 36c7612f7..b3fbe5c5c 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -35,7 +35,11 @@ from pecos.qec.surface._ancilla_batching import ( normalize_ancilla_budget as _normalize_ancilla_budget, ) -from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan +from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + resolve_surface_check_plan, +) from pecos.qec.surface._clifford_deformation import ( resolve_surface_clifford_frame, ) @@ -930,6 +934,7 @@ def build_surface_code_circuit( resolved_plan, context="abstract surface-code circuit generation", ) + ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) interaction_basis = _normalize_interaction_basis(resolved_plan.interaction_basis) resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( patch, @@ -955,7 +960,11 @@ def build_surface_code_circuit( ancilla_pool = list(range(num_data, num_data + effective_ancilla_budget)) x_ancilla_qubits = [-1] * num_x_anc z_ancilla_qubits = [-1] * num_z_anc - for batch in _batched_stabilizers(patch, effective_ancilla_budget): + for batch in _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ): for pool_idx, (stab_type, stab_idx) in enumerate(batch): if stab_type == "X": x_ancilla_qubits[stab_idx] = ancilla_pool[pool_idx] @@ -1036,6 +1045,7 @@ def emit_gate_local_twirl_layer( allocation, cnot_rounds, _szz_residual_plan_for_check_plan(patch, resolved_plan), + ancilla_schedule, resolved_clifford_frame, ), allocation, @@ -1121,7 +1131,11 @@ def emit_gate_local_twirl_layer( ops.append(SurfaceCircuitStep(OpType.TICK)) else: - stabilizer_batches = _batched_stabilizers(patch, effective_ancilla_budget) + stabilizer_batches = _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ) for batch in stabilizer_batches: init_batch = [(stab_type, stab_idx) for stab_type, stab_idx in batch if stab_type == init_stabilizer_type] if not init_batch: @@ -1226,7 +1240,11 @@ def emit_gate_local_twirl_layer( ops.append(SurfaceCircuitStep(OpType.TICK)) else: - stabilizer_batches = _batched_stabilizers(patch, effective_ancilla_budget) + stabilizer_batches = _batched_stabilizers( + patch, + effective_ancilla_budget, + ancilla_schedule=ancilla_schedule, + ) for batch in stabilizer_batches: ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Prepare ancillas")) batch_ancillas = { @@ -1316,6 +1334,7 @@ def _build_surface_code_circuit_szz( allocation: QubitAllocation, cnot_rounds: list[list[tuple[str, int, int]]], residual_plan: SzzResidualPlan, + ancilla_schedule: str, resolved_clifford_frame: ResolvedSurfaceCliffordFrame | None = None, ) -> list[SurfaceCircuitStep]: """Build the abstract SZZ/SZZdg surface-memory template.""" @@ -1414,7 +1433,11 @@ def stabilizer_batches_for(selected_type: str | None = None) -> list[list[tuple[ batch.extend(("Z", s.index) for s in geom.z_stabilizers) batches = [batch] else: - batches = _batched_stabilizers(patch, allocation_ancilla_count) + batches = _batched_stabilizers( + patch, + allocation_ancilla_count, + ancilla_schedule=ancilla_schedule, + ) if selected_type is None: return batches diff --git a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py index f6bdc67e2..aa69079cc 100644 --- a/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py +++ b/python/quantum-pecos/tests/qec/surface/test_ancilla_batching.py @@ -19,8 +19,10 @@ import pytest from pecos.qec.surface import SurfacePatch from pecos.qec.surface._ancilla_batching import ( + BALANCED_DATA_ANCILLA_SCHEDULE, batched_stabilizers, normalize_ancilla_budget, + normalize_ancilla_schedule, ) # --- normalize_ancilla_budget ----------------------------------------------- @@ -62,6 +64,21 @@ def test_normalize_ancilla_budget_rejects_non_int() -> None: normalize_ancilla_budget(8, "1") +# --- normalize_ancilla_schedule --------------------------------------------- + + +def test_normalize_ancilla_schedule_accepts_named_policies() -> None: + assert normalize_ancilla_schedule(None) == "default" + assert normalize_ancilla_schedule("default") == "default" + assert normalize_ancilla_schedule("balanced_data_v1") == BALANCED_DATA_ANCILLA_SCHEDULE + assert normalize_ancilla_schedule("balanced-data-v1") == BALANCED_DATA_ANCILLA_SCHEDULE + + +def test_normalize_ancilla_schedule_rejects_unknown_policy() -> None: + with pytest.raises(ValueError, match=r"ancilla_schedule must be one of"): + normalize_ancilla_schedule("row-scan") + + # --- batched_stabilizers (concrete sequences) ------------------------------- @@ -159,6 +176,53 @@ def test_batched_stabilizers_clamps_oversized_budget() -> None: assert len(huge[0]) == total +def test_balanced_data_schedule_is_explicit_and_deterministic() -> None: + """The balanced schedule is a named non-default policy, not a change to + the legacy batching semantics.""" + patch = SurfacePatch.create(distance=3) + + assert batched_stabilizers(patch, 2) == [ + [("X", 0), ("Z", 0)], + [("X", 1), ("Z", 1)], + [("X", 2), ("Z", 2)], + [("X", 3), ("Z", 3)], + ] + assert batched_stabilizers( + patch, + 2, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) == [ + [("X", 0), ("Z", 3)], + [("X", 3), ("Z", 0)], + [("Z", 1), ("Z", 2)], + [("X", 2), ("X", 1)], + ] + + +def test_balanced_data_schedule_spreads_d9_a17_batches() -> None: + """The d=9/a17 target gets equal-size batches and no data qubit whose + four adjacent checks all live in one batch.""" + patch = SurfacePatch.create(distance=9) + batches = batched_stabilizers( + patch, + 17, + ancilla_schedule=BALANCED_DATA_ANCILLA_SCHEDULE, + ) + + assert [len(batch) for batch in batches] == [16, 16, 16, 16, 16] + + batch_of = {stabilizer: batch_idx for batch_idx, batch in enumerate(batches) for stabilizer in batch} + touches_by_data: dict[int, set[int]] = {} + for stab in patch.geometry.x_stabilizers: + for data_qubit in stab.data_qubits: + touches_by_data.setdefault(data_qubit, set()).add(batch_of[("X", stab.index)]) + for stab in patch.geometry.z_stabilizers: + for data_qubit in stab.data_qubits: + touches_by_data.setdefault(data_qubit, set()).add(batch_of[("Z", stab.index)]) + + assert min(len(batch_indices) for batch_indices in touches_by_data.values()) > 1 + + # --- D1: pin emitted CX sequences for the constrained Guppy codegen -------- # The byte-identical traced-vs-traced DEM oracle and the lowered-qubit-stream # invariant catch many constrained-codegen errors, but not a wrong-CX-order / diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 8b6acba73..543950b00 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -21,7 +21,14 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: plan = resolve_surface_check_plan() - assert surface_check_plan_ids() == ("cx_standard_v1", "szz_boundary_first_v1", "szz_current_v1") + assert surface_check_plan_ids() == ( + "cx_balanced_data_v1", + "cx_standard_v1", + "szz_balanced_data_v1", + "szz_boundary_first_balanced_data_v1", + "szz_boundary_first_v1", + "szz_current_v1", + ) assert default_surface_check_plan_id() == "cx_standard_v1" assert default_surface_check_plan_id("SZZ") == "szz_current_v1" assert plan.plan_id == "cx_standard_v1" @@ -73,6 +80,39 @@ def test_boundary_first_szz_check_plan_resolves_to_concrete_synthesis() -> None: assert plan.semantic_content["z_check"]["sign_policy"] == "boundary_first_szz_sign_vector_v1" +@pytest.mark.parametrize( + ("plan_id", "interaction_basis", "phase_pattern"), + [ + ("cx_balanced_data_v1", "cx", "none"), + ("szz_balanced_data_v1", "szz", "standard"), + ("szz_boundary_first_balanced_data_v1", "szz", "boundary-first"), + ], +) +def test_balanced_data_check_plans_resolve_to_explicit_schedule( + plan_id: str, + interaction_basis: str, + phase_pattern: str, +) -> None: + from pecos.qec.surface._check_plan import ( + ancilla_schedule_for_check_plan, + require_current_surface_check_plan_renderer, + resolve_surface_check_plan, + ) + + plan = resolve_surface_check_plan(check_plan=plan_id) + + assert plan.interaction_basis == interaction_basis + assert plan.synthesis_identity == { + "family": interaction_basis, + "szz_phase_pattern": phase_pattern, + "interaction_order": "pecos-default", + "ancilla_schedule": "balanced-data-v1", + } + assert plan.semantic_content["schedule"]["ancilla_batch_policy"] == "balanced-data-v1" + assert ancilla_schedule_for_check_plan(plan) == "balanced-data-v1" + require_current_surface_check_plan_renderer(plan, context="unit-test") + + def test_current_renderer_rejects_unimplemented_plan_semantics() -> None: from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan @@ -142,6 +182,28 @@ def test_guppy_surface_code_accepts_check_plan_as_source_of_truth() -> None: assert program is not None +@pytest.mark.parametrize( + "check_plan", + [ + "cx_balanced_data_v1", + "szz_balanced_data_v1", + "szz_boundary_first_balanced_data_v1", + ], +) +def test_guppy_surface_code_accepts_balanced_data_check_plans(check_plan: str) -> None: + from pecos.guppy import make_surface_code + + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + ancilla_budget=2, + check_plan=check_plan, + ) + + assert program is not None + + def test_surface_code_memory_records_resolved_check_plan() -> None: from pecos.qec.surface import surface_code_memory From 099c6d338157ad984daa3a9bc04251463bd5f562 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 22 Jun 2026 19:52:32 -0600 Subject: [PATCH 240/388] Forward-flow SZZ Guppy data frames within helpers --- .../quantum-pecos/src/pecos/guppy/surface.py | 192 +++++++++++++++++- .../tests/guppy/test_surface_twirl_render.py | 14 +- 2 files changed, 194 insertions(+), 12 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index ff0e2ab90..f29111049 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -148,7 +148,9 @@ def generate_guppy_source( Uses a 4-round parallel schedule for syndrome extraction. The default ``interaction_basis="cx"`` emits the CNOT template; ``"szz"`` emits the - signed SZZ/SZZdg template with immediate data compensation. + signed SZZ/SZZdg template. SZZ helpers forward-flow single-qubit data + frames within each helper and then explicitly flush the frame before + returning, preserving the reusable Guppy result-tag structure. ``ancilla_budget=None`` (default) emits the unconstrained shape: one ancilla per stabilizer, all measured in parallel at the end of @@ -210,7 +212,11 @@ def generate_guppy_source( require_current_surface_check_plan_renderer, ) from pecos.qec.surface.circuit_builder import ( + _SZZ_FLOW_IDENTITY, + OpType, _resolve_szz_clifford_frame_for_builder, + _szz_flow_compose_pending_gate, + _szz_flow_is_virtual_z, _szz_memory_physical_axis_for_data, _szz_residual_plan_for_check_plan, ) @@ -419,6 +425,111 @@ def _append_szz_touch_compensation(target: list[str], indent: str, axis: str, si msg = f"unsupported Pauli axis {axis!r}" raise ValueError(msg) + def _szz_axis_rotation_to_z_gates(axis: str) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.H,) + if axis == "Y": + return (OpType.SXDG,) + if axis == "Z": + return () + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _szz_axis_rotation_from_z_gates(axis: str) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.H,) + if axis == "Y": + return (OpType.SX,) + if axis == "Z": + return () + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: + if axis == "X": + return (OpType.SXDG if sign > 0 else OpType.SX,) + if axis == "Y": + return ( + OpType.SZDG, + OpType.SXDG if sign > 0 else OpType.SX, + OpType.SZ, + ) + if axis == "Z": + return (OpType.SZDG if sign > 0 else OpType.SZ,) + msg = f"unsupported Pauli axis {axis!r}" + raise ValueError(msg) + + def _append_szz_flow_gate(target: list[str], indent: str, op_type: OpType, qubit_expr: str) -> None: + op_name = { + OpType.H: "h", + OpType.SX: "v", + OpType.SXDG: "vdg", + OpType.SZ: "s", + OpType.SZDG: "sdg", + OpType.X: "x", + OpType.Z: "z", + }.get(op_type) + if op_name is None: + msg = f"unsupported Guppy SZZ forward-flow gate {op_type.name}" + raise ValueError(msg) + target.append(f"{indent}{op_name}({qubit_expr})") + + _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} + _szz_guppy_prefix_generators = ( + OpType.H, + OpType.SX, + OpType.SXDG, + OpType.SZ, + OpType.SZDG, + OpType.X, + OpType.Z, + ) + + def _szz_guppy_prefix_gates_for_pending(pending: tuple[int, int]) -> tuple[OpType, ...]: + """Return an exact Guppy-supported 1q Clifford sequence for ``pending``.""" + cached = _szz_guppy_prefix_cache.get(pending) + if cached is not None: + return cached + + queue: list[tuple[tuple[int, int], tuple[OpType, ...]]] = [(_SZZ_FLOW_IDENTITY, ())] + seen = {_SZZ_FLOW_IDENTITY} + while queue: + current, gates = queue.pop(0) + for gate in _szz_guppy_prefix_generators: + next_pending = _szz_flow_compose_pending_gate(current, gate) + if next_pending in seen: + continue + next_gates = (*gates, gate) + _szz_guppy_prefix_cache[next_pending] = next_gates + if next_pending == pending: + return next_gates + seen.add(next_pending) + queue.append((next_pending, next_gates)) + + msg = f"cannot synthesize Guppy SZZ forward-flow Clifford prefix for {pending!r}" + raise ValueError(msg) + + def _append_szz_flush_data_frame( + target: list[str], + indent: str, + pending_by_data: dict[int, tuple[int, int]], + *, + reason: str, + ) -> None: + """Materialize pending data Cliffords before returning a frame-free helper.""" + emitted_comment = False + for data_q, pending in sorted(pending_by_data.items()): + if pending == _SZZ_FLOW_IDENTITY: + continue + prefix = _szz_guppy_prefix_gates_for_pending(pending) + if prefix and not emitted_comment: + target.append("") + target.append(f"{indent}# Flush SZZ data frame before {reason}") + emitted_comment = True + for gate in prefix: + _append_szz_flow_gate(target, indent, gate, f"d{data_q}") + pending_by_data[data_q] = _SZZ_FLOW_IDENTITY + def _append_szz_logical_pauli(target: list[str], indent: str, axis: str, qubit_expr: str) -> None: if axis == "X": target.append(f"{indent}x({qubit_expr})") @@ -485,14 +596,37 @@ def _append_szz_layer( layer_gates: list[tuple[str, int, int]], ancilla_expr: Callable[[str, int], str], data_expr: Callable[[int], str], + pending_by_data: dict[int, tuple[int, int]] | None = None, ) -> None: + def compose_data(data_q: int, gates: tuple[OpType, ...]) -> None: + if pending_by_data is None: + for gate in gates: + _append_szz_flow_gate(target, indent, gate, data_expr(data_q)) + return + pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + for gate in gates: + pending = _szz_flow_compose_pending_gate(pending, gate) + pending_by_data[data_q] = pending + + def discharge_data_for_szz(data_q: int) -> None: + if pending_by_data is None: + return + pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + if pending == _SZZ_FLOW_IDENTITY or _szz_flow_is_virtual_z(pending): + return + prefix = _szz_guppy_prefix_gates_for_pending(pending) + for gate in prefix: + _append_szz_flow_gate(target, indent, gate, data_expr(data_q)) + pending_by_data[data_q] = _SZZ_FLOW_IDENTITY + target.append("") target.append(f"{indent}# SZZ round {rnd_idx + 1}") for stab_type, stab_idx, data_q in layer_gates: axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) - _append_szz_axis_rotation_to_z(target, indent, axis, data_expr(data_q)) + compose_data(data_q, _szz_axis_rotation_to_z_gates(axis)) for stab_type, stab_idx, data_q in layer_gates: + discharge_data_for_szz(data_q) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] half_turns = "0.5" if sign > 0 else "-0.5" target.append( @@ -502,12 +636,12 @@ def _append_szz_layer( for stab_type, stab_idx, data_q in layer_gates: axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) - _append_szz_axis_rotation_from_z(target, indent, axis, data_expr(data_q)) + compose_data(data_q, _szz_axis_rotation_from_z_gates(axis)) for stab_type, stab_idx, data_q in layer_gates: sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] axis = _szz_physical_axis_for_touch(stab_type, stab_idx, data_q) - _append_szz_touch_compensation(target, indent, axis, sign, data_expr(data_q)) + compose_data(data_q, _szz_touch_compensation_gates(axis, sign)) lines.extend( [ @@ -552,6 +686,10 @@ def _append_szz_layer( lines.append(" # Unpack data qubits") _append_szz_data_unpack(lines, " ") + szz_syndrome_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) + lines.append(" # Allocate ancilla qubits (one per stabilizer)") lines.extend(f" ax{stab.index} = qubit()" for stab in geom.x_stabilizers) lines.extend(f" az{stab.index} = qubit()" for stab in geom.z_stabilizers) @@ -580,7 +718,15 @@ def _append_szz_layer( lines.extend(f" h(az{stab.index})" for stab in geom.z_stabilizers) for rnd_idx, rnd_gates in enumerate(rounds): - _append_szz_layer(lines, " ", rnd_idx, list(rnd_gates), _szz_ancilla_expr, _szz_data_expr) + _append_szz_layer( + lines, + " ", + rnd_idx, + list(rnd_gates), + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=szz_syndrome_pending_by_data, + ) lines.append("") lines.append(" # Hadamard on SZZ ancillas") @@ -631,6 +777,9 @@ def _append_szz_layer( if interaction_basis == "szz": lines.append(" # Unpack data qubits") _append_szz_data_unpack(lines, " ") + szz_syndrome_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) idx = 0 for batch_idx, batch in enumerate(batches): lines.append("") @@ -687,6 +836,7 @@ def _append_szz_layer( (stab_type, stab_idx) ], _szz_data_expr, + pending_by_data=szz_syndrome_pending_by_data, ) if interaction_basis == "cx": @@ -717,6 +867,10 @@ def _append_szz_layer( lines.extend(["", f" synx = array({x_calls})", f" synz = array({z_calls})", ""]) if interaction_basis == "szz": + if szz_syndrome_pending_by_data is None: + msg = "internal error: SZZ syndrome extraction did not initialize data-frame state" + raise ValueError(msg) + _append_szz_flush_data_frame(lines, " ", szz_syndrome_pending_by_data, reason="syndrome return") lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") lines.append(f" return surf, Syndrome_{dx}x{dz}(synx, synz)") else: @@ -752,6 +906,9 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ) if interaction_basis == "szz": _append_szz_data_unpack(lines, " ") + szz_init_pending_by_data = ( + dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) if interaction_basis == "szz" else None + ) if not constrained: if stab_type == "X": @@ -793,7 +950,15 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: filtered = [(t, i, q) for t, i, q in rnd_gates if t == stab_type] if not filtered: continue - _append_szz_layer(lines, " ", rnd_idx, filtered, _szz_ancilla_expr, _szz_data_expr) + _append_szz_layer( + lines, + " ", + rnd_idx, + filtered, + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=szz_init_pending_by_data, + ) lines.append("") lines.append(" # Hadamard on SZZ ancillas") @@ -865,11 +1030,12 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: " ", rnd_idx, rnd_in_batch, - lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ - (selected_type, stab_idx) - ], - _szz_data_expr, - ) + lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (selected_type, stab_idx) + ], + _szz_data_expr, + pending_by_data=szz_init_pending_by_data, + ) if interaction_basis == "cx": if stab_type == "X": @@ -894,6 +1060,10 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: lines.append("") if interaction_basis == "szz": + if szz_init_pending_by_data is None: + msg = "internal error: SZZ init helper did not initialize data-frame state" + raise ValueError(msg) + _append_szz_flush_data_frame(lines, " ", szz_init_pending_by_data, reason=f"{function_name} return") lines.append(f" surf = SurfaceCode_{dx}x{dz}({szz_data_args})") lines.append(f" return surf, array({return_calls})") else: diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index abff4e129..f710863c7 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -89,6 +89,17 @@ def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: assert "_cfglobal_axis_cycle_f" in framed_key +def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: + src = generate_guppy_source(patch, interaction_basis="szz", num_rounds=2) + + assert "for _t in range(comptime(num_rounds)):" in src + assert "surf, syn = syndrome_extraction(surf)" in src + assert "# Flush SZZ data frame before syndrome return" in src + assert "# Flush SZZ data frame before init_z_basis return" in src + assert "# Flush SZZ data frame before init_x_basis return" in src + assert 'result("final", final)' in src + + def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, @@ -98,7 +109,8 @@ def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) assert "from guppylang.std.quantum import" in src assert ", x, y, z" in src - assert "sdg(d0)\n vdg(d0)\n s(d0)" in src + assert "# Flush SZZ data frame before syndrome return" in src + assert "sdg(d0)\n vdg(d0)\n s(d0)" not in src assert "def apply_logical_x" in src assert "y(surf.d" in src assert "def apply_logical_z" in src From 0f943105520aeae563d8b459d273c1880f0748e0 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 22 Jun 2026 20:43:12 -0600 Subject: [PATCH 241/388] Add optional SZZ runtime barriers --- .../quantum-pecos/src/pecos/guppy/surface.py | 59 +++++++++++++++---- .../tests/guppy/test_surface_twirl_render.py | 47 +++++++++++++++ 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index f29111049..b746b3c1e 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -34,7 +34,7 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, bool], dict]] = {} _state = _ModuleState() @@ -143,6 +143,7 @@ def generate_guppy_source( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> str: """Generate Guppy source code for a surface code patch. @@ -199,6 +200,11 @@ def generate_guppy_source( clifford_frame_policy: Optional source-level Clifford-deformation policy. Currently supported for SZZ global axis-cycle and checkerboard XZZX/ZXXZ deformed-check frames. + szz_runtime_barriers: When true, emit qsystem RuntimeBarrier + operations immediately before SZZ/SZZdg host regions. This has no + ideal-unitary effect, but gives runtimes a principled scheduling + boundary before local data-frame pulses are discharged into their + entangling host. Returns: Python/Guppy source code as a string. @@ -287,7 +293,7 @@ def generate_guppy_source( "", "from guppylang import guppy", "from guppylang.std.angles import angle", - "from guppylang.std.builtins import array, owned, result", + "from guppylang.std.builtins import array, barrier, owned, result", "from guppylang.std.qsystem.functional import zz_phase", "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", ] @@ -626,6 +632,8 @@ def discharge_data_for_szz(data_q: int) -> None: compose_data(data_q, _szz_axis_rotation_to_z_gates(axis)) for stab_type, stab_idx, data_q in layer_gates: + if szz_runtime_barriers: + target.append(f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)})") discharge_data_for_szz(data_q) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] half_turns = "0.5" if sign > 0 else "-0.5" @@ -1818,12 +1826,14 @@ def _validate_surface_memory_distance(d: int) -> None: def _guppy_module_cache_key( patch: "SurfacePatch", effective_budget: int, + *, twirl: "TwirlConfig | None" = None, rng: "GuppyRngMaskConfig | None" = None, num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1834,7 +1844,8 @@ def _guppy_module_cache_key( Twirled source is Python-time unrolled, so the cache key includes ``num_rounds`` in addition to the structural twirl fields, runtime - frame-output mode, RNG seed, and activation-probability threshold. + frame-output mode, RNG seed, activation-probability threshold, and + whether qsystem runtime barriers are emitted around SZZ host regions. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -1846,9 +1857,10 @@ def _guppy_module_cache_key( interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" + runtime_barrier_part = "_szzrb" if szz_runtime_barriers else "" base = ( f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" - f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}" + f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}{runtime_barrier_part}" ) if twirl is None: return base @@ -1874,6 +1886,7 @@ def _load_guppy_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1883,7 +1896,7 @@ def _load_guppy_module( ``ancilla_budget >= total_ancilla`` resolve to the same cache entry while distinct patch geometries never collide. Twirled source also keys on twirl fields, frame-output mode, activation-probability threshold, - RNG seed, and round count. + RNG seed, round count, and qsystem runtime barrier usage. Args: patch: SurfacePatch with geometry @@ -1896,6 +1909,8 @@ def _load_guppy_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Whether generated SZZ source includes qsystem + RuntimeBarrier operations before host regions. Returns: Module dictionary with generated functions @@ -1913,12 +1928,13 @@ def _load_guppy_module( cache_key = _guppy_module_cache_key( patch, effective_budget, - twirl, - rng, - num_rounds, - interaction_basis, - resolved_plan.plan_id if check_plan is not None else None, - clifford_frame_policy, + twirl=twirl, + rng=rng, + num_rounds=num_rounds, + interaction_basis=interaction_basis, + check_plan=resolved_plan.plan_id if check_plan is not None else None, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) if cache_key in _state.module_cache: @@ -1933,6 +1949,7 @@ def _load_guppy_module( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) # Write to temp file (required for Guppy introspection). @@ -1966,6 +1983,7 @@ def generate_memory_experiment( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> object: """Generate a memory experiment for a patch. @@ -1981,6 +1999,8 @@ def generate_memory_experiment( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before + SZZ/SZZdg host regions. Returns: Guppy function for the experiment @@ -1994,6 +2014,7 @@ def generate_memory_experiment( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) if basis.upper() == "Z": @@ -2087,6 +2108,7 @@ def generate_surface_code_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> str: """Generate source code for a distance-d surface code module. @@ -2099,6 +2121,8 @@ def generate_surface_code_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before + SZZ/SZZdg host regions. Returns: Python/Guppy source code as a string @@ -2114,6 +2138,7 @@ def generate_surface_code_module( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) @@ -2124,6 +2149,7 @@ def _surface_code_module_for_patch( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -2152,6 +2178,7 @@ def _surface_code_module_for_patch( interaction_basis, resolved_plan.plan_id, None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), + bool(szz_runtime_barriers), ) if cache_key in _state.distance_module_cache: @@ -2163,6 +2190,7 @@ def _surface_code_module_for_patch( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) # Metadata derived from the actual patch geometry. @@ -2173,6 +2201,7 @@ def _surface_code_module_for_patch( module["interaction_basis"] = interaction_basis module["check_plan"] = resolved_plan.plan_id module["clifford_frame_policy"] = clifford_frame_policy + module["szz_runtime_barriers"] = bool(szz_runtime_barriers) module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash @@ -2187,6 +2216,7 @@ def get_surface_code_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> dict: """Get a loaded surface code module for distance d. @@ -2198,6 +2228,8 @@ def get_surface_code_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before + SZZ/SZZdg host regions. Returns: Dictionary with module contents and metadata @@ -2212,6 +2244,7 @@ def get_surface_code_module( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) @@ -2224,6 +2257,7 @@ def make_surface_code( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool = False, ) -> object: """Create a surface code memory experiment. @@ -2241,6 +2275,8 @@ def make_surface_code( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. + szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before + SZZ/SZZdg host regions. Returns: Compiled Guppy program @@ -2255,6 +2291,7 @@ def make_surface_code( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index f710863c7..6603fc3a7 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -81,12 +81,20 @@ def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: interaction_basis="szz", clifford_frame_policy="global_axis_cycle_f", ) + barrier_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + szz_runtime_barriers=True, + ) assert cx_key != szz_key assert framed_key != szz_key + assert barrier_key != szz_key assert "_ibcx" not in cx_key assert "_ibszz" in szz_key assert "_cfglobal_axis_cycle_f" in framed_key + assert "_szzrb" in barrier_key def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: @@ -100,6 +108,34 @@ def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: S assert 'result("final", final)' in src +def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) -> None: + src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers=True, + ) + + assert "from guppylang.std.builtins import array, barrier, owned, result" in src + assert "barrier(" in src + assert "barrier(" not in generate_guppy_source( + patch, + interaction_basis="szz", + ) + + lines = src.splitlines() + for index, line in enumerate(lines): + if "vdg(d1)" in line: + nearby = lines[index - 2 : index + 2] + assert "barrier(" in nearby[0] + assert "h(d1)" in nearby[1] + assert "vdg(d1)" in nearby[2] + assert "zz_phase(" in nearby[3] + break + else: + msg = "expected an SZZ touch with a Vdg data-prefix pulse" + raise AssertionError(msg) + + def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, @@ -138,6 +174,17 @@ def test_szz_axis_cycle_memory_experiment_compiles_to_guppy_function(patch: Surf assert fn is not None +def test_szz_runtime_barrier_memory_experiment_compiles_to_guppy_function(patch: SurfacePatch) -> None: + fn = generate_memory_experiment( + patch, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_runtime_barriers=True, + ) + assert fn is not None + + def test_twirl_source_unrolls_rng_masks_and_runtime_paulis(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, From dc1dfe417060e37345136b55fd99e9df2f012c81 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Mon, 22 Jun 2026 21:10:39 -0600 Subject: [PATCH 242/388] Add selective SZZ runtime barrier policy --- .../quantum-pecos/src/pecos/guppy/surface.py | 126 +++++++++++++----- .../tests/guppy/test_surface_twirl_render.py | 44 +++++- 2 files changed, 137 insertions(+), 33 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index b746b3c1e..be0bde3af 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -34,12 +34,42 @@ class _ModuleState: # Keyed by full patch identity + effective budget (dx, dz, orientation, # rotated, effective_budget) so distinct patch geometries -- e.g. rotated # vs non-rotated at the same dx/dz -- never collide on a cached module. - distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, bool], dict]] = {} + distance_module_cache: ClassVar[dict[tuple[int, int, str, bool, int, str, str, str | None, str], dict]] = {} _state = _ModuleState() +_SZZ_RUNTIME_BARRIER_POLICY_NONE = "none" +_SZZ_RUNTIME_BARRIER_POLICY_ALL = "all" +_SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX = "data-prefix" +_SZZ_RUNTIME_BARRIER_POLICIES = frozenset( + { + _SZZ_RUNTIME_BARRIER_POLICY_NONE, + _SZZ_RUNTIME_BARRIER_POLICY_ALL, + _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX, + }, +) + + +def _normalize_szz_runtime_barrier_policy(value: bool | str) -> str: + """Return the canonical SZZ runtime-barrier policy token.""" + if isinstance(value, bool): + return _SZZ_RUNTIME_BARRIER_POLICY_ALL if value else _SZZ_RUNTIME_BARRIER_POLICY_NONE + normalized = str(value).strip().lower().replace("_", "-") + if normalized in {"1", "true", "t", "yes", "y", "on"}: + return _SZZ_RUNTIME_BARRIER_POLICY_ALL + if normalized in {"0", "false", "f", "no", "n", "off"}: + return _SZZ_RUNTIME_BARRIER_POLICY_NONE + if normalized not in _SZZ_RUNTIME_BARRIER_POLICIES: + msg = ( + "szz_runtime_barriers must be a boolean or one of " + f"{sorted(_SZZ_RUNTIME_BARRIER_POLICIES)}, got {value!r}" + ) + raise ValueError(msg) + return normalized + + def _normalize_surface_interaction_basis(interaction_basis: str) -> str: from pecos.qec.surface.circuit_builder import _normalize_interaction_basis @@ -143,7 +173,7 @@ def generate_guppy_source( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> str: """Generate Guppy source code for a surface code patch. @@ -200,11 +230,14 @@ def generate_guppy_source( clifford_frame_policy: Optional source-level Clifford-deformation policy. Currently supported for SZZ global axis-cycle and checkerboard XZZX/ZXXZ deformed-check frames. - szz_runtime_barriers: When true, emit qsystem RuntimeBarrier - operations immediately before SZZ/SZZdg host regions. This has no - ideal-unitary effect, but gives runtimes a principled scheduling - boundary before local data-frame pulses are discharged into their - entangling host. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. ``False`` + or ``"none"`` emits no barriers; ``True`` or ``"all"`` emits a + public Guppy ``barrier`` before every SZZ/SZZdg host region; + ``"data-prefix"`` emits one only when a non-virtual local + data-frame pulse is about to be discharged into that host. Barriers + have no ideal-unitary effect, but give runtimes a principled + scheduling boundary before selected local data-frame pulses and + their entangling host. Returns: Python/Guppy source code as a string. @@ -237,6 +270,13 @@ def generate_guppy_source( ) ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) + if ( + interaction_basis != "szz" + and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + ): + msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" + raise ValueError(msg) resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( patch, interaction_basis=interaction_basis, @@ -632,8 +672,22 @@ def discharge_data_for_szz(data_q: int) -> None: compose_data(data_q, _szz_axis_rotation_to_z_gates(axis)) for stab_type, stab_idx, data_q in layer_gates: - if szz_runtime_barriers: - target.append(f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)})") + pending = ( + _SZZ_FLOW_IDENTITY + if pending_by_data is None + else pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) + ) + has_data_prefix = ( + pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) + ) + if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( + szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX + and has_data_prefix + ): + target.append( + f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, " + f"{data_expr(data_q)})", + ) discharge_data_for_szz(data_q) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] half_turns = "0.5" if sign > 0 else "-0.5" @@ -1833,7 +1887,7 @@ def _guppy_module_cache_key( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -1845,7 +1899,7 @@ def _guppy_module_cache_key( Twirled source is Python-time unrolled, so the cache key includes ``num_rounds`` in addition to the structural twirl fields, runtime frame-output mode, RNG seed, activation-probability threshold, and - whether qsystem runtime barriers are emitted around SZZ host regions. + the SZZ runtime-barrier policy. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -1854,10 +1908,21 @@ def _guppy_module_cache_key( check_plan=check_plan, ) interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) + if ( + interaction_basis != "szz" + and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + ): + msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" + raise ValueError(msg) interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" - runtime_barrier_part = "_szzrb" if szz_runtime_barriers else "" + runtime_barrier_part = ( + "" + if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE + else f"_szzrb-{szz_runtime_barrier_policy}" + ) base = ( f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}{runtime_barrier_part}" @@ -1886,7 +1951,7 @@ def _load_guppy_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -1896,7 +1961,7 @@ def _load_guppy_module( ``ancilla_budget >= total_ancilla`` resolve to the same cache entry while distinct patch geometries never collide. Twirled source also keys on twirl fields, frame-output mode, activation-probability threshold, - RNG seed, round count, and qsystem runtime barrier usage. + RNG seed, round count, and SZZ runtime-barrier policy. Args: patch: SurfacePatch with geometry @@ -1909,8 +1974,7 @@ def _load_guppy_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Whether generated SZZ source includes qsystem - RuntimeBarrier operations before host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Module dictionary with generated functions @@ -1983,7 +2047,7 @@ def generate_memory_experiment( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> object: """Generate a memory experiment for a patch. @@ -1999,8 +2063,7 @@ def generate_memory_experiment( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before - SZZ/SZZdg host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Guppy function for the experiment @@ -2108,7 +2171,7 @@ def generate_surface_code_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> str: """Generate source code for a distance-d surface code module. @@ -2121,8 +2184,7 @@ def generate_surface_code_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before - SZZ/SZZdg host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Python/Guppy source code as a string @@ -2149,7 +2211,7 @@ def _surface_code_module_for_patch( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -2166,6 +2228,7 @@ def _surface_code_module_for_patch( check_plan=check_plan, ) interaction_basis = resolved_plan.interaction_basis + szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) geom = patch.geometry total_ancilla = len(geom.x_stabilizers) + len(geom.z_stabilizers) effective_budget = normalize_ancilla_budget(total_ancilla, ancilla_budget) @@ -2178,7 +2241,7 @@ def _surface_code_module_for_patch( interaction_basis, resolved_plan.plan_id, None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), - bool(szz_runtime_barriers), + szz_runtime_barrier_policy, ) if cache_key in _state.distance_module_cache: @@ -2190,7 +2253,7 @@ def _surface_code_module_for_patch( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, - szz_runtime_barriers=szz_runtime_barriers, + szz_runtime_barriers=szz_runtime_barrier_policy, ) # Metadata derived from the actual patch geometry. @@ -2201,7 +2264,8 @@ def _surface_code_module_for_patch( module["interaction_basis"] = interaction_basis module["check_plan"] = resolved_plan.plan_id module["clifford_frame_policy"] = clifford_frame_policy - module["szz_runtime_barriers"] = bool(szz_runtime_barriers) + module["szz_runtime_barriers"] = szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE + module["szz_runtime_barrier_policy"] = szz_runtime_barrier_policy module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash @@ -2216,7 +2280,7 @@ def get_surface_code_module( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> dict: """Get a loaded surface code module for distance d. @@ -2228,8 +2292,7 @@ def get_surface_code_module( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before - SZZ/SZZdg host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Dictionary with module contents and metadata @@ -2257,7 +2320,7 @@ def make_surface_code( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool = False, + szz_runtime_barriers: bool | str = False, ) -> object: """Create a surface code memory experiment. @@ -2275,8 +2338,7 @@ def make_surface_code( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. - szz_runtime_barriers: Emit qsystem RuntimeBarrier operations before - SZZ/SZZdg host regions. + szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. Returns: Compiled Guppy program diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 6603fc3a7..05676e9ff 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -87,14 +87,22 @@ def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: interaction_basis="szz", szz_runtime_barriers=True, ) + prefix_barrier_key = _guppy_module_cache_key( + patch, + effective_budget=8, + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) assert cx_key != szz_key assert framed_key != szz_key assert barrier_key != szz_key + assert prefix_barrier_key != barrier_key assert "_ibcx" not in cx_key assert "_ibszz" in szz_key assert "_cfglobal_axis_cycle_f" in framed_key - assert "_szzrb" in barrier_key + assert "_szzrb-all" in barrier_key + assert "_szzrb-data-prefix" in prefix_barrier_key def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: @@ -136,6 +144,40 @@ def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) raise AssertionError(msg) +def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: SurfacePatch) -> None: + all_src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers="all", + ) + prefix_src = generate_guppy_source( + patch, + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) + + assert "barrier(" in prefix_src + assert prefix_src.count("barrier(") < all_src.count("barrier(") + + lines = prefix_src.splitlines() + for index, line in enumerate(lines): + if "vdg(d1)" in line: + nearby = lines[index - 2 : index + 2] + assert "barrier(" in nearby[0] + assert "h(d1)" in nearby[1] + assert "vdg(d1)" in nearby[2] + assert "zz_phase(" in nearby[3] + break + else: + msg = "expected an SZZ touch with a Vdg data-prefix pulse" + raise AssertionError(msg) + + +def test_szz_runtime_barriers_reject_cx_source(patch: SurfacePatch) -> None: + with pytest.raises(ValueError, match="interaction_basis='szz'"): + generate_guppy_source(patch, szz_runtime_barriers="data-prefix") + + def test_szz_axis_cycle_frame_source_uses_y_check_scaffold(patch: SurfacePatch) -> None: src = generate_guppy_source( patch, From b13a548dad2e46bf5c7e458e1ed970dadfab75b4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 11:46:15 -0600 Subject: [PATCH 243/388] Fix zlup composite-gate tests to canonical cx (q0, q1) syntax and reject duplicate gate declarations --- exp/zlup/src/semantic.rs | 70 ++++++++++++++++++++++++++++------------ exp/zlup/src/tests.rs | 8 ++--- 2 files changed, 53 insertions(+), 25 deletions(-) diff --git a/exp/zlup/src/semantic.rs b/exp/zlup/src/semantic.rs index f201254b3..20675aeb1 100644 --- a/exp/zlup/src/semantic.rs +++ b/exp/zlup/src/semantic.rs @@ -1837,6 +1837,40 @@ impl SemanticAnalyzer { } } + /// Register a user-declared gate (target declaration or composite definition). + /// + /// Rejects a collision with an existing user-declared gate (the same name + /// declared/defined twice). Built-in gates may be shadowed -- e.g. a backend + /// providing its own definition for `rz` -- so only non-builtin collisions error. + fn register_user_gate( + &mut self, + name: &str, + num_params: usize, + num_qubits: usize, + location: Option, + ) -> SemanticResult<()> { + if self + .gate_registry + .get(name) + .is_some_and(|existing| !existing.is_builtin) + { + return Err(SemanticError::DuplicateSymbol { + name: name.to_string(), + location: location.unwrap_or_default(), + }); + } + self.gate_registry.insert( + name.to_string(), + GateSignature { + name: name.to_string(), + num_params, + num_qubits, + is_builtin: false, + }, + ); + Ok(()) + } + /// Collect top-level declarations (forward declaration pass). fn collect_top_level(&mut self, decl: &TopLevelDecl) -> SemanticResult<()> { match decl { @@ -1973,28 +2007,22 @@ impl SemanticAnalyzer { // Tests don't declare symbols } TopLevelDecl::DeclareGate(gate) => { - // Register target gate in the gate registry - self.gate_registry.insert( - gate.name.clone(), - GateSignature { - name: gate.name.clone(), - num_params: gate.params.len(), - num_qubits: gate.qubits.len(), - is_builtin: false, - }, - ); + // Register target gate in the gate registry (reject duplicates) + self.register_user_gate( + &gate.name, + gate.params.len(), + gate.qubits.len(), + gate.location.clone(), + )?; } TopLevelDecl::Gate(gate) => { - // Register composite gate in the gate registry - self.gate_registry.insert( - gate.name.clone(), - GateSignature { - name: gate.name.clone(), - num_params: gate.params.len(), - num_qubits: gate.qubits.len(), - is_builtin: false, - }, - ); + // Register composite gate in the gate registry (reject duplicates) + self.register_user_gate( + &gate.name, + gate.params.len(), + gate.qubits.len(), + gate.location.clone(), + )?; } TopLevelDecl::ErrorSet(error_set) => { // Error sets define a type containing the error values with optional associated data @@ -8781,7 +8809,7 @@ mod tests { assert!(analyze(r#" gate bell()(q0, q1) { h q0; - cx q0, q1; + cx (q0, q1); } "#).is_ok()); } diff --git a/exp/zlup/src/tests.rs b/exp/zlup/src/tests.rs index 41a7109c9..48999cf78 100644 --- a/exp/zlup/src/tests.rs +++ b/exp/zlup/src/tests.rs @@ -1769,7 +1769,7 @@ fn test_parse_composite_gate_multi_qubit() { r#" gate bell()(q0, q1) { h q0; - cx q0, q1; + cx (q0, q1); } "# ); @@ -1780,9 +1780,9 @@ fn test_parse_pub_composite_gate() { assert_parses!( r#" pub gate swap()(a, b) { - cx a, b; - cx b, a; - cx a, b; + cx (a, b); + cx (b, a); + cx (a, b); } "# ); From 349cd98144035531edfc879ec6c0324a5008c12a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 11:46:15 -0600 Subject: [PATCH 244/388] Gate zlup cli integration tests behind the cli feature so they skip without the binary --- exp/zlup/Cargo.toml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/exp/zlup/Cargo.toml b/exp/zlup/Cargo.toml index 0cdcbf095..bda2f7ac4 100644 --- a/exp/zlup/Cargo.toml +++ b/exp/zlup/Cargo.toml @@ -74,6 +74,13 @@ proptest = "1.4" # Property-based testing name = "compiler" harness = false +# CLI integration tests drive the `zlup` binary, which only exists with the +# `cli` feature. Gate the test target so it is skipped (not failed) when `cli` +# is off, e.g. the default-feature workspace test run. +[[test]] +name = "cli" +required-features = ["cli"] + [features] default = [] # Enable CLI binary From 9b453ec0d616dcc415c7539accbcc060b55fa828 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 12:23:24 -0600 Subject: [PATCH 245/388] Fix QIS LLVM qubit allocation by not freezing an unstarted dynamic engine's inferred-zero count as a hard Selene hint --- crates/pecos-engines/src/sim_builder.rs | 10 +++++++++- crates/pecos-qis/src/selene_runtime.rs | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index dc9a5caba..d1c46da14 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -284,7 +284,15 @@ impl SimBuilder { ) })?; - classical_engine.set_num_qubits_hint(num_qubits); + // A dynamic classical engine (e.g. the QIS/Selene runtime) reports 0 + // qubits until program execution has discovered its allocations. That 0 + // means "unknown", not "zero qubits": freezing it as a hint would + // override the runtime's own capacity discovery and initialize the + // plugin with no qubits, so every qalloc fails. Only pass a hint when + // the count is actually known (explicit, or a positive static count). + if num_qubits > 0 { + classical_engine.set_num_qubits_hint(num_qubits); + } // Build quantum engine (require explicit qubit specification) let quantum_engine = if let Some(mut builder) = self.quantum_builder { diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index f88ed9a1e..70cabdcae 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -444,6 +444,10 @@ impl SeleneRuntime { } fn plugin_num_qubits(&self) -> usize { + // An explicit hint is authoritative and caps the plugin capacity (a + // program that exceeds it fails loudly at qalloc by design). The bogus + // "0 inferred before execution" case is handled upstream in SimBuilder, + // which no longer freezes that 0 as a hint. self.num_qubits_hint.unwrap_or(self.num_qubits) } From 9eb746112596fff593d5ea360d18f6aebcc5f7a4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 13:07:46 -0600 Subject: [PATCH 246/388] Tighten zlup gate registration: reject mismatched built-in shadowing and declare-then-define, allow exact-signature built-in redeclaration --- exp/zlup/src/semantic.rs | 61 ++++++++++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 8 deletions(-) diff --git a/exp/zlup/src/semantic.rs b/exp/zlup/src/semantic.rs index 20675aeb1..577144fb5 100644 --- a/exp/zlup/src/semantic.rs +++ b/exp/zlup/src/semantic.rs @@ -1839,9 +1839,16 @@ impl SemanticAnalyzer { /// Register a user-declared gate (target declaration or composite definition). /// - /// Rejects a collision with an existing user-declared gate (the same name - /// declared/defined twice). Built-in gates may be shadowed -- e.g. a backend - /// providing its own definition for `rz` -- so only non-builtin collisions error. + /// Rejects a duplicate user gate (the same name declared/defined twice). + /// `declare gate` introduces an opaque target/backend gate and `gate ... {}` + /// a composite definition; PECOS treats either as a complete declaration, so + /// a second one of the same name -- including declare-then-define -- is a + /// duplicate, not a forward declaration. + /// + /// A built-in gate may only be redeclared with its exact signature (a + /// harmless no-op); shadowing a built-in with a different arity/parameter + /// count is rejected, because built-in names are parsed with a fixed + /// parameterization and a mismatched redeclaration would be uncallable. fn register_user_gate( &mut self, name: &str, @@ -1849,11 +1856,20 @@ impl SemanticAnalyzer { num_qubits: usize, location: Option, ) -> SemanticResult<()> { - if self - .gate_registry - .get(name) - .is_some_and(|existing| !existing.is_builtin) - { + if let Some(existing) = self.gate_registry.get(name) { + if existing.is_builtin { + if existing.num_params != num_params || existing.num_qubits != num_qubits { + return Err(SemanticError::Other { + message: format!( + "cannot redeclare built-in gate '{name}' with a different signature: \ + built-in '{name}' takes {} parameter(s) and {} qubit(s)", + existing.num_params, existing.num_qubits + ), + }); + } + // Exact-signature redeclaration of a built-in: no-op. + return Ok(()); + } return Err(SemanticError::DuplicateSymbol { name: name.to_string(), location: location.unwrap_or_default(), @@ -8824,6 +8840,35 @@ mod tests { assert!(result.is_err(), "Duplicate gate declaration should fail"); } + #[test] + fn test_declare_then_define_same_gate_rejected() { + // `declare gate` is an opaque target gate, not a forward declaration: + // declaring then defining the same name is a duplicate, not a definition. + let result = analyze(r#" + declare gate foo()(q); + gate foo()(q) { h q; } + "#); + assert!(result.is_err(), "declare-then-define of the same gate should fail"); + } + + #[test] + fn test_declare_gate_builtin_exact_signature_allowed() { + // Redeclaring a built-in with its exact signature is a harmless no-op. + // `rz` is a 1-parameter, 1-qubit built-in. + assert!(analyze(r#" + declare gate rz(angle)(q); + "#).is_ok()); + } + + #[test] + fn test_declare_gate_builtin_mismatched_signature_rejected() { + // `rz` is a 1-parameter built-in; redeclaring it with no parameters + // would be uncallable under the fixed built-in parameterization. + assert!(analyze(r#" + declare gate rz()(q); + "#).is_err()); + } + #[test] fn test_builtin_gates_still_work() { // Built-in gates should still work alongside custom gate declarations From 03b95e3c811f3107d94422d34bcbaaf4e7a50695 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 13:07:46 -0600 Subject: [PATCH 247/388] Run zlup cli-feature integration tests in the rust gate (skipped by the default-feature workspace run) --- crates/pecos-cli/src/cli/rust_cmd.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/pecos-cli/src/cli/rust_cmd.rs b/crates/pecos-cli/src/cli/rust_cmd.rs index bfd53f9cb..4aed6dcf0 100644 --- a/crates/pecos-cli/src/cli/rust_cmd.rs +++ b/crates/pecos-cli/src/cli/rust_cmd.rs @@ -566,6 +566,19 @@ fn run_test(profile: super::BuildProfile, include_ffi: bool) -> Result<()> { )); } + // Test zlup's CLI integration tests separately with --features=cli. The + // `zlup` binary has `required-features = ["cli"]`, so the default-feature + // workspace run above does not build it and skips tests/cli.rs entirely; + // this run exercises those CLI integration tests. + println!("Testing zlup with cli feature..."); + let mut zlup_args: Vec<&str> = vec!["test", "-p", "zlup", "--features=cli"]; + zlup_args.extend(profile_args); + if !run(&zlup_args) { + return Err(Error::Config( + "cargo test (zlup with cli) failed".to_string(), + )); + } + // Test cuQuantum if SDK is available (requires both CUDA and cuQuantum) if probe_cuquantum_availability() { println!("cuQuantum runtime available - testing pecos-cuquantum"); From c9dfd47d0a3ce84a8e22aacc595a51c8fa27b3c0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 13:39:37 -0600 Subject: [PATCH 248/388] Unify the workspace on selene-core 0.2.2 by bumping pecos-selene-core and the python selene plugin crates to match pecos-qis --- Cargo.lock | 29 +++++-------------- crates/pecos-selene-core/Cargo.toml | 2 +- .../pecos-selene-mast/Cargo.toml | 2 +- .../pecos-selene-stab-mps/Cargo.toml | 2 +- .../pecos-selene-stab-vec/Cargo.toml | 2 +- .../pecos-selene-stabilizer/Cargo.toml | 2 +- .../pecos-selene-statevec/Cargo.toml | 2 +- 7 files changed, 14 insertions(+), 27 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5697310f8..19b5a23ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4843,7 +4843,7 @@ dependencies = [ "clap 4.6.1", "pecos-core", "pecos-simulators", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4854,7 +4854,7 @@ dependencies = [ "pecos-core", "pecos-simulators", "pecos-stab-tn", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4865,7 +4865,7 @@ dependencies = [ "pecos-core", "pecos-simulators", "pecos-stab-tn", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4875,7 +4875,7 @@ dependencies = [ "anyhow", "pecos-core", "pecos-simulators", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4886,7 +4886,7 @@ dependencies = [ "clap 4.6.1", "pecos-core", "pecos-simulators", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -4896,7 +4896,7 @@ dependencies = [ "anyhow", "pecos-core", "pecos-simulators", - "selene-core 0.2.1", + "selene-core", ] [[package]] @@ -6480,19 +6480,6 @@ dependencies = [ "libc", ] -[[package]] -name = "selene-core" -version = "0.2.1" -source = "git+https://github.com/Quantinuum/selene.git?rev=1794e8d1dba26120a18e904940c014f4e034bed6#1794e8d1dba26120a18e904940c014f4e034bed6" -dependencies = [ - "anyhow", - "delegate", - "derive_more 2.1.1", - "libloading 0.8.9", - "ouroboros", - "thiserror 2.0.18", -] - [[package]] name = "selene-core" version = "0.2.2" @@ -6512,7 +6499,7 @@ version = "0.2.6" source = "git+https://github.com/Quantinuum/selene.git?rev=01300ee5d4825e2dfc6500941d0540c3ff06988a#01300ee5d4825e2dfc6500941d0540c3ff06988a" dependencies = [ "anyhow", - "selene-core 0.2.2", + "selene-core", ] [[package]] @@ -6521,7 +6508,7 @@ version = "0.2.6" source = "git+https://github.com/Quantinuum/selene.git?rev=01300ee5d4825e2dfc6500941d0540c3ff06988a#01300ee5d4825e2dfc6500941d0540c3ff06988a" dependencies = [ "anyhow", - "selene-core 0.2.2", + "selene-core", ] [[package]] diff --git a/crates/pecos-selene-core/Cargo.toml b/crates/pecos-selene-core/Cargo.toml index 1ce97c9ea..502bb0439 100644 --- a/crates/pecos-selene-core/Cargo.toml +++ b/crates/pecos-selene-core/Cargo.toml @@ -16,7 +16,7 @@ crate-type = ["rlib"] anyhow.workspace = true pecos-core.workspace = true pecos-simulators.workspace = true -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [dev-dependencies] pecos-simulators.workspace = true diff --git a/python/selene-plugins/pecos-selene-mast/Cargo.toml b/python/selene-plugins/pecos-selene-mast/Cargo.toml index 2f14e16cb..32f92050e 100644 --- a/python/selene-plugins/pecos-selene-mast/Cargo.toml +++ b/python/selene-plugins/pecos-selene-mast/Cargo.toml @@ -18,7 +18,7 @@ anyhow = { workspace = true } pecos-core = { workspace = true } pecos-simulators = { workspace = true } pecos-stab-tn = { path = "../../../exp/pecos-stab-tn" } -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml b/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml index ea91374eb..ccbcd0f5b 100644 --- a/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml +++ b/python/selene-plugins/pecos-selene-stab-mps/Cargo.toml @@ -18,7 +18,7 @@ anyhow = { workspace = true } pecos-core = { workspace = true } pecos-simulators = { workspace = true } pecos-stab-tn = { path = "../../../exp/pecos-stab-tn" } -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml b/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml index 40cb39f97..4e00dff27 100644 --- a/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml +++ b/python/selene-plugins/pecos-selene-stab-vec/Cargo.toml @@ -19,7 +19,7 @@ pecos-core = { workspace = true } pecos-simulators = { workspace = true } # selene-core is a git dependency since it's not published to crates.io # Use the same revision as the other selene plugins for consistency -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/python/selene-plugins/pecos-selene-stabilizer/Cargo.toml b/python/selene-plugins/pecos-selene-stabilizer/Cargo.toml index 9892f6b11..2714ab55f 100644 --- a/python/selene-plugins/pecos-selene-stabilizer/Cargo.toml +++ b/python/selene-plugins/pecos-selene-stabilizer/Cargo.toml @@ -20,7 +20,7 @@ pecos-simulators = { workspace = true } pecos-core = { workspace = true } # selene-core is a git dependency since it's not published to crates.io # Use the same revision as pecos-qis for consistency -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true diff --git a/python/selene-plugins/pecos-selene-statevec/Cargo.toml b/python/selene-plugins/pecos-selene-statevec/Cargo.toml index 3c30bd733..fd804cea6 100644 --- a/python/selene-plugins/pecos-selene-statevec/Cargo.toml +++ b/python/selene-plugins/pecos-selene-statevec/Cargo.toml @@ -19,7 +19,7 @@ pecos-core = { workspace = true } pecos-simulators = { workspace = true } # selene-core is a git dependency since it's not published to crates.io # Use the same revision as pecos-qis for consistency -selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "1794e8d1dba26120a18e904940c014f4e034bed6" } +selene-core = { git = "https://github.com/Quantinuum/selene.git", rev = "01300ee5d4825e2dfc6500941d0540c3ff06988a" } [lints] workspace = true From 19fc06eaf41bb7b55622ec27484cb8485a09800a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 14:59:55 -0600 Subject: [PATCH 249/388] Scope the QIS persistent compiled-program cache to the running build so a stale shared object is never reused across builds with a different ABI --- crates/pecos-qis/src/executor.rs | 43 +++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index 1199662fb..b31c22c8e 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -97,6 +97,39 @@ fn get_persistent_cache_dir() -> Result { Ok(cache_dir) } +/// A fingerprint of the running build, mixed into the persistent compiled-program +/// cache key so a shared object produced by one build is never reused by a +/// different build whose QIS/runtime/ABI may differ (the cache directory is a +/// fixed path shared across worktrees and rebuilds). +/// +/// Derived from the running executable's identity (path + last-modified time): +/// stable across processes of the same build, but different whenever the binary +/// is rebuilt. If the executable cannot be identified, the process id is mixed in +/// instead so a stale object is never shared across processes with a possibly +/// different ABI (at the cost of in-build reuse for that process). +fn build_fingerprint() -> u64 { + use std::hash::{Hash, Hasher}; + static BUILD_FINGERPRINT: OnceLock = OnceLock::new(); + *BUILD_FINGERPRINT.get_or_init(|| { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + match std::env::current_exe() { + Ok(exe) => { + exe.hash(&mut hasher); + let mtime_nanos = std::fs::metadata(&exe) + .and_then(|m| m.modified()) + .ok() + .and_then(|modified| modified.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|since_epoch| since_epoch.as_nanos()); + mtime_nanos.hash(&mut hasher); + } + Err(_) => { + std::process::id().hash(&mut hasher); + } + } + hasher.finish() + }) +} + /// Remove cache files older than the specified age in seconds fn cleanup_old_cache_files(cache_dir: &Path, max_age_secs: u64) { let now = std::time::SystemTime::now(); @@ -1257,12 +1290,16 @@ impl QisHeliosInterface { fn create_shared_library(&mut self) -> Result { use std::hash::{Hash, Hasher}; - // Compute content hash for caching - // We include the format as a discriminator in case the same bytes could be - // interpreted differently (e.g., bitcode vs text IR) + // Compute content hash for caching. + // - the format is a discriminator in case the same bytes could be + // interpreted differently (e.g., bitcode vs text IR) + // - the build fingerprint scopes the on-disk cache to this build, so a + // shared object compiled against a different QIS/runtime ABI in the + // fixed, cross-worktree cache directory is never reused let mut hasher = std::collections::hash_map::DefaultHasher::new(); self.program.hash(&mut hasher); std::mem::discriminant(&self.format).hash(&mut hasher); + build_fingerprint().hash(&mut hasher); let content_hash = hasher.finish(); // Check if we already have a compiled library for this content From 4ecd64a54e505eded485e25cf8ef00928f5bd812 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 15:28:13 -0600 Subject: [PATCH 250/388] Model zlup gate origin (Builtin/TargetDeclared/CompositeDefined) for per-origin redeclaration rules and clearer diagnostics --- exp/zlup/src/semantic.rs | 91 ++++++++++++++++++++++++++++++---------- 1 file changed, 69 insertions(+), 22 deletions(-) diff --git a/exp/zlup/src/semantic.rs b/exp/zlup/src/semantic.rs index 577144fb5..f921ee66d 100644 --- a/exp/zlup/src/semantic.rs +++ b/exp/zlup/src/semantic.rs @@ -1489,13 +1489,26 @@ pub struct SemanticAnalyzer { gate_registry: BTreeMap, } +/// Where a registered gate came from. Determines which redeclarations are +/// allowed: built-ins may be redeclared only with their exact signature, while a +/// user gate (declared or defined) may not be redeclared at all. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GateOrigin { + /// A built-in gate provided by the language/backend. + Builtin, + /// `declare gate name(...)(...);` -- an opaque target/backend gate. + TargetDeclared, + /// `gate name(...)(...) { ... }` -- a composite gate defined inline. + CompositeDefined, +} + /// Signature of a registered gate (built-in or custom). #[derive(Debug, Clone)] pub struct GateSignature { pub name: String, pub num_params: usize, pub num_qubits: usize, - pub is_builtin: bool, + pub origin: GateOrigin, } /// Information about an alias for overlap detection. @@ -1634,7 +1647,7 @@ impl SemanticAnalyzer { name: name.to_string(), num_params, num_qubits, - is_builtin: true, + origin: GateOrigin::Builtin, }, ); } @@ -1854,26 +1867,38 @@ impl SemanticAnalyzer { name: &str, num_params: usize, num_qubits: usize, - location: Option, + origin: GateOrigin, ) -> SemanticResult<()> { if let Some(existing) = self.gate_registry.get(name) { - if existing.is_builtin { - if existing.num_params != num_params || existing.num_qubits != num_qubits { + match existing.origin { + GateOrigin::Builtin => { + if existing.num_params != num_params || existing.num_qubits != num_qubits { + return Err(SemanticError::Other { + message: format!( + "cannot redeclare built-in gate '{name}' with a different \ + signature: built-in '{name}' takes {} parameter(s) and {} \ + qubit(s)", + existing.num_params, existing.num_qubits + ), + }); + } + // Exact-signature redeclaration of a built-in: no-op. + return Ok(()); + } + GateOrigin::TargetDeclared => { return Err(SemanticError::Other { message: format!( - "cannot redeclare built-in gate '{name}' with a different signature: \ - built-in '{name}' takes {} parameter(s) and {} qubit(s)", - existing.num_params, existing.num_qubits + "gate '{name}' is already declared as a target gate; \ + `declare gate` is an opaque backend gate, not a forward declaration" ), }); } - // Exact-signature redeclaration of a built-in: no-op. - return Ok(()); + GateOrigin::CompositeDefined => { + return Err(SemanticError::Other { + message: format!("gate '{name}' is already defined"), + }); + } } - return Err(SemanticError::DuplicateSymbol { - name: name.to_string(), - location: location.unwrap_or_default(), - }); } self.gate_registry.insert( name.to_string(), @@ -1881,7 +1906,7 @@ impl SemanticAnalyzer { name: name.to_string(), num_params, num_qubits, - is_builtin: false, + origin, }, ); Ok(()) @@ -2023,21 +2048,21 @@ impl SemanticAnalyzer { // Tests don't declare symbols } TopLevelDecl::DeclareGate(gate) => { - // Register target gate in the gate registry (reject duplicates) + // Register an opaque target gate (reject duplicates) self.register_user_gate( &gate.name, gate.params.len(), gate.qubits.len(), - gate.location.clone(), + GateOrigin::TargetDeclared, )?; } TopLevelDecl::Gate(gate) => { - // Register composite gate in the gate registry (reject duplicates) + // Register a composite gate definition (reject duplicates) self.register_user_gate( &gate.name, gate.params.len(), gate.qubits.len(), - gate.location.clone(), + GateOrigin::CompositeDefined, )?; } TopLevelDecl::ErrorSet(error_set) => { @@ -8844,11 +8869,33 @@ mod tests { fn test_declare_then_define_same_gate_rejected() { // `declare gate` is an opaque target gate, not a forward declaration: // declaring then defining the same name is a duplicate, not a definition. - let result = analyze(r#" + let err = analyze( + r#" declare gate foo()(q); gate foo()(q) { h q; } - "#); - assert!(result.is_err(), "declare-then-define of the same gate should fail"); + "#, + ) + .expect_err("declare-then-define of the same gate should fail"); + assert!( + err.to_string().contains("target gate"), + "error should explain the gate is already a target declaration: {err}" + ); + } + + #[test] + fn test_define_then_define_same_gate_rejected() { + // Two composite definitions of the same name collide. + let err = analyze( + r#" + gate foo()(q) { h q; } + gate foo()(q) { x q; } + "#, + ) + .expect_err("defining the same gate twice should fail"); + assert!( + err.to_string().contains("already defined"), + "error should report the gate is already defined: {err}" + ); } #[test] From 7224835a79fcda1b92726aa577a09400513bbb21 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 15:40:54 -0600 Subject: [PATCH 251/388] Harden the QIS program cache: stable SHA-256 key (replacing the unstable DefaultHasher) plus an auditable per-object manifest sidecar --- Cargo.lock | 1 + crates/pecos-qis/Cargo.toml | 1 + crates/pecos-qis/src/executor.rs | 98 +++++++++++++++++++++++++------- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 19b5a23ff..503c19e4d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4679,6 +4679,7 @@ dependencies = [ "selene-soft-rz-runtime", "serde", "serde_json", + "sha2 0.11.0", "tempfile", ] diff --git a/crates/pecos-qis/Cargo.toml b/crates/pecos-qis/Cargo.toml index ee8636375..b56e57d58 100644 --- a/crates/pecos-qis/Cargo.toml +++ b/crates/pecos-qis/Cargo.toml @@ -49,6 +49,7 @@ tempfile.workspace = true rand.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +sha2.workspace = true crossbeam-channel.workspace = true libloading.workspace = true diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index b31c22c8e..e6dce0f38 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -64,7 +64,7 @@ static PROGRAM_LIB_CACHE: OnceLock< /// engines are cloned for parallel execution), this cache ensures they all use /// the same compiled shared library file. static COMPILED_PROGRAM_CACHE: OnceLock< - std::sync::Mutex>, + std::sync::Mutex>, > = OnceLock::new(); /// Tracks whether cache cleanup has been performed (once per process). @@ -130,6 +130,26 @@ fn build_fingerprint() -> u64 { }) } +/// An auditable record written next to each compiled program object in the +/// shared cache directory. It is not consulted to validate a load -- the cache +/// filename is the full SHA-256 of `(program, format, build fingerprint)`, so a +/// filename match already pins those exactly -- but it lets the shared cache +/// directory be inspected and each `.so` traced back to the program, format, +/// build, and toolchain that produced it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct CacheManifest { + /// Full SHA-256 (hex) of program bytes + format + build fingerprint. + digest: String, + /// Program format (stable `Debug` variant name). + format: String, + /// Build fingerprint of the process that compiled the object. + build_fingerprint: u64, + /// `pecos-qis` version that compiled the object. + pecos_qis_version: String, + /// Target arch/os the object was compiled for. + target: String, +} + /// Remove cache files older than the specified age in seconds fn cleanup_old_cache_files(cache_dir: &Path, max_age_secs: u64) { let now = std::time::SystemTime::now(); @@ -1288,19 +1308,27 @@ impl QisHeliosInterface { /// Link the program with Helios interface to create a shared library #[allow(clippy::too_many_lines)] fn create_shared_library(&mut self) -> Result { - use std::hash::{Hash, Hasher}; - - // Compute content hash for caching. - // - the format is a discriminator in case the same bytes could be - // interpreted differently (e.g., bitcode vs text IR) - // - the build fingerprint scopes the on-disk cache to this build, so a - // shared object compiled against a different QIS/runtime ABI in the - // fixed, cross-worktree cache directory is never reused - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - self.program.hash(&mut hasher); - std::mem::discriminant(&self.format).hash(&mut hasher); - build_fingerprint().hash(&mut hasher); - let content_hash = hasher.finish(); + use sha2::{Digest, Sha256}; + use std::fmt::Write as _; + + // Compute a stable content digest for caching. + // - SHA-256 (not the std `DefaultHasher`, whose output is not stable + // across toolchains) so the on-disk key is reproducible for a cache + // that persists across processes + // - the format discriminates bytes that could be interpreted differently + // (e.g., bitcode vs text IR) + // - the build fingerprint scopes the digest to this build, so a shared + // object compiled against a different QIS/runtime ABI in the fixed, + // cross-worktree cache directory is never reused + let mut hasher = Sha256::new(); + hasher.update(&self.program); + hasher.update(format!("{:?}", self.format).as_bytes()); + hasher.update(build_fingerprint().to_le_bytes()); + let digest = hasher.finalize(); + let mut content_hash = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(content_hash, "{byte:02x}"); + } // Check if we already have a compiled library for this content let compiled_cache = COMPILED_PROGRAM_CACHE @@ -1314,7 +1342,7 @@ impl QisHeliosInterface { if let Some(cached_path) = cache_guard.get(&content_hash) { debug!( - "Using cached compiled library for content hash {content_hash:016x}: {}", + "Using cached compiled library for content hash {content_hash}: {}", cached_path.display() ); // Verify the file still exists (might have been cleaned up) @@ -1344,8 +1372,7 @@ impl QisHeliosInterface { } else { ".so" }; - let persistent_cache_path = - cache_dir.join(format!("program_{content_hash:016x}{lib_suffix}")); + let persistent_cache_path = cache_dir.join(format!("program_{content_hash}{lib_suffix}")); if persistent_cache_path.exists() { debug!( @@ -1361,7 +1388,7 @@ impl QisHeliosInterface { std::sync::Mutex::new(std::collections::BTreeMap::new()) }); if let Ok(mut cache_guard) = compiled_cache.lock() { - cache_guard.insert(content_hash, persistent_cache_path.clone()); + cache_guard.insert(content_hash.clone(), persistent_cache_path.clone()); } } self.executable_path = Some(persistent_cache_path.clone()); @@ -1393,7 +1420,7 @@ impl QisHeliosInterface { let compiled_cache = COMPILED_PROGRAM_CACHE .get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); if let Ok(mut cache_guard) = compiled_cache.lock() { - cache_guard.insert(content_hash, persistent_cache_path.clone()); + cache_guard.insert(content_hash.clone(), persistent_cache_path.clone()); } self.executable_path = Some(persistent_cache_path.clone()); info!( @@ -1421,7 +1448,7 @@ impl QisHeliosInterface { let compiled_cache = COMPILED_PROGRAM_CACHE .get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); if let Ok(mut cache_guard) = compiled_cache.lock() { - cache_guard.insert(content_hash, persistent_cache_path.clone()); + cache_guard.insert(content_hash.clone(), persistent_cache_path.clone()); } self.executable_path = Some(persistent_cache_path.clone()); info!( @@ -1813,14 +1840,41 @@ entry: let compiled_cache = COMPILED_PROGRAM_CACHE .get_or_init(|| std::sync::Mutex::new(std::collections::BTreeMap::new())); if let Ok(mut cache_guard) = compiled_cache.lock() { - cache_guard.insert(content_hash, so_path.clone()); + cache_guard.insert(content_hash.clone(), so_path.clone()); debug!( - "Cached compiled library for content hash {content_hash:016x}: {}", + "Cached compiled library for content hash {content_hash}: {}", so_path.display() ); } } + // Write an auditable manifest next to the compiled object so the shared + // cache directory can be inspected and each library traced back to the + // exact program/format/build/toolchain that produced it. Best-effort: + // a missing or stale manifest never affects correctness (the filename is + // the content+build digest), so a write failure is only logged. + { + let manifest = CacheManifest { + digest: content_hash.clone(), + format: format!("{:?}", self.format), + build_fingerprint: build_fingerprint(), + pecos_qis_version: env!("CARGO_PKG_VERSION").to_string(), + target: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS), + }; + let manifest_path = so_path.with_extension("manifest"); + match serde_json::to_string_pretty(&manifest) { + Ok(json) => { + if let Err(e) = std::fs::write(&manifest_path, json) { + debug!( + "Failed to write cache manifest {}: {e}", + manifest_path.display() + ); + } + } + Err(e) => debug!("Failed to serialize cache manifest: {e}"), + } + } + // Load the program library into the global cache. // This avoids repeated library load/unload cycles which cause instability on macOS. debug!("Loading program library into global cache..."); From d0b81fbf1b3113696b9e6ce27acd7f92d4fff906 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 18:12:21 -0600 Subject: [PATCH 252/388] Make the legacy u64 SampleBatch observable APIs fail loud on >64 observables (reject wide batches up front) and add a wide get_observable_mask_wide getter --- .../src/fault_tolerance_bindings.rs | 47 ++++++++++++++++++- .../tests/qec/test_wide_observables.py | 21 +++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index a8a4a14d8..b6e4c7b01 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -3336,8 +3336,33 @@ impl PySampleBatch { } } + /// Reject a batch that cannot be represented by the legacy `u64` observable + /// APIs (more than 64 observable columns). Callers with >64 observables must + /// use the wide `LogicalSubgraphDecoder` decode/decode_count paths, which + /// return arbitrary-precision Python ints. Call this up front in every + /// `u64`-returning public method before [`Self::extract_obs_mask`]. + fn ensure_narrow_observables(&self) -> PyResult<()> { + if self.obs_columns.len() > 64 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "SampleBatch has {} observable columns, exceeding the 64-observable limit of \ + this u64-based API; use the wide LogicalSubgraphDecoder decode/decode_count \ + paths (arbitrary-precision int) for more than 64 observables", + self.obs_columns.len() + ))); + } + Ok(()) + } + /// Extract observable mask for one shot (`u64`; observables 0..=63 only). + /// + /// The caller must have rejected wide batches via + /// [`Self::ensure_narrow_observables`] first; with >64 observable columns the + /// `1u64 << obs_idx` below would overflow. fn extract_obs_mask(&self, shot: usize) -> u64 { + debug_assert!( + self.obs_columns.len() <= 64, + "extract_obs_mask requires <=64 observable columns; call ensure_narrow_observables first" + ); let word_idx = shot / 64; let bit_mask = 1u64 << (shot % 64); let mut mask = 0u64; @@ -3483,8 +3508,9 @@ impl PySampleBatch { Ok(buf) } - /// Get the expected observable mask for shot `i`. + /// Get the expected observable mask for shot `i` (`u64`; <=64 observables). fn get_observable_mask(&self, i: usize) -> PyResult { + self.ensure_narrow_observables()?; if i >= self.num_shots { return Err(PyErr::new::(format!( "Shot index {i} out of range (num_shots={})", @@ -3494,6 +3520,18 @@ impl PySampleBatch { Ok(self.extract_obs_mask(i)) } + /// Observable mask for shot `i` as a Python ``int`` (arbitrary precision, so + /// more than 64 observables are not truncated). + fn get_observable_mask_wide(&self, py: Python<'_>, i: usize) -> PyResult> { + if i >= self.num_shots { + return Err(PyErr::new::(format!( + "Shot index {i} out of range (num_shots={})", + self.num_shots + ))); + } + obsmask_to_py(py, &self.extract_obs_mask_wide(i)) + } + /// Decode all samples with the given decoder type and return the error count. /// /// This runs entirely in Rust -- no per-shot Python crossing. @@ -3508,6 +3546,7 @@ impl PySampleBatch { /// Number of logical errors. #[pyo3(signature = (dem, decoder_type="pymatching"))] fn decode_count(&self, dem: &str, decoder_type: &str) -> PyResult { + self.ensure_narrow_observables()?; let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut errors = 0usize; let mut syndrome = vec![0u8; self.num_detectors]; @@ -3570,6 +3609,7 @@ impl PySampleBatch { ) -> PyResult { use rayon::prelude::*; + self.ensure_narrow_observables()?; let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); let pool = rayon::ThreadPoolBuilder::new() .num_threads(n_workers) @@ -3621,6 +3661,8 @@ impl PySampleBatch { fn decode_count_batch(&self, dem: &str) -> PyResult { use pecos_decoders::{BatchConfig, PyMatchingDecoder}; + self.ensure_narrow_observables()?; + let mut decoder = PyMatchingDecoder::from_dem(dem) .map_err(|e| PyErr::new::(e.to_string()))?; @@ -3681,6 +3723,8 @@ impl PySampleBatch { fn decode_stats(&self, dem: &str, decoder_type: &str) -> PyResult { use std::time::Instant; + self.ensure_narrow_observables()?; + let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut num_errors = 0usize; let mut per_shot_seconds: Vec = Vec::with_capacity(self.num_shots); @@ -3726,6 +3770,7 @@ impl PySampleBatch { ) -> PyResult { use rayon::prelude::*; + self.ensure_narrow_observables()?; let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); // Validate decoder type early. diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index 7cc153729..a5d210893 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -11,6 +11,7 @@ from __future__ import annotations +import pytest from pecos_rslib.qec import LogicalSubgraphDecoder, ParsedDem, SampleBatch @@ -86,3 +87,23 @@ def test_decode_count_above_64_observables() -> None: batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(2000, seed=1) count = dec.decode_count(batch) assert 0 <= count <= 2000 + + +def test_narrow_sample_batch_apis_reject_wide_observables() -> None: + # The legacy u64-based SampleBatch APIs cannot represent >64 observables and + # must reject a wide batch up front rather than panicking or truncating. + n = 65 + dem, _ = _wide_dem(n) + syn = [0] * n + syn[64] = 1 + wide = SampleBatch([syn, syn], [1 << 64, 1 << 64]) + + with pytest.raises(ValueError, match="64-observable"): + wide.get_observable_mask(0) + with pytest.raises(ValueError, match="64-observable"): + wide.decode_count(dem, "pecos_uf:fast") + with pytest.raises(ValueError, match="64-observable"): + wide.decode_stats(dem, "pecos_uf:fast") + + # The wide getter returns the full mask as a Python int with no truncation. + assert wide.get_observable_mask_wide(0) == 1 << 64 From c40af08c54c081087b5217180dda7170d3336f57 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 18:46:10 -0600 Subject: [PATCH 253/388] Make apply_boundary_gate fail loud on observable bits >= 64 (real Result check, not debug_assert) so direct Rust callers cannot silently shift-overflow the u64 frame --- crates/pecos-decoder-core/src/errors.rs | 6 ++ .../src/logical_algorithm.rs | 87 ++++++++++++++----- 2 files changed, 70 insertions(+), 23 deletions(-) diff --git a/crates/pecos-decoder-core/src/errors.rs b/crates/pecos-decoder-core/src/errors.rs index d6eff425d..017d83fc4 100644 --- a/crates/pecos-decoder-core/src/errors.rs +++ b/crates/pecos-decoder-core/src/errors.rs @@ -48,6 +48,12 @@ pub enum DecoderError { #[error("Invalid node index {index}: must be < {max}")] InvalidNodeIndex { index: usize, max: usize }, + /// Observable-frame bit index out of range for the `u64` frame + #[error( + "Observable frame bit {bit} out of range: boundary-gate bits index a u64 frame and must be < 64" + )] + ObservableBitOutOfRange { bit: u32 }, + /// Invalid edge #[error("Invalid edge: {0}")] InvalidEdge(String), diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index 5367aebf6..4798fe666 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -175,15 +175,18 @@ impl LogicalAlgorithmDecoder { /// Apply boundary gate to a Pauli frame. /// Used when consuming the frame at logical operations. - pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) { - // All bits index the u64 observable frame, so each must be < 64. The - // Python descriptor binding enforces this fail-loud at construction; this - // documents and guards the invariant for direct Rust callers (a shift by - // >= 64 is otherwise an overflow panic in debug / unspecified in release). - debug_assert!( - gate.obs_bits().iter().all(|&b| b < 64), - "boundary gate observable bit >= 64" - ); + /// + /// # Errors + /// Returns [`DecoderError::ObservableBitOutOfRange`] if any of the gate's + /// observable bits is `>= 64`. All bits index the `u64` observable frame, so + /// each must be `< 64`; the Python descriptor binding rejects this at + /// construction, and this runtime check guards the same invariant for direct + /// Rust callers (a shift by `>= 64` is otherwise an overflow panic in debug / + /// unspecified in release). + pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) -> Result<(), DecoderError> { + if let Some(&bit) = gate.obs_bits().iter().find(|&&b| b >= 64) { + return Err(DecoderError::ObservableBitOutOfRange { bit }); + } match gate { BoundaryGate::Hadamard { x_obs_bit, @@ -233,6 +236,7 @@ impl LogicalAlgorithmDecoder { } } } + Ok(()) } } @@ -397,8 +401,11 @@ impl StreamingLogicalDecoder { } /// Apply boundary gate to a Pauli frame (delegates to inner). - pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) { - LogicalAlgorithmDecoder::apply_boundary_gate(frame, gate); + /// + /// # Errors + /// Propagates [`DecoderError::ObservableBitOutOfRange`] from the inner apply. + pub fn apply_boundary_gate(frame: &mut u64, gate: &BoundaryGate) -> Result<(), DecoderError> { + LogicalAlgorithmDecoder::apply_boundary_gate(frame, gate) } /// Reset for the next shot. @@ -880,10 +887,33 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b10); // X became Z } + #[test] + fn test_apply_boundary_gate_rejects_obs_bit_ge_64() { + // A boundary bit >= 64 cannot index the u64 frame; apply must fail loud + // (not panic in debug / shift-overflow in release) for direct Rust callers. + let mut frame = 0u64; + let result = LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::Hadamard { + x_obs_bit: 64, + z_obs_bit: 1, + }, + ); + assert!(matches!( + result, + Err(DecoderError::ObservableBitOutOfRange { bit: 64 }) + )); + assert_eq!( + frame, 0, + "frame must be untouched when the gate is rejected" + ); + } + #[test] fn test_boundary_gate_obs_bits_cover_all_fields() { // The apply-time `< 64` assert relies on obs_bits() listing EVERY bit a @@ -935,7 +965,8 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b0101); // X propagated to target } @@ -979,7 +1010,8 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b1010); // Z propagated back to control Z (bit 1) } @@ -995,7 +1027,8 @@ mod tests { tgt_x_bit: 2, tgt_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); // X ctrl -> X tgt (bit 2), Z tgt -> Z ctrl (bit 1) assert_eq!(frame, 0b1111); } @@ -1010,7 +1043,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b11); // X stays, Z also set } @@ -1024,7 +1058,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b10); // Z stays, no X induced } @@ -1037,7 +1072,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0); // No correction, no change } @@ -1051,7 +1087,8 @@ mod tests { z_obs_bit: 1, // data Z ancilla_z_bit: 3, // ancilla Z }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b1010); // data Z (bit 1) flipped } @@ -1065,7 +1102,8 @@ mod tests { z_obs_bit: 1, ancilla_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b1000); // data Z cancelled, ancilla unchanged } @@ -1079,7 +1117,8 @@ mod tests { z_obs_bit: 1, ancilla_z_bit: 3, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b0010); // unchanged } @@ -1093,7 +1132,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b11); // Swap of (1,1) is still (1,1) } @@ -1106,7 +1146,8 @@ mod tests { x_obs_bit: 0, z_obs_bit: 1, }, - ); + ) + .expect("boundary observable bits < 64"); assert_eq!(frame, 0b01); // Z became X } From 2635c10971532f603e18dfcaffac02a8b3bae232 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 18:48:57 -0600 Subject: [PATCH 254/388] Strengthen the QIS cache key with explicit ABI inputs: a stable ProgramFormat cache tag (not Debug), crate version, and target triple --- crates/pecos-qis/src/executor.rs | 18 +++++++++++++++--- crates/pecos-qis/src/qis_interface.rs | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index e6dce0f38..b7dfa40dc 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -130,6 +130,13 @@ fn build_fingerprint() -> u64 { }) } +/// A coarse target identifier (arch + OS) mixed into the cache key and recorded +/// in the manifest, so a shared object compiled for one target is never reused +/// on another even if the build fingerprint somehow matched. +fn cache_target() -> String { + format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS) +} + /// An auditable record written next to each compiled program object in the /// shared cache directory. It is not consulted to validate a load -- the cache /// filename is the full SHA-256 of `(program, format, build fingerprint)`, so a @@ -1322,7 +1329,12 @@ impl QisHeliosInterface { // cross-worktree cache directory is never reused let mut hasher = Sha256::new(); hasher.update(&self.program); - hasher.update(format!("{:?}", self.format).as_bytes()); + // Explicit ABI inputs (stable format tag, crate version, target triple) + // in addition to the build fingerprint, so the key does not rely on the + // running-executable mtime proxy alone to scope reuse. + hasher.update(self.format.cache_tag().as_bytes()); + hasher.update(env!("CARGO_PKG_VERSION").as_bytes()); + hasher.update(cache_target().as_bytes()); hasher.update(build_fingerprint().to_le_bytes()); let digest = hasher.finalize(); let mut content_hash = String::with_capacity(digest.len() * 2); @@ -1856,10 +1868,10 @@ entry: { let manifest = CacheManifest { digest: content_hash.clone(), - format: format!("{:?}", self.format), + format: self.format.cache_tag().to_string(), build_fingerprint: build_fingerprint(), pecos_qis_version: env!("CARGO_PKG_VERSION").to_string(), - target: format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS), + target: cache_target(), }; let manifest_path = so_path.with_extension("manifest"); match serde_json::to_string_pretty(&manifest) { diff --git a/crates/pecos-qis/src/qis_interface.rs b/crates/pecos-qis/src/qis_interface.rs index 12c040a42..bb39af454 100644 --- a/crates/pecos-qis/src/qis_interface.rs +++ b/crates/pecos-qis/src/qis_interface.rs @@ -19,6 +19,21 @@ pub enum ProgramFormat { QisBitcode, } +impl ProgramFormat { + /// A stable identifier for persistent-cache keys and manifests, independent + /// of the `Debug` representation (which would change if a variant is renamed, + /// silently invalidating or, worse, colliding cached objects). + #[must_use] + pub fn cache_tag(self) -> &'static str { + match self { + Self::LlvmIrText => "llvm-ir-text", + Self::LlvmBitcode => "llvm-bitcode", + Self::HugrBytes => "hugr-bytes", + Self::QisBitcode => "qis-bitcode", + } + } +} + /// Error type for interface operations /// /// This is kept minimal to avoid circular dependencies with pecos-core. From a156fae6fd341fad2fb304e38f30d744e2c3a02d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 24 Jun 2026 19:00:05 -0600 Subject: [PATCH 255/388] Distinguish an explicit .qubits(0) (forwarded as a hint) from a dynamic engine's inferred unknown-0 (not frozen) in SimBuilder --- crates/pecos-engines/src/sim_builder.rs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index d1c46da14..8079e9686 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -284,14 +284,17 @@ impl SimBuilder { ) })?; - // A dynamic classical engine (e.g. the QIS/Selene runtime) reports 0 - // qubits until program execution has discovered its allocations. That 0 - // means "unknown", not "zero qubits": freezing it as a hint would + // Forward a qubit-count hint only when it is meaningful. An explicit + // count is the caller's choice and is always forwarded (even 0). But an + // inferred 0 from a dynamic classical engine (e.g. the QIS/Selene + // runtime, which reports 0 qubits until program execution discovers its + // allocations) means "unknown", not "zero qubits": freezing it would // override the runtime's own capacity discovery and initialize the - // plugin with no qubits, so every qalloc fails. Only pass a hint when - // the count is actually known (explicit, or a positive static count). - if num_qubits > 0 { - classical_engine.set_num_qubits_hint(num_qubits); + // plugin with no qubits, so every qalloc fails. Keep that distinction. + match self.explicit_num_qubits { + Some(explicit) => classical_engine.set_num_qubits_hint(explicit), + None if num_qubits > 0 => classical_engine.set_num_qubits_hint(num_qubits), + None => {} } // Build quantum engine (require explicit qubit specification) From 16e351546c2bbf8d29c30143e291ca4abed47333 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 24 Jun 2026 21:48:31 -0600 Subject: [PATCH 256/388] Add generic trace metadata plumbing for QIS replay --- crates/pecos-qis-ffi-types/src/lib.rs | 2 +- crates/pecos-qis-ffi-types/src/operations.rs | 38 +++ crates/pecos-qis-ffi/src/ffi.rs | 179 +++++++++++- crates/pecos-qis-ffi/src/lib.rs | 2 +- crates/pecos-qis/src/ccengine.rs | 255 ++++++++++++++---- crates/pecos-qis/src/runtime.rs | 18 +- crates/pecos-qis/src/selene_runtime.rs | 9 +- .../src/pecos/qec/surface/decode.py | 20 ++ .../tests/qec/test_from_guppy_dem.py | 25 ++ 9 files changed, 496 insertions(+), 52 deletions(-) diff --git a/crates/pecos-qis-ffi-types/src/lib.rs b/crates/pecos-qis-ffi-types/src/lib.rs index 94cc36807..26da989e2 100644 --- a/crates/pecos-qis-ffi-types/src/lib.rs +++ b/crates/pecos-qis-ffi-types/src/lib.rs @@ -7,7 +7,7 @@ mod operations; -pub use operations::{NamedResultTrace, Operation, QuantumOp}; +pub use operations::{LoweredQuantumOp, NamedResultTrace, Operation, QuantumOp, TraceMetadata}; const DEFAULT_OPERATION_CAPACITY: usize = 1024; const DEFAULT_MEASUREMENT_CAPACITY: usize = 256; diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 61cf38b11..103828414 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -3,6 +3,11 @@ //! This module defines the quantum operations that can be collected by the interface //! and later executed by a runtime. +use std::collections::BTreeMap; + +/// Structured metadata attached to QIS operations or lowered quantum operations. +pub type TraceMetadata = BTreeMap; + /// Runtime provenance for a named `result(...)` output. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct NamedResultTrace { @@ -20,6 +25,13 @@ pub enum Operation { /// Quantum gate operation Quantum(QuantumOp), + /// Source-level metadata intended to annotate subsequent lowered operations. + /// + /// Runtimes may preserve this metadata when lowering, scheduling, or expanding + /// operations. PECOS's direct lowering path attaches it to the next emitted + /// simulator gate and then clears it. + TraceMetadata { metadata: TraceMetadata }, + /// Allocate a qubit AllocateQubit { id: usize }, @@ -86,6 +98,32 @@ pub enum QuantumOp { Reset(usize), } +/// A lowered quantum operation plus any provenance supplied by the lowering runtime. +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct LoweredQuantumOp { + /// Lowered operation to send to the quantum/noise engine. + pub op: QuantumOp, + /// Generic trace/source metadata associated with this lowered operation. + pub metadata: TraceMetadata, +} + +impl LoweredQuantumOp { + /// Create a lowered operation with explicit metadata. + #[must_use] + pub fn new(op: QuantumOp, metadata: TraceMetadata) -> Self { + Self { op, metadata } + } +} + +impl From for LoweredQuantumOp { + fn from(op: QuantumOp) -> Self { + Self { + op, + metadata: TraceMetadata::new(), + } + } +} + impl From for Operation { fn from(op: QuantumOp) -> Self { Operation::Quantum(op) diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 120f53e3a..30e5035e4 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -4,7 +4,7 @@ //! with Rust. These functions simply collect operations into the thread-local interface //! without performing any simulation or complex state management. -use crate::{Operation, QuantumOp, with_interface}; +use crate::{Operation, QuantumOp, TraceMetadata, with_interface}; use log::debug; use std::cell::Cell; @@ -26,6 +26,59 @@ fn i64_to_usize(value: i64) -> usize { usize::try_from(value).expect("Invalid ID: value must be non-negative and fit in usize") } +unsafe fn read_tket_string_arg( + func_name: &str, + arg_name: &str, + ptr: *const u8, + len: i64, +) -> Option { + let Ok(len) = usize::try_from(len) else { + log::error!("{func_name}: invalid {arg_name} length {len}"); + return None; + }; + if ptr.is_null() { + log::error!("{func_name}: null {arg_name} pointer"); + return None; + } + + // The tket2 string format is: {len: u8, data: [u8; len]}. + // The pointer references the length byte, so skip it to read the payload. + let data_ptr = unsafe { ptr.add(1) }; + let bytes = unsafe { std::slice::from_raw_parts(data_ptr, len) }; + match std::str::from_utf8(bytes) { + Ok(value) => Some(value.to_string()), + Err(_) => { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None + } + } +} + +unsafe fn read_direct_string_arg( + func_name: &str, + arg_name: &str, + ptr: *const u8, + len: i64, +) -> Option { + let Ok(len) = usize::try_from(len) else { + log::error!("{func_name}: invalid {arg_name} length {len}"); + return None; + }; + if ptr.is_null() { + log::error!("{func_name}: null {arg_name} pointer"); + return None; + } + + let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; + match std::str::from_utf8(bytes) { + Ok(value) => Some(value.to_string()), + Err(_) => { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None + } + } +} + // --- Gate FFI Macros --- // // These macros generate the boilerplate for FFI gate functions. @@ -353,6 +406,79 @@ pub unsafe extern "C" fn __quantum__rt__record(data: *const std::ffi::c_char) { } } +fn queue_trace_metadata(key: String, value: String) { + let mut metadata = TraceMetadata::new(); + metadata.insert(key, value); + with_interface(|interface| { + interface.queue_operation(Operation::TraceMetadata { metadata }); + }); +} + +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This function uses the tket2 string ABI: each string pointer references a +/// `{len: u8, data: [u8; len]}` payload and the length argument gives the data +/// length. Metadata is intentionally represented as ordinary key/value strings +/// so callers can add generic provenance without PECOS knowing about a specific +/// runtime or hardware target. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs with at least +/// `len + 1` bytes. Invalid pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata( + key_ptr: *const u8, + key_len: i64, + value_ptr: *const u8, + value_len: i64, +) { + let Some(key) = + (unsafe { read_tket_string_arg("pecos_qis_trace_metadata", "key", key_ptr, key_len) }) + else { + return; + }; + let Some(value) = (unsafe { + read_tket_string_arg("pecos_qis_trace_metadata", "value", value_ptr, value_len) + }) else { + return; + }; + queue_trace_metadata(key, value); +} + +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This variant uses direct string data pointers instead of the tket2 string +/// struct layout. It is useful for runtime shims that already carry plain +/// pointer/length pairs. +/// +/// # Safety +/// The key and value pointers must reference valid UTF-8 data of the provided +/// lengths. Invalid pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_direct( + key_ptr: *const u8, + key_len: i64, + value_ptr: *const u8, + value_len: i64, +) { + let Some(key) = (unsafe { + read_direct_string_arg("pecos_qis_trace_metadata_direct", "key", key_ptr, key_len) + }) else { + return; + }; + let Some(value) = (unsafe { + read_direct_string_arg( + "pecos_qis_trace_metadata_direct", + "value", + value_ptr, + value_len, + ) + }) else { + return; + }; + queue_trace_metadata(key, value); +} + // --- Selene-style FFI Functions --- // // These functions match the naming convention used by Selene's hugr-qis compiler. @@ -1309,6 +1435,57 @@ mod tests { }); } + #[test] + fn test_trace_metadata_direct() { + setup_test(); + let key = b"source_label"; + let value = b"szz_prefix:H:data_0"; + unsafe { + pecos_qis_trace_metadata_direct( + key.as_ptr(), + key.len() as i64, + value.as_ptr(), + value.len() as i64, + ); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!( + metadata.get("source_label").map(String::as_str), + Some("szz_prefix:H:data_0") + ); + }); + } + + #[test] + fn test_trace_metadata_tket_string_layout() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + unsafe { + pecos_qis_trace_metadata(key.as_ptr(), 11, value.as_ptr(), 10); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + // --- Measurement and reset tests --- #[test] diff --git a/crates/pecos-qis-ffi/src/lib.rs b/crates/pecos-qis-ffi/src/lib.rs index e83988691..5a2cb8f07 100644 --- a/crates/pecos-qis-ffi/src/lib.rs +++ b/crates/pecos-qis-ffi/src/lib.rs @@ -268,7 +268,7 @@ fn get_execution_context() -> Option<*mut ExecutionContext> { // Re-export all types from pecos-qis-ffi-types pub use pecos_qis_ffi_types::{ - NamedResultTrace, Operation, OperationCollector, OperationList, QuantumOp, + NamedResultTrace, Operation, OperationCollector, OperationList, QuantumOp, TraceMetadata, }; /// Type alias for the quantum executor callback diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index af3978717..9a6bb8224 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -24,7 +24,8 @@ use pecos_engines::{ ByteMessage, ByteMessageBuilder, ClassicalEngine, ControlEngine, Engine, EngineStage, }; use pecos_qis_ffi_types::{ - NamedResultTrace, Operation, OperationCollector as OperationList, QuantumOp, + LoweredQuantumOp, NamedResultTrace, Operation, OperationCollector as OperationList, QuantumOp, + TraceMetadata, }; use pecos_random::PecosRng; use std::collections::{BTreeMap, BTreeSet}; @@ -45,6 +46,7 @@ pub struct LoweredQuantumGateTrace { pub params: Vec, pub qubits: Vec, pub measurement_result_ids: Vec, + pub metadata: TraceMetadata, } /// One traced batch of QIS operations and their lowered simulator commands. @@ -70,6 +72,12 @@ pub type OperationTraceStore = Arc>>; /// Result from worker thread - returns both the operations and the interface type WorkerResult = Result<(OperationList, BoxedInterface), String>; +/// Simulator commands plus one metadata record per lowered quantum gate. +struct LoweredCommandBatch { + commands: ByteMessage, + gate_metadata: Vec, +} + /// State for dynamic circuit execution /// /// The LLVM program runs in a worker thread. When it needs a measurement result, @@ -546,17 +554,33 @@ impl QisEngine { /// by `sim()` operate on a fixed physical qubit pool, so we must honor /// `AllocateQubit`/`ReleaseQubit` and remap program handles back onto reusable /// physical slots before sending the quantum ops downstream. - fn operations_to_bytemessage(&mut self, ops: &[Operation]) -> Result { + fn push_gate_metadata( + gate_metadata: &mut Vec, + pending_metadata: &mut TraceMetadata, + ) { + gate_metadata.push(std::mem::take(pending_metadata)); + } + + fn operations_to_lowered_commands( + &mut self, + ops: &[Operation], + ) -> Result { let mut builder = std::mem::take(&mut self.command_builder); builder.reset(); self.measurement_mapping.clear(); + let mut gate_metadata = Vec::new(); + let mut pending_metadata = TraceMetadata::new(); let result = (|| -> Result<(), PecosError> { for op in ops { match op { + Operation::TraceMetadata { metadata } => { + pending_metadata.extend(metadata.clone()); + } Operation::AllocateQubit { id } => { let slot = self.allocate_qubit_slot(*id)?; builder.pz(&[slot]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } Operation::ReleaseQubit { id } => { self.release_qubit_slot(*id); @@ -567,45 +591,56 @@ impl QisEngine { Operation::Quantum(qop) => match qop { QuantumOp::H(qubit) => { builder.h(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::X(qubit) => { builder.x(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Y(qubit) => { builder.y(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Z(qubit) => { builder.z(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::S(qubit) => { builder.sz(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Sdg(qubit) => { builder.szdg(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::T(qubit) => { builder.t(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Tdg(qubit) => { builder.tdg(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RX(angle, qubit) => { builder.rx( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RY(angle, qubit) => { builder.ry( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RZ(angle, qubit) => { builder.rz( Angle64::from_radians(*angle), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RXY(theta, phi, qubit) => { builder.r1xy( @@ -613,25 +648,30 @@ impl QisEngine { Angle64::from_radians(*phi), &[self.mapped_qubit(*qubit, qop)?], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Idle(duration, qubit) => { builder.idle(*duration, &[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::CX(control, target) => { builder.cx(&[( self.mapped_qubit(*control, qop)?, self.mapped_qubit(*target, qop)?, )]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Measure(qubit, result_id) => { self.measurement_mapping.push(*result_id); builder.mz(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[( self.mapped_qubit(*qubit1, qop)?, self.mapped_qubit(*qubit2, qop)?, )]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::RZZ(angle, qubit1, qubit2) => { builder.rzz( @@ -641,9 +681,11 @@ impl QisEngine { self.mapped_qubit(*qubit2, qop)?, )], ); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } QuantumOp::Reset(qubit) => { builder.pz(&[self.mapped_qubit(*qubit, qop)?]); + Self::push_gate_metadata(&mut gate_metadata, &mut pending_metadata); } _ => { return Err(PecosError::Generic(format!( @@ -654,10 +696,18 @@ impl QisEngine { } } + if !pending_metadata.is_empty() { + warn!( + "QIS operation trace metadata was not followed by a lowerable quantum operation" + ); + } Ok(()) })(); - let message = result.map(|()| builder.build()); + let message = result.map(|()| LoweredCommandBatch { + commands: builder.build(), + gate_metadata, + }); self.command_builder = builder; message } @@ -667,68 +717,80 @@ impl QisEngine { /// Selene runtime plugins can opt in to lowering so their scheduler sees /// the same operation stream that Selene would receive. Other runtimes keep /// using PECOS's direct QIS lowering path. - fn lower_operations_to_bytemessage( + fn lower_operations_to_commands( &mut self, ops: &[Operation], - ) -> Result { + ) -> Result { if self.runtime.supports_operation_lowering() { let lowered_ops = self .runtime - .lower_operations(ops) + .lower_operations_with_metadata(ops) .map_err(|e| PecosError::Generic(format!("Runtime lowering error: {e}")))?; - return self.quantum_ops_to_bytemessage(lowered_ops); + return self.quantum_ops_to_lowered_commands(lowered_ops); } - self.operations_to_bytemessage(ops) + self.operations_to_lowered_commands(ops) } /// Convert already-materialized quantum ops into a `ByteMessage`. /// /// This path is used by runtimes that already present qubit ids in the fixed /// simulator space, so no allocate/release remapping is needed. - fn quantum_ops_to_bytemessage( + fn quantum_ops_to_lowered_commands( &mut self, - ops: Vec, - ) -> Result { + ops: Vec, + ) -> Result { let mut builder = std::mem::take(&mut self.command_builder); builder.reset(); self.measurement_mapping.clear(); + let mut gate_metadata = Vec::new(); let result = (|| -> Result<(), PecosError> { - for op in ops { + for LoweredQuantumOp { op, metadata } in ops { match op { QuantumOp::H(qubit) => { builder.h(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::X(qubit) => { builder.x(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Y(qubit) => { builder.y(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Z(qubit) => { builder.z(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::S(qubit) => { builder.sz(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Sdg(qubit) => { builder.szdg(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::T(qubit) => { builder.t(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::Tdg(qubit) => { builder.tdg(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::RX(angle, qubit) => { builder.rx(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RY(angle, qubit) => { builder.ry(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RZ(angle, qubit) => { builder.rz(Angle64::from_radians(angle), &[qubit]); + gate_metadata.push(metadata); } QuantumOp::RXY(theta, phi, qubit) => { builder.r1xy( @@ -736,25 +798,32 @@ impl QisEngine { Angle64::from_radians(phi), &[qubit], ); + gate_metadata.push(metadata); } QuantumOp::Idle(duration, qubit) => { builder.idle(duration, &[qubit]); + gate_metadata.push(metadata); } QuantumOp::CX(control, target) => { builder.cx(&[(control, target)]); + gate_metadata.push(metadata); } QuantumOp::Measure(qubit, result_id) => { self.measurement_mapping.push(result_id); builder.mz(&[qubit]); + gate_metadata.push(metadata); } QuantumOp::ZZ(qubit1, qubit2) => { builder.szz(&[(qubit1, qubit2)]); + gate_metadata.push(metadata); } QuantumOp::RZZ(angle, qubit1, qubit2) => { builder.rzz(Angle64::from_radians(angle), &[(qubit1, qubit2)]); + gate_metadata.push(metadata); } QuantumOp::Reset(qubit) => { builder.pz(&[qubit]); + gate_metadata.push(metadata); } _ => { return Err(PecosError::Generic(format!( @@ -767,10 +836,21 @@ impl QisEngine { Ok(()) })(); - let message = result.map(|()| builder.build()); + let message = result.map(|()| LoweredCommandBatch { + commands: builder.build(), + gate_metadata, + }); self.command_builder = builder; message } + + fn quantum_ops_to_bytemessage( + &mut self, + ops: Vec, + ) -> Result { + self.quantum_ops_to_lowered_commands(ops.into_iter().map(LoweredQuantumOp::from).collect()) + .map(|lowered| lowered.commands) + } } impl Clone for QisEngine { @@ -845,12 +925,20 @@ impl QisEngine { fn lowered_quantum_ops_trace( commands: &ByteMessage, measurement_mapping: &[usize], + gate_metadata: &[TraceMetadata], ) -> Vec { match commands.quantum_ops() { Ok(gates) => { let mut measurement_cursor = 0usize; let mut traces = Vec::with_capacity(gates.len()); - for gate in gates { + if gate_metadata.len() != gates.len() { + warn!( + "Lowered operation trace has {} metadata record(s) for {} gate(s)", + gate_metadata.len(), + gates.len() + ); + } + for (gate_index, gate) in gates.iter().enumerate() { let gate_type = gate.gate_type.to_string(); let qubits = gate .qubits @@ -882,6 +970,7 @@ impl QisEngine { params: gate.params.iter().copied().collect::>(), qubits, measurement_result_ids, + metadata: gate_metadata.get(gate_index).cloned().unwrap_or_default(), }); } if measurement_cursor != measurement_mapping.len() { @@ -905,14 +994,20 @@ impl QisEngine { stage: &str, ops: &[Operation], waiting_for_result_id: Option, - lowered_quantum_ops: Option<&ByteMessage>, + lowered_quantum_ops: Option<&LoweredCommandBatch>, ) { if self.operation_trace_dir.is_none() && self.operation_trace_collector.is_none() { return; } let lowered_trace = lowered_quantum_ops - .map(|commands| Self::lowered_quantum_ops_trace(commands, &self.measurement_mapping)) + .map(|lowered| { + Self::lowered_quantum_ops_trace( + &lowered.commands, + &self.measurement_mapping, + &lowered.gate_metadata, + ) + }) .unwrap_or_default(); let file_name = format!( "engine_{:04}_shot_{:06}_chunk_{:04}_{}.json", @@ -1517,14 +1612,14 @@ impl ControlEngine for QisEngine { // Track how many operations we're sending for simulation self.simulated_op_count = ops.len(); if !ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&ops)?; + let lowered = self.lower_operations_to_commands(&ops)?; self.trace_operations_chunk( "pending_start", &ops, Some(result_id), - Some(&commands), + Some(&lowered), ); - return Ok(EngineStage::NeedsProcessing(commands)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } } @@ -1536,9 +1631,9 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } self.trace_named_result_traces_from_dynamic_handle(); @@ -1583,9 +1678,9 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } self.trace_named_result_traces_from_dynamic_handle(); @@ -1628,14 +1723,14 @@ impl ControlEngine for QisEngine { if let Some(ops) = self.get_dynamic_operations() { self.simulated_op_count += ops.len(); if !ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&ops)?; + let lowered = self.lower_operations_to_commands(&ops)?; self.trace_operations_chunk( "pending_continue", &ops, Some(result_id), - Some(&commands), + Some(&lowered), ); - return Ok(EngineStage::NeedsProcessing(commands)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } } @@ -1648,9 +1743,9 @@ impl ControlEngine for QisEngine { if !self.pending_dynamic_ops.is_empty() { let final_ops = std::mem::take(&mut self.pending_dynamic_ops); if !final_ops.is_empty() { - let commands = self.lower_operations_to_bytemessage(&final_ops)?; - self.trace_operations_chunk("pending_final", &final_ops, None, Some(&commands)); - return Ok(EngineStage::NeedsProcessing(commands)); + let lowered = self.lower_operations_to_commands(&final_ops)?; + self.trace_operations_chunk("pending_final", &final_ops, None, Some(&lowered)); + return Ok(EngineStage::NeedsProcessing(lowered.commands)); } } self.trace_named_result_traces_from_dynamic_handle(); @@ -1733,10 +1828,10 @@ mod tests { QuantumOp::Idle(20e-9, 0).into(), QuantumOp::Measure(0, 7).into(), ]; - let commands = engine - .operations_to_bytemessage(&ops) - .expect("convert ops to bytemessage"); - engine.trace_operations_chunk("unit_test", &ops, Some(7), Some(&commands)); + let lowered = engine + .operations_to_lowered_commands(&ops) + .expect("convert ops to lowered commands"); + engine.trace_operations_chunk("unit_test", &ops, Some(7), Some(&lowered)); let mut trace_files = std::fs::read_dir(temp_dir.path()) .expect("read trace dir") @@ -1758,6 +1853,10 @@ mod tests { assert_eq!(value["operations"][1]["Quantum"]["H"], 0); assert_eq!(value["operations"][2]["Quantum"]["Idle"][0], 20e-9); assert_eq!(value["lowered_quantum_ops"][0]["gate_type"], "PZ"); + assert_eq!( + value["lowered_quantum_ops"][0]["metadata"], + serde_json::json!({}) + ); assert_eq!(value["lowered_quantum_ops"][1]["gate_type"], "H"); assert_eq!(value["lowered_quantum_ops"][2]["gate_type"], "Idle"); assert_eq!(value["lowered_quantum_ops"][2]["params"][0], 20e-9); @@ -1779,6 +1878,43 @@ mod tests { ); } + #[test] + fn test_direct_lowering_attaches_trace_metadata_to_next_gate() { + let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); + let collector: OperationTraceStore = Arc::new(Mutex::new(Vec::new())); + engine.set_operation_trace_collector(collector.clone()); + engine.begin_trace_shot(); + + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "szz_physical_prefix:H:X0:q0".to_string(), + ); + metadata.insert("source_kind".to_string(), "szz_prefix".to_string()); + let ops = vec![ + Operation::TraceMetadata { metadata }, + QuantumOp::H(0).into(), + QuantumOp::Measure(0, 7).into(), + ]; + + let lowered = engine + .operations_to_lowered_commands(&ops) + .expect("convert ops to lowered commands"); + engine.trace_operations_chunk("unit_test", &ops, None, Some(&lowered)); + + let in_memory = collector.lock().expect("collector lock"); + assert_eq!(in_memory.len(), 1); + assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "H"); + assert_eq!( + in_memory[0].lowered_quantum_ops[0] + .metadata + .get("source_label"), + Some(&"szz_physical_prefix:H:X0:q0".to_string()) + ); + assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "MZ"); + assert!(in_memory[0].lowered_quantum_ops[1].metadata.is_empty()); + } + #[derive(Clone, Default)] struct IdleLoweringRuntime { state: ClassicalState, @@ -1827,6 +1963,19 @@ mod tests { QuantumOp::Measure(0, 17), ]) } + + fn lower_operations_with_metadata( + &mut self, + _operations: &[Operation], + ) -> RuntimeResult> { + let mut idle_metadata = TraceMetadata::new(); + idle_metadata.insert("runtime_stage".to_string(), "scheduled_idle".to_string()); + Ok(vec![ + LoweredQuantumOp::new(QuantumOp::Idle(20e-9, 0), idle_metadata), + QuantumOp::H(0).into(), + QuantumOp::Measure(0, 17).into(), + ]) + } } #[test] @@ -1837,16 +1986,22 @@ mod tests { engine.begin_trace_shot(); let ops = vec![QuantumOp::H(0).into()]; - let commands = engine - .lower_operations_to_bytemessage(&ops) - .expect("runtime lower ops to bytemessage"); - engine.trace_operations_chunk("unit_test", &ops, None, Some(&commands)); + let lowered = engine + .lower_operations_to_commands(&ops) + .expect("runtime lower ops to commands"); + engine.trace_operations_chunk("unit_test", &ops, None, Some(&lowered)); let in_memory = collector.lock().expect("collector lock"); assert_eq!(in_memory.len(), 1); assert_eq!(in_memory[0].lowered_quantum_ops[0].gate_type, "Idle"); assert_eq!(in_memory[0].lowered_quantum_ops[0].params, vec![20e-9]); assert_eq!(in_memory[0].lowered_quantum_ops[0].qubits, vec![0]); + assert_eq!( + in_memory[0].lowered_quantum_ops[0] + .metadata + .get("runtime_stage"), + Some(&"scheduled_idle".to_string()) + ); assert_eq!(in_memory[0].lowered_quantum_ops[1].gate_type, "H"); assert_eq!(in_memory[0].lowered_quantum_ops[2].gate_type, "MZ"); assert_eq!( @@ -1860,11 +2015,14 @@ mod tests { let mut engine = QisEngine::with_runtime(Box::new(DummyRuntime::default())); let ops = vec![QuantumOp::H(0).into(), QuantumOp::Measure(0, 7).into()]; - let commands = engine - .operations_to_bytemessage(&ops) + let lowered_commands = engine + .operations_to_lowered_commands(&ops) .expect("convert ops with implicit static handles"); - let lowered = commands.quantum_ops().expect("parse lowered commands"); + let lowered = lowered_commands + .commands + .quantum_ops() + .expect("parse lowered commands"); assert_eq!(lowered.len(), 2); assert_eq!(lowered[0].gate_type.to_string(), "H"); assert_eq!(lowered[0].qubits.as_slice(), &[pecos_core::QubitId(0)]); @@ -1882,7 +2040,7 @@ mod tests { QuantumOp::X(0).into(), ]; - let Err(err) = engine.operations_to_bytemessage(&ops) else { + let Err(err) = engine.operations_to_lowered_commands(&ops) else { panic!("released qubit reuse should error"); }; @@ -1902,11 +2060,14 @@ mod tests { QuantumOp::CX(81, 105).into(), ]; - let commands = engine - .operations_to_bytemessage(&ops) + let lowered_commands = engine + .operations_to_lowered_commands(&ops) .expect("sparse handles should map onto live physical slots"); - let lowered = commands.quantum_ops().expect("parse lowered commands"); + let lowered = lowered_commands + .commands + .quantum_ops() + .expect("parse lowered commands"); assert_eq!(lowered[0].qubits.as_slice(), &[pecos_core::QubitId(0)]); assert_eq!(lowered[1].qubits.as_slice(), &[pecos_core::QubitId(1)]); assert_eq!( @@ -1926,7 +2087,7 @@ mod tests { Operation::AllocateQubit { id: 105 }, ]; - let Err(err) = engine.operations_to_bytemessage(&ops) else { + let Err(err) = engine.operations_to_lowered_commands(&ops) else { panic!("allocating beyond the physical qubit hint should error"); }; diff --git a/crates/pecos-qis/src/runtime.rs b/crates/pecos-qis/src/runtime.rs index 961e16ede..20d4ef664 100644 --- a/crates/pecos-qis/src/runtime.rs +++ b/crates/pecos-qis/src/runtime.rs @@ -11,7 +11,7 @@ //! doesn't perform quantum simulation but manages program execution flow. use log::trace; -use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; +use pecos_qis_ffi_types::{LoweredQuantumOp, Operation, OperationCollector, QuantumOp}; use std::collections::BTreeMap; /// Result type for runtime operations @@ -242,6 +242,22 @@ pub trait QisRuntime: Send + Sync + dyn_clone::DynClone { )) } + /// Lower freshly collected program operations through the runtime with provenance. + /// + /// Runtimes that can preserve source/scheduler metadata should override this + /// method. The default preserves the existing `lower_operations` behavior and + /// attaches empty metadata to every lowered operation. + /// + /// # Errors + /// Returns an error if the runtime cannot accept or lower the operations. + fn lower_operations_with_metadata( + &mut self, + operations: &[Operation], + ) -> Result> { + self.lower_operations(operations) + .map(|ops| ops.into_iter().map(LoweredQuantumOp::from).collect()) + } + /// Check if the runtime needs to re-execute with known measurements /// /// This is set to true after measurements are provided for programs diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index f88ed9a1e..f5898ec91 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -599,6 +599,10 @@ impl SeleneRuntime { // The actual result mapping is handled by the runtime's results collection self.current_op_index += 1; } + Operation::TraceMetadata { .. } => { + trace!("Trace metadata encountered"); + self.current_op_index += 1; + } Operation::Barrier => { trace!("Barrier encountered"); // Barriers don't produce quantum ops but can break batches @@ -875,7 +879,9 @@ impl SeleneRuntime { self.runtime_qfree(runtime_qubit)?; } } - Operation::RecordOutput { .. } | Operation::Barrier => {} + Operation::RecordOutput { .. } + | Operation::TraceMetadata { .. } + | Operation::Barrier => {} Operation::Quantum(qop) => self.submit_quantum_op_to_runtime(qop, lowered_ops)?, } @@ -1218,6 +1224,7 @@ fn operation_capacity_with_mode( Operation::RecordOutput { result_id, .. } => { include_result(&mut num_results, *result_id); } + Operation::TraceMetadata { .. } => {} Operation::Barrier => {} } } diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index b76f9ff13..b7db9f396 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1124,6 +1124,24 @@ def _gate_triples(qubits: list[int], gate_type: str) -> list[tuple[int, int, int return [(qubits[i], qubits[i + 1], qubits[i + 2]) for i in range(0, len(qubits), 3)] +def _lowered_gate_metadata(gate: Mapping[str, Any]) -> dict[str, Any]: + """Return validated runtime/source metadata for a lowered trace gate.""" + metadata = gate.get("metadata") + if metadata is None: + return {} + if not isinstance(metadata, Mapping): + msg = f"Lowered gate metadata must be an object, got {metadata!r}" + raise ValueError(msg) + return {str(key): value for key, value in metadata.items()} + + +def _set_lowered_gate_metadata(tick: Any, metadata: Mapping[str, Any]) -> None: + """Attach lowered trace metadata to the gate most recently added to ``tick``.""" + if not metadata: + return + tick.metas(metadata) + + def _replay_lowered_qis_trace_into_tick_circuit( chunks: list[dict[str, Any]], *, @@ -1153,6 +1171,7 @@ def _replay_lowered_qis_trace_into_tick_circuit( qubits = [int(q) for q in gate.get("qubits", [])] angles = [float(theta) for theta in gate.get("angles", [])] params = [float(param) for param in gate.get("params", [])] + metadata = _lowered_gate_metadata(gate) tick = tick_circuit.tick() if gate_type == "H": @@ -1235,6 +1254,7 @@ def _replay_lowered_qis_trace_into_tick_circuit( else: msg = f"Unsupported lowered traced gate {gate_type!r}" raise ValueError(msg) + _set_lowered_gate_metadata(tick, metadata) # Compact: ASAP-schedule gates into minimal ticks tick_circuit.compact_ticks() diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 93af19a64..22d9a43b0 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -230,6 +230,31 @@ def test_lowered_replay_preserves_runtime_idles() -> None: assert _flat_idle_gates(tc) == [([0], 20.0)] +def test_lowered_replay_preserves_gate_metadata() -> None: + chunks = [ + { + "operations": [{"Quantum": {"H": 0}}], + "lowered_quantum_ops": [ + { + "gate_type": "H", + "qubits": [0], + "angles": [], + "params": [], + "metadata": { + "source_label": "szz_physical_prefix:H:X0:q0", + "source_kind": "szz_prefix", + }, + }, + ], + }, + ] + + tc = _replay_lowered_qis_trace_into_tick_circuit(chunks) + + assert tc.get_gate_meta(0, 0, "source_label") == "szz_physical_prefix:H:X0:q0" + assert tc.get_gate_meta(0, 0, "source_kind") == "szz_prefix" + + def test_lowered_replay_preserves_measurement_crosstalk_payloads() -> None: chunks = [ { From 38fb2534bea290f624a92f605733ad5ae36134fc Mon Sep 17 00:00:00 2001 From: ciaranra Date: Wed, 24 Jun 2026 23:13:14 -0600 Subject: [PATCH 257/388] Add SZZ source metadata plumbing --- crates/pecos-hugr-qis/src/compiler.rs | 53 +++++++++++++ crates/pecos-qis-ffi/src/ffi.rs | 65 ++++++++++++++++ .../quantum-pecos/src/pecos/guppy/surface.py | 76 +++++++++++++++++-- .../tests/guppy/test_hugr_to_llvm_parsing.py | 21 +++++ .../tests/qec/surface/test_check_plan.py | 4 + 5 files changed, 214 insertions(+), 5 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 91bf4eb0d..857adeb07 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -66,6 +66,8 @@ use crate::utils::read_hugr_envelope; const LLVM_MAIN: &str = "qmain"; const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; +const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; +const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; // Extension registry is defined in the parent module @@ -389,6 +391,56 @@ fn compile<'c, 'hugr: 'c>( Ok(module) } +fn is_llvm_symbol_char(ch: char) -> bool { + ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '$' | '-') +} + +/// Normalize PECOS-owned helper declarations that Guppy/HUGR lowers under a +/// private `__hugr__.*` symbol name. +/// +/// These helpers are part of PECOS's runtime ABI, not ordinary user functions, +/// so they need stable external symbols for dynamic linking. Keep this rewrite +/// deliberately narrow: only the metadata helper declaration receives this +/// treatment. +fn normalize_pecos_helper_symbols_in_llvm(mut llvm_ir: String) -> String { + let private_prefix = format!("@{HUGR_SYMBOL_PREFIX}{TRACE_METADATA_HUGR_SYMBOL}."); + let public_symbol = format!("@{TRACE_METADATA_HUGR_SYMBOL}"); + + while let Some(start) = llvm_ir.find(&private_prefix) { + let suffix_start = start + private_prefix.len(); + let suffix_len = llvm_ir[suffix_start..] + .chars() + .take_while(|ch| is_llvm_symbol_char(*ch)) + .map(char::len_utf8) + .sum::(); + if suffix_len == 0 { + break; + } + llvm_ir.replace_range(start..suffix_start + suffix_len, &public_symbol); + } + + llvm_ir +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_trace_metadata_helper_symbol() { + let llvm = concat!( + "declare void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8*, i8*)\n", + "call void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8* %0, i8* %1)\n", + "call void @__hugr__.other_helper.16()\n", + ) + .to_string(); + let normalized = normalize_pecos_helper_symbols_in_llvm(llvm); + assert!(normalized.contains("declare void @pecos_qis_trace_metadata_hugr(i8*, i8*)")); + assert!(normalized.contains("call void @pecos_qis_trace_metadata_hugr(i8* %0, i8* %1)")); + assert!(normalized.contains("@__hugr__.other_helper.16")); + } +} + /// Compile HUGR bytes to LLVM IR string /// /// This is the main entry point for the compiler. @@ -422,6 +474,7 @@ pub fn compile_hugr_bytes_to_string_with_options( // Get the module string let mut llvm_str = module.to_string(); + llvm_str = normalize_pecos_helper_symbols_in_llvm(llvm_str); // Workaround: Manually add the EntryPoint attribute if it's missing // This is needed because inkwell sometimes doesn't properly serialize string attributes diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 30e5035e4..38630036f 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -445,6 +445,46 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata( queue_trace_metadata(key, value); } +/// Attach source/runtime metadata to the next lowerable quantum operation. +/// +/// This variant matches the HUGR lowering ABI for Guppy string arguments: each +/// argument is passed as a pointer to a tket2 string payload whose first byte is +/// the string length. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs. Invalid +/// pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_hugr(key_ptr: *const u8, value_ptr: *const u8) { + if key_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_hugr: null key pointer"); + return; + } + if value_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_hugr: null value pointer"); + return; + } + + let key_len = i64::from(unsafe { *key_ptr }); + let value_len = i64::from(unsafe { *value_ptr }); + let Some(key) = + (unsafe { read_tket_string_arg("pecos_qis_trace_metadata_hugr", "key", key_ptr, key_len) }) + else { + return; + }; + let Some(value) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_hugr", + "value", + value_ptr, + value_len, + ) + }) else { + return; + }; + queue_trace_metadata(key, value); +} + /// Attach source/runtime metadata to the next lowerable quantum operation. /// /// This variant uses direct string data pointers instead of the tket2 string @@ -1486,6 +1526,31 @@ mod tests { }); } + #[test] + fn test_trace_metadata_hugr_string_layout() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + unsafe { + pecos_qis_trace_metadata_hugr(key.as_ptr(), value.as_ptr()); + } + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + // --- Measurement and reset tests --- #[test] diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index be0bde3af..a05d94033 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -367,6 +367,16 @@ def generate_guppy_source( if twirl is not None: lines.extend(_render_inline_pcg32()) + if interaction_basis == "szz": + lines.extend( + [ + "@guppy.declare", + "def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ...", + "", + "", + ], + ) + # Generate struct definitions. lines.extend( [ @@ -505,7 +515,34 @@ def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: msg = f"unsupported Pauli axis {axis!r}" raise ValueError(msg) - def _append_szz_flow_gate(target: list[str], indent: str, op_type: OpType, qubit_expr: str) -> None: + def _append_szz_trace_metadata(target: list[str], indent: str, key: str, value: str) -> None: + target.append(f'{indent}pecos_qis_trace_metadata_hugr("{key}", "{value}")') + + def _append_szz_gate_trace_metadata( + target: list[str], + indent: str, + *, + source_kind: str, + source_label: str, + host_label: str | None = None, + gate: OpType | None = None, + ) -> None: + _append_szz_trace_metadata(target, indent, "source_kind", source_kind) + _append_szz_trace_metadata(target, indent, "source_label", source_label) + if host_label is not None: + _append_szz_trace_metadata(target, indent, "szz_host_label", host_label) + if gate is not None: + _append_szz_trace_metadata(target, indent, "source_gate", gate.name) + + def _append_szz_flow_gate( + target: list[str], + indent: str, + op_type: OpType, + qubit_expr: str, + *, + source_label: str | None = None, + host_label: str | None = None, + ) -> None: op_name = { OpType.H: "h", OpType.SX: "v", @@ -518,6 +555,15 @@ def _append_szz_flow_gate(target: list[str], indent: str, op_type: OpType, qubit if op_name is None: msg = f"unsupported Guppy SZZ forward-flow gate {op_type.name}" raise ValueError(msg) + if source_label is not None: + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_data_prefix", + source_label=source_label, + host_label=host_label, + gate=op_type, + ) target.append(f"{indent}{op_name}({qubit_expr})") _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} @@ -654,15 +700,26 @@ def compose_data(data_q: int, gates: tuple[OpType, ...]) -> None: pending = _szz_flow_compose_pending_gate(pending, gate) pending_by_data[data_q] = pending - def discharge_data_for_szz(data_q: int) -> None: + def discharge_data_for_szz( + data_q: int, + *, + host_label: str, + ) -> None: if pending_by_data is None: return pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) if pending == _SZZ_FLOW_IDENTITY or _szz_flow_is_virtual_z(pending): return prefix = _szz_guppy_prefix_gates_for_pending(pending) - for gate in prefix: - _append_szz_flow_gate(target, indent, gate, data_expr(data_q)) + for prefix_idx, gate in enumerate(prefix): + _append_szz_flow_gate( + target, + indent, + gate, + data_expr(data_q), + source_label=f"{host_label}:prefix:{prefix_idx}:{gate.name}", + host_label=host_label, + ) pending_by_data[data_q] = _SZZ_FLOW_IDENTITY target.append("") @@ -688,9 +745,18 @@ def discharge_data_for_szz(data_q: int) -> None: f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, " f"{data_expr(data_q)})", ) - discharge_data_for_szz(data_q) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG + host_label = f"szz:r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + discharge_data_for_szz(data_q, host_label=host_label) half_turns = "0.5" if sign > 0 else "-0.5" + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_host", + source_label=host_label, + gate=host_gate, + ) target.append( f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " f"zz_phase({ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)}, angle({half_turns}))", diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index b4b27d46f..4861ebb45 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -61,3 +61,24 @@ def hadamard_test() -> bool: assert "@___qalloc()" in llvm_ir, "Should have Selene qubit allocation" assert "@___rxy" in llvm_ir or "@___rz" in llvm_ir, "Should have Selene rotation gates for H" assert "@___lazy_measure" in llvm_ir, "Should have Selene measurement" + + +def test_trace_metadata_helper_uses_public_symbol() -> None: + """Test that declared trace metadata helpers compile to the public FFI symbol.""" + try: + from guppylang import guppy + from pecos_rslib import compile_hugr_to_qis + except ImportError as e: + pytest.skip(f"Required imports not available: {e}") + + @guppy.declare + def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ... + + @guppy + def metadata_probe() -> None: + pecos_qis_trace_metadata_hugr("source_kind", "szz_host") + + llvm_ir = compile_hugr_to_qis(metadata_probe.compile().to_bytes()) + + assert "@pecos_qis_trace_metadata_hugr" in llvm_ir + assert "@__hugr__.pecos_qis_trace_metadata_hugr" not in llvm_ir diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 543950b00..1a057ddd4 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -304,6 +304,10 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None guppy_source = generate_guppy_from_patch(patch, check_plan="szz_current_v1") assert "Check plan: szz_current_v1" in guppy_source + assert "def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ..." in guppy_source + assert 'pecos_qis_trace_metadata_hugr("source_kind", "szz_host")' in guppy_source + assert 'pecos_qis_trace_metadata_hugr("source_kind", "szz_data_prefix")' in guppy_source + assert 'pecos_qis_trace_metadata_hugr("szz_host_label", "szz:' in guppy_source def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: From eda0cd8fb3a895aa3ad2cf30ee9a4313c2d34c19 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 02:07:00 -0600 Subject: [PATCH 258/388] Preserve qubit-scoped SZZ trace metadata --- crates/pecos-hugr-qis/src/compiler.rs | 60 +- crates/pecos-qis-ffi-types/src/operations.rs | 11 +- crates/pecos-qis-ffi/src/ffi.rs | 94 ++- crates/pecos-qis/src/ccengine.rs | 7 +- crates/pecos-qis/src/selene_runtime.rs | 788 +++++++++++++++++- .../quantum-pecos/src/pecos/guppy/surface.py | 39 +- .../tests/guppy/test_hugr_to_llvm_parsing.py | 13 +- .../tests/qec/surface/test_check_plan.py | 10 +- .../tests/qec/test_from_guppy_dem.py | 35 +- 9 files changed, 975 insertions(+), 82 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 857adeb07..733d49292 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -68,6 +68,7 @@ const LLVM_MAIN: &str = "qmain"; const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; +const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_hugr"; // Extension registry is defined in the parent module @@ -402,24 +403,50 @@ fn is_llvm_symbol_char(ch: char) -> bool { /// so they need stable external symbols for dynamic linking. Keep this rewrite /// deliberately narrow: only the metadata helper declaration receives this /// treatment. -fn normalize_pecos_helper_symbols_in_llvm(mut llvm_ir: String) -> String { - let private_prefix = format!("@{HUGR_SYMBOL_PREFIX}{TRACE_METADATA_HUGR_SYMBOL}."); - let public_symbol = format!("@{TRACE_METADATA_HUGR_SYMBOL}"); +fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { + let helper_symbols = [TRACE_METADATA_HUGR_SYMBOL, TRACE_METADATA_QUBIT_HUGR_SYMBOL]; + let mut normalized = String::with_capacity(llvm_ir.len()); + let mut cursor = 0; - while let Some(start) = llvm_ir.find(&private_prefix) { - let suffix_start = start + private_prefix.len(); - let suffix_len = llvm_ir[suffix_start..] + while let Some(relative_start) = llvm_ir[cursor..].find('@') { + let start = cursor + relative_start; + normalized.push_str(&llvm_ir[cursor..start]); + + let symbol_start = start + 1; + let symbol_len = llvm_ir[symbol_start..] .chars() .take_while(|ch| is_llvm_symbol_char(*ch)) .map(char::len_utf8) .sum::(); - if suffix_len == 0 { - break; + if symbol_len == 0 { + normalized.push('@'); + cursor = symbol_start; + continue; } - llvm_ir.replace_range(start..suffix_start + suffix_len, &public_symbol); + + let symbol_end = symbol_start + symbol_len; + let symbol = &llvm_ir[symbol_start..symbol_end]; + if let Some(rest) = symbol.strip_prefix(HUGR_SYMBOL_PREFIX) { + let mut parts = rest.rsplit('.'); + let suffix = parts.next(); + let helper_name = parts.next(); + if let (Some(_suffix), Some(helper_name)) = (suffix, helper_name) { + if helper_symbols.iter().any(|helper| helper == &helper_name) { + normalized.push('@'); + normalized.push_str(helper_name); + cursor = symbol_end; + continue; + } + } + } + + normalized.push('@'); + normalized.push_str(symbol); + cursor = symbol_end; } - llvm_ir + normalized.push_str(&llvm_ir[cursor..]); + normalized } #[cfg(test)] @@ -431,12 +458,25 @@ mod tests { let llvm = concat!( "declare void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8*, i8*)\n", "call void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8* %0, i8* %1)\n", + "call void @__hugr__.__main__.pecos_qis_trace_metadata_hugr.18(i8* %0, i8* %1)\n", + "declare i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64, i8*, i8*)\n", + "%q = call i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64 %0, i8* %1, i8* %2)\n", + "%q2 = call i64 @__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21(i64 %0, i8* %1, i8* %2)\n", "call void @__hugr__.other_helper.16()\n", ) .to_string(); let normalized = normalize_pecos_helper_symbols_in_llvm(llvm); assert!(normalized.contains("declare void @pecos_qis_trace_metadata_hugr(i8*, i8*)")); assert!(normalized.contains("call void @pecos_qis_trace_metadata_hugr(i8* %0, i8* %1)")); + assert!( + normalized.contains("declare i64 @pecos_qis_trace_metadata_qubit_hugr(i64, i8*, i8*)") + ); + assert!(normalized.contains( + "%q = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" + )); + assert!(normalized.contains( + "%q2 = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" + )); assert!(normalized.contains("@__hugr__.other_helper.16")); } } diff --git a/crates/pecos-qis-ffi-types/src/operations.rs b/crates/pecos-qis-ffi-types/src/operations.rs index 103828414..7cfc5f604 100644 --- a/crates/pecos-qis-ffi-types/src/operations.rs +++ b/crates/pecos-qis-ffi-types/src/operations.rs @@ -30,7 +30,16 @@ pub enum Operation { /// Runtimes may preserve this metadata when lowering, scheduling, or expanding /// operations. PECOS's direct lowering path attaches it to the next emitted /// simulator gate and then clears it. - TraceMetadata { metadata: TraceMetadata }, + TraceMetadata { + metadata: TraceMetadata, + /// Optional source qubit that owns this metadata. + /// + /// Runtime lowering uses this to wait for the next compatible source + /// operation touching the same qubit, which is stricter than attaching + /// metadata to the next operation in global program order. + #[serde(default)] + qubit: Option, + }, /// Allocate a qubit AllocateQubit { id: usize }, diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 38630036f..3583705d1 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -406,11 +406,11 @@ pub unsafe extern "C" fn __quantum__rt__record(data: *const std::ffi::c_char) { } } -fn queue_trace_metadata(key: String, value: String) { +fn queue_trace_metadata(key: String, value: String, qubit: Option) { let mut metadata = TraceMetadata::new(); metadata.insert(key, value); with_interface(|interface| { - interface.queue_operation(Operation::TraceMetadata { metadata }); + interface.queue_operation(Operation::TraceMetadata { metadata, qubit }); }); } @@ -442,7 +442,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata( }) else { return; }; - queue_trace_metadata(key, value); + queue_trace_metadata(key, value, None); } /// Attach source/runtime metadata to the next lowerable quantum operation. @@ -482,7 +482,56 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_hugr(key_ptr: *const u8, value }) else { return; }; - queue_trace_metadata(key, value); + queue_trace_metadata(key, value, None); +} + +/// Attach source/runtime metadata to the next operation on a specific qubit. +/// +/// Returning the qubit handle gives Guppy/HUGR a data dependency that preserves +/// the metadata call immediately before the gate it annotates. +/// +/// # Safety +/// The key and value pointers must be valid tket2 string structs. Invalid +/// pointers cause undefined behavior. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_trace_metadata_qubit_hugr( + qubit: i64, + key_ptr: *const u8, + value_ptr: *const u8, +) -> i64 { + if key_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_qubit_hugr: null key pointer"); + return qubit; + } + if value_ptr.is_null() { + log::error!("pecos_qis_trace_metadata_qubit_hugr: null value pointer"); + return qubit; + } + + let key_len = i64::from(unsafe { *key_ptr }); + let value_len = i64::from(unsafe { *value_ptr }); + let Some(key) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_qubit_hugr", + "key", + key_ptr, + key_len, + ) + }) else { + return qubit; + }; + let Some(value) = (unsafe { + read_tket_string_arg( + "pecos_qis_trace_metadata_qubit_hugr", + "value", + value_ptr, + value_len, + ) + }) else { + return qubit; + }; + queue_trace_metadata(key, value, Some(i64_to_usize(qubit))); + qubit } /// Attach source/runtime metadata to the next lowerable quantum operation. @@ -516,7 +565,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_direct( }) else { return; }; - queue_trace_metadata(key, value); + queue_trace_metadata(key, value, None); } // --- Selene-style FFI Functions --- @@ -1491,9 +1540,10 @@ mod tests { with_interface(|iface| { assert_eq!(iface.operations.len(), 1); - let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { panic!("expected trace metadata operation"); }; + assert_eq!(*qubit, None); assert_eq!( metadata.get("source_label").map(String::as_str), Some("szz_prefix:H:data_0") @@ -1516,9 +1566,10 @@ mod tests { with_interface(|iface| { assert_eq!(iface.operations.len(), 1); - let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { panic!("expected trace metadata operation"); }; + assert_eq!(*qubit, None); assert_eq!( metadata.get("source_kind").map(String::as_str), Some("szz_prefix") @@ -1541,9 +1592,36 @@ mod tests { with_interface(|iface| { assert_eq!(iface.operations.len(), 1); - let Operation::TraceMetadata { metadata } = &iface.operations[0] else { + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, None); + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_prefix") + ); + }); + } + + #[test] + fn test_trace_metadata_qubit_hugr_returns_qubit_and_queues_metadata() { + setup_test(); + let key = [ + 11_u8, b's', b'o', b'u', b'r', b'c', b'e', b'_', b'k', b'i', b'n', b'd', + ]; + let value = [ + 10_u8, b's', b'z', b'z', b'_', b'p', b'r', b'e', b'f', b'i', b'x', + ]; + let returned = + unsafe { pecos_qis_trace_metadata_qubit_hugr(17, key.as_ptr(), value.as_ptr()) }; + assert_eq!(returned, 17); + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { panic!("expected trace metadata operation"); }; + assert_eq!(*qubit, Some(17)); assert_eq!( metadata.get("source_kind").map(String::as_str), Some("szz_prefix") diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index 9a6bb8224..f56601b3e 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -574,7 +574,7 @@ impl QisEngine { let result = (|| -> Result<(), PecosError> { for op in ops { match op { - Operation::TraceMetadata { metadata } => { + Operation::TraceMetadata { metadata, .. } => { pending_metadata.extend(metadata.clone()); } Operation::AllocateQubit { id } => { @@ -1892,7 +1892,10 @@ mod tests { ); metadata.insert("source_kind".to_string(), "szz_prefix".to_string()); let ops = vec![ - Operation::TraceMetadata { metadata }, + Operation::TraceMetadata { + metadata, + qubit: None, + }, QuantumOp::H(0).into(), QuantumOp::Measure(0, 7).into(), ]; diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index f5898ec91..3b22f1622 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -5,8 +5,10 @@ use crate::runtime::{ClassicalState, QisRuntime, Result, RuntimeError, Shot}; use log::{debug, trace}; -use pecos_qis_ffi_types::{Operation, OperationCollector, QuantumOp}; -use std::collections::{BTreeMap, BTreeSet}; +use pecos_qis_ffi_types::{ + LoweredQuantumOp, Operation, OperationCollector, QuantumOp, TraceMetadata, +}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::ffi::{CString, c_void}; use std::mem::ManuallyDrop; use std::path::{Path, PathBuf}; @@ -59,6 +61,12 @@ impl RuntimeOperationBatch { } } +#[derive(Debug, Clone)] +struct SourceTraceMetadata { + op: QuantumOp, + metadata: TraceMetadata, +} + #[repr(C)] struct SeleneRuntimeGetOperationInterface { rzz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64, f64), @@ -888,6 +896,49 @@ impl SeleneRuntime { Ok(()) } + fn map_quantum_op_to_runtime_qubits(&mut self, qop: &QuantumOp) -> Result { + let mut map = |qubit: usize| -> Result { + let runtime_qubit = self.runtime_qubit_for_program(qubit)?; + usize::try_from(runtime_qubit).map_err(|_| { + RuntimeError::ExecutionError(format!( + "Runtime qubit id {runtime_qubit} does not fit in usize" + )) + }) + }; + + Ok(match qop { + QuantumOp::H(qubit) => QuantumOp::H(map(*qubit)?), + QuantumOp::X(qubit) => QuantumOp::X(map(*qubit)?), + QuantumOp::Y(qubit) => QuantumOp::Y(map(*qubit)?), + QuantumOp::Z(qubit) => QuantumOp::Z(map(*qubit)?), + QuantumOp::S(qubit) => QuantumOp::S(map(*qubit)?), + QuantumOp::Sdg(qubit) => QuantumOp::Sdg(map(*qubit)?), + QuantumOp::T(qubit) => QuantumOp::T(map(*qubit)?), + QuantumOp::Tdg(qubit) => QuantumOp::Tdg(map(*qubit)?), + QuantumOp::RX(theta, qubit) => QuantumOp::RX(*theta, map(*qubit)?), + QuantumOp::RY(theta, qubit) => QuantumOp::RY(*theta, map(*qubit)?), + QuantumOp::RZ(theta, qubit) => QuantumOp::RZ(*theta, map(*qubit)?), + QuantumOp::RXY(theta, phi, qubit) => QuantumOp::RXY(*theta, *phi, map(*qubit)?), + QuantumOp::Idle(duration, qubit) => QuantumOp::Idle(*duration, map(*qubit)?), + QuantumOp::CX(control, target) => QuantumOp::CX(map(*control)?, map(*target)?), + QuantumOp::CY(control, target) => QuantumOp::CY(map(*control)?, map(*target)?), + QuantumOp::CZ(control, target) => QuantumOp::CZ(map(*control)?, map(*target)?), + QuantumOp::CH(control, target) => QuantumOp::CH(map(*control)?, map(*target)?), + QuantumOp::CRZ(theta, control, target) => { + QuantumOp::CRZ(*theta, map(*control)?, map(*target)?) + } + QuantumOp::CCX(control_1, control_2, target) => { + QuantumOp::CCX(map(*control_1)?, map(*control_2)?, map(*target)?) + } + QuantumOp::ZZ(qubit_1, qubit_2) => QuantumOp::ZZ(map(*qubit_1)?, map(*qubit_2)?), + QuantumOp::RZZ(theta, qubit_1, qubit_2) => { + QuantumOp::RZZ(*theta, map(*qubit_1)?, map(*qubit_2)?) + } + QuantumOp::Measure(qubit, result_id) => QuantumOp::Measure(map(*qubit)?, *result_id), + QuantumOp::Reset(qubit) => QuantumOp::Reset(map(*qubit)?), + }) + } + fn submit_quantum_op_to_runtime( &mut self, qop: &QuantumOp, @@ -919,54 +970,13 @@ impl SeleneRuntime { } _ => { lowered_ops.extend(self.drain_runtime_operations()?); - lowered_ops.push(self.map_passthrough_op_to_runtime_qubits(qop)?); + lowered_ops.push(self.map_quantum_op_to_runtime_qubits(qop)?); } } Ok(()) } - fn map_passthrough_op_to_runtime_qubits(&mut self, qop: &QuantumOp) -> Result { - let mut map = |qubit: usize| -> Result { - let runtime_qubit = self.runtime_qubit_for_program(qubit)?; - usize::try_from(runtime_qubit).map_err(|_| { - RuntimeError::ExecutionError(format!( - "Runtime qubit id {runtime_qubit} does not fit in usize" - )) - }) - }; - - Ok(match qop { - QuantumOp::H(qubit) => QuantumOp::H(map(*qubit)?), - QuantumOp::X(qubit) => QuantumOp::X(map(*qubit)?), - QuantumOp::Y(qubit) => QuantumOp::Y(map(*qubit)?), - QuantumOp::Z(qubit) => QuantumOp::Z(map(*qubit)?), - QuantumOp::S(qubit) => QuantumOp::S(map(*qubit)?), - QuantumOp::Sdg(qubit) => QuantumOp::Sdg(map(*qubit)?), - QuantumOp::T(qubit) => QuantumOp::T(map(*qubit)?), - QuantumOp::Tdg(qubit) => QuantumOp::Tdg(map(*qubit)?), - QuantumOp::RX(theta, qubit) => QuantumOp::RX(*theta, map(*qubit)?), - QuantumOp::RY(theta, qubit) => QuantumOp::RY(*theta, map(*qubit)?), - QuantumOp::CX(control, target) => QuantumOp::CX(map(*control)?, map(*target)?), - QuantumOp::CY(control, target) => QuantumOp::CY(map(*control)?, map(*target)?), - QuantumOp::CZ(control, target) => QuantumOp::CZ(map(*control)?, map(*target)?), - QuantumOp::CH(control, target) => QuantumOp::CH(map(*control)?, map(*target)?), - QuantumOp::CRZ(theta, control, target) => { - QuantumOp::CRZ(*theta, map(*control)?, map(*target)?) - } - QuantumOp::CCX(control_1, control_2, target) => { - QuantumOp::CCX(map(*control_1)?, map(*control_2)?, map(*target)?) - } - QuantumOp::ZZ(qubit_1, qubit_2) => QuantumOp::ZZ(map(*qubit_1)?, map(*qubit_2)?), - QuantumOp::Idle(duration, qubit) => QuantumOp::Idle(*duration, map(*qubit)?), - QuantumOp::RXY(..) - | QuantumOp::RZ(..) - | QuantumOp::RZZ(..) - | QuantumOp::Measure(..) - | QuantumOp::Reset(..) => qop.clone(), - }) - } - fn drain_runtime_operations(&mut self) -> Result> { self.load_plugin()?; let mut lowered_ops = Vec::new(); @@ -1017,6 +1027,363 @@ impl SeleneRuntime { Ok(lowered_ops) } + fn push_lowered_ops_with_source_metadata( + lowered_ops: &mut Vec, + ops: Vec, + source_metadata: &mut VecDeque, + ) { + for op in ops { + let metadata = if let Some(source_index) = source_metadata + .iter() + .position(|source| Self::source_trace_metadata_matches_lowered_op(source, &op)) + { + source_metadata + .remove(source_index) + .map(|source| source.metadata) + .unwrap_or_default() + } else { + TraceMetadata::new() + }; + lowered_ops.push(LoweredQuantumOp::new(op, metadata)); + } + } + + fn merge_trace_metadata(target: &mut TraceMetadata, metadata: TraceMetadata) -> Result<()> { + for (key, value) in metadata { + if let Some(existing) = target.get(&key) + && existing != &value + { + return Err(RuntimeError::ExecutionError(format!( + "conflicting trace metadata for key {key:?}: {existing:?} != {value:?}" + ))); + } + target.insert(key, value); + } + Ok(()) + } + + fn take_pending_trace_metadata_for_source_op( + qop: &QuantumOp, + pending_global_metadata: &mut TraceMetadata, + pending_qubit_metadata: &mut BTreeMap, + ) -> Result { + let mut metadata = std::mem::take(pending_global_metadata); + let mut consumed_qubits = Vec::new(); + + for qubit in Self::quantum_op_qubits(qop) { + let Some(pending) = pending_qubit_metadata.get(&qubit) else { + continue; + }; + if !Self::trace_metadata_can_annotate_source_op(pending, qop) { + continue; + } + Self::merge_trace_metadata(&mut metadata, pending.clone())?; + consumed_qubits.push(qubit); + } + + for qubit in consumed_qubits { + pending_qubit_metadata.remove(&qubit); + } + + Ok(metadata) + } + + fn trace_metadata_can_annotate_source_op(metadata: &TraceMetadata, qop: &QuantumOp) -> bool { + let Some(source_gate) = metadata.get("source_gate").map(String::as_str) else { + return true; + }; + match source_gate { + "SZZ" | "SZZDG" => Self::two_qubit_gate_qubits(qop).is_some(), + _ => Self::single_qubit_gate_qubit(qop).is_some(), + } + } + + fn source_trace_metadata_matches_lowered_op( + source: &SourceTraceMetadata, + lowered: &QuantumOp, + ) -> bool { + if source.metadata.contains_key("source_gate") { + return Self::source_gate_metadata_matches_lowered_op(source, lowered); + } + Self::source_op_matches_lowered_op(&source.op, lowered) + } + + fn source_gate_metadata_matches_lowered_op( + source: &SourceTraceMetadata, + lowered: &QuantumOp, + ) -> bool { + let Some(source_gate) = source.metadata.get("source_gate").map(String::as_str) else { + return false; + }; + if matches!(source_gate, "SZZ" | "SZZDG") { + let Some((source_qubit_1, source_qubit_2)) = Self::two_qubit_gate_qubits(&source.op) + else { + return false; + }; + let Some((lowered_qubit_1, lowered_qubit_2)) = Self::two_qubit_gate_qubits(lowered) + else { + return false; + }; + return Self::same_unordered_pair( + source_qubit_1, + source_qubit_2, + lowered_qubit_1, + lowered_qubit_2, + ); + } + let Some(source_qubit) = Self::single_qubit_gate_qubit(&source.op) else { + return false; + }; + let Some(lowered_qubit) = Self::single_qubit_gate_qubit(lowered) else { + return false; + }; + source_qubit == lowered_qubit + } + + fn source_op_matches_lowered_op(source: &QuantumOp, lowered: &QuantumOp) -> bool { + match (source, lowered) { + (QuantumOp::H(source_qubit), QuantumOp::H(lowered_qubit)) + | (QuantumOp::X(source_qubit), QuantumOp::X(lowered_qubit)) + | (QuantumOp::Y(source_qubit), QuantumOp::Y(lowered_qubit)) + | (QuantumOp::Z(source_qubit), QuantumOp::Z(lowered_qubit)) + | (QuantumOp::S(source_qubit), QuantumOp::S(lowered_qubit)) + | (QuantumOp::Sdg(source_qubit), QuantumOp::Sdg(lowered_qubit)) + | (QuantumOp::T(source_qubit), QuantumOp::T(lowered_qubit)) + | (QuantumOp::Tdg(source_qubit), QuantumOp::Tdg(lowered_qubit)) + | (QuantumOp::Reset(source_qubit), QuantumOp::Reset(lowered_qubit)) => { + source_qubit == lowered_qubit + } + ( + QuantumOp::RX(source_theta, source_qubit), + QuantumOp::RX(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::RY(source_theta, source_qubit), + QuantumOp::RY(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::RZ(source_theta, source_qubit), + QuantumOp::RZ(lowered_theta, lowered_qubit), + ) + | ( + QuantumOp::Idle(source_theta, source_qubit), + QuantumOp::Idle(lowered_theta, lowered_qubit), + ) => source_qubit == lowered_qubit && Self::same_float(*source_theta, *lowered_theta), + ( + QuantumOp::RXY(source_theta, source_phi, source_qubit), + QuantumOp::RXY(lowered_theta, lowered_phi, lowered_qubit), + ) => { + source_qubit == lowered_qubit + && Self::same_float(*source_theta, *lowered_theta) + && Self::same_float(*source_phi, *lowered_phi) + } + ( + QuantumOp::CX(source_control, source_target), + QuantumOp::CX(lowered_control, lowered_target), + ) + | ( + QuantumOp::CY(source_control, source_target), + QuantumOp::CY(lowered_control, lowered_target), + ) + | ( + QuantumOp::CZ(source_control, source_target), + QuantumOp::CZ(lowered_control, lowered_target), + ) + | ( + QuantumOp::CH(source_control, source_target), + QuantumOp::CH(lowered_control, lowered_target), + ) => Self::same_pair( + *source_control, + *source_target, + *lowered_control, + *lowered_target, + ), + ( + QuantumOp::CRZ(source_theta, source_control, source_target), + QuantumOp::CRZ(lowered_theta, lowered_control, lowered_target), + ) => { + Self::same_float(*source_theta, *lowered_theta) + && Self::same_pair( + *source_control, + *source_target, + *lowered_control, + *lowered_target, + ) + } + ( + QuantumOp::CCX(source_control_1, source_control_2, source_target), + QuantumOp::CCX(lowered_control_1, lowered_control_2, lowered_target), + ) => { + (source_control_1, source_control_2, source_target) + == (lowered_control_1, lowered_control_2, lowered_target) + } + ( + QuantumOp::ZZ(source_qubit_1, source_qubit_2), + QuantumOp::ZZ(lowered_qubit_1, lowered_qubit_2), + ) => Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ), + ( + QuantumOp::RZZ(source_theta, source_qubit_1, source_qubit_2), + QuantumOp::RZZ(lowered_theta, lowered_qubit_1, lowered_qubit_2), + ) => { + Self::same_float(*source_theta, *lowered_theta) + && Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ) + } + ( + QuantumOp::ZZ(source_qubit_1, source_qubit_2), + QuantumOp::RZZ(_, lowered_qubit_1, lowered_qubit_2), + ) => Self::same_unordered_pair( + *source_qubit_1, + *source_qubit_2, + *lowered_qubit_1, + *lowered_qubit_2, + ), + ( + QuantumOp::Measure(source_qubit, source_result), + QuantumOp::Measure(lowered_qubit, lowered_result), + ) => source_qubit == lowered_qubit && source_result == lowered_result, + _ => false, + } + } + + fn same_float(left: f64, right: f64) -> bool { + (left - right).abs() <= 1e-12 + } + + fn same_pair(left_a: usize, left_b: usize, right_a: usize, right_b: usize) -> bool { + (left_a, left_b) == (right_a, right_b) + } + + fn same_unordered_pair(left_a: usize, left_b: usize, right_a: usize, right_b: usize) -> bool { + Self::same_pair(left_a, left_b, right_a, right_b) + || Self::same_pair(left_a, left_b, right_b, right_a) + } + + fn quantum_op_qubits(qop: &QuantumOp) -> BTreeSet { + let mut qubits = BTreeSet::new(); + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) + | QuantumOp::Idle(_, qubit) + | QuantumOp::Measure(qubit, _) + | QuantumOp::Reset(qubit) => { + qubits.insert(*qubit); + } + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => { + qubits.insert(*qubit_1); + qubits.insert(*qubit_2); + } + QuantumOp::CCX(qubit_1, qubit_2, qubit_3) => { + qubits.insert(*qubit_1); + qubits.insert(*qubit_2); + qubits.insert(*qubit_3); + } + } + qubits + } + + fn single_qubit_gate_qubit(qop: &QuantumOp) -> Option { + match qop { + QuantumOp::H(qubit) + | QuantumOp::X(qubit) + | QuantumOp::Y(qubit) + | QuantumOp::Z(qubit) + | QuantumOp::S(qubit) + | QuantumOp::Sdg(qubit) + | QuantumOp::T(qubit) + | QuantumOp::Tdg(qubit) + | QuantumOp::RX(_, qubit) + | QuantumOp::RY(_, qubit) + | QuantumOp::RZ(_, qubit) + | QuantumOp::RXY(_, _, qubit) => Some(*qubit), + _ => None, + } + } + + fn two_qubit_gate_qubits(qop: &QuantumOp) -> Option<(usize, usize)> { + match qop { + QuantumOp::CX(qubit_1, qubit_2) + | QuantumOp::CY(qubit_1, qubit_2) + | QuantumOp::CZ(qubit_1, qubit_2) + | QuantumOp::CH(qubit_1, qubit_2) + | QuantumOp::CRZ(_, qubit_1, qubit_2) + | QuantumOp::ZZ(qubit_1, qubit_2) + | QuantumOp::RZZ(_, qubit_1, qubit_2) => Some((*qubit_1, *qubit_2)), + _ => None, + } + } + + fn fail_if_metadata_was_not_lowered( + source_metadata: &VecDeque, + ) -> Result<()> { + let leftover_metadata = source_metadata + .iter() + .filter(|source| Self::trace_metadata_requires_lowering(&source.metadata)) + .collect::>(); + if leftover_metadata.is_empty() { + return Ok(()); + } + let examples = leftover_metadata + .iter() + .take(3) + .map(|source| format!("{:?} for {:?}", source.metadata, source.op)) + .collect::>() + .join(", "); + Err(RuntimeError::ExecutionError(format!( + "runtime lowering did not emit non-idle operations for {} metadata-bearing source operation(s); examples: {examples}", + leftover_metadata.len() + ))) + } + + fn trace_metadata_requires_lowering(metadata: &TraceMetadata) -> bool { + metadata + .get("source_lowering_required") + .is_some_and(|value| value.eq_ignore_ascii_case("true")) + } + + fn fail_if_qubit_metadata_was_not_consumed( + pending_qubit_metadata: &BTreeMap, + ) -> Result<()> { + if pending_qubit_metadata.is_empty() { + return Ok(()); + } + let examples = pending_qubit_metadata + .iter() + .take(3) + .map(|(qubit, metadata)| format!("qubit {qubit}: {metadata:?}")) + .collect::>() + .join(", "); + Err(RuntimeError::ExecutionError(format!( + "qubit-scoped trace metadata was not followed by a compatible quantum operation for {} qubit(s); examples: {examples}", + pending_qubit_metadata.len() + ))) + } + fn convert_runtime_batch(&mut self, batch: RuntimeOperationBatch) -> Result> { let mut lowered_ops = Vec::new(); let start_time = batch.start_time_nanos; @@ -1349,6 +1716,86 @@ impl QisRuntime for SeleneRuntime { Ok(lowered_ops) } + fn lower_operations_with_metadata( + &mut self, + operations: &[Operation], + ) -> Result> { + if has_explicit_qubit_allocations(operations) { + self.uses_explicit_qubit_allocation = true; + } + let (num_qubits, num_results) = + operation_capacity_with_mode(operations, self.uses_explicit_qubit_allocation); + self.num_qubits = self.num_qubits.max(num_qubits); + self.num_results = self.num_results.max(num_results); + self.ensure_plugin_capacity()?; + self.load_plugin()?; + + let mut lowered_ops = Vec::new(); + let mut source_metadata = VecDeque::new(); + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata: BTreeMap = BTreeMap::new(); + + for op in operations { + match op { + Operation::TraceMetadata { metadata, qubit } => { + if let Some(qubit) = qubit { + let pending = pending_qubit_metadata.entry(*qubit).or_default(); + Self::merge_trace_metadata(pending, metadata.clone())?; + } else { + Self::merge_trace_metadata(&mut pending_global_metadata, metadata.clone())?; + } + } + Operation::Quantum(qop) => { + let metadata = Self::take_pending_trace_metadata_for_source_op( + qop, + &mut pending_global_metadata, + &mut pending_qubit_metadata, + )?; + if !metadata.is_empty() { + let source_op = self.map_quantum_op_to_runtime_qubits(qop)?; + source_metadata.push_back(SourceTraceMetadata { + op: source_op, + metadata, + }); + } + let mut emitted_ops = Vec::new(); + self.submit_operation_to_runtime(op, &mut emitted_ops)?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } + _ => { + let mut emitted_ops = Vec::new(); + self.submit_operation_to_runtime(op, &mut emitted_ops)?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } + } + } + + let emitted_ops = self.drain_runtime_operations()?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + + if !pending_global_metadata.is_empty() { + return Err(RuntimeError::ExecutionError(format!( + "trace metadata was not followed by a quantum operation: {pending_global_metadata:?}" + ))); + } + Self::fail_if_qubit_metadata_was_not_consumed(&pending_qubit_metadata)?; + Self::fail_if_metadata_was_not_lowered(&source_metadata)?; + + Ok(lowered_ops) + } + fn provide_measurements(&mut self, measurements: BTreeMap) -> Result<()> { debug!( "Received {} measurement results, num_results={}, allocated_results={:?}", @@ -1579,6 +2026,259 @@ mod tests { ); } + #[test] + fn test_source_metadata_attaches_to_first_non_idle_lowered_op() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::Idle(20e-9, 0), QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert!(lowered_ops[0].metadata.is_empty()); + assert_eq!( + lowered_ops[1] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:szz-host") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_source_idle_metadata_can_attach_to_idle_op() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_label".to_string(), "probe:idle".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::Idle(20e-9, 0), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::Idle(20e-9, 0)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:idle") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_unmatched_metadata_does_not_block_later_compatible_metadata() { + let mut rz_metadata = TraceMetadata::new(); + rz_metadata.insert("source_label".to_string(), "probe:virtual-rz".to_string()); + let mut rzz_metadata = TraceMetadata::new(); + rzz_metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + let mut source_metadata = VecDeque::from([ + SourceTraceMetadata { + op: QuantumOp::RZ(0.25, 0), + metadata: rz_metadata, + }, + SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata: rzz_metadata, + }, + ]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:szz-host") + ); + assert_eq!(source_metadata.len(), 1); + assert_eq!( + source_metadata[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:virtual-rz") + ); + } + + #[test] + fn test_source_metadata_can_attach_after_runtime_reordering() { + let mut first_metadata = TraceMetadata::new(); + first_metadata.insert("source_label".to_string(), "probe:first".to_string()); + let mut second_metadata = TraceMetadata::new(); + second_metadata.insert("source_label".to_string(), "probe:second".to_string()); + let mut source_metadata = VecDeque::from([ + SourceTraceMetadata { + op: QuantumOp::RZZ(0.5, 0, 1), + metadata: first_metadata, + }, + SourceTraceMetadata { + op: QuantumOp::RZZ(-0.5, 2, 3), + metadata: second_metadata, + }, + ]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RZZ(-0.5, 2, 3), QuantumOp::RZZ(0.5, 0, 1)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:second") + ); + assert_eq!( + lowered_ops[1] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:first") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_source_gate_metadata_matches_runtime_normalized_single_qubit_pulse() { + let mut metadata = TraceMetadata::new(); + metadata.insert("source_gate".to_string(), "H".to_string()); + metadata.insert("source_label".to_string(), "probe:h-prefix".to_string()); + let mut source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RXY(std::f64::consts::FRAC_PI_2, -std::f64::consts::FRAC_PI_2, 2), + metadata, + }]); + let mut lowered_ops = Vec::new(); + + SeleneRuntime::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + vec![QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 2)], + &mut source_metadata, + ); + + assert_eq!( + lowered_ops[0] + .metadata + .get("source_label") + .map(String::as_str), + Some("probe:h-prefix") + ); + assert!(source_metadata.is_empty()); + } + + #[test] + fn test_qubit_scoped_metadata_waits_for_compatible_source_op() { + let mut h_metadata = TraceMetadata::new(); + h_metadata.insert("source_gate".to_string(), "H".to_string()); + h_metadata.insert("source_label".to_string(), "probe:h-prefix".to_string()); + let mut szz_metadata = TraceMetadata::new(); + szz_metadata.insert("source_gate".to_string(), "SZZ".to_string()); + szz_metadata.insert("source_label".to_string(), "probe:szz-host".to_string()); + + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata = BTreeMap::from([(1, szz_metadata), (8, h_metadata)]); + + let rxy_metadata = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 8), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect("take metadata for RXY"); + assert_eq!( + rxy_metadata.get("source_label").map(String::as_str), + Some("probe:h-prefix") + ); + assert!(pending_qubit_metadata.contains_key(&1)); + assert!(!pending_qubit_metadata.contains_key(&8)); + + let rzz_metadata = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RZZ(-std::f64::consts::FRAC_PI_2, 9, 1), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect("take metadata for RZZ"); + assert_eq!( + rzz_metadata.get("source_label").map(String::as_str), + Some("probe:szz-host") + ); + assert!(pending_qubit_metadata.is_empty()); + } + + #[test] + fn test_conflicting_trace_metadata_fails_loudly() { + let mut left_metadata = TraceMetadata::new(); + left_metadata.insert("source_label".to_string(), "probe:left".to_string()); + let mut right_metadata = TraceMetadata::new(); + right_metadata.insert("source_label".to_string(), "probe:right".to_string()); + + let mut pending_global_metadata = TraceMetadata::new(); + let mut pending_qubit_metadata = BTreeMap::from([(0, left_metadata), (1, right_metadata)]); + + let error = SeleneRuntime::take_pending_trace_metadata_for_source_op( + &QuantumOp::RZZ(std::f64::consts::FRAC_PI_2, 0, 1), + &mut pending_global_metadata, + &mut pending_qubit_metadata, + ) + .expect_err("conflicting source labels should fail"); + assert!(error.to_string().contains("conflicting trace metadata")); + } + + #[test] + fn test_optional_unlowered_trace_metadata_is_allowed() { + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "probe:optimized-away-prefix".to_string(), + ); + let source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RXY(std::f64::consts::FRAC_PI_2, 0.0, 4), + metadata, + }]); + + SeleneRuntime::fail_if_metadata_was_not_lowered(&source_metadata) + .expect("optional metadata may be optimized away by the runtime"); + } + + #[test] + fn test_required_unlowered_trace_metadata_fails_loudly() { + let mut metadata = TraceMetadata::new(); + metadata.insert( + "source_label".to_string(), + "probe:required-host".to_string(), + ); + metadata.insert("source_lowering_required".to_string(), "true".to_string()); + let source_metadata = VecDeque::from([SourceTraceMetadata { + op: QuantumOp::RZZ(std::f64::consts::FRAC_PI_2, 0, 1), + metadata, + }]); + + let error = SeleneRuntime::fail_if_metadata_was_not_lowered(&source_metadata) + .expect_err("required metadata should fail when it is not lowered"); + assert!(error.to_string().contains("required-host")); + } + #[test] fn test_collector_capacity_includes_direct_program_handles() { let mut collector = OperationCollector::new(); diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index a05d94033..f3ff8e6e5 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -371,7 +371,11 @@ def generate_guppy_source( lines.extend( [ "@guppy.declare", - "def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ...", + ( + "def pecos_qis_trace_metadata_qubit_hugr(" + "q: qubit @ owned, key: str, value: str" + ") -> qubit: ..." + ), "", "", ], @@ -515,8 +519,16 @@ def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: msg = f"unsupported Pauli axis {axis!r}" raise ValueError(msg) - def _append_szz_trace_metadata(target: list[str], indent: str, key: str, value: str) -> None: - target.append(f'{indent}pecos_qis_trace_metadata_hugr("{key}", "{value}")') + def _append_szz_trace_metadata( + target: list[str], + indent: str, + key: str, + value: str, + qubit_expr: str, + ) -> None: + target.append( + f'{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr({qubit_expr}, "{key}", "{value}")', + ) def _append_szz_gate_trace_metadata( target: list[str], @@ -524,15 +536,25 @@ def _append_szz_gate_trace_metadata( *, source_kind: str, source_label: str, + qubit_expr: str, host_label: str | None = None, gate: OpType | None = None, + lowering_required: bool = False, ) -> None: - _append_szz_trace_metadata(target, indent, "source_kind", source_kind) - _append_szz_trace_metadata(target, indent, "source_label", source_label) + _append_szz_trace_metadata(target, indent, "source_kind", source_kind, qubit_expr) + _append_szz_trace_metadata(target, indent, "source_label", source_label, qubit_expr) if host_label is not None: - _append_szz_trace_metadata(target, indent, "szz_host_label", host_label) + _append_szz_trace_metadata(target, indent, "szz_host_label", host_label, qubit_expr) if gate is not None: - _append_szz_trace_metadata(target, indent, "source_gate", gate.name) + _append_szz_trace_metadata(target, indent, "source_gate", gate.name, qubit_expr) + if lowering_required: + _append_szz_trace_metadata( + target, + indent, + "source_lowering_required", + "true", + qubit_expr, + ) def _append_szz_flow_gate( target: list[str], @@ -561,6 +583,7 @@ def _append_szz_flow_gate( indent, source_kind="szz_data_prefix", source_label=source_label, + qubit_expr=qubit_expr, host_label=host_label, gate=op_type, ) @@ -755,7 +778,9 @@ def discharge_data_for_szz( indent, source_kind="szz_host", source_label=host_label, + qubit_expr=data_expr(data_q), gate=host_gate, + lowering_required=True, ) target.append( f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index 4861ebb45..111f9fb33 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -67,18 +67,23 @@ def test_trace_metadata_helper_uses_public_symbol() -> None: """Test that declared trace metadata helpers compile to the public FFI symbol.""" try: from guppylang import guppy + from guppylang.std.builtins import owned + from guppylang.std.quantum import h, measure, qubit from pecos_rslib import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") @guppy.declare - def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ... + def pecos_qis_trace_metadata_qubit_hugr(q: qubit @ owned, key: str, value: str) -> qubit: ... @guppy def metadata_probe() -> None: - pecos_qis_trace_metadata_hugr("source_kind", "szz_host") + q = qubit() + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_kind", "szz_host") + h(q) + _ = measure(q) llvm_ir = compile_hugr_to_qis(metadata_probe.compile().to_bytes()) - assert "@pecos_qis_trace_metadata_hugr" in llvm_ir - assert "@__hugr__.pecos_qis_trace_metadata_hugr" not in llvm_ir + assert "@pecos_qis_trace_metadata_qubit_hugr" in llvm_ir + assert "@__hugr__.pecos_qis_trace_metadata_qubit_hugr" not in llvm_ir diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 1a057ddd4..79dada3d8 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -304,10 +304,12 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None guppy_source = generate_guppy_from_patch(patch, check_plan="szz_current_v1") assert "Check plan: szz_current_v1" in guppy_source - assert "def pecos_qis_trace_metadata_hugr(key: str, value: str) -> None: ..." in guppy_source - assert 'pecos_qis_trace_metadata_hugr("source_kind", "szz_host")' in guppy_source - assert 'pecos_qis_trace_metadata_hugr("source_kind", "szz_data_prefix")' in guppy_source - assert 'pecos_qis_trace_metadata_hugr("szz_host_label", "szz:' in guppy_source + assert "def pecos_qis_trace_metadata_qubit_hugr(" in guppy_source + assert " = pecos_qis_trace_metadata_qubit_hugr(" in guppy_source + assert '"source_kind", "szz_host"' in guppy_source + assert '"source_kind", "szz_data_prefix"' in guppy_source + assert '"szz_host_label", "szz:' in guppy_source + assert '"source_lowering_required", "true"' in guppy_source def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index 22d9a43b0..af02c778c 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -8,7 +8,7 @@ import pytest from guppylang import guppy -from guppylang.std.builtins import result +from guppylang.std.builtins import owned, result from guppylang.std.quantum import h, measure, qubit, x from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -55,11 +55,25 @@ def _measurement_feedback() -> None: result("b1", b1) +@guppy.declare +def pecos_qis_trace_metadata_qubit_hugr(q: qubit @ owned, key: str, value: str) -> qubit: ... + + +@guppy +def _metadata_before_h_gate() -> None: + q = qubit() + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_kind", "szz_data_prefix") + q = pecos_qis_trace_metadata_qubit_hugr(q, "source_label", "probe:prefix") + h(q) + _ = measure(q) + + def test_operation_trace_capture_uses_trace_friendly_quantum_backend(monkeypatch: pytest.MonkeyPatch) -> None: import pecos def forbidden_stabilizer(): - raise AssertionError("trace capture should not validate operations with stabilizer evolution") + msg = "trace capture should not validate operations with stabilizer evolution" + raise AssertionError(msg) monkeypatch.setattr(pecos, "stabilizer", forbidden_stabilizer) @@ -69,6 +83,23 @@ def forbidden_stabilizer(): assert "m" in result_names +def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: + chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) + lowered_ops = [ + op + for chunk in chunks + for op in chunk.get("lowered_quantum_ops", []) + ] + + assert lowered_ops[1]["gate_type"] == "R1XY" + assert lowered_ops[1]["metadata"] == { + "source_kind": "szz_data_prefix", + "source_label": "probe:prefix", + } + assert lowered_ops[-1]["gate_type"] == "MZ" + assert lowered_ops[-1]["metadata"] == {} + + def _dem_text(*, detectors_json: str = "[]", observables_json: str = "[]") -> str: dem = DetectorErrorModel.from_guppy( _single_measurement, From 3210d0b2e007c3023029161b916b2641381bb5aa Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 09:51:04 -0600 Subject: [PATCH 259/388] Fence SZZ prefixes before runtime barriers --- crates/pecos-qis/src/selene_runtime.rs | 11 +++++++++++ .../quantum-pecos/src/pecos/guppy/surface.py | 18 +++++++++--------- .../tests/qec/surface/test_check_plan.py | 19 +++++++++++++++++++ 3 files changed, 39 insertions(+), 9 deletions(-) diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 3b22f1622..53803a964 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -1709,6 +1709,9 @@ impl QisRuntime for SeleneRuntime { let mut lowered_ops = Vec::new(); for op in operations { + if matches!(op, Operation::Barrier) { + lowered_ops.extend(self.drain_runtime_operations()?); + } self.submit_operation_to_runtime(op, &mut lowered_ops)?; } @@ -1766,6 +1769,14 @@ impl QisRuntime for SeleneRuntime { &mut source_metadata, ); } + Operation::Barrier => { + let emitted_ops = self.drain_runtime_operations()?; + Self::push_lowered_ops_with_source_metadata( + &mut lowered_ops, + emitted_ops, + &mut source_metadata, + ); + } _ => { let mut emitted_ops = Vec::new(); self.submit_operation_to_runtime(op, &mut emitted_ops)?; diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index f3ff8e6e5..57e14ef6d 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -233,11 +233,11 @@ def generate_guppy_source( szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. ``False`` or ``"none"`` emits no barriers; ``True`` or ``"all"`` emits a public Guppy ``barrier`` before every SZZ/SZZdg host region; - ``"data-prefix"`` emits one only when a non-virtual local - data-frame pulse is about to be discharged into that host. Barriers - have no ideal-unitary effect, but give runtimes a principled - scheduling boundary before selected local data-frame pulses and - their entangling host. + ``"data-prefix"`` emits one only after a non-virtual local + data-frame pulse is discharged and before its host. Barriers have + no ideal-unitary effect, but give runtimes a principled scheduling + boundary between selected local data-frame pulses and their + entangling host. Returns: Python/Guppy source code as a string. @@ -760,6 +760,10 @@ def discharge_data_for_szz( has_data_prefix = ( pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) ) + sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] + host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG + host_label = f"szz:r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + discharge_data_for_szz(data_q, host_label=host_label) if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX and has_data_prefix @@ -768,10 +772,6 @@ def discharge_data_for_szz( f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, " f"{data_expr(data_q)})", ) - sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] - host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG - host_label = f"szz:r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" - discharge_data_for_szz(data_q, host_label=host_label) half_turns = "0.5" if sign > 0 else "-0.5" _append_szz_gate_trace_metadata( target, diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 79dada3d8..eed293d92 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -312,6 +312,25 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert '"source_lowering_required", "true"' in guppy_source +def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + source = generate_guppy_source( + patch, + check_plan="szz_current_v1", + szz_runtime_barriers="data-prefix", + ) + + prefix_index = source.index('"source_kind", "szz_data_prefix"') + barrier_index = source.index("barrier(") + host_index = source.index('"source_kind", "szz_host"', prefix_index) + zz_phase_index = source.index("zz_phase(", barrier_index) + + assert prefix_index < barrier_index < host_index < zz_phase_index + + def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import ( From a1abbd1273121978246f60368a1f97cd2071404d Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 09:57:29 -0600 Subject: [PATCH 260/388] Document Guppy barrier trace gap --- .../tests/qec/test_from_guppy_dem.py | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index af02c778c..dfdad3354 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -8,7 +8,7 @@ import pytest from guppylang import guppy -from guppylang.std.builtins import owned, result +from guppylang.std.builtins import barrier, owned, result from guppylang.std.quantum import h, measure, qubit, x from pecos.guppy import get_num_qubits, make_surface_code from pecos.qec import DetectorErrorModel @@ -68,6 +68,17 @@ def _metadata_before_h_gate() -> None: _ = measure(q) +@guppy +def _barrier_between_single_qubit_gates() -> None: + q0 = qubit() + q1 = qubit() + h(q0) + barrier(q0, q1) + h(q1) + _ = measure(q0) + _ = measure(q1) + + def test_operation_trace_capture_uses_trace_friendly_quantum_backend(monkeypatch: pytest.MonkeyPatch) -> None: import pecos @@ -83,6 +94,29 @@ def forbidden_stabilizer(): assert "m" in result_names +@pytest.mark.xfail( + reason=( + "Guppy public barrier(...) is currently optimized away before PECOS " + "QIS operation collection; hosted SZZ prefix scheduling needs a " + "barrier-preserving or hosted-operation lowering path." + ), + strict=True, +) +def test_guppy_barrier_survives_into_qis_operation_trace() -> None: + chunks = capture_guppy_operation_trace( + _barrier_between_single_qubit_gates, + num_qubits=2, + seed=0, + ) + operations = [ + operation + for chunk in chunks + for operation in chunk.get("operations", []) + ] + + assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) + + def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) lowered_ops = [ From 88ee9b16b77e63243c9ddd36450feaad5e9c0b6d Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 10:13:12 -0600 Subject: [PATCH 261/388] Add hosted operation design note --- docs/development/hosted-operations.md | 187 ++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 docs/development/hosted-operations.md diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md new file mode 100644 index 000000000..22313b5c0 --- /dev/null +++ b/docs/development/hosted-operations.md @@ -0,0 +1,187 @@ +# Hosted Operations + +This note sketches a generic PECOS abstraction for operations that must stay +attached to a later quantum host operation during lowering, scheduling, tracing, +and DEM construction. + +## Motivation + +Some source circuits contain local operations whose physical meaning is only +complete when paired with a later host operation. A common example is a +single-qubit Clifford basis change that prepares a data qubit for a two-qubit +interaction. If a compiler or runtime legally moves that local pulse far away +from the host, the ideal unitary can remain correct while the physical idle +noise model changes substantially. + +Plain source adjacency and public language-level barriers are not enough as a +long-term contract: + +- Source order can be changed by Guppy/HUGR/QIR/runtime lowering. +- Public barriers may be optimized away before QIS operation collection. +- A barrier says "do not reorder across this point"; it does not say "this + local pulse is hosted by that two-qubit gate". + +PECOS needs a way to represent this source intent directly and fail loudly when +the intent is dropped or cannot be honored. + +## Definition + +A hosted operation relationship binds one or more local source operations to one +host source operation: + +- `host_id`: stable source identifier for the host operation. +- `host_kind`: generic host category, for example `two_qubit_gate` or + `measurement`. +- `local_role`: local operation role, for example `basis_prefix`, + `basis_suffix`, `frame_update`, or `readout_prefix`. +- `local_qubits`: source qubits touched by the local operation. +- `host_qubits`: source qubits touched by the host operation. +- `policy`: requested lowering/scheduling policy. + +The relationship is not a physical-device-specific instruction. It is source +intent that runtimes may use to lower better schedules, and that PECOS can use +to validate traces and build diagnostics. + +## Initial Policies + +Start with strict, observable policies instead of implicit best effort: + +- `metadata_only`: preserve provenance, but do not require adjacency. +- `same_runtime_batch`: the local operation and host must be submitted to the + runtime in the same hosted group or batch. +- `max_idle_time`: the lowered trace must show no more than a configured time + between the local operation and host on the local qubit. +- `lowering_required`: the host must produce a compatible lowered operation. + +For any policy stronger than `metadata_only`, failure should be explicit and +actionable. Silent fallback to unhosted behavior is not acceptable. + +## Trace Requirements + +Traces should preserve both the local and host sides: + +- local lowered operations carry `source_kind`, `source_label`, `host_id`, and + `local_role`. +- host lowered operations carry `source_kind`, `source_label`, and `host_id`. +- replay code can pair local operations to hosts by exact `host_id`, not by + nearest-neighbor inference. +- audit tools can report whether provenance is exact, inferred, mismatched, or + missing. + +Exact provenance is necessary but not sufficient. It proves PECOS can identify +the intended host; it does not prove the lowered schedule kept the operations +adjacent or within a noise-model threshold. + +## Candidate Implementation Layers + +### 1. Metadata-Only Vertical Slice + +The smallest useful slice is the current trace-metadata approach: + +1. Emit qubit-scoped metadata before each local operation and host operation. +2. Preserve metadata through QIS operation collection and runtime replay. +3. Attach metadata to lowered operations. +4. Require exact host matching in downstream audits. + +This slice is useful for diagnostics and DEM cache identity, but it does not +constrain scheduling. + +### 2. Barrier-Preserving Lowering + +Preserving public barriers into `Operation::Barrier` can create runtime replay +batch boundaries, and PECOS replay should drain at those boundaries. This is a +valid generic improvement, but it still does not express "this local pulse is +hosted by that operation". It should not be the only hosted-operation plan. + +Current state: + +- SLR QIR codegen can emit QIR barrier calls such as + `__quantum__qis__barrierN__body`. +- `pecos-qis-ffi-types` and `pecos-qis-ffi` already have an + `Operation::Barrier` control-flow marker. +- PECOS traced Selene replay drains runtime operations at `Operation::Barrier` + when that marker is present. +- A minimal Guppy public `barrier(...)` probe is currently optimized away before + PECOS QIS operation collection. The captured raw operation trace contains + allocations, gates, measurements, and releases, but no `Barrier` operation. + +So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public +barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` +instead of dropping them as pass-through no-ops. This is useful, but still +secondary to a hosted-operation relationship because a barrier does not identify +which host operation a local pulse belongs to. + +### 3. Explicit Hosted Operation + +The stronger long-term abstraction is a QIS-level hosted operation or hosted +group: + +```text +HostedGroup { + host_id, + locals: [QuantumOp], + host: QuantumOp, + policy, +} +``` + +An equivalent representation could be a pair of begin/end host markers around a +set of normal `QuantumOp`s, if that is easier to thread through existing +collectors. The key requirement is that the runtime receives a relationship, +not just a sequence of independent gates. + +## Surface-Code SZZ Example + +For SZZ/SZZdg surface-code checks: + +1. The source renderer forward-flows data-frame Cliffords. +2. Before an SZZ/SZZdg interaction, it emits any required non-virtual local + data prefix. +3. The prefix is tagged as `local_role=basis_prefix` with the SZZ/SZZdg + `host_id`. +4. The SZZ/SZZdg interaction is tagged as the host with the same `host_id`. +5. Runtime trace replay verifies exact provenance and, when requested, a + bounded prefix-host idle threshold. + +This should remain generic. PECOS should not encode downstream device names or +experiment packages into the abstraction. + +## Fail-Loud Conditions + +PECOS should fail loudly when: + +- hosted metadata is attached to a source qubit but never consumed by a + compatible source operation. +- two hosted metadata maps disagree on a key for the same source operation. +- a required host is optimized away or cannot be matched to a lowered operation. +- exact host provenance is requested but only inferred matching is available. +- a scheduling policy such as `max_idle_time` is exceeded in the lowered trace. + +Error messages should include source labels, qubits, host labels, observed idle +duration or tick separation, and the relevant policy. + +## Open Questions + +- Should hosted groups be a first-class QIS `Operation`, or represented as + markers plus normal `QuantumOp`s? +- Which Guppy/HUGR constructs can carry hosted relationships without being + optimized away? +- Do runtime plugins need an explicit hosted-operation callback, or can PECOS + lower hosted groups into existing runtime APIs with flush boundaries and + metadata? +- How should hosted operations interact with DEM generation for decoders that + consume raw hypergraph DEMs versus decomposed DEMs? +- Can the same abstraction cover measurement-hosted readout prefixes and + two-qubit-gate-hosted basis prefixes? + +## Recommended Next Slice + +1. Add a minimal barrier-survival diagnostic for public Guppy barriers in QIS + traces. +2. If preserving public barriers is small and generic, implement it as a + separate quality-of-lowering improvement. +3. Prototype an explicit hosted SZZ prefix relationship in the QIS trace path. +4. Use idle audits to compare hosted and unhosted lowering on representative + surface-code memory circuits. +5. Keep downstream experiments guarded by exact provenance and prefix-host idle + thresholds until hosted scheduling is proven by traces. From f8495a68d2a77a7789ccda891ed0347e6d43ecde Mon Sep 17 00:00:00 2001 From: ciaranra Date: Thu, 25 Jun 2026 11:08:44 -0600 Subject: [PATCH 262/388] Add hosted SZZ metadata keys --- .../quantum-pecos/src/pecos/guppy/surface.py | 6 ++++++ .../tests/qec/surface/test_check_plan.py | 20 +++++++++++++++++++ .../tests/qec/test_from_guppy_dem.py | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 57e14ef6d..e69f45d3d 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -538,6 +538,7 @@ def _append_szz_gate_trace_metadata( source_label: str, qubit_expr: str, host_label: str | None = None, + local_role: str | None = None, gate: OpType | None = None, lowering_required: bool = False, ) -> None: @@ -545,6 +546,9 @@ def _append_szz_gate_trace_metadata( _append_szz_trace_metadata(target, indent, "source_label", source_label, qubit_expr) if host_label is not None: _append_szz_trace_metadata(target, indent, "szz_host_label", host_label, qubit_expr) + _append_szz_trace_metadata(target, indent, "host_id", host_label, qubit_expr) + if local_role is not None: + _append_szz_trace_metadata(target, indent, "local_role", local_role, qubit_expr) if gate is not None: _append_szz_trace_metadata(target, indent, "source_gate", gate.name, qubit_expr) if lowering_required: @@ -585,6 +589,7 @@ def _append_szz_flow_gate( source_label=source_label, qubit_expr=qubit_expr, host_label=host_label, + local_role="basis_prefix", gate=op_type, ) target.append(f"{indent}{op_name}({qubit_expr})") @@ -779,6 +784,7 @@ def discharge_data_for_szz( source_kind="szz_host", source_label=host_label, qubit_expr=data_expr(data_q), + host_label=host_label, gate=host_gate, lowering_required=True, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index eed293d92..ec941932e 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -309,6 +309,8 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert '"source_kind", "szz_host"' in guppy_source assert '"source_kind", "szz_data_prefix"' in guppy_source assert '"szz_host_label", "szz:' in guppy_source + assert '"host_id", "szz:' in guppy_source + assert '"local_role", "basis_prefix"' in guppy_source assert '"source_lowering_required", "true"' in guppy_source @@ -331,6 +333,24 @@ def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: assert prefix_index < barrier_index < host_index < zz_phase_index +def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + ) + + prefix_index = source.index('"source_kind", "szz_data_prefix"') + role_index = source.index('"local_role", "basis_prefix"', prefix_index) + prefix_host_index = source.index('"host_id", "szz:', prefix_index) + host_index = source.index('"source_kind", "szz_host"', prefix_index) + host_id_index = source.index('"host_id", "szz:', host_index) + + assert prefix_index < prefix_host_index < role_index < host_index < host_id_index + + def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import ( diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index dfdad3354..b02b65f7e 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -64,6 +64,8 @@ def _metadata_before_h_gate() -> None: q = qubit() q = pecos_qis_trace_metadata_qubit_hugr(q, "source_kind", "szz_data_prefix") q = pecos_qis_trace_metadata_qubit_hugr(q, "source_label", "probe:prefix") + q = pecos_qis_trace_metadata_qubit_hugr(q, "host_id", "probe:host") + q = pecos_qis_trace_metadata_qubit_hugr(q, "local_role", "basis_prefix") h(q) _ = measure(q) @@ -127,6 +129,8 @@ def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: assert lowered_ops[1]["gate_type"] == "R1XY" assert lowered_ops[1]["metadata"] == { + "host_id": "probe:host", + "local_role": "basis_prefix", "source_kind": "szz_data_prefix", "source_label": "probe:prefix", } From b6e40a1f4539684a868ccba37b9b1da586ad3290 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 26 Jun 2026 09:34:06 -0600 Subject: [PATCH 263/388] Guard the narrow u64 decode APIs on DEM/decoder observable width (not just batch truth-width): reject >64-observable DEMs up front, fixing the decode_count_batch 1< Strin /// /// This is the shared factory used by `SampleBatch.decode_count`, /// `DemSampler.sample_decode_count`, and the parallel variants. +/// Reject a DEM whose observable count exceeds 64, the limit of the legacy +/// `u64` decode/decode_count APIs that build or compare `u64` observable masks +/// (`1u64 << obs_idx`). This guards on the *decoder/DEM* observable width, which +/// is independent of the batch's stored truth-mask width: a narrow batch paired +/// with a wide DEM would otherwise overflow `1 << j` in the prediction path. +/// Callers with more than 64 observables must use the wide +/// `LogicalSubgraphDecoder` decode/decode_count paths (arbitrary-precision int). +fn ensure_narrow_dem(dem: &str) -> PyResult<()> { + let parsed = dem + .parse::() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let n = parsed.num_observables(); + if n > 64 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "DEM has {n} observables, exceeding the 64-observable limit of this u64-based \ + decode API; use the wide LogicalSubgraphDecoder decode/decode_count paths \ + (arbitrary-precision int) for more than 64 observables" + ))); + } + Ok(()) +} + fn create_observable_decoder( dem: &str, decoder_type: &str, @@ -3547,6 +3569,7 @@ impl PySampleBatch { #[pyo3(signature = (dem, decoder_type="pymatching"))] fn decode_count(&self, dem: &str, decoder_type: &str) -> PyResult { self.ensure_narrow_observables()?; + ensure_narrow_dem(dem)?; let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut errors = 0usize; let mut syndrome = vec![0u8; self.num_detectors]; @@ -3574,6 +3597,7 @@ impl PySampleBatch { /// List of predicted observable masks, one per shot. #[pyo3(signature = (dem, decoder_type="pymatching"))] fn decode_each(&self, dem: &str, decoder_type: &str) -> PyResult> { + ensure_narrow_dem(dem)?; let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut predictions = Vec::with_capacity(self.num_shots); let mut syndrome = vec![0u8; self.num_detectors]; @@ -3610,6 +3634,7 @@ impl PySampleBatch { use rayon::prelude::*; self.ensure_narrow_observables()?; + ensure_narrow_dem(dem)?; let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); let pool = rayon::ThreadPoolBuilder::new() .num_threads(n_workers) @@ -3662,6 +3687,7 @@ impl PySampleBatch { use pecos_decoders::{BatchConfig, PyMatchingDecoder}; self.ensure_narrow_observables()?; + ensure_narrow_dem(dem)?; let mut decoder = PyMatchingDecoder::from_dem(dem) .map_err(|e| PyErr::new::(e.to_string()))?; @@ -3724,6 +3750,7 @@ impl PySampleBatch { use std::time::Instant; self.ensure_narrow_observables()?; + ensure_narrow_dem(dem)?; let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut num_errors = 0usize; @@ -3771,6 +3798,7 @@ impl PySampleBatch { use rayon::prelude::*; self.ensure_narrow_observables()?; + ensure_narrow_dem(dem)?; let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); // Validate decoder type early. diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index a5d210893..a8a3f492d 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -107,3 +107,27 @@ def test_narrow_sample_batch_apis_reject_wide_observables() -> None: # The wide getter returns the full mask as a Python int with no truncation. assert wide.get_observable_mask_wide(0) == 1 << 64 + + +def test_narrow_decode_apis_reject_wide_dem() -> None: + # A DEM with >64 observables cannot be represented by the legacy u64 decode + # APIs (they build predictions with `1 << j`). The decoder/DEM width is + # independent of the batch's truth-mask width: a narrow batch (all-zero truth + # masks => 0 observable columns, which passes the batch-width guard) paired + # with a wide DEM must still be rejected up front, not overflow `1 << j`. + n = 70 + dem, _ = _wide_dem(n) + syn = [0] * n + batch = SampleBatch([syn, syn], [0, 0]) # 0 observable columns (narrow batch) + + narrow_calls = [ + lambda: batch.decode_count(dem, "pecos_uf:fast"), + lambda: batch.decode_each(dem, "pecos_uf:fast"), + lambda: batch.decode_count_parallel(dem, "pecos_uf:fast"), + lambda: batch.decode_count_batch(dem), + lambda: batch.decode_stats(dem, "pecos_uf:fast"), + lambda: batch.decode_stats_parallel(dem, "pecos_uf:fast"), + ] + for call in narrow_calls: + with pytest.raises(ValueError, match="64-observable"): + call() From 51f0aca98e4dff5babe660ebb17b854d43ffc106 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 26 Jun 2026 11:41:56 -0600 Subject: [PATCH 264/388] Add hosted operation metadata validation --- .../src/pecos/quantum/__init__.py | 12 + .../quantum-pecos/src/pecos/quantum/hosted.py | 281 ++++++++++++++++++ .../tests/pecos/test_hosted_operations.py | 197 ++++++++++++ 3 files changed, 490 insertions(+) create mode 100644 python/quantum-pecos/src/pecos/quantum/hosted.py create mode 100644 python/quantum-pecos/tests/pecos/test_hosted_operations.py diff --git a/python/quantum-pecos/src/pecos/quantum/__init__.py b/python/quantum-pecos/src/pecos/quantum/__init__.py index 3a0d84c38..6cd99b993 100644 --- a/python/quantum-pecos/src/pecos/quantum/__init__.py +++ b/python/quantum-pecos/src/pecos/quantum/__init__.py @@ -69,6 +69,13 @@ from typing import TYPE_CHECKING from pecos.quantum import commute, gate_groups +from pecos.quantum.hosted import ( + HOST_ID_META_KEY, + LOCAL_ROLE_META_KEY, + HostedGateRecord, + HostedOperationBinding, + validate_hosted_operations, +) from pecos.typing import INTEGER_TYPES if TYPE_CHECKING: @@ -269,7 +276,9 @@ def pauli_string( "H4", "H5", "H6", + "HOST_ID_META_KEY", "ISWAP", + "LOCAL_ROLE_META_KEY", "PHYSICAL_DURATION_META_KEY", "SWAP", "SX", @@ -293,6 +302,8 @@ def pauli_string( "GateRegistry", "GateType", "H", + "HostedGateRecord", + "HostedOperationBinding", "HugrConversionError", "Pauli", "PauliSequence", @@ -325,4 +336,5 @@ def pauli_string( "is_quantum_operation", "pauli_string", "sparse_stab", + "validate_hosted_operations", ] diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py new file mode 100644 index 000000000..cfaac8cc3 --- /dev/null +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -0,0 +1,281 @@ +"""Hosted-operation metadata utilities. + +A hosted operation is a source-local operation whose semantic role is tied to a +later host operation. For example, an SZZ surface-code data-prefix pulse can +declare ``host_id=`` and ``local_role=basis_prefix`` while the +lowered SZZ/SZZdg host carries the same ``host_id``. Runtimes are free to +lower and schedule the gates, but traced circuits can then fail loudly if the +source-host relationship was lost or separated too far. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + +HOST_ID_META_KEY = "host_id" +LOCAL_ROLE_META_KEY = "local_role" + + +@dataclass(frozen=True) +class HostedGateRecord: + """A traced gate carrying hosted-operation metadata.""" + + tick_index: int + gate_index: int + gate_name: str + qubits: tuple[int, ...] + host_id: str + local_role: str + metadata: Mapping[str, object] + + +@dataclass(frozen=True) +class HostedOperationBinding: + """A concrete local-to-host relationship recovered from traced metadata.""" + + host_id: str + local_role: str + local: HostedGateRecord + host: HostedGateRecord + tick_separation: int + + +def validate_hosted_operations( + tick_circuit: object, + *, + host_id_key: str = HOST_ID_META_KEY, + local_role_key: str = LOCAL_ROLE_META_KEY, + max_tick_separation: int | None = None, + require_shared_qubit: bool = True, + require_host_after_local: bool = True, + context: str = "hosted operation validation", +) -> tuple[HostedOperationBinding, ...]: + """Validate and bind hosted-operation metadata in a traced ``TickCircuit``. + + A gate with ``local_role_key`` is a hosted local operation and must carry a + non-empty ``host_id_key``. By default it binds to the first later gate with + the same host id and no local role. When ``require_shared_qubit`` is true, + the bound host must touch at least one of the local gate's qubits. + + Args: + tick_circuit: PECOS ``TickCircuit``-like object with ``num_ticks()``, + ``get_tick()``, tick ``gate_batches()``, and optional + ``get_gate_meta(tick, gate, key)`` support. + host_id_key: Metadata key naming the source host relationship. + local_role_key: Metadata key identifying local operations. + max_tick_separation: Optional maximum absolute signed tick separation + between the local and selected host operation. + require_shared_qubit: Require the selected host to share a qubit with + the local operation. + require_host_after_local: Require host tick/gate order to follow local + tick/gate order. Disable only for metadata-shape audits that need + to report ordering drift instead of rejecting it immediately. + context: Human-readable context included in failures. + + Returns: + Tuple of recovered local-to-host bindings. + + Raises: + ValueError: If a local operation is missing a host id, has no later + compatible host, or exceeds ``max_tick_separation``. + TypeError: If the supplied object is not TickCircuit-like. + """ + if max_tick_separation is not None and max_tick_separation < 0: + msg = f"{context}: max_tick_separation must be non-negative, got {max_tick_separation}" + raise ValueError(msg) + + records = _hosted_gate_records( + tick_circuit, + host_id_key=host_id_key, + local_role_key=local_role_key, + context=context, + ) + bindings: list[HostedOperationBinding] = [] + for local in records: + if not local.local_role: + continue + if not local.host_id: + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} has local_role " + f"{local.local_role!r} but no non-empty {host_id_key!r} metadata." + ) + raise ValueError(msg) + host_candidates = _matching_host_records( + records, + local, + require_shared_qubit=require_shared_qubit, + ) + if not host_candidates: + shared_clause = " sharing a qubit" if require_shared_qubit else "" + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} and local_role {local.local_role!r} has no " + f"host gate{shared_clause} carrying the same host_id." + ) + raise ValueError(msg) + later_hosts = [ + candidate + for candidate in host_candidates + if _gate_order(candidate) > _gate_order(local) + ] + if require_host_after_local and not later_hosts: + nearest_host = host_candidates[-1] + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} and local_role {local.local_role!r} has matching " + f"host metadata only before it; nearest host is {nearest_host.gate_name}" + f"@t{nearest_host.tick_index}/g{nearest_host.gate_index}. This " + "indicates source-host ordering drift in the traced runtime schedule." + ) + raise ValueError(msg) + host = later_hosts[0] if later_hosts else host_candidates[-1] + tick_separation = host.tick_index - local.tick_index + if max_tick_separation is not None and abs(tick_separation) > max_tick_separation: + msg = ( + f"{context}: hosted local gate {local.gate_name}@t{local.tick_index}/" + f"g{local.gate_index} on qubits {local.qubits} with host_id " + f"{local.host_id!r} binds to {host.gate_name}@t{host.tick_index}/" + f"g{host.gate_index} with signed tick separation {tick_separation}, " + f"exceeding max_tick_separation={max_tick_separation}." + ) + raise ValueError(msg) + bindings.append( + HostedOperationBinding( + host_id=local.host_id, + local_role=local.local_role, + local=local, + host=host, + tick_separation=tick_separation, + ), + ) + return tuple(bindings) + + +def _matching_host_records( + records: Sequence[HostedGateRecord], + local: HostedGateRecord, + *, + require_shared_qubit: bool, +) -> tuple[HostedGateRecord, ...]: + local_qubits = set(local.qubits) + candidates: list[HostedGateRecord] = [] + for candidate in records: + if candidate.host_id != local.host_id or candidate.local_role: + continue + if require_shared_qubit and local_qubits.isdisjoint(candidate.qubits): + continue + candidates.append(candidate) + return tuple(candidates) + + +def _gate_order(record: HostedGateRecord) -> tuple[int, int]: + return (record.tick_index, record.gate_index) + + +def _hosted_gate_records( + tick_circuit: object, + *, + host_id_key: str, + local_role_key: str, + context: str, +) -> tuple[HostedGateRecord, ...]: + records: list[HostedGateRecord] = [] + for tick_index, gate_index, gate in _iter_tick_gates(tick_circuit, context=context): + metadata = _gate_metadata( + tick_circuit, + tick_index, + gate_index, + keys=(host_id_key, local_role_key), + ) + host_id = _metadata_text(metadata, host_id_key) + local_role = _metadata_text(metadata, local_role_key) + if not host_id and not local_role: + continue + records.append( + HostedGateRecord( + tick_index=tick_index, + gate_index=gate_index, + gate_name=_gate_name(gate), + qubits=tuple(int(qubit) for qubit in getattr(gate, "qubits", ())), + host_id=host_id, + local_role=local_role, + metadata=metadata, + ), + ) + return tuple(records) + + +def _iter_tick_gates( + tick_circuit: object, + *, + context: str, +) -> tuple[tuple[int, int, object], ...]: + try: + num_ticks = int(tick_circuit.num_ticks()) + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with num_ticks()." + raise TypeError(msg) from exc + + gate_locations: list[tuple[int, int, object]] = [] + for tick_index in range(num_ticks): + try: + tick = tick_circuit.get_tick(tick_index) + except AttributeError as exc: + msg = f"{context}: expected a TickCircuit with get_tick()." + raise TypeError(msg) from exc + if tick is None: + continue + try: + gate_batches = tick.gate_batches() + except AttributeError as exc: + msg = f"{context}: expected TickCircuit ticks with gate_batches()." + raise TypeError(msg) from exc + gate_locations.extend( + (tick_index, gate_index, gate) + for gate_index, gate in enumerate(gate_batches) + ) + return tuple(gate_locations) + + +def _gate_metadata( + tick_circuit: object, + tick_index: int, + gate_index: int, + *, + keys: Sequence[str], +) -> dict[str, object]: + getter = getattr(tick_circuit, "get_gate_meta", None) + if getter is None: + return {} + metadata: dict[str, object] = {} + for key in keys: + try: + value = getter(tick_index, gate_index, key) + except (AttributeError, IndexError, KeyError, TypeError): + continue + if value is not None: + metadata[key] = value + return metadata + + +def _metadata_text(metadata: Mapping[str, object], key: str) -> str: + value = metadata.get(key) + return "" if value is None else str(value) + + +def _gate_name(gate: object) -> str: + gate_type = getattr(gate, "gate_type", None) + name = getattr(gate_type, "name", None) + if name is not None: + return str(name) + if gate_type is not None: + return str(gate_type) + return type(gate).__name__ diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py new file mode 100644 index 000000000..b9a787ed9 --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -0,0 +1,197 @@ +from __future__ import annotations + +import pytest +from pecos.quantum.hosted import validate_hosted_operations + + +class FakeGateType: + def __init__(self, name: str) -> None: + self.name = name + + +class FakeGate: + def __init__( + self, + name: str, + qubits: list[int], + *, + meta: dict[str, object] | None = None, + ) -> None: + self.gate_type = FakeGateType(name) + self.qubits = qubits + self.meta = meta or {} + + +class FakeTick: + def __init__(self, gates: list[FakeGate]) -> None: + self._gates = gates + + def gate_batches(self) -> list[FakeGate]: + return self._gates + + +class FakeTickCircuit: + def __init__(self, ticks: list[list[FakeGate]]) -> None: + self._ticks = [FakeTick(gates) for gates in ticks] + + def num_ticks(self) -> int: + return len(self._ticks) + + def get_tick(self, tick_index: int) -> FakeTick: + return self._ticks[tick_index] + + def get_gate_meta(self, tick_index: int, gate_index: int, key: str) -> object: + return self._ticks[tick_index].gate_batches()[gate_index].meta[key] + + +def test_validate_hosted_operations_binds_local_to_later_host() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("Idle", [2])], + [FakeGate("SZZ", [2, 5], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit) + + assert len(bindings) == 1 + assert bindings[0].host_id == "host:a" + assert bindings[0].local_role == "basis_prefix" + assert bindings[0].local.gate_name == "H" + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].tick_separation == 2 + + +def test_validate_hosted_operations_selects_later_shared_host_record() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SX", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("RZ", [9], meta={"host_id": "host:a"})], + [FakeGate("SZZ", [2, 5], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].host.qubits == (2, 5) + + +def test_validate_hosted_operations_can_bind_without_shared_qubit_requirement() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SX", + [2], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("RZ", [9], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit, require_shared_qubit=False) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "RZ" + + +def test_validate_hosted_operations_rejects_ordering_drift_by_default() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="matching host metadata only before it"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_can_bind_prior_host_for_metadata_shape_audit() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + bindings = validate_hosted_operations(circuit, require_host_after_local=False) + + assert len(bindings) == 1 + assert bindings[0].host.gate_name == "SZZ" + assert bindings[0].tick_separation == -1 + + +def test_validate_hosted_operations_rejects_missing_local_host_id() -> None: + circuit = FakeTickCircuit( + [[FakeGate("H", [0], meta={"local_role": "basis_prefix"})]], + ) + + with pytest.raises(ValueError, match="no non-empty 'host_id' metadata"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_rejects_unbound_local() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "missing", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "other"})], + ], + ) + + with pytest.raises(ValueError, match="has no host gate sharing a qubit"): + validate_hosted_operations(circuit) + + +def test_validate_hosted_operations_rejects_large_tick_separation() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("Idle", [0])], + [FakeGate("Idle", [0])], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="exceeding max_tick_separation=2"): + validate_hosted_operations(circuit, max_tick_separation=2) From f37dbff58f877c7a2e2225defe3c49909b076fca Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 26 Jun 2026 13:59:21 -0600 Subject: [PATCH 265/388] Widen the SampleBatch decode methods to wide ObsMask masks (inline-fast for <=64, correct for >64) instead of rejecting >64: no truncation/panic, decode_count_batch decodes >64, decode_each returns Python ints --- .../src/fault_tolerance_bindings.rs | 94 +++++++------------ .../tests/qec/test_wide_observables.py | 52 +++++----- 2 files changed, 59 insertions(+), 87 deletions(-) diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index b68e8816d..4cd26e721 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2221,28 +2221,6 @@ fn subgraph_to_dem_string(graph: &pecos_decoder_core::DemMatchingGraph) -> Strin /// /// This is the shared factory used by `SampleBatch.decode_count`, /// `DemSampler.sample_decode_count`, and the parallel variants. -/// Reject a DEM whose observable count exceeds 64, the limit of the legacy -/// `u64` decode/decode_count APIs that build or compare `u64` observable masks -/// (`1u64 << obs_idx`). This guards on the *decoder/DEM* observable width, which -/// is independent of the batch's stored truth-mask width: a narrow batch paired -/// with a wide DEM would otherwise overflow `1 << j` in the prediction path. -/// Callers with more than 64 observables must use the wide -/// `LogicalSubgraphDecoder` decode/decode_count paths (arbitrary-precision int). -fn ensure_narrow_dem(dem: &str) -> PyResult<()> { - let parsed = dem - .parse::() - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; - let n = parsed.num_observables(); - if n > 64 { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "DEM has {n} observables, exceeding the 64-observable limit of this u64-based \ - decode API; use the wide LogicalSubgraphDecoder decode/decode_count paths \ - (arbitrary-precision int) for more than 64 observables" - ))); - } - Ok(()) -} - fn create_observable_decoder( dem: &str, decoder_type: &str, @@ -3568,15 +3546,18 @@ impl PySampleBatch { /// Number of logical errors. #[pyo3(signature = (dem, decoder_type="pymatching"))] fn decode_count(&self, dem: &str, decoder_type: &str) -> PyResult { - self.ensure_narrow_observables()?; - ensure_narrow_dem(dem)?; let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut errors = 0usize; let mut syndrome = vec![0u8; self.num_detectors]; for i in 0..self.num_shots { self.extract_syndrome(i, &mut syndrome); - let predicted = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); - if predicted != self.extract_obs_mask(i) { + // Wide ObsMask comparison: inline (one stack word) for the typical + // <=64 observables, correct without truncation beyond. A decode + // failure counts as a logical error (matching the prior sentinel). + let is_error = decoder + .decode_obs(&syndrome) + .map_or(true, |p| p != self.extract_obs_mask_wide(i)); + if is_error { errors += 1; } } @@ -3594,10 +3575,15 @@ impl PySampleBatch { /// `decoder_type`: Decoder type string. /// /// Returns: - /// List of predicted observable masks, one per shot. + /// List of predicted observable masks (Python ints; arbitrary precision, + /// so more than 64 observables are not truncated), one per shot. #[pyo3(signature = (dem, decoder_type="pymatching"))] - fn decode_each(&self, dem: &str, decoder_type: &str) -> PyResult> { - ensure_narrow_dem(dem)?; + fn decode_each( + &self, + py: Python<'_>, + dem: &str, + decoder_type: &str, + ) -> PyResult>> { let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut predictions = Vec::with_capacity(self.num_shots); let mut syndrome = vec![0u8; self.num_detectors]; @@ -3606,9 +3592,9 @@ impl PySampleBatch { // Propagate a decode failure rather than masking it as a sentinel // observable value (which would read as a spurious disagreement). let predicted = decoder - .decode_to_observables(&syndrome) + .decode_obs(&syndrome) .map_err(|e| PyErr::new::(e.to_string()))?; - predictions.push(predicted); + predictions.push(obsmask_to_py(py, &predicted)?); } Ok(predictions) } @@ -3633,8 +3619,6 @@ impl PySampleBatch { ) -> PyResult { use rayon::prelude::*; - self.ensure_narrow_observables()?; - ensure_narrow_dem(dem)?; let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); let pool = rayon::ThreadPoolBuilder::new() .num_threads(n_workers) @@ -3654,7 +3638,8 @@ impl PySampleBatch { s }) .collect(); - let observable_masks: Vec = (0..n).map(|i| self.extract_obs_mask(i)).collect(); + let observable_masks: Vec = + (0..n).map(|i| self.extract_obs_mask_wide(i)).collect(); let total_errors: usize = pool.install(|| { (0..n) @@ -3662,10 +3647,11 @@ impl PySampleBatch { .map_init( || create_observable_decoder(&dem_str, &dt).unwrap(), |decoder, i| { - let predicted = decoder - .decode_to_observables(&detection_events[i]) - .unwrap_or(u64::MAX); - usize::from(predicted != observable_masks[i]) + usize::from( + decoder + .decode_obs(&detection_events[i]) + .map_or(true, |p| p != observable_masks[i]), + ) }, ) .sum() @@ -3686,9 +3672,6 @@ impl PySampleBatch { fn decode_count_batch(&self, dem: &str) -> PyResult { use pecos_decoders::{BatchConfig, PyMatchingDecoder}; - self.ensure_narrow_observables()?; - ensure_narrow_dem(dem)?; - let mut decoder = PyMatchingDecoder::from_dem(dem) .map_err(|e| PyErr::new::(e.to_string()))?; @@ -3715,17 +3698,19 @@ impl PySampleBatch { .decode_batch_with_config(&flat, self.num_shots, num_detectors, config) .map_err(|e| PyErr::new::(e.to_string()))?; - // Count errors by comparing predictions to true observable masks + // Count errors by comparing predictions to true observable masks. The + // predicted mask is a wide ObsMask (inline for <=64 observables, correct + // beyond), so a DEM with more than 64 observables is not truncated. let num_observables = decoder.num_observables(); let mut num_errors = 0usize; for (i, prediction) in result.predictions.iter().enumerate() { - let mut predicted_mask = 0u64; + let mut predicted = pecos_decoder_core::obs_mask::ObsMask::new(); for (j, &v) in prediction.iter().enumerate() { if v != 0 && j < num_observables { - predicted_mask |= 1 << j; + predicted.set(j); } } - if predicted_mask != self.extract_obs_mask(i) { + if predicted != self.extract_obs_mask_wide(i) { num_errors += 1; } } @@ -3749,9 +3734,6 @@ impl PySampleBatch { fn decode_stats(&self, dem: &str, decoder_type: &str) -> PyResult { use std::time::Instant; - self.ensure_narrow_observables()?; - ensure_narrow_dem(dem)?; - let mut decoder = create_observable_decoder(dem, decoder_type)?; let mut num_errors = 0usize; let mut per_shot_seconds: Vec = Vec::with_capacity(self.num_shots); @@ -3760,10 +3742,10 @@ impl PySampleBatch { for i in 0..self.num_shots { self.extract_syndrome(i, &mut syndrome); let t0 = Instant::now(); - let predicted = decoder.decode_to_observables(&syndrome).unwrap_or(u64::MAX); + let predicted = decoder.decode_obs(&syndrome); let elapsed = t0.elapsed().as_secs_f64(); per_shot_seconds.push(elapsed); - if predicted != self.extract_obs_mask(i) { + if predicted.map_or(true, |p| p != self.extract_obs_mask_wide(i)) { num_errors += 1; } } @@ -3797,8 +3779,6 @@ impl PySampleBatch { ) -> PyResult { use rayon::prelude::*; - self.ensure_narrow_observables()?; - ensure_narrow_dem(dem)?; let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); // Validate decoder type early. @@ -3821,8 +3801,8 @@ impl PySampleBatch { s }) .collect(); - let observable_masks: Vec = (0..self.num_shots) - .map(|i| self.extract_obs_mask(i)) + let observable_masks: Vec = (0..self.num_shots) + .map(|i| self.extract_obs_mask_wide(i)) .collect(); // Each worker decodes a slice of shots and returns (errors, per_shot_times). @@ -3843,11 +3823,9 @@ impl PySampleBatch { for i in start..end { let t0 = std::time::Instant::now(); - let predicted = decoder - .decode_to_observables(&detection_events[i]) - .unwrap_or(u64::MAX); + let predicted = decoder.decode_obs(&detection_events[i]); times.push(t0.elapsed().as_secs_f64()); - if predicted != observable_masks[i] { + if predicted.map_or(true, |p| p != observable_masks[i]) { errors += 1; } } diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index a8a3f492d..ee206efed 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -89,45 +89,39 @@ def test_decode_count_above_64_observables() -> None: assert 0 <= count <= 2000 -def test_narrow_sample_batch_apis_reject_wide_observables() -> None: - # The legacy u64-based SampleBatch APIs cannot represent >64 observables and - # must reject a wide batch up front rather than panicking or truncating. +def test_u64_observable_getter_rejects_wide_batch() -> None: + # get_observable_mask returns a u64 and cannot represent observable >= 64, so + # it rejects a wide batch; get_observable_mask_wide returns the full Python + # int. (The decode methods, by contrast, compare wide ObsMasks and do not + # reject -- see below.) n = 65 dem, _ = _wide_dem(n) syn = [0] * n - syn[64] = 1 wide = SampleBatch([syn, syn], [1 << 64, 1 << 64]) with pytest.raises(ValueError, match="64-observable"): wide.get_observable_mask(0) - with pytest.raises(ValueError, match="64-observable"): - wide.decode_count(dem, "pecos_uf:fast") - with pytest.raises(ValueError, match="64-observable"): - wide.decode_stats(dem, "pecos_uf:fast") - - # The wide getter returns the full mask as a Python int with no truncation. assert wide.get_observable_mask_wide(0) == 1 << 64 -def test_narrow_decode_apis_reject_wide_dem() -> None: - # A DEM with >64 observables cannot be represented by the legacy u64 decode - # APIs (they build predictions with `1 << j`). The decoder/DEM width is - # independent of the batch's truth-mask width: a narrow batch (all-zero truth - # masks => 0 observable columns, which passes the batch-width guard) paired - # with a wide DEM must still be rejected up front, not overflow `1 << j`. +def test_sample_batch_decode_count_batch_handles_wide_dem() -> None: + # decode_count_batch builds wide ObsMask predictions from PyMatching's batch + # output, so a >64-observable DEM is decoded and compared with no truncation + # or panic (no `1 << j` overflow). n = 70 dem, _ = _wide_dem(n) syn = [0] * n - batch = SampleBatch([syn, syn], [0, 0]) # 0 observable columns (narrow batch) - - narrow_calls = [ - lambda: batch.decode_count(dem, "pecos_uf:fast"), - lambda: batch.decode_each(dem, "pecos_uf:fast"), - lambda: batch.decode_count_parallel(dem, "pecos_uf:fast"), - lambda: batch.decode_count_batch(dem), - lambda: batch.decode_stats(dem, "pecos_uf:fast"), - lambda: batch.decode_stats_parallel(dem, "pecos_uf:fast"), - ] - for call in narrow_calls: - with pytest.raises(ValueError, match="64-observable"): - call() + syn[69] = 1 # detector 69 fires => boundary error flips observable 69 + batch = SampleBatch([syn, syn], [1 << 69, 1 << 69]) # truth: observable 69 set + assert batch.decode_count_batch(dem) == 0 + + +def test_decode_each_returns_python_ints() -> None: + # decode_each returns Python ints (arbitrary precision) rather than u64, so + # the value is not truncated; for the <=64 case it equals the historical u64. + n = 5 + dem, _ = _wide_dem(n) + batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(8, seed=1) + preds = batch.decode_each(dem, "pymatching") + assert len(preds) == 8 + assert all(isinstance(p, int) for p in preds) From 568ed65db40140d2766a18afb14ab96a97d4479e Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 26 Jun 2026 14:24:55 -0600 Subject: [PATCH 266/388] Add strict hosted trace validation plumbing --- python/quantum-pecos/src/pecos/qec/dem.py | 17 ++- .../src/pecos/qec/surface/decode.py | 123 ++++++++++++++++-- .../tests/pecos/test_hosted_operations.py | 60 +++++++++ 3 files changed, 191 insertions(+), 9 deletions(-) diff --git a/python/quantum-pecos/src/pecos/qec/dem.py b/python/quantum-pecos/src/pecos/qec/dem.py index 53302e7d7..e2b0e8a9d 100644 --- a/python/quantum-pecos/src/pecos/qec/dem.py +++ b/python/quantum-pecos/src/pecos/qec/dem.py @@ -136,6 +136,8 @@ def from_guppy( p_idle_z_quadratic_sine_rate: float | None = None, runtime: object | None = None, seed: int = 0, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> _RustDetectorErrorModel: """Build a circuit-level DEM from a Guppy program by tracing it. @@ -244,6 +246,12 @@ def from_guppy( the default Selene runtime. Runtime plugin objects are passed through to ``pecos.selene_engine(runtime)``. seed: Seed for the ideal trace run. + require_hosted_operation_order: If true, validate generic + hosted-operation metadata after trace replay. A gate with + ``local_role`` metadata must bind to a later same-``host_id`` + host gate sharing a qubit. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A ``DetectorErrorModel`` built from the traced circuit. @@ -305,7 +313,14 @@ def from_guppy( ) raise ValueError(msg) from exc - tc = trace_guppy_into_tick_circuit(guppy, num_qubits, seed=seed, runtime=runtime) + tc = trace_guppy_into_tick_circuit( + guppy, + num_qubits, + seed=seed, + runtime=runtime, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + ) # Compilation passes required for traced QIS circuits before fault # analysis: normalize parameterized Clifford rotations to named gates, diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index b7db9f396..0ba338208 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -52,6 +52,7 @@ import numpy as np from pecos.qec.surface._check_plan import require_current_surface_check_plan_renderer, resolve_surface_check_plan +from pecos.quantum import validate_hosted_operations if TYPE_CHECKING: import stim @@ -1131,7 +1132,7 @@ def _lowered_gate_metadata(gate: Mapping[str, Any]) -> dict[str, Any]: return {} if not isinstance(metadata, Mapping): msg = f"Lowered gate metadata must be an object, got {metadata!r}" - raise ValueError(msg) + raise TypeError(msg) return {str(key): value for key, value in metadata.items()} @@ -1385,16 +1386,22 @@ def trace_guppy_into_tick_circuit_with_result_traces( seed: int = 0, runtime: object | None = None, measurement_crosstalk_topology: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a Guppy/QIS program into a ``TickCircuit`` plus result-tag provenance.""" chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return ( - _replay_qis_trace_chunks_into_tick_circuit( - chunks, - measurement_crosstalk_topology=measurement_crosstalk_topology, - ), - named_result_traces_from_operation_trace(chunks), + tick_circuit = _replay_qis_trace_chunks_into_tick_circuit( + chunks, + measurement_crosstalk_topology=measurement_crosstalk_topology, + ) + _validate_trace_hosted_operations_if_requested( + tick_circuit, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + context="trace_guppy_into_tick_circuit_with_result_traces", ) + return tick_circuit, named_result_traces_from_operation_trace(chunks) def trace_guppy_into_tick_circuit( @@ -1404,6 +1411,8 @@ def trace_guppy_into_tick_circuit( seed: int = 0, runtime: object | None = None, measurement_crosstalk_topology: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Trace a Guppy/QIS program's lowered Selene op stream into a ``TickCircuit``. @@ -1430,16 +1439,47 @@ def trace_guppy_into_tick_circuit( ``pecos.selene_engine(runtime)``. measurement_crosstalk_topology: Optional measurement-crosstalk replay mode for stamping global measurement-crosstalk payload markers. + require_hosted_operation_order: If true, validate generic hosted-operation + metadata after trace replay. A gate with ``local_role`` metadata + must bind to a later same-``host_id`` host gate sharing a qubit. + This catches runtime/compiler lowering that reorders hosted local + pulses after the operation they semantically prepare. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A ``TickCircuit`` with no detector/observable metadata attached; the caller supplies that. """ chunks = capture_guppy_operation_trace(program, num_qubits, seed=seed, runtime=runtime) - return _replay_qis_trace_chunks_into_tick_circuit( + tick_circuit = _replay_qis_trace_chunks_into_tick_circuit( chunks, measurement_crosstalk_topology=measurement_crosstalk_topology, ) + _validate_trace_hosted_operations_if_requested( + tick_circuit, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, + context="trace_guppy_into_tick_circuit", + ) + return tick_circuit + + +def _validate_trace_hosted_operations_if_requested( + tick_circuit: Any, + *, + require_hosted_operation_order: bool, + max_hosted_tick_separation: int | None, + context: str, +) -> None: + if not require_hosted_operation_order and max_hosted_tick_separation is None: + return + validate_hosted_operations( + tick_circuit, + max_tick_separation=max_hosted_tick_separation, + require_host_after_local=require_hosted_operation_order, + context=context, + ) def _generate_traced_surface_tick_circuit( @@ -1452,6 +1492,8 @@ def _generate_traced_surface_tick_circuit( check_plan: str | None = None, runtime: object | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Trace the lowered ideal Selene/QIS op stream and replay it into a TickCircuit. @@ -1476,6 +1518,8 @@ def _generate_traced_surface_tick_circuit( check_plan=check_plan, runtime=runtime, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) return tc @@ -1490,6 +1534,8 @@ def _generate_traced_surface_tick_circuit_with_result_traces( check_plan: str | None = None, runtime: object | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> tuple[Any, list[dict[str, Any]]]: """Trace a surface Guppy program into a ``TickCircuit`` plus result provenance.""" from pecos.guppy.surface import generate_memory_experiment, get_num_qubits @@ -1514,6 +1560,8 @@ def _generate_traced_surface_tick_circuit_with_result_traces( ), seed=0, runtime=runtime, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1530,6 +1578,8 @@ def _build_surface_tick_circuit_for_native_model( check_plan: str | None = None, szz_physical_prefixes: bool = False, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Build the TickCircuit used by the native DEM and sampler paths.""" from pecos.qec.surface.circuit_builder import _normalize_interaction_basis, generate_tick_circuit_from_patch @@ -1580,6 +1630,8 @@ def _build_surface_tick_circuit_for_native_model( check_plan=resolved_plan.plan_id, runtime=runtime, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) measurement_index_remap = _surface_runtime_measurement_remap_from_result_traces(abstract_tc, result_traces) @@ -1624,6 +1676,8 @@ def build_memory_circuit( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> Any: """Build the standard surface-code memory ``TickCircuit``. @@ -1650,6 +1704,11 @@ def build_memory_circuit( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for native abstract SZZ generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after trace replay. A + hosted local gate must appear before its same-``host_id`` host. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: A Rust-backed ``TickCircuit`` with detector and observable metadata. @@ -1685,6 +1744,8 @@ def build_memory_circuit( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1905,6 +1966,8 @@ def _surface_native_topology( check_plan: str | None = None, szz_physical_prefixes: bool = False, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: """Build topology-only native analysis shared across noise parameters.""" import json @@ -1936,6 +1999,8 @@ def _surface_native_topology( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) if circuit_source == "traced_qis": # Keep this surface helper aligned with DetectorErrorModel.from_guppy: @@ -2010,6 +2075,8 @@ def _cached_surface_native_topology( szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: """Cache topology-only native analysis shared across noise parameters.""" _ = resolved_check_plan_hash @@ -2025,6 +2092,8 @@ def _cached_surface_native_topology( check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2111,6 +2180,9 @@ def _cached_surface_native_dem_string( check_plan: str | None = None, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, + *, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> str: """Cache native DEM strings across callers for one topology + noise tuple.""" _ = resolved_check_plan_hash @@ -2149,6 +2221,8 @@ def _cached_surface_native_dem_string( szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_check_plan_hash, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) return _dem_string_from_cached_surface_topology( topology, @@ -2263,6 +2337,8 @@ def generate_circuit_level_dem_from_builder( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> str: """Generate circuit-level DEM using PECOS native fault propagation. @@ -2318,6 +2394,12 @@ def generate_circuit_level_dem_from_builder( policy for native SZZ generation. For ``circuit_source="traced_qis"``, the Guppy program is generated from the same concrete deformed checks before runtime result tags are bound to surface metadata. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. This is intended for source-local pulses that semantically + prepare a later host operation, such as SZZ/SZZdg data prefixes. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: DEM string in standard format @@ -2352,6 +2434,8 @@ def generate_circuit_level_dem_from_builder( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) return _dem_string_from_cached_surface_topology( topology, @@ -2383,6 +2467,8 @@ def generate_circuit_level_dem_from_builder( "check_plan": resolved_plan.plan_id, "resolved_check_plan_hash": resolved_plan.resolved_hash, "clifford_frame_policy": clifford_frame_policy, + "require_hosted_operation_order": require_hosted_operation_order, + "max_hosted_tick_separation": max_hosted_tick_separation, } if dem_decomposition != "source_graphlike": cache_kwargs["dem_decomposition"] = dem_decomposition @@ -3811,6 +3897,8 @@ def surface_code_memory( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> SimulationResult: """Run the recommended native surface-code memory workflow. @@ -3841,6 +3929,11 @@ def surface_code_memory( supplied. clifford_frame_policy: Optional source-level Clifford-deformation policy for native SZZ generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: ``SimulationResult`` with logical and raw error counts/rates. @@ -3881,6 +3974,8 @@ def surface_code_memory( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) batch = ParsedDem.from_string(dem).to_dem_sampler().generate_samples(shots, seed) num_raw_errors = sum(1 for shot in range(shots) if batch.get_observable_mask(shot) != 0) @@ -4182,6 +4277,9 @@ def build_native_sampler( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", check_plan: str | None = None, clifford_frame_policy: str | None = None, + *, + require_hosted_operation_order: bool = False, + max_hosted_tick_separation: int | None = None, ) -> NativeSampler: """Build a PECOS native sampler for threshold estimation. @@ -4222,6 +4320,11 @@ def build_native_sampler( supplied. clifford_frame_policy: Optional source-level Clifford-deformation policy for native abstract SZZ generation. + require_hosted_operation_order: For ``circuit_source="traced_qis"``, + validate generic hosted-operation metadata after runtime trace + replay. + max_hosted_tick_separation: Optional maximum absolute signed tick + separation accepted by the hosted-operation validator. Returns: NativeSampler that can generate samples for threshold estimation @@ -4255,6 +4358,8 @@ def build_native_sampler( szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -4293,6 +4398,8 @@ def build_native_sampler( check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + require_hosted_operation_order=require_hosted_operation_order, + max_hosted_tick_separation=max_hosted_tick_separation, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py index b9a787ed9..ce773f452 100644 --- a/python/quantum-pecos/tests/pecos/test_hosted_operations.py +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -1,6 +1,7 @@ from __future__ import annotations import pytest +from pecos.qec.surface.decode import _validate_trace_hosted_operations_if_requested from pecos.quantum.hosted import validate_hosted_operations @@ -195,3 +196,62 @@ def test_validate_hosted_operations_rejects_large_tick_separation() -> None: with pytest.raises(ValueError, match="exceeding max_tick_separation=2"): validate_hosted_operations(circuit, max_tick_separation=2) + + +def test_trace_hosted_validation_is_noop_unless_requested() -> None: + circuit = FakeTickCircuit( + [[FakeGate("H", [0], meta={"local_role": "basis_prefix"})]], + ) + + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=False, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + +def test_trace_hosted_validation_rejects_ordering_drift_when_requested() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="ordering drift"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=True, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + +def test_trace_hosted_validation_can_check_separation_without_order_guard() -> None: + circuit = FakeTickCircuit( + [ + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + ], + ) + + with pytest.raises(ValueError, match="exceeding max_tick_separation=0"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=False, + max_hosted_tick_separation=0, + context="test trace validation", + ) From 43a33bdc6b4844193b1bf1943607f42e91bb1b83 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 26 Jun 2026 16:36:37 -0600 Subject: [PATCH 267/388] Add hosted SZZ barrier handoff target --- docs/development/hosted-operations.md | 7 ++- .../tests/guppy/test_surface_twirl_render.py | 52 ++++++++++--------- .../tests/qec/test_from_guppy_dem.py | 31 +++++++++++ 3 files changed, 65 insertions(+), 25 deletions(-) diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md index 22313b5c0..8a256bba9 100644 --- a/docs/development/hosted-operations.md +++ b/docs/development/hosted-operations.md @@ -104,6 +104,9 @@ Current state: - A minimal Guppy public `barrier(...)` probe is currently optimized away before PECOS QIS operation collection. The captured raw operation trace contains allocations, gates, measurements, and releases, but no `Barrier` operation. +- The generated surface SZZ/SZZdg path can emit public Guppy barriers via + `szz_runtime_barriers`, but those barriers currently disappear for the same + reason before PECOS QIS operation collection. So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` @@ -177,7 +180,9 @@ duration or tick separation, and the relevant policy. ## Recommended Next Slice 1. Add a minimal barrier-survival diagnostic for public Guppy barriers in QIS - traces. + traces. Current strict-xfail regression targets: + `test_guppy_barrier_survives_into_qis_operation_trace` and + `test_szz_runtime_barrier_survives_into_qis_operation_trace`. 2. If preserving public barriers is small and generic, implement it as a separate quality-of-lowering improvement. 3. Prototype an explicit hosted SZZ prefix relationship in the QIS trace path. diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 05676e9ff..f01d8b44d 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -24,6 +24,32 @@ def patch() -> SurfacePatch: return SurfacePatch.create(distance=3) +def _assert_szz_prefix_barrier_host_order(src: str) -> None: + """Assert a real SZZ data-prefix pulse is fenced before its host.""" + lines = src.splitlines() + for index, line in enumerate(lines): + if "vdg(d1)" not in line: + continue + prefix_meta = next( + i + for i in range(index - 1, -1, -1) + if '"source_kind", "szz_data_prefix"' in lines[i] + ) + barrier_index = next(i for i in range(index + 1, len(lines)) if "barrier(" in lines[i]) + host_meta = next( + i + for i in range(barrier_index + 1, len(lines)) + if '"source_kind", "szz_host"' in lines[i] + ) + zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) + + assert prefix_meta < index < barrier_index < host_meta < zz_phase_index + return + + msg = "expected an SZZ touch with a Vdg data-prefix pulse" + raise AssertionError(msg) + + def test_no_twirl_source_has_no_rng_or_mask_tags(patch: SurfacePatch) -> None: src = generate_guppy_source(patch) @@ -130,18 +156,7 @@ def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) interaction_basis="szz", ) - lines = src.splitlines() - for index, line in enumerate(lines): - if "vdg(d1)" in line: - nearby = lines[index - 2 : index + 2] - assert "barrier(" in nearby[0] - assert "h(d1)" in nearby[1] - assert "vdg(d1)" in nearby[2] - assert "zz_phase(" in nearby[3] - break - else: - msg = "expected an SZZ touch with a Vdg data-prefix pulse" - raise AssertionError(msg) + _assert_szz_prefix_barrier_host_order(src) def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: SurfacePatch) -> None: @@ -159,18 +174,7 @@ def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: Surfac assert "barrier(" in prefix_src assert prefix_src.count("barrier(") < all_src.count("barrier(") - lines = prefix_src.splitlines() - for index, line in enumerate(lines): - if "vdg(d1)" in line: - nearby = lines[index - 2 : index + 2] - assert "barrier(" in nearby[0] - assert "h(d1)" in nearby[1] - assert "vdg(d1)" in nearby[2] - assert "zz_phase(" in nearby[3] - break - else: - msg = "expected an SZZ touch with a Vdg data-prefix pulse" - raise AssertionError(msg) + _assert_szz_prefix_barrier_host_order(prefix_src) def test_szz_runtime_barriers_reject_cx_source(patch: SurfacePatch) -> None: diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index b02b65f7e..a5644b66f 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -119,6 +119,37 @@ def test_guppy_barrier_survives_into_qis_operation_trace() -> None: assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) +@pytest.mark.xfail( + reason=( + "Generated SZZ runtime barriers are emitted as public Guppy barrier(...) " + "calls today, which are optimized away before PECOS QIS operation " + "collection; hosted SZZ scheduling needs a barrier-preserving or " + "hosted-operation lowering path." + ), + strict=True, +) +def test_szz_runtime_barrier_survives_into_qis_operation_trace() -> None: + program = make_surface_code( + distance=3, + num_rounds=1, + basis="Z", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + ) + chunks = capture_guppy_operation_trace( + program, + num_qubits=get_num_qubits(d=3, interaction_basis="szz"), + seed=0, + ) + operations = [ + operation + for chunk in chunks + for operation in chunk.get("operations", []) + ] + + assert any(operation == "Barrier" or "Barrier" in str(operation) for operation in operations) + + def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) lowered_ops = [ From a74ad8be5d7c0b055ca3323812b7cd1f11904499 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 26 Jun 2026 19:54:36 -0600 Subject: [PATCH 268/388] Invert the ObservableDecoder trait: decode_obs (wide ObsMask) is now the required primitive, decode_to_observables (u64) a derived default that errors on >64. Every inner decoder packs observables wide (PyMatching/DEM directly, matching decoders via from_u64), so the generic SampleBatch decode path fully handles >64 with no panic/truncation --- crates/pecos-decoder-core/src/adaptive.rs | 18 ++++++---- crates/pecos-decoder-core/src/bp_matching.rs | 8 ++--- .../src/correlated_decoder.rs | 8 ++--- crates/pecos-decoder-core/src/dem.rs | 24 +++++++++++-- crates/pecos-decoder-core/src/ensemble.rs | 19 +++++----- crates/pecos-decoder-core/src/k_mwpm.rs | 6 ++-- crates/pecos-decoder-core/src/lib.rs | 35 ++++++++++--------- .../src/logical_algorithm.rs | 9 ++--- .../src/logical_subgraph.rs | 13 ++++--- .../src/logical_subgraph/committed.rs | 6 ++-- .../pecos-decoder-core/src/multi_decoder.rs | 4 +-- crates/pecos-decoder-core/src/pauli_frame.rs | 9 ++--- crates/pecos-decoder-core/src/perturbed.rs | 6 ++-- crates/pecos-decoder-core/src/telemetry.rs | 11 +++--- .../src/two_pass_decoder.rs | 6 ++-- .../tests/ensemble_integration.rs | 16 ++++++--- .../pecos-fusion-blossom/src/core_traits.rs | 5 +-- crates/pecos-mwpf/src/core_traits.rs | 9 +++-- crates/pecos-pymatching/src/core_traits.rs | 9 +++-- crates/pecos-tesseract/src/decoder.rs | 8 +++-- crates/pecos-uf-decoder/src/astar.rs | 9 +++-- crates/pecos-uf-decoder/src/bp_uf.rs | 11 +++--- crates/pecos-uf-decoder/src/css_decoder.rs | 9 +++-- crates/pecos-uf-decoder/src/decoder.rs | 9 +++-- crates/pecos-uf-decoder/src/windowed.rs | 30 +++++++++++----- .../src/fault_tolerance_bindings.rs | 20 +++++------ .../tests/qec/test_wide_observables.py | 18 +++++++++- 27 files changed, 217 insertions(+), 118 deletions(-) diff --git a/crates/pecos-decoder-core/src/adaptive.rs b/crates/pecos-decoder-core/src/adaptive.rs index c05d5cc18..1e2e280e3 100644 --- a/crates/pecos-decoder-core/src/adaptive.rs +++ b/crates/pecos-decoder-core/src/adaptive.rs @@ -139,8 +139,8 @@ impl AdaptiveDecoder { } impl ObservableDecoder for AdaptiveDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - self.decoder.decode_to_observables(syndrome) + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { + self.decoder.decode_obs(syndrome) } } @@ -153,8 +153,11 @@ mod tests { let dec = AdaptiveDecoder::new("error(0.1) D0\n", |_dem| { struct Zero; impl ObservableDecoder for Zero { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(0) + fn decode_obs( + &mut self, + _: &[u8], + ) -> Result { + Ok(crate::obs_mask::ObsMask::new()) } } Ok(Box::new(Zero)) @@ -170,8 +173,11 @@ mod tests { let mut dec = AdaptiveDecoder::new("error(0.1) D0\n", |_dem| { struct Zero; impl ObservableDecoder for Zero { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(0) + fn decode_obs( + &mut self, + _: &[u8], + ) -> Result { + Ok(crate::obs_mask::ObsMask::new()) } } Ok(Box::new(Zero)) diff --git a/crates/pecos-decoder-core/src/bp_matching.rs b/crates/pecos-decoder-core/src/bp_matching.rs index 7e8f6477f..89198658d 100644 --- a/crates/pecos-decoder-core/src/bp_matching.rs +++ b/crates/pecos-decoder-core/src/bp_matching.rs @@ -79,7 +79,7 @@ impl BpMatchingDecoder { } impl crate::ObservableDecoder for BpMatchingDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { // Predecoder fast path: only for zero-defect syndromes. // At d>=5, always use full MWPM (predecoder can be suboptimal). // At d=3, BP + predecoder is actually better than MWPM for simple @@ -87,7 +87,7 @@ impl crate::ObservableDecoder for BpMat // predecoder and let MWPM handle everything for consistency. let num_defects = syndrome.iter().filter(|&&v| v != 0).count(); if num_defects == 0 { - return Ok(0); + return Ok(crate::obs_mask::ObsMask::new()); } // Compute BP-adjusted weights. @@ -117,11 +117,11 @@ impl crate::ObservableDecoder for BpMat let (obs, _) = self .matching .decode_with_weights(syndrome, &self.adjusted_weights)?; - return Ok(obs); + return Ok(crate::obs_mask::ObsMask::from_u64(obs)); } // Single-pass belief-matching. let (obs, _) = self.matching.decode_with_weights(syndrome, &bp_weights)?; - Ok(obs) + Ok(crate::obs_mask::ObsMask::from_u64(obs)) } } diff --git a/crates/pecos-decoder-core/src/correlated_decoder.rs b/crates/pecos-decoder-core/src/correlated_decoder.rs index 4c3f81e4c..167c5bc9c 100644 --- a/crates/pecos-decoder-core/src/correlated_decoder.rs +++ b/crates/pecos-decoder-core/src/correlated_decoder.rs @@ -136,7 +136,7 @@ impl CorrelatedDecoder { } impl crate::ObservableDecoder for CorrelatedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { self.shots_decoded += 1; // During training: decode normally, record matchings @@ -149,7 +149,7 @@ impl crate::ObservableDecoder for CorrelatedDecoder { self.base_weights = self.tracker.aligned_weights(); } - return Ok(mask); + return Ok(crate::obs_mask::ObsMask::from_u64(mask)); } // After training: two-pass decode with correlation adjustment @@ -163,7 +163,7 @@ impl crate::ObservableDecoder for CorrelatedDecoder { self.tracker.record_matching(&first_matching); if !self.config.use_correlation { - return Ok(first_mask); + return Ok(crate::obs_mask::ObsMask::from_u64(first_mask)); } // Build matched-edge flags for correlation adjustment @@ -184,7 +184,7 @@ impl crate::ObservableDecoder for CorrelatedDecoder { .inner .decode_with_weights(syndrome, &adjusted_weights)?; - Ok(second_mask) + Ok(crate::obs_mask::ObsMask::from_u64(second_mask)) } } diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index c2812664b..a4102628b 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -615,6 +615,26 @@ impl DemCheckMatrix { } mask } + + /// Pack observable predictions into an [`ObsMask`](crate::obs_mask::ObsMask). + /// + /// Bit `i` is set if observable `i` is predicted to flip. Unlike + /// [`Self::observables_mask_from_correction`], this supports more than 64 + /// observables without truncation or overflow. + #[must_use] + pub fn observables_obsmask_from_correction( + &self, + correction: &[u8], + ) -> crate::obs_mask::ObsMask { + let obs = self.observables_from_correction(correction); + let mut mask = crate::obs_mask::ObsMask::new(); + for (i, &v) in obs.iter().enumerate() { + if v != 0 { + mask.set(i); + } + } + mask + } } /// An edge in a matching graph extracted from a DEM. @@ -977,7 +997,7 @@ impl super::ObservableDecoder for CheckMatrixObservableDecoder where D: super::Decoder, { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { use super::DecodingResultTrait; // Copy syndrome into reusable buffer (no allocation after first call) @@ -995,7 +1015,7 @@ where .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; let correction = result.correction(); - Ok(self.dem.observables_mask_from_correction(correction)) + Ok(self.dem.observables_obsmask_from_correction(correction)) } } diff --git a/crates/pecos-decoder-core/src/ensemble.rs b/crates/pecos-decoder-core/src/ensemble.rs index c969f22c3..a2a3fa0b4 100644 --- a/crates/pecos-decoder-core/src/ensemble.rs +++ b/crates/pecos-decoder-core/src/ensemble.rs @@ -87,9 +87,9 @@ impl EnsembleDecoder { } impl ObservableDecoder for EnsembleDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { if self.decoders.is_empty() { - return Ok(0); + return Ok(crate::obs_mask::ObsMask::new()); } // Collect predictions from all decoders. @@ -135,7 +135,7 @@ impl ObservableDecoder for EnsembleDecoder { } } - Ok(result) + Ok(crate::obs_mask::ObsMask::from_u64(result)) } } @@ -168,11 +168,11 @@ impl ParallelEnsembleDecoder { } impl ObservableDecoder for ParallelEnsembleDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { use rayon::prelude::*; if self.decoders.is_empty() { - return Ok(0); + return Ok(crate::obs_mask::ObsMask::new()); } // Decode all members in parallel. @@ -193,7 +193,7 @@ impl ObservableDecoder for ParallelEnsembleDecoder { result |= mask; } } - Ok(result) + Ok(crate::obs_mask::ObsMask::from_u64(result)) } } @@ -205,8 +205,11 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _syndrome: &[u8]) -> Result { - Ok(self.0) + fn decode_obs( + &mut self, + _syndrome: &[u8], + ) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/k_mwpm.rs b/crates/pecos-decoder-core/src/k_mwpm.rs index 7814059f7..a5fe2bb52 100644 --- a/crates/pecos-decoder-core/src/k_mwpm.rs +++ b/crates/pecos-decoder-core/src/k_mwpm.rs @@ -72,14 +72,14 @@ impl KMwpmDecoder { } impl ObservableDecoder for KMwpmDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { let k = self.config.k; // First matching: standard MWPM. let (obs1, edges1) = self.decoder.decode_with_matching(syndrome)?; if edges1.is_empty() { - return Ok(obs1); + return Ok(crate::obs_mask::ObsMask::from_u64(obs1)); } // Collect K matchings via decoding tree. @@ -207,7 +207,7 @@ impl ObservableDecoder for KMwpmDecoder { result |= mask; } } - Ok(result) + Ok(crate::obs_mask::ObsMask::from_u64(result)) } } diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index ea2baf8b5..6e8054505 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -168,33 +168,34 @@ pub trait BatchDecoder: Decoder { /// orchestrator needs -- it doesn't care about decoder internals, weights, /// convergence, or matched edges. pub trait ObservableDecoder { - /// Decode a dense syndrome and return predicted observable flips as a bitmask. + /// Decode a dense syndrome and return predicted observable flips as a wide + /// [`ObsMask`](crate::obs_mask::ObsMask). /// - /// Bit `i` of the returned value is 1 if observable `i` is predicted to flip. + /// Bit `i` of the mask is set if observable `i` is predicted to flip. This is + /// the primitive every decoder implements; it carries one inline stack word + /// for the common `<= 64`-observable case and spills to the heap only beyond, + /// so more than 64 observables are supported with no truncation. /// /// # Errors /// /// Returns [`DecoderError`] if decoding fails. - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result; + fn decode_obs(&mut self, syndrome: &[u8]) -> Result; - /// Decode a dense syndrome and return predicted observable flips as an - /// [`ObsMask`](crate::obs_mask::ObsMask), which supports more than 64 - /// observables. + /// Narrowing convenience over [`Self::decode_obs`]: the predicted observable + /// flips packed into a `u64` (bit `i` = observable `i`). /// - /// This is the general entry point used by the transport layer - /// (`SampleBatch`, the Python bindings). The default implementation bridges - /// [`Self::decode_to_observables`] — the 64-observable `u64` fast path that - /// inner decoders implement. Decoders that can produce more than 64 - /// observables (the per-observable aggregators) override this to build a - /// wide mask directly, with no truncation. + /// Errors (rather than truncating) if the decoder has more than 64 + /// observables; callers that may exceed 64 should use [`Self::decode_obs`]. /// /// # Errors /// - /// Returns [`DecoderError`] if decoding fails. - fn decode_obs(&mut self, syndrome: &[u8]) -> Result { - Ok(crate::obs_mask::ObsMask::from_u64( - self.decode_to_observables(syndrome)?, - )) + /// Returns [`DecoderError`] if decoding fails or the mask exceeds 64 observables. + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + self.decode_obs(syndrome)?.to_u64().ok_or_else(|| { + DecoderError::InvalidConfiguration( + "decoder has more than 64 observables; use decode_obs() for the wide mask".into(), + ) + }) } /// Batch decode: flat buffer of `num_shots × num_detectors` bytes. diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index 4798fe666..e6507cfa3 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -267,6 +267,7 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// /// ``` /// use pecos_decoder_core::{DecoderError, ObservableDecoder}; +/// use pecos_decoder_core::obs_mask::ObsMask; /// use pecos_decoder_core::logical_algorithm::{ /// AlgorithmDescriptor, LogicalAlgorithmDecoder, SegmentDescriptor, StreamingLogicalDecoder, /// }; @@ -274,8 +275,8 @@ impl ObservableDecoder for LogicalAlgorithmDecoder { /// struct AnyDetectionDecoder; /// /// impl ObservableDecoder for AnyDetectionDecoder { -/// fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { -/// Ok(u64::from(syndrome.iter().any(|&bit| bit != 0))) +/// fn decode_obs(&mut self, syndrome: &[u8]) -> Result { +/// Ok(ObsMask::from_u64(u64::from(syndrome.iter().any(|&bit| bit != 0)))) /// } /// } /// @@ -808,8 +809,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/logical_subgraph.rs b/crates/pecos-decoder-core/src/logical_subgraph.rs index 5da515a07..3a68a335d 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph.rs @@ -786,18 +786,21 @@ mod tests { struct NullDecoder; impl ObservableDecoder for NullDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::new()) } } struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { if syndrome.iter().any(|&v| v != 0) { - Ok(self.0) + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } else { - Ok(0) + Ok(crate::obs_mask::ObsMask::new()) } } } diff --git a/crates/pecos-decoder-core/src/logical_subgraph/committed.rs b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs index 4397ebc40..27e2f1e8c 100644 --- a/crates/pecos-decoder-core/src/logical_subgraph/committed.rs +++ b/crates/pecos-decoder-core/src/logical_subgraph/committed.rs @@ -124,10 +124,12 @@ impl CommittedLogicalSubgraphDecoder { } impl ObservableDecoder for CommittedLogicalSubgraphDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { // Full decode: committed XOR active let active = self.decode_active(syndrome)?; - Ok(self.committed_obs ^ active) + Ok(crate::obs_mask::ObsMask::from_u64( + self.committed_obs ^ active, + )) } } diff --git a/crates/pecos-decoder-core/src/multi_decoder.rs b/crates/pecos-decoder-core/src/multi_decoder.rs index c88e291b6..b5d4c5ea4 100644 --- a/crates/pecos-decoder-core/src/multi_decoder.rs +++ b/crates/pecos-decoder-core/src/multi_decoder.rs @@ -174,8 +174,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/pauli_frame.rs b/crates/pecos-decoder-core/src/pauli_frame.rs index c5182c999..695957f6e 100644 --- a/crates/pecos-decoder-core/src/pauli_frame.rs +++ b/crates/pecos-decoder-core/src/pauli_frame.rs @@ -21,13 +21,14 @@ //! //! ``` //! use pecos_decoder_core::{DecoderError, ObservableDecoder}; +//! use pecos_decoder_core::obs_mask::ObsMask; //! use pecos_decoder_core::pauli_frame::PauliFrameAccumulator; //! //! struct FixedDecoder(u64); //! //! impl ObservableDecoder for FixedDecoder { -//! fn decode_to_observables(&mut self, _syndrome: &[u8]) -> Result { -//! Ok(self.0) +//! fn decode_obs(&mut self, _syndrome: &[u8]) -> Result { +//! Ok(ObsMask::from_u64(self.0)) //! } //! } //! @@ -185,8 +186,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/perturbed.rs b/crates/pecos-decoder-core/src/perturbed.rs index a2fb1e81e..7386a32ec 100644 --- a/crates/pecos-decoder-core/src/perturbed.rs +++ b/crates/pecos-decoder-core/src/perturbed.rs @@ -198,11 +198,11 @@ mod tests { // Trivial decoder that always returns 0. struct Zero; impl crate::ObservableDecoder for Zero { - fn decode_to_observables( + fn decode_obs( &mut self, _: &[u8], - ) -> Result { - Ok(0) + ) -> Result { + Ok(crate::obs_mask::ObsMask::new()) } } Ok(Box::new(Zero)) diff --git a/crates/pecos-decoder-core/src/telemetry.rs b/crates/pecos-decoder-core/src/telemetry.rs index 395fc6940..ea3d2d327 100644 --- a/crates/pecos-decoder-core/src/telemetry.rs +++ b/crates/pecos-decoder-core/src/telemetry.rs @@ -145,12 +145,13 @@ impl TelemetryDecoder { } impl ObservableDecoder for TelemetryDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { let syndrome_weight = syndrome.iter().filter(|&&v| v != 0).count() as u64; let start = Instant::now(); - let obs = self.inner.decode_to_observables(syndrome)?; + let obs = self.inner.decode_obs(syndrome)?; let elapsed_ns = start.elapsed().as_nanos() as u64; - self.stats.record(elapsed_ns, syndrome_weight, obs != 0); + self.stats + .record(elapsed_ns, syndrome_weight, !obs.is_zero()); Ok(obs) } } @@ -161,8 +162,8 @@ mod tests { struct FixedDecoder(u64); impl ObservableDecoder for FixedDecoder { - fn decode_to_observables(&mut self, _: &[u8]) -> Result { - Ok(self.0) + fn decode_obs(&mut self, _: &[u8]) -> Result { + Ok(crate::obs_mask::ObsMask::from_u64(self.0)) } } diff --git a/crates/pecos-decoder-core/src/two_pass_decoder.rs b/crates/pecos-decoder-core/src/two_pass_decoder.rs index 8c7298855..d081719af 100644 --- a/crates/pecos-decoder-core/src/two_pass_decoder.rs +++ b/crates/pecos-decoder-core/src/two_pass_decoder.rs @@ -59,11 +59,11 @@ impl TwoPassDecoder { } impl crate::ObservableDecoder for TwoPassDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs(&mut self, syndrome: &[u8]) -> Result { if !self.correlation_table.has_correlations() { // No correlations: single-pass decode (no overhead) let (mask, _) = self.inner.decode_with_matching(syndrome)?; - return Ok(mask); + return Ok(crate::obs_mask::ObsMask::from_u64(mask)); } // First pass: decode to get matched edges @@ -87,7 +87,7 @@ impl crate::ObservableDecoder for TwoPassDecoder { let (mask, _) = self .inner .decode_with_weights(syndrome, &self.adjusted_weights)?; - Ok(mask) + Ok(crate::obs_mask::ObsMask::from_u64(mask)) } } diff --git a/crates/pecos-decoder-core/tests/ensemble_integration.rs b/crates/pecos-decoder-core/tests/ensemble_integration.rs index 38073875c..7745910f4 100644 --- a/crates/pecos-decoder-core/tests/ensemble_integration.rs +++ b/crates/pecos-decoder-core/tests/ensemble_integration.rs @@ -23,12 +23,17 @@ struct ConfigurableDecoder { } impl ObservableDecoder for ConfigurableDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let has_defects = syndrome.iter().any(|&v| v != 0); if has_defects { - Ok(self.defect_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + self.defect_mask, + )) } else { - Ok(0) + Ok(pecos_decoder_core::obs_mask::ObsMask::new()) } } } @@ -37,7 +42,10 @@ impl ObservableDecoder for ConfigurableDecoder { struct FailingDecoder; impl ObservableDecoder for FailingDecoder { - fn decode_to_observables(&mut self, _syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + _syndrome: &[u8], + ) -> Result { Err(DecoderError::DecodingFailed("always fails".into())) } } diff --git a/crates/pecos-fusion-blossom/src/core_traits.rs b/crates/pecos-fusion-blossom/src/core_traits.rs index ee44d32b4..aacddedcf 100644 --- a/crates/pecos-fusion-blossom/src/core_traits.rs +++ b/crates/pecos-fusion-blossom/src/core_traits.rs @@ -37,11 +37,12 @@ impl Decoder for FusionBlossomDecoder { /// /// Uses the fast decode path with pre-computed observable bitmasks. impl pecos_decoder_core::ObservableDecoder for FusionBlossomDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { self.decode_to_obs_mask(syndrome) + .map(pecos_decoder_core::obs_mask::ObsMask::from_u64) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string())) } } diff --git a/crates/pecos-mwpf/src/core_traits.rs b/crates/pecos-mwpf/src/core_traits.rs index fe7922ccb..fface82e1 100644 --- a/crates/pecos-mwpf/src/core_traits.rs +++ b/crates/pecos-mwpf/src/core_traits.rs @@ -19,13 +19,16 @@ use crate::decoder::MwpfDecoder; /// This is the primary trait used by the fast decode path /// (`SampleBatch.decode_count`, `sample_decode_count`, etc.). impl pecos_decoder_core::ObservableDecoder for MwpfDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> std::result::Result { + ) -> std::result::Result + { let result = self .decode_syndrome(syndrome) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; - Ok(result.observable_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + result.observable_mask, + )) } } diff --git a/crates/pecos-pymatching/src/core_traits.rs b/crates/pecos-pymatching/src/core_traits.rs index 8a96c2b16..5131ba78b 100644 --- a/crates/pecos-pymatching/src/core_traits.rs +++ b/crates/pecos-pymatching/src/core_traits.rs @@ -188,14 +188,17 @@ impl DetailedDecoder for PyMatchingDecoder { /// /// Converts the observable vector to a bitmask for the sample+decode loop. impl ObservableDecoder for PyMatchingDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let result = self .decode(syndrome) .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; - let mut mask = 0u64; + let mut mask = pecos_decoder_core::obs_mask::ObsMask::new(); for (i, &v) in result.observable.iter().enumerate() { if v != 0 { - mask |= 1 << i; + mask.set(i); } } Ok(mask) diff --git a/crates/pecos-tesseract/src/decoder.rs b/crates/pecos-tesseract/src/decoder.rs index 4962d2163..102c9fff8 100644 --- a/crates/pecos-tesseract/src/decoder.rs +++ b/crates/pecos-tesseract/src/decoder.rs @@ -350,10 +350,10 @@ impl TesseractDecoder { } impl pecos_decoder_core::ObservableDecoder for TesseractDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { let detections: Vec = syndrome .iter() .enumerate() @@ -363,7 +363,9 @@ impl pecos_decoder_core::ObservableDecoder for TesseractDecoder { let result = self .decode_detections(&det_arr.view()) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; - Ok(result.observables_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + result.observables_mask, + )) } } diff --git a/crates/pecos-uf-decoder/src/astar.rs b/crates/pecos-uf-decoder/src/astar.rs index 1b0b56bc8..cf34b581e 100644 --- a/crates/pecos-uf-decoder/src/astar.rs +++ b/crates/pecos-uf-decoder/src/astar.rs @@ -306,7 +306,10 @@ impl AStarDecoder { } impl ObservableDecoder for AStarDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let n = self.num_detectors; let m = self.num_mechanisms; @@ -319,7 +322,7 @@ impl ObservableDecoder for AStarDecoder { } let num_defects = init_residual.count_ones(); if num_defects == 0 { - return Ok(0); + return Ok(pecos_decoder_core::obs_mask::ObsMask::new()); } // A* priority queue and visited set. @@ -462,7 +465,7 @@ impl ObservableDecoder for AStarDecoder { } } - Ok(best_obs) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(best_obs)) } } diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs index 428069a55..ac24baaf7 100644 --- a/crates/pecos-uf-decoder/src/bp_uf.rs +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -463,7 +463,10 @@ impl pecos_decoder_core::bp_matching::BpWeightProvider for BpUfDecoder { } impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { // Fast path: cluster predecoder handles isolated cases without BP. // This catches 0 defects, single defects, and isolated pairs. Gated on // the UF config like the plain `UfDecoder` paths. It deliberately runs @@ -473,7 +476,7 @@ impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { if self.uf.config.predecoder && let Some(obs) = self.uf.predecode_clusters(syndrome) { - return Ok(obs); + return Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs)); } let num_defects = syndrome.iter().filter(|&&v| v != 0).count(); @@ -571,10 +574,10 @@ impl pecos_decoder_core::ObservableDecoder for BpUfDecoder { let (mask2, _) = self .uf .decode_with_weights(syndrome, &self.adjusted_weights)?; - return Ok(mask2); + return Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(mask2)); } - Ok(mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(mask)) } } diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs index 051e970c1..29cdfc5c3 100644 --- a/crates/pecos-uf-decoder/src/css_decoder.rs +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -297,7 +297,10 @@ impl pecos_decoder_core::ObservableDecoder for CssUfDecoder { /// /// The syndrome is split at `x_num_detectors` into X and Z parts. /// Returns the XOR of both observable masks. - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let split = self.x_num_detectors; if syndrome.len() < split { return Err(DecoderError::DecodingFailed(format!( @@ -309,7 +312,9 @@ impl pecos_decoder_core::ObservableDecoder for CssUfDecoder { let x_syn = &syndrome[..split]; let z_syn = &syndrome[split..]; let (x_obs, z_obs) = self.decode_css(x_syn, z_syn)?; - Ok(x_obs ^ z_obs) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + x_obs ^ z_obs, + )) } } diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 85944b203..75ab81c39 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -1115,8 +1115,13 @@ impl UfDecoder { // === Trait implementations === impl pecos_decoder_core::ObservableDecoder for UfDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { - Ok(self.decode_syndrome(syndrome)) + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64( + self.decode_syndrome(syndrome), + )) } } diff --git a/crates/pecos-uf-decoder/src/windowed.rs b/crates/pecos-uf-decoder/src/windowed.rs index 8864adaf8..fad046960 100644 --- a/crates/pecos-uf-decoder/src/windowed.rs +++ b/crates/pecos-uf-decoder/src/windowed.rs @@ -120,7 +120,10 @@ impl WindowedDecoder { } impl ObservableDecoder for WindowedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let mut obs_mask = 0u64; for window in &mut self.windows { let mut window_syn = vec![0u8; window.num_local]; @@ -132,7 +135,7 @@ impl ObservableDecoder for WindowedDecoder { } obs_mask ^= window.decoder.decode_to_observables(&window_syn)?; } - Ok(obs_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs_mask)) } } @@ -227,7 +230,10 @@ impl OverlappingWindowedDecoder { } impl ObservableDecoder for OverlappingWindowedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let mut obs_mask = 0u64; for window in &mut self.windows { @@ -258,7 +264,7 @@ impl ObservableDecoder for OverlappingWindowedDecoder } } - Ok(obs_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs_mask)) } } @@ -450,7 +456,10 @@ impl SandwichWindowedDecoder { } impl ObservableDecoder for SandwichWindowedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let mut obs_mask = 0u64; let mut correction_effect = vec![0u8; self.num_detectors]; let commit_weight_max = self.commit_weight_max; @@ -504,7 +513,7 @@ impl ObservableDecoder for SandwichWindowedDecoder { } obs_mask ^= self.residual_decoder.decode_to_observables(&residual_syn)?; - Ok(obs_mask) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(obs_mask)) } } @@ -1033,7 +1042,10 @@ impl BeamSearchWindowedDecoder { } impl ObservableDecoder for BeamSearchWindowedDecoder { - fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + fn decode_obs( + &mut self, + syndrome: &[u8], + ) -> Result { let k = self.beam_width; let commit_weight_max = self.commit_weight_max; @@ -1123,7 +1135,7 @@ impl ObservableDecoder for BeamSearchWindowedDecoder // Each hypothesis may have a different Phase-1 obs_mask; we also run // Phase-2 on each to get the complete observable prediction. if beam.is_empty() { - return Ok(0); + return Ok(pecos_decoder_core::obs_mask::ObsMask::new()); } // Collect final observable predictions from each hypothesis. @@ -1155,7 +1167,7 @@ impl ObservableDecoder for BeamSearchWindowedDecoder result |= mask; } } - Ok(result) + Ok(pecos_decoder_core::obs_mask::ObsMask::from_u64(result)) } } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 4cd26e721..bf8aafa8b 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -428,11 +428,11 @@ struct SendWrapper(Box); unsafe impl Send for SendWrapper {} unsafe impl Sync for SendWrapper {} impl pecos_decoders::ObservableDecoder for SendWrapper { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { - self.0.decode_to_observables(syndrome) + ) -> Result { + self.0.decode_obs(syndrome) } } @@ -2141,10 +2141,10 @@ struct WeightedUfObservableDecoder { } impl pecos_decoders::ObservableDecoder for WeightedUfObservableDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { let arr = ndarray::Array1::from_vec(syndrome.to_vec()); // bits_per_step=1: grow one bit at a time, sorted by LLR weight. // bits_per_step=0 with non-empty LLRs causes the C++ UF decoder to @@ -2155,7 +2155,7 @@ impl pecos_decoders::ObservableDecoder for WeightedUfObservableDecoder { .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; Ok(self .dcm - .observables_mask_from_correction(result.decoding.as_slice().unwrap_or(&[]))) + .observables_obsmask_from_correction(result.decoding.as_slice().unwrap_or(&[]))) } } @@ -2169,10 +2169,10 @@ struct RelabeledObservableDecoder { } impl pecos_decoders::ObservableDecoder for RelabeledObservableDecoder { - fn decode_to_observables( + fn decode_obs( &mut self, syndrome: &[u8], - ) -> Result { + ) -> Result { // Relabel syndrome into the expanded vertex space (detectors + virtual + gap) let expected = self.decoder.num_nodes(); let mut relabeled = vec![0u8; expected]; @@ -2189,10 +2189,10 @@ impl pecos_decoders::ObservableDecoder for RelabeledObservableDecoder { .decoder .decode(&arr.view()) .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; - let mut mask = 0u64; + let mut mask = pecos_decoder_core::obs_mask::ObsMask::new(); for (i, &v) in result.observable.iter().enumerate() { if v != 0 { - mask |= 1 << i; + mask.set(i); } } Ok(mask) diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index ee206efed..2c7cb664d 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -95,7 +95,7 @@ def test_u64_observable_getter_rejects_wide_batch() -> None: # int. (The decode methods, by contrast, compare wide ObsMasks and do not # reject -- see below.) n = 65 - dem, _ = _wide_dem(n) + _dem, _ = _wide_dem(n) syn = [0] * n wide = SampleBatch([syn, syn], [1 << 64, 1 << 64]) @@ -116,6 +116,22 @@ def test_sample_batch_decode_count_batch_handles_wide_dem() -> None: assert batch.decode_count_batch(dem) == 0 +def test_generic_decode_handles_wide_dem() -> None: + # With wide inner decoders (PyMatching now packs an ObsMask directly), the + # generic decode_each / decode_count handle a >64-observable DEM end-to-end: + # predictions are wide Python ints with no truncation or panic. + n = 70 + dem, _ = _wide_dem(n) + syn = [0] * n + syn[69] = 1 # detector 69 => boundary error flips observable 69 + batch = SampleBatch([syn, syn], [1 << 69, 1 << 69]) # truth: observable 69 set + + preds = batch.decode_each(dem, "pymatching") + assert preds == [1 << 69, 1 << 69] # observable 69 predicted, not truncated + assert batch.decode_count(dem, "pymatching") == 0 # predictions match truth + assert batch.decode_count_parallel(dem, "pymatching") == 0 + + def test_decode_each_returns_python_ints() -> None: # decode_each returns Python ints (arbitrary precision) rather than u64, so # the value is not truncated; for the <=64 case it equals the historical u64. From 9f4602e40c5e6bff435e7d533fcbdec2b4617a0a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 26 Jun 2026 20:18:49 -0600 Subject: [PATCH 269/388] Fail fast when the default (non-growing) SparseStab quantum engine would be built with an inferred-zero qubit count from a dynamic classical engine, instead of constructing a 0-qubit engine that panics on first allocation --- crates/pecos-engines/src/sim_builder.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index 8079e9686..03d13f782 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -303,7 +303,21 @@ impl SimBuilder { builder.set_qubits_if_needed(num_qubits); builder.build_boxed()? } else { - // Default: sparse stabilizer + // Default: sparse stabilizer. It is fixed-size and does not grow, so + // an inferred-zero count (a dynamic classical engine that has not yet + // executed and reports 0) would build a 0-qubit engine that fails the + // moment the program allocates a qubit. Reject that at build time + // rather than silently constructing a non-growing 0-qubit default -- + // the caller must supply an explicit count via `.qubits(n)` or a + // (growing) quantum engine via `.quantum(...)`. + if self.explicit_num_qubits.is_none() && num_qubits == 0 { + return Err(PecosError::Input( + "Cannot infer a qubit count for the default quantum engine from a dynamic \ + classical engine that reports 0 qubits before execution. Specify .qubits(n) \ + or provide a quantum engine via .quantum()." + .to_string(), + )); + } Box::new(SparseStabEngine::new(num_qubits)) }; From ee2bd5f4f7b0938cf692f9e0702354c923e959a9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 26 Jun 2026 20:24:29 -0600 Subject: [PATCH 270/388] Strengthen the QIS cache key further: use the real build target triple (build-script TARGET, distinguishes gnu/musl) and fold the runtime-selected QIS FFI + Selene shim identity (path + mtime, incl. PECOS_SELENE_SHIM_PATH override) into the digest --- crates/pecos-qis/build.rs | 7 +++++++ crates/pecos-qis/src/executor.rs | 34 ++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/crates/pecos-qis/build.rs b/crates/pecos-qis/build.rs index ccaede835..bec15897b 100644 --- a/crates/pecos-qis/build.rs +++ b/crates/pecos-qis/build.rs @@ -25,6 +25,13 @@ fn main() { println!("cargo:rustc-env=PECOS_LLVM_BIN_PATH={}", llvm_bin.display()); } + // Embed the build target triple so the QIS program-cache key can scope cached + // shared objects to the exact target ABI (e.g. distinguish + // x86_64-unknown-linux-gnu from -musl), not just arch/os. + if let Ok(target) = env::var("TARGET") { + println!("cargo:rustc-env=PECOS_QIS_TARGET={target}"); + } + // Build Selene-specific components only when the selene feature is enabled #[cfg(feature = "selene")] build_selene::build_selene_components(); diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index b7dfa40dc..0a2df0c1a 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -130,11 +130,13 @@ fn build_fingerprint() -> u64 { }) } -/// A coarse target identifier (arch + OS) mixed into the cache key and recorded -/// in the manifest, so a shared object compiled for one target is never reused -/// on another even if the build fingerprint somehow matched. +/// The build target triple (e.g. `x86_64-unknown-linux-gnu`), embedded by the +/// build script, mixed into the cache key and recorded in the manifest so a +/// shared object compiled for one target ABI is never reused on another. Using +/// the full triple distinguishes ABIs a coarse `arch-os` would collapse (e.g. +/// gnu vs musl). fn cache_target() -> String { - format!("{}-{}", std::env::consts::ARCH, std::env::consts::OS) + env!("PECOS_QIS_TARGET").to_string() } /// An auditable record written next to each compiled program object in the @@ -1336,6 +1338,30 @@ impl QisHeliosInterface { hasher.update(env!("CARGO_PKG_VERSION").as_bytes()); hasher.update(cache_target().as_bytes()); hasher.update(build_fingerprint().to_le_bytes()); + // The compiled object resolves `__quantum__rt__*` / `selene_*` symbols at + // runtime from the QIS FFI shim and the Selene shim, which are SELECTED at + // runtime (library search order + the `PECOS_SELENE_SHIM_PATH` override) + // and may differ from the build-embedded libraries that the executable + // fingerprint captures. Fold each selected library's identity (path + + // last-modified time) into the key so swapping a shim invalidates a cached + // object that was compiled against the old one. + for lib in [ + Self::find_pecos_qis_lib().ok(), + crate::shim::get_shim_library_path(), + ] + .into_iter() + .flatten() + { + hasher.update(lib.to_string_lossy().as_bytes()); + let mtime_nanos = std::fs::metadata(&lib) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|d| d.as_nanos()); + if let Some(nanos) = mtime_nanos { + hasher.update(nanos.to_le_bytes()); + } + } let digest = hasher.finalize(); let mut content_hash = String::with_capacity(digest.len() * 2); for byte in digest { From 034eea677f494928f59707f68c0146880eb15b6e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 26 Jun 2026 20:30:54 -0600 Subject: [PATCH 271/388] Revert the inferred-zero default-quantum fail-fast: it broke the QASM sim path (QASMEngine reports 0 qubits + default quantum, which works fine because SparseStab grows on allocation). The path is benign, already covered by pecos-qasm expression_separation_test --- crates/pecos-engines/src/sim_builder.rs | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index 03d13f782..366b205c0 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -303,21 +303,10 @@ impl SimBuilder { builder.set_qubits_if_needed(num_qubits); builder.build_boxed()? } else { - // Default: sparse stabilizer. It is fixed-size and does not grow, so - // an inferred-zero count (a dynamic classical engine that has not yet - // executed and reports 0) would build a 0-qubit engine that fails the - // moment the program allocates a qubit. Reject that at build time - // rather than silently constructing a non-growing 0-qubit default -- - // the caller must supply an explicit count via `.qubits(n)` or a - // (growing) quantum engine via `.quantum(...)`. - if self.explicit_num_qubits.is_none() && num_qubits == 0 { - return Err(PecosError::Input( - "Cannot infer a qubit count for the default quantum engine from a dynamic \ - classical engine that reports 0 qubits before execution. Specify .qubits(n) \ - or provide a quantum engine via .quantum()." - .to_string(), - )); - } + // Default: sparse stabilizer. A 0-qubit default (an inferred-zero + // count from a dynamic classical engine that has not yet executed) is + // benign here -- the simulator grows as the program allocates qubits, + // as exercised by the QASM and QIS sim paths -- so it is built as-is. Box::new(SparseStabEngine::new(num_qubits)) }; From 514463c1e5071a2e095dc0dde7fa0761c90cb5af Mon Sep 17 00:00:00 2001 From: ciaranra Date: Fri, 26 Jun 2026 21:47:39 -0600 Subject: [PATCH 272/388] Add runtime barrier helper for SZZ traces --- crates/pecos-hugr-qis/src/compiler.rs | 17 ++++++++-- crates/pecos-qis-ffi/src/ffi.rs | 29 ++++++++++++++++ docs/development/hosted-operations.md | 16 +++++---- .../quantum-pecos/src/pecos/guppy/surface.py | 9 +++-- .../src/pecos/qec/surface/decode.py | 34 +++++++++++++++++++ .../tests/guppy/test_surface_twirl_render.py | 16 +++++---- .../tests/qec/surface/test_check_plan.py | 2 +- .../qec/surface/test_szz_interaction_basis.py | 26 ++++++++++++++ .../tests/qec/test_from_guppy_dem.py | 9 ----- 9 files changed, 131 insertions(+), 27 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 733d49292..4684cabf1 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -69,6 +69,7 @@ const METADATA: &[(&str, &[&str])] = &[("name", &["mainlib"])]; const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_hugr"; +const RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubit_hugr"; // Extension registry is defined in the parent module @@ -401,10 +402,14 @@ fn is_llvm_symbol_char(ch: char) -> bool { /// /// These helpers are part of PECOS's runtime ABI, not ordinary user functions, /// so they need stable external symbols for dynamic linking. Keep this rewrite -/// deliberately narrow: only the metadata helper declaration receives this +/// deliberately narrow: only PECOS-owned QIS helper declarations receive this /// treatment. fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { - let helper_symbols = [TRACE_METADATA_HUGR_SYMBOL, TRACE_METADATA_QUBIT_HUGR_SYMBOL]; + let helper_symbols = [ + TRACE_METADATA_HUGR_SYMBOL, + TRACE_METADATA_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + ]; let mut normalized = String::with_capacity(llvm_ir.len()); let mut cursor = 0; @@ -462,6 +467,8 @@ mod tests { "declare i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64, i8*, i8*)\n", "%q = call i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64 %0, i8* %1, i8* %2)\n", "%q2 = call i64 @__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21(i64 %0, i8* %1, i8* %2)\n", + "%q3 = call i64 @__hugr__.pecos_qis_runtime_barrier_qubit_hugr.22(i64 %0)\n", + "%q4 = call i64 @__hugr__.__main__.pecos_qis_runtime_barrier_qubit_hugr.23(i64 %0)\n", "call void @__hugr__.other_helper.16()\n", ) .to_string(); @@ -477,6 +484,12 @@ mod tests { assert!(normalized.contains( "%q2 = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" )); + assert!( + normalized.contains("%q3 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") + ); + assert!( + normalized.contains("%q4 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") + ); assert!(normalized.contains("@__hugr__.other_helper.16")); } } diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 3583705d1..d2655fcbe 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -534,6 +534,24 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_qubit_hugr( qubit } +/// Insert a runtime scheduling barrier after prior operations touching this qubit. +/// +/// Returning the qubit handle gives Guppy/HUGR a data dependency that keeps the +/// barrier between the preceding operation on this qubit and the following +/// operation that consumes the returned handle. The barrier itself is a +/// runtime-level batch/drain marker; it does not emit a quantum gate. +/// +/// # Safety +/// Called from C/LLVM code. Qubit must be a valid non-negative ID. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_runtime_barrier_qubit_hugr(qubit: i64) -> i64 { + let _ = i64_to_usize(qubit); + with_interface(|interface| { + interface.queue_operation(Operation::Barrier); + }); + qubit +} + /// Attach source/runtime metadata to the next lowerable quantum operation. /// /// This variant uses direct string data pointers instead of the tket2 string @@ -1629,6 +1647,17 @@ mod tests { }); } + #[test] + fn test_runtime_barrier_qubit_hugr_returns_qubit_and_queues_barrier() { + setup_test(); + let returned = unsafe { pecos_qis_runtime_barrier_qubit_hugr(17) }; + assert_eq!(returned, 17); + + with_interface(|iface| { + assert_eq!(iface.operations, vec![Operation::Barrier]); + }); + } + // --- Measurement and reset tests --- #[test] diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md index 8a256bba9..4f6ebf375 100644 --- a/docs/development/hosted-operations.md +++ b/docs/development/hosted-operations.md @@ -104,9 +104,11 @@ Current state: - A minimal Guppy public `barrier(...)` probe is currently optimized away before PECOS QIS operation collection. The captured raw operation trace contains allocations, gates, measurements, and releases, but no `Barrier` operation. -- The generated surface SZZ/SZZdg path can emit public Guppy barriers via - `szz_runtime_barriers`, but those barriers currently disappear for the same - reason before PECOS QIS operation collection. +- The generated surface SZZ/SZZdg path uses a PECOS-owned + `pecos_qis_runtime_barrier_qubit_hugr` helper for `szz_runtime_barriers`. + The helper returns its qubit argument to create a Guppy/HUGR data dependency + and queues a real `Operation::Barrier`, so Selene runtime lowering drains the + current batch before the following host operation. So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` @@ -180,9 +182,11 @@ duration or tick separation, and the relevant policy. ## Recommended Next Slice 1. Add a minimal barrier-survival diagnostic for public Guppy barriers in QIS - traces. Current strict-xfail regression targets: - `test_guppy_barrier_survives_into_qis_operation_trace` and - `test_szz_runtime_barrier_survives_into_qis_operation_trace`. + traces. Current strict-xfail target: + `test_guppy_barrier_survives_into_qis_operation_trace`. The generated SZZ + path has a positive regression, + `test_szz_runtime_barrier_survives_into_qis_operation_trace`, through the + PECOS runtime-barrier helper. 2. If preserving public barriers is small and generic, implement it as a separate quality-of-lowering improvement. 3. Prototype an explicit hosted SZZ prefix relationship in the QIS trace path. diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index e69f45d3d..2677fbdab 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -333,7 +333,7 @@ def generate_guppy_source( "", "from guppylang import guppy", "from guppylang.std.angles import angle", - "from guppylang.std.builtins import array, barrier, owned, result", + "from guppylang.std.builtins import array, owned, result", "from guppylang.std.qsystem.functional import zz_phase", "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", ] @@ -377,6 +377,9 @@ def generate_guppy_source( ") -> qubit: ..." ), "", + "@guppy.declare", + "def pecos_qis_runtime_barrier_qubit_hugr(q: qubit @ owned) -> qubit: ...", + "", "", ], ) @@ -774,8 +777,8 @@ def discharge_data_for_szz( and has_data_prefix ): target.append( - f"{indent}barrier({ancilla_expr(stab_type, stab_idx)}, " - f"{data_expr(data_q)})", + f"{indent}{data_expr(data_q)} = " + f"pecos_qis_runtime_barrier_qubit_hugr({data_expr(data_q)})", ) half_turns = "0.5" if sign > 0 else "-0.5" _append_szz_gate_trace_metadata( diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 0ba338208..168faf7f3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1492,6 +1492,7 @@ def _generate_traced_surface_tick_circuit( check_plan: str | None = None, runtime: object | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> Any: @@ -1518,6 +1519,7 @@ def _generate_traced_surface_tick_circuit( check_plan=check_plan, runtime=runtime, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1534,6 +1536,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( check_plan: str | None = None, runtime: object | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> tuple[Any, list[dict[str, Any]]]: @@ -1548,6 +1551,7 @@ def _generate_traced_surface_tick_circuit_with_result_traces( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) return trace_guppy_into_tick_circuit_with_result_traces( program, @@ -1578,6 +1582,7 @@ def _build_surface_tick_circuit_for_native_model( check_plan: str | None = None, szz_physical_prefixes: bool = False, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> Any: @@ -1630,6 +1635,7 @@ def _build_surface_tick_circuit_for_native_model( check_plan=resolved_plan.plan_id, runtime=runtime, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1676,6 +1682,7 @@ def build_memory_circuit( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> Any: @@ -1704,6 +1711,10 @@ def build_memory_circuit( check_plan: Named surface check-plan preset. clifford_frame_policy: Optional source-level Clifford-deformation policy for native abstract SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy used + for traced-QIS Guppy generation. This emits PECOS runtime barrier + helpers between selected data-prefix pulses and their host + SZZ/SZZdg operations. require_hosted_operation_order: For ``circuit_source="traced_qis"``, validate generic hosted-operation metadata after trace replay. A hosted local gate must appear before its same-``host_id`` host. @@ -1744,6 +1755,7 @@ def build_memory_circuit( interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -1966,6 +1978,7 @@ def _surface_native_topology( check_plan: str | None = None, szz_physical_prefixes: bool = False, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: @@ -1999,6 +2012,7 @@ def _surface_native_topology( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2075,6 +2089,7 @@ def _cached_surface_native_topology( szz_physical_prefixes: bool = False, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> _CachedNativeSurfaceTopology: @@ -2092,6 +2107,7 @@ def _cached_surface_native_topology( check_plan=check_plan, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2180,6 +2196,7 @@ def _cached_surface_native_dem_string( check_plan: str | None = None, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, *, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, @@ -2221,6 +2238,7 @@ def _cached_surface_native_dem_string( szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_check_plan_hash, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2337,6 +2355,7 @@ def generate_circuit_level_dem_from_builder( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> str: @@ -2394,6 +2413,8 @@ def generate_circuit_level_dem_from_builder( policy for native SZZ generation. For ``circuit_source="traced_qis"``, the Guppy program is generated from the same concrete deformed checks before runtime result tags are bound to surface metadata. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. require_hosted_operation_order: For ``circuit_source="traced_qis"``, validate generic hosted-operation metadata after runtime trace replay. This is intended for source-local pulses that semantically @@ -2434,6 +2455,7 @@ def generate_circuit_level_dem_from_builder( check_plan=resolved_plan.plan_id, szz_physical_prefixes=szz_physical_prefixes, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -2467,6 +2489,7 @@ def generate_circuit_level_dem_from_builder( "check_plan": resolved_plan.plan_id, "resolved_check_plan_hash": resolved_plan.resolved_hash, "clifford_frame_policy": clifford_frame_policy, + "szz_runtime_barriers": szz_runtime_barriers, "require_hosted_operation_order": require_hosted_operation_order, "max_hosted_tick_separation": max_hosted_tick_separation, } @@ -3897,6 +3920,7 @@ def surface_code_memory( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> SimulationResult: @@ -3929,6 +3953,8 @@ def surface_code_memory( supplied. clifford_frame_policy: Optional source-level Clifford-deformation policy for native SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. require_hosted_operation_order: For ``circuit_source="traced_qis"``, validate generic hosted-operation metadata after runtime trace replay. @@ -3974,6 +4000,7 @@ def surface_code_memory( interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -4277,6 +4304,7 @@ def build_native_sampler( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, *, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, @@ -4320,6 +4348,8 @@ def build_native_sampler( supplied. clifford_frame_policy: Optional source-level Clifford-deformation policy for native abstract SZZ generation. + szz_runtime_barriers: Optional SZZ/SZZdg runtime-barrier policy for + traced-QIS Guppy generation. require_hosted_operation_order: For ``circuit_source="traced_qis"``, validate generic hosted-operation metadata after runtime trace replay. @@ -4358,6 +4388,7 @@ def build_native_sampler( szz_physical_prefixes=szz_physical_prefixes, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -4398,6 +4429,7 @@ def build_native_sampler( check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, require_hosted_operation_order=require_hosted_operation_order, max_hosted_tick_separation=max_hosted_tick_separation, ) @@ -4436,6 +4468,7 @@ def build_native_sampler_from_dem( interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, + szz_runtime_barriers: bool | str = False, ) -> NativeSampler: """Build a native sampler from a caller-supplied decomposed DEM string. @@ -4463,6 +4496,7 @@ def build_native_sampler_from_dem( check_plan=resolved_plan.plan_id, resolved_check_plan_hash=resolved_plan.resolved_hash, clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, ) sampler = _cached_parsed_dem(decomposed_dem).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index f01d8b44d..845e573a2 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -19,6 +19,10 @@ from pecos.qec.surface.patch import SurfacePatch +_SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubit_hugr(" +_SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubit_hugr(" + + @pytest.fixture def patch() -> SurfacePatch: return SurfacePatch.create(distance=3) @@ -35,7 +39,7 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: for i in range(index - 1, -1, -1) if '"source_kind", "szz_data_prefix"' in lines[i] ) - barrier_index = next(i for i in range(index + 1, len(lines)) if "barrier(" in lines[i]) + barrier_index = next(i for i in range(index + 1, len(lines)) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) host_meta = next( i for i in range(barrier_index + 1, len(lines)) @@ -149,9 +153,9 @@ def test_szz_runtime_barriers_precede_data_prefix_and_host(patch: SurfacePatch) szz_runtime_barriers=True, ) - assert "from guppylang.std.builtins import array, barrier, owned, result" in src - assert "barrier(" in src - assert "barrier(" not in generate_guppy_source( + assert "from guppylang.std.builtins import array, owned, result" in src + assert _SZZ_RUNTIME_BARRIER_HELPER in src + assert _SZZ_RUNTIME_BARRIER_CALL not in generate_guppy_source( patch, interaction_basis="szz", ) @@ -171,8 +175,8 @@ def test_szz_data_prefix_runtime_barriers_only_guard_real_prefixes(patch: Surfac szz_runtime_barriers="data-prefix", ) - assert "barrier(" in prefix_src - assert prefix_src.count("barrier(") < all_src.count("barrier(") + assert _SZZ_RUNTIME_BARRIER_CALL in prefix_src + assert prefix_src.count(_SZZ_RUNTIME_BARRIER_CALL) < all_src.count(_SZZ_RUNTIME_BARRIER_CALL) _assert_szz_prefix_barrier_host_order(prefix_src) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index ec941932e..8af97250c 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -326,7 +326,7 @@ def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: ) prefix_index = source.index('"source_kind", "szz_data_prefix"') - barrier_index = source.index("barrier(") + barrier_index = source.index("= pecos_qis_runtime_barrier_qubit_hugr(") host_index = source.index('"source_kind", "szz_host"', prefix_index) zz_phase_index = source.index("zz_phase(", barrier_index) diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 2e5a1eda3..11b72a283 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -474,6 +474,32 @@ def test_szz_detector_paths_accept_abstract_and_traced_qis_basis() -> None: assert int(traced_memory_circuit.get_meta("num_detectors")) == int(tick_circuit.get_meta("num_detectors")) +def test_szz_runtime_barriers_allow_strict_traced_hosted_order() -> None: + patch = SurfacePatch.create(distance=3) + + tick_circuit = build_memory_circuit( + patch=patch, + rounds=1, + circuit_source="traced_qis", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + require_hosted_operation_order=True, + ) + assert tick_circuit.get_meta("circuit_source") == "traced_qis" + assert int(tick_circuit.get_meta("num_detectors")) > 0 + + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=1, + noise=NoiseModel(p1=0.0, p2=0.001, p_meas=0.0, p_prep=0.0), + circuit_source="traced_qis", + interaction_basis="szz", + szz_runtime_barriers="data-prefix", + require_hosted_operation_order=True, + ) + assert stim.DetectorErrorModel(dem).num_detectors == int(tick_circuit.get_meta("num_detectors")) + + @pytest.mark.parametrize("distance", [3, 5]) @pytest.mark.parametrize("basis", ["Z", "X"]) def test_szz_noiseless_detector_record_equivalence(distance: int, basis: str) -> None: diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index a5644b66f..cb0abfdd8 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -119,15 +119,6 @@ def test_guppy_barrier_survives_into_qis_operation_trace() -> None: assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) -@pytest.mark.xfail( - reason=( - "Generated SZZ runtime barriers are emitted as public Guppy barrier(...) " - "calls today, which are optimized away before PECOS QIS operation " - "collection; hosted SZZ scheduling needs a barrier-preserving or " - "hosted-operation lowering path." - ), - strict=True, -) def test_szz_runtime_barrier_survives_into_qis_operation_trace() -> None: program = make_surface_code( distance=3, From cf46094fcc4a950986cd257aa943cbed6a2f2fb9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 11:24:56 -0600 Subject: [PATCH 273/388] Fail loud (not overflow-panic) when a graphlike matching decoder is built on a >64-observable DEM: add DemMatchingGraph/DemCheckMatrix ensure_observables_fit_u64 guards at every matching-decoder construction site, directing >64 cases to pymatching or LogicalSubgraphDecoder (round-4 Finding A) --- crates/pecos-decoder-core/src/dem.rs | 44 +++++++++++++++++++ crates/pecos-fusion-blossom/src/decoder.rs | 23 ++++++++++ crates/pecos-mwpf/src/decoder.rs | 4 ++ crates/pecos-tesseract/src/decoder.rs | 12 +++++ crates/pecos-uf-decoder/src/astar.rs | 2 + crates/pecos-uf-decoder/src/bp_uf.rs | 2 + crates/pecos-uf-decoder/src/css_decoder.rs | 2 + crates/pecos-uf-decoder/src/decoder.rs | 1 + .../src/fault_tolerance_bindings.rs | 43 +++++++++++++++++- 9 files changed, 131 insertions(+), 2 deletions(-) diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index a4102628b..99e3fe233 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -581,6 +581,27 @@ impl DemCheckMatrix { }) } + /// Hyperedge matching decoders (MWPF, A* on the full DEM) pack observable + /// flips into a `u64` (`1 << observable`), so they support at most 64 + /// observables. Returns an error (rather than letting construction + /// overflow-panic on `1 << o` for `o >= 64`) if this DEM exceeds that, + /// directing callers to a wide decoder. + /// + /// # Errors + /// + /// Returns [`DecoderError::InvalidConfiguration`] if `num_observables > 64`. + pub fn ensure_observables_fit_u64(&self) -> Result<(), DecoderError> { + if self.num_observables > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "this matching decoder packs observables into a u64 and supports at most 64 \ + observables, but the DEM has {}; use the 'pymatching' decoder or \ + LogicalSubgraphDecoder for wider observable sets", + self.num_observables + ))); + } + Ok(()) + } + /// Compute the observable prediction from a correction vector. /// /// Given a binary correction vector (one entry per mechanism, from a @@ -841,6 +862,29 @@ impl DemMatchingGraph { }) } + /// Matching decoders pack observable flips into a `u64` (`1 << observable`), + /// so they support at most 64 observables. Returns an error (rather than + /// letting construction overflow-panic on `1 << o` for `o >= 64`) if this + /// graph exceeds that, directing callers to a wide decoder. + /// + /// Call this at the start of any matching-decoder construction that enters + /// the `1 << o` packing loop, on untrusted DEM input. + /// + /// # Errors + /// + /// Returns [`DecoderError::InvalidConfiguration`] if `num_observables > 64`. + pub fn ensure_observables_fit_u64(&self) -> Result<(), DecoderError> { + if self.num_observables > 64 { + return Err(DecoderError::InvalidConfiguration(format!( + "this matching decoder packs observables into a u64 and supports at most 64 \ + observables, but the DEM has {}; use the 'pymatching' decoder or \ + LogicalSubgraphDecoder for wider observable sets", + self.num_observables + ))); + } + Ok(()) + } + /// Merge edges with independent fault-ID-aware probability combination. /// /// Components from the same fault mechanism (same `fault_id`) that land on diff --git a/crates/pecos-fusion-blossom/src/decoder.rs b/crates/pecos-fusion-blossom/src/decoder.rs index ad05cd961..75fa0f154 100644 --- a/crates/pecos-fusion-blossom/src/decoder.rs +++ b/crates/pecos-fusion-blossom/src/decoder.rs @@ -282,6 +282,11 @@ impl FusionBlossomDecoder { /// /// Returns error if the graph is empty or construction fails. pub fn from_matching_graph(graph: &pecos_decoder_core::dem::DemMatchingGraph) -> Result { + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in the `1 << o` loop below. + graph + .ensure_observables_fit_u64() + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -368,6 +373,16 @@ impl FusionBlossomDecoder { parsed: &ParsedCorrelatedDem, weight_factors: Option<&[f64]>, ) -> Result { + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in build_obs_masks. + if parsed.num_observables > 64 { + return Err(FusionBlossomError::Configuration(format!( + "this matching decoder packs observables into a u64 and supports at most 64 \ + observables, but the DEM has {}; use the 'pymatching' decoder or \ + LogicalSubgraphDecoder for wider observable sets", + parsed.num_observables + ))); + } let config = FusionBlossomConfig { num_nodes: Some(parsed.num_detectors), num_observables: parsed.num_observables, @@ -446,6 +461,10 @@ impl FusionBlossomDecoder { dcm: &pecos_decoder_core::dem::DemCheckMatrix, weight_factors: Option<&[f64]>, ) -> Result { + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in build_obs_masks. + dcm.ensure_observables_fit_u64() + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; // Use Legacy solver which tolerates duplicate edges (no assertion). let config = FusionBlossomConfig { num_nodes: Some(dcm.num_detectors), @@ -511,6 +530,10 @@ impl FusionBlossomDecoder { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in build_obs_masks. + dcm.ensure_observables_fit_u64() + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(dcm.num_detectors), diff --git a/crates/pecos-mwpf/src/decoder.rs b/crates/pecos-mwpf/src/decoder.rs index 244fa6edf..c3e52343a 100644 --- a/crates/pecos-mwpf/src/decoder.rs +++ b/crates/pecos-mwpf/src/decoder.rs @@ -182,6 +182,10 @@ impl MwpfDecoder { pub fn from_dem(dem_str: &str, config: MwpfConfig) -> Result { let dem = DemCheckMatrix::from_dem_str(dem_str) .map_err(|e| MwpfError::InvalidDem(e.to_string()))?; + // Matching decoders pack observable flips into a u64; reject >64-observable + // DEMs as an error rather than overflow-panicking in the `1 << o` loop below. + dem.ensure_observables_fit_u64() + .map_err(|e| MwpfError::InvalidDem(e.to_string()))?; // Build hyperedges from the check matrix. Each mechanism (column) becomes // one HyperEdge with all its incident detectors. diff --git a/crates/pecos-tesseract/src/decoder.rs b/crates/pecos-tesseract/src/decoder.rs index 102c9fff8..517f4d8fd 100644 --- a/crates/pecos-tesseract/src/decoder.rs +++ b/crates/pecos-tesseract/src/decoder.rs @@ -185,6 +185,18 @@ impl TesseractDecoder { let num_errors = ffi::get_num_errors(&inner); let num_observables = ffi::get_num_observables(&inner); + // Tesseract reports its predicted observables as a u64 mask + // (`DecodingResult::observables_mask`), so it supports at most 64 + // observables. Reject wider DEMs as an error rather than silently + // truncating observables 64.. into the u64. + if num_observables > 64 { + return Err(TesseractError::InvalidConfig(format!( + "this matching decoder packs observables into a u64 and supports at most 64 \ + observables, but the DEM has {num_observables}; use the 'pymatching' decoder \ + or LogicalSubgraphDecoder for wider observable sets" + ))); + } + Ok(Self { inner, config, diff --git a/crates/pecos-uf-decoder/src/astar.rs b/crates/pecos-uf-decoder/src/astar.rs index cf34b581e..24238b78b 100644 --- a/crates/pecos-uf-decoder/src/astar.rs +++ b/crates/pecos-uf-decoder/src/astar.rs @@ -135,6 +135,7 @@ impl AStarDecoder { /// Returns `DecoderError` if the DEM is malformed. pub fn from_dem(dem: &str, config: AStarConfig) -> Result { let graph = DemMatchingGraph::from_dem_str(dem)?; + graph.ensure_observables_fit_u64()?; let num_detectors = graph.num_detectors; let mut mechanisms = Vec::new(); @@ -189,6 +190,7 @@ impl AStarDecoder { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| DecoderError::InvalidGraph(e.to_string()))?; + dcm.ensure_observables_fit_u64()?; let num_detectors = dcm.num_detectors; let mut mechanisms = Vec::new(); diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs index ac24baaf7..1630d0507 100644 --- a/crates/pecos-uf-decoder/src/bp_uf.rs +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -151,6 +151,7 @@ impl BpUfDecoder { let dcm = DemCheckMatrix::from_dem_str(dem) .map_err(|e| DecoderError::InvalidConfiguration(e.to_string()))?; let graph = DemMatchingGraph::from_dem_str(dem)?; + graph.ensure_observables_fit_u64()?; UfDecoder::check_non_negative_weights(&graph)?; let uf = UfDecoder::from_matching_graph(&graph, config.uf_config); @@ -253,6 +254,7 @@ impl BpUfDecoder { // Matching graph and UF from the decomposed DEM. let match_graph = DemMatchingGraph::from_dem_str(matching_dem)?; + match_graph.ensure_observables_fit_u64()?; UfDecoder::check_non_negative_weights(&match_graph)?; let uf = UfDecoder::from_matching_graph(&match_graph, config.uf_config); diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs index 29cdfc5c3..08011a756 100644 --- a/crates/pecos-uf-decoder/src/css_decoder.rs +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -113,6 +113,8 @@ impl CssUfDecoder { ) -> Result { let x_graph = DemMatchingGraph::from_dem_str(x_dem)?; let z_graph = DemMatchingGraph::from_dem_str(z_dem)?; + x_graph.ensure_observables_fit_u64()?; + z_graph.ensure_observables_fit_u64()?; UfDecoder::check_non_negative_weights(&x_graph)?; UfDecoder::check_non_negative_weights(&z_graph)?; diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 75ab81c39..3a1bb14f0 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -332,6 +332,7 @@ impl UfDecoder { /// proofs do not admit). pub fn from_dem(dem: &str, config: UfDecoderConfig) -> Result { let graph = DemMatchingGraph::from_dem_str(dem)?; + graph.ensure_observables_fit_u64()?; Self::check_non_negative_weights(&graph)?; Ok(Self::from_matching_graph(&graph, config)) } diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index bf8aafa8b..605d466ac 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2304,6 +2304,11 @@ fn create_observable_decoder( use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; // Use absolute weight scaling. Fusion Blossom uses integer weights; // we multiply by 1000 for precision (matching the internal 1000x @@ -2347,6 +2352,11 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), @@ -2451,6 +2461,11 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; // Group detectors by time coordinate for round-contiguous relabeling. let mut round_groups: std::collections::BTreeMap> = @@ -2746,8 +2761,12 @@ fn create_observable_decoder( |e| PyErr::new::(e.to_string()), )?; - // Reject negative-weight edges (error priors p > 0.5) as a Python - // error rather than panicking in `from_matching_graph`. + // Reject negative-weight edges (error priors p > 0.5) and >64-observable + // DEMs as Python errors rather than panicking in `from_matching_graph` + // (negative-weight assert / `1 << o` overflow). + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; pecos_decoders::UfDecoder::check_non_negative_weights(&graph) .map_err(|e| PyErr::new::(e.to_string()))?; let uf = pecos_decoders::UfDecoder::from_matching_graph( @@ -2783,6 +2802,11 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -2846,6 +2870,11 @@ fn create_observable_decoder( // Build Fusion Blossom as the matching backend. let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, @@ -2890,6 +2919,11 @@ fn create_observable_decoder( let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; // Build Fusion Blossom. let config = FusionBlossomConfig { @@ -2956,6 +2990,11 @@ fn create_observable_decoder( // Build Fusion Blossom from decomposed DEM. let graph = DemMatchingGraph::from_dem_str(dem) .map_err(|e| PyErr::new::(e.to_string()))?; + // Matching decoders pack observables into a u64; reject >64-observable + // DEMs rather than overflow-panicking in build_obs_masks. + graph + .ensure_observables_fit_u64() + .map_err(|e| PyErr::new::(e.to_string()))?; let config = FusionBlossomConfig { num_nodes: Some(graph.num_detectors), num_observables: graph.num_observables, From 0730a4bd3ff115d7b764107b9e6acf82edd06e32 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 11:33:55 -0600 Subject: [PATCH 274/388] Fix the inferred-zero default-quantum panic properly via a ClassicalEngine::has_dynamic_qubit_count capability: reject only dynamic-unknown-zero (QisEngine) + default fixed-size quantum, leaving genuinely-0-qubit static engines (classical-only QASM) untouched (round-4 Finding D) --- crates/pecos-engines/src/classical.rs | 12 ++++++++++++ crates/pecos-engines/src/sim_builder.rs | 26 +++++++++++++++++++++---- crates/pecos-qis/src/ccengine.rs | 6 ++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/pecos-engines/src/classical.rs b/crates/pecos-engines/src/classical.rs index 3c6dc3375..45e2856f4 100644 --- a/crates/pecos-engines/src/classical.rs +++ b/crates/pecos-engines/src/classical.rs @@ -18,6 +18,18 @@ pub trait ClassicalEngine: Engine + DynClone + Send + // Default implementation does nothing. } + /// Whether this engine's qubit count is only known after execution because it + /// allocates qubits dynamically. + /// + /// For such engines a [`Self::num_qubits`] of 0 before execution means "not + /// yet known", not "genuinely zero qubits". Static engines that parse their + /// whole program up front (e.g. QASM) know their exact count and return + /// `false` (the default); dynamic runtimes (e.g. the QIS/Selene runtime, + /// which discovers allocations during execution) return `true`. + fn has_dynamic_qubit_count(&self) -> bool { + false + } + /// Generate a `ByteMessage` containing the next batch of quantum commands to execute. /// An empty message indicates no more commands are available. /// diff --git a/crates/pecos-engines/src/sim_builder.rs b/crates/pecos-engines/src/sim_builder.rs index 366b205c0..bcd1b9142 100644 --- a/crates/pecos-engines/src/sim_builder.rs +++ b/crates/pecos-engines/src/sim_builder.rs @@ -303,10 +303,28 @@ impl SimBuilder { builder.set_qubits_if_needed(num_qubits); builder.build_boxed()? } else { - // Default: sparse stabilizer. A 0-qubit default (an inferred-zero - // count from a dynamic classical engine that has not yet executed) is - // benign here -- the simulator grows as the program allocates qubits, - // as exercised by the QASM and QIS sim paths -- so it is built as-is. + // Default: fixed-size sparse stabilizer. It does NOT grow, so a + // 0-qubit default is only correct for a program that genuinely uses + // zero qubits (e.g. a classical-only QASM program, which reports a + // STATIC 0). A dynamic engine (e.g. QIS) instead reports 0 *before + // execution* and then allocates qubits at runtime; building a 0-qubit + // fixed engine for it would panic on the first allocation. Reject only + // that specific case -- no explicit count, no quantum engine, and a + // dynamic-unknown-zero classical engine -- and ask for an explicit + // `.qubits(n)` or a `.quantum(...)` engine. Genuinely-0-qubit and + // explicit-count programs are unaffected. + if self.explicit_num_qubits.is_none() + && num_qubits == 0 + && classical_engine.has_dynamic_qubit_count() + { + return Err(PecosError::Input( + "A dynamic classical engine reports 0 qubits before execution, but the \ + default quantum engine is fixed-size and cannot grow to fit qubits \ + allocated at runtime. Specify .qubits(n) or provide a quantum engine \ + via .quantum()." + .to_string(), + )); + } Box::new(SparseStabEngine::new(num_qubits)) }; diff --git a/crates/pecos-qis/src/ccengine.rs b/crates/pecos-qis/src/ccengine.rs index af3978717..bec34d50b 100644 --- a/crates/pecos-qis/src/ccengine.rs +++ b/crates/pecos-qis/src/ccengine.rs @@ -1291,6 +1291,12 @@ impl ClassicalEngine for QisEngine { num_qubits } + /// QIS programs allocate qubits dynamically during execution, so a + /// pre-execution count of 0 means "not yet discovered", not "zero qubits". + fn has_dynamic_qubit_count(&self) -> bool { + true + } + fn set_num_qubits_hint(&mut self, num_qubits: usize) { self.num_qubits_hint = Some(num_qubits); self.runtime.set_num_qubits(num_qubits); From 02237445be94c91fac6d2e830630593cf2618380 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 12:26:36 -0600 Subject: [PATCH 275/388] Pin the selected QIS FFI + selene shim library paths once (OnceLock) and route both the cache-key digest and the dlopen singletons through them, so the hashed library identity always matches the loaded one even if PECOS_SELENE_SHIM_PATH/search state changes mid-process (round-4 Finding C) --- crates/pecos-qis/src/executor.rs | 39 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index 0a2df0c1a..a9faa97d7 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -43,6 +43,19 @@ static QIS_FFI_LIB_SINGLETON: OnceLock> = OnceLock /// By making it a singleton, we load it once and keep it for the process lifetime. static SHIM_LIB_SINGLETON: OnceLock> = OnceLock::new(); +/// Pinned selected library paths, resolved once at first use. +/// +/// The QIS FFI and selene shim libraries are discovered at runtime (library +/// search order, and for the shim the `PECOS_SELENE_SHIM_PATH` override). Both +/// the program-cache key (which hashes the selected library identity) and the +/// `dlopen` singletons must agree on WHICH library is selected; if discovery +/// re-ran independently and the environment changed in between, the key could be +/// computed for one library while another was loaded. Pinning the discovery +/// result the first time either path is needed keeps them consistent for the +/// process lifetime. +static QIS_FFI_LIB_PATH: OnceLock> = OnceLock::new(); +static SHIM_LIB_PATH: OnceLock> = OnceLock::new(); + /// Process-wide cache for program libraries (keyed by file path). /// /// When engines are cloned for parallel shot execution, each clone creates its own @@ -823,6 +836,24 @@ impl QisHeliosInterface { } } + /// The selected QIS FFI library path, resolved once and pinned for the + /// process lifetime (see [`QIS_FFI_LIB_PATH`]). Both the cache key and the + /// `dlopen` singleton use this so they always agree on the selected library. + fn pinned_qis_ffi_lib_path() -> Result { + QIS_FFI_LIB_PATH + .get_or_init(|| Self::find_pecos_qis_lib().map_err(|e| e.to_string())) + .clone() + } + + /// The selected selene shim library path, resolved once and pinned for the + /// process lifetime (see [`SHIM_LIB_PATH`]). Honors `PECOS_SELENE_SHIM_PATH` + /// at first resolution only, so the cache key and the loaded shim agree. + fn pinned_shim_lib_path() -> Option { + SHIM_LIB_PATH + .get_or_init(crate::shim::get_shim_library_path) + .clone() + } + /// Find the `libpecos_qis_ffi` library by searching common locations fn find_pecos_qis_lib() -> Result { // On Windows, Rust cdylibs don't use the "lib" prefix @@ -930,7 +961,7 @@ impl QisHeliosInterface { /// /// Returns a reference to the `SharedLibrary` wrapper for symbol lookups. fn get_qis_ffi_lib_singleton() -> Result<&'static SharedLibrary, InterfaceError> { - let result = QIS_FFI_LIB_SINGLETON.get_or_init(|| match Self::find_pecos_qis_lib() { + let result = QIS_FFI_LIB_SINGLETON.get_or_init(|| match Self::pinned_qis_ffi_lib_path() { Ok(lib_path) => { debug!( "Initializing QIS FFI library singleton from: {}", @@ -1025,7 +1056,7 @@ impl QisHeliosInterface { /// By making it a singleton, we load once and keep it for the process lifetime. fn get_shim_lib_singleton() -> Result<&'static SharedLibrary, InterfaceError> { let result = SHIM_LIB_SINGLETON.get_or_init(|| { - let shim_path = crate::shim::get_shim_library_path().ok_or_else(|| { + let shim_path = Self::pinned_shim_lib_path().ok_or_else(|| { "PECOS selene shim library not found - build script may have failed".to_string() })?; @@ -1346,8 +1377,8 @@ impl QisHeliosInterface { // last-modified time) into the key so swapping a shim invalidates a cached // object that was compiled against the old one. for lib in [ - Self::find_pecos_qis_lib().ok(), - crate::shim::get_shim_library_path(), + Self::pinned_qis_ffi_lib_path().ok(), + Self::pinned_shim_lib_path(), ] .into_iter() .flatten() From e5b889c4a9fe11220d02657dcf229fd70d2a6fe0 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 12:57:08 -0600 Subject: [PATCH 276/388] Reject ambiguous hosted operation metadata --- .../src/pecos/qec/surface/decode.py | 1 + .../quantum-pecos/src/pecos/quantum/hosted.py | 47 +++++++++++ .../tests/pecos/test_hosted_operations.py | 83 +++++++++++++++++++ 3 files changed, 131 insertions(+) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 168faf7f3..933d6b8e5 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -1478,6 +1478,7 @@ def _validate_trace_hosted_operations_if_requested( tick_circuit, max_tick_separation=max_hosted_tick_separation, require_host_after_local=require_hosted_operation_order, + require_unique_host_id=True, context=context, ) diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py index cfaac8cc3..48a01606e 100644 --- a/python/quantum-pecos/src/pecos/quantum/hosted.py +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -53,6 +53,7 @@ def validate_hosted_operations( max_tick_separation: int | None = None, require_shared_qubit: bool = True, require_host_after_local: bool = True, + require_unique_host_id: bool = False, context: str = "hosted operation validation", ) -> tuple[HostedOperationBinding, ...]: """Validate and bind hosted-operation metadata in a traced ``TickCircuit``. @@ -75,6 +76,10 @@ def validate_hosted_operations( require_host_after_local: Require host tick/gate order to follow local tick/gate order. Disable only for metadata-shape audits that need to report ordering drift instead of rejecting it immediately. + require_unique_host_id: Require each host id to appear on at most one + host gate. Enable this for strict validation because repeated host + records make first-later-host binding ambiguous across repeated + helper invocations. context: Human-readable context included in failures. Returns: @@ -95,6 +100,12 @@ def validate_hosted_operations( local_role_key=local_role_key, context=context, ) + if require_unique_host_id: + _raise_if_repeated_host_records( + records, + host_id_key=host_id_key, + context=context, + ) bindings: list[HostedOperationBinding] = [] for local in records: if not local.local_role: @@ -176,6 +187,42 @@ def _matching_host_records( return tuple(candidates) +def _raise_if_repeated_host_records( + records: Sequence[HostedGateRecord], + *, + host_id_key: str, + context: str, +) -> None: + host_records_by_id: dict[str, list[HostedGateRecord]] = {} + for record in records: + if record.local_role or not record.host_id: + continue + host_records_by_id.setdefault(record.host_id, []).append(record) + repeated = { + host_id: host_records + for host_id, host_records in host_records_by_id.items() + if len(host_records) > 1 + } + if not repeated: + return + host_id, host_records = next(iter(repeated.items())) + first_locations = ", ".join( + f"{record.gate_name}@t{record.tick_index}/g{record.gate_index}" + for record in host_records[:4] + ) + extra_count = len(host_records) - 4 + if extra_count > 0: + first_locations = f"{first_locations}, ... (+{extra_count} more)" + msg = ( + f"{context}: hosted metadata key {host_id_key!r} is ambiguous because " + f"host_id {host_id!r} appears on {len(host_records)} host gates " + f"({first_locations}). Strict hosted-operation validation requires " + "host ids to identify one source host gate; include invocation-scoped " + "metadata before validating ordering or tick separation." + ) + raise ValueError(msg) + + def _gate_order(record: HostedGateRecord) -> tuple[int, int]: return (record.tick_index, record.gate_index) diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py index ce773f452..fb53aa460 100644 --- a/python/quantum-pecos/tests/pecos/test_hosted_operations.py +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -92,6 +92,58 @@ def test_validate_hosted_operations_selects_later_shared_host_record() -> None: assert bindings[0].host.qubits == (2, 5) +def test_validate_hosted_operations_can_require_unique_host_ids() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="host_id 'host:a' appears on 2 host gates"): + validate_hosted_operations(circuit, require_unique_host_id=True) + + +def test_validate_hosted_operations_unique_host_ids_allow_many_locals() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + FakeGate( + "S", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + bindings = validate_hosted_operations(circuit, require_unique_host_id=True) + + assert len(bindings) == 2 + assert {binding.local.gate_name for binding in bindings} == {"H", "S"} + assert all(binding.host.gate_name == "SZZ" for binding in bindings) + + def test_validate_hosted_operations_can_bind_without_shared_qubit_requirement() -> None: circuit = FakeTickCircuit( [ @@ -234,6 +286,37 @@ def test_trace_hosted_validation_rejects_ordering_drift_when_requested() -> None ) +def test_trace_hosted_validation_rejects_repeated_host_ids_when_requested() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + [ + FakeGate( + "H", + [0], + meta={"host_id": "host:a", "local_role": "basis_prefix"}, + ), + ], + [FakeGate("SZZ", [0, 1], meta={"host_id": "host:a"})], + ], + ) + + with pytest.raises(ValueError, match="host_id 'host:a' appears on 2 host gates"): + _validate_trace_hosted_operations_if_requested( + circuit, + require_hosted_operation_order=True, + max_hosted_tick_separation=None, + context="test trace validation", + ) + + def test_trace_hosted_validation_can_check_separation_without_order_guard() -> None: circuit = FakeTickCircuit( [ From 3c903e118dc68128f093adb81bd184cac509e6cc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 13:44:17 -0600 Subject: [PATCH 277/388] Make PyMatching's narrow decode_batch_to_observables u64 batch API fail loud on >64 observables (8+ bit-packed bytes) instead of silently overflowing the shift; wide callers use decode_obs (round-4 minor) --- crates/pecos-pymatching/src/core_traits.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/pecos-pymatching/src/core_traits.rs b/crates/pecos-pymatching/src/core_traits.rs index 5131ba78b..bf1690a3e 100644 --- a/crates/pecos-pymatching/src/core_traits.rs +++ b/crates/pecos-pymatching/src/core_traits.rs @@ -220,6 +220,18 @@ impl ObservableDecoder for PyMatchingDecoder { .decode_batch_with_config(shots, num_shots, num_detectors, config) .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; + // This narrow batch API packs observables into a u64 (8 bit-packed + // bytes), so it caps at 64 observables; a wider decode would overflow the + // shift. Fail loud rather than truncate -- callers needing >64 observables + // use the wide per-shot `decode_obs` path. + if result.predictions.first().is_some_and(|p| p.len() > 8) { + return Err(DecoderError::InvalidConfiguration( + "decode_batch_to_observables packs observables into a u64 and supports at most \ + 64 observables; use decode_obs for wider observable sets" + .to_string(), + )); + } + // Convert per-shot bit-packed predictions to u64 masks. let mut masks = Vec::with_capacity(num_shots); for pred in &result.predictions { From 19f666e5cfca7eb6455fb8ea608be4ddc3560a79 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 14:14:09 -0600 Subject: [PATCH 278/388] Apply rustfmt to the slr-polish-inherited crates (zlup, guppy-zlup, zluppy, pecos-phir-pliron) so cargo fmt --all --check is clean --- crates/pecos-phir-pliron/src/lib.rs | 1277 ++++++++++--- exp/guppy-zlup/src/compiler.rs | 4 +- exp/guppy-zlup/src/compiler/transform.rs | 153 +- exp/guppy-zlup/src/ir.rs | 757 ++++++-- exp/guppy-zlup/src/lib.rs | 64 +- exp/guppy-zlup/src/linter.rs | 2 +- exp/guppy-zlup/src/linter/ast.rs | 33 +- exp/guppy-zlup/src/linter/diagnostic.rs | 25 +- exp/guppy-zlup/src/linter/engine.rs | 18 +- exp/guppy-zlup/src/linter/lower.rs | 174 +- exp/guppy-zlup/src/linter/noqa.rs | 7 +- exp/guppy-zlup/src/linter/output.rs | 24 +- exp/guppy-zlup/src/linter/rules/zlup001.rs | 41 +- exp/guppy-zlup/src/linter/rules/zlup002.rs | 4 +- exp/guppy-zlup/src/linter/rules/zlup003.rs | 42 +- exp/guppy-zlup/src/linter/rules/zlup004.rs | 4 +- exp/guppy-zlup/src/linter/rules/zlup005.rs | 12 +- exp/guppy-zlup/src/linter/rules/zlup006.rs | 27 +- exp/guppy-zlup/src/linter/rules/zlup007.rs | 12 +- exp/guppy-zlup/src/linter/rules/zlup008.rs | 139 +- exp/guppy-zlup/src/linter/rules/zlup009.rs | 20 +- exp/guppy-zlup/src/linter/rules/zlup010.rs | 12 +- exp/guppy-zlup/src/main.rs | 160 +- exp/guppy-zlup/tests/e2e_tests.rs | 75 +- exp/guppy-zlup/tests/transform_tests.rs | 2 +- exp/zlup/benches/compiler.rs | 2 +- exp/zlup/src/analysis.rs | 65 +- exp/zlup/src/ast.rs | 46 +- exp/zlup/src/build.rs | 41 +- exp/zlup/src/codegen/hugr.rs | 249 ++- exp/zlup/src/codegen/phir.rs | 468 +++-- exp/zlup/src/codegen/qasm.rs | 103 +- exp/zlup/src/codegen/slr.rs | 458 +++-- exp/zlup/src/comptime.rs | 1002 +++++++---- exp/zlup/src/docgen.rs | 149 +- exp/zlup/src/formatter.rs | 11 +- exp/zlup/src/lib.rs | 9 +- exp/zlup/src/linter.rs | 392 ++-- exp/zlup/src/logging.rs | 4 +- exp/zlup/src/lsp/main.rs | 78 +- exp/zlup/src/lsp/semantic_tokens.rs | 61 +- exp/zlup/src/main.rs | 380 ++-- exp/zlup/src/module.rs | 60 +- exp/zlup/src/optimize.rs | 433 +++-- exp/zlup/src/parser.rs | 456 +++-- exp/zlup/src/pretty.rs | 40 +- exp/zlup/src/rational.rs | 59 +- exp/zlup/src/semantic.rs | 1883 ++++++++++++++------ exp/zlup/src/test_runner.rs | 15 +- exp/zlup/src/tests.rs | 34 +- exp/zlup/tests/cli.rs | 175 +- exp/zlup/tests/proptest.rs | 696 ++++++-- exp/zluppy/src/lib.rs | 34 +- 53 files changed, 7387 insertions(+), 3104 deletions(-) diff --git a/crates/pecos-phir-pliron/src/lib.rs b/crates/pecos-phir-pliron/src/lib.rs index 357a1ae63..801716a34 100644 --- a/crates/pecos-phir-pliron/src/lib.rs +++ b/crates/pecos-phir-pliron/src/lib.rs @@ -18,8 +18,13 @@ use pecos_core::Angle64; use pecos_engines::byte_message::ByteMessage; use pecos_engines::hybrid::builder::HybridEngineBuilder; use pecos_engines::quantum::StateVecEngine; -use pecos_engines::{ClassicalControlEngine, ClassicalEngine, ControlEngine, Data, Engine, EngineStage, PecosError, Shot}; +use pecos_engines::{ + ClassicalControlEngine, ClassicalEngine, ControlEngine, Data, Engine, EngineStage, PecosError, + Shot, +}; use pliron::{ + attribute::AttrObj, + basic_block::BasicBlock, builtin::{ attributes::IntegerAttr, op_interfaces::{ @@ -29,13 +34,11 @@ use pliron::{ ops::{FuncOp, ModuleOp}, types::{FunctionType, IntegerType, Signedness}, }, - attribute::AttrObj, - basic_block::BasicBlock, common_traits::Verify, context::{Context, Ptr}, derive::{pliron_attr, pliron_op, pliron_type}, linked_list::ContainsLinkedList, - op::{verify_op, Op}, + op::{Op, verify_op}, operation::Operation, printable::Printable, result::Result, @@ -66,11 +69,17 @@ pub fn run_and_check(label: &str, msg: ByteMessage, shots: usize) { let o = out.outcomes().unwrap(); assert_eq!(o.len(), 2, "expected 2 measurement outcomes"); let combined = (o[1] << 1) | o[0]; // q1 q0 - assert!(combined == 0 || combined == 3, "{label}: Bell must be 00 or 11, got {combined}"); + assert!( + combined == 0 || combined == 3, + "{label}: Bell must be 00 or 11, got {combined}" + ); saw0 |= combined == 0; saw3 |= combined == 3; } - assert!(saw0 && saw3, "{label}: expected both 00 and 11 over {shots} shots"); + assert!( + saw0 && saw3, + "{label}: expected both 00 and 11 over {shots} shots" + ); println!("[{label}] OK -- all {shots} shots in {{0,3}}, saw both 00 and 11"); } @@ -164,12 +173,22 @@ impl MeasurementRegistry { /// Store a fixed-point `Angle64` on an op as a `qec.angle` attribute. pub fn set_angle(ctx: &Context, op: Ptr, angle: Angle64) { - op.deref_mut(ctx).attributes.0.insert(angle_attr::ANGLE.clone(), Box::new(Angle64Attr::from(angle))); + op.deref_mut(ctx).attributes.0.insert( + angle_attr::ANGLE.clone(), + Box::new(Angle64Attr::from(angle)), + ); } pub fn get_angle(ctx: &Context, op: Ptr) -> Angle64 { let o = op.deref(ctx); - let a: AttrObj = o.attributes.0.get(&*angle_attr::ANGLE).expect("angle attr").clone(); - let aa = a.downcast::().unwrap_or_else(|_| panic!("angle not Angle64Attr")); + let a: AttrObj = o + .attributes + .0 + .get(&*angle_attr::ANGLE) + .expect("angle attr") + .clone(); + let aa = a + .downcast::() + .unwrap_or_else(|_| panic!("angle not Angle64Attr")); Angle64::from(*aa) } @@ -178,7 +197,14 @@ pub struct QallocOp; impl QallocOp { pub fn new(ctx: &mut Context) -> Self { let a = alloc_ty(ctx); - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![a], vec![], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![a], + vec![], + vec![], + 0, + ); QallocOp { op } } } @@ -188,16 +214,33 @@ pub struct SlotOp; impl SlotOp { pub fn new(ctx: &mut Context, alloc: Value, index: u64) -> Self { let r = qubitref_ty(ctx); - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![r], vec![alloc], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![r], + vec![alloc], + vec![], + 0, + ); let i64_ty = IntegerType::get(ctx, 64, Signedness::Signless); let attr = IntegerAttr::new(i64_ty, APInt::from_u64(index, bw(64))); - op.deref_mut(ctx).attributes.0.insert(slot_attr::INDEX.clone(), Box::new(attr)); + op.deref_mut(ctx) + .attributes + .0 + .insert(slot_attr::INDEX.clone(), Box::new(attr)); SlotOp { op } } pub fn index(&self, ctx: &Context) -> u64 { let op = self.get_operation().deref(ctx); - let attr: AttrObj = op.attributes.0.get(&*slot_attr::INDEX).expect("slot index attr").clone(); - let int_attr = attr.downcast::().unwrap_or_else(|_| panic!("not IntegerAttr")); + let attr: AttrObj = op + .attributes + .0 + .get(&*slot_attr::INDEX) + .expect("slot index attr") + .clone(); + let int_attr = attr + .downcast::() + .unwrap_or_else(|_| panic!("not IntegerAttr")); Into::::into(*int_attr).to_u64() } } @@ -214,7 +257,14 @@ impl Verify for SlotOp { pub struct PrepareOp; impl PrepareOp { pub fn new(ctx: &mut Context, qref: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![qref], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![qref], + vec![], + 0, + ); PrepareOp { op } } } @@ -231,7 +281,14 @@ impl Verify for PrepareOp { pub struct HOp; impl HOp { pub fn new(ctx: &mut Context, qref: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![qref], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![qref], + vec![], + 0, + ); HOp { op } } } @@ -248,7 +305,14 @@ impl Verify for HOp { pub struct CxOp; impl CxOp { pub fn new(ctx: &mut Context, ctrl: Value, tgt: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![ctrl, tgt], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![ctrl, tgt], + vec![], + 0, + ); CxOp { op } } } @@ -267,7 +331,14 @@ impl Verify for CxOp { pub struct CzOp; impl CzOp { pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![a, b], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![a, b], + vec![], + 0, + ); CzOp { op } } } @@ -286,7 +357,14 @@ impl Verify for CzOp { pub struct SwapOp; impl SwapOp { pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![a, b], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![a, b], + vec![], + 0, + ); SwapOp { op } } } @@ -309,7 +387,14 @@ pub struct MeasureOp; impl MeasureOp { pub fn new(ctx: &mut Context, qref: Value) -> Self { let i1 = IntegerType::get(ctx, 1, Signedness::Signless); - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![i1.into()], vec![qref], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![i1.into()], + vec![qref], + vec![], + 0, + ); MeasureOp { op } } } @@ -329,16 +414,28 @@ impl Verify for MeasureOp { pub struct RecordOp; impl RecordOp { pub fn new(ctx: &mut Context, result: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![result], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![result], + vec![], + 0, + ); RecordOp { op } } } impl Verify for RecordOp { fn verify(&self, ctx: &Context) -> Result<()> { let opnd = self.get_operation().deref(ctx).get_operand(0); - let records_a_measure = opnd.defining_op().is_some_and(|o| Operation::get_op::(o, ctx).is_some()); + let records_a_measure = opnd + .defining_op() + .is_some_and(|o| Operation::get_op::(o, ctx).is_some()); if !records_a_measure { - return verify_err!(self.loc(ctx), "qec.record operand must be a qec.measure result"); + return verify_err!( + self.loc(ctx), + "qec.record operand must be a qec.measure result" + ); } // Region scope: the recorded measurement must be visible from this record op -- i.e. its // defining block must be the record's block or an enclosing one. Recording a measurement @@ -354,7 +451,10 @@ impl Verify for RecordOp { } }; if !visible { - return verify_err!(self.loc(ctx), "qec.record operand is defined in a non-enclosing region (cross-region escape needs yield)"); + return verify_err!( + self.loc(ctx), + "qec.record operand is defined in a non-enclosing region (cross-region escape needs yield)" + ); } Ok(()) } @@ -366,7 +466,14 @@ impl Verify for RecordOp { pub struct CondXOp; impl CondXOp { pub fn new(ctx: &mut Context, cond: Value, target: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![cond, target], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![cond, target], + vec![], + 0, + ); CondXOp { op } } } @@ -376,9 +483,13 @@ impl Verify for CondXOp { // We inspect the operand's actual type read-only (TypePtr::from_ptr + width) rather than // construct the expected i1 -- constructing a parameterized type needs &mut Context. let cond_ty = self.get_operation().deref(ctx).get_operand(0).get_type(ctx); - let cond_is_i1 = TypePtr::::from_ptr(cond_ty, ctx).is_ok_and(|tp| tp.deref(ctx).width() == 1); + let cond_is_i1 = TypePtr::::from_ptr(cond_ty, ctx) + .is_ok_and(|tp| tp.deref(ctx).width() == 1); if !cond_is_i1 { - return verify_err!(self.loc(ctx), "qec.cond_x condition (operand 0) must be an i1 measurement result"); + return verify_err!( + self.loc(ctx), + "qec.cond_x condition (operand 0) must be an i1 measurement result" + ); } if self.get_operation().deref(ctx).get_operand(1).get_type(ctx) != qubitref_ty(ctx) { return verify_err!(self.loc(ctx), "qec.cond_x target must be a qec.qubitref"); @@ -392,7 +503,14 @@ impl Verify for CondXOp { pub struct XOp; impl XOp { pub fn new(ctx: &mut Context, qref: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![qref], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![qref], + vec![], + 0, + ); XOp { op } } } @@ -411,7 +529,14 @@ impl Verify for XOp { pub struct IfOp; impl IfOp { pub fn new(ctx: &mut Context, cond: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![cond], vec![], 2); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![cond], + vec![], + 2, + ); IfOp { op } } /// Create + attach a fresh single block for region `idx` and return it (for building). @@ -427,9 +552,13 @@ impl Verify for IfOp { // the condition (operand 0) must be an i1 measurement result (region count is enforced by // NRegionsInterface<2>); inspect the operand's type read-only, do not construct it. let cond_ty = self.get_operation().deref(ctx).get_operand(0).get_type(ctx); - let is_i1 = TypePtr::::from_ptr(cond_ty, ctx).is_ok_and(|tp| tp.deref(ctx).width() == 1); + let is_i1 = TypePtr::::from_ptr(cond_ty, ctx) + .is_ok_and(|tp| tp.deref(ctx).width() == 1); if !is_i1 { - return verify_err!(self.loc(ctx), "qec.if condition (operand 0) must be an i1 measurement result"); + return verify_err!( + self.loc(ctx), + "qec.if condition (operand 0) must be an i1 measurement result" + ); } Ok(()) } @@ -440,7 +569,14 @@ impl Verify for IfOp { pub struct RzOp; impl RzOp { pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![q], + vec![], + 0, + ); set_angle(ctx, op, angle); RzOp { op } } @@ -457,7 +593,14 @@ impl Verify for RzOp { pub struct RxOp; impl RxOp { pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![q], + vec![], + 0, + ); set_angle(ctx, op, angle); RxOp { op } } @@ -474,7 +617,14 @@ impl Verify for RxOp { pub struct RyOp; impl RyOp { pub fn new(ctx: &mut Context, q: Value, angle: Angle64) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![q], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![q], + vec![], + 0, + ); set_angle(ctx, op, angle); RyOp { op } } @@ -492,7 +642,14 @@ impl Verify for RyOp { pub struct SzzOp; impl SzzOp { pub fn new(ctx: &mut Context, a: Value, b: Value) -> Self { - let op = Operation::new(ctx, Self::get_concrete_op_info(), vec![], vec![a, b], vec![], 0); + let op = Operation::new( + ctx, + Self::get_concrete_op_info(), + vec![], + vec![a, b], + vec![], + 0, + ); SzzOp { op } } } @@ -518,7 +675,11 @@ pub fn build_bell_ir(ctx: &mut Context) -> (ModuleOp, Ptr) { let bb = func.get_entry_block(ctx); macro_rules! push { - ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; } let q = push!(QallocOp::new(ctx)); @@ -651,7 +812,10 @@ impl ControlEngine for PlironBellEngine { type Output = Shot; type EngineInput = ByteMessage; type EngineOutput = ByteMessage; - fn start(&mut self, _input: ()) -> std::result::Result, PecosError> { + fn start( + &mut self, + _input: (), + ) -> std::result::Result, PecosError> { self.sent = false; self.outcomes.clear(); let cmds = self.generate_commands()?; @@ -661,7 +825,10 @@ impl ControlEngine for PlironBellEngine { Ok(EngineStage::NeedsProcessing(cmds)) } } - fn continue_processing(&mut self, measurements: ByteMessage) -> std::result::Result, PecosError> { + fn continue_processing( + &mut self, + measurements: ByteMessage, + ) -> std::result::Result, PecosError> { self.handle_measurements(measurements)?; // Bell is a single batch with no classical feedback: once the (only) batch's measurements // are in, we are done. (A general engine would loop on generate_commands + a `finished` @@ -677,10 +844,16 @@ impl ControlEngine for PlironBellEngine { pub fn run_milestone_2() { let ctx = &mut Context::new(); let (module, bb) = build_bell_ir(ctx); - verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-2 verify] FAILED: {}", e.disp(ctx))); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-2 verify] FAILED: {}", e.disp(ctx))); let msg = emit_bytemessage(ctx, bb); // pliron-emitted Bell ByteMessage - let mut engine = PlironBellEngine { msg, num_qubits: 2, sent: false, outcomes: Vec::new() }; + let mut engine = PlironBellEngine { + msg, + num_qubits: 2, + sent: false, + outcomes: Vec::new(), + }; let mut qsys = StateVecEngine::new(2); // Drive the ControlEngine protocol by hand (start -> {process, continue_processing} loop -> @@ -699,13 +872,25 @@ pub fn run_milestone_2() { } } }; - let v = shot.data.get("c").and_then(Data::as_u32).expect("register c"); - assert!(v == 0 || v == 3, "milestone-2: Bell via ClassicalEngine must be 00 or 11, got {v}"); + let v = shot + .data + .get("c") + .and_then(Data::as_u32) + .expect("register c"); + assert!( + v == 0 || v == 3, + "milestone-2: Bell via ClassicalEngine must be 00 or 11, got {v}" + ); saw0 |= v == 0; saw3 |= v == 3; } - assert!(saw0 && saw3, "milestone-2: expected both 00 and 11 over 200 shots"); - println!("[milestone-2 pliron ClassicalControlEngine -> StateVecEngine] OK -- 200 shots in {{0,3}}, saw both (register \"c\")"); + assert!( + saw0 && saw3, + "milestone-2: expected both 00 and 11 over 200 shots" + ); + println!( + "[milestone-2 pliron ClassicalControlEngine -> StateVecEngine] OK -- 200 shots in {{0,3}}, saw both (register \"c\")" + ); } // ===================== Milestone 3: parse a real bell.ll into pliron qec IR ===================== @@ -713,20 +898,30 @@ pub fn run_milestone_2() { /// Structured rejection for anything outside the covered QIS-LLVM subset (see the strangler scope /// doc) -- used instead of silently dropping unrecognized calls or panicking on malformed structure. fn unsupported_qis(msg: impl Into) -> PecosError { - PecosError::Feature(format!("pecos-phir-pliron: unsupported QIS-LLVM-IR -- {}", msg.into())) + PecosError::Feature(format!( + "pecos-phir-pliron: unsupported QIS-LLVM-IR -- {}", + msg.into() + )) } /// Minimal QIS-LLVM-IR -> pliron `qec` IR parser for the Bell-style straight-line subset. Recognizes /// `__quantum__qis__{h,cx,m}__body` + `__quantum__rt__result_record_output`; any other `__quantum__` /// call (or a malformed operand list) is rejected with a structured error rather than dropped. -pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { +pub fn parse_bell_ll( + ctx: &mut Context, + src: &str, +) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { validate_straight_line_qis_shape(src)?; fn i64_args(line: &str) -> Vec { match (line.find('('), line.rfind(')')) { (Some(l), Some(r)) if r > l => line[l + 1..r] .split(',') - .filter_map(|t| t.trim().strip_prefix("i64 ").and_then(|n| n.trim().parse::().ok())) + .filter_map(|t| { + t.trim() + .strip_prefix("i64 ") + .and_then(|n| n.trim().parse::().ok()) + }) .collect(), _ => Vec::new(), } @@ -752,11 +947,15 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modul } let a = i64_args(l); if l.contains("__quantum__qis__h__body") { - let q = *a.first().ok_or_else(|| unsupported_qis("h__body missing qubit operand"))?; + let q = *a + .first() + .ok_or_else(|| unsupported_qis("h__body missing qubit operand"))?; qubits.insert(q); parsed.push(("h", a)); } else if l.contains("__quantum__qis__x__body") { - let q = *a.first().ok_or_else(|| unsupported_qis("x__body missing qubit operand"))?; + let q = *a + .first() + .ok_or_else(|| unsupported_qis("x__body missing qubit operand"))?; qubits.insert(q); parsed.push(("x", a)); } else if l.contains("__quantum__qis__cx__body") { @@ -788,11 +987,15 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modul parsed.push(("m", a)); } else if l.contains("__quantum__rt__result_record_output") { if a.is_empty() { - return Err(unsupported_qis("result_record_output missing result_id operand")); + return Err(unsupported_qis( + "result_record_output missing result_id operand", + )); } parsed.push(("record", a)); } else if l.contains("__quantum__") { - return Err(unsupported_qis(format!("operation not in the covered subset: {l}"))); + return Err(unsupported_qis(format!( + "operation not in the covered subset: {l}" + ))); } } // pass 2: build the pliron qec IR + the measurement-SSA registry @@ -802,7 +1005,11 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modul module.append_operation(ctx, func.get_operation(), 0); let bb = func.get_entry_block(ctx); macro_rules! push { - ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; } let q = push!(QallocOp::new(ctx)); @@ -838,13 +1045,22 @@ pub fn parse_bell_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modul let v = m.get_result(ctx); let rid = a[1] as u64; measured.insert(rid, v); - reg.record(v, MeasurementInfo { qubit: a[0], basis: Basis::Z, export_label: rid }); + reg.record( + v, + MeasurementInfo { + qubit: a[0], + basis: Basis::Z, + export_label: rid, + }, + ); } "record" => { let rid = a[0] as u64; - let v = *measured - .get(&rid) - .ok_or_else(|| unsupported_qis(format!("result_record_output references unknown result-id {rid}")))?; + let v = *measured.get(&rid).ok_or_else(|| { + unsupported_qis(format!( + "result_record_output references unknown result-id {rid}" + )) + })?; push!(RecordOp::new(ctx, v)); } _ => {} @@ -860,7 +1076,8 @@ pub fn run_milestone_3() { let (module, bb, _reg) = parse_bell_ll(ctx, src).expect("milestone-3: parse bell.ll"); println!("=== bell.ll parsed into pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); - verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-3 verify] FAILED: {}", e.disp(ctx))); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-3 verify] FAILED: {}", e.disp(ctx))); let msg = emit_bytemessage(ctx, bb); run_and_check("milestone-3 bell.ll -> pliron qec -> sim", msg, 200); } @@ -897,7 +1114,12 @@ pub struct AdaptivePlan { /// Build the `Cmd::Mz` for a `qec.measure`, asserting the registry's qubit agrees with the IR slot /// index. The registry is a side-table, so `verify` can't catch a mismatch; this fails loud at /// plan-build time if the registry and the IR ever drift apart. -fn measure_to_mz(ctx: &Context, m: MeasureOp, qubit_of: &HashMap, reg: &MeasurementRegistry) -> Cmd { +fn measure_to_mz( + ctx: &Context, + m: MeasureOp, + qubit_of: &HashMap, + reg: &MeasurementRegistry, +) -> Cmd { let operand = m.get_operation().deref(ctx).get_operand(0); let slot_qubit = qubit_of[&operand]; let info = reg.get(m.get_result(ctx)); @@ -912,7 +1134,11 @@ fn measure_to_mz(ctx: &Context, m: MeasureOp, qubit_of: &HashMap, /// Walk a `qec` block once and split it into the two-batch adaptive plan at the `cond_x` boundary. /// Gate qubits come from the explicit slot-index map; measurement metadata (qubit + export label) /// comes from the `MeasurementRegistry`, keyed by the measure op's SSA value. -pub fn plan_from_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegistry) -> AdaptivePlan { +pub fn plan_from_ir( + ctx: &Context, + block: Ptr, + reg: &MeasurementRegistry, +) -> AdaptivePlan { let mut qubit_of: HashMap = HashMap::new(); let (mut batch1, mut batch2): (Vec, Vec) = (Vec::new(), Vec::new()); let mut after_cond = false; @@ -935,10 +1161,19 @@ pub fn plan_from_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegi cond_target = qubit_of[&c.get_operation().deref(ctx).get_operand(1)]; after_cond = true; } else if let Some(cmd) = gate_op_to_cmd(ctx, op, &qubit_of) { - if after_cond { batch2.push(cmd) } else { batch1.push(cmd) } + if after_cond { + batch2.push(cmd) + } else { + batch1.push(cmd) + } } } - AdaptivePlan { batch1, cond_outcome_idx, cond_target, batch2 } + AdaptivePlan { + batch1, + cond_outcome_idx, + cond_target, + batch2, + } } /// Number of qubits a Cmd stream touches = max referenced qubit index + 1 (0 if it touches none). @@ -948,7 +1183,13 @@ pub fn cmds_num_qubits(batches: &[&[Cmd]]) -> usize { for cmds in batches { for c in *cmds { let hi = match *c { - Cmd::Pz(q) | Cmd::H(q) | Cmd::X(q) | Cmd::Rz(q, _) | Cmd::Rx(q, _) | Cmd::Ry(q, _) | Cmd::Mz(q, _) => q, + Cmd::Pz(q) + | Cmd::H(q) + | Cmd::X(q) + | Cmd::Rz(q, _) + | Cmd::Rx(q, _) + | Cmd::Ry(q, _) + | Cmd::Mz(q, _) => q, Cmd::Szz(a, b) | Cmd::Cx(a, b) | Cmd::Cz(a, b) | Cmd::Swap(a, b) => a.max(b), }; n = n.max(hi + 1); @@ -960,17 +1201,39 @@ pub fn cmds_num_qubits(batches: &[&[Cmd]]) -> usize { pub fn emit_cmds(b: &mut pecos_engines::byte_message::ByteMessageBuilder, cmds: &[Cmd]) { for c in cmds { match *c { - Cmd::Pz(q) => { b.pz(&[q]); } - Cmd::H(q) => { b.h(&[q]); } - Cmd::X(q) => { b.x(&[q]); } - Cmd::Rz(q, a) => { b.rz(a, &[q]); } - Cmd::Rx(q, a) => { b.rx(a, &[q]); } - Cmd::Ry(q, a) => { b.ry(a, &[q]); } - Cmd::Szz(a, c0) => { b.szz(&[(a, c0)]); } - Cmd::Cx(c0, t) => { b.cx(&[(c0, t)]); } - Cmd::Cz(a, c0) => { b.cz(&[(a, c0)]); } - Cmd::Swap(a, c0) => { b.swap(&[(a, c0)]); } - Cmd::Mz(q, _rid) => { b.mz(&[q]); } // result-id is bookkeeping, not a simulator op + Cmd::Pz(q) => { + b.pz(&[q]); + } + Cmd::H(q) => { + b.h(&[q]); + } + Cmd::X(q) => { + b.x(&[q]); + } + Cmd::Rz(q, a) => { + b.rz(a, &[q]); + } + Cmd::Rx(q, a) => { + b.rx(a, &[q]); + } + Cmd::Ry(q, a) => { + b.ry(a, &[q]); + } + Cmd::Szz(a, c0) => { + b.szz(&[(a, c0)]); + } + Cmd::Cx(c0, t) => { + b.cx(&[(c0, t)]); + } + Cmd::Cz(a, c0) => { + b.cz(&[(a, c0)]); + } + Cmd::Swap(a, c0) => { + b.swap(&[(a, c0)]); + } + Cmd::Mz(q, _rid) => { + b.mz(&[q]); + } // result-id is bookkeeping, not a simulator op } } } @@ -1029,7 +1292,11 @@ impl ClassicalEngine for PlironAdaptiveEngine { } fn handle_measurements(&mut self, m: ByteMessage) -> std::result::Result<(), PecosError> { let o = m.outcomes()?; - if self.stage == 1 { self.b1 = o } else { self.b2 = o } + if self.stage == 1 { + self.b1 = o + } else { + self.b2 = o + } Ok(()) } fn get_results(&self) -> std::result::Result { @@ -1064,7 +1331,10 @@ impl ControlEngine for PlironAdaptiveEngine { self.stage = 1; Ok(EngineStage::NeedsProcessing(self.batch1_msg())) } - fn continue_processing(&mut self, meas: ByteMessage) -> std::result::Result, PecosError> { + fn continue_processing( + &mut self, + meas: ByteMessage, + ) -> std::result::Result, PecosError> { if self.stage == 1 { self.b1 = meas.outcomes()?; self.stage = 2; @@ -1086,7 +1356,11 @@ pub fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr, Measu module.append_operation(ctx, func.get_operation(), 0); let bb = func.get_entry_block(ctx); macro_rules! push { - ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; } let q = push!(QallocOp::new(ctx)); let qv = q.get_result(ctx); @@ -1100,10 +1374,24 @@ pub fn build_adaptive_ir(ctx: &mut Context) -> (ModuleOp, Ptr, Measu let mut reg = MeasurementRegistry::default(); let m0 = push!(MeasureOp::new(ctx, s0v)); // mid measure (conditioning) let m0v = m0.get_result(ctx); - reg.record(m0v, MeasurementInfo { qubit: 0, basis: Basis::Z, export_label: 0 }); + reg.record( + m0v, + MeasurementInfo { + qubit: 0, + basis: Basis::Z, + export_label: 0, + }, + ); push!(CondXOp::new(ctx, m0v, s1v)); // X q1 iff m0 == 1 let m1 = push!(MeasureOp::new(ctx, s1v)); // final measure - reg.record(m1.get_result(ctx), MeasurementInfo { qubit: 1, basis: Basis::Z, export_label: 1 }); + reg.record( + m1.get_result(ctx), + MeasurementInfo { + qubit: 1, + basis: Basis::Z, + export_label: 1, + }, + ); push!(EndOp::new(ctx)); (module, bb, reg) } @@ -1113,9 +1401,15 @@ pub fn run_milestone_4() { let (module, bb, reg) = build_adaptive_ir(ctx); println!("=== adaptive (mid-measure -> cond_x -> final) pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); - verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-4 verify] FAILED: {}", e.disp(ctx))); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-4 verify] FAILED: {}", e.disp(ctx))); let plan = plan_from_ir(ctx, bb, ®); - let engine = PlironAdaptiveEngine { plan, stage: 0, b1: Vec::new(), b2: Vec::new() }; + let engine = PlironAdaptiveEngine { + plan, + stage: 0, + b1: Vec::new(), + b2: Vec::new(), + }; // Drive through the REAL HybridEngine this time (closes the "wrapper unrun" gap from round 3). let mut hybrid = HybridEngineBuilder::new() @@ -1127,7 +1421,11 @@ pub fn run_milestone_4() { for _ in 0..200 { let shot = hybrid.run_shot().unwrap(); let mid = shot.data.get("mid").and_then(Data::as_u32).expect("mid"); - let fin = shot.data.get("final").and_then(Data::as_u32).expect("final"); + let fin = shot + .data + .get("final") + .and_then(Data::as_u32) + .expect("final"); if fin != mid { all_eq = false; } @@ -1135,9 +1433,17 @@ pub fn run_milestone_4() { saw_mid1 |= mid == 1; Engine::reset(&mut hybrid).unwrap(); } - assert!(all_eq, "milestone-4: final must equal mid in every shot (measurement-conditioned X feedback)"); - assert!(saw_mid0 && saw_mid1, "milestone-4: expected both mid=0 and mid=1 over 200 shots"); - println!("[milestone-4 adaptive multi-batch via HybridEngine] OK -- final==mid in all 200 shots, saw mid 0 and 1"); + assert!( + all_eq, + "milestone-4: final must equal mid in every shot (measurement-conditioned X feedback)" + ); + assert!( + saw_mid0 && saw_mid1, + "milestone-4: expected both mid=0 and mid=1 over 200 shots" + ); + println!( + "[milestone-4 adaptive multi-batch via HybridEngine] OK -- final==mid in all 200 shots, saw mid 0 and 1" + ); } // ===================== Milestone 5: region-based conditional control flow (qec.if) ===================== @@ -1151,7 +1457,11 @@ pub fn run_milestone_4() { /// `qec.slot`/`qec.if`/`qec.record`/`qec.qalloc`/`qec.end`/`qec.measure`). Shared by every plan /// walker so none of them can silently drop a gate another handles (the round-7 drift bug). /// Measurement is deliberately excluded -- it needs the registry + per-walker bookkeeping. -fn gate_op_to_cmd(ctx: &Context, op: Ptr, qubit_of: &HashMap) -> Option { +fn gate_op_to_cmd( + ctx: &Context, + op: Ptr, + qubit_of: &HashMap, +) -> Option { let q = |i: usize, o: Ptr| qubit_of[&o.deref(ctx).get_operand(i)]; if let Some(p) = Operation::get_op::(op, ctx) { Some(Cmd::Pz(q(0, p.get_operation()))) @@ -1164,19 +1474,37 @@ fn gate_op_to_cmd(ctx: &Context, op: Ptr, qubit_of: &HashMap(op, ctx) { Some(Cmd::Cz(q(0, cz.get_operation()), q(1, cz.get_operation()))) } else if let Some(sw) = Operation::get_op::(op, ctx) { - Some(Cmd::Swap(q(0, sw.get_operation()), q(1, sw.get_operation()))) + Some(Cmd::Swap( + q(0, sw.get_operation()), + q(1, sw.get_operation()), + )) } else if let Some(r) = Operation::get_op::(op, ctx) { - Some(Cmd::Rz(q(0, r.get_operation()), get_angle(ctx, r.get_operation()))) + Some(Cmd::Rz( + q(0, r.get_operation()), + get_angle(ctx, r.get_operation()), + )) } else if let Some(r) = Operation::get_op::(op, ctx) { - Some(Cmd::Rx(q(0, r.get_operation()), get_angle(ctx, r.get_operation()))) + Some(Cmd::Rx( + q(0, r.get_operation()), + get_angle(ctx, r.get_operation()), + )) } else if let Some(r) = Operation::get_op::(op, ctx) { - Some(Cmd::Ry(q(0, r.get_operation()), get_angle(ctx, r.get_operation()))) + Some(Cmd::Ry( + q(0, r.get_operation()), + get_angle(ctx, r.get_operation()), + )) } else { - Operation::get_op::(op, ctx).map(|z| Cmd::Szz(q(0, z.get_operation()), q(1, z.get_operation()))) + Operation::get_op::(op, ctx) + .map(|z| Cmd::Szz(q(0, z.get_operation()), q(1, z.get_operation()))) } } -pub fn block_to_cmds(ctx: &Context, block: Ptr, qubit_of: &HashMap, reg: &MeasurementRegistry) -> Vec { +pub fn block_to_cmds( + ctx: &Context, + block: Ptr, + qubit_of: &HashMap, + reg: &MeasurementRegistry, +) -> Vec { let mut cmds = Vec::new(); for op in block.deref(ctx).iter(ctx).collect::>() { if let Some(cmd) = gate_op_to_cmd(ctx, op, qubit_of) { @@ -1201,7 +1529,8 @@ pub struct IfPlan { /// then/else command lists. Measurement metadata + export order come from the registry. pub fn plan_from_if_ir(ctx: &Context, block: Ptr, reg: &MeasurementRegistry) -> IfPlan { let mut qubit_of: HashMap = HashMap::new(); - let (mut batch1, mut post, mut then_cmds, mut else_cmds) = (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let (mut batch1, mut post, mut then_cmds, mut else_cmds) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new()); let mut after_if = false; let mut mz_b1 = 0usize; let mut cond_outcome_idx = 0usize; @@ -1223,7 +1552,11 @@ pub fn plan_from_if_ir(ctx: &Context, block: Ptr, reg: &MeasurementR else_cmds = block_to_cmds(ctx, ifop.get_body(ctx, 1), &qubit_of, reg); after_if = true; } else if let Some(cmd) = gate_op_to_cmd(ctx, op, &qubit_of) { - if after_if { post.push(cmd) } else { batch1.push(cmd) } + if after_if { + post.push(cmd) + } else { + batch1.push(cmd) + } } else if let Some(rec) = Operation::get_op::(op, ctx) { // export order = textual order of qec.record; resolve the recorded measurement-SSA value // to its export label via the registry (the value is the identity). @@ -1231,7 +1564,14 @@ pub fn plan_from_if_ir(ctx: &Context, block: Ptr, reg: &MeasurementR export.push(reg.get(recorded).export_label); } } - IfPlan { batch1, cond_outcome_idx, then_cmds, else_cmds, post, export } + IfPlan { + batch1, + cond_outcome_idx, + then_cmds, + else_cmds, + post, + export, + } } #[derive(Clone)] @@ -1253,7 +1593,11 @@ impl PlironIfEngine { b.build() } fn taken_cmds(&self) -> &[Cmd] { - if self.b1.get(self.cond_outcome_idx).copied() == Some(1) { &self.then_cmds } else { &self.else_cmds } + if self.b1.get(self.cond_outcome_idx).copied() == Some(1) { + &self.then_cmds + } else { + &self.else_cmds + } } fn b2_msg(&self) -> ByteMessage { let mut b = ByteMessage::quantum_operations_builder(); @@ -1286,19 +1630,35 @@ impl PlironIfEngine { impl Engine for PlironIfEngine { type Input = (); type Output = Shot; - fn process(&mut self, _i: ()) -> std::result::Result { self.get_results() } - fn reset(&mut self) -> std::result::Result<(), PecosError> { self.stage = 0; self.b1.clear(); self.b2.clear(); Ok(()) } + fn process(&mut self, _i: ()) -> std::result::Result { + self.get_results() + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + self.stage = 0; + self.b1.clear(); + self.b2.clear(); + Ok(()) + } } impl ClassicalEngine for PlironIfEngine { fn num_qubits(&self) -> usize { cmds_num_qubits(&[&self.batch1, &self.then_cmds, &self.else_cmds, &self.post]) } fn generate_commands(&mut self) -> std::result::Result { - if self.stage == 0 { self.stage = 1; Ok(self.b1_msg()) } else { Ok(ByteMessage::create_empty()) } + if self.stage == 0 { + self.stage = 1; + Ok(self.b1_msg()) + } else { + Ok(ByteMessage::create_empty()) + } } fn handle_measurements(&mut self, m: ByteMessage) -> std::result::Result<(), PecosError> { let o = m.outcomes()?; - if self.stage == 1 { self.b1 = o } else { self.b2 = o } + if self.stage == 1 { + self.b1 = o + } else { + self.b2 = o + } Ok(()) } fn get_results(&self) -> std::result::Result { @@ -1310,10 +1670,18 @@ impl ClassicalEngine for PlironIfEngine { } Ok(s) } - fn compile(&self) -> std::result::Result<(), PecosError> { Ok(()) } - fn reset(&mut self) -> std::result::Result<(), PecosError> { Engine::reset(self) } - fn as_any(&self) -> &dyn Any { self } - fn as_any_mut(&mut self) -> &mut dyn Any { self } + fn compile(&self) -> std::result::Result<(), PecosError> { + Ok(()) + } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } + fn as_any(&self) -> &dyn Any { + self + } + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } } impl ControlEngine for PlironIfEngine { type Input = (); @@ -1326,7 +1694,10 @@ impl ControlEngine for PlironIfEngine { self.b2.clear(); Ok(EngineStage::NeedsProcessing(self.b1_msg())) } - fn continue_processing(&mut self, meas: ByteMessage) -> std::result::Result, PecosError> { + fn continue_processing( + &mut self, + meas: ByteMessage, + ) -> std::result::Result, PecosError> { if self.stage == 1 { self.b1 = meas.outcomes()?; self.stage = 2; @@ -1336,7 +1707,9 @@ impl ControlEngine for PlironIfEngine { Ok(EngineStage::Complete(self.get_results()?)) } } - fn reset(&mut self) -> std::result::Result<(), PecosError> { Engine::reset(self) } + fn reset(&mut self) -> std::result::Result<(), PecosError> { + Engine::reset(self) + } } pub fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr, MeasurementRegistry) { @@ -1346,7 +1719,11 @@ pub fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr, Measurement module.append_operation(ctx, func.get_operation(), 0); let bb = func.get_entry_block(ctx); macro_rules! push { - ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; } let q = push!(QallocOp::new(ctx)); let qv = q.get_result(ctx); @@ -1360,14 +1737,30 @@ pub fn build_if_ir(ctx: &mut Context) -> (ModuleOp, Ptr, Measurement let mut reg = MeasurementRegistry::default(); let m0 = push!(MeasureOp::new(ctx, s0v)); // mid measure (conditioning) let m0v = m0.get_result(ctx); - reg.record(m0v, MeasurementInfo { qubit: 0, basis: Basis::Z, export_label: 0 }); + reg.record( + m0v, + MeasurementInfo { + qubit: 0, + basis: Basis::Z, + export_label: 0, + }, + ); let ifop = push!(IfOp::new(ctx, m0v)); // if m0 { x q1 } else { } let then_bb = ifop.make_region_block(ctx, 0); - XOp::new(ctx, s1v).get_operation().insert_at_back(then_bb, ctx); + XOp::new(ctx, s1v) + .get_operation() + .insert_at_back(then_bb, ctx); let _else_bb = ifop.make_region_block(ctx, 1); let m1 = push!(MeasureOp::new(ctx, s1v)); // final measure let m1v = m1.get_result(ctx); - reg.record(m1v, MeasurementInfo { qubit: 1, basis: Basis::Z, export_label: 1 }); + reg.record( + m1v, + MeasurementInfo { + qubit: 1, + basis: Basis::Z, + export_label: 1, + }, + ); push!(RecordOp::new(ctx, m0v)); // record mid -> register r0 push!(RecordOp::new(ctx, m1v)); // record final -> register r1 push!(EndOp::new(ctx)); @@ -1379,7 +1772,8 @@ pub fn run_milestone_5() { let (module, bb, reg) = build_if_ir(ctx); println!("=== region-based conditional (qec.if) pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); - verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-5 verify] FAILED: {}", e.disp(ctx))); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-5 verify] FAILED: {}", e.disp(ctx))); let plan = plan_from_if_ir(ctx, bb, ®); let engine = PlironIfEngine { batch1: plan.batch1, @@ -1400,8 +1794,16 @@ pub fn run_milestone_5() { for _ in 0..200 { let shot = hybrid.run_shot().unwrap(); // explicit export mapping: r0 = mid (result-id 0), r1 = final (result-id 1) - let mid = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (mid)"); - let fin = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (final)"); + let mid = shot + .data + .get("r0") + .and_then(Data::as_u32) + .expect("r0 (mid)"); + let fin = shot + .data + .get("r1") + .and_then(Data::as_u32) + .expect("r1 (final)"); if fin != mid { all_eq = false; } @@ -1409,9 +1811,14 @@ pub fn run_milestone_5() { saw1 |= mid == 1; Engine::reset(&mut hybrid).unwrap(); } - assert!(all_eq, "milestone-5: final must equal mid (region-based qec.if feedback)"); + assert!( + all_eq, + "milestone-5: final must equal mid (region-based qec.if feedback)" + ); assert!(saw0 && saw1, "milestone-5: expected both mid=0 and mid=1"); - println!("[milestone-5 region-based qec.if via HybridEngine] OK -- r1(final)==r0(mid) in all 200 shots, saw mid 0 and 1"); + println!( + "[milestone-5 region-based qec.if via HybridEngine] OK -- r1(final)==r0(mid) in all 200 shots, saw mid 0 and 1" + ); } // ===================== Milestone 6: parse the literal qprog.ll (adaptive) ===================== @@ -1429,8 +1836,8 @@ pub enum ParsedOp { Cz(usize, usize), Swap(usize, usize), X(usize), - M(usize, u64), // (qubit, QIS result-id = 2nd i64 of m__body) - Record(u64), // result_record_output(result-id): export this measurement-SSA, in this order + M(usize, u64), // (qubit, QIS result-id = 2nd i64 of m__body) + Record(u64), // result_record_output(result-id): export this measurement-SSA, in this order } pub fn inner_parens(l: &str) -> &str { @@ -1458,7 +1865,12 @@ pub fn doubles_and_ints(inner: &str) -> (Vec, Vec) { pub fn br_labels(l: &str) -> Vec { l.split("label %") .skip(1) - .filter_map(|s| s.split([',', ' ']).next().filter(|x| !x.is_empty()).map(str::to_string)) + .filter_map(|s| { + s.split([',', ' ']) + .next() + .filter(|x| !x.is_empty()) + .map(str::to_string) + }) .collect() } @@ -1478,16 +1890,20 @@ enum LastQuantumOp { enum LlTerminator { Ret, Br(String), - CondBr { cond: String, then_label: String, else_label: String }, + CondBr { + cond: String, + then_label: String, + else_label: String, + }, } /// A parsed `%result = icmp i32 , ` -- enough to prove the branch condition's dataflow. #[derive(Clone, Debug, PartialEq, Eq)] struct IcmpShape { - result: String, // the `%name` the icmp defines + result: String, // the `%name` the icmp defines measure_ref: Option, // the `%ssa` operand (the measurement result it compares), if any - rhs_const: Option, // the integer-literal operand, if any - pred_eq: bool, // predicate is `eq` + rhs_const: Option, // the integer-literal operand, if any + pred_eq: bool, // predicate is `eq` } #[derive(Clone, Debug, Default)] @@ -1495,7 +1911,7 @@ struct LlBlockShape { label: String, terminator: Option, last_quantum_before_terminator: Option, - measures: Vec, // SSA result names of `m__body` calls, in order + measures: Vec, // SSA result names of `m__body` calls, in order icmp: Option, // the last `icmp` defined in the block } @@ -1522,7 +1938,12 @@ fn parse_icmp(result: &str, rhs: &str) -> Option { rhs_const = Some(k); } } - Some(IcmpShape { result: result.to_string(), measure_ref, rhs_const, pred_eq: pred == "eq" }) + Some(IcmpShape { + result: result.to_string(), + measure_ref, + rhs_const, + pred_eq: pred == "eq", + }) } fn block_name(label: &str) -> &str { @@ -1553,17 +1974,25 @@ fn collect_qmain_cfg(src: &str) -> std::result::Result, PecosE } if l.ends_with(':') && !l.contains(' ') { blocks.push(cur); - cur = LlBlockShape { label: l.trim_end_matches(':').to_string(), ..Default::default() }; + cur = LlBlockShape { + label: l.trim_end_matches(':').to_string(), + ..Default::default() + }; continue; } if cur.terminator.is_some() { - return Err(unsupported_qis(format!("instructions after terminator in block {}", block_name(&cur.label)))); + return Err(unsupported_qis(format!( + "instructions after terminator in block {}", + block_name(&cur.label) + ))); } if l.starts_with("br ") { let labels = br_labels(l); cur.terminator = Some(if l.starts_with("br i1 ") { if labels.len() != 2 { - return Err(unsupported_qis(format!("conditional branch must name two labels: {l}"))); + return Err(unsupported_qis(format!( + "conditional branch must name two labels: {l}" + ))); } let cond = l .strip_prefix("br i1 ") @@ -1571,7 +2000,11 @@ fn collect_qmain_cfg(src: &str) -> std::result::Result, PecosE .map(str::trim) .unwrap_or_default() .to_string(); - LlTerminator::CondBr { cond, then_label: labels[0].clone(), else_label: labels[1].clone() } + LlTerminator::CondBr { + cond, + then_label: labels[0].clone(), + else_label: labels[1].clone(), + } } else if labels.len() == 1 { LlTerminator::Br(labels[0].clone()) } else { @@ -1602,10 +2035,14 @@ fn collect_qmain_cfg(src: &str) -> std::result::Result, PecosE fn validate_straight_line_blocks(blocks: &[LlBlockShape]) -> std::result::Result<(), PecosError> { if blocks.len() != 1 || blocks.first().is_some_and(|b| !b.label.is_empty()) { - return Err(unsupported_qis("straight-line subset must have exactly one basic block; control-flow labels/branches are unsupported")); + return Err(unsupported_qis( + "straight-line subset must have exactly one basic block; control-flow labels/branches are unsupported", + )); } match blocks.first().and_then(|b| b.terminator.as_ref()) { - Some(LlTerminator::Br(_)) | Some(LlTerminator::CondBr { .. }) => Err(unsupported_qis("straight-line subset cannot contain branch control flow")), + Some(LlTerminator::Br(_)) | Some(LlTerminator::CondBr { .. }) => Err(unsupported_qis( + "straight-line subset cannot contain branch control flow", + )), Some(LlTerminator::Ret) | None => Ok(()), } } @@ -1618,7 +2055,10 @@ fn validate_straight_line_qis_shape(src: &str) -> std::result::Result<(), PecosE fn single_diamond_branch_target(block: &LlBlockShape) -> std::result::Result<&str, PecosError> { match &block.terminator { Some(LlTerminator::Br(target)) => Ok(target.as_str()), - _ => Err(unsupported_qis(format!("then/else block {} must end with an unconditional branch to the common merge block", block_name(&block.label)))), + _ => Err(unsupported_qis(format!( + "then/else block {} must end with an unconditional branch to the common merge block", + block_name(&block.label) + ))), } } @@ -1628,26 +2068,47 @@ fn validate_single_diamond_blocks(blocks: &[LlBlockShape]) -> std::result::Resul .filter(|b| matches!(&b.terminator, Some(LlTerminator::CondBr { .. }))) .collect(); if cond_blocks.len() > 1 { - return Err(unsupported_qis("more than one conditional branch -- only a single diamond is supported")); + return Err(unsupported_qis( + "more than one conditional branch -- only a single diamond is supported", + )); } if cond_blocks.is_empty() { - return Err(unsupported_qis("single-diamond subset requires one conditional branch")); + return Err(unsupported_qis( + "single-diamond subset requires one conditional branch", + )); } - let entry = blocks.iter().find(|b| b.label.is_empty()).ok_or_else(|| unsupported_qis("missing entry block"))?; + let entry = blocks + .iter() + .find(|b| b.label.is_empty()) + .ok_or_else(|| unsupported_qis("missing entry block"))?; if !cond_blocks[0].label.is_empty() { - return Err(unsupported_qis("single-diamond conditional branch must be in the entry block")); + return Err(unsupported_qis( + "single-diamond conditional branch must be in the entry block", + )); } if entry.last_quantum_before_terminator != Some(LastQuantumOp::Measure) { - return Err(unsupported_qis("entry conditional branch must follow a mid-measurement")); + return Err(unsupported_qis( + "entry conditional branch must follow a mid-measurement", + )); } let (cond, then_label, else_label) = match &entry.terminator { - Some(LlTerminator::CondBr { cond, then_label, else_label }) => (cond, then_label, else_label), - _ => return Err(unsupported_qis("entry block must terminate with the single conditional branch")), + Some(LlTerminator::CondBr { + cond, + then_label, + else_label, + }) => (cond, then_label, else_label), + _ => { + return Err(unsupported_qis( + "entry block must terminate with the single conditional branch", + )); + } }; if then_label == else_label { - return Err(unsupported_qis("conditional branch arms must target distinct blocks")); + return Err(unsupported_qis( + "conditional branch arms must target distinct blocks", + )); } // Branch-condition dataflow: prove the branch is driven by the trailing mid-measurement, i.e. @@ -1656,62 +2117,84 @@ fn validate_single_diamond_blocks(blocks: &[LlBlockShape]) -> std::result::Resul // `mid==1` case, so we require exactly that: the branch uses an `icmp eq , 1`. // (Without this, a constant condition like `icmp eq i32 0, 1` or a branch on an *earlier* // measurement would pass the shape check and be silently mis-lowered.) - let icmp = entry - .icmp - .as_ref() - .ok_or_else(|| unsupported_qis("conditional branch condition must be produced by an `icmp` on the mid-measurement"))?; + let icmp = entry.icmp.as_ref().ok_or_else(|| { + unsupported_qis( + "conditional branch condition must be produced by an `icmp` on the mid-measurement", + ) + })?; if icmp.result != *cond { - return Err(unsupported_qis("conditional branch does not use the entry `icmp` result")); + return Err(unsupported_qis( + "conditional branch does not use the entry `icmp` result", + )); } if !icmp.pred_eq || icmp.rhs_const != Some(1) { - return Err(unsupported_qis("only `icmp eq , 1` branch conditions are supported")); - } - let measure_ref = icmp - .measure_ref - .as_deref() - .ok_or_else(|| unsupported_qis("branch condition must compare a measurement result, not a constant"))?; - let last_measure = entry - .measures - .last() - .map(String::as_str) - .ok_or_else(|| unsupported_qis("entry block has no named measurement to condition on"))?; + return Err(unsupported_qis( + "only `icmp eq , 1` branch conditions are supported", + )); + } + let measure_ref = icmp.measure_ref.as_deref().ok_or_else(|| { + unsupported_qis("branch condition must compare a measurement result, not a constant") + })?; + let last_measure = + entry.measures.last().map(String::as_str).ok_or_else(|| { + unsupported_qis("entry block has no named measurement to condition on") + })?; if measure_ref != last_measure { - return Err(unsupported_qis("conditional branch must condition on the trailing mid-measurement, not an earlier one")); + return Err(unsupported_qis( + "conditional branch must condition on the trailing mid-measurement, not an earlier one", + )); } let mut by_label: HashMap<&str, &LlBlockShape> = HashMap::new(); for block in blocks { if by_label.insert(block.label.as_str(), block).is_some() { - return Err(unsupported_qis(format!("duplicate basic-block label {}", block_name(&block.label)))); + return Err(unsupported_qis(format!( + "duplicate basic-block label {}", + block_name(&block.label) + ))); } } - let then_block = by_label - .get(then_label.as_str()) - .copied() - .ok_or_else(|| unsupported_qis(format!("conditional branch target {then_label} is missing")))?; - let else_block = by_label - .get(else_label.as_str()) - .copied() - .ok_or_else(|| unsupported_qis(format!("conditional branch target {else_label} is missing")))?; + let then_block = by_label.get(then_label.as_str()).copied().ok_or_else(|| { + unsupported_qis(format!("conditional branch target {then_label} is missing")) + })?; + let else_block = by_label.get(else_label.as_str()).copied().ok_or_else(|| { + unsupported_qis(format!("conditional branch target {else_label} is missing")) + })?; let then_merge = single_diamond_branch_target(then_block)?; let else_merge = single_diamond_branch_target(else_block)?; if then_merge != else_merge { - return Err(unsupported_qis("then/else blocks must branch to the same merge block")); + return Err(unsupported_qis( + "then/else blocks must branch to the same merge block", + )); } if then_merge.is_empty() || then_merge == then_label || then_merge == else_label { - return Err(unsupported_qis("single-diamond merge target must be a distinct non-entry block")); + return Err(unsupported_qis( + "single-diamond merge target must be a distinct non-entry block", + )); } - let merge_block = by_label.get(then_merge).copied().ok_or_else(|| unsupported_qis(format!("merge block {then_merge} is missing")))?; + let merge_block = by_label + .get(then_merge) + .copied() + .ok_or_else(|| unsupported_qis(format!("merge block {then_merge} is missing")))?; if !matches!(merge_block.terminator, Some(LlTerminator::Ret) | None) { - return Err(unsupported_qis("single-diamond merge block must not branch again")); + return Err(unsupported_qis( + "single-diamond merge block must not branch again", + )); } for block in blocks { - if !block.label.is_empty() && block.label != *then_label && block.label != *else_label && block.label != then_merge { - return Err(unsupported_qis(format!("extra basic block {} outside the single-diamond shape", block_name(&block.label)))); + if !block.label.is_empty() + && block.label != *then_label + && block.label != *else_label + && block.label != then_merge + { + return Err(unsupported_qis(format!( + "extra basic block {} outside the single-diamond shape", + block_name(&block.label) + ))); } } Ok(()) @@ -1739,8 +2222,18 @@ fn classify_covered_qis_shape(src: &str) -> std::result::Result) { for p in ops { match *p { - ParsedOp::H(q) | ParsedOp::Rz(q, _) | ParsedOp::Rx(q, _) | ParsedOp::Ry(q, _) | ParsedOp::X(q) | ParsedOp::M(q, _) => { set.insert(q); } - ParsedOp::Szz(a, b) | ParsedOp::Cz(a, b) | ParsedOp::Swap(a, b) => { set.insert(a); set.insert(b); } + ParsedOp::H(q) + | ParsedOp::Rz(q, _) + | ParsedOp::Rx(q, _) + | ParsedOp::Ry(q, _) + | ParsedOp::X(q) + | ParsedOp::M(q, _) => { + set.insert(q); + } + ParsedOp::Szz(a, b) | ParsedOp::Cz(a, b) | ParsedOp::Swap(a, b) => { + set.insert(a); + set.insert(b); + } ParsedOp::Record(_) => {} } } @@ -1748,29 +2241,79 @@ pub fn collect_qubits(ops: &[ParsedOp], set: &mut BTreeSet) { /// Emit one parsed op into `block`. `measured` accumulates `result-id -> measurement-SSA Value` so a /// later `ParsedOp::Record` resolves exactly which `qec.measure` becomes a program output; `reg` /// gets each measurement's metadata (qubit, basis, export label) keyed by its SSA value. -pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot_of: &HashMap, measured: &mut HashMap, reg: &mut MeasurementRegistry) -> std::result::Result<(), PecosError> { +pub fn emit_parsed( + ctx: &mut Context, + p: &ParsedOp, + block: Ptr, + slot_of: &HashMap, + measured: &mut HashMap, + reg: &mut MeasurementRegistry, +) -> std::result::Result<(), PecosError> { match *p { - ParsedOp::H(q) => { HOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } + ParsedOp::H(q) => { + HOp::new(ctx, slot_of[&q]) + .get_operation() + .insert_at_back(block, ctx); + } // .ll angles are f64 radians (the wire format); convert to fixed-point Angle64 at the boundary. - ParsedOp::Rz(q, t) => { RzOp::new(ctx, slot_of[&q], Angle64::from_radians(t)).get_operation().insert_at_back(block, ctx); } - ParsedOp::Rx(q, t) => { RxOp::new(ctx, slot_of[&q], Angle64::from_radians(t)).get_operation().insert_at_back(block, ctx); } - ParsedOp::Ry(q, t) => { RyOp::new(ctx, slot_of[&q], Angle64::from_radians(t)).get_operation().insert_at_back(block, ctx); } - ParsedOp::Szz(a, b) => { SzzOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } - ParsedOp::Cz(a, b) => { CzOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } - ParsedOp::Swap(a, b) => { SwapOp::new(ctx, slot_of[&a], slot_of[&b]).get_operation().insert_at_back(block, ctx); } - ParsedOp::X(q) => { XOp::new(ctx, slot_of[&q]).get_operation().insert_at_back(block, ctx); } + ParsedOp::Rz(q, t) => { + RzOp::new(ctx, slot_of[&q], Angle64::from_radians(t)) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Rx(q, t) => { + RxOp::new(ctx, slot_of[&q], Angle64::from_radians(t)) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Ry(q, t) => { + RyOp::new(ctx, slot_of[&q], Angle64::from_radians(t)) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Szz(a, b) => { + SzzOp::new(ctx, slot_of[&a], slot_of[&b]) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Cz(a, b) => { + CzOp::new(ctx, slot_of[&a], slot_of[&b]) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::Swap(a, b) => { + SwapOp::new(ctx, slot_of[&a], slot_of[&b]) + .get_operation() + .insert_at_back(block, ctx); + } + ParsedOp::X(q) => { + XOp::new(ctx, slot_of[&q]) + .get_operation() + .insert_at_back(block, ctx); + } ParsedOp::M(q, rid) => { let m = MeasureOp::new(ctx, slot_of[&q]); m.get_operation().insert_at_back(block, ctx); let v = m.get_result(ctx); measured.insert(rid, v); - reg.record(v, MeasurementInfo { qubit: q, basis: Basis::Z, export_label: rid }); + reg.record( + v, + MeasurementInfo { + qubit: q, + basis: Basis::Z, + export_label: rid, + }, + ); } ParsedOp::Record(rid) => { - let v = *measured - .get(&rid) - .ok_or_else(|| unsupported_qis(format!("result_record_output references unknown result-id {rid}")))?; - RecordOp::new(ctx, v).get_operation().insert_at_back(block, ctx); + let v = *measured.get(&rid).ok_or_else(|| { + unsupported_qis(format!( + "result_record_output references unknown result-id {rid}" + )) + })?; + RecordOp::new(ctx, v) + .get_operation() + .insert_at_back(block, ctx); } } Ok(()) @@ -1779,7 +2322,10 @@ pub fn emit_parsed(ctx: &mut Context, p: &ParsedOp, block: Ptr, slot /// Parse the adaptive single-diamond QIS-LLVM subset, lifting the conditional branch into a `qec.if`. /// Rejects (structured error, not silent-drop/panic) anything outside the subset: unrecognized /// `__quantum__` calls, malformed operand lists, and more than one conditional branch. -pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { +pub fn parse_qprog_ll( + ctx: &mut Context, + src: &str, +) -> std::result::Result<(ModuleOp, Ptr, MeasurementRegistry), PecosError> { validate_single_diamond_qis_shape(src)?; // pass 1: collect ops per block label (entry = "") and the conditional-branch targets. @@ -1790,17 +2336,33 @@ pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modu let mut in_func = false; for raw in src.lines() { let l = raw.trim(); - if l.starts_with("define ") && l.contains("@qmain") { in_func = true; continue; } - if !in_func { continue; } - if l == "}" { blocks.push((std::mem::take(&mut cur_label), std::mem::take(&mut cur_ops))); break; } + if l.starts_with("define ") && l.contains("@qmain") { + in_func = true; + continue; + } + if !in_func { + continue; + } + if l == "}" { + blocks.push((std::mem::take(&mut cur_label), std::mem::take(&mut cur_ops))); + break; + } if l.ends_with(':') && !l.contains(' ') { blocks.push((std::mem::take(&mut cur_label), std::mem::take(&mut cur_ops))); cur_label = l.trim_end_matches(':').to_string(); continue; } let (ds, is) = doubles_and_ints(inner_parens(l)); - let iq = |i: usize| is.get(i).copied().ok_or_else(|| unsupported_qis(format!("missing i64 operand {i}: {l}"))); - let da = |i: usize| ds.get(i).copied().ok_or_else(|| unsupported_qis(format!("missing double operand {i}: {l}"))); + let iq = |i: usize| { + is.get(i) + .copied() + .ok_or_else(|| unsupported_qis(format!("missing i64 operand {i}: {l}"))) + }; + let da = |i: usize| { + ds.get(i) + .copied() + .ok_or_else(|| unsupported_qis(format!("missing double operand {i}: {l}"))) + }; if l.contains("__quantum__qis__h__body") { cur_ops.push(ParsedOp::H(iq(0)?)); } else if l.contains("__quantum__qis__rz__body") { @@ -1825,28 +2387,42 @@ pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modu let labels = br_labels(l); if labels.len() == 2 { if then_label.is_some() { - return Err(unsupported_qis("more than one conditional branch -- only a single diamond is supported")); + return Err(unsupported_qis( + "more than one conditional branch -- only a single diamond is supported", + )); } then_label = Some(labels[0].clone()); else_label = Some(labels[1].clone()); } } else if l.contains("__quantum__") { - return Err(unsupported_qis(format!("operation not in the covered subset: {l}"))); + return Err(unsupported_qis(format!( + "operation not in the covered subset: {l}" + ))); } } - let find = |lab: &str| blocks.iter().find(|(l, _)| l == lab).map(|(_, o)| o.clone()).unwrap_or_default(); + let find = |lab: &str| { + blocks + .iter() + .find(|(l, _)| l == lab) + .map(|(_, o)| o.clone()) + .unwrap_or_default() + }; let entry_ops = find(""); let then_ops = then_label.as_deref().map(find).unwrap_or_default(); let else_ops = else_label.as_deref().map(find).unwrap_or_default(); let merge_ops = blocks .iter() - .find(|(l, _)| !l.is_empty() && Some(l) != then_label.as_ref() && Some(l) != else_label.as_ref()) + .find(|(l, _)| { + !l.is_empty() && Some(l) != then_label.as_ref() && Some(l) != else_label.as_ref() + }) .map(|(_, o)| o.clone()) .unwrap_or_default(); // pass 2: build the pliron qec IR. let mut qubits = BTreeSet::new(); - for ops in [&entry_ops, &then_ops, &else_ops, &merge_ops] { collect_qubits(ops, &mut qubits); } + for ops in [&entry_ops, &then_ops, &else_ops, &merge_ops] { + collect_qubits(ops, &mut qubits); + } let module = ModuleOp::new(ctx, "qprog".try_into().unwrap()); let func_ty = FunctionType::get(ctx, vec![], vec![]); @@ -1863,7 +2439,9 @@ pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modu s.get_operation().insert_at_back(bb, ctx); let sv = s.get_result(ctx); slot_of.insert(idx, sv); - PrepareOp::new(ctx, sv).get_operation().insert_at_back(bb, ctx); + PrepareOp::new(ctx, sv) + .get_operation() + .insert_at_back(bb, ctx); } // result-id -> measurement-SSA Value, so result_record_output can name the recorded measurement. let mut measured: HashMap = HashMap::new(); @@ -1871,23 +2449,42 @@ pub fn parse_qprog_ll(ctx: &mut Context, src: &str) -> std::result::Result<(Modu // entry gates (everything before the trailing mid-measure), then the mid measure (the cond). let (mid_q, mid_rid) = match entry_ops.last() { Some(ParsedOp::M(q, rid)) => (*q, *rid), - _ => return Err(unsupported_qis("entry block must end with a mid-measurement (single-diamond adaptive shape)")), + _ => { + return Err(unsupported_qis( + "entry block must end with a mid-measurement (single-diamond adaptive shape)", + )); + } }; - for p in &entry_ops[..entry_ops.len() - 1] { emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg)?; } + for p in &entry_ops[..entry_ops.len() - 1] { + emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg)?; + } let m0 = MeasureOp::new(ctx, slot_of[&mid_q]); m0.get_operation().insert_at_back(bb, ctx); let m0v = m0.get_result(ctx); measured.insert(mid_rid, m0v); - reg.record(m0v, MeasurementInfo { qubit: mid_q, basis: Basis::Z, export_label: mid_rid }); + reg.record( + m0v, + MeasurementInfo { + qubit: mid_q, + basis: Basis::Z, + export_label: mid_rid, + }, + ); // lift the diamond into qec.if(mid) { then } { else } let ifop = IfOp::new(ctx, m0v); ifop.get_operation().insert_at_back(bb, ctx); let then_bb = ifop.make_region_block(ctx, 0); - for p in &then_ops { emit_parsed(ctx, p, then_bb, &slot_of, &mut measured, &mut reg)?; } + for p in &then_ops { + emit_parsed(ctx, p, then_bb, &slot_of, &mut measured, &mut reg)?; + } let else_bb = ifop.make_region_block(ctx, 1); - for p in &else_ops { emit_parsed(ctx, p, else_bb, &slot_of, &mut measured, &mut reg)?; } + for p in &else_ops { + emit_parsed(ctx, p, else_bb, &slot_of, &mut measured, &mut reg)?; + } // final measurements + result_record_output ops (the export list) - for p in &merge_ops { emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg)?; } + for p in &merge_ops { + emit_parsed(ctx, p, bb, &slot_of, &mut measured, &mut reg)?; + } EndOp::new(ctx).get_operation().insert_at_back(bb, ctx); Ok((module, bb, reg)) } @@ -1898,7 +2495,8 @@ pub fn run_milestone_6() { let (module, bb, reg) = parse_qprog_ll(ctx, src).expect("milestone-6: parse qprog.ll"); println!("=== qprog.ll parsed into pliron qec IR (rotations + qec.if) ==="); println!("{}", module.get_operation().disp(ctx)); - verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-6 verify] FAILED: {}", e.disp(ctx))); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-6 verify] FAILED: {}", e.disp(ctx))); let plan = plan_from_if_ir(ctx, bb, ®); let engine = PlironIfEngine { batch1: plan.batch1, @@ -1920,21 +2518,41 @@ pub fn run_milestone_6() { let mut n = 0; for _ in 0..200 { let shot = hybrid.run_shot().unwrap(); - let f0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (final_q0)"); - let f1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (final_q1)"); - let mid = shot.data.get("r2").and_then(Data::as_u32).expect("r2 (mid)"); + let f0 = shot + .data + .get("r0") + .and_then(Data::as_u32) + .expect("r0 (final_q0)"); + let f1 = shot + .data + .get("r1") + .and_then(Data::as_u32) + .expect("r1 (final_q1)"); + let mid = shot + .data + .get("r2") + .and_then(Data::as_u32) + .expect("r2 (mid)"); // q0 only sees Z-diagonal ops (rz, szz), so its mid and final measures are deterministically 0. assert_eq!(mid, 0, "milestone-6: q0 is Z-diagonal, mid (r2) must be 0"); - assert_eq!(f0, 0, "milestone-6: q0 is Z-diagonal, final_q0 (r0) must be 0"); + assert_eq!( + f0, 0, + "milestone-6: q0 is Z-diagonal, final_q0 (r0) must be 0" + ); seen.insert((mid, f0, f1)); n += 1; Engine::reset(&mut hybrid).unwrap(); } assert_eq!(n, 200, "milestone-6: expected 200 shots"); - assert!(!seen.is_empty(), "milestone-6: qprog.ll must produce results"); + assert!( + !seen.is_empty(), + "milestone-6: qprog.ll must produce results" + ); // The conditional branch is never taken here (mid is deterministically 0) -- branch-firing is // exercised by M5/M7; the quantum variety is on q1 (rx(pi)+ry+szz), so r1 varies across shots. - println!("[milestone-6 qprog.ll -> pliron qec (rotations + qec.if) -> HybridEngine] OK -- {n} shots, observed (r2_mid,r0_final_q0,r1_final_q1): {seen:?}"); + println!( + "[milestone-6 qprog.ll -> pliron qec (rotations + qec.if) -> HybridEngine] OK -- {n} shots, observed (r2_mid,r0_final_q0,r1_final_q1): {seen:?}" + ); } /// M7 step 2: a branch-*taken* fixture parsed from `.ll` — proves `qec.if` firing from parsed input @@ -1942,12 +2560,18 @@ pub fn run_milestone_6() { pub fn run_milestone_7() { let ctx = &mut Context::new(); let src = include_str!("../fixtures/adaptive_branch.ll"); - let (module, bb, reg) = parse_qprog_ll(ctx, src).expect("milestone-7: parse adaptive_branch.ll"); + let (module, bb, reg) = + parse_qprog_ll(ctx, src).expect("milestone-7: parse adaptive_branch.ll"); println!("=== adaptive_branch.ll parsed into pliron qec IR ==="); println!("{}", module.get_operation().disp(ctx)); - verify_op(&module, ctx).unwrap_or_else(|e| panic!("[milestone-7 verify] FAILED: {}", e.disp(ctx))); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[milestone-7 verify] FAILED: {}", e.disp(ctx))); let plan = plan_from_if_ir(ctx, bb, ®); - assert_eq!(plan.export, vec![2, 1], "milestone-7: result_record_output order must be [2,1] (mid, final_q1)"); + assert_eq!( + plan.export, + vec![2, 1], + "milestone-7: result_record_output order must be [2,1] (mid, final_q1)" + ); let engine = PlironIfEngine { batch1: plan.batch1, cond_outcome_idx: plan.cond_outcome_idx, @@ -1967,8 +2591,16 @@ pub fn run_milestone_7() { let (mut all_eq, mut saw0, mut saw1) = (true, false, false); for _ in 0..200 { let shot = hybrid.run_shot().unwrap(); - let mid = shot.data.get("r2").and_then(Data::as_u32).expect("r2 (mid)"); - let fin = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (final_q1)"); + let mid = shot + .data + .get("r2") + .and_then(Data::as_u32) + .expect("r2 (mid)"); + let fin = shot + .data + .get("r1") + .and_then(Data::as_u32) + .expect("r1 (final_q1)"); if fin != mid { all_eq = false; } @@ -1976,9 +2608,17 @@ pub fn run_milestone_7() { saw1 |= mid == 1; Engine::reset(&mut hybrid).unwrap(); } - assert!(all_eq, "milestone-7: final_q1 (r1) must equal mid (r2) -- qec.if firing from parsed input"); - assert!(saw0 && saw1, "milestone-7: branch must both fire (mid=1) and not (mid=0)"); - println!("[milestone-7 adaptive_branch.ll -> qec.if branch TAKEN from parsed input] OK -- r1(final_q1)==r2(mid) all 200 shots, mid 0 and 1 both seen"); + assert!( + all_eq, + "milestone-7: final_q1 (r1) must equal mid (r2) -- qec.if firing from parsed input" + ); + assert!( + saw0 && saw1, + "milestone-7: branch must both fire (mid=1) and not (mid=0)" + ); + println!( + "[milestone-7 adaptive_branch.ll -> qec.if branch TAKEN from parsed input] OK -- r1(final_q1)==r2(mid) all 200 shots, mid 0 and 1 both seen" + ); } /// Coverage for a measurement INSIDE the conditional branch: `b2`'s length varies with the taken @@ -1989,12 +2629,20 @@ pub fn run_branch_measure() { let ctx = &mut Context::new(); let src = include_str!("../fixtures/branch_measure.ll"); let (module, bb, reg) = parse_qprog_ll(ctx, src).expect("branch_measure: parse"); - verify_op(&module, ctx).unwrap_or_else(|e| panic!("[branch_measure verify] FAILED: {}", e.disp(ctx))); + verify_op(&module, ctx) + .unwrap_or_else(|e| panic!("[branch_measure verify] FAILED: {}", e.disp(ctx))); let plan = plan_from_if_ir(ctx, bb, ®); // structural: the then-branch really does contain a measurement (this is what we are covering), // and the in-branch measure (result-id 3) is NOT in the export list. - assert!(plan.then_cmds.iter().any(|c| matches!(c, Cmd::Mz(..))), "then-branch must contain a measurement"); - assert_eq!(plan.export, vec![0, 1], "only the unconditional finals are recorded (in-branch m1 is not)"); + assert!( + plan.then_cmds.iter().any(|c| matches!(c, Cmd::Mz(..))), + "then-branch must contain a measurement" + ); + assert_eq!( + plan.export, + vec![0, 1], + "only the unconditional finals are recorded (in-branch m1 is not)" + ); let engine = PlironIfEngine { batch1: plan.batch1, cond_outcome_idx: plan.cond_outcome_idx, @@ -2014,8 +2662,16 @@ pub fn run_branch_measure() { for _ in 0..200 { let shot = hybrid.run_shot().unwrap(); // r0 = final_q0 (== mid), r1 = final_q1 (== mid). The in-branch measure of q1 leaves q1 == mid. - let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (final_q0)"); - let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (final_q1)"); + let r0 = shot + .data + .get("r0") + .and_then(Data::as_u32) + .expect("r0 (final_q0)"); + let r1 = shot + .data + .get("r1") + .and_then(Data::as_u32) + .expect("r1 (final_q1)"); if r0 != r1 { all_eq = false; } @@ -2023,9 +2679,17 @@ pub fn run_branch_measure() { saw1 |= r0 == 1; Engine::reset(&mut hybrid).unwrap(); } - assert!(all_eq, "branch_measure: final_q0 (r0) must equal final_q1 (r1) -- variable-length b2 reconstructed correctly"); - assert!(saw0 && saw1, "branch_measure: branch must both fire and not (r0 both 0 and 1)"); - println!("[branch_measure measure-inside-branch -> variable-length b2 reconstruction] OK -- r0==r1 all 200 shots, both 0 and 1 seen"); + assert!( + all_eq, + "branch_measure: final_q0 (r0) must equal final_q1 (r1) -- variable-length b2 reconstructed correctly" + ); + assert!( + saw0 && saw1, + "branch_measure: branch must both fire and not (r0 both 0 and 1)" + ); + println!( + "[branch_measure measure-inside-branch -> variable-length b2 reconstruction] OK -- r0==r1 all 200 shots, both 0 and 1 seen" + ); } // ===================== public adapter: the opt-in QIS-LLVM-IR -> pliron call path ===================== @@ -2039,14 +2703,17 @@ pub fn run_branch_measure() { /// `result_record_output` export). The returned engine reports its own `num_qubits()` for sizing the /// quantum backend. Returns a structured error if the source is outside the covered subset or the /// lowered IR fails verification. -pub fn from_qis_llvm_ir_pliron(src: &str) -> std::result::Result, PecosError> { +pub fn from_qis_llvm_ir_pliron( + src: &str, +) -> std::result::Result, PecosError> { let ctx = &mut Context::new(); let (module, bb, reg) = match classify_covered_qis_shape(src)? { QisLlShape::SingleDiamond => parse_qprog_ll(ctx, src)?, QisLlShape::StraightLine => parse_bell_ll(ctx, src)?, }; - verify_op(&module, ctx) - .map_err(|e| PecosError::Compilation(format!("pliron qec verification failed: {}", e.disp(ctx))))?; + verify_op(&module, ctx).map_err(|e| { + PecosError::Compilation(format!("pliron qec verification failed: {}", e.disp(ctx))) + })?; let plan = plan_from_if_ir(ctx, bb, ®); Ok(Box::new(PlironIfEngine { batch1: plan.batch1, @@ -2086,7 +2753,11 @@ mod tests { let (module, bb, reg) = parse_bell_ll(ctx, bell).expect("port lowers bell.ll"); verify_op(&module, ctx).expect("port lowers bell.ll (with qec.record) and verifies"); let plan = plan_from_if_ir(ctx, bb, ®); - assert_eq!(plan.export, vec![0, 1], "bell.ll records result-ids 0 (q0) then 1 (q1)"); + assert_eq!( + plan.export, + vec![0, 1], + "bell.ll records result-ids 0 (q0) then 1 (q1)" + ); let engine = PlironIfEngine { batch1: plan.batch1, cond_outcome_idx: plan.cond_outcome_idx, @@ -2107,7 +2778,10 @@ mod tests { let shot = hybrid.run_shot().unwrap(); let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0 (q0)"); let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1 (q1)"); - assert_eq!(r0, r1, "port bell.ll via qec.record export must be Bell-correlated, got r0={r0} r1={r1}"); + assert_eq!( + r0, r1, + "port bell.ll via qec.record export must be Bell-correlated, got r0={r0} r1={r1}" + ); saw0 |= r0 == 0; saw1 |= r0 == 1; Engine::reset(&mut hybrid).unwrap(); @@ -2128,7 +2802,9 @@ mod tests { registers; the port exports r0/r1. pecos-phir gave: {:?}", shot.data.keys().collect::>() ); - println!("[differential bell.ll] port -> r0==r1 Bell pair via qec.record export; pecos-phir -> empty Shot (record_output elided)"); + println!( + "[differential bell.ll] port -> r0==r1 Bell pair via qec.record export; pecos-phir -> empty Shot (record_output elided)" + ); } /// qprog.ll rotation divergence: the port lowers `rz/rx/ry/zz` (M6 runs it end-to-end), while @@ -2150,7 +2826,9 @@ mod tests { msg.contains("angle") || msg.contains("rz"), "expected the divergence to be the rz-angle resolution; got: {msg}" ); - println!("[differential qprog.ll] port lowers rotations + qec.if; pecos-phir errors: {msg}"); + println!( + "[differential qprog.ll] port lowers rotations + qec.if; pecos-phir errors: {msg}" + ); } /// The real call path: drive the PUBLIC `from_qis_llvm_ir_pliron` adapter (not the internal @@ -2161,7 +2839,8 @@ mod tests { use pecos_engines::hybrid::HybridEngineBuilder; // bell.ll (straight-line) -> r0==r1 Bell pair via the registry/qec.record export. - let eng = from_qis_llvm_ir_pliron(include_str!("../../../examples/llvm/bell.ll")).expect("adapter lowers bell.ll"); + let eng = from_qis_llvm_ir_pliron(include_str!("../../../examples/llvm/bell.ll")) + .expect("adapter lowers bell.ll"); let n = eng.num_qubits(); let mut hybrid = HybridEngineBuilder::new() .with_classical_engine(eng) @@ -2172,7 +2851,10 @@ mod tests { let shot = hybrid.run_shot().unwrap(); let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0"); let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1"); - assert_eq!(r0, r1, "bell via adapter must be Bell-correlated, got r0={r0} r1={r1}"); + assert_eq!( + r0, r1, + "bell via adapter must be Bell-correlated, got r0={r0} r1={r1}" + ); saw0 |= r0 == 0; saw1 |= r0 == 1; Engine::reset(&mut hybrid).unwrap(); @@ -2180,7 +2862,8 @@ mod tests { assert!(saw0 && saw1, "bell via adapter must see both 00 and 11"); // qprog.ll (diamond) -> records r0/r1/r2; q0 is Z-diagonal so mid (r2) and final_q0 (r0) are 0. - let eng = from_qis_llvm_ir_pliron(include_str!("../../../examples/llvm/qprog.ll")).expect("adapter lowers qprog.ll"); + let eng = from_qis_llvm_ir_pliron(include_str!("../../../examples/llvm/qprog.ll")) + .expect("adapter lowers qprog.ll"); let n = eng.num_qubits(); let mut hybrid = HybridEngineBuilder::new() .with_classical_engine(eng) @@ -2188,9 +2871,20 @@ mod tests { .build(); for _ in 0..50 { let shot = hybrid.run_shot().unwrap(); - assert_eq!(shot.data.get("r2").and_then(Data::as_u32), Some(0), "qprog mid (r2) deterministically 0"); - assert_eq!(shot.data.get("r0").and_then(Data::as_u32), Some(0), "qprog final_q0 (r0) deterministically 0"); - assert!(shot.data.get("r1").and_then(Data::as_u32).is_some(), "qprog final_q1 (r1) present"); + assert_eq!( + shot.data.get("r2").and_then(Data::as_u32), + Some(0), + "qprog mid (r2) deterministically 0" + ); + assert_eq!( + shot.data.get("r0").and_then(Data::as_u32), + Some(0), + "qprog final_q0 (r0) deterministically 0" + ); + assert!( + shot.data.get("r1").and_then(Data::as_u32).is_some(), + "qprog final_q1 (r1) present" + ); Engine::reset(&mut hybrid).unwrap(); } } @@ -2201,7 +2895,8 @@ mod tests { #[test] fn adapter_cz_swap_gates() { use pecos_engines::hybrid::HybridEngineBuilder; - let eng = from_qis_llvm_ir_pliron(include_str!("../fixtures/cz_swap.ll")).expect("adapter lowers cz_swap.ll"); + let eng = from_qis_llvm_ir_pliron(include_str!("../fixtures/cz_swap.ll")) + .expect("adapter lowers cz_swap.ll"); let n = eng.num_qubits(); assert_eq!(n, 3, "cz_swap uses qubits 0,1,2"); let mut hybrid = HybridEngineBuilder::new() @@ -2210,17 +2905,34 @@ mod tests { .build(); for _ in 0..50 { let shot = hybrid.run_shot().unwrap(); - assert_eq!(shot.data.get("r0").and_then(Data::as_u32), Some(1), "cz+h must drive q0 to 1"); - assert_eq!(shot.data.get("r1").and_then(Data::as_u32), Some(0), "swap moves the 1 off q1"); - assert_eq!(shot.data.get("r2").and_then(Data::as_u32), Some(1), "swap moves the 1 onto q2"); + assert_eq!( + shot.data.get("r0").and_then(Data::as_u32), + Some(1), + "cz+h must drive q0 to 1" + ); + assert_eq!( + shot.data.get("r1").and_then(Data::as_u32), + Some(0), + "swap moves the 1 off q1" + ); + assert_eq!( + shot.data.get("r2").and_then(Data::as_u32), + Some(1), + "swap moves the 1 onto q2" + ); Engine::reset(&mut hybrid).unwrap(); } } #[test] fn adapter_ghz3_dynamic_qubit_count() { use pecos_engines::hybrid::HybridEngineBuilder; - let eng = from_qis_llvm_ir_pliron(include_str!("../fixtures/ghz3.ll")).expect("adapter lowers ghz3.ll"); - assert_eq!(eng.num_qubits(), 3, "GHZ-3 engine must report 3 qubits (dynamic, not hard-coded 2)"); + let eng = from_qis_llvm_ir_pliron(include_str!("../fixtures/ghz3.ll")) + .expect("adapter lowers ghz3.ll"); + assert_eq!( + eng.num_qubits(), + 3, + "GHZ-3 engine must report 3 qubits (dynamic, not hard-coded 2)" + ); let n = eng.num_qubits(); let mut hybrid = HybridEngineBuilder::new() .with_classical_engine(eng) @@ -2232,7 +2944,10 @@ mod tests { let r0 = shot.data.get("r0").and_then(Data::as_u32).expect("r0"); let r1 = shot.data.get("r1").and_then(Data::as_u32).expect("r1"); let r2 = shot.data.get("r2").and_then(Data::as_u32).expect("r2"); - assert!(r0 == r1 && r1 == r2, "GHZ-3 must be fully correlated, got r0={r0} r1={r1} r2={r2}"); + assert!( + r0 == r1 && r1 == r2, + "GHZ-3 must be fully correlated, got r0={r0} r1={r1} r2={r2}" + ); saw0 |= r0 == 0; saw1 |= r0 == 1; Engine::reset(&mut hybrid).unwrap(); @@ -2297,7 +3012,10 @@ mod tests { .collect(); infos.sort_by_key(|&(q, _, label)| (label, q)); // qprog.ll: result-id 0 = final q0, 1 = final q1, 2 = mid q0 -- all Z measurements. - assert_eq!(infos, vec![(0, Basis::Z, 0), (1, Basis::Z, 1), (0, Basis::Z, 2)]); + assert_eq!( + infos, + vec![(0, Basis::Z, 0), (1, Basis::Z, 1), (0, Basis::Z, 2)] + ); } // ---- negative tests: prove the verifiers and seam invariants actually bite ---- @@ -2310,7 +3028,10 @@ mod tests { let alloc = QallocOp::new(ctx); let bad = HOp::new(ctx, alloc.get_result(ctx)); let res = verify_op(&bad, ctx); - assert!(res.is_err(), "qec.h on a qec.alloc handle must fail verification, got Ok"); + assert!( + res.is_err(), + "qec.h on a qec.alloc handle must fail verification, got Ok" + ); } /// `qec.cond_x`'s condition (operand 0) must be an `i1` measurement result. A non-`i1` condition @@ -2323,7 +3044,8 @@ mod tests { let s1 = SlotOp::new(ctx, alloc.get_result(ctx), 1); // use a qubitref (operand 0) as the condition -- it is not an i1 measurement result. let bad = CondXOp::new(ctx, s0.get_result(ctx), s1.get_result(ctx)); - let err = verify_op(&bad, ctx).expect_err("qec.cond_x with a non-i1 condition must fail verification"); + let err = verify_op(&bad, ctx) + .expect_err("qec.cond_x with a non-i1 condition must fail verification"); assert!( format!("{}", err.disp(ctx)).contains("i1"), "expected the i1-condition rejection, got: {}", @@ -2339,7 +3061,10 @@ mod tests { let alloc = QallocOp::new(ctx); // rz on a qec.alloc handle (not a qubitref). let bad = RzOp::new(ctx, alloc.get_result(ctx), Angle64::from_radians(0.5)); - assert!(verify_op(&bad, ctx).is_err(), "qec.rz on a qec.alloc handle must fail verification"); + assert!( + verify_op(&bad, ctx).is_err(), + "qec.rz on a qec.alloc handle must fail verification" + ); } /// `qec.if`'s condition must be an `i1`; a non-`i1` (here a qubitref) must be rejected -- guards @@ -2352,7 +3077,8 @@ mod tests { let ifop = IfOp::new(ctx, s0v); // qubitref condition -- not an i1 ifop.make_region_block(ctx, 0); ifop.make_region_block(ctx, 1); - let err = verify_op(&ifop, ctx).expect_err("qec.if with a non-i1 condition must fail verification"); + let err = verify_op(&ifop, ctx) + .expect_err("qec.if with a non-i1 condition must fail verification"); assert!( format!("{}", err.disp(ctx)).contains("i1"), "expected the i1-condition rejection, got: {}", @@ -2372,14 +3098,25 @@ mod tests { module.append_operation(ctx, func.get_operation(), 0); let bb = func.get_entry_block(ctx); macro_rules! push { - ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; } let qv = push!(QallocOp::new(ctx)).get_result(ctx); let sv = push!(SlotOp::new(ctx, qv, 0)).get_result(ctx); // slot index 0 push!(PrepareOp::new(ctx, sv)); let m = push!(MeasureOp::new(ctx, sv)); let mut reg = MeasurementRegistry::default(); - reg.record(m.get_result(ctx), MeasurementInfo { qubit: 1, basis: Basis::Z, export_label: 0 }); // qubit 1 != slot 0 + reg.record( + m.get_result(ctx), + MeasurementInfo { + qubit: 1, + basis: Basis::Z, + export_label: 0, + }, + ); // qubit 1 != slot 0 let _ = plan_from_if_ir(ctx, bb, ®); // measure_to_mz must assert and panic } @@ -2394,14 +3131,22 @@ mod tests { module.append_operation(ctx, func.get_operation(), 0); let bb = func.get_entry_block(ctx); macro_rules! push { - ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; } let q = push!(QallocOp::new(ctx)); let sv = push!(SlotOp::new(ctx, q.get_result(ctx), 0)).get_result(ctx); let angle = Angle64::from_radians(1.07); let rz = push!(RzOp::new(ctx, sv, angle)); let got = get_angle(ctx, rz.get_operation()); - assert_eq!(got.fraction(), angle.fraction(), "qec.angle must round-trip the Angle64 fixed-point fraction exactly"); + assert_eq!( + got.fraction(), + angle.fraction(), + "qec.angle must round-trip the Angle64 fixed-point fraction exactly" + ); } /// Recording a measurement defined *inside* a `qec.if` region from the OUTER block is a @@ -2416,7 +3161,11 @@ mod tests { module.append_operation(ctx, func.get_operation(), 0); let bb = func.get_entry_block(ctx); macro_rules! push { - ($op:expr) => {{ let o = $op; o.get_operation().insert_at_back(bb, ctx); o }}; + ($op:expr) => {{ + let o = $op; + o.get_operation().insert_at_back(bb, ctx); + o + }}; } let q = push!(QallocOp::new(ctx)); let qv = q.get_result(ctx); @@ -2451,8 +3200,14 @@ mod tests { #[test] fn empty_message_is_semantically_empty() { let empty = ByteMessage::create_empty(); - assert!(empty.is_empty().unwrap(), "create_empty() must be semantically empty (is_empty()==Ok(true))"); - assert!(!empty.as_bytes().is_empty(), "create_empty().as_bytes() is NOT byte-empty -- that is the footgun"); + assert!( + empty.is_empty().unwrap(), + "create_empty() must be semantically empty (is_empty()==Ok(true))" + ); + assert!( + !empty.as_bytes().is_empty(), + "create_empty().as_bytes() is NOT byte-empty -- that is the footgun" + ); } /// A `result_record_output` that names a result-id no measurement produced must return a @@ -2638,7 +3393,9 @@ final: } "; let Err(err) = from_qis_llvm_ir_pliron(BAD) else { - panic!("conditional branch without mid-measurement must error, but adapter lowering succeeded"); + panic!( + "conditional branch without mid-measurement must error, but adapter lowering succeeded" + ); }; assert!( format!("{err}").contains("mid-measurement"), diff --git a/exp/guppy-zlup/src/compiler.rs b/exp/guppy-zlup/src/compiler.rs index 9b0d09945..3550b7472 100644 --- a/exp/guppy-zlup/src/compiler.rs +++ b/exp/guppy-zlup/src/compiler.rs @@ -5,5 +5,5 @@ pub mod parser; pub mod transform; -pub use parser::{parse_ir, ParseError}; -pub use transform::{transform, TransformError}; +pub use parser::{ParseError, parse_ir}; +pub use transform::{TransformError, transform}; diff --git a/exp/guppy-zlup/src/compiler/transform.rs b/exp/guppy-zlup/src/compiler/transform.rs index 808c6c0d2..9b4f53648 100644 --- a/exp/guppy-zlup/src/compiler/transform.rs +++ b/exp/guppy-zlup/src/compiler/transform.rs @@ -21,8 +21,11 @@ struct TransformContext { impl TransformContext { /// Register a new qalloc. fn register_qalloc(&mut self, name: &str, size: i128) { - debug_assert!(!self.qalloc_sizes.contains_key(name), - "Invariant violation: duplicate qalloc for '{}'", name); + debug_assert!( + !self.qalloc_sizes.contains_key(name), + "Invariant violation: duplicate qalloc for '{}'", + name + ); self.qalloc_sizes.insert(name.to_string(), size); self.declared_vars.insert(name.to_string()); } @@ -42,8 +45,11 @@ impl TransformContext { let size = self.qalloc_sizes.get(name).copied(); #[cfg(debug_assertions)] if size.is_some() { - debug_assert!(self.declared_vars.contains(name), - "Invariant violation: qalloc '{}' in qalloc_sizes but not in declared_vars", name); + debug_assert!( + self.declared_vars.contains(name), + "Invariant violation: qalloc '{}' in qalloc_sizes but not in declared_vars", + name + ); } size } @@ -51,8 +57,11 @@ impl TransformContext { /// Mark an allocator as used (for gates). #[cfg(debug_assertions)] fn mark_allocator_used(&mut self, name: &str) { - debug_assert!(self.qalloc_sizes.contains_key(name), - "Invariant violation: gate uses allocator '{}' before it was allocated", name); + debug_assert!( + self.qalloc_sizes.contains_key(name), + "Invariant violation: gate uses allocator '{}' before it was allocated", + name + ); self.used_allocators.insert(name.to_string()); } @@ -104,16 +113,18 @@ fn transform_function(func: &Function) -> Result zlup_ast::TypeExpr { }), } } - "qalloc" => zlup_ast::TypeExpr::QAlloc(ty.size.as_ref().map(|s| Box::new(transform_expr(s)))), + "qalloc" => { + zlup_ast::TypeExpr::QAlloc(ty.size.as_ref().map(|s| Box::new(transform_expr(s)))) + } "array" => { let element = ty .element @@ -178,13 +191,17 @@ fn transform_type(ty: &TypeExpr) -> zlup_ast::TypeExpr { "tuple" => { // In quantum code, tuple[bool, ...] typically contains measurement results // which are u1 in Zlup, so map bool->u1 within tuples - let elements: Vec = ty.elements.iter().map(|elem| { - if elem.kind == "primitive" && elem.name.as_deref() == Some("bool") { - zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }) - } else { - transform_type(elem) - } - }).collect(); + let elements: Vec = ty + .elements + .iter() + .map(|elem| { + if elem.kind == "primitive" && elem.name.as_deref() == Some("bool") { + zlup_ast::TypeExpr::Primitive(zlup_ast::PrimitiveType::UInt { bits: 1 }) + } else { + transform_type(elem) + } + }) + .collect(); zlup_ast::TypeExpr::Tuple(elements) } "named" => { @@ -203,7 +220,10 @@ fn transform_block(stmts: &[Stmt]) -> Result { transform_block_with_ctx(stmts, &mut ctx) } -fn transform_block_with_ctx(stmts: &[Stmt], ctx: &mut TransformContext) -> Result { +fn transform_block_with_ctx( + stmts: &[Stmt], + ctx: &mut TransformContext, +) -> Result { let mut statements = Vec::new(); for stmt in stmts { @@ -225,10 +245,16 @@ fn transform_stmt(stmt: &Stmt) -> Result, TransformError> { transform_stmt_with_ctx(stmt, &mut ctx) } -fn transform_stmt_with_ctx(stmt: &Stmt, ctx: &mut TransformContext) -> Result, TransformError> { +fn transform_stmt_with_ctx( + stmt: &Stmt, + ctx: &mut TransformContext, +) -> Result, TransformError> { match stmt.kind { StmtKind::Qalloc => { - let name = stmt.name.as_ref().ok_or(TransformError::MissingField("name"))?; + let name = stmt + .name + .as_ref() + .ok_or(TransformError::MissingField("name"))?; // Extract the size value for tracking let size_value = stmt.size.as_ref().and_then(|s| { @@ -242,15 +268,13 @@ fn transform_stmt_with_ctx(stmt: &Stmt, ctx: &mut TransformContext) -> Result Result Result Result { - let var = stmt.var.as_ref().ok_or(TransformError::MissingField("var"))?; + let var = stmt + .var + .as_ref() + .ok_or(TransformError::MissingField("var"))?; let range = stmt .range .as_ref() @@ -528,7 +561,10 @@ fn transform_stmt_with_ctx(stmt: &Stmt, ctx: &mut TransformContext) -> Result Result Result { - let name = stmt.name.as_ref().ok_or(TransformError::MissingField("name"))?; + let name = stmt + .name + .as_ref() + .ok_or(TransformError::MissingField("name"))?; let ty = stmt.ty.as_ref().map(transform_type); let value = stmt.value.as_ref().map(transform_expr); @@ -654,7 +696,10 @@ fn transform_stmt_with_ctx(stmt: &Stmt, ctx: &mut TransformContext) -> Result { - let tag = stmt.tag.as_ref().ok_or(TransformError::MissingField("tag"))?; + let tag = stmt + .tag + .as_ref() + .ok_or(TransformError::MissingField("tag"))?; let value = stmt .value .as_ref() @@ -704,11 +749,15 @@ fn transform_expr(expr: &Expr) -> zlup_ast::Expr { value: *b, location: None, }), - serde_json::Value::String(s) => zlup_ast::Expr::StringLit(zlup_ast::StringLit { - value: s.clone(), - location: None, - }), - serde_json::Value::Null => zlup_ast::Expr::Null(zlup_ast::NullLit { location: None }), + serde_json::Value::String(s) => { + zlup_ast::Expr::StringLit(zlup_ast::StringLit { + value: s.clone(), + location: None, + }) + } + serde_json::Value::Null => { + zlup_ast::Expr::Null(zlup_ast::NullLit { location: None }) + } _ => zlup_ast::Expr::Unit(zlup_ast::UnitLit { location: None }), } } else { @@ -957,7 +1006,10 @@ fn transform_binary_op(op: &str) -> Result { "bitxor" => Ok(zlup_ast::BinaryOp::BitXor), "shl" => Ok(zlup_ast::BinaryOp::Shl), "shr" => Ok(zlup_ast::BinaryOp::Shr), - _ => Err(TransformError::UnsupportedOp(format!("unknown binary operator: {}", op))), + _ => Err(TransformError::UnsupportedOp(format!( + "unknown binary operator: {}", + op + ))), } } @@ -966,7 +1018,10 @@ fn transform_unary_op(op: &str) -> Result { "neg" => Ok(zlup_ast::UnaryOp::Neg), "not" => Ok(zlup_ast::UnaryOp::Not), "bitnot" => Ok(zlup_ast::UnaryOp::BitNot), - _ => Err(TransformError::UnsupportedOp(format!("unknown unary operator: {}", op))), + _ => Err(TransformError::UnsupportedOp(format!( + "unknown unary operator: {}", + op + ))), } } diff --git a/exp/guppy-zlup/src/ir.rs b/exp/guppy-zlup/src/ir.rs index 0eabbc1de..508cb2693 100644 --- a/exp/guppy-zlup/src/ir.rs +++ b/exp/guppy-zlup/src/ir.rs @@ -308,38 +308,77 @@ impl Default for GuppyIR { #[derive(Debug, Clone)] pub enum ValidationError { /// Missing required field for statement kind. - MissingField { kind: StmtKind, field: &'static str, location: Option }, + MissingField { + kind: StmtKind, + field: &'static str, + location: Option, + }, /// Missing required field for expression kind. MissingExprField { kind: ExprKind, field: &'static str }, /// Undefined variable reference. - UndefinedVariable { name: String, location: Option }, + UndefinedVariable { + name: String, + location: Option, + }, /// Undefined allocator (qubit register) reference. - UndefinedAllocator { name: String, location: Option }, + UndefinedAllocator { + name: String, + location: Option, + }, /// Gate used before qalloc. - GateBeforeAlloc { allocator: String, location: Option }, + GateBeforeAlloc { + allocator: String, + location: Option, + }, /// Invalid gate arity. - InvalidGateArity { gate: GateKind, expected: usize, actual: usize, location: Option }, + InvalidGateArity { + gate: GateKind, + expected: usize, + actual: usize, + location: Option, + }, /// Unknown operator. - UnknownOperator { op: String, location: Option }, + UnknownOperator { + op: String, + location: Option, + }, } impl std::fmt::Display for ValidationError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - ValidationError::MissingField { kind, field, .. } => - write!(f, "missing required field '{}' for {:?} statement", field, kind), - ValidationError::MissingExprField { kind, field } => - write!(f, "missing required field '{}' for {:?} expression", field, kind), - ValidationError::UndefinedVariable { name, .. } => - write!(f, "undefined variable: {}", name), - ValidationError::UndefinedAllocator { name, .. } => - write!(f, "undefined qubit allocator: {}", name), - ValidationError::GateBeforeAlloc { allocator, .. } => - write!(f, "gate uses allocator '{}' before it was allocated", allocator), - ValidationError::InvalidGateArity { gate, expected, actual, .. } => - write!(f, "gate {:?} expects {} targets, got {}", gate, expected, actual), - ValidationError::UnknownOperator { op, .. } => - write!(f, "unknown operator: {}", op), + ValidationError::MissingField { kind, field, .. } => write!( + f, + "missing required field '{}' for {:?} statement", + field, kind + ), + ValidationError::MissingExprField { kind, field } => write!( + f, + "missing required field '{}' for {:?} expression", + field, kind + ), + ValidationError::UndefinedVariable { name, .. } => { + write!(f, "undefined variable: {}", name) + } + ValidationError::UndefinedAllocator { name, .. } => { + write!(f, "undefined qubit allocator: {}", name) + } + ValidationError::GateBeforeAlloc { allocator, .. } => write!( + f, + "gate uses allocator '{}' before it was allocated", + allocator + ), + ValidationError::InvalidGateArity { + gate, + expected, + actual, + .. + } => write!( + f, + "gate {:?} expects {} targets, got {}", + gate, expected, actual + ), + ValidationError::UnknownOperator { op, .. } => write!(f, "unknown operator: {}", op), } } } @@ -628,7 +667,8 @@ impl IrValidator { } ExprKind::Index => { if let Some(array) = &expr.array - && !self.variables.contains(array) && !self.allocators.contains(array) + && !self.variables.contains(array) + && !self.allocators.contains(array) { self.result.add_error(ValidationError::UndefinedVariable { name: array.clone(), @@ -643,9 +683,8 @@ impl IrValidator { // Validate operator if let Some(op) = &expr.op { let valid_ops = [ - "add", "sub", "mul", "div", "floordiv", "mod", - "eq", "ne", "lt", "le", "gt", "ge", - "and", "or", "bitand", "bitor", "bitxor", "shl", "shr", + "add", "sub", "mul", "div", "floordiv", "mod", "eq", "ne", "lt", "le", + "gt", "ge", "and", "or", "bitand", "bitor", "bitxor", "shl", "shr", ]; if !valid_ops.contains(&op.as_str()) { self.result.add_error(ValidationError::UnknownOperator { @@ -914,7 +953,7 @@ fn convert_type_annotation(ann: &PyExpr) -> TypeExpr { size: None, elements, } - }, + } _ => TypeExpr { kind: "named".to_string(), name: Some(name_str.to_string()), @@ -954,9 +993,7 @@ fn convert_stmt(stmt: &PyStmt) -> Result { match stmt { PyStmt::Assign(assign) => { if assign.targets.len() != 1 { - return Err(EmitError::Unsupported( - "multiple assignment targets".into(), - )); + return Err(EmitError::Unsupported("multiple assignment targets".into())); } // Check for qalloc: q = qubit[4] @@ -1221,9 +1258,10 @@ fn convert_stmt(stmt: &PyStmt) -> Result { // Skip docstrings (string constant expressions) if let PyExpr::Constant(c) = expr_stmt.value.as_ref() - && matches!(c.value, Constant::Str(_)) { - return Err(EmitError::Skip); - } + && matches!(c.value, Constant::Str(_)) + { + return Err(EmitError::Skip); + } let expr = convert_expr(&expr_stmt.value)?; Ok(Stmt { @@ -1497,31 +1535,32 @@ fn convert_stmt(stmt: &PyStmt) -> Result { // Check for qalloc: q: qubit[4] or q: qubit[4] = ... if let PyExpr::Subscript(sub) = ann.annotation.as_ref() && let PyExpr::Name(type_name) = sub.value.as_ref() - && (type_name.id.as_str() == "qubit" || type_name.id.as_str() == "qalloc") { - return Ok(Stmt { - kind: StmtKind::Qalloc, - name: Some(name), - size: convert_expr(&sub.slice).ok(), - gate: None, - targets: Vec::new(), - params: Vec::new(), - results: Vec::new(), - var: None, - range: None, - condition: None, - then_body: Vec::new(), - else_body: Vec::new(), - target: None, - value: None, - return_value: None, - expr: None, - ty: None, - is_mutable: None, - body: Vec::new(), - tag: None, - location: None, - }); - } + && (type_name.id.as_str() == "qubit" || type_name.id.as_str() == "qalloc") + { + return Ok(Stmt { + kind: StmtKind::Qalloc, + name: Some(name), + size: convert_expr(&sub.slice).ok(), + gate: None, + targets: Vec::new(), + params: Vec::new(), + results: Vec::new(), + var: None, + range: None, + condition: None, + then_body: Vec::new(), + else_body: Vec::new(), + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: Vec::new(), + tag: None, + location: None, + }); + } // Regular annotated assignment -> Binding let ty = convert_type_annotation(&ann.annotation); @@ -1570,7 +1609,12 @@ fn convert_stmt(stmt: &PyStmt) -> Result { ast::Operator::BitXor => "bitxor", ast::Operator::LShift => "shl", ast::Operator::RShift => "shr", - _ => return Err(EmitError::Unsupported(format!("augmented operator: {:?}", aug.op))), + _ => { + return Err(EmitError::Unsupported(format!( + "augmented operator: {:?}", + aug.op + ))); + } }; Ok(Stmt { @@ -1705,7 +1749,12 @@ fn convert_expr(expr: &PyExpr) -> Result { ast::Operator::BitXor => "bitxor", ast::Operator::LShift => "shl", ast::Operator::RShift => "shr", - other => return Err(EmitError::Unsupported(format!("binary operator: {:?}", other))), + other => { + return Err(EmitError::Unsupported(format!( + "binary operator: {:?}", + other + ))); + } }; Ok(Expr { @@ -1764,7 +1813,12 @@ fn convert_expr(expr: &PyExpr) -> Result { ast::CmpOp::LtE => "le", ast::CmpOp::Gt => "gt", ast::CmpOp::GtE => "ge", - other => return Err(EmitError::Unsupported(format!("comparison operator: {:?}", other))), + other => { + return Err(EmitError::Unsupported(format!( + "comparison operator: {:?}", + other + ))); + } }; Ok(Expr { @@ -1960,14 +2014,37 @@ mod validation_tests { size: Some(Expr { kind: ExprKind::Literal, value: Some(serde_json::json!(4)), - name: None, array: None, index: None, op: None, - left: None, right: None, operand: None, - callee: None, args: vec![], object: None, field: None, location: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, }), - gate: None, targets: vec![], params: vec![], results: vec![], - var: None, range: None, condition: None, then_body: vec![], - else_body: vec![], target: None, value: None, return_value: None, - expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, }, Stmt { kind: StmtKind::Gate, @@ -1982,22 +2059,54 @@ mod validation_tests { index: Some(Box::new(Expr { kind: ExprKind::Literal, value: Some(serde_json::json!(0)), - name: None, array: None, index: None, op: None, - left: None, right: None, operand: None, - callee: None, args: vec![], object: None, field: None, location: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, })), - op: None, left: None, right: None, operand: None, - callee: None, args: vec![], object: None, field: None, location: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, }], - params: vec![], results: vec![], var: None, range: None, condition: None, - then_body: vec![], else_body: vec![], target: None, value: None, - return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], - tag: None, location: None, + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, }, ]); let result = validate_ir(&ir); - assert!(result.is_valid(), "Expected valid IR, got errors: {:?}", result.errors); + assert!( + result.is_valid(), + "Expected valid IR, got errors: {:?}", + result.errors + ); } #[test] @@ -2017,43 +2126,98 @@ mod validation_tests { index: Some(Box::new(Expr { kind: ExprKind::Literal, value: Some(serde_json::json!(0)), - name: None, array: None, index: None, op: None, - left: None, right: None, operand: None, - callee: None, args: vec![], object: None, field: None, location: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, })), - op: None, left: None, right: None, operand: None, - callee: None, args: vec![], object: None, field: None, location: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, }], - params: vec![], results: vec![], var: None, range: None, condition: None, - then_body: vec![], else_body: vec![], target: None, value: None, - return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], - tag: None, location: None, + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, }, ]); let result = validate_ir(&ir); - assert!(!result.is_valid(), "Expected validation error for undefined allocator"); - assert!(result.errors.iter().any(|e| matches!(e, ValidationError::UndefinedAllocator { .. }))); + assert!( + !result.is_valid(), + "Expected validation error for undefined allocator" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::UndefinedAllocator { .. })) + ); } #[test] fn test_missing_gate_field() { - let ir = make_ir(vec![ - Stmt { - kind: StmtKind::Gate, - name: None, - size: None, - gate: None, // Missing required gate field - targets: vec![], params: vec![], results: vec![], var: None, range: None, - condition: None, then_body: vec![], else_body: vec![], target: None, value: None, - return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], - tag: None, location: None, - }, - ]); + let ir = make_ir(vec![Stmt { + kind: StmtKind::Gate, + name: None, + size: None, + gate: None, // Missing required gate field + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }]); let result = validate_ir(&ir); - assert!(!result.is_valid(), "Expected validation error for missing gate field"); - assert!(result.errors.iter().any(|e| matches!(e, ValidationError::MissingField { field: "gate", .. }))); + assert!( + !result.is_valid(), + "Expected validation error for missing gate field" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::MissingField { field: "gate", .. })) + ); } #[test] @@ -2065,14 +2229,37 @@ mod validation_tests { size: Some(Expr { kind: ExprKind::Literal, value: Some(serde_json::json!(2)), - name: None, array: None, index: None, op: None, - left: None, right: None, operand: None, - callee: None, args: vec![], object: None, field: None, location: None, + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, }), - gate: None, targets: vec![], params: vec![], results: vec![], - var: None, range: None, condition: None, then_body: vec![], - else_body: vec![], target: None, value: None, return_value: None, - expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, }, Stmt { kind: StmtKind::Gate, @@ -2081,89 +2268,237 @@ mod validation_tests { gate: Some(GateKind::H), // H is 1-qubit gate targets: vec![ // But we provide 2 targets - Expr { kind: ExprKind::Index, value: None, name: None, array: Some("q".to_string()), - index: Some(Box::new(Expr { kind: ExprKind::Literal, value: Some(serde_json::json!(0)), - name: None, array: None, index: None, op: None, left: None, right: None, - operand: None, callee: None, args: vec![], object: None, field: None, location: None })), - op: None, left: None, right: None, operand: None, callee: None, args: vec![], - object: None, field: None, location: None }, - Expr { kind: ExprKind::Index, value: None, name: None, array: Some("q".to_string()), - index: Some(Box::new(Expr { kind: ExprKind::Literal, value: Some(serde_json::json!(1)), - name: None, array: None, index: None, op: None, left: None, right: None, - operand: None, callee: None, args: vec![], object: None, field: None, location: None })), - op: None, left: None, right: None, operand: None, callee: None, args: vec![], - object: None, field: None, location: None }, + Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some("q".to_string()), + index: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(0)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }, + Expr { + kind: ExprKind::Index, + value: None, + name: None, + array: Some("q".to_string()), + index: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(1)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }, ], - params: vec![], results: vec![], var: None, range: None, condition: None, - then_body: vec![], else_body: vec![], target: None, value: None, - return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], - tag: None, location: None, + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, }, ]); let result = validate_ir(&ir); - assert!(!result.is_valid(), "Expected validation error for invalid gate arity"); - assert!(result.errors.iter().any(|e| matches!(e, ValidationError::InvalidGateArity { .. }))); + assert!( + !result.is_valid(), + "Expected validation error for invalid gate arity" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::InvalidGateArity { .. })) + ); } #[test] fn test_unknown_operator() { - let ir = make_ir(vec![ - Stmt { - kind: StmtKind::Return, - name: None, size: None, gate: None, targets: vec![], params: vec![], - results: vec![], var: None, range: None, condition: None, then_body: vec![], - else_body: vec![], target: None, value: None, - return_value: Some(Expr { - kind: ExprKind::Binary, - value: None, + let ir = make_ir(vec![Stmt { + kind: StmtKind::Return, + name: None, + size: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: Some(Expr { + kind: ExprKind::Binary, + value: None, + name: None, + array: None, + index: None, + op: Some("invalid_op".to_string()), // Unknown operator + left: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(1)), name: None, array: None, index: None, - op: Some("invalid_op".to_string()), // Unknown operator - left: Some(Box::new(Expr { - kind: ExprKind::Literal, value: Some(serde_json::json!(1)), - name: None, array: None, index: None, op: None, left: None, right: None, - operand: None, callee: None, args: vec![], object: None, field: None, location: None, - })), - right: Some(Box::new(Expr { - kind: ExprKind::Literal, value: Some(serde_json::json!(2)), - name: None, array: None, index: None, op: None, left: None, right: None, - operand: None, callee: None, args: vec![], object: None, field: None, location: None, - })), - operand: None, callee: None, args: vec![], object: None, field: None, location: None, - }), - expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, - }, - ]); + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + right: Some(Box::new(Expr { + kind: ExprKind::Literal, + value: Some(serde_json::json!(2)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + })), + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }), + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }]); let result = validate_ir(&ir); - assert!(!result.is_valid(), "Expected validation error for unknown operator"); - assert!(result.errors.iter().any(|e| matches!(e, ValidationError::UnknownOperator { .. }))); + assert!( + !result.is_valid(), + "Expected validation error for unknown operator" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::UnknownOperator { .. })) + ); } #[test] fn test_undefined_variable() { - let ir = make_ir(vec![ - Stmt { - kind: StmtKind::Return, - name: None, size: None, gate: None, targets: vec![], params: vec![], - results: vec![], var: None, range: None, condition: None, then_body: vec![], - else_body: vec![], target: None, value: None, - return_value: Some(Expr { - kind: ExprKind::Ident, - value: None, - name: Some("undefined_var".to_string()), - array: None, index: None, op: None, left: None, right: None, - operand: None, callee: None, args: vec![], object: None, field: None, location: None, - }), - expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, - }, - ]); + let ir = make_ir(vec![Stmt { + kind: StmtKind::Return, + name: None, + size: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, + return_value: Some(Expr { + kind: ExprKind::Ident, + value: None, + name: Some("undefined_var".to_string()), + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, + }), + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, + }]); let result = validate_ir(&ir); - assert!(!result.is_valid(), "Expected validation error for undefined variable"); - assert!(result.errors.iter().any(|e| matches!(e, ValidationError::UndefinedVariable { .. }))); + assert!( + !result.is_valid(), + "Expected validation error for undefined variable" + ); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::UndefinedVariable { .. })) + ); } #[test] @@ -2172,34 +2507,86 @@ mod validation_tests { Stmt { kind: StmtKind::Binding, name: Some("x".to_string()), - size: None, gate: None, targets: vec![], params: vec![], results: vec![], - var: None, range: None, condition: None, then_body: vec![], else_body: vec![], + size: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], target: None, value: Some(Expr { - kind: ExprKind::Literal, value: Some(serde_json::json!(42)), - name: None, array: None, index: None, op: None, left: None, right: None, - operand: None, callee: None, args: vec![], object: None, field: None, location: None, + kind: ExprKind::Literal, + value: Some(serde_json::json!(42)), + name: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, }), - return_value: None, expr: None, ty: None, is_mutable: None, body: vec![], - tag: None, location: None, + return_value: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, }, Stmt { kind: StmtKind::Return, - name: None, size: None, gate: None, targets: vec![], params: vec![], - results: vec![], var: None, range: None, condition: None, then_body: vec![], - else_body: vec![], target: None, value: None, + name: None, + size: None, + gate: None, + targets: vec![], + params: vec![], + results: vec![], + var: None, + range: None, + condition: None, + then_body: vec![], + else_body: vec![], + target: None, + value: None, return_value: Some(Expr { kind: ExprKind::Ident, value: None, name: Some("x".to_string()), - array: None, index: None, op: None, left: None, right: None, - operand: None, callee: None, args: vec![], object: None, field: None, location: None, + array: None, + index: None, + op: None, + left: None, + right: None, + operand: None, + callee: None, + args: vec![], + object: None, + field: None, + location: None, }), - expr: None, ty: None, is_mutable: None, body: vec![], tag: None, location: None, + expr: None, + ty: None, + is_mutable: None, + body: vec![], + tag: None, + location: None, }, ]); let result = validate_ir(&ir); - assert!(result.is_valid(), "Expected valid IR with defined variable, got errors: {:?}", result.errors); + assert!( + result.is_valid(), + "Expected valid IR with defined variable, got errors: {:?}", + result.errors + ); } } diff --git a/exp/guppy-zlup/src/lib.rs b/exp/guppy-zlup/src/lib.rs index eb1eddbe8..c8fdc271a 100644 --- a/exp/guppy-zlup/src/lib.rs +++ b/exp/guppy-zlup/src/lib.rs @@ -76,7 +76,7 @@ pub use linter::{Config, Diagnostic, LintResult, Linter, LowerError, OutputForma pub use ir::{GuppyIR, IrValidator, ValidationError, ValidationResult, validate_ir}; // Re-export compiler types -pub use compiler::{parse_ir, ParseError, TransformError}; +pub use compiler::{ParseError, TransformError, parse_ir}; /// Crate version pub const VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -154,7 +154,10 @@ pub fn validate_zlup(source: &str) -> Result<(), CompileError> { /// Validate with round-trip: generated AST → source → parsed AST comparison. /// /// This ensures the pretty printer produces code that parses back to an equivalent AST. -pub fn validate_zlup_roundtrip(original_ast: &zlup::ast::Program, source: &str) -> Result<(), CompileError> { +pub fn validate_zlup_roundtrip( + original_ast: &zlup::ast::Program, + source: &str, +) -> Result<(), CompileError> { // First do basic validation validate_zlup(source)?; @@ -167,14 +170,18 @@ pub fn validate_zlup_roundtrip(original_ast: &zlup::ast::Program, source: &str) })?; // Compare key structural properties - let original_fns: Vec<_> = original_ast.declarations.iter() + let original_fns: Vec<_> = original_ast + .declarations + .iter() .filter_map(|d| match d { zlup_ast::TopLevelDecl::Fn(f) => Some(f), _ => None, }) .collect(); - let reparsed_fns: Vec<_> = reparsed.declarations.iter() + let reparsed_fns: Vec<_> = reparsed + .declarations + .iter() .filter_map(|d| match d { zlup_ast::TopLevelDecl::Fn(f) => Some(f), _ => None, @@ -182,25 +189,33 @@ pub fn validate_zlup_roundtrip(original_ast: &zlup::ast::Program, source: &str) .collect(); if original_fns.len() != reparsed_fns.len() { - return Err(CompileError::Transform(compiler::TransformError::ValidationFailed(format!( - "Round-trip: function count mismatch (original: {}, reparsed: {})", - original_fns.len(), - reparsed_fns.len() - )))); + return Err(CompileError::Transform( + compiler::TransformError::ValidationFailed(format!( + "Round-trip: function count mismatch (original: {}, reparsed: {})", + original_fns.len(), + reparsed_fns.len() + )), + )); } for (orig, repr) in original_fns.iter().zip(reparsed_fns.iter()) { if orig.name != repr.name { - return Err(CompileError::Transform(compiler::TransformError::ValidationFailed(format!( - "Round-trip: function name mismatch (original: {}, reparsed: {})", - orig.name, repr.name - )))); + return Err(CompileError::Transform( + compiler::TransformError::ValidationFailed(format!( + "Round-trip: function name mismatch (original: {}, reparsed: {})", + orig.name, repr.name + )), + )); } if orig.params.len() != repr.params.len() { - return Err(CompileError::Transform(compiler::TransformError::ValidationFailed(format!( - "Round-trip: parameter count mismatch for '{}' (original: {}, reparsed: {})", - orig.name, orig.params.len(), repr.params.len() - )))); + return Err(CompileError::Transform( + compiler::TransformError::ValidationFailed(format!( + "Round-trip: parameter count mismatch for '{}' (original: {}, reparsed: {})", + orig.name, + orig.params.len(), + repr.params.len() + )), + )); } } @@ -281,7 +296,8 @@ pub fn lint_and_compile(source: &str, filename: Option<&str>) -> Result, - span: Span, - }, + Return { value: Option, span: Span }, /// Expression statement - Expr { - value: Expr, - span: Span, - }, + Expr { value: Expr, span: Span }, /// Pass statement Pass { span: Span }, @@ -168,10 +162,7 @@ pub enum Stmt { Continue { span: Span }, /// Barrier (quantum synchronization) - Barrier { - qubits: Vec, - span: Span, - }, + Barrier { qubits: Vec, span: Span }, /// Try/except block Try { @@ -220,9 +211,17 @@ pub enum AssignTarget { /// Simple name: x Name { name: String, span: Span }, /// Subscript: x[i] - Subscript { value: Box, slice: Box, span: Span }, + Subscript { + value: Box, + slice: Box, + span: Span, + }, /// Attribute: x.attr - Attribute { value: Box, attr: String, span: Span }, + Attribute { + value: Box, + attr: String, + span: Span, + }, /// Tuple unpacking: (a, b) Tuple { elts: Vec, span: Span }, } @@ -555,11 +554,7 @@ impl GateKind { | GateKind::Ry | GateKind::Rz | GateKind::Pz => 1, - GateKind::Cx - | GateKind::Cy - | GateKind::Cz - | GateKind::Swap - | GateKind::Iswap => 2, + GateKind::Cx | GateKind::Cy | GateKind::Cz | GateKind::Swap | GateKind::Iswap => 2, GateKind::Ccx => 3, GateKind::Custom(_) => 0, // Unknown } diff --git a/exp/guppy-zlup/src/linter/diagnostic.rs b/exp/guppy-zlup/src/linter/diagnostic.rs index 8da7e449a..574484e49 100644 --- a/exp/guppy-zlup/src/linter/diagnostic.rs +++ b/exp/guppy-zlup/src/linter/diagnostic.rs @@ -108,28 +108,33 @@ impl Diagnostic { pub fn with_source_context(mut self, source: &str) -> Self { let line_num = self.location.line as usize; if line_num > 0 - && let Some(line) = source.lines().nth(line_num - 1) { - self.source_context = Some(line.to_string()); - } + && let Some(line) = source.lines().nth(line_num - 1) + { + self.source_context = Some(line.to_string()); + } self } - pub fn error(rule_id: impl Into, message: impl Into, location: SourceLocation) -> Self { + pub fn error( + rule_id: impl Into, + message: impl Into, + location: SourceLocation, + ) -> Self { Self::new(rule_id, message, Severity::Error, location) } - pub fn warning(rule_id: impl Into, message: impl Into, location: SourceLocation) -> Self { + pub fn warning( + rule_id: impl Into, + message: impl Into, + location: SourceLocation, + ) -> Self { Self::new(rule_id, message, Severity::Warning, location) } } impl fmt::Display for Diagnostic { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - writeln!( - f, - "{}: [{}] {}", - self.severity, self.rule_id, self.message - )?; + writeln!(f, "{}: [{}] {}", self.severity, self.rule_id, self.message)?; writeln!(f, " --> {}", self.location)?; // Show source context if available diff --git a/exp/guppy-zlup/src/linter/engine.rs b/exp/guppy-zlup/src/linter/engine.rs index 91a207697..59b8795a4 100644 --- a/exp/guppy-zlup/src/linter/engine.rs +++ b/exp/guppy-zlup/src/linter/engine.rs @@ -2,7 +2,7 @@ use std::fmt; -use rustpython_parser::{parse, Mode}; +use rustpython_parser::{Mode, parse}; use super::config::Config; use super::diagnostic::{Diagnostic, Severity, SourceLocation}; @@ -97,7 +97,9 @@ impl Linter { rules.push(Box::new(rules::ZLUP006MissingTypes)); } if config.is_rule_enabled("ZLUP007") { - rules.push(Box::new(rules::ZLUP007ComplexControlFlow::new(config.max_complexity))); + rules.push(Box::new(rules::ZLUP007ComplexControlFlow::new( + config.max_complexity, + ))); } if config.is_rule_enabled("ZLUP008") { rules.push(Box::new(rules::ZLUP008CallDepth::default())); @@ -158,10 +160,18 @@ mod tests { #[test] fn test_lint_result_merge() { let mut r1 = LintResult::new(); - r1.add(Diagnostic::error("TEST", "error", SourceLocation::new(1, 0))); + r1.add(Diagnostic::error( + "TEST", + "error", + SourceLocation::new(1, 0), + )); let mut r2 = LintResult::new(); - r2.add(Diagnostic::warning("TEST", "warning", SourceLocation::new(2, 0))); + r2.add(Diagnostic::warning( + "TEST", + "warning", + SourceLocation::new(2, 0), + )); r1.merge(r2); assert!(r1.has_errors); diff --git a/exp/guppy-zlup/src/linter/lower.rs b/exp/guppy-zlup/src/linter/lower.rs index 05e2079dd..0da92cfd7 100644 --- a/exp/guppy-zlup/src/linter/lower.rs +++ b/exp/guppy-zlup/src/linter/lower.rs @@ -7,9 +7,8 @@ use rustpython_parser::ast::{self, Constant, Expr as PyExpr, Mod, Ranged, Stmt as PyStmt}; use super::ast::{ - AssignTarget, BinOp, BoolOpKind, CmpOp, Comprehension, ExceptHandler, Expr, ForIter, - Function, GateKind, Keyword, Module, Param, PrimitiveType, Span, Stmt, Type, UnaryOp, - WithItem, + AssignTarget, BinOp, BoolOpKind, CmpOp, Comprehension, ExceptHandler, Expr, ForIter, Function, + GateKind, Keyword, Module, Param, PrimitiveType, Span, Stmt, Type, UnaryOp, WithItem, }; /// Errors that can occur during lowering. @@ -211,9 +210,10 @@ fn lower_stmt(stmt: &PyStmt) -> Result { // Check if this is a gate call if let PyExpr::Call(call) = expr.as_ref() - && let Some(stmt) = try_lower_gate_call(call, span)? { - return Ok(stmt); - } + && let Some(stmt) = try_lower_gate_call(call, span)? + { + return Ok(stmt); + } Ok(Stmt::Expr { value: lower_expr(expr)?, @@ -455,7 +455,10 @@ fn lower_assign(assign: &ast::StmtAssign) -> Result { let span = make_span(assign.range); if assign.targets.len() != 1 { - return Err(LowerError::Unsupported("multiple assignment targets".into(), span)); + return Err(LowerError::Unsupported( + "multiple assignment targets".into(), + span, + )); } let target_expr = &assign.targets[0]; @@ -463,45 +466,53 @@ fn lower_assign(assign: &ast::StmtAssign) -> Result { // Check for qubit allocation: q = qubit[n] if let PyExpr::Subscript(sub) = assign.value.as_ref() && let PyExpr::Name(name) = sub.value.as_ref() - && name.id.as_str() == "qubit" { - let var_name = if let PyExpr::Name(n) = target_expr { - n.id.to_string() - } else { - return Err(LowerError::Unsupported("complex qalloc target".into(), span)); - }; - - let size = lower_expr(&sub.slice)?; - - return Ok(Stmt::Qalloc { - name: var_name, - size, - span, - }); - } + && name.id.as_str() == "qubit" + { + let var_name = if let PyExpr::Name(n) = target_expr { + n.id.to_string() + } else { + return Err(LowerError::Unsupported( + "complex qalloc target".into(), + span, + )); + }; + + let size = lower_expr(&sub.slice)?; + + return Ok(Stmt::Qalloc { + name: var_name, + size, + span, + }); + } // Check for measurement: m = measure(q) if let PyExpr::Call(call) = assign.value.as_ref() && let PyExpr::Name(func_name) = call.func.as_ref() - && func_name.id.as_str() == "measure" { - let result_name = if let PyExpr::Name(n) = target_expr { - n.id.to_string() - } else { - return Err(LowerError::Unsupported("complex measure target".into(), span)); - }; - - let target = call - .args - .first() - .map(lower_expr) - .transpose()? - .ok_or_else(|| LowerError::Unsupported("measure without target".into(), span))?; - - return Ok(Stmt::Measure { - target, - result: Some(result_name), - span, - }); - } + && func_name.id.as_str() == "measure" + { + let result_name = if let PyExpr::Name(n) = target_expr { + n.id.to_string() + } else { + return Err(LowerError::Unsupported( + "complex measure target".into(), + span, + )); + }; + + let target = call + .args + .first() + .map(lower_expr) + .transpose()? + .ok_or_else(|| LowerError::Unsupported("measure without target".into(), span))?; + + return Ok(Stmt::Measure { + target, + result: Some(result_name), + span, + }); + } // Regular assignment let target = lower_assign_target(target_expr)?; @@ -553,32 +564,33 @@ fn lower_for_iter(expr: &PyExpr) -> Result { // Check for range(...) if let PyExpr::Call(call) = expr && let PyExpr::Name(name) = call.func.as_ref() - && name.id.as_str() == "range" { - match call.args.len() { - 1 => { - return Ok(ForIter::Range { - start: None, - end: Box::new(lower_expr(&call.args[0])?), - step: None, - }); - } - 2 => { - return Ok(ForIter::Range { - start: Some(Box::new(lower_expr(&call.args[0])?)), - end: Box::new(lower_expr(&call.args[1])?), - step: None, - }); - } - 3 => { - return Ok(ForIter::Range { - start: Some(Box::new(lower_expr(&call.args[0])?)), - end: Box::new(lower_expr(&call.args[1])?), - step: Some(Box::new(lower_expr(&call.args[2])?)), - }); - } - _ => {} - } + && name.id.as_str() == "range" + { + match call.args.len() { + 1 => { + return Ok(ForIter::Range { + start: None, + end: Box::new(lower_expr(&call.args[0])?), + step: None, + }); + } + 2 => { + return Ok(ForIter::Range { + start: Some(Box::new(lower_expr(&call.args[0])?)), + end: Box::new(lower_expr(&call.args[1])?), + step: None, + }); + } + 3 => { + return Ok(ForIter::Range { + start: Some(Box::new(lower_expr(&call.args[0])?)), + end: Box::new(lower_expr(&call.args[1])?), + step: Some(Box::new(lower_expr(&call.args[2])?)), + }); } + _ => {} + } + } Ok(ForIter::Iter(Box::new(lower_expr(expr)?))) } @@ -699,7 +711,11 @@ fn lower_expr(expr: &PyExpr) -> Result { } PyExpr::Tuple(tuple) => { - let elts = tuple.elts.iter().map(lower_expr).collect::>()?; + let elts = tuple + .elts + .iter() + .map(lower_expr) + .collect::>()?; Ok(Expr::Tuple { elts, span }) } @@ -709,7 +725,11 @@ fn lower_expr(expr: &PyExpr) -> Result { .iter() .map(|k| k.as_ref().and_then(|k| lower_expr(k).ok())) .collect(); - let values = dict.values.iter().map(lower_expr).collect::>()?; + let values = dict + .values + .iter() + .map(lower_expr) + .collect::>()?; Ok(Expr::Dict { keys, values, span }) } @@ -907,8 +927,20 @@ def bell(): let body = &module.functions[0].body; assert!(matches!(body[0], Stmt::Qalloc { .. })); - assert!(matches!(body[1], Stmt::Gate { gate: GateKind::H, .. })); - assert!(matches!(body[2], Stmt::Gate { gate: GateKind::Cx, .. })); + assert!(matches!( + body[1], + Stmt::Gate { + gate: GateKind::H, + .. + } + )); + assert!(matches!( + body[2], + Stmt::Gate { + gate: GateKind::Cx, + .. + } + )); } #[test] diff --git a/exp/guppy-zlup/src/linter/noqa.rs b/exp/guppy-zlup/src/linter/noqa.rs index a412e96e3..a26cced1b 100644 --- a/exp/guppy-zlup/src/linter/noqa.rs +++ b/exp/guppy-zlup/src/linter/noqa.rs @@ -40,9 +40,10 @@ impl NoqaDirectives { // Check line-level rule suppression if let Some(rules) = self.suppress_rules.get(&line) - && rules.contains(&rule_upper) { - return true; - } + && rules.contains(&rule_upper) + { + return true; + } false } diff --git a/exp/guppy-zlup/src/linter/output.rs b/exp/guppy-zlup/src/linter/output.rs index dfaca7030..8a755feaa 100644 --- a/exp/guppy-zlup/src/linter/output.rs +++ b/exp/guppy-zlup/src/linter/output.rs @@ -25,7 +25,10 @@ impl std::str::FromStr for OutputFormat { "text" => Ok(OutputFormat::Text), "json" => Ok(OutputFormat::Json), "sarif" => Ok(OutputFormat::Sarif), - _ => Err(format!("Unknown output format: '{}'. Valid options: text, json, sarif", s)), + _ => Err(format!( + "Unknown output format: '{}'. Valid options: text, json, sarif", + s + )), } } } @@ -37,8 +40,16 @@ impl LintResult { diagnostics: &self.diagnostics, summary: JsonSummary { total: self.diagnostics.len(), - errors: self.diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error)).count(), - warnings: self.diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(), + errors: self + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error)) + .count(), + warnings: self + .diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(), has_errors: self.has_errors, has_warnings: self.has_warnings, }, @@ -262,14 +273,17 @@ fn get_rule_description(rule_id: &str) -> String { #[cfg(test)] mod tests { - use super::*; use super::super::diagnostic::SourceLocation; + use super::*; #[test] fn test_output_format_parse() { assert_eq!("text".parse::().unwrap(), OutputFormat::Text); assert_eq!("json".parse::().unwrap(), OutputFormat::Json); - assert_eq!("sarif".parse::().unwrap(), OutputFormat::Sarif); + assert_eq!( + "sarif".parse::().unwrap(), + OutputFormat::Sarif + ); assert_eq!("JSON".parse::().unwrap(), OutputFormat::Json); assert!("invalid".parse::().is_err()); } diff --git a/exp/guppy-zlup/src/linter/rules/zlup001.rs b/exp/guppy-zlup/src/linter/rules/zlup001.rs index 07a44cd33..3a1c184ab 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup001.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup001.rs @@ -2,8 +2,8 @@ use rustpython_parser::ast::{self, Constant, Expr, Mod, Stmt}; -use super::{make_location, LintRule}; use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; /// Detects unbounded loops that violate NASA Power of 10 rules. pub struct ZLUP001UnboundedLoops; @@ -136,21 +136,22 @@ fn check_while_loop( // Check for `while 1:` if let Constant::Int(ref i) = c.value - && (i.to_u32_digits().1 == [1] || i.to_string() == "1") { - // Only report if there's no unconditional break - if !has_unconditional_break(&while_stmt.body) { - diagnostics.push( - Diagnostic::error( - "ZLUP001", - "'while 1' creates an unbounded loop", - make_location(while_stmt.range, filename, source), - ) - .with_suggestion("Use a for loop with a fixed upper bound instead") - .with_source_context(source), - ); - } - return; + && (i.to_u32_digits().1 == [1] || i.to_string() == "1") + { + // Only report if there's no unconditional break + if !has_unconditional_break(&while_stmt.body) { + diagnostics.push( + Diagnostic::error( + "ZLUP001", + "'while 1' creates an unbounded loop", + make_location(while_stmt.range, filename, source), + ) + .with_suggestion("Use a for loop with a fixed upper bound instead") + .with_source_context(source), + ); } + return; + } } // Check for while loops without a clear termination condition @@ -192,9 +193,11 @@ fn has_unconditional_break(body: &[Stmt]) -> bool { } // Check for guaranteed break in all branches of an if if let Stmt::If(if_stmt) = stmt - && has_unconditional_break(&if_stmt.body) && has_unconditional_break(&if_stmt.orelse) { - return true; - } + && has_unconditional_break(&if_stmt.body) + && has_unconditional_break(&if_stmt.orelse) + { + return true; + } } false } @@ -202,7 +205,7 @@ fn has_unconditional_break(body: &[Stmt]) -> bool { #[cfg(test)] mod tests { use super::*; - use rustpython_parser::{parse, Mode}; + use rustpython_parser::{Mode, parse}; fn check_source(source: &str) -> Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/linter/rules/zlup002.rs b/exp/guppy-zlup/src/linter/rules/zlup002.rs index b5fa7c5a6..268a79909 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup002.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup002.rs @@ -4,8 +4,8 @@ use std::collections::{HashMap, HashSet}; use rustpython_parser::ast::{self, Expr, Mod, Stmt}; -use super::{make_location, LintRule}; use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; /// Detects recursive function calls (call graph cycles). pub struct ZLUP002Recursion; @@ -293,7 +293,7 @@ fn find_cycle(start: &str, graph: &HashMap>) -> Option Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/linter/rules/zlup003.rs b/exp/guppy-zlup/src/linter/rules/zlup003.rs index 0f2071a07..1ac6d916d 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup003.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup003.rs @@ -2,12 +2,18 @@ use rustpython_parser::ast::{self, Expr, Mod, Stmt}; -use super::{make_location, LintRule}; use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; /// Functions/types that perform allocation. const ALLOCATION_FUNCTIONS: &[&str] = &[ - "qalloc", "qubit", "list", "dict", "set", "bytearray", "array", + "qalloc", + "qubit", + "list", + "dict", + "set", + "bytearray", + "array", ]; /// Methods that may cause allocation. @@ -135,9 +141,10 @@ fn check_stmt( } Stmt::AnnAssign(ann) => { if loop_depth > 0 - && let Some(value) = &ann.value { - check_expr(value, filename, source, diagnostics); - } + && let Some(value) = &ann.value + { + check_expr(value, filename, source, diagnostics); + } } _ => {} } @@ -191,17 +198,18 @@ fn check_expr(expr: &Expr, filename: &str, source: &str, diagnostics: &mut Vec { // Check for qubit[n] allocation if let Expr::Name(name) = sub.value.as_ref() - && name.id.as_str() == "qubit" { - diagnostics.push( - Diagnostic::error( - "ZLUP003", - "'qubit[...]' allocates qubits inside a loop", - make_location(sub.range, filename, source), - ) - .with_suggestion("Allocate qubits outside the loop") - .with_source_context(source), - ); - } + && name.id.as_str() == "qubit" + { + diagnostics.push( + Diagnostic::error( + "ZLUP003", + "'qubit[...]' allocates qubits inside a loop", + make_location(sub.range, filename, source), + ) + .with_suggestion("Allocate qubits outside the loop") + .with_source_context(source), + ); + } } Expr::ListComp(comp) => { @@ -260,7 +268,7 @@ fn check_expr(expr: &Expr, filename: &str, source: &str, diagnostics: &mut Vec Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/linter/rules/zlup004.rs b/exp/guppy-zlup/src/linter/rules/zlup004.rs index 82724fbfe..981d0f8db 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup004.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup004.rs @@ -2,8 +2,8 @@ use rustpython_parser::ast::{Expr, Mod, Stmt}; -use super::{make_location, LintRule}; use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; /// Functions that enable dynamic dispatch or code execution. const DYNAMIC_FUNCTIONS: &[(&str, &str)] = &[ @@ -244,7 +244,7 @@ fn get_suggestion(func_name: &str) -> &'static str { #[cfg(test)] mod tests { use super::*; - use rustpython_parser::{parse, Mode}; + use rustpython_parser::{Mode, parse}; fn check_source(source: &str) -> Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/linter/rules/zlup005.rs b/exp/guppy-zlup/src/linter/rules/zlup005.rs index 5999bfe45..548b76a06 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup005.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup005.rs @@ -2,8 +2,8 @@ use rustpython_parser::ast::{self, Constant, Expr, Mod, Stmt}; -use super::{make_location, LintRule}; use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; /// Functions that may raise exceptions and should be wrapped in try/except. const FUNCTIONS_THAT_MAY_RAISE: &[(&str, &str)] = &[ @@ -168,7 +168,8 @@ fn check_expr( match &c.value { Constant::Int(i) => { // Check if not zero - i.to_u32_digits().1.first() != Some(&0) || !i.to_u32_digits().1.is_empty() + i.to_u32_digits().1.first() != Some(&0) + || !i.to_u32_digits().1.is_empty() } Constant::Float(f) => *f != 0.0, _ => false, @@ -204,8 +205,9 @@ fn check_expr( // Check for calls to functions that may raise if let Expr::Name(name) = call.func.as_ref() { let func_name = name.id.as_str(); - if let Some((_, reason)) = - FUNCTIONS_THAT_MAY_RAISE.iter().find(|(n, _)| *n == func_name) + if let Some((_, reason)) = FUNCTIONS_THAT_MAY_RAISE + .iter() + .find(|(n, _)| *n == func_name) { diagnostics.push( Diagnostic::warning( @@ -252,7 +254,7 @@ fn check_expr( #[cfg(test)] mod tests { use super::*; - use rustpython_parser::{parse, Mode}; + use rustpython_parser::{Mode, parse}; fn check_source(source: &str) -> Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/linter/rules/zlup006.rs b/exp/guppy-zlup/src/linter/rules/zlup006.rs index 6c0324ebd..cf0de62e4 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup006.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup006.rs @@ -2,8 +2,8 @@ use rustpython_parser::ast::{Mod, Stmt}; -use super::{make_location, LintRule}; use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; /// Detects missing type annotations on function signatures. pub struct ZLUP006MissingTypes; @@ -136,7 +136,10 @@ fn check_function( format!("Function '{}' is missing return type annotation", func_name), make_location(func.range, filename, source), ) - .with_suggestion(format!("Add return type: def {}(...) -> :", func_name)) + .with_suggestion(format!( + "Add return type: def {}(...) -> :", + func_name + )) .with_source_context(source), ); } @@ -222,7 +225,7 @@ fn is_trivial_body(body: &[Stmt]) -> bool { #[cfg(test)] mod tests { use super::*; - use rustpython_parser::{parse, Mode}; + use rustpython_parser::{Mode, parse}; fn check_source(source: &str) -> Vec { let parsed = parse(source, Mode::Module, "").unwrap(); @@ -238,7 +241,11 @@ def foo(x): "#, ); assert!(!diagnostics.is_empty()); - assert!(diagnostics.iter().any(|d| d.message.contains("Parameter 'x'"))); + assert!( + diagnostics + .iter() + .any(|d| d.message.contains("Parameter 'x'")) + ); } #[test] @@ -250,7 +257,11 @@ def foo(x: int): "#, ); assert!(!diagnostics.is_empty()); - assert!(diagnostics.iter().any(|d| d.message.contains("return type"))); + assert!( + diagnostics + .iter() + .any(|d| d.message.contains("return type")) + ); } #[test] @@ -274,7 +285,11 @@ def foo(x): ); // Still warns about parameter but not return (trivial body) assert!(diagnostics.iter().any(|d| d.message.contains("Parameter"))); - assert!(!diagnostics.iter().any(|d| d.message.contains("return type"))); + assert!( + !diagnostics + .iter() + .any(|d| d.message.contains("return type")) + ); } #[test] diff --git a/exp/guppy-zlup/src/linter/rules/zlup007.rs b/exp/guppy-zlup/src/linter/rules/zlup007.rs index a7ce0c378..fba593a7e 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup007.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup007.rs @@ -2,8 +2,8 @@ use rustpython_parser::ast::{self, Expr, Mod, Stmt}; -use super::{make_location, LintRule}; use super::super::diagnostic::{Diagnostic, Severity}; +use super::{LintRule, make_location}; /// Detects functions with overly complex control flow. pub struct ZLUP007ComplexControlFlow { @@ -42,7 +42,13 @@ impl LintRule for ZLUP007ComplexControlFlow { }; for stmt in &module.body { - check_stmt(stmt, filename, source, self.max_complexity, &mut diagnostics); + check_stmt( + stmt, + filename, + source, + self.max_complexity, + &mut diagnostics, + ); } diagnostics @@ -287,7 +293,7 @@ fn count_decision_points_expr(expr: &Expr) -> u32 { #[cfg(test)] mod tests { use super::*; - use rustpython_parser::{parse, Mode}; + use rustpython_parser::{Mode, parse}; fn check_source_with_max(source: &str, max: u32) -> Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/linter/rules/zlup008.rs b/exp/guppy-zlup/src/linter/rules/zlup008.rs index dacb63f8c..adb98c64a 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup008.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup008.rs @@ -3,7 +3,7 @@ use rustpython_parser::ast::{Expr, Mod, Stmt}; use super::super::diagnostic::{Diagnostic, Severity}; -use super::{make_location, LintRule}; +use super::{LintRule, make_location}; /// Maximum allowed call depth (function calls nested within other calls). const DEFAULT_MAX_CALL_DEPTH: u32 = 4; @@ -91,7 +91,14 @@ fn check_stmt( } } Stmt::While(while_stmt) => { - check_expr(&while_stmt.test, filename, source, max_depth, 0, diagnostics); + check_expr( + &while_stmt.test, + filename, + source, + max_depth, + 0, + diagnostics, + ); for s in &while_stmt.body { check_stmt(s, filename, source, max_depth, diagnostics); } @@ -125,7 +132,14 @@ fn check_stmt( } } Stmt::Expr(expr_stmt) => { - check_expr(&expr_stmt.value, filename, source, max_depth, 0, diagnostics); + check_expr( + &expr_stmt.value, + filename, + source, + max_depth, + 0, + diagnostics, + ); } Stmt::Assign(assign) => { check_expr(&assign.value, filename, source, max_depth, 0, diagnostics); @@ -175,7 +189,14 @@ fn check_expr( } // Check the function being called - check_expr(&call.func, filename, source, max_depth, new_depth, diagnostics); + check_expr( + &call.func, + filename, + source, + max_depth, + new_depth, + diagnostics, + ); // Check arguments for arg in &call.args { @@ -185,27 +206,90 @@ fn check_expr( // Recurse into sub-expressions Expr::BinOp(binop) => { - check_expr(&binop.left, filename, source, max_depth, current_depth, diagnostics); - check_expr(&binop.right, filename, source, max_depth, current_depth, diagnostics); + check_expr( + &binop.left, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + check_expr( + &binop.right, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); } Expr::UnaryOp(unary) => { - check_expr(&unary.operand, filename, source, max_depth, current_depth, diagnostics); + check_expr( + &unary.operand, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); } Expr::Compare(cmp) => { - check_expr(&cmp.left, filename, source, max_depth, current_depth, diagnostics); + check_expr( + &cmp.left, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); for comparator in &cmp.comparators { - check_expr(comparator, filename, source, max_depth, current_depth, diagnostics); + check_expr( + comparator, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); } } Expr::BoolOp(boolop) => { for value in &boolop.values { - check_expr(value, filename, source, max_depth, current_depth, diagnostics); + check_expr( + value, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); } } Expr::IfExp(ifexp) => { - check_expr(&ifexp.test, filename, source, max_depth, current_depth, diagnostics); - check_expr(&ifexp.body, filename, source, max_depth, current_depth, diagnostics); - check_expr(&ifexp.orelse, filename, source, max_depth, current_depth, diagnostics); + check_expr( + &ifexp.test, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + check_expr( + &ifexp.body, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + check_expr( + &ifexp.orelse, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); } Expr::List(list) => { for elt in &list.elts { @@ -218,11 +302,32 @@ fn check_expr( } } Expr::Subscript(sub) => { - check_expr(&sub.value, filename, source, max_depth, current_depth, diagnostics); - check_expr(&sub.slice, filename, source, max_depth, current_depth, diagnostics); + check_expr( + &sub.value, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); + check_expr( + &sub.slice, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); } Expr::Attribute(attr) => { - check_expr(&attr.value, filename, source, max_depth, current_depth, diagnostics); + check_expr( + &attr.value, + filename, + source, + max_depth, + current_depth, + diagnostics, + ); } _ => {} } @@ -231,7 +336,7 @@ fn check_expr( #[cfg(test)] mod tests { use super::*; - use rustpython_parser::{parse, Mode}; + use rustpython_parser::{Mode, parse}; fn check_source_with_max(source: &str, max: u32) -> Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/linter/rules/zlup009.rs b/exp/guppy-zlup/src/linter/rules/zlup009.rs index 0b51f3ae7..aba7a0b50 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup009.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup009.rs @@ -47,14 +47,28 @@ impl LintRule for ZLUP009AssertionDensity { fn check_stmt(stmt: &Stmt, filename: &str, source: &str, diagnostics: &mut Vec) { match stmt { Stmt::FunctionDef(func) => { - check_function(&func.name, &func.body, func.range, filename, source, diagnostics); + check_function( + &func.name, + &func.body, + func.range, + filename, + source, + diagnostics, + ); // Check nested functions for s in &func.body { check_stmt(s, filename, source, diagnostics); } } Stmt::AsyncFunctionDef(func) => { - check_function(&func.name, &func.body, func.range, filename, source, diagnostics); + check_function( + &func.name, + &func.body, + func.range, + filename, + source, + diagnostics, + ); for s in &func.body { check_stmt(s, filename, source, diagnostics); } @@ -186,7 +200,7 @@ fn has_assertion_in_body(body: &[Stmt]) -> bool { #[cfg(test)] mod tests { use super::*; - use rustpython_parser::{parse, Mode}; + use rustpython_parser::{Mode, parse}; fn check_source(source: &str) -> Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/linter/rules/zlup010.rs b/exp/guppy-zlup/src/linter/rules/zlup010.rs index 32030b5f0..f9c955f8f 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup010.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup010.rs @@ -3,13 +3,17 @@ use rustpython_parser::ast::{Mod, Stmt}; use super::super::diagnostic::{Diagnostic, Severity}; -use super::{make_location, LintRule}; +use super::{LintRule, make_location}; /// Names that are allowed as module-level constants (typically UPPER_CASE). fn is_constant_name(name: &str) -> bool { // Allow UPPER_CASE names as constants - name.chars().all(|c| c.is_uppercase() || c == '_' || c.is_numeric()) - && name.chars().next().is_some_and(|c| c.is_alphabetic() || c == '_') + name.chars() + .all(|c| c.is_uppercase() || c == '_' || c.is_numeric()) + && name + .chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_') } /// Known safe module-level definitions. @@ -183,7 +187,7 @@ fn check_global_in_function( #[cfg(test)] mod tests { use super::*; - use rustpython_parser::{parse, Mode}; + use rustpython_parser::{Mode, parse}; fn check_source(source: &str) -> Vec { let parsed = parse(source, Mode::Module, "").unwrap(); diff --git a/exp/guppy-zlup/src/main.rs b/exp/guppy-zlup/src/main.rs index 15cfb0a11..e95d30fc0 100644 --- a/exp/guppy-zlup/src/main.rs +++ b/exp/guppy-zlup/src/main.rs @@ -6,14 +6,18 @@ use std::sync::mpsc::channel; use std::time::Duration; use clap::{Parser, Subcommand, ValueEnum}; -use guppy_zlup::{compile_file, ir, lint_source, CompileError, Config, LintResult, Linter, OutputFormat, Severity}; +use guppy_zlup::{ + CompileError, Config, LintResult, Linter, OutputFormat, Severity, compile_file, ir, lint_source, +}; use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode}; use rayon::prelude::*; #[derive(Parser)] #[command(name = "guppy-zlup")] #[command(author, version, about = "Guppy linter and Zlup compiler")] -#[command(long_about = "Validate Guppy quantum programs against NASA Power of 10 rules and compile to Zlup")] +#[command( + long_about = "Validate Guppy quantum programs against NASA Power of 10 rules and compile to Zlup" +)] struct Cli { #[command(subcommand)] command: Commands, @@ -174,9 +178,23 @@ fn main() -> ExitCode { watch, } => { if watch { - cmd_watch(&files, warnings_as_errors, config.as_deref(), &disabled_rules, max_complexity, format.into()) + cmd_watch( + &files, + warnings_as_errors, + config.as_deref(), + &disabled_rules, + max_complexity, + format.into(), + ) } else { - cmd_check(&files, warnings_as_errors, config.as_deref(), &disabled_rules, max_complexity, format.into()) + cmd_check( + &files, + warnings_as_errors, + config.as_deref(), + &disabled_rules, + max_complexity, + format.into(), + ) } } @@ -240,7 +258,11 @@ fn walkdir(dir: &Path) -> std::io::Result> { if path.is_dir() { // Skip hidden directories and common non-source dirs let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); - if !name.starts_with('.') && name != "__pycache__" && name != "node_modules" && name != "venv" { + if !name.starts_with('.') + && name != "__pycache__" + && name != "node_modules" + && name != "venv" + { files.extend(walkdir(&path)?); } } else if path.extension().is_some_and(|ext| ext == "py") { @@ -351,7 +373,9 @@ fn cmd_check( .count(); println!( "\nFound {} error(s) and {} warning(s) in {} file(s).", - error_count, warning_count, files.len() + error_count, + warning_count, + files.len() ); } @@ -376,7 +400,14 @@ fn cmd_watch( println!("Watching for changes... (press Ctrl+C to stop)"); // Run initial check - let _ = cmd_check(paths, warnings_as_errors, config_path, disabled_rules, max_complexity, format); + let _ = cmd_check( + paths, + warnings_as_errors, + config_path, + disabled_rules, + max_complexity, + format, + ); println!("\n---\n"); // Set up file watcher @@ -398,7 +429,10 @@ fn cmd_watch( path.as_path() }; - if let Err(e) = debouncer.watcher().watch(watch_path, RecursiveMode::Recursive) { + if let Err(e) = debouncer + .watcher() + .watch(watch_path, RecursiveMode::Recursive) + { eprintln!("Error watching {}: {}", watch_path.display(), e); } } @@ -412,9 +446,9 @@ fn cmd_watch( match rx.recv() { Ok(Ok(events)) => { // Check if any Python files changed - let has_py_changes = events.iter().any(|e| { - e.path.extension().is_some_and(|ext| ext == "py") - }); + let has_py_changes = events + .iter() + .any(|e| e.path.extension().is_some_and(|ext| ext == "py")); if has_py_changes { // Clear screen (optional, works on most terminals) @@ -530,7 +564,13 @@ fn cmd_compile_ir(input: &Path, output: Option<&Path>, to_stdout: bool, analyze: ExitCode::SUCCESS } -fn cmd_compile_source(input: &PathBuf, output: Option<&Path>, to_stdout: bool, validate: bool, analyze: bool) -> ExitCode { +fn cmd_compile_source( + input: &PathBuf, + output: Option<&Path>, + to_stdout: bool, + validate: bool, + analyze: bool, +) -> ExitCode { // Run guppylang validation if requested if validate { let exit = cmd_validate(std::slice::from_ref(input), false); @@ -677,7 +717,9 @@ fn cmd_analyze(input: &PathBuf, is_ir: bool, format: AnalyzeFormat, verbose: boo } fn run_analysis(zlup_source: &str, format: AnalyzeFormat, verbose: bool) { - use zlup::analysis::{analyze_parallelism, AllocatorAnalysis, DependencyGraph, OperationTagger}; + use zlup::analysis::{ + AllocatorAnalysis, DependencyGraph, OperationTagger, analyze_parallelism, + }; // Parse the Zlup source let program = match zlup::parse(zlup_source) { @@ -704,9 +746,14 @@ fn run_analysis(zlup_source: &str, format: AnalyzeFormat, verbose: bool) { println!(" (none)"); } else { for (name, info) in &allocator_analysis.allocators { - let size_str = info.size.map(|s| format!("[{}]", s)).unwrap_or_else(|| "[?]".to_string()); - println!(" {} {}qubit (scope depth: {}, line: {})", - name, size_str, info.scope_depth, info.defined_at_line); + let size_str = info + .size + .map(|s| format!("[{}]", s)) + .unwrap_or_else(|| "[?]".to_string()); + println!( + " {} {}qubit (scope depth: {}, line: {})", + name, size_str, info.scope_depth, info.defined_at_line + ); } } println!(); @@ -732,42 +779,56 @@ fn run_analysis(zlup_source: &str, format: AnalyzeFormat, verbose: bool) { AnalyzeFormat::Json => { use serde_json::json; - let allocators: Vec<_> = allocator_analysis.allocators.iter().map(|(name, info)| { - json!({ - "name": name, - "size": info.size, - "scope_depth": info.scope_depth, - "defined_at_line": info.defined_at_line, + let allocators: Vec<_> = allocator_analysis + .allocators + .iter() + .map(|(name, info)| { + json!({ + "name": name, + "size": info.size, + "scope_depth": info.scope_depth, + "defined_at_line": info.defined_at_line, + }) }) - }).collect(); - - let functions: Vec<_> = summaries.iter().map(|s| { - json!({ - "name": s.function_name, - "total_ops": s.total_ops, - "quantum_ops": s.quantum_ops, - "classical_ops": s.classical_ops, - "num_layers": s.num_layers, - "max_parallelism": s.max_parallelism, + .collect(); + + let functions: Vec<_> = summaries + .iter() + .map(|s| { + json!({ + "name": s.function_name, + "total_ops": s.total_ops, + "quantum_ops": s.quantum_ops, + "classical_ops": s.classical_ops, + "num_layers": s.num_layers, + "max_parallelism": s.max_parallelism, + }) }) - }).collect(); + .collect(); let layers = dep_graph.parallel_layers(); - let layer_details: Vec<_> = layers.iter().enumerate().map(|(i, ops)| { - let op_details: Vec<_> = ops.iter().map(|&id| { - let op = &dep_graph.operations[id]; + let layer_details: Vec<_> = layers + .iter() + .enumerate() + .map(|(i, ops)| { + let op_details: Vec<_> = ops + .iter() + .map(|&id| { + let op = &dep_graph.operations[id]; + json!({ + "id": op.id, + "description": op.description, + "line": op.line, + "is_quantum": op.touches_qubits(), + }) + }) + .collect(); json!({ - "id": op.id, - "description": op.description, - "line": op.line, - "is_quantum": op.touches_qubits(), + "layer": i, + "operations": op_details, }) - }).collect(); - json!({ - "layer": i, - "operations": op_details, }) - }).collect(); + .collect(); let output = json!({ "allocators": allocators, @@ -856,7 +917,9 @@ fn cmd_validate(files: &[PathBuf], json_output: bool) -> ExitCode { } Err(e) => { eprintln!("Error running validation for {}: {}", file.display(), e); - eprintln!("Make sure Python with guppylang is available (uv run python or python3)"); + eprintln!( + "Make sure Python with guppylang is available (uv run python or python3)" + ); all_valid = false; } } @@ -869,7 +932,10 @@ fn cmd_validate(files: &[PathBuf], json_output: bool) -> ExitCode { }); println!("{}", serde_json::to_string_pretty(&combined).unwrap()); } else if all_valid { - println!("\nAll {} file(s) validated successfully!", python_files.len()); + println!( + "\nAll {} file(s) validated successfully!", + python_files.len() + ); } else { println!("\nValidation failed for some files."); } diff --git a/exp/guppy-zlup/tests/e2e_tests.rs b/exp/guppy-zlup/tests/e2e_tests.rs index 8ded7b1b7..55c9d37a8 100644 --- a/exp/guppy-zlup/tests/e2e_tests.rs +++ b/exp/guppy-zlup/tests/e2e_tests.rs @@ -31,10 +31,17 @@ fn lint_should_error(source: &str, expected_rule: &str) { expected_rule ); assert!( - result.diagnostics.iter().any(|d| d.rule_id == expected_rule), + result + .diagnostics + .iter() + .any(|d| d.rule_id == expected_rule), "Expected rule {} but got: {:?}", expected_rule, - result.diagnostics.iter().map(|d| &d.rule_id).collect::>() + result + .diagnostics + .iter() + .map(|d| &d.rule_id) + .collect::>() ); } @@ -48,7 +55,10 @@ fn test_e2e_simple_function() { def add(a: int, b: int) -> int: return a + b "#; - compile_and_check(source, &["fn add(a: i64, b: i64)", "-> i64", "return a + b"]); + compile_and_check( + source, + &["fn add(a: i64, b: i64)", "-> i64", "return a + b"], + ); } #[test] @@ -68,7 +78,10 @@ def compute() -> int: y: int = 20 return x + y "#; - compile_and_check(source, &["mut x: i64 = 10", "mut y: i64 = 20", "return x + y"]); + compile_and_check( + source, + &["mut x: i64 = 10", "mut y: i64 = 20", "return x + y"], + ); } // ============================================================================= @@ -424,7 +437,10 @@ def matrix_sum() -> int: total += 1 return total "#; - compile_and_check(source, &["for i in 0..3", "for j in 0..4", "total = total + 1"]); + compile_and_check( + source, + &["for i in 0..3", "for j in 0..4", "total = total + 1"], + ); } #[test] @@ -481,7 +497,10 @@ def swap_like() -> int: c = a return a + b + c "#; - compile_and_check(source, &["mut a: i64 = 1", "mut b: i64 = 2", "mut c: i64 = 3"]); + compile_and_check( + source, + &["mut a: i64 = 1", "mut b: i64 = 2", "mut c: i64 = 3"], + ); } #[test] @@ -537,7 +556,16 @@ def nested_cond(a: int, b: int) -> int: else: return 3 "#; - compile_and_check(source, &["if (a > 0)", "if (b > 0)", "return 1", "return 2", "return 3"]); + compile_and_check( + source, + &[ + "if (a > 0)", + "if (b > 0)", + "return 1", + "return 2", + "return 3", + ], + ); } #[test] @@ -564,7 +592,16 @@ def multi_reg() -> None: h(b[0]) cx(a[0], b[0]) "#; - compile_and_check(source, &["qalloc(2)", "qalloc(3)", "h a[0]", "h b[0]", "cx (a[0], b[0])"]); + compile_and_check( + source, + &[ + "qalloc(2)", + "qalloc(3)", + "h a[0]", + "h b[0]", + "cx (a[0], b[0])", + ], + ); } #[test] @@ -602,7 +639,7 @@ def half(x: int) -> int: #[test] fn test_e2e_analysis_after_compile() { - use zlup::analysis::{analyze_parallelism, AllocatorAnalysis}; + use zlup::analysis::{AllocatorAnalysis, analyze_parallelism}; let source = r#" def bell() -> None: @@ -618,14 +655,20 @@ def bell() -> None: let program = zlup::parse(&zlup_source).expect("parse failed"); let allocator_analysis = AllocatorAnalysis::analyze(&program); - assert!(allocator_analysis.allocators.contains_key("q"), "Should detect allocator q"); + assert!( + allocator_analysis.allocators.contains_key("q"), + "Should detect allocator q" + ); let summaries = analyze_parallelism(&program); assert!(!summaries.is_empty(), "Should have function summaries"); let bell_summary = summaries.iter().find(|s| s.function_name == "bell"); assert!(bell_summary.is_some(), "Should have bell function summary"); - assert!(bell_summary.unwrap().quantum_ops >= 2, "Should have at least 2 quantum ops"); + assert!( + bell_summary.unwrap().quantum_ops >= 2, + "Should have at least 2 quantum ops" + ); } #[test] @@ -647,7 +690,10 @@ def parallel_prep() -> None: let summary = &summaries[0]; // Two independent H gates on different allocators should enable parallelism - assert!(summary.max_parallelism >= 2, "Disjoint allocators should enable parallelism"); + assert!( + summary.max_parallelism >= 2, + "Disjoint allocators should enable parallelism" + ); } #[test] @@ -686,5 +732,8 @@ def bad_loop() -> None: // lint_and_compile should fail let compile_result = lint_and_compile(source, None); - assert!(compile_result.is_err(), "Should fail to compile with lint errors"); + assert!( + compile_result.is_err(), + "Should fail to compile with lint errors" + ); } diff --git a/exp/guppy-zlup/tests/transform_tests.rs b/exp/guppy-zlup/tests/transform_tests.rs index 66450463f..6cebf201d 100644 --- a/exp/guppy-zlup/tests/transform_tests.rs +++ b/exp/guppy-zlup/tests/transform_tests.rs @@ -1,6 +1,6 @@ //! Integration tests for Guppy IR to Zlup transformation. -use guppy_zlup::{compile, CompileError}; +use guppy_zlup::{CompileError, compile}; #[test] fn test_compile_empty_program() { diff --git a/exp/zlup/benches/compiler.rs b/exp/zlup/benches/compiler.rs index 012a07ad8..91a3f6011 100644 --- a/exp/zlup/benches/compiler.rs +++ b/exp/zlup/benches/compiler.rs @@ -2,7 +2,7 @@ //! //! Run with: cargo bench --bench compiler -use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use criterion::{BenchmarkId, Criterion, Throughput, black_box, criterion_group, criterion_main}; use zlup::codegen::{QasmCodegen, SlrCodegen}; use zlup::semantic::SemanticAnalyzer; diff --git a/exp/zlup/src/analysis.rs b/exp/zlup/src/analysis.rs index c752ca8c3..81d15359a 100644 --- a/exp/zlup/src/analysis.rs +++ b/exp/zlup/src/analysis.rs @@ -47,9 +47,7 @@ use std::collections::{BTreeMap, BTreeSet}; -use crate::ast::{ - Block, Expr, FnDecl, GateOp, MeasureOp, Program, Stmt, TopLevelDecl, -}; +use crate::ast::{Block, Expr, FnDecl, GateOp, MeasureOp, Program, Stmt, TopLevelDecl}; // ============================================================================= // Resource Identifiers @@ -197,11 +195,7 @@ impl AllocatorAnalysis { name: binding.name.clone(), size: Self::extract_qalloc_size(value), scope_depth: depth, - defined_at_line: binding - .location - .as_ref() - .map(|l| l.line) - .unwrap_or(0), + defined_at_line: binding.location.as_ref().map(|l| l.line).unwrap_or(0), }, ); } @@ -306,7 +300,8 @@ pub struct TaggedOp { impl TaggedOp { /// Check if this operation touches any qubit allocators. pub fn touches_qubits(&self) -> bool { - self.reads.iter().any(|r| r.touches_qubits()) || self.writes.iter().any(|r| r.touches_qubits()) + self.reads.iter().any(|r| r.touches_qubits()) + || self.writes.iter().any(|r| r.touches_qubits()) } /// Check if this operation is purely classical. @@ -858,8 +853,16 @@ pub fn analyze_parallelism(program: &Program) -> Vec { let graph = DependencyGraph::build(tagger.operations); let layers = graph.parallel_layers(); - let classical_ops = graph.operations.iter().filter(|op| op.is_classical()).count(); - let quantum_ops = graph.operations.iter().filter(|op| op.touches_qubits()).count(); + let classical_ops = graph + .operations + .iter() + .filter(|op| op.is_classical()) + .count(); + let quantum_ops = graph + .operations + .iter() + .filter(|op| op.touches_qubits()) + .count(); summaries.push(ParallelismSummary { function_name: fn_decl.name.clone(), @@ -943,8 +946,14 @@ mod tests { assert_eq!(graph.operations.len(), 4, "Expected 4 operations"); // H and CX both touch allocator "q", so they're dependent - let h_op = graph.operations.iter().find(|op| op.description.contains("H")); - let cx_op = graph.operations.iter().find(|op| op.description.contains("CX")); + let h_op = graph + .operations + .iter() + .find(|op| op.description.contains("H")); + let cx_op = graph + .operations + .iter() + .find(|op| op.description.contains("CX")); assert!(h_op.is_some(), "Should have H gate"); assert!(cx_op.is_some(), "Should have CX gate"); @@ -1063,7 +1072,10 @@ mod tests { // The measure is part of a binding (m := mz...), so it's recorded as "bind m" // but should still track that it reads from allocator q - let m_binding = graph.operations.iter().find(|op| op.description == "bind m"); + let m_binding = graph + .operations + .iter() + .find(|op| op.description == "bind m"); assert!(m_binding.is_some(), "Should have binding for m"); // The binding should read from allocator "q" (via the measure expression) @@ -1129,7 +1141,10 @@ mod tests { // Each binding should be in a different layer due to data dependencies // (x, return could be parallel with others if no dep, but y needs x, z needs y) - assert!(layers.len() >= 3, "Should have at least 3 layers for x->y->z chain"); + assert!( + layers.len() >= 3, + "Should have at least 3 layers for x->y->z chain" + ); } #[test] @@ -1188,8 +1203,14 @@ mod tests { let summary = &summaries[0]; assert_eq!(summary.function_name, "main"); assert!(summary.total_ops > 0); - assert!(summary.quantum_ops >= 2, "Should have at least 2 quantum ops (H gates)"); - assert!(summary.classical_ops >= 1, "Should have at least 1 classical op (x binding)"); + assert!( + summary.quantum_ops >= 2, + "Should have at least 2 quantum ops (H gates)" + ); + assert!( + summary.classical_ops >= 1, + "Should have at least 1 classical op (x binding)" + ); } #[test] @@ -1203,9 +1224,15 @@ mod tests { let graph = parse_and_analyze(source); // Should have just the return operation - assert!(graph.operations.len() <= 1, "Empty function should have minimal ops"); + assert!( + graph.operations.len() <= 1, + "Empty function should have minimal ops" + ); let layers = graph.parallel_layers(); - assert!(layers.len() <= 1, "Empty function should have at most 1 layer"); + assert!( + layers.len() <= 1, + "Empty function should have at most 1 layer" + ); } #[test] diff --git a/exp/zlup/src/ast.rs b/exp/zlup/src/ast.rs index 4565a3a40..323b361ca 100644 --- a/exp/zlup/src/ast.rs +++ b/exp/zlup/src/ast.rs @@ -769,11 +769,11 @@ pub enum Expr { // Literals IntLit(IntLit), FloatLit(FloatLit), - AngleLit(Box), // 0.25 turns, pi/4 rad - angle with explicit unit - TypeAscription(Box), // 42 u32, 1/4 f64 - expression with type suffix + AngleLit(Box), // 0.25 turns, pi/4 rad - angle with explicit unit + TypeAscription(Box), // 42 u32, 1/4 f64 - expression with type suffix BoolLit(BoolLit), StringLit(StringLit), - FString(Box), // f"Hello {name}!" - Python-style interpolation + FString(Box), // f"Hello {name}!" - Python-style interpolation CharLit(CharLit), Null(NullLit), Undefined(UndefinedLit), @@ -792,37 +792,37 @@ pub enum Expr { Field(Box), Index(Box), Call(Box), - BatchApply(Box), // h { q[0], q[1] } - batch gate apply + BatchApply(Box), // h { q[0], q[1] } - batch gate apply // Special If(Box), Block(Box), Comptime(Box), Builtin(Box), - AnonStruct(Box), // struct { x: i32, y: i32 } - anonymous struct type + AnonStruct(Box), // struct { x: i32, y: i32 } - anonymous struct type StructInit(Box), ArrayInit(Box), - BracketArray(Box), // [a, b, c] literal - Tuple(Box), // (a, b) tuple - Set(Box), // {a, b, c} set literal + BracketArray(Box), // [a, b, c] literal + Tuple(Box), // (a, b) tuple + Set(Box), // {a, b, c} set literal Range(Box), - Measure(Box), // mz(T) targets - measurement - Gate(Box), // h q[0], rx(0.123) q[0] - quantum gate + Measure(Box), // mz(T) targets - measurement + Gate(Box), // h q[0], rx(0.123) q[0] - quantum gate // Error/fault handling - ErrorValue(Box), // error.Name literal - FaultValue(Box), // fault.Name literal - Catch(Box), // a catch |err| b - TryBlock(Box), // try { } or try! { } as expression + ErrorValue(Box), // error.Name literal + FaultValue(Box), // fault.Name literal + Catch(Box), // a catch |err| b + TryBlock(Box), // try { } or try! { } as expression // Function literal (for comptime type constructors) - FnLit(Box), // fn(params) -> ret { body } + FnLit(Box), // fn(params) -> ret { body } // Result emission (program output channel - special, never elided) - Result(Box), // result("tag", value) - emit to caller + Result(Box), // result("tag", value) - emit to caller // Side-channel communication (sticky/barrier semantics) - Channel(Box), // @emit.channel.command(...) - log, sim, hw, custom + Channel(Box), // @emit.channel.command(...) - log, sim, hw, custom } /// Try block as expression. @@ -1252,7 +1252,7 @@ pub struct TupleExpr { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SetExpr { pub elements: Vec, - pub element_type: Option, // For empty_set: Set(T){} + pub element_type: Option, // For empty_set: Set(T){} pub location: Option, } @@ -1401,11 +1401,11 @@ pub enum TypeExpr { Array(Box), Pointer(Box), Optional(Box), - ErrorUnion(Box), // E!T - single error, either/or + ErrorUnion(Box), // E!T - single error, either/or CollectedErrors(Box), // []E!T - collected errors, both Fn(Box), Tuple(Vec), - Set(Box), // Set(T) - unordered unique elements + Set(Box), // Set(T) - unordered unique elements // Inline/anonymous struct type: struct { x: i32, y: i32 } Struct(Box), @@ -1425,8 +1425,8 @@ pub enum TypeExpr { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum PrimitiveType { // Arbitrary-width integers (like Zig: u1, u4, u7, u128, etc.) - UInt { bits: u16 }, // Unsigned integer with N bits - IInt { bits: u16 }, // Signed integer with N bits + UInt { bits: u16 }, // Unsigned integer with N bits + IInt { bits: u16 }, // Signed integer with N bits Usize, // Platform-dependent unsigned size Isize, // Platform-dependent signed size // Floating point @@ -1451,7 +1451,7 @@ pub struct ArrayType { pub struct PointerType { pub pointee: TypeExpr, pub is_const: bool, - pub is_many: bool, // [*] vs * + pub is_many: bool, // [*] vs * pub sentinel: Option, } diff --git a/exp/zlup/src/build.rs b/exp/zlup/src/build.rs index b5de31e2c..4b7b532b9 100644 --- a/exp/zlup/src/build.rs +++ b/exp/zlup/src/build.rs @@ -352,7 +352,9 @@ impl Build { let exe = Executable { name: options.name, root_source: self.project_root.join(&options.root_source), - target: options.target.unwrap_or_else(|| self.default_target.clone()), + target: options + .target + .unwrap_or_else(|| self.default_target.clone()), optimize: options.optimize.unwrap_or(self.default_optimize), strict: options.strict, defines: BTreeMap::new(), @@ -370,7 +372,9 @@ impl Build { let lib = Library { name: options.name, root_source: self.project_root.join(&options.root_source), - target: options.target.unwrap_or_else(|| self.default_target.clone()), + target: options + .target + .unwrap_or_else(|| self.default_target.clone()), optimize: options.optimize.unwrap_or(self.default_optimize), strict: options.strict, step, @@ -493,7 +497,8 @@ impl BuildRunner { } else if let Ok(n) = value.parse::() { self.build.set_option(name, OptionValue::Int(n)); } else { - self.build.set_option(name, OptionValue::String(value.to_string())); + self.build + .set_option(name, OptionValue::String(value.to_string())); } } else { // -Dflag without value means true @@ -517,7 +522,10 @@ impl BuildRunner { if !build_file.exists() { return Err(BuildError { - message: format!("build.zlp not found in {}", self.build.project_root.display()), + message: format!( + "build.zlp not found in {}", + self.build.project_root.display() + ), }); } @@ -526,11 +534,10 @@ impl BuildRunner { })?; // Parse the build file - let _ast = crate::parse_file(&source, build_file.to_string_lossy()).map_err(|e| { - BuildError { + let _ast = + crate::parse_file(&source, build_file.to_string_lossy()).map_err(|e| BuildError { message: format!("failed to parse build.zlp: {}", e), - } - })?; + })?; // TODO: Execute the build function using the comptime evaluator // For now, we just validate that build.zlp parses correctly @@ -601,17 +608,17 @@ impl BuildRunner { /// Run all tests. fn run_tests(&self) -> BuildResult<()> { - use crate::test_runner::{format_results, TestOutcome, TestRunConfig, TestRunner}; + use crate::test_runner::{TestOutcome, TestRunConfig, TestRunner, format_results}; for test in &self.build.tests { println!("Running test: {}", test.root_source.display()); - let source = std::fs::read_to_string(&test.root_source).map_err(|e| { - BuildError { message: format!("failed to read {}: {}", test.root_source.display(), e) } + let source = std::fs::read_to_string(&test.root_source).map_err(|e| BuildError { + message: format!("failed to read {}: {}", test.root_source.display(), e), })?; - let program = crate::parser::parse(&source).map_err(|e| { - BuildError { message: format!("parse error: {}", e) } + let program = crate::parser::parse(&source).map_err(|e| BuildError { + message: format!("parse error: {}", e), })?; let config = TestRunConfig::default(); @@ -619,9 +626,13 @@ impl BuildRunner { let results = runner.run(&program); print!("{}", format_results(&results)); - let has_failures = results.iter().any(|r| matches!(r.outcome, TestOutcome::Fail(_))); + let has_failures = results + .iter() + .any(|r| matches!(r.outcome, TestOutcome::Fail(_))); if has_failures { - return Err(BuildError { message: "some tests failed".to_string() }); + return Err(BuildError { + message: "some tests failed".to_string(), + }); } } Ok(()) diff --git a/exp/zlup/src/codegen/hugr.rs b/exp/zlup/src/codegen/hugr.rs index f7b880317..f0e4f379d 100644 --- a/exp/zlup/src/codegen/hugr.rs +++ b/exp/zlup/src/codegen/hugr.rs @@ -23,15 +23,15 @@ use thiserror::Error; use std::io::Cursor; +use tket::TketOp; +use tket::extension::bool::bool_type; use tket::hugr::builder::{ BuildError, DFGBuilder, Dataflow, DataflowHugr, DataflowSubContainer, SubContainer, }; use tket::hugr::envelope::EnvelopeConfig; use tket::hugr::extension::prelude::qb_t; -use tket::extension::bool::bool_type; use tket::hugr::types::Signature; -use tket::hugr::{type_row, Hugr, Wire}; -use tket::TketOp; +use tket::hugr::{Hugr, Wire, type_row}; use crate::ast::{ BinaryOp, Binding, Block, CallExpr, ElseBranch, Expr, FnDecl, IndexExpr, Program, Stmt, @@ -195,11 +195,11 @@ fn gate_name_to_mapping(name: &str) -> Option { "z" => Some(GateMapping::Direct(TketOp::Z)), // Square root gates (sx = sqrt(X), sy = sqrt(Y), sz = sqrt(Z)) - "sx" => Some(GateMapping::Direct(TketOp::V)), // V = sqrt(X) + "sx" => Some(GateMapping::Direct(TketOp::V)), // V = sqrt(X) "sxdg" => Some(GateMapping::Direct(TketOp::Vdg)), - "sy" => Some(GateMapping::SY), // sqrt(Y) = Ry(π/2) - "sydg" => Some(GateMapping::SYdg), // sqrt(Y)† = Ry(-π/2) - "sz" => Some(GateMapping::Direct(TketOp::S)), // S = sqrt(Z) + "sy" => Some(GateMapping::SY), // sqrt(Y) = Ry(π/2) + "sydg" => Some(GateMapping::SYdg), // sqrt(Y)† = Ry(-π/2) + "sz" => Some(GateMapping::Direct(TketOp::S)), // S = sqrt(Z) "szdg" => Some(GateMapping::Direct(TketOp::Sdg)), // T gates (fourth root of Z) @@ -215,9 +215,9 @@ fn gate_name_to_mapping(name: &str) -> Option { "cx" => Some(GateMapping::Direct(TketOp::CX)), "cy" => Some(GateMapping::Direct(TketOp::CY)), "cz" => Some(GateMapping::Direct(TketOp::CZ)), - "ch" => Some(GateMapping::CH), // Controlled Hadamard (decomposed) + "ch" => Some(GateMapping::CH), // Controlled Hadamard (decomposed) "crz" => Some(GateMapping::Direct(TketOp::CRz)), - "rzz" => Some(GateMapping::RZZ), // ZZ rotation (decomposed) + "rzz" => Some(GateMapping::RZZ), // ZZ rotation (decomposed) // Two-qubit Ising gates (decomposed) "sxx" => Some(GateMapping::SXX), @@ -506,9 +506,10 @@ impl HugrCodegen { /// Check if an expression is a measurement call. fn is_measurement_call(&self, expr: &Expr) -> bool { if let Expr::Call(call) = expr - && let Ok(name) = self.extract_call_name(&call.callee) { - return name.as_str() == "mz"; - } + && let Ok(name) = self.extract_call_name(&call.callee) + { + return name.as_str() == "mz"; + } false } @@ -566,14 +567,15 @@ impl HugrCodegen { // Array of qubits: &[q[0], q[1]] Expr::Unary(unary) => { if let crate::ast::UnaryOp::AddrOf = unary.op - && let Expr::BracketArray(arr) = &unary.operand { - let mut qubits = Vec::new(); - for elem in &arr.elements { - let qubit = self.extract_qubit_ref(elem)?; - qubits.push(qubit); - } - return Ok(qubits); + && let Expr::BracketArray(arr) = &unary.operand + { + let mut qubits = Vec::new(); + for elem in &arr.elements { + let qubit = self.extract_qubit_ref(elem)?; + qubits.push(qubit); } + return Ok(qubits); + } Err(HugrError::UnsupportedExpression) } _ => Err(HugrError::UnsupportedExpression), @@ -607,14 +609,15 @@ impl HugrCodegen { // Address-of array: &[q[0], q[1], q[2]] Expr::Unary(unary) => { if let crate::ast::UnaryOp::AddrOf = unary.op - && let Expr::BracketArray(arr) = &unary.operand { - let mut qubits = Vec::new(); - for elem in &arr.elements { - let qubit = self.extract_qubit_ref(elem)?; - qubits.push(qubit); - } - return Ok(qubits); + && let Expr::BracketArray(arr) = &unary.operand + { + let mut qubits = Vec::new(); + for elem in &arr.elements { + let qubit = self.extract_qubit_ref(elem)?; + qubits.push(qubit); } + return Ok(qubits); + } // Single qubit (not batch) let qubit = self.extract_qubit_ref(expr)?; Ok(vec![qubit]) @@ -642,14 +645,15 @@ impl HugrCodegen { // Address-of array: &[(q[0], q[1]), (q[2], q[3])] Expr::Unary(unary) => { if let crate::ast::UnaryOp::AddrOf = unary.op - && let Expr::BracketArray(arr) = &unary.operand { - let mut pairs = Vec::new(); - for elem in &arr.elements { - let pair = self.extract_qubit_pair(elem)?; - pairs.push(pair); - } - return Ok(pairs); + && let Expr::BracketArray(arr) = &unary.operand + { + let mut pairs = Vec::new(); + for elem in &arr.elements { + let pair = self.extract_qubit_pair(elem)?; + pairs.push(pair); } + return Ok(pairs); + } Err(HugrError::UnsupportedExpression) } // Single pair (not batch) - could be tuple or two separate args @@ -696,7 +700,8 @@ impl HugrCodegen { } Stmt::If(if_stmt) => { // Check if the condition is a classical variable (from measurement) - if let Some(condition_var) = self.try_extract_classical_condition(&if_stmt.condition) + if let Some(condition_var) = + self.try_extract_classical_condition(&if_stmt.condition) { // Collect operations for both branches separately let then_ops = self.collect_block_ops(&if_stmt.then_body)?; @@ -732,9 +737,10 @@ impl HugrCodegen { /// Returns Some(var_name) if the condition is a simple reference to a classical variable. fn try_extract_classical_condition(&self, expr: &Expr) -> Option { if let Expr::Ident(ident) = expr - && self.classical_vars.contains(&ident.name) { - return Some(ident.name.clone()); - } + && self.classical_vars.contains(&ident.name) + { + return Some(ident.name.clone()); + } None } @@ -820,7 +826,10 @@ impl HugrCodegen { let qubit_args = &call.args[qubit_start..]; // Single-qubit gate with batch: h([q[0], q[1]) or h(&[q[0], q[1]]) - if qubit_count == 1 && qubit_args.len() == 1 && self.is_batch_literal(&qubit_args[0]) { + if qubit_count == 1 + && qubit_args.len() == 1 + && self.is_batch_literal(&qubit_args[0]) + { let targets = self.extract_batch_single_targets(&qubit_args[0])?; for qubit in targets { self.operations.push(GateOp::Direct { @@ -833,7 +842,10 @@ impl HugrCodegen { } // Two-qubit gate with batch: cx({(q[0], q[1])}) or cx(&[(q[0], q[1])]) - if qubit_count == 2 && qubit_args.len() == 1 && self.is_batch_literal(&qubit_args[0]) { + if qubit_count == 2 + && qubit_args.len() == 1 + && self.is_batch_literal(&qubit_args[0]) + { let pairs = self.extract_batch_pair_targets(&qubit_args[0])?; for (qubit_a, qubit_b) in pairs { self.operations.push(GateOp::Direct { @@ -1373,13 +1385,15 @@ impl HugrCodegen { let qubits = self.extract_measurement_targets(target_arg)?; for qubit in qubits { let result_var = format!("__measure_{}", self.operations.len()); - self.operations.push(GateOp::MidMeasure { qubit, result_var }); + self.operations + .push(GateOp::MidMeasure { qubit, result_var }); } } else if call.args.len() == 1 { // Legacy syntax: mz(qubit) let qubit = self.extract_qubit_ref(&call.args[0])?; let result_var = format!("__measure_{}", self.operations.len()); - self.operations.push(GateOp::MidMeasure { qubit, result_var }); + self.operations + .push(GateOp::MidMeasure { qubit, result_var }); } else { return Err(HugrError::WrongArgumentCount { gate: name, @@ -1413,11 +1427,13 @@ impl HugrCodegen { if let Expr::Call(call) = expr { // Check for method call pattern: expr.child(n) if let Expr::Field(field) = &call.callee - && field.field == "child" && call.args.len() == 1 { - let parent = self.extract_identifier(&field.object).ok()?; - let size = self.extract_integer(&call.args[0]).ok()?; - return Some((parent, size)); - } + && field.field == "child" + && call.args.len() == 1 + { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } } None } @@ -1453,12 +1469,12 @@ impl HugrCodegen { let idx = self.extract_integer(&index.index)?; // Validate the allocator exists - let alloc = self - .allocators - .get(&allocator) - .ok_or_else(|| HugrError::AllocatorNotFound { - name: allocator.clone(), - })?; + let alloc = + self.allocators + .get(&allocator) + .ok_or_else(|| HugrError::AllocatorNotFound { + name: allocator.clone(), + })?; // Validate index is in bounds if idx >= alloc.capacity { @@ -1522,8 +1538,8 @@ impl HugrCodegen { let signature = Signature::new(vec![], bool_row); // Create builder - let mut builder = DFGBuilder::new(signature) - .map_err(|e| HugrError::BuilderError(e.to_string()))?; + let mut builder = + DFGBuilder::new(signature).map_err(|e| HugrError::BuilderError(e.to_string()))?; // Allocate qubits using QAlloc let mut qubit_wires: BTreeMap = BTreeMap::new(); @@ -1536,7 +1552,9 @@ impl HugrCodegen { .map_err(|e| HugrError::BuilderError(e.to_string()))? .outputs() .next() - .ok_or_else(|| HugrError::BuilderError("QAlloc produced no output".to_string()))?; + .ok_or_else(|| { + HugrError::BuilderError("QAlloc produced no output".to_string()) + })?; qubit_wires.insert(qubit_ref, qalloc_wire); } } @@ -1546,7 +1564,12 @@ impl HugrCodegen { // Apply operations for gate_op in &self.operations { - self.apply_gate(&mut builder, &mut qubit_wires, &mut classical_wires, gate_op)?; + self.apply_gate( + &mut builder, + &mut qubit_wires, + &mut classical_wires, + gate_op, + )?; } // Measure and free all qubits using MeasureFree, collect bool results @@ -1630,9 +1653,27 @@ impl HugrCodegen { GateOp::ISwap { qubit_a, qubit_b } => { // iSWAP decomposition: S(a) S(b) H(a) CX(a,b) CX(b,a) H(b) - self.apply_direct_gate(builder, qubit_wires, TketOp::S, std::slice::from_ref(qubit_a), None)?; - self.apply_direct_gate(builder, qubit_wires, TketOp::S, std::slice::from_ref(qubit_b), None)?; - self.apply_direct_gate(builder, qubit_wires, TketOp::H, std::slice::from_ref(qubit_a), None)?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::S, + std::slice::from_ref(qubit_a), + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::S, + std::slice::from_ref(qubit_b), + None, + )?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::H, + std::slice::from_ref(qubit_a), + None, + )?; self.apply_direct_gate( builder, qubit_wires, @@ -1647,17 +1688,24 @@ impl HugrCodegen { &[qubit_b.clone(), qubit_a.clone()], None, )?; - self.apply_direct_gate(builder, qubit_wires, TketOp::H, std::slice::from_ref(qubit_b), None)?; + self.apply_direct_gate( + builder, + qubit_wires, + TketOp::H, + std::slice::from_ref(qubit_b), + None, + )?; } GateOp::MidMeasure { qubit, result_var } => { // Mid-circuit measurement: Measure keeps the qubit alive - let wire = qubit_wires - .get(qubit) - .copied() - .ok_or_else(|| HugrError::UndefinedQubit { - name: format!("{}[{}]", qubit.allocator, qubit.index), - })?; + let wire = + qubit_wires + .get(qubit) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", qubit.allocator, qubit.index), + })?; // Measure produces (qubit, bool) let outputs: Vec = builder @@ -1683,13 +1731,13 @@ impl HugrCodegen { else_ops, } => { // Get the classical condition wire - let condition_wire = classical_wires - .get(condition_var) - .copied() - .ok_or_else(|| HugrError::BuilderError(format!( - "Classical variable '{}' not found for conditional", - condition_var - )))?; + let condition_wire = + classical_wires.get(condition_var).copied().ok_or_else(|| { + HugrError::BuilderError(format!( + "Classical variable '{}' not found for conditional", + condition_var + )) + })?; // Collect all qubits used in both branches let mut used_qubits: Vec = Vec::new(); @@ -1748,14 +1796,17 @@ impl HugrCodegen { // Apply else operations for op in else_ops { - self.apply_gate_in_case(&mut case0, &mut branch_qubit_wires, &mut branch_classical_wires, op)?; + self.apply_gate_in_case( + &mut case0, + &mut branch_qubit_wires, + &mut branch_classical_wires, + op, + )?; } // Collect output wires in the same order as used_qubits - let output_wires: Vec = used_qubits - .iter() - .map(|q| branch_qubit_wires[q]) - .collect(); + let output_wires: Vec = + used_qubits.iter().map(|q| branch_qubit_wires[q]).collect(); case0 .finish_with_outputs(output_wires) @@ -1779,14 +1830,17 @@ impl HugrCodegen { // Apply then operations for op in then_ops { - self.apply_gate_in_case(&mut case1, &mut branch_qubit_wires, &mut branch_classical_wires, op)?; + self.apply_gate_in_case( + &mut case1, + &mut branch_qubit_wires, + &mut branch_classical_wires, + op, + )?; } // Collect output wires in the same order as used_qubits - let output_wires: Vec = used_qubits - .iter() - .map(|q| branch_qubit_wires[q]) - .collect(); + let output_wires: Vec = + used_qubits.iter().map(|q| branch_qubit_wires[q]).collect(); case1 .finish_with_outputs(output_wires) @@ -1824,9 +1878,7 @@ impl HugrCodegen { } GateOp::MidMeasure { qubit, .. } => qubits.push(qubit.clone()), GateOp::Conditional { - then_ops, - else_ops, - .. + then_ops, else_ops, .. } => { self.collect_used_qubits(then_ops, qubits); self.collect_used_qubits(else_ops, qubits); @@ -1942,12 +1994,13 @@ impl HugrCodegen { } GateOp::MidMeasure { qubit, result_var } => { - let wire = qubit_wires - .get(qubit) - .copied() - .ok_or_else(|| HugrError::UndefinedQubit { - name: format!("{}[{}]", qubit.allocator, qubit.index), - })?; + let wire = + qubit_wires + .get(qubit) + .copied() + .ok_or_else(|| HugrError::UndefinedQubit { + name: format!("{}[{}]", qubit.allocator, qubit.index), + })?; let outputs: Vec = builder .add_dataflow_op(TketOp::Measure, vec![wire]) @@ -2188,8 +2241,16 @@ mod tests { assert!(result.is_err(), "Expected QubitIndexOutOfBounds error"); assert!( - matches!(result, Err(crate::semantic::SemanticError::QubitIndexOutOfBounds { index: 5, capacity: 2, .. })), - "Expected QubitIndexOutOfBounds error, got: {:?}", result + matches!( + result, + Err(crate::semantic::SemanticError::QubitIndexOutOfBounds { + index: 5, + capacity: 2, + .. + }) + ), + "Expected QubitIndexOutOfBounds error, got: {:?}", + result ); } diff --git a/exp/zlup/src/codegen/phir.rs b/exp/zlup/src/codegen/phir.rs index 9d5e5ba95..f640ee376 100644 --- a/exp/zlup/src/codegen/phir.rs +++ b/exp/zlup/src/codegen/phir.rs @@ -56,7 +56,9 @@ pub enum PhirJsonError { #[error("undefined allocator '{name}'")] UndefinedAllocator { name: String }, - #[error("qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}")] + #[error( + "qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}" + )] QubitIndexOutOfBounds { allocator: String, index: usize, @@ -246,12 +248,13 @@ impl PhirJsonQop { } /// Create a two-qubit gate operation. - pub fn two_qubit(gate: impl Into, pairs: Vec<((String, usize), (String, usize))>) -> Self { + pub fn two_qubit( + gate: impl Into, + pairs: Vec<((String, usize), (String, usize))>, + ) -> Self { let args: Vec = pairs .into_iter() - .map(|((n1, i1), (n2, i2))| { - serde_json::json!([[n1, i1], [n2, i2]]) - }) + .map(|((n1, i1), (n2, i2))| serde_json::json!([[n1, i1], [n2, i2]])) .collect(); Self { qop: gate.into(), @@ -262,7 +265,12 @@ impl PhirJsonQop { } /// Create a single-qubit rotation. - pub fn rotation(gate: impl Into, angle: f64, unit: &str, qubits: Vec<(String, usize)>) -> Self { + pub fn rotation( + gate: impl Into, + angle: f64, + unit: &str, + qubits: Vec<(String, usize)>, + ) -> Self { let args: Vec = qubits .into_iter() .map(|(name, idx)| serde_json::json!([name, idx])) @@ -426,48 +434,184 @@ struct GateInfo { fn get_gate_info(kind: GateKind) -> GateInfo { match kind { // Single-qubit gates - GateKind::H => GateInfo { phir_name: "H", num_qubits: 1, num_angles: 0 }, - GateKind::X => GateInfo { phir_name: "X", num_qubits: 1, num_angles: 0 }, - GateKind::Y => GateInfo { phir_name: "Y", num_qubits: 1, num_angles: 0 }, - GateKind::Z => GateInfo { phir_name: "Z", num_qubits: 1, num_angles: 0 }, - GateKind::T => GateInfo { phir_name: "T", num_qubits: 1, num_angles: 0 }, - GateKind::Tdg => GateInfo { phir_name: "Tdg", num_qubits: 1, num_angles: 0 }, - GateKind::SX => GateInfo { phir_name: "SX", num_qubits: 1, num_angles: 0 }, - GateKind::SXdg => GateInfo { phir_name: "SXdg", num_qubits: 1, num_angles: 0 }, - GateKind::SY => GateInfo { phir_name: "SY", num_qubits: 1, num_angles: 0 }, - GateKind::SYdg => GateInfo { phir_name: "SYdg", num_qubits: 1, num_angles: 0 }, - GateKind::SZ => GateInfo { phir_name: "SZ", num_qubits: 1, num_angles: 0 }, - GateKind::SZdg => GateInfo { phir_name: "SZdg", num_qubits: 1, num_angles: 0 }, - GateKind::F => GateInfo { phir_name: "F", num_qubits: 1, num_angles: 0 }, - GateKind::Fdg => GateInfo { phir_name: "Fdg", num_qubits: 1, num_angles: 0 }, - GateKind::F4 => GateInfo { phir_name: "F4", num_qubits: 1, num_angles: 0 }, - GateKind::F4dg => GateInfo { phir_name: "F4dg", num_qubits: 1, num_angles: 0 }, + GateKind::H => GateInfo { + phir_name: "H", + num_qubits: 1, + num_angles: 0, + }, + GateKind::X => GateInfo { + phir_name: "X", + num_qubits: 1, + num_angles: 0, + }, + GateKind::Y => GateInfo { + phir_name: "Y", + num_qubits: 1, + num_angles: 0, + }, + GateKind::Z => GateInfo { + phir_name: "Z", + num_qubits: 1, + num_angles: 0, + }, + GateKind::T => GateInfo { + phir_name: "T", + num_qubits: 1, + num_angles: 0, + }, + GateKind::Tdg => GateInfo { + phir_name: "Tdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SX => GateInfo { + phir_name: "SX", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SXdg => GateInfo { + phir_name: "SXdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SY => GateInfo { + phir_name: "SY", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SYdg => GateInfo { + phir_name: "SYdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SZ => GateInfo { + phir_name: "SZ", + num_qubits: 1, + num_angles: 0, + }, + GateKind::SZdg => GateInfo { + phir_name: "SZdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::F => GateInfo { + phir_name: "F", + num_qubits: 1, + num_angles: 0, + }, + GateKind::Fdg => GateInfo { + phir_name: "Fdg", + num_qubits: 1, + num_angles: 0, + }, + GateKind::F4 => GateInfo { + phir_name: "F4", + num_qubits: 1, + num_angles: 0, + }, + GateKind::F4dg => GateInfo { + phir_name: "F4dg", + num_qubits: 1, + num_angles: 0, + }, // Single-qubit rotations - GateKind::RX => GateInfo { phir_name: "RX", num_qubits: 1, num_angles: 1 }, - GateKind::RY => GateInfo { phir_name: "RY", num_qubits: 1, num_angles: 1 }, - GateKind::RZ => GateInfo { phir_name: "RZ", num_qubits: 1, num_angles: 1 }, + GateKind::RX => GateInfo { + phir_name: "RX", + num_qubits: 1, + num_angles: 1, + }, + GateKind::RY => GateInfo { + phir_name: "RY", + num_qubits: 1, + num_angles: 1, + }, + GateKind::RZ => GateInfo { + phir_name: "RZ", + num_qubits: 1, + num_angles: 1, + }, // Two-qubit gates - GateKind::CX => GateInfo { phir_name: "CX", num_qubits: 2, num_angles: 0 }, - GateKind::CY => GateInfo { phir_name: "CY", num_qubits: 2, num_angles: 0 }, - GateKind::CZ => GateInfo { phir_name: "CZ", num_qubits: 2, num_angles: 0 }, - GateKind::CH => GateInfo { phir_name: "CH", num_qubits: 2, num_angles: 0 }, - GateKind::SWAP => GateInfo { phir_name: "SWAP", num_qubits: 2, num_angles: 0 }, - GateKind::ISWAP => GateInfo { phir_name: "ISWAP", num_qubits: 2, num_angles: 0 }, - GateKind::SXX => GateInfo { phir_name: "SXX", num_qubits: 2, num_angles: 0 }, - GateKind::SXXdg => GateInfo { phir_name: "SXXdg", num_qubits: 2, num_angles: 0 }, - GateKind::SYY => GateInfo { phir_name: "SYY", num_qubits: 2, num_angles: 0 }, - GateKind::SYYdg => GateInfo { phir_name: "SYYdg", num_qubits: 2, num_angles: 0 }, - GateKind::SZZ => GateInfo { phir_name: "SZZ", num_qubits: 2, num_angles: 0 }, - GateKind::SZZdg => GateInfo { phir_name: "SZZdg", num_qubits: 2, num_angles: 0 }, - GateKind::RZZ => GateInfo { phir_name: "RZZ", num_qubits: 2, num_angles: 1 }, + GateKind::CX => GateInfo { + phir_name: "CX", + num_qubits: 2, + num_angles: 0, + }, + GateKind::CY => GateInfo { + phir_name: "CY", + num_qubits: 2, + num_angles: 0, + }, + GateKind::CZ => GateInfo { + phir_name: "CZ", + num_qubits: 2, + num_angles: 0, + }, + GateKind::CH => GateInfo { + phir_name: "CH", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SWAP => GateInfo { + phir_name: "SWAP", + num_qubits: 2, + num_angles: 0, + }, + GateKind::ISWAP => GateInfo { + phir_name: "ISWAP", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SXX => GateInfo { + phir_name: "SXX", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SXXdg => GateInfo { + phir_name: "SXXdg", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SYY => GateInfo { + phir_name: "SYY", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SYYdg => GateInfo { + phir_name: "SYYdg", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SZZ => GateInfo { + phir_name: "SZZ", + num_qubits: 2, + num_angles: 0, + }, + GateKind::SZZdg => GateInfo { + phir_name: "SZZdg", + num_qubits: 2, + num_angles: 0, + }, + GateKind::RZZ => GateInfo { + phir_name: "RZZ", + num_qubits: 2, + num_angles: 1, + }, // Three-qubit gates - GateKind::CCX => GateInfo { phir_name: "CCX", num_qubits: 3, num_angles: 0 }, + GateKind::CCX => GateInfo { + phir_name: "CCX", + num_qubits: 3, + num_angles: 0, + }, // Prepare operations (treated as Init) - GateKind::PZ => GateInfo { phir_name: "Init", num_qubits: 1, num_angles: 0 }, + GateKind::PZ => GateInfo { + phir_name: "Init", + num_qubits: 1, + num_angles: 0, + }, } } @@ -532,33 +676,36 @@ impl PhirJsonCodegen { // Add quantum variable definitions for alloc in self.allocators.values() { - phir.ops.push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( - &alloc.name, - alloc.capacity, - ))); + phir.ops + .push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( + &alloc.name, + alloc.capacity, + ))); } // Add classical variable definitions for reg in self.registers.values() { - phir.ops.push(PhirJsonOp::CvarDefine(PhirJsonCvarDefine::new( - ®.name, - reg.size, - ))); + phir.ops + .push(PhirJsonOp::CvarDefine(PhirJsonCvarDefine::new( + ®.name, reg.size, + ))); } // Second pass: convert main function body for decl in &program.declarations { if let TopLevelDecl::Fn(fn_decl) = decl - && fn_decl.name == "main" { - let ops = self.convert_block(&fn_decl.body)?; - phir.ops.extend(ops); - } + && fn_decl.name == "main" + { + let ops = self.convert_block(&fn_decl.body)?; + phir.ops.extend(ops); + } } // Export all classical variables if !self.registers.is_empty() { let vars: Vec = self.registers.keys().cloned().collect(); - phir.ops.push(PhirJsonOp::CvarExport(PhirJsonCvarExport::new(vars))); + phir.ops + .push(PhirJsonOp::CvarExport(PhirJsonCvarExport::new(vars))); } Ok(phir) @@ -573,17 +720,18 @@ impl PhirJsonCodegen { // Add definitions for alloc in self.allocators.values() { - phir.ops.push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( - &alloc.name, - alloc.capacity, - ))); + phir.ops + .push(PhirJsonOp::QvarDefine(PhirJsonQvarDefine::new( + &alloc.name, + alloc.capacity, + ))); } for reg in self.registers.values() { - phir.ops.push(PhirJsonOp::CvarDefine(PhirJsonCvarDefine::new( - ®.name, - reg.size, - ))); + phir.ops + .push(PhirJsonOp::CvarDefine(PhirJsonCvarDefine::new( + ®.name, reg.size, + ))); } // Convert body @@ -662,29 +810,31 @@ impl PhirJsonCodegen { // Check for qalloc if let Expr::Call(call) = init && self.get_callee_name(call) == Some("qalloc".to_string()) - && let Some(Expr::IntLit(IntLit { value, .. })) = call.args.first() { - self.allocators.insert( - binding.name.clone(), - AllocatorInfo { - name: binding.name.clone(), - capacity: *value as usize, - }, - ); - } + && let Some(Expr::IntLit(IntLit { value, .. })) = call.args.first() + { + self.allocators.insert( + binding.name.clone(), + AllocatorInfo { + name: binding.name.clone(), + capacity: *value as usize, + }, + ); + } // Check for typed measurement (creates a register) if let Expr::Call(call) = init && let Some(name) = self.get_callee_name(call) - && (name == "mz" || name == "mx" || name == "my") { - let size = call.args.len().max(1); - self.registers.insert( - binding.name.clone(), - RegisterInfo { - name: binding.name.clone(), - size, - }, - ); - } + && (name == "mz" || name == "mx" || name == "my") + { + let size = call.args.len().max(1); + self.registers.insert( + binding.name.clone(), + RegisterInfo { + name: binding.name.clone(), + size, + }, + ); + } } Ok(()) } @@ -741,23 +891,25 @@ impl PhirJsonCodegen { if let Some(ref init) = binding.value { // Check for qalloc - already handled in collection phase if let Expr::Call(call) = init - && self.get_callee_name(call) == Some("qalloc".to_string()) { - return Ok(vec![]); - } + && self.get_callee_name(call) == Some("qalloc".to_string()) + { + return Ok(vec![]); + } // Check for measurement call (mz(...) [targets]) if let Expr::Call(call) = init && let Some(name) = self.get_callee_name(call) - && (name == "mz" || name == "mx" || name == "my") { - let qubits = self.extract_qubits_from_args(&call.args)?; - let results: Vec<(String, usize)> = qubits - .iter() - .enumerate() - .map(|(i, _)| (binding.name.clone(), i)) - .collect(); - ops.push(PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))); - return Ok(ops); - } + && (name == "mz" || name == "mx" || name == "my") + { + let qubits = self.extract_qubits_from_args(&call.args)?; + let results: Vec<(String, usize)> = qubits + .iter() + .enumerate() + .map(|(i, _)| (binding.name.clone(), i)) + .collect(); + ops.push(PhirJsonOp::Qop(PhirJsonQop::measure(qubits, results))); + return Ok(ops); + } // Check for measurement expression (mz(T) targets) if let Expr::Measure(measure_expr) = init { @@ -798,7 +950,10 @@ impl PhirJsonCodegen { } } - fn convert_gate_expr(&self, gate_expr: &crate::ast::GateExpr) -> PhirJsonResult> { + fn convert_gate_expr( + &self, + gate_expr: &crate::ast::GateExpr, + ) -> PhirJsonResult> { let gate_info = get_gate_info(gate_expr.kind); // Handle prepare operations @@ -807,12 +962,13 @@ impl PhirJsonCodegen { if qubits.is_empty() { // Prepare all qubits in the allocator if let Expr::Ident(ident) = &gate_expr.target - && let Some(alloc) = self.allocators.get(&ident.name) { - let all_qubits: Vec<(String, usize)> = (0..alloc.capacity) - .map(|i| (alloc.name.clone(), i)) - .collect(); - return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(all_qubits))]); - } + && let Some(alloc) = self.allocators.get(&ident.name) + { + let all_qubits: Vec<(String, usize)> = (0..alloc.capacity) + .map(|i| (alloc.name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(all_qubits))]); + } } return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]); } @@ -834,7 +990,10 @@ impl PhirJsonCodegen { qubits, ))]) } else { - Ok(vec![PhirJsonOp::Qop(PhirJsonQop::single_qubit(gate_info.phir_name, qubits))]) + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::single_qubit( + gate_info.phir_name, + qubits, + ))]) } } else { // Two-qubit gate - pair up qubits @@ -849,11 +1008,17 @@ impl PhirJsonCodegen { .chunks(2) .map(|chunk| (chunk[0].clone(), chunk[1].clone())) .collect(); - Ok(vec![PhirJsonOp::Qop(PhirJsonQop::two_qubit(gate_info.phir_name, pairs))]) + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::two_qubit( + gate_info.phir_name, + pairs, + ))]) } } - fn convert_measure_expr(&mut self, measure_expr: &crate::ast::MeasureExpr) -> PhirJsonResult> { + fn convert_measure_expr( + &mut self, + measure_expr: &crate::ast::MeasureExpr, + ) -> PhirJsonResult> { let qubits = self.extract_qubits_from_target(&measure_expr.targets)?; let reg_name = format!("m{}", self.register_counter); self.register_counter += 1; @@ -870,9 +1035,10 @@ impl PhirJsonCodegen { match target { Expr::Index(idx_expr) => { if let Expr::Ident(ident) = &idx_expr.object - && let Some(idx) = self.eval_index(&idx_expr.index) { - qubits.push((ident.name.clone(), idx)); - } + && let Some(idx) = self.eval_index(&idx_expr.index) + { + qubits.push((ident.name.clone(), idx)); + } } Expr::Tuple(tuple_expr) => { for elem in &tuple_expr.elements { @@ -911,12 +1077,13 @@ impl PhirJsonCodegen { if qubits.is_empty() { // Prepare all qubits in the allocator if let Some(Expr::Ident(ident)) = call.args.first() - && let Some(alloc) = self.allocators.get(&ident.name) { - let all_qubits: Vec<(String, usize)> = (0..alloc.capacity) - .map(|i| (alloc.name.clone(), i)) - .collect(); - return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(all_qubits))]); - } + && let Some(alloc) = self.allocators.get(&ident.name) + { + let all_qubits: Vec<(String, usize)> = (0..alloc.capacity) + .map(|i| (alloc.name.clone(), i)) + .collect(); + return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(all_qubits))]); + } } return Ok(vec![PhirJsonOp::Qop(PhirJsonQop::init(qubits))]); } @@ -975,7 +1142,10 @@ impl PhirJsonCodegen { qubits, ))]) } else { - Ok(vec![PhirJsonOp::Qop(PhirJsonQop::single_qubit(gate_info.phir_name, qubits))]) + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::single_qubit( + gate_info.phir_name, + qubits, + ))]) } } else { // Two-qubit gate - pair up qubits @@ -990,20 +1160,25 @@ impl PhirJsonCodegen { .chunks(2) .map(|chunk| (chunk[0].clone(), chunk[1].clone())) .collect(); - Ok(vec![PhirJsonOp::Qop(PhirJsonQop::two_qubit(gate_info.phir_name, pairs))]) + Ok(vec![PhirJsonOp::Qop(PhirJsonQop::two_qubit( + gate_info.phir_name, + pairs, + ))]) } } fn convert_prepare(&self, prepare_op: &PrepareOp) -> PhirJsonResult> { - let alloc = self - .allocators - .get(&prepare_op.allocator) - .ok_or_else(|| PhirJsonError::UndefinedAllocator { + let alloc = self.allocators.get(&prepare_op.allocator).ok_or_else(|| { + PhirJsonError::UndefinedAllocator { name: prepare_op.allocator.clone(), - })?; + } + })?; let qubits: Vec<(String, usize)> = if let Some(ref slots) = prepare_op.slots { - slots.iter().map(|&i| (alloc.name.clone(), i as usize)).collect() + slots + .iter() + .map(|&i| (alloc.name.clone(), i as usize)) + .collect() } else { (0..alloc.capacity) .map(|i| (alloc.name.clone(), i)) @@ -1019,7 +1194,8 @@ impl PhirJsonCodegen { .results .iter() .filter_map(|br| { - self.eval_index(&br.index).map(|idx| (br.register.clone(), idx)) + self.eval_index(&br.index) + .map(|idx| (br.register.clone(), idx)) }) .collect(); @@ -1050,15 +1226,14 @@ impl PhirJsonCodegen { let mut ops = Vec::new(); if let ForRange::Range { start, end, .. } = &for_stmt.range - && let (Some(start_val), Some(end_val)) = ( - self.try_eval_const(start), - self.try_eval_const(end), - ) { - for _ in start_val..end_val { - ops.extend(self.convert_block(&for_stmt.body)?); - } - return Ok(ops); + && let (Some(start_val), Some(end_val)) = + (self.try_eval_const(start), self.try_eval_const(end)) + { + for _ in start_val..end_val { + ops.extend(self.convert_block(&for_stmt.body)?); } + return Ok(ops); + } Err(PhirJsonError::UnsupportedStatement( "for loops with non-constant bounds".to_string(), @@ -1098,7 +1273,8 @@ impl PhirJsonCodegen { targets .iter() .filter_map(|slot| { - self.eval_index(&slot.index).map(|idx| (slot.allocator.clone(), idx)) + self.eval_index(&slot.index) + .map(|idx| (slot.allocator.clone(), idx)) }) .collect() } @@ -1109,26 +1285,29 @@ impl PhirJsonCodegen { match arg { Expr::Index(idx_expr) => { if let Expr::Ident(ident) = &idx_expr.object - && let Some(idx) = self.eval_index(&idx_expr.index) { - qubits.push((ident.name.clone(), idx)); - } + && let Some(idx) = self.eval_index(&idx_expr.index) + { + qubits.push((ident.name.clone(), idx)); + } } Expr::Tuple(tuple_expr) => { for elem in &tuple_expr.elements { if let Expr::Index(idx_expr) = elem && let Expr::Ident(ident) = &idx_expr.object - && let Some(idx) = self.eval_index(&idx_expr.index) { - qubits.push((ident.name.clone(), idx)); - } + && let Some(idx) = self.eval_index(&idx_expr.index) + { + qubits.push((ident.name.clone(), idx)); + } } } Expr::BracketArray(arr) => { for elem in &arr.elements { if let Expr::Index(idx_expr) = elem && let Expr::Ident(ident) = &idx_expr.object - && let Some(idx) = self.eval_index(&idx_expr.index) { - qubits.push((ident.name.clone(), idx)); - } + && let Some(idx) = self.eval_index(&idx_expr.index) + { + qubits.push((ident.name.clone(), idx)); + } } } _ => {} @@ -1170,7 +1349,9 @@ impl PhirJsonCodegen { Ok(serde_json::Value::Number((*value as i64).into())) } Expr::FloatLit(fl) => Ok(serde_json::json!(fl.value)), - Expr::BoolLit(bl) => Ok(serde_json::Value::Number(if bl.value { 1 } else { 0 }.into())), + Expr::BoolLit(bl) => Ok(serde_json::Value::Number( + if bl.value { 1 } else { 0 }.into(), + )), Expr::Ident(ident) => Ok(serde_json::Value::String(ident.name.clone())), Expr::Binary(bin) => { let left = self.convert_expr_to_value(&bin.left)?; @@ -1209,9 +1390,10 @@ impl PhirJsonCodegen { } Expr::Index(idx) => { if let Expr::Ident(ident) = &idx.object - && let Some(i) = self.eval_index(&idx.index) { - return Ok(serde_json::json!([ident.name, i])); - } + && let Some(i) = self.eval_index(&idx.index) + { + return Ok(serde_json::json!([ident.name, i])); + } Err(PhirJsonError::UnsupportedExpression) } _ => Err(PhirJsonError::UnsupportedExpression), diff --git a/exp/zlup/src/codegen/qasm.rs b/exp/zlup/src/codegen/qasm.rs index d4cc2ac5e..c42f6360d 100644 --- a/exp/zlup/src/codegen/qasm.rs +++ b/exp/zlup/src/codegen/qasm.rs @@ -38,7 +38,9 @@ pub enum QasmError { #[error("undefined allocator '{name}'")] UndefinedAllocator { name: String }, - #[error("qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}")] + #[error( + "qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}" + )] QubitIndexOutOfBounds { allocator: String, index: usize, @@ -288,11 +290,12 @@ impl QasmCodegen { let mut measurement_count = 0; for decl in &program.declarations { if let TopLevelDecl::Fn(fn_decl) = decl - && fn_decl.name == "main" { - let (body, mcount) = self.convert_block(&fn_decl.body)?; - body_output = body; - measurement_count = mcount; - } + && fn_decl.name == "main" + { + let (body, mcount) = self.convert_block(&fn_decl.body)?; + body_output = body; + measurement_count = mcount; + } } // Write classical register if measurements exist @@ -461,8 +464,15 @@ impl QasmCodegen { // Add barrier to mark tick boundary (optional but useful) if !tick_stmt.body.is_empty() { - writeln!(output, "// tick{}", - tick_stmt.label.as_ref().map(|l| format!(" {}", l)).unwrap_or_default())?; + writeln!( + output, + "// tick{}", + tick_stmt + .label + .as_ref() + .map(|l| format!(" {}", l)) + .unwrap_or_default() + )?; } for inner_stmt in &tick_stmt.body { @@ -506,7 +516,10 @@ impl QasmCodegen { } } - fn convert_expr_stmt(&mut self, expr_stmt: &crate::ast::ExprStmt) -> QasmResult<(String, usize)> { + fn convert_expr_stmt( + &mut self, + expr_stmt: &crate::ast::ExprStmt, + ) -> QasmResult<(String, usize)> { match &expr_stmt.expr { Expr::Call(call) => self.convert_call(call), Expr::Gate(gate) => self.convert_gate_expr(gate), @@ -529,7 +542,7 @@ impl QasmCodegen { GateKind::Tdg => "tdg", GateKind::SX => "sx", GateKind::SY => "sy", - GateKind::SZ => "s", // QASM uses "s" for S gate + GateKind::SZ => "s", // QASM uses "s" for S gate GateKind::SXdg => "sxdg", GateKind::SYdg => "sydg", GateKind::SZdg => "sdg", // QASM uses "sdg" for S-dagger @@ -595,7 +608,10 @@ impl QasmCodegen { Ok((output, 0)) } - fn convert_measure_expr(&mut self, measure: &crate::ast::MeasureExpr) -> QasmResult<(String, usize)> { + fn convert_measure_expr( + &mut self, + measure: &crate::ast::MeasureExpr, + ) -> QasmResult<(String, usize)> { use std::fmt::Write; let mut output = String::new(); @@ -681,9 +697,10 @@ impl QasmCodegen { // Address-of array: h(&[q[0], q[1]]) if let Expr::Unary(unary) = &qubit_args[0] && let crate::ast::UnaryOp::AddrOf = unary.op - && let Expr::BracketArray(arr) = &unary.operand { - return self.convert_batch_gate(&gate_info, &arr.elements, ¶ms); - } + && let Expr::BracketArray(arr) = &unary.operand + { + return self.convert_batch_gate(&gate_info, &arr.elements, ¶ms); + } } // Standard gate call @@ -792,16 +809,17 @@ impl QasmCodegen { // Address-of array: &[q[0], q[1], ...] Expr::Unary(unary) => { if let crate::ast::UnaryOp::AddrOf = unary.op - && let Expr::BracketArray(arr) = &unary.operand { - for elem in &arr.elements { - let (alloc, idx) = self.extract_qubit_ref(elem)?; - let global_idx = self.get_global_qubit_index(&alloc, idx)?; - let creg_idx = self.creg_counter; - self.creg_counter += 1; - writeln!(output, "measure q[{}] -> c[{}];", global_idx, creg_idx)?; - measurement_count += 1; - } + && let Expr::BracketArray(arr) = &unary.operand + { + for elem in &arr.elements { + let (alloc, idx) = self.extract_qubit_ref(elem)?; + let global_idx = self.get_global_qubit_index(&alloc, idx)?; + let creg_idx = self.creg_counter; + self.creg_counter += 1; + writeln!(output, "measure q[{}] -> c[{}];", global_idx, creg_idx)?; + measurement_count += 1; } + } } _ => return Err(QasmError::UnsupportedExpression), } @@ -832,15 +850,16 @@ impl QasmCodegen { let mut first = true; for arg in &call.args { if let Expr::Ident(ident) = arg - && let Some(alloc) = self.allocators.get(&ident.name) { - for i in 0..alloc.capacity { - if !first { - write!(output, ", ")?; - } - first = false; - write!(output, "q[{}]", alloc.offset + i)?; + && let Some(alloc) = self.allocators.get(&ident.name) + { + for i in 0..alloc.capacity { + if !first { + write!(output, ", ")?; } + first = false; + write!(output, "q[{}]", alloc.offset + i)?; } + } } writeln!(output, ";")?; } @@ -900,11 +919,13 @@ impl QasmCodegen { fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { if let Expr::Call(call) = expr && let Expr::Field(field) = &call.callee - && field.field == "child" && call.args.len() == 1 { - let parent = self.extract_identifier(&field.object).ok()?; - let size = self.extract_integer(&call.args[0]).ok()?; - return Some((parent, size)); - } + && field.field == "child" + && call.args.len() == 1 + { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } None } @@ -935,12 +956,12 @@ impl QasmCodegen { } fn get_global_qubit_index(&self, allocator: &str, index: usize) -> QasmResult { - let alloc = self - .allocators - .get(allocator) - .ok_or_else(|| QasmError::UndefinedAllocator { - name: allocator.to_string(), - })?; + let alloc = + self.allocators + .get(allocator) + .ok_or_else(|| QasmError::UndefinedAllocator { + name: allocator.to_string(), + })?; if index >= alloc.capacity { return Err(QasmError::QubitIndexOutOfBounds { diff --git a/exp/zlup/src/codegen/slr.rs b/exp/zlup/src/codegen/slr.rs index 13acefdb8..88ae28bd4 100644 --- a/exp/zlup/src/codegen/slr.rs +++ b/exp/zlup/src/codegen/slr.rs @@ -48,7 +48,9 @@ pub enum SlrError { #[error("undefined allocator '{name}'")] UndefinedAllocator { name: String }, - #[error("qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}")] + #[error( + "qubit index {index} out of bounds for allocator '{allocator}' with capacity {capacity}" + )] QubitIndexOutOfBounds { allocator: String, index: usize, @@ -233,7 +235,10 @@ impl SlrGateOp { self } - pub fn with_attrs(mut self, attrs: std::collections::BTreeMap) -> Self { + pub fn with_attrs( + mut self, + attrs: std::collections::BTreeMap, + ) -> Self { self.attrs = attrs; self } @@ -289,7 +294,11 @@ impl SlrMeasureOp { } } - pub fn with_result_type(targets: Vec, results: Vec, result_type: &str) -> Self { + pub fn with_result_type( + targets: Vec, + results: Vec, + result_type: &str, + ) -> Self { Self { node_type: "MeasureOp", targets, @@ -451,7 +460,10 @@ impl SlrTickStmt { self } - pub fn with_attrs(mut self, attrs: std::collections::BTreeMap) -> Self { + pub fn with_attrs( + mut self, + attrs: std::collections::BTreeMap, + ) -> Self { self.attrs = attrs; self } @@ -639,7 +651,10 @@ pub enum SlrCType { Angle { bits: u8 }, /// Pointer type #[serde(rename = "pointer")] - Pointer { element: Box, is_const: bool }, + Pointer { + element: Box, + is_const: bool, + }, /// Void type (for return) #[serde(rename = "void")] Void, @@ -894,18 +909,39 @@ fn gate_kind_arity(kind: &crate::ast::GateKind) -> usize { use crate::ast::GateKind; match kind { // Single-qubit gates - GateKind::X | GateKind::Y | GateKind::Z | GateKind::H - | GateKind::T | GateKind::Tdg - | GateKind::SX | GateKind::SY | GateKind::SZ - | GateKind::SXdg | GateKind::SYdg | GateKind::SZdg - | GateKind::RX | GateKind::RY | GateKind::RZ - | GateKind::F | GateKind::Fdg | GateKind::F4 | GateKind::F4dg + GateKind::X + | GateKind::Y + | GateKind::Z + | GateKind::H + | GateKind::T + | GateKind::Tdg + | GateKind::SX + | GateKind::SY + | GateKind::SZ + | GateKind::SXdg + | GateKind::SYdg + | GateKind::SZdg + | GateKind::RX + | GateKind::RY + | GateKind::RZ + | GateKind::F + | GateKind::Fdg + | GateKind::F4 + | GateKind::F4dg | GateKind::PZ => 1, // Two-qubit gates - GateKind::CX | GateKind::CY | GateKind::CZ | GateKind::CH - | GateKind::SWAP | GateKind::ISWAP - | GateKind::SXX | GateKind::SYY | GateKind::SZZ - | GateKind::SXXdg | GateKind::SYYdg | GateKind::SZZdg + GateKind::CX + | GateKind::CY + | GateKind::CZ + | GateKind::CH + | GateKind::SWAP + | GateKind::ISWAP + | GateKind::SXX + | GateKind::SYY + | GateKind::SZZ + | GateKind::SXXdg + | GateKind::SYYdg + | GateKind::SZZdg | GateKind::RZZ => 2, // Three-qubit gates GateKind::CCX => 3, @@ -1339,10 +1375,11 @@ impl SlrCodegen { // Second pass: convert statements for decl in &program.declarations { if let TopLevelDecl::Fn(fn_decl) = decl - && fn_decl.name == "main" { - let body = self.convert_block(&fn_decl.body)?; - slr_program.body = body; - } + && fn_decl.name == "main" + { + let body = self.convert_block(&fn_decl.body)?; + slr_program.body = body; + } } Ok(slr_program) @@ -1528,7 +1565,10 @@ impl SlrCodegen { } /// Convert new measure syntax: mz(T) targets - fn convert_measure_expr(&mut self, measure: &crate::ast::MeasureExpr) -> SlrResult> { + fn convert_measure_expr( + &mut self, + measure: &crate::ast::MeasureExpr, + ) -> SlrResult> { let mut results = Vec::new(); let mut targets = Vec::new(); @@ -1583,10 +1623,17 @@ impl SlrCodegen { } } - Ok(vec![SlrStatement::Measure(SlrMeasureOp::with_result_type(targets, results, &result_type))]) + Ok(vec![SlrStatement::Measure(SlrMeasureOp::with_result_type( + targets, + results, + &result_type, + ))]) } - fn convert_expr_stmt(&mut self, expr_stmt: &crate::ast::ExprStmt) -> SlrResult> { + fn convert_expr_stmt( + &mut self, + expr_stmt: &crate::ast::ExprStmt, + ) -> SlrResult> { // Convert attributes for gates let attrs = self.convert_attributes(&expr_stmt.attrs); @@ -1602,7 +1649,10 @@ impl SlrCodegen { } /// Convert builtin expressions like @swap. - fn convert_builtin_expr(&self, builtin: &crate::ast::BuiltinExpr) -> SlrResult> { + fn convert_builtin_expr( + &self, + builtin: &crate::ast::BuiltinExpr, + ) -> SlrResult> { match builtin.name.as_str() { "swap" => { // @swap(&a, &b) - emit as SwapOp @@ -1644,7 +1694,10 @@ impl SlrCodegen { /// Result sends are never elided - they represent the actual program output. fn convert_result_expr(&self, result: &crate::ast::ResultExpr) -> SlrResult> { let value = self.convert_expression(&result.value)?; - Ok(vec![SlrStatement::Send(SlrSendStmt::result(&result.tag, value))]) + Ok(vec![SlrStatement::Send(SlrSendStmt::result( + &result.tag, + value, + ))]) } /// Convert a channel expression to SLR statements. @@ -1654,7 +1707,10 @@ impl SlrCodegen { /// - @emit.sim.*: Simulator control with barrier/elide modes /// - @emit.hw.*: Hardware messages (elided for simulator) /// - Custom channels: Configurable behavior - fn convert_channel_expr(&mut self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + fn convert_channel_expr( + &mut self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { match channel.channel.as_str() { "log" => self.convert_log_channel(channel), "sim" => self.convert_sim_channel(channel), @@ -1664,7 +1720,10 @@ impl SlrCodegen { } /// Convert @emit.log.* channel expressions. - fn convert_log_channel(&self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + fn convert_log_channel( + &self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { // Map command to log level let (level, numeric_level, level_consumes_arg) = match channel.command.as_str() { "trace" => (SlrLogLevel::Standard("trace".to_string()), 0u32, false), @@ -1675,8 +1734,10 @@ impl SlrCodegen { "at" => { // @emit.log.at(level, message) or @emit.log.at(level, ns, message) - first arg is level if let Some(level_arg) = channel.args.first() { - if let Ok(SlrExpression::Literal(SlrLiteralExpr { value: SlrLiteralValue::Int(n), .. })) = - self.convert_expression(level_arg.value()) + if let Ok(SlrExpression::Literal(SlrLiteralExpr { + value: SlrLiteralValue::Int(n), + .. + })) = self.convert_expression(level_arg.value()) { (SlrLogLevel::Numeric(n), n as u32, true) } else { @@ -1694,14 +1755,15 @@ impl SlrCodegen { return Ok(vec![]); } - // Start index after the level argument (if "at" command) let start_idx = if level_consumes_arg { 1 } else { 0 }; // Determine namespace and message // If first positional is string literal AND there's another arg, first is namespace let (sub_namespace, message) = { - let remaining: Vec<_> = channel.args.iter() + let remaining: Vec<_> = channel + .args + .iter() .skip(start_idx) .filter(|arg| arg.name() != Some("data")) .collect(); @@ -1746,12 +1808,17 @@ impl SlrCodegen { } /// Convert @emit.sim.* channel expressions. - fn convert_sim_channel(&mut self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + fn convert_sim_channel( + &mut self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { match self.sim_mode { SimMode::Elide => Ok(vec![]), SimMode::Barrier => { let scope_allocators: Vec = self.allocators.keys().cloned().collect(); - Ok(vec![SlrStatement::Barrier(SlrBarrierOp::new(scope_allocators))]) + Ok(vec![SlrStatement::Barrier(SlrBarrierOp::new( + scope_allocators, + ))]) } SimMode::Emit => { // Handle @emit.sim.send(key, value) specially - key comes from first arg @@ -1765,7 +1832,9 @@ impl SlrCodegen { return Err(SlrError::UnsupportedExpression); }; let value = self.convert_expression(channel.args[1].value())?; - return Ok(vec![SlrStatement::Send(SlrSendStmt::sim_with_value(&key, value))]); + return Ok(vec![SlrStatement::Send(SlrSendStmt::sim_with_value( + &key, value, + ))]); } else if channel.args.len() == 1 { // Just a key, no value let key_expr = channel.args[0].value(); @@ -1783,7 +1852,9 @@ impl SlrCodegen { let key = channel.command.clone(); if let Some(arg) = channel.args.first() { let value = self.convert_expression(arg.value())?; - Ok(vec![SlrStatement::Send(SlrSendStmt::sim_with_value(&key, value))]) + Ok(vec![SlrStatement::Send(SlrSendStmt::sim_with_value( + &key, value, + ))]) } else { Ok(vec![SlrStatement::Send(SlrSendStmt::sim(&key))]) } @@ -1792,7 +1863,10 @@ impl SlrCodegen { } /// Convert @emit.hw.* channel expressions. - fn convert_hw_channel(&mut self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + fn convert_hw_channel( + &mut self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { // hw channel is opposite of sim: active for hardware, elided for simulator match self.sim_mode { SimMode::Emit => { @@ -1804,7 +1878,9 @@ impl SlrCodegen { let key = channel.command.clone(); if let Some(arg) = channel.args.first() { let value = self.convert_expression(arg.value())?; - Ok(vec![SlrStatement::Send(SlrSendStmt::new("hw", &key).with_value(value))]) + Ok(vec![SlrStatement::Send( + SlrSendStmt::new("hw", &key).with_value(value), + )]) } else { Ok(vec![SlrStatement::Send(SlrSendStmt::new("hw", &key))]) } @@ -1813,15 +1889,23 @@ impl SlrCodegen { } /// Convert custom channel expressions. - fn convert_custom_channel(&mut self, channel: &crate::ast::ChannelExpr) -> SlrResult> { + fn convert_custom_channel( + &mut self, + channel: &crate::ast::ChannelExpr, + ) -> SlrResult> { // Custom channels emit as SendStmt with channel name // They act as barriers (sticky) let key = channel.command.clone(); if let Some(arg) = channel.args.first() { let value = self.convert_expression(arg.value())?; - Ok(vec![SlrStatement::Send(SlrSendStmt::new(&channel.channel, &key).with_value(value))]) + Ok(vec![SlrStatement::Send( + SlrSendStmt::new(&channel.channel, &key).with_value(value), + )]) } else { - Ok(vec![SlrStatement::Send(SlrSendStmt::new(&channel.channel, &key))]) + Ok(vec![SlrStatement::Send(SlrSendStmt::new( + &channel.channel, + &key, + ))]) } } @@ -2008,9 +2092,7 @@ impl SlrCodegen { ) -> SlrResult> { match &gate.target { // pz q - prepare all qubits in allocator - Expr::Ident(ident) => { - Ok(vec![SlrStatement::Prepare(SlrPrepareOp::all(&ident.name))]) - } + Expr::Ident(ident) => Ok(vec![SlrStatement::Prepare(SlrPrepareOp::all(&ident.name))]), // pz q[0] - prepare single qubit Expr::Index(_) => { let slot_ref = self.extract_slot_ref(&gate.target)?; @@ -2021,7 +2103,8 @@ impl SlrCodegen { } // pz {q[0], q[1]} - prepare batch (set) Expr::Set(set) => { - let mut slots_by_alloc: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut slots_by_alloc: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); for elem in &set.elements { let slot_ref = self.extract_slot_ref(elem)?; slots_by_alloc @@ -2037,7 +2120,8 @@ impl SlrCodegen { } // pz [q[0], q[1]] - prepare batch (array) Expr::BracketArray(arr) => { - let mut slots_by_alloc: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut slots_by_alloc: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); for elem in &arr.elements { let slot_ref = self.extract_slot_ref(elem)?; slots_by_alloc @@ -2088,7 +2172,10 @@ impl SlrCodegen { } /// Convert AST attributes to SLR attributes. - fn convert_attributes(&self, attrs: &[crate::ast::Attribute]) -> std::collections::BTreeMap { + fn convert_attributes( + &self, + attrs: &[crate::ast::Attribute], + ) -> std::collections::BTreeMap { let mut result = std::collections::BTreeMap::new(); for attr in attrs { let value = match &attr.value { @@ -2133,9 +2220,7 @@ impl SlrCodegen { .map(|arg| self.convert_expression(arg)) .collect(); return Ok(vec![SlrStatement::ExternCall(SlrExternCall::new( - name, - args?, - None, // Result variable set later during assignment handling + name, args?, None, // Result variable set later during assignment handling ))]); } @@ -2163,10 +2248,20 @@ impl SlrCodegen { // Check if qubit argument is a Set or BracketArray (batch operation) if !qubit_args.is_empty() { if let Expr::Set(set_expr) = &qubit_args[0] { - return self.convert_batch_gate_with_attrs(gate_info, &set_expr.elements, params, attrs); + return self.convert_batch_gate_with_attrs( + gate_info, + &set_expr.elements, + params, + attrs, + ); } if let Expr::BracketArray(arr_expr) = &qubit_args[0] { - return self.convert_batch_gate_with_attrs(gate_info, &arr_expr.elements, params, attrs); + return self.convert_batch_gate_with_attrs( + gate_info, + &arr_expr.elements, + params, + attrs, + ); } } @@ -2205,7 +2300,12 @@ impl SlrCodegen { elements: &[Expr], params: Vec, ) -> SlrResult> { - self.convert_batch_gate_with_attrs(gate_info, elements, params, std::collections::BTreeMap::new()) + self.convert_batch_gate_with_attrs( + gate_info, + elements, + params, + std::collections::BTreeMap::new(), + ) } /// Convert a batch gate operation with attributes into multiple SLR gates. @@ -2361,7 +2461,9 @@ impl SlrCodegen { } Ok(vec![SlrStatement::Measure(SlrMeasureOp::with_result_type( - targets, results, &result_type, + targets, + results, + &result_type, ))]) } @@ -2376,7 +2478,10 @@ impl SlrCodegen { } /// Extract measurement result type from a TypeExpr (for new mz(T) target syntax). - fn extract_measurement_result_type_from_type_expr(&self, type_expr: &crate::ast::TypeExpr) -> String { + fn extract_measurement_result_type_from_type_expr( + &self, + type_expr: &crate::ast::TypeExpr, + ) -> String { use crate::ast::{PrimitiveType, TypeExpr}; match type_expr { TypeExpr::Primitive(prim) => match prim { @@ -2484,7 +2589,10 @@ impl SlrCodegen { )) } - fn convert_prepare_op(&mut self, prepare_op: &crate::ast::PrepareOp) -> SlrResult { + fn convert_prepare_op( + &mut self, + prepare_op: &crate::ast::PrepareOp, + ) -> SlrResult { if let Some(ref slots) = prepare_op.slots { let slot_indices: Vec = slots.iter().map(|&s| s as usize).collect(); Ok(SlrStatement::Prepare(SlrPrepareOp::slots( @@ -2492,11 +2600,16 @@ impl SlrCodegen { slot_indices, ))) } else { - Ok(SlrStatement::Prepare(SlrPrepareOp::all(&prepare_op.allocator))) + Ok(SlrStatement::Prepare(SlrPrepareOp::all( + &prepare_op.allocator, + ))) } } - fn convert_measure_op(&mut self, measure_op: &crate::ast::MeasureOp) -> SlrResult { + fn convert_measure_op( + &mut self, + measure_op: &crate::ast::MeasureOp, + ) -> SlrResult { let mut targets = Vec::new(); let mut results = Vec::new(); @@ -2524,7 +2637,10 @@ impl SlrCodegen { Ok(SlrStatement::Measure(SlrMeasureOp::new(targets, results))) } - fn convert_barrier_op(&mut self, barrier_op: &crate::ast::BarrierOp) -> SlrResult { + fn convert_barrier_op( + &mut self, + barrier_op: &crate::ast::BarrierOp, + ) -> SlrResult { Ok(SlrStatement::Barrier(SlrBarrierOp::new( barrier_op.allocators.clone(), ))) @@ -2584,8 +2700,12 @@ impl SlrCodegen { Some(crate::ast::AttributeValue::Bool(b)) => SlrAttributeValue::Bool(*b), Some(crate::ast::AttributeValue::Int(i)) => SlrAttributeValue::Int(*i), Some(crate::ast::AttributeValue::Float(f)) => SlrAttributeValue::Float(*f), - Some(crate::ast::AttributeValue::String(s)) => SlrAttributeValue::String(s.clone()), - Some(crate::ast::AttributeValue::Ident(s)) => SlrAttributeValue::String(s.clone()), + Some(crate::ast::AttributeValue::String(s)) => { + SlrAttributeValue::String(s.clone()) + } + Some(crate::ast::AttributeValue::Ident(s)) => { + SlrAttributeValue::String(s.clone()) + } None => SlrAttributeValue::Bool(true), // Flag attributes default to true }; attrs.insert(attr.name.clone(), value); @@ -2625,23 +2745,28 @@ impl SlrCodegen { .unwrap_or_else(|| "_".to_string()); let body = self.convert_block(&for_stmt.body)?; - Ok(SlrStatement::For(SlrForStmt::new(variable, start, end, body))) + Ok(SlrStatement::For(SlrForStmt::new( + variable, start, end, body, + ))) } fn try_extract_repeat_count(&self, for_stmt: &ForStmt) -> Option { // Check for patterns like: for _ in 0..10 { } if let ForRange::Range { start, end } = &for_stmt.range && let Expr::IntLit(start_lit) = start - && start_lit.value == 0 - && let Expr::IntLit(end_lit) = end { - return Some(end_lit.value as usize); - } + && start_lit.value == 0 + && let Expr::IntLit(end_lit) = end + { + return Some(end_lit.value as usize); + } None } fn convert_expression(&self, expr: &Expr) -> SlrResult { match expr { - Expr::IntLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::int(lit.value as i64))), + Expr::IntLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::int( + lit.value as i64, + ))), Expr::FloatLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::float(lit.value))), Expr::BoolLit(lit) => Ok(SlrExpression::Literal(SlrLiteralExpr::bool(lit.value))), Expr::Ident(ident) => { @@ -2686,14 +2811,17 @@ impl SlrCodegen { // For radians, try to recognize common pi-based patterns for exact conversion if let AngleUnit::Rad = angle.unit - && let Some(exact_turns) = recognize_exact_radian_pattern(&angle.value) { - return Ok(SlrExpression::Literal(SlrLiteralExpr::angle(exact_turns))); - } + && let Some(exact_turns) = recognize_exact_radian_pattern(&angle.value) + { + return Ok(SlrExpression::Literal(SlrLiteralExpr::angle(exact_turns))); + } // Fall back to floating-point evaluation use crate::comptime::ComptimeEvaluator; let mut eval = ComptimeEvaluator::new(); - let value = eval.eval_expr(&angle.value).map_err(|_| SlrError::InvalidAngle)?; + let value = eval + .eval_expr(&angle.value) + .map_err(|_| SlrError::InvalidAngle)?; let numeric = match value { crate::comptime::ComptimeValue::Float(f) => f, crate::comptime::ComptimeValue::Int(i) => i as f64, @@ -2749,11 +2877,13 @@ impl SlrCodegen { fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { if let Expr::Call(call) = expr && let Expr::Field(field) = &call.callee - && field.field == "child" && call.args.len() == 1 { - let parent = self.extract_identifier(&field.object).ok()?; - let size = self.extract_integer(&call.args[0]).ok()?; - return Some((parent, size)); - } + && field.field == "child" + && call.args.len() == 1 + { + let parent = self.extract_identifier(&field.object).ok()?; + let size = self.extract_integer(&call.args[0]).ok()?; + return Some((parent, size)); + } None } @@ -2784,12 +2914,12 @@ impl SlrCodegen { let idx = self.extract_integer(&index.index)?; // Validate allocator exists - let alloc = self - .allocators - .get(&allocator) - .ok_or_else(|| SlrError::UndefinedAllocator { - name: allocator.clone(), - })?; + let alloc = + self.allocators + .get(&allocator) + .ok_or_else(|| SlrError::UndefinedAllocator { + name: allocator.clone(), + })?; // Validate index bounds if idx >= alloc.capacity { @@ -2879,19 +3009,52 @@ impl SlrCodegen { TypeExpr::Named(path) => { let name = path.segments.join("::"); match name.as_str() { - "u8" => SlrCType::Int { bits: 8, signed: false }, - "u16" => SlrCType::Int { bits: 16, signed: false }, - "u32" => SlrCType::Int { bits: 32, signed: false }, - "u64" => SlrCType::Int { bits: 64, signed: false }, - "usize" => SlrCType::Int { bits: 64, signed: false }, // Assume 64-bit - "i8" => SlrCType::Int { bits: 8, signed: true }, - "i16" => SlrCType::Int { bits: 16, signed: true }, - "i32" => SlrCType::Int { bits: 32, signed: true }, - "i64" => SlrCType::Int { bits: 64, signed: true }, - "isize" => SlrCType::Int { bits: 64, signed: true }, + "u8" => SlrCType::Int { + bits: 8, + signed: false, + }, + "u16" => SlrCType::Int { + bits: 16, + signed: false, + }, + "u32" => SlrCType::Int { + bits: 32, + signed: false, + }, + "u64" => SlrCType::Int { + bits: 64, + signed: false, + }, + "usize" => SlrCType::Int { + bits: 64, + signed: false, + }, // Assume 64-bit + "i8" => SlrCType::Int { + bits: 8, + signed: true, + }, + "i16" => SlrCType::Int { + bits: 16, + signed: true, + }, + "i32" => SlrCType::Int { + bits: 32, + signed: true, + }, + "i64" => SlrCType::Int { + bits: 64, + signed: true, + }, + "isize" => SlrCType::Int { + bits: 64, + signed: true, + }, "f32" => SlrCType::Float { bits: 32 }, "f64" => SlrCType::Float { bits: 64 }, - "bool" => SlrCType::Int { bits: 8, signed: false }, + "bool" => SlrCType::Int { + bits: 8, + signed: false, + }, "unit" | "void" => SlrCType::Void, _ => SlrCType::Opaque { name }, } @@ -2949,32 +3112,35 @@ fn recognize_exact_radian_pattern(expr: &Expr) -> Option { if let Expr::Binary(binary) = expr { if binary.op == crate::ast::BinaryOp::Div && is_pi_reference(&binary.left) - && let Some(n) = extract_integer_value(&binary.right) - && n > 0 { - // pi / n radians = 1 / (2*n) turns - return Some(1.0 / (2.0 * n as f64)); - } + && let Some(n) = extract_integer_value(&binary.right) + && n > 0 + { + // pi / n radians = 1 / (2*n) turns + return Some(1.0 / (2.0 * n as f64)); + } // Pattern: N * pi / M or (N * pi) / M if binary.op == crate::ast::BinaryOp::Div - && let Some((num, denom)) = extract_pi_fraction(&binary.left, &binary.right) { - // (num * pi) / denom radians = num / (2 * denom) turns - return Some(num as f64 / (2.0 * denom as f64)); - } + && let Some((num, denom)) = extract_pi_fraction(&binary.left, &binary.right) + { + // (num * pi) / denom radians = num / (2 * denom) turns + return Some(num as f64 / (2.0 * denom as f64)); + } // Pattern: pi * N / M (reordered) if binary.op == crate::ast::BinaryOp::Mul { // Check for pi * (N / M) - less common but possible if is_pi_reference(&binary.left) && let Expr::Binary(inner) = &binary.right - && inner.op == crate::ast::BinaryOp::Div - && let (Some(num), Some(denom)) = ( - extract_integer_value(&inner.left), - extract_integer_value(&inner.right), - ) - && denom > 0 { - return Some(num as f64 / (2.0 * denom as f64)); - } + && inner.op == crate::ast::BinaryOp::Div + && let (Some(num), Some(denom)) = ( + extract_integer_value(&inner.left), + extract_integer_value(&inner.right), + ) + && denom > 0 + { + return Some(num as f64 / (2.0 * denom as f64)); + } } } @@ -3011,22 +3177,25 @@ fn extract_integer_value(expr: &Expr) -> Option { fn extract_pi_fraction(left: &Expr, right: &Expr) -> Option<(i64, i64)> { // Check if left is N * pi if let Expr::Binary(mul) = left - && mul.op == crate::ast::BinaryOp::Mul { - // N * pi - if let Some(n) = extract_integer_value(&mul.left) - && is_pi_reference(&mul.right) - && let Some(m) = extract_integer_value(right) - && m > 0 { - return Some((n, m)); - } - // pi * N - if is_pi_reference(&mul.left) - && let Some(n) = extract_integer_value(&mul.right) - && let Some(m) = extract_integer_value(right) - && m > 0 { - return Some((n, m)); - } + && mul.op == crate::ast::BinaryOp::Mul + { + // N * pi + if let Some(n) = extract_integer_value(&mul.left) + && is_pi_reference(&mul.right) + && let Some(m) = extract_integer_value(right) + && m > 0 + { + return Some((n, m)); + } + // pi * N + if is_pi_reference(&mul.left) + && let Some(n) = extract_integer_value(&mul.right) + && let Some(m) = extract_integer_value(right) + && m > 0 + { + return Some((n, m)); } + } None } @@ -3072,7 +3241,7 @@ mod tests { assert_eq!(slr.body.len(), 1); if let SlrStatement::Gate(gate) = &slr.body[0] { - assert_eq!(gate.gate, "H"); // Output remains uppercase + assert_eq!(gate.gate, "H"); // Output remains uppercase assert_eq!(gate.targets.len(), 1); assert_eq!(gate.targets[0].allocator, "q"); assert_eq!(gate.targets[0].index, 0); @@ -3281,7 +3450,10 @@ mod tests { "#; let result = compile_to_slr(source); - assert!(matches!(result, Err(SlrError::QubitIndexOutOfBounds { .. }))); + assert!(matches!( + result, + Err(SlrError::QubitIndexOutOfBounds { .. }) + )); } #[test] @@ -3425,8 +3597,13 @@ mod tests { if let SlrStatement::Tick(tick) = &slr.body[0] { assert_eq!(tick.label.as_ref().unwrap(), "syndrome_round"); assert_eq!(tick.attrs.len(), 2); - assert!(matches!(tick.attrs.get("round"), Some(SlrAttributeValue::Int(0)))); - assert!(matches!(tick.attrs.get("kind"), Some(SlrAttributeValue::String(s)) if s == "syndrome")); + assert!(matches!( + tick.attrs.get("round"), + Some(SlrAttributeValue::Int(0)) + )); + assert!( + matches!(tick.attrs.get("kind"), Some(SlrAttributeValue::String(s)) if s == "syndrome") + ); } else { panic!("Expected tick statement"); } @@ -3450,7 +3627,10 @@ mod tests { if let SlrStatement::Tick(tick) = &slr.body[0] { assert!(tick.label.is_none()); assert_eq!(tick.attrs.len(), 1); - assert!(matches!(tick.attrs.get("noisy"), Some(SlrAttributeValue::Bool(true)))); + assert!(matches!( + tick.attrs.get("noisy"), + Some(SlrAttributeValue::Bool(true)) + )); } else { panic!("Expected tick statement"); } @@ -3473,7 +3653,10 @@ mod tests { if let SlrStatement::Tick(tick) = &slr.body[0] { assert_eq!(tick.label.as_ref().unwrap(), "layer"); assert_eq!(tick.attrs.len(), 2); - assert!(matches!(tick.attrs.get("round"), Some(SlrAttributeValue::Int(5)))); + assert!(matches!( + tick.attrs.get("round"), + Some(SlrAttributeValue::Int(5)) + )); // Check float attribute if let Some(SlrAttributeValue::Float(f)) = tick.attrs.get("error_rate") { assert!((*f - 0.001).abs() < 0.0001); @@ -3526,7 +3709,9 @@ mod tests { if let SlrStatement::Gate(gate) = &slr.body[0] { assert_eq!(gate.gate, "CX"); assert_eq!(gate.attrs.len(), 1); - assert!(matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "X")); + assert!( + matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "X") + ); } else { panic!("Expected gate statement"); } @@ -3547,8 +3732,13 @@ mod tests { if let SlrStatement::Gate(gate) = &slr.body[0] { assert_eq!(gate.gate, "H"); assert_eq!(gate.attrs.len(), 2); - assert!(matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "Z")); - assert!(matches!(gate.attrs.get("layer"), Some(SlrAttributeValue::Int(1)))); + assert!( + matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "Z") + ); + assert!(matches!( + gate.attrs.get("layer"), + Some(SlrAttributeValue::Int(1)) + )); } else { panic!("Expected gate statement"); } @@ -3572,7 +3762,9 @@ mod tests { if let SlrStatement::Gate(gate) = stmt { assert_eq!(gate.gate, "H"); assert_eq!(gate.attrs.len(), 1); - assert!(matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "X")); + assert!( + matches!(gate.attrs.get("syndrome"), Some(SlrAttributeValue::String(s)) if s == "X") + ); } else { panic!("Expected gate statement"); } diff --git a/exp/zlup/src/comptime.rs b/exp/zlup/src/comptime.rs index 34cea802d..10a9fde7f 100644 --- a/exp/zlup/src/comptime.rs +++ b/exp/zlup/src/comptime.rs @@ -38,7 +38,9 @@ use std::collections::BTreeMap; use std::fmt; -use crate::ast::{BinaryOp, Expr, FnDecl, ForRange, FStringPart, PrimitiveType, Stmt, TypeExpr, UnaryOp}; +use crate::ast::{ + BinaryOp, Expr, FStringPart, FnDecl, ForRange, PrimitiveType, Stmt, TypeExpr, UnaryOp, +}; use crate::rational::Rational; use crate::semantic::{BitWidth, SemanticError, Type}; @@ -142,9 +144,7 @@ pub enum ComptimeValue { fields: BTreeMap, }, /// Slice reference (ptr + len) - Slice { - data: Vec, - }, + Slice { data: Vec }, /// String literal String(String), /// A comptime function (for generic type constructors) @@ -164,9 +164,16 @@ impl PartialEq for ComptimeValue { (ComptimeValue::Undefined, ComptimeValue::Undefined) => true, (ComptimeValue::Unit, ComptimeValue::Unit) => true, (ComptimeValue::Array(a), ComptimeValue::Array(b)) => a == b, - (ComptimeValue::Struct { name: n1, fields: f1 }, ComptimeValue::Struct { name: n2, fields: f2 }) => { - n1 == n2 && f1 == f2 - } + ( + ComptimeValue::Struct { + name: n1, + fields: f1, + }, + ComptimeValue::Struct { + name: n2, + fields: f2, + }, + ) => n1 == n2 && f1 == f2, (ComptimeValue::Slice { data: d1 }, ComptimeValue::Slice { data: d2 }) => d1 == d2, (ComptimeValue::String(a), ComptimeValue::String(b)) => a == b, (ComptimeValue::Function(_), ComptimeValue::Function(_)) => false, // Functions not comparable @@ -227,8 +234,12 @@ impl ComptimeValue { /// Get the type of this comptime value. pub fn get_type(&self) -> Type { match self { - ComptimeValue::Int(_) => Type::IInt { bits: BitWidth::BITS_64 }, - ComptimeValue::Uint(_) => Type::UInt { bits: BitWidth::BITS_64 }, + ComptimeValue::Int(_) => Type::IInt { + bits: BitWidth::BITS_64, + }, + ComptimeValue::Uint(_) => Type::UInt { + bits: BitWidth::BITS_64, + }, ComptimeValue::Float(_) => Type::F64, ComptimeValue::Rational(_) => Type::F64, // Rationals coerce to f64 when needed ComptimeValue::Bool(_) => Type::Bool, @@ -262,7 +273,9 @@ impl ComptimeValue { } } ComptimeValue::String(_) => Type::Slice { - element: Box::new(Type::UInt { bits: BitWidth::BITS_8 }), + element: Box::new(Type::UInt { + bits: BitWidth::BITS_8, + }), }, ComptimeValue::Function(_) => Type::Type, // Comptime functions return types } @@ -484,7 +497,9 @@ impl ComptimeEvaluator { ComptimeValue::Struct { name, fields } => { let field_strs: Vec<_> = fields .iter() - .map(|(k, v)| format!("{}:{}", k, Self::serialize_args_for_cache(&[v.clone()]))) + .map(|(k, v)| { + format!("{}:{}", k, Self::serialize_args_for_cache(&[v.clone()])) + }) .collect(); format!("st{}[{}]", name, field_strs.join(";")) } @@ -506,18 +521,28 @@ impl ComptimeEvaluator { let field_strs: Vec<_> = fields .iter() .map(|(field_name, field_ty)| { - format!("{}:{}", field_name, Self::serialize_type_for_cache(field_ty)) + format!( + "{}:{}", + field_name, + Self::serialize_type_for_cache(field_ty) + ) }) .collect(); format!("struct{}[{}]", name, field_strs.join(";")) } Type::Array { element, size } => { - format!("[{}]{}", size.unwrap_or(0), Self::serialize_type_for_cache(element)) + format!( + "[{}]{}", + size.unwrap_or(0), + Self::serialize_type_for_cache(element) + ) } Type::Slice { element } => { format!("[]{}", Self::serialize_type_for_cache(element)) } - Type::Pointer { pointee, is_const, .. } => { + Type::Pointer { + pointee, is_const, .. + } => { let prefix = if *is_const { "*const" } else { "*" }; format!("{}{}", prefix, Self::serialize_type_for_cache(pointee)) } @@ -594,14 +619,16 @@ impl ComptimeEvaluator { // Valid bit widths are 1-128 if let Some(bits_str) = name.strip_prefix('u') { if let Ok(bits) = bits_str.parse::() - && let Some(bw) = BitWidth::new(bits) { - return Some(Type::UInt { bits: bw }); - } + && let Some(bw) = BitWidth::new(bits) + { + return Some(Type::UInt { bits: bw }); + } } else if let Some(bits_str) = name.strip_prefix('i') && let Ok(bits) = bits_str.parse::() - && let Some(bw) = BitWidth::new(bits) { - return Some(Type::IInt { bits: bw }); - } + && let Some(bw) = BitWidth::new(bits) + { + return Some(Type::IInt { bits: bw }); + } None } @@ -612,10 +639,10 @@ impl ComptimeEvaluator { TypeExpr::Primitive(prim) => Ok(match prim { PrimitiveType::Bool => Type::Bool, PrimitiveType::UInt { bits } => Type::UInt { - bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64) + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64), }, PrimitiveType::IInt { bits } => Type::IInt { - bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64) + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64), }, PrimitiveType::Usize => Type::Usize, PrimitiveType::Isize => Type::Isize, @@ -666,9 +693,10 @@ impl ComptimeEvaluator { } // For single-segment names, try direct lookup if path.segments.len() == 1 - && let Some(ComptimeValue::Type(ty)) = self.context.lookup(&path.segments[0]) { - return Ok(ty.clone()); - } + && let Some(ComptimeValue::Type(ty)) = self.context.lookup(&path.segments[0]) + { + return Ok(ty.clone()); + } // Unresolved named type - return Unknown (will be resolved by semantic analyzer) Ok(Type::Unknown) } @@ -689,7 +717,10 @@ impl ComptimeEvaluator { }) } _ => Err(ComptimeError { - message: format!("type expression not yet supported at comptime: {:?}", type_expr), + message: format!( + "type expression not yet supported at comptime: {:?}", + type_expr + ), }), } } @@ -797,130 +828,200 @@ impl ComptimeEvaluator { // Arithmetic operations - fn eval_add(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_add( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { - (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { - a.checked_add(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => a + .checked_add(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in addition".to_string(), - }) - } - (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { - a.checked_add(*b).map(ComptimeValue::Uint).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => a + .checked_add(*b) + .map(ComptimeValue::Uint) + .ok_or_else(|| ComptimeError { message: "unsigned integer overflow in addition".to_string(), - }) - } - (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => { - a.checked_add(*b as i64).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => a + .checked_add(*b as i64) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in addition".to_string(), - }) - } - (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => { - (*a as i64).checked_add(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => (*a as i64) + .checked_add(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in addition".to_string(), - }) - } + }), // Rational arithmetic - (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(*a + *b)), - (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Rational(*a + Rational::from_int(*b))), - (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a) + *b)), - (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Rational(*a + Rational::from_int(*b as i64))), - (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) + *b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(*a + *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Rational(*a + Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a) + *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => { + Ok(ComptimeValue::Rational(*a + Rational::from_int(*b as i64))) + } + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) + *b)) + } // Float arithmetic - validate results are finite (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a + b), (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a + *b as f64), (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 + b), // Rational with float promotes to float - (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => Self::validate_float(a.to_f64() + b), - (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => Self::validate_float(a + b.to_f64()), + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { + Self::validate_float(a.to_f64() + b) + } + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { + Self::validate_float(a + b.to_f64()) + } _ => Err(ComptimeError { message: format!("cannot add {} and {}", left, right), }), } } - fn eval_sub(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_sub( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { - (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { - a.checked_sub(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => a + .checked_sub(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in subtraction".to_string(), - }) - } - (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { - a.checked_sub(*b).map(ComptimeValue::Uint).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => a + .checked_sub(*b) + .map(ComptimeValue::Uint) + .ok_or_else(|| ComptimeError { message: "unsigned integer underflow in subtraction".to_string(), - }) - } - (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => { - a.checked_sub(*b as i64).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => a + .checked_sub(*b as i64) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in subtraction".to_string(), - }) - } - (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => { - (*a as i64).checked_sub(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => (*a as i64) + .checked_sub(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in subtraction".to_string(), - }) - } + }), // Rational arithmetic - (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(*a - *b)), - (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Rational(*a - Rational::from_int(*b))), - (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a) - *b)), - (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Rational(*a - Rational::from_int(*b as i64))), - (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) - *b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(*a - *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Rational(*a - Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a) - *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => { + Ok(ComptimeValue::Rational(*a - Rational::from_int(*b as i64))) + } + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) - *b)) + } // Float arithmetic - validate results are finite (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a - b), (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a - *b as f64), (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 - b), // Rational with float promotes to float - (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => Self::validate_float(a.to_f64() - b), - (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => Self::validate_float(a - b.to_f64()), + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { + Self::validate_float(a.to_f64() - b) + } + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { + Self::validate_float(a - b.to_f64()) + } _ => Err(ComptimeError { message: format!("cannot subtract {} and {}", left, right), }), } } - fn eval_mul(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_mul( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { - (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { - a.checked_mul(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + (ComptimeValue::Int(a), ComptimeValue::Int(b)) => a + .checked_mul(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in multiplication".to_string(), - }) - } - (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => { - a.checked_mul(*b).map(ComptimeValue::Uint).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => a + .checked_mul(*b) + .map(ComptimeValue::Uint) + .ok_or_else(|| ComptimeError { message: "unsigned integer overflow in multiplication".to_string(), - }) - } - (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => { - a.checked_mul(*b as i64).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Int(a), ComptimeValue::Uint(b)) => a + .checked_mul(*b as i64) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in multiplication".to_string(), - }) - } - (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => { - (*a as i64).checked_mul(*b).map(ComptimeValue::Int).ok_or_else(|| ComptimeError { + }), + (ComptimeValue::Uint(a), ComptimeValue::Int(b)) => (*a as i64) + .checked_mul(*b) + .map(ComptimeValue::Int) + .ok_or_else(|| ComptimeError { message: "integer overflow in multiplication".to_string(), - }) - } + }), // Rational arithmetic - (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(*a * *b)), - (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Rational(*a * Rational::from_int(*b))), - (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a) * *b)), - (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Rational(*a * Rational::from_int(*b as i64))), - (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) * *b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(*a * *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Rational(*a * Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a) * *b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Uint(b)) => { + Ok(ComptimeValue::Rational(*a * Rational::from_int(*b as i64))) + } + (ComptimeValue::Uint(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Rational(Rational::from_int(*a as i64) * *b)) + } // Float arithmetic - validate results are finite (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Self::validate_float(a * b), (ComptimeValue::Float(a), ComptimeValue::Int(b)) => Self::validate_float(a * *b as f64), (ComptimeValue::Int(a), ComptimeValue::Float(b)) => Self::validate_float(*a as f64 * b), // Rational with float promotes to float - (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => Self::validate_float(a.to_f64() * b), - (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => Self::validate_float(a * b.to_f64()), + (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { + Self::validate_float(a.to_f64() * b) + } + (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { + Self::validate_float(a * b.to_f64()) + } _ => Err(ComptimeError { message: format!("cannot multiply {} and {}", left, right), }), } } - fn eval_div(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_div( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { if *b == 0 { @@ -980,35 +1081,45 @@ impl ComptimeEvaluator { // Float arithmetic - check for division by zero (ComptimeValue::Float(a), ComptimeValue::Float(b)) => { if *b == 0.0 { - Err(ComptimeError { message: "division by zero".to_string() }) + Err(ComptimeError { + message: "division by zero".to_string(), + }) } else { Self::validate_float(a / b) } } (ComptimeValue::Int(a), ComptimeValue::Float(b)) => { if *b == 0.0 { - Err(ComptimeError { message: "division by zero".to_string() }) + Err(ComptimeError { + message: "division by zero".to_string(), + }) } else { Self::validate_float(*a as f64 / b) } } (ComptimeValue::Float(a), ComptimeValue::Int(b)) => { if *b == 0 { - Err(ComptimeError { message: "division by zero".to_string() }) + Err(ComptimeError { + message: "division by zero".to_string(), + }) } else { Self::validate_float(a / *b as f64) } } (ComptimeValue::Uint(a), ComptimeValue::Float(b)) => { if *b == 0.0 { - Err(ComptimeError { message: "division by zero".to_string() }) + Err(ComptimeError { + message: "division by zero".to_string(), + }) } else { Self::validate_float(*a as f64 / b) } } (ComptimeValue::Float(a), ComptimeValue::Uint(b)) => { if *b == 0 { - Err(ComptimeError { message: "division by zero".to_string() }) + Err(ComptimeError { + message: "division by zero".to_string(), + }) } else { Self::validate_float(a / *b as f64) } @@ -1016,14 +1127,18 @@ impl ComptimeEvaluator { // Rational with float promotes to float (ComptimeValue::Rational(a), ComptimeValue::Float(b)) => { if *b == 0.0 { - Err(ComptimeError { message: "division by zero".to_string() }) + Err(ComptimeError { + message: "division by zero".to_string(), + }) } else { Self::validate_float(a.to_f64() / b) } } (ComptimeValue::Float(a), ComptimeValue::Rational(b)) => { if b.is_zero() { - Err(ComptimeError { message: "division by zero".to_string() }) + Err(ComptimeError { + message: "division by zero".to_string(), + }) } else { Self::validate_float(a / b.to_f64()) } @@ -1034,7 +1149,11 @@ impl ComptimeEvaluator { } } - fn eval_mod(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_mod( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => { if *b == 0 { @@ -1062,64 +1181,112 @@ impl ComptimeEvaluator { // Comparison operations - fn eval_eq(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_eq( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { Ok(ComptimeValue::Bool(left == right)) } - fn eval_ne(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_ne( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { Ok(ComptimeValue::Bool(left != right)) } - fn eval_lt(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_lt( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a < b)), (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a < b)), (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a < b)), - (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(a < b)), - (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(*a < Rational::from_int(*b))), - (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(Rational::from_int(*a) < *b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(a < b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Bool(*a < Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(Rational::from_int(*a) < *b)) + } _ => Err(ComptimeError { message: format!("cannot compare {} < {}", left, right), }), } } - fn eval_le(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_le( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a <= b)), (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a <= b)), (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a <= b)), - (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(a <= b)), - (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(*a <= Rational::from_int(*b))), - (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(Rational::from_int(*a) <= *b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(a <= b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Bool(*a <= Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(Rational::from_int(*a) <= *b)) + } _ => Err(ComptimeError { message: format!("cannot compare {} <= {}", left, right), }), } } - fn eval_gt(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_gt( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a > b)), (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a > b)), (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a > b)), - (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(a > b)), - (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(*a > Rational::from_int(*b))), - (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(Rational::from_int(*a) > *b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(a > b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Bool(*a > Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(Rational::from_int(*a) > *b)) + } _ => Err(ComptimeError { message: format!("cannot compare {} > {}", left, right), }), } } - fn eval_ge(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_ge( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(a >= b)), (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Bool(a >= b)), (ComptimeValue::Float(a), ComptimeValue::Float(b)) => Ok(ComptimeValue::Bool(a >= b)), - (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(a >= b)), - (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Bool(*a >= Rational::from_int(*b))), - (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => Ok(ComptimeValue::Bool(Rational::from_int(*a) >= *b)), + (ComptimeValue::Rational(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(a >= b)) + } + (ComptimeValue::Rational(a), ComptimeValue::Int(b)) => { + Ok(ComptimeValue::Bool(*a >= Rational::from_int(*b))) + } + (ComptimeValue::Int(a), ComptimeValue::Rational(b)) => { + Ok(ComptimeValue::Bool(Rational::from_int(*a) >= *b)) + } _ => Err(ComptimeError { message: format!("cannot compare {} >= {}", left, right), }), @@ -1128,7 +1295,11 @@ impl ComptimeEvaluator { // Logical operations - fn eval_and(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_and( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Bool(a), ComptimeValue::Bool(b)) => Ok(ComptimeValue::Bool(*a && *b)), _ => Err(ComptimeError { @@ -1137,7 +1308,11 @@ impl ComptimeEvaluator { } } - fn eval_or(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_or( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Bool(a), ComptimeValue::Bool(b)) => Ok(ComptimeValue::Bool(*a || *b)), _ => Err(ComptimeError { @@ -1148,7 +1323,11 @@ impl ComptimeEvaluator { // Bitwise operations - fn eval_bit_and(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_bit_and( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a & b)), (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a & b)), @@ -1158,7 +1337,11 @@ impl ComptimeEvaluator { } } - fn eval_bit_or(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_bit_or( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a | b)), (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a | b)), @@ -1168,7 +1351,11 @@ impl ComptimeEvaluator { } } - fn eval_bit_xor(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_bit_xor( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match (left, right) { (ComptimeValue::Int(a), ComptimeValue::Int(b)) => Ok(ComptimeValue::Int(a ^ b)), (ComptimeValue::Uint(a), ComptimeValue::Uint(b)) => Ok(ComptimeValue::Uint(a ^ b)), @@ -1178,7 +1365,11 @@ impl ComptimeEvaluator { } } - fn eval_shl(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_shl( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { let shift = right.as_uint().ok_or_else(|| ComptimeError { message: "shift amount must be unsigned integer".to_string(), })?; @@ -1186,7 +1377,10 @@ impl ComptimeEvaluator { // Validate shift amount is within bounds (max 63 for 64-bit integers) if shift >= 64 { return Err(ComptimeError { - message: format!("shift amount {} is too large (max 63 for 64-bit integers)", shift), + message: format!( + "shift amount {} is too large (max 63 for 64-bit integers)", + shift + ), }); } let shift = shift as u32; @@ -1200,7 +1394,11 @@ impl ComptimeEvaluator { } } - fn eval_shr(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_shr( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { let shift = right.as_uint().ok_or_else(|| ComptimeError { message: "shift amount must be unsigned integer".to_string(), })?; @@ -1208,7 +1406,10 @@ impl ComptimeEvaluator { // Validate shift amount is within bounds (max 63 for 64-bit integers) if shift >= 64 { return Err(ComptimeError { - message: format!("shift amount {} is too large (max 63 for 64-bit integers)", shift), + message: format!( + "shift amount {} is too large (max 63 for 64-bit integers)", + shift + ), }); } let shift = shift as u32; @@ -1224,7 +1425,11 @@ impl ComptimeEvaluator { // Optional operations - fn eval_orelse(&self, left: &ComptimeValue, right: &ComptimeValue) -> ComptimeResult { + fn eval_orelse( + &self, + left: &ComptimeValue, + right: &ComptimeValue, + ) -> ComptimeResult { match left { ComptimeValue::Null => Ok(right.clone()), other => Ok(other.clone()), @@ -1288,19 +1493,17 @@ impl ComptimeEvaluator { ComptimeValue::Uint(u) => *u as f64, _ => { return Err(ComptimeError { - message: format!( - "angle value must be numeric, found {:?}", - val - ), - }) + message: format!("angle value must be numeric, found {:?}", val), + }); } }; // For radian values, try to detect pi-multiples and preserve precision if let AngleUnit::Rad = angle.unit - && let Some(turns_rational) = Rational::radians_to_turns(numeric) { - return Ok(ComptimeValue::Rational(turns_rational)); - } + && let Some(turns_rational) = Rational::radians_to_turns(numeric) + { + return Ok(ComptimeValue::Rational(turns_rational)); + } // Fall back to float conversion let turns = angle.unit.to_turns(numeric); @@ -1323,7 +1526,7 @@ impl ComptimeEvaluator { "cannot convert {:?} to {}", val, asc.type_name ), - }) + }); } }; Ok(ComptimeValue::Float(f)) @@ -1333,16 +1536,15 @@ impl ComptimeEvaluator { match val { ComptimeValue::Rational(r) => Ok(ComptimeValue::Rational(r)), ComptimeValue::Float(f) => Ok(ComptimeValue::Float(f)), - ComptimeValue::Int(i) => Ok(ComptimeValue::Rational(Rational::from_int(i))), - ComptimeValue::Uint(u) => Ok(ComptimeValue::Rational(Rational::from_int(u as i64))), - _ => { - Err(ComptimeError { - message: format!( - "cannot convert {:?} to a64", - val - ), - }) + ComptimeValue::Int(i) => { + Ok(ComptimeValue::Rational(Rational::from_int(i))) + } + ComptimeValue::Uint(u) => { + Ok(ComptimeValue::Rational(Rational::from_int(u as i64))) } + _ => Err(ComptimeError { + message: format!("cannot convert {:?} to a64", val), + }), } } t if t.starts_with('u') => { @@ -1356,7 +1558,7 @@ impl ComptimeEvaluator { "cannot convert {:?} to {}", val, asc.type_name ), - }) + }); } }; Ok(ComptimeValue::Uint(u)) @@ -1372,7 +1574,7 @@ impl ComptimeEvaluator { "cannot convert {:?} to {}", val, asc.type_name ), - }) + }); } }; Ok(ComptimeValue::Int(i)) @@ -1472,13 +1674,21 @@ impl ComptimeEvaluator { (ComptimeValue::Array(arr), ComptimeValue::Int(i)) => { let i = *i as usize; arr.get(i).cloned().ok_or_else(|| ComptimeError { - message: format!("index {} out of bounds for array of length {}", i, arr.len()), + message: format!( + "index {} out of bounds for array of length {}", + i, + arr.len() + ), }) } (ComptimeValue::Array(arr), ComptimeValue::Uint(i)) => { let i = *i as usize; arr.get(i).cloned().ok_or_else(|| ComptimeError { - message: format!("index {} out of bounds for array of length {}", i, arr.len()), + message: format!( + "index {} out of bounds for array of length {}", + i, + arr.len() + ), }) } _ => Err(ComptimeError { @@ -1491,11 +1701,12 @@ impl ComptimeEvaluator { let object = self.eval_expr(&field.object)?; match object { - ComptimeValue::Struct { fields, .. } => { - fields.get(&field.field).cloned().ok_or_else(|| ComptimeError { + ComptimeValue::Struct { fields, .. } => fields + .get(&field.field) + .cloned() + .ok_or_else(|| ComptimeError { message: format!("no field '{}' on struct", field.field), - }) - } + }), _ => Err(ComptimeError { message: format!("cannot access field on {}", object), }), @@ -1503,11 +1714,15 @@ impl ComptimeEvaluator { } Expr::Range(range) => { - let start = range.start.as_ref() + let start = range + .start + .as_ref() .map(|e| self.eval_expr(e)) .transpose()? .unwrap_or(ComptimeValue::Int(0)); - let end = range.end.as_ref() + let end = range + .end + .as_ref() .map(|e| self.eval_expr(e)) .transpose()? .ok_or_else(|| ComptimeError { @@ -1516,15 +1731,11 @@ impl ComptimeEvaluator { match (&start, &end) { (ComptimeValue::Int(s), ComptimeValue::Int(e)) => { - let arr: Vec = (*s..*e) - .map(ComptimeValue::Int) - .collect(); + let arr: Vec = (*s..*e).map(ComptimeValue::Int).collect(); Ok(ComptimeValue::Array(arr)) } (ComptimeValue::Uint(s), ComptimeValue::Uint(e)) => { - let arr: Vec = (*s..*e) - .map(ComptimeValue::Uint) - .collect(); + let arr: Vec = (*s..*e).map(ComptimeValue::Uint).collect(); Ok(ComptimeValue::Array(arr)) } _ => Err(ComptimeError { @@ -1534,20 +1745,14 @@ impl ComptimeEvaluator { } Expr::ArrayInit(arr) => { - let values: ComptimeResult> = arr - .elements - .iter() - .map(|e| self.eval_expr(e)) - .collect(); + let values: ComptimeResult> = + arr.elements.iter().map(|e| self.eval_expr(e)).collect(); Ok(ComptimeValue::Array(values?)) } Expr::BracketArray(arr) => { - let values: ComptimeResult> = arr - .elements - .iter() - .map(|e| self.eval_expr(e)) - .collect(); + let values: ComptimeResult> = + arr.elements.iter().map(|e| self.eval_expr(e)).collect(); Ok(ComptimeValue::Array(values?)) } @@ -1569,7 +1774,9 @@ impl ComptimeEvaluator { return Err(ComptimeError { message: format!( "function '{}' expects {} arguments, got {}", - func.name, func.params.len(), arg_values.len() + func.name, + func.params.len(), + arg_values.len() ), }); } @@ -1655,12 +1862,9 @@ impl ComptimeEvaluator { }), Expr::Tuple(tuple) => { - let values: ComptimeResult> = tuple - .elements - .iter() - .map(|e| self.eval_expr(e)) - .collect(); - Ok(ComptimeValue::Array(values?)) // Represent tuples as arrays for now + let values: ComptimeResult> = + tuple.elements.iter().map(|e| self.eval_expr(e)).collect(); + Ok(ComptimeValue::Array(values?)) // Represent tuples as arrays for now } Expr::Set(_) => Err(ComptimeError { @@ -1700,9 +1904,7 @@ impl ComptimeEvaluator { }), // Function literal - creates a comptime function value - Expr::FnLit(func) => { - Ok(ComptimeValue::Function(func.clone())) - } + Expr::FnLit(func) => Ok(ComptimeValue::Function(func.clone())), // Channel expressions (@emit.log.*, @emit.sim.*, @emit.hw.*, custom channels) // At comptime, these evaluate to unit (actual behavior happens at runtime) @@ -1868,8 +2070,16 @@ impl ComptimeEvaluator { /// Get the TypeInfoKind for a Type. fn get_type_info_kind(ty: &Type) -> TypeInfoKind { match ty { - Type::Bool | Type::UInt { .. } | Type::IInt { .. } | Type::Usize | Type::Isize | - Type::F16 | Type::F32 | Type::F64 | Type::F128 | Type::A64 => TypeInfoKind::Primitive, + Type::Bool + | Type::UInt { .. } + | Type::IInt { .. } + | Type::Usize + | Type::Isize + | Type::F16 + | Type::F32 + | Type::F64 + | Type::F128 + | Type::A64 => TypeInfoKind::Primitive, Type::Array { .. } => TypeInfoKind::Array, Type::Slice { .. } => TypeInfoKind::Slice, Type::Set { .. } => TypeInfoKind::Struct, // Set is like a collection @@ -1906,16 +2116,36 @@ impl ComptimeEvaluator { // Try to resolve primitive types match ident.name.as_str() { "bool" => Ok(Type::Bool), - "u8" => Ok(Type::UInt { bits: BitWidth::must(8) }), - "u16" => Ok(Type::UInt { bits: BitWidth::must(16) }), - "u32" => Ok(Type::UInt { bits: BitWidth::must(32) }), - "u64" => Ok(Type::UInt { bits: BitWidth::must(64) }), - "u128" => Ok(Type::UInt { bits: BitWidth::must(128) }), - "i8" => Ok(Type::IInt { bits: BitWidth::must(8) }), - "i16" => Ok(Type::IInt { bits: BitWidth::must(16) }), - "i32" => Ok(Type::IInt { bits: BitWidth::must(32) }), - "i64" => Ok(Type::IInt { bits: BitWidth::must(64) }), - "i128" => Ok(Type::IInt { bits: BitWidth::must(128) }), + "u8" => Ok(Type::UInt { + bits: BitWidth::must(8), + }), + "u16" => Ok(Type::UInt { + bits: BitWidth::must(16), + }), + "u32" => Ok(Type::UInt { + bits: BitWidth::must(32), + }), + "u64" => Ok(Type::UInt { + bits: BitWidth::must(64), + }), + "u128" => Ok(Type::UInt { + bits: BitWidth::must(128), + }), + "i8" => Ok(Type::IInt { + bits: BitWidth::must(8), + }), + "i16" => Ok(Type::IInt { + bits: BitWidth::must(16), + }), + "i32" => Ok(Type::IInt { + bits: BitWidth::must(32), + }), + "i64" => Ok(Type::IInt { + bits: BitWidth::must(64), + }), + "i128" => Ok(Type::IInt { + bits: BitWidth::must(128), + }), "usize" => Ok(Type::Usize), "isize" => Ok(Type::Isize), "f16" => Ok(Type::F16), @@ -1945,18 +2175,27 @@ impl ComptimeEvaluator { let kind = Self::get_type_info_kind(&ty); let mut fields = BTreeMap::new(); - fields.insert("kind".to_string(), ComptimeValue::String(kind.as_str().to_string())); + fields.insert( + "kind".to_string(), + ComptimeValue::String(kind.as_str().to_string()), + ); fields.insert("name".to_string(), ComptimeValue::String(ty.display_name())); // Add type-specific information match &ty { - Type::Struct { name, fields: struct_fields } => { + Type::Struct { + name, + fields: struct_fields, + } => { let field_names: Vec = struct_fields .iter() .map(|(n, _)| ComptimeValue::String(n.clone())) .collect(); fields.insert("fields".to_string(), ComptimeValue::Array(field_names)); - fields.insert("struct_name".to_string(), ComptimeValue::String(name.clone())); + fields.insert( + "struct_name".to_string(), + ComptimeValue::String(name.clone()), + ); } Type::Enum { name, variants } => { let variant_names: Vec = variants @@ -1966,13 +2205,20 @@ impl ComptimeEvaluator { fields.insert("variants".to_string(), ComptimeValue::Array(variant_names)); fields.insert("enum_name".to_string(), ComptimeValue::String(name.clone())); } - Type::Union { name, fields: union_fields, is_tagged } => { + Type::Union { + name, + fields: union_fields, + is_tagged, + } => { let field_names: Vec = union_fields .iter() .map(|(n, _)| ComptimeValue::String(n.clone())) .collect(); fields.insert("fields".to_string(), ComptimeValue::Array(field_names)); - fields.insert("union_name".to_string(), ComptimeValue::String(name.clone())); + fields.insert( + "union_name".to_string(), + ComptimeValue::String(name.clone()), + ); fields.insert("is_tagged".to_string(), ComptimeValue::Bool(*is_tagged)); } Type::ErrorSet { name, errors } => { @@ -1981,7 +2227,10 @@ impl ComptimeEvaluator { .map(|(n, _)| ComptimeValue::String(n.clone())) .collect(); fields.insert("errors".to_string(), ComptimeValue::Array(error_names)); - fields.insert("error_set_name".to_string(), ComptimeValue::String(name.clone())); + fields.insert( + "error_set_name".to_string(), + ComptimeValue::String(name.clone()), + ); } Type::FaultSet { name, faults } => { let fault_names: Vec = faults @@ -1989,7 +2238,10 @@ impl ComptimeEvaluator { .map(|(n, _)| ComptimeValue::String(n.clone())) .collect(); fields.insert("faults".to_string(), ComptimeValue::Array(fault_names)); - fields.insert("fault_set_name".to_string(), ComptimeValue::String(name.clone())); + fields.insert( + "fault_set_name".to_string(), + ComptimeValue::String(name.clone()), + ); } Type::Array { element, size } => { fields.insert("element".to_string(), ComptimeValue::Type(*element.clone())); @@ -2000,7 +2252,11 @@ impl ComptimeEvaluator { Type::Slice { element } => { fields.insert("element".to_string(), ComptimeValue::Type(*element.clone())); } - Type::Pointer { pointee, is_const, is_many } => { + Type::Pointer { + pointee, + is_const, + is_many, + } => { fields.insert("pointee".to_string(), ComptimeValue::Type(*pointee.clone())); fields.insert("is_const".to_string(), ComptimeValue::Bool(*is_const)); fields.insert("is_many".to_string(), ComptimeValue::Bool(*is_many)); @@ -2012,13 +2268,19 @@ impl ComptimeEvaluator { fields.insert("error".to_string(), ComptimeValue::Type(*error.clone())); fields.insert("payload".to_string(), ComptimeValue::Type(*payload.clone())); } - Type::Function { params, return_type } => { + Type::Function { + params, + return_type, + } => { let param_types: Vec = params .iter() .map(|p| ComptimeValue::Type(p.clone())) .collect(); fields.insert("params".to_string(), ComptimeValue::Array(param_types)); - fields.insert("return_type".to_string(), ComptimeValue::Type(*return_type.clone())); + fields.insert( + "return_type".to_string(), + ComptimeValue::Type(*return_type.clone()), + ); } Type::Tuple { elements } => { let element_types: Vec = elements @@ -2115,36 +2377,63 @@ impl ComptimeEvaluator { let kind_str = match kind { ComptimeValue::String(s) => s.as_str(), - _ => return Err(ComptimeError { - message: "TypeInfo.kind must be a string".to_string(), - }), + _ => { + return Err(ComptimeError { + message: "TypeInfo.kind must be a string".to_string(), + }); + } }; // Construct the type based on kind let ty = match kind_str { "primitive" => { // Get the name to determine which primitive - let name = fields.get("name").and_then(|v| { - if let ComptimeValue::String(s) = v { Some(s.as_str()) } else { None } - }).ok_or_else(|| ComptimeError { - message: "primitive TypeInfo requires 'name' field".to_string(), - })?; + let name = fields + .get("name") + .and_then(|v| { + if let ComptimeValue::String(s) = v { + Some(s.as_str()) + } else { + None + } + }) + .ok_or_else(|| ComptimeError { + message: "primitive TypeInfo requires 'name' field".to_string(), + })?; match name { "bool" => Type::Bool, - "u8" => Type::UInt { bits: BitWidth::must(8) }, - "u16" => Type::UInt { bits: BitWidth::must(16) }, - "u32" => Type::UInt { bits: BitWidth::must(32) }, - "u64" => Type::UInt { bits: BitWidth::must(64) }, - "i8" => Type::IInt { bits: BitWidth::must(8) }, - "i16" => Type::IInt { bits: BitWidth::must(16) }, - "i32" => Type::IInt { bits: BitWidth::must(32) }, - "i64" => Type::IInt { bits: BitWidth::must(64) }, + "u8" => Type::UInt { + bits: BitWidth::must(8), + }, + "u16" => Type::UInt { + bits: BitWidth::must(16), + }, + "u32" => Type::UInt { + bits: BitWidth::must(32), + }, + "u64" => Type::UInt { + bits: BitWidth::must(64), + }, + "i8" => Type::IInt { + bits: BitWidth::must(8), + }, + "i16" => Type::IInt { + bits: BitWidth::must(16), + }, + "i32" => Type::IInt { + bits: BitWidth::must(32), + }, + "i64" => Type::IInt { + bits: BitWidth::must(64), + }, "f32" => Type::F32, "f64" => Type::F64, - _ => return Err(ComptimeError { - message: format!("unknown primitive type '{}'", name), - }), + _ => { + return Err(ComptimeError { + message: format!("unknown primitive type '{}'", name), + }); + } } } "array" => { @@ -2153,18 +2442,21 @@ impl ComptimeEvaluator { })?; let element_ty = match element { ComptimeValue::Type(t) => t.clone(), - _ => return Err(ComptimeError { - message: "TypeInfo.element must be a type".to_string(), - }), - }; - let size = fields.get("size").and_then(|v| { - match v { - ComptimeValue::Uint(n) => Some(*n), - ComptimeValue::Int(n) if *n >= 0 => Some(*n as u64), - _ => None, + _ => { + return Err(ComptimeError { + message: "TypeInfo.element must be a type".to_string(), + }); } + }; + let size = fields.get("size").and_then(|v| match v { + ComptimeValue::Uint(n) => Some(*n), + ComptimeValue::Int(n) if *n >= 0 => Some(*n as u64), + _ => None, }); - Type::Array { element: Box::new(element_ty), size } + Type::Array { + element: Box::new(element_ty), + size, + } } "slice" => { let element = fields.get("element").ok_or_else(|| ComptimeError { @@ -2172,11 +2464,15 @@ impl ComptimeEvaluator { })?; let element_ty = match element { ComptimeValue::Type(t) => t.clone(), - _ => return Err(ComptimeError { - message: "TypeInfo.element must be a type".to_string(), - }), + _ => { + return Err(ComptimeError { + message: "TypeInfo.element must be a type".to_string(), + }); + } }; - Type::Slice { element: Box::new(element_ty) } + Type::Slice { + element: Box::new(element_ty), + } } "optional" => { let child = fields.get("child").ok_or_else(|| ComptimeError { @@ -2184,18 +2480,24 @@ impl ComptimeEvaluator { })?; let child_ty = match child { ComptimeValue::Type(t) => t.clone(), - _ => return Err(ComptimeError { - message: "TypeInfo.child must be a type".to_string(), - }), + _ => { + return Err(ComptimeError { + message: "TypeInfo.child must be a type".to_string(), + }); + } }; - Type::Optional { inner: Box::new(child_ty) } + Type::Optional { + inner: Box::new(child_ty), + } } "unit" => Type::Unit, "never" => Type::Never, "type" => Type::Type, - _ => return Err(ComptimeError { - message: format!("cannot construct type from kind '{}'", kind_str), - }), + _ => { + return Err(ComptimeError { + message: format!("cannot construct type from kind '{}'", kind_str), + }); + } }; Ok(ComptimeValue::Type(ty)) @@ -2481,13 +2783,15 @@ mod tests { let fallback = ComptimeValue::Int(42); assert_eq!( - eval.eval_binary_op(BinaryOp::Orelse, &null, &fallback).unwrap(), + eval.eval_binary_op(BinaryOp::Orelse, &null, &fallback) + .unwrap(), ComptimeValue::Int(42) ); let some = ComptimeValue::Int(10); assert_eq!( - eval.eval_binary_op(BinaryOp::Orelse, &some, &fallback).unwrap(), + eval.eval_binary_op(BinaryOp::Orelse, &some, &fallback) + .unwrap(), ComptimeValue::Int(10) ); } @@ -2579,14 +2883,12 @@ mod tests { // Create a simple comptime function that returns a type let func = FnDecl { name: "makeArray".to_string(), - params: vec![ - Param { - name: "T".to_string(), - ty: TypeExpr::Type, - is_comptime: true, - location: None, - }, - ], + params: vec![Param { + name: "T".to_string(), + ty: TypeExpr::Type, + is_comptime: true, + location: None, + }], return_type: Some(TypeExpr::Type), body: Block { label: None, @@ -2600,7 +2902,7 @@ mod tests { }, is_pub: false, is_inline: false, - error_mode: None, + error_mode: None, doc_comment: None, location: None, }; @@ -2611,7 +2913,7 @@ mod tests { #[test] fn test_comptime_anon_struct() { - use crate::ast::{AnonStructExpr, StructField, TypeExpr, PrimitiveType}; + use crate::ast::{AnonStructExpr, PrimitiveType, StructField, TypeExpr}; let mut eval = ComptimeEvaluator::new(); @@ -2655,7 +2957,7 @@ mod tests { #[test] fn test_comptime_function_call() { - use crate::ast::{Block, CallExpr, Param, TypeExpr, Ident}; + use crate::ast::{Block, CallExpr, Ident, Param, TypeExpr}; let mut eval = ComptimeEvaluator::new(); @@ -2663,14 +2965,12 @@ mod tests { // This is an identity function for types let func = FnDecl { name: "identity".to_string(), - params: vec![ - Param { - name: "T".to_string(), - ty: TypeExpr::Type, - is_comptime: true, - location: None, - }, - ], + params: vec![Param { + name: "T".to_string(), + ty: TypeExpr::Type, + is_comptime: true, + location: None, + }], return_type: Some(TypeExpr::Type), body: Block { label: None, @@ -2684,13 +2984,14 @@ mod tests { }, is_pub: false, is_inline: false, - error_mode: None, + error_mode: None, doc_comment: None, location: None, }; // Store function in context - eval.context.define("identity", ComptimeValue::Function(Box::new(func))); + eval.context + .define("identity", ComptimeValue::Function(Box::new(func))); // Call identity(u32) let call = CallExpr { @@ -2735,8 +3036,7 @@ mod tests { #[test] fn test_inline_for_with_comptime_function_nested_types() { use crate::ast::{ - AnonStructExpr, ArrayType, Block, CallExpr, Ident, IntLit, - Param, StructField, TypeExpr, + AnonStructExpr, ArrayType, Block, CallExpr, Ident, IntLit, Param, StructField, TypeExpr, }; let mut eval = ComptimeEvaluator::new(); @@ -2783,13 +3083,16 @@ mod tests { }, is_pub: false, is_inline: false, - error_mode: None, + error_mode: None, doc_comment: None, location: None, }; // Store function in context - eval.context.define("WrapArray", ComptimeValue::Function(Box::new(wrap_array_func))); + eval.context.define( + "WrapArray", + ComptimeValue::Function(Box::new(wrap_array_func)), + ); // Now simulate the comptime block: // comptime { @@ -2799,7 +3102,12 @@ mod tests { // } // Step 1: mut T := u8; - eval.context.define("T", ComptimeValue::Type(Type::UInt { bits: BitWidth::BITS_8 })); + eval.context.define( + "T", + ComptimeValue::Type(Type::UInt { + bits: BitWidth::BITS_8, + }), + ); // Step 2: Simulate inline for with 3 iterations for _ in 0..3 { @@ -2831,24 +3139,46 @@ mod tests { assert_eq!(fields[0].0, "data", "Field name should be 'data'"); // Level 1 field type: [7]struct { ... } - if let Type::Array { element: level2, size } = &fields[0].1 { + if let Type::Array { + element: level2, + size, + } = &fields[0].1 + { assert_eq!(*size, Some(7), "Array size should be 7 at level 1"); // Level 2: struct { data: [7]... } - if let Type::Struct { fields: fields2, .. } = level2.as_ref() { + if let Type::Struct { + fields: fields2, .. + } = level2.as_ref() + { assert_eq!(fields2.len(), 1, "Expected 1 field at level 2"); // Level 2 field type: [7]struct { ... } - if let Type::Array { element: level3, size: size2 } = &fields2[0].1 { + if let Type::Array { + element: level3, + size: size2, + } = &fields2[0].1 + { assert_eq!(*size2, Some(7), "Array size should be 7 at level 2"); // Level 3: struct { data: [7]u8 } - if let Type::Struct { fields: fields3, .. } = level3.as_ref() { + if let Type::Struct { + fields: fields3, .. + } = level3.as_ref() + { assert_eq!(fields3.len(), 1, "Expected 1 field at level 3"); // Level 3 field type: [7]u8 - if let Type::Array { element: inner, size: size3 } = &fields3[0].1 { - assert_eq!(*size3, Some(7), "Array size should be 7 at level 3"); + if let Type::Array { + element: inner, + size: size3, + } = &fields3[0].1 + { + assert_eq!( + *size3, + Some(7), + "Array size should be 7 at level 3" + ); assert!( matches!(inner.as_ref(), Type::UInt { bits } if *bits == BitWidth::BITS_8), "Innermost type should be u8, got {:?}", @@ -2881,9 +3211,7 @@ mod tests { /// Uses the actual for loop evaluation, not manual simulation. #[test] fn test_comptime_block_with_inline_for() { - use crate::ast::{ - Block, ForRange, ForStmt, Ident, IntLit, AssignStmt, Stmt, - }; + use crate::ast::{AssignStmt, Block, ForRange, ForStmt, Ident, IntLit, Stmt}; let mut eval = ComptimeEvaluator::new(); @@ -2898,8 +3226,16 @@ mod tests { let for_stmt = ForStmt { captures: vec!["i".to_string()], range: ForRange::Range { - start: Expr::IntLit(IntLit { value: 0, suffix: None, location: None }), - end: Expr::IntLit(IntLit { value: 5, suffix: None, location: None }), + start: Expr::IntLit(IntLit { + value: 0, + suffix: None, + location: None, + }), + end: Expr::IntLit(IntLit { + value: 5, + suffix: None, + location: None, + }), }, body: Block { label: None, @@ -2907,12 +3243,21 @@ mod tests { statements: vec![ // sum = sum + i Stmt::Assign(AssignStmt { - target: Expr::Ident(Ident { name: "sum".to_string(), location: None }), + target: Expr::Ident(Ident { + name: "sum".to_string(), + location: None, + }), op: crate::ast::AssignOp::Assign, value: Expr::Binary(Box::new(crate::ast::BinaryExpr { - left: Expr::Ident(Ident { name: "sum".to_string(), location: None }), + left: Expr::Ident(Ident { + name: "sum".to_string(), + location: None, + }), op: BinaryOp::Add, - right: Expr::Ident(Ident { name: "i".to_string(), location: None }), + right: Expr::Ident(Ident { + name: "i".to_string(), + location: None, + }), location: None, })), location: None, @@ -2947,15 +3292,27 @@ mod tests { let one = ComptimeValue::Int(1); let four = ComptimeValue::Int(4); let result = eval.eval_div(&one, &four).unwrap(); - assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 4)), "1/4 should be Rational(1/4)"); + assert_eq!( + result, + ComptimeValue::Rational(Rational::new(1, 4)), + "1/4 should be Rational(1/4)" + ); // Verify it converts to correct float assert_eq!(result.as_float(), Some(0.25), "1/4 as float should be 0.25"); // 1/8 should be Rational(1/8) let eight = ComptimeValue::Int(8); let result = eval.eval_div(&one, &eight).unwrap(); - assert_eq!(result, ComptimeValue::Rational(Rational::new(1, 8)), "1/8 should be Rational(1/8)"); - assert_eq!(result.as_float(), Some(0.125), "1/8 as float should be 0.125"); + assert_eq!( + result, + ComptimeValue::Rational(Rational::new(1, 8)), + "1/8 should be Rational(1/8)" + ); + assert_eq!( + result.as_float(), + Some(0.125), + "1/8 as float should be 0.125" + ); // 4/2 is exact, should return Int let two = ComptimeValue::Int(2); @@ -3049,7 +3406,9 @@ mod tests { TypeInfoKind::Primitive ); assert_eq!( - ComptimeEvaluator::get_type_info_kind(&Type::UInt { bits: BitWidth::must(32) }), + ComptimeEvaluator::get_type_info_kind(&Type::UInt { + bits: BitWidth::must(32) + }), TypeInfoKind::Primitive ); assert_eq!( @@ -3082,10 +3441,7 @@ mod tests { assert_eq!( ComptimeEvaluator::get_type_info_kind(&Type::Struct { name: "Point".to_string(), - fields: vec![ - ("x".to_string(), Type::F64), - ("y".to_string(), Type::F64), - ], + fields: vec![("x".to_string(), Type::F64), ("y".to_string(), Type::F64),], }), TypeInfoKind::Struct ); @@ -3147,7 +3503,12 @@ mod tests { location: Some(SourceLocation::default()), }); let result = eval.resolve_type_from_expr(&u32_expr).unwrap(); - assert_eq!(result, Type::UInt { bits: BitWidth::must(32) }); + assert_eq!( + result, + Type::UInt { + bits: BitWidth::must(32) + } + ); let f64_expr = Expr::Ident(Ident { name: "f64".to_string(), @@ -3232,11 +3593,7 @@ mod tests { "Color", ComptimeValue::Type(Type::Enum { name: "Color".to_string(), - variants: vec![ - "Red".to_string(), - "Green".to_string(), - "Blue".to_string(), - ], + variants: vec!["Red".to_string(), "Green".to_string(), "Blue".to_string()], }), ); @@ -3265,9 +3622,20 @@ mod tests { // Create a TypeInfo struct for an array type let mut fields = BTreeMap::new(); - fields.insert("kind".to_string(), ComptimeValue::String("array".to_string())); - fields.insert("name".to_string(), ComptimeValue::String("[4]u32".to_string())); - fields.insert("element".to_string(), ComptimeValue::Type(Type::UInt { bits: BitWidth::must(32) })); + fields.insert( + "kind".to_string(), + ComptimeValue::String("array".to_string()), + ); + fields.insert( + "name".to_string(), + ComptimeValue::String("[4]u32".to_string()), + ); + fields.insert( + "element".to_string(), + ComptimeValue::Type(Type::UInt { + bits: BitWidth::must(32), + }), + ); fields.insert("size".to_string(), ComptimeValue::Uint(4)); // Store the TypeInfo in context @@ -3287,7 +3655,12 @@ mod tests { // Should be an array type if let ComptimeValue::Type(Type::Array { element, size }) = result { - assert_eq!(*element, Type::UInt { bits: BitWidth::must(32) }); + assert_eq!( + *element, + Type::UInt { + bits: BitWidth::must(32) + } + ); assert_eq!(size, Some(4)); } else { panic!("Expected array type, got {:?}", result); @@ -3344,7 +3717,8 @@ mod tests { location: None, }; - eval.context.define("add_10", ComptimeValue::Function(Box::new(add_10_func))); + eval.context + .define("add_10", ComptimeValue::Function(Box::new(add_10_func))); // Call the function with argument 5 let call1 = crate::ast::CallExpr { @@ -3361,7 +3735,9 @@ mod tests { }; // First call - should compute and cache - let result1 = eval.eval_expr(&Expr::Call(Box::new(call1.clone()))).unwrap(); + let result1 = eval + .eval_expr(&Expr::Call(Box::new(call1.clone()))) + .unwrap(); assert_eq!(result1, ComptimeValue::Int(15)); // Verify it's in the cache diff --git a/exp/zlup/src/docgen.rs b/exp/zlup/src/docgen.rs index 832e0f8e0..317cd72d4 100644 --- a/exp/zlup/src/docgen.rs +++ b/exp/zlup/src/docgen.rs @@ -161,14 +161,13 @@ fn format_params(params: &[Param]) -> String { fn format_type_expr(ty: &TypeExpr) -> String { // Simplified type expression formatting - format!("{:?}", ty) - .chars() - .take(80) - .collect() + format!("{:?}", ty).chars().take(80).collect() } fn extract_fn_doc(f: &FnDecl) -> DocItem { - let ret = f.return_type.as_ref() + let ret = f + .return_type + .as_ref() .map(|t| format!(" -> {}", format_type_expr(t))) .unwrap_or_default(); let sig = format!("fn {}({}){}", f.name, format_params(&f.params), ret); @@ -185,12 +184,17 @@ fn extract_fn_doc(f: &FnDecl) -> DocItem { } fn extract_extern_fn_doc(f: &ExternFnDecl) -> DocItem { - let ret = f.return_type.as_ref() + let ret = f + .return_type + .as_ref() .map(|t| format!(" -> {}", format_type_expr(t))) .unwrap_or_default(); let sig = format!( "extern \"{}\" fn {}({}){}", - f.calling_convention, f.name, format_params(&f.params), ret + f.calling_convention, + f.name, + format_params(&f.params), + ret ); DocItem { @@ -205,13 +209,15 @@ fn extract_extern_fn_doc(f: &ExternFnDecl) -> DocItem { } fn extract_struct_doc(s: &StructDecl) -> DocItem { - let children = s.fields.iter().map(|f| { - DocChild { + let children = s + .fields + .iter() + .map(|f| DocChild { name: f.name.clone(), description: format_type_expr(&f.ty), doc: f.doc_comment.clone(), - } - }).collect(); + }) + .collect(); DocItem { kind: DocItemKind::Struct, @@ -225,13 +231,15 @@ fn extract_struct_doc(s: &StructDecl) -> DocItem { } fn extract_enum_doc(e: &EnumDecl) -> DocItem { - let children = e.variants.iter().map(|v| { - DocChild { + let children = e + .variants + .iter() + .map(|v| DocChild { name: v.name.clone(), description: String::new(), doc: None, - } - }).collect(); + }) + .collect(); DocItem { kind: DocItemKind::Enum, @@ -245,16 +253,21 @@ fn extract_enum_doc(e: &EnumDecl) -> DocItem { } fn extract_union_doc(u: &UnionDecl) -> DocItem { - let children = u.fields.iter().map(|f| { - let desc = f.ty.as_ref() - .map(|t| format_type_expr(t)) - .unwrap_or_default(); - DocChild { - name: f.name.clone(), - description: desc, - doc: None, - } - }).collect(); + let children = u + .fields + .iter() + .map(|f| { + let desc = + f.ty.as_ref() + .map(|t| format_type_expr(t)) + .unwrap_or_default(); + DocChild { + name: f.name.clone(), + description: desc, + doc: None, + } + }) + .collect(); DocItem { kind: DocItemKind::Union, @@ -268,13 +281,15 @@ fn extract_union_doc(u: &UnionDecl) -> DocItem { } fn extract_error_set_doc(e: &ErrorSetDecl) -> DocItem { - let children = e.variants.iter().map(|v| { - DocChild { + let children = e + .variants + .iter() + .map(|v| DocChild { name: v.name.clone(), description: String::new(), doc: None, - } - }).collect(); + }) + .collect(); DocItem { kind: DocItemKind::ErrorSet, @@ -288,13 +303,15 @@ fn extract_error_set_doc(e: &ErrorSetDecl) -> DocItem { } fn extract_fault_set_doc(f: &FaultSetDecl) -> DocItem { - let children = f.variants.iter().map(|v| { - DocChild { + let children = f + .variants + .iter() + .map(|v| DocChild { name: v.name.clone(), description: String::new(), doc: None, - } - }).collect(); + }) + .collect(); DocItem { kind: DocItemKind::FaultSet, @@ -372,9 +389,7 @@ pub fn generate_markdown(items: &[DocItem], module_name: &str) -> String { let doc_str = child.doc.as_deref().unwrap_or(""); out.push_str(&format!( "| `{}` | `{}` | {} |\n", - child.name, - child.description, - doc_str + child.name, child.description, doc_str )); } out.push('\n'); @@ -484,17 +499,15 @@ mod tests { #[test] fn test_generate_markdown_basic() { - let items = vec![ - DocItem { - kind: DocItemKind::Function, - name: "main".to_string(), - signature: "fn main() -> unit".to_string(), - doc: Some("Entry point.".to_string()), - children: Vec::new(), - is_pub: true, - location: None, - }, - ]; + let items = vec![DocItem { + kind: DocItemKind::Function, + name: "main".to_string(), + signature: "fn main() -> unit".to_string(), + doc: Some("Entry point.".to_string()), + children: Vec::new(), + is_pub: true, + location: None, + }]; let md = generate_markdown(&items, "my_module"); assert!(md.contains("# my_module")); assert!(md.contains("## Functions")); @@ -504,28 +517,26 @@ mod tests { #[test] fn test_generate_markdown_with_children() { - let items = vec![ - DocItem { - kind: DocItemKind::Struct, - name: "Point".to_string(), - signature: "struct Point".to_string(), - doc: None, - children: vec![ - DocChild { - name: "x".to_string(), - description: "f64".to_string(), - doc: None, - }, - DocChild { - name: "y".to_string(), - description: "f64".to_string(), - doc: None, - }, - ], - is_pub: true, - location: None, - }, - ]; + let items = vec![DocItem { + kind: DocItemKind::Struct, + name: "Point".to_string(), + signature: "struct Point".to_string(), + doc: None, + children: vec![ + DocChild { + name: "x".to_string(), + description: "f64".to_string(), + doc: None, + }, + DocChild { + name: "y".to_string(), + description: "f64".to_string(), + doc: None, + }, + ], + is_pub: true, + location: None, + }]; let md = generate_markdown(&items, "geometry"); assert!(md.contains("## Structs")); assert!(md.contains("| `x` |")); diff --git a/exp/zlup/src/formatter.rs b/exp/zlup/src/formatter.rs index 9938ac94e..9208314af 100644 --- a/exp/zlup/src/formatter.rs +++ b/exp/zlup/src/formatter.rs @@ -172,7 +172,10 @@ fn format_line(line: &str) -> String { result.push(' '); } result.push('='); - } else if prev_char == '!' || prev_char == '<' || prev_char == '>' || prev_char == '=' + } else if prev_char == '!' + || prev_char == '<' + || prev_char == '>' + || prev_char == '=' { // Part of !=, <=, >=, == result.push('='); @@ -313,7 +316,11 @@ mod tests { assert!(formatted.contains("tick {")); // Inner statements should be indented let lines: Vec<&str> = formatted.lines().collect(); - assert!(lines.iter().any(|l| l.starts_with(" h") || l.starts_with(" cx"))); + assert!( + lines + .iter() + .any(|l| l.starts_with(" h") || l.starts_with(" cx")) + ); } #[test] diff --git a/exp/zlup/src/lib.rs b/exp/zlup/src/lib.rs index 9ff7dbb72..b92ac7bb5 100644 --- a/exp/zlup/src/lib.rs +++ b/exp/zlup/src/lib.rs @@ -112,8 +112,8 @@ pub const VERSION: &str = env!("CARGO_PKG_VERSION"); // Re-export commonly used analysis types for convenience pub use analysis::{ - analyze_parallelism, AllocatorAnalysis, AllocatorInfo, DependencyGraph, DepEdge, DepKind, - OperationTagger, ParallelismSummary, Resource, TaggedOp, + AllocatorAnalysis, AllocatorInfo, DepEdge, DepKind, DependencyGraph, OperationTagger, + ParallelismSummary, Resource, TaggedOp, analyze_parallelism, }; /// Parse a Zluppy source file into an AST. @@ -130,7 +130,10 @@ pub fn parse(source: &str) -> Result { /// # Errors /// /// Returns an error if the source contains syntax errors. -pub fn parse_file(source: &str, filename: impl Into) -> Result { +pub fn parse_file( + source: &str, + filename: impl Into, +) -> Result { parser::parse_file(source, filename) } diff --git a/exp/zlup/src/linter.rs b/exp/zlup/src/linter.rs index 1bfe3c31a..feddb74a5 100644 --- a/exp/zlup/src/linter.rs +++ b/exp/zlup/src/linter.rs @@ -455,7 +455,8 @@ impl Linter { for param in &fn_decl.params { self.check_variable_name(¶m.name, param.location.as_ref()); if let Some(ref loc) = param.location { - self.defined_variables.insert(param.name.clone(), loc.clone()); + self.defined_variables + .insert(param.name.clone(), loc.clone()); } } @@ -488,7 +489,8 @@ impl Linter { // Check variable naming self.check_variable_name(&binding.name, binding.location.as_ref()); if let Some(ref loc) = binding.location { - self.defined_variables.insert(binding.name.clone(), loc.clone()); + self.defined_variables + .insert(binding.name.clone(), loc.clone()); } if let Some(ref value) = binding.value { self.analyze_expr(value); @@ -532,14 +534,20 @@ impl Linter { Stmt::Gate(gate_op) => { // Track allocator usage for target in &gate_op.targets { - *self.variable_uses.entry(target.allocator.clone()).or_insert(0) += 1; + *self + .variable_uses + .entry(target.allocator.clone()) + .or_insert(0) += 1; self.analyze_expr(&target.index); } } Stmt::Measure(measure_op) => { // Track allocator usage for target in &measure_op.targets { - *self.variable_uses.entry(target.allocator.clone()).or_insert(0) += 1; + *self + .variable_uses + .entry(target.allocator.clone()) + .or_insert(0) += 1; self.analyze_expr(&target.index); } } @@ -690,7 +698,10 @@ impl Linter { } // Common short names are allowed - if matches!(name, "i" | "j" | "k" | "n" | "x" | "y" | "z" | "q" | "r" | "c") { + if matches!( + name, + "i" | "j" | "k" | "n" | "x" | "y" | "z" | "q" | "r" | "c" + ) { return; } @@ -865,128 +876,139 @@ impl Linter { // Check if using radians when exact turn fractions exist if self.config.prefer_turns_over_radians.enabled - && let AngleUnit::Rad = angle.unit { - // Try to evaluate the expression to see if it's a common angle - if let Some(suggestion) = self.suggest_turn_equivalent(angle) { - self.diagnostics.push( - LintDiagnostic::new( - "prefer_turns_over_radians", - "radians can lose precision; consider using turns for exact representation" - .to_string(), - self.config.prefer_turns_over_radians.severity, - ) - .with_location_opt(angle.location.clone()) - .with_suggestion(&suggestion), - ); - } + && let AngleUnit::Rad = angle.unit + { + // Try to evaluate the expression to see if it's a common angle + if let Some(suggestion) = self.suggest_turn_equivalent(angle) { + self.diagnostics.push( + LintDiagnostic::new( + "prefer_turns_over_radians", + "radians can lose precision; consider using turns for exact representation" + .to_string(), + self.config.prefer_turns_over_radians.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(&suggestion), + ); } + } // Check if using decimal turns when fractions exist if self.config.prefer_fraction_turns.enabled && let AngleUnit::Turns = angle.unit - && let Expr::FloatLit(lit) = &angle.value - && let Some(fraction_str) = suggest_fraction_for_decimal(lit.value) { - let mut diag = LintDiagnostic::new( - "prefer_fraction_turns", + && let Expr::FloatLit(lit) = &angle.value + && let Some(fraction_str) = suggest_fraction_for_decimal(lit.value) + { + let mut diag = LintDiagnostic::new( + "prefer_fraction_turns", + format!( + "decimal `{}` can be expressed exactly as `{}`", + lit.value, fraction_str + ), + self.config.prefer_fraction_turns.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(format!( + "use `{} turns` for exact representation", + fraction_str + )); + + // Generate safe fix: replace the float literal with the fraction + if let (Some(source), Some(lit_loc)) = (&self.source, &lit.location) { + let start = location_to_offset(source, lit_loc.line, lit_loc.column); + let end = location_to_offset(source, lit_loc.end_line, lit_loc.end_column); + diag = diag.with_fix(LintFix { + start, + end, + replacement: fraction_str.clone(), + safety: FixSafety::Safe, + }); + } + + self.diagnostics.push(diag); + } + + // Check if using float literals that could be exact fractions or std constants + if self.config.prefer_exact_angles.enabled + && let Expr::FloatLit(lit) = &angle.value + { + let value = lit.value; + + // Check if this float could be a rational fraction + if let Some(r) = Rational::from_f64_common(value) { + // Only suggest if it's a "nice" fraction (small denominator) + if r.denominator() <= 16 && !r.is_integer() { + let suggestion = if r.numerator() == 1 { + format!("1/{}", r.denominator()) + } else { + format!("{}/{}", r.numerator(), r.denominator()) + }; + + self.diagnostics.push( + LintDiagnostic::new( + "prefer_exact_angles", format!( - "decimal `{}` can be expressed exactly as `{}`", - lit.value, fraction_str + "float literal `{}` can be expressed exactly as fraction `{}`", + value, suggestion ), - self.config.prefer_fraction_turns.severity, + self.config.prefer_exact_angles.severity, ) .with_location_opt(angle.location.clone()) - .with_suggestion(format!("use `{} turns` for exact representation", fraction_str)); - - // Generate safe fix: replace the float literal with the fraction - if let (Some(source), Some(lit_loc)) = (&self.source, &lit.location) { - let start = location_to_offset(source, lit_loc.line, lit_loc.column); - let end = location_to_offset(source, lit_loc.end_line, lit_loc.end_column); - diag = diag.with_fix(LintFix { - start, - end, - replacement: fraction_str.clone(), - safety: FixSafety::Safe, - }); - } + .with_suggestion(format!( + "use `{} {}` for exact representation", + suggestion, + match angle.unit { + AngleUnit::Turns => "turns", + AngleUnit::Rad => "rad", + } + )), + ); + } + } - self.diagnostics.push(diag); - } + // For radians, also check if it's a pi multiple (like 3.14159...) + if let AngleUnit::Rad = angle.unit + && let Some((n, d)) = Rational::from_f64_pi_multiple(value) + { + let pi_suggestion = if n == 1 && d == 1 { + "std.f64.pi".to_string() + } else if n == 1 { + format!("std.f64.pi/{}", d) + } else if d == 1 { + format!("{}*std.f64.pi", n) + } else { + format!("{}*std.f64.pi/{}", n, d) + }; - // Check if using float literals that could be exact fractions or std constants - if self.config.prefer_exact_angles.enabled - && let Expr::FloatLit(lit) = &angle.value { - let value = lit.value; - - // Check if this float could be a rational fraction - if let Some(r) = Rational::from_f64_common(value) { - // Only suggest if it's a "nice" fraction (small denominator) - if r.denominator() <= 16 && !r.is_integer() { - let suggestion = if r.numerator() == 1 { - format!("1/{}", r.denominator()) - } else { - format!("{}/{}", r.numerator(), r.denominator()) - }; - - self.diagnostics.push( - LintDiagnostic::new( - "prefer_exact_angles", - format!( - "float literal `{}` can be expressed exactly as fraction `{}`", - value, suggestion - ), - self.config.prefer_exact_angles.severity, - ) - .with_location_opt(angle.location.clone()) - .with_suggestion(format!( - "use `{} {}` for exact representation", - suggestion, - match angle.unit { - AngleUnit::Turns => "turns", - AngleUnit::Rad => "rad", - } - )), - ); - } - } + // Also suggest the turns equivalent + let turns_fraction = Rational::new(n, 2 * d as i64); + let turns_suggestion = if turns_fraction.numerator() == 1 { + format!("1/{}", turns_fraction.denominator()) + } else { + format!( + "{}/{}", + turns_fraction.numerator(), + turns_fraction.denominator() + ) + }; - // For radians, also check if it's a pi multiple (like 3.14159...) - if let AngleUnit::Rad = angle.unit - && let Some((n, d)) = Rational::from_f64_pi_multiple(value) { - let pi_suggestion = if n == 1 && d == 1 { - "std.f64.pi".to_string() - } else if n == 1 { - format!("std.f64.pi/{}", d) - } else if d == 1 { - format!("{}*std.f64.pi", n) - } else { - format!("{}*std.f64.pi/{}", n, d) - }; - - // Also suggest the turns equivalent - let turns_fraction = Rational::new(n, 2 * d as i64); - let turns_suggestion = if turns_fraction.numerator() == 1 { - format!("1/{}", turns_fraction.denominator()) - } else { - format!("{}/{}", turns_fraction.numerator(), turns_fraction.denominator()) - }; - - self.diagnostics.push( - LintDiagnostic::new( - "prefer_exact_angles", - format!( - "float `{}` appears to be {}*pi/{}; use exact form or turns", - value, n, d - ), - self.config.prefer_exact_angles.severity, - ) - .with_location_opt(angle.location.clone()) - .with_suggestion(format!( - "use `{} rad` or `{} turns` for exact representation", - pi_suggestion, turns_suggestion - )), - ); - } + self.diagnostics.push( + LintDiagnostic::new( + "prefer_exact_angles", + format!( + "float `{}` appears to be {}*pi/{}; use exact form or turns", + value, n, d + ), + self.config.prefer_exact_angles.severity, + ) + .with_location_opt(angle.location.clone()) + .with_suggestion(format!( + "use `{} rad` or `{} turns` for exact representation", + pi_suggestion, turns_suggestion + )), + ); } + } } fn suggest_turn_equivalent(&self, angle: &ast::AngleLit) -> Option { @@ -1004,14 +1026,19 @@ impl Linter { // Use Rational to detect pi multiples and convert to turns if let Some(turns_rational) = Rational::radians_to_turns(radians) - && (!turns_rational.is_integer() || turns_rational.numerator() != 0) { - let suggestion = if turns_rational.numerator() == 1 { - format!("1/{}", turns_rational.denominator()) - } else { - format!("{}/{}", turns_rational.numerator(), turns_rational.denominator()) - }; - return Some(format!("use `{} turns` instead", suggestion)); - } + && (!turns_rational.is_integer() || turns_rational.numerator() != 0) + { + let suggestion = if turns_rational.numerator() == 1 { + format!("1/{}", turns_rational.denominator()) + } else { + format!( + "{}/{}", + turns_rational.numerator(), + turns_rational.denominator() + ) + }; + return Some(format!("use `{} turns` instead", suggestion)); + } None } @@ -1127,9 +1154,10 @@ fn to_pascal_case(s: &str) -> String { /// Count approximate lines in a block (for function length checking). fn count_block_lines(block: &ast::Block) -> usize { if let (Some(start), Some(end)) = (&block.location, block.statements.last()) - && let Some(end_loc) = get_stmt_location(end) { - return (end_loc.line.saturating_sub(start.line) + 1) as usize; - } + && let Some(end_loc) = get_stmt_location(end) + { + return (end_loc.line.saturating_sub(start.line) + 1) as usize; + } // Fallback: count statements block.statements.len() @@ -1191,7 +1219,11 @@ pub struct FixResult { /// /// # Returns /// The modified source code and statistics about fixes applied. -pub fn apply_fixes(source: &str, diagnostics: &[LintDiagnostic], include_unsafe: bool) -> FixResult { +pub fn apply_fixes( + source: &str, + diagnostics: &[LintDiagnostic], + include_unsafe: bool, +) -> FixResult { // Collect all applicable fixes let mut fixes: Vec<&LintFix> = diagnostics .iter() @@ -1475,7 +1507,9 @@ mod tests { ); assert!( - diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("helper")), + diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("helper")), "expected unused_function lint for helper" ); } @@ -1495,7 +1529,9 @@ mod tests { ); assert!( - !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("helper")), + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("helper")), "should not lint used function" ); } @@ -1511,7 +1547,9 @@ mod tests { ); assert!( - !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("exported")), + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("exported")), "pub functions should not be considered unused" ); } @@ -1527,7 +1565,9 @@ mod tests { ); assert!( - !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("main")), + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("main")), "main should never be considered unused" ); } @@ -1549,7 +1589,9 @@ mod tests { ); assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`x`")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`x`")), "used variable should not be flagged" ); } @@ -1570,7 +1612,8 @@ mod tests { let unused_count = diags.iter().filter(|d| d.rule == "unused_variable").count(); assert!( unused_count >= 3, - "expected at least 3 unused variable lints, got {}", unused_count + "expected at least 3 unused variable lints, got {}", + unused_count ); } @@ -1797,7 +1840,9 @@ mod tests { // q should be considered used because of gate operations assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`q`")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`q`")), "allocator used in gates should not be flagged as unused" ); } @@ -1854,7 +1899,9 @@ mod tests { ); assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("result")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("result")), "variable used in return should not be flagged" ); } @@ -1874,7 +1921,9 @@ mod tests { ); assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("flag")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("flag")), "variable used in condition should not be flagged" ); } @@ -1890,7 +1939,9 @@ mod tests { ); assert!( - diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("x")), + diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("x")), "unused parameter should be flagged" ); } @@ -1906,7 +1957,9 @@ mod tests { ); assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("_x")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("_x")), "underscore-prefixed parameter should not be flagged" ); } @@ -1943,7 +1996,9 @@ mod tests { // factorial calls itself, so it should be considered used assert!( - !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("factorial")), + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("factorial")), "recursive function should not be flagged as unused" ); } @@ -1969,11 +2024,15 @@ mod tests { // Both functions are used via mutual recursion assert!( - !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("is_even")), + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("is_even")), "mutually recursive function should not be flagged" ); assert!( - !diags.iter().any(|d| d.rule == "unused_function" && d.message.contains("is_odd")), + !diags + .iter() + .any(|d| d.rule == "unused_function" && d.message.contains("is_odd")), "mutually recursive function should not be flagged" ); } @@ -1992,7 +2051,9 @@ mod tests { ); assert!( - diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("inner")), + diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("inner")), "variable unused in nested block should be flagged" ); } @@ -2014,7 +2075,9 @@ mod tests { ); assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("q")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("q")), "allocator used in tick block should not be flagged" ); } @@ -2055,15 +2118,21 @@ mod tests { // a, b, c are all used in the expression assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`a`")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`a`")), "variable used in expression should not be flagged" ); assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`b`")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`b`")), "variable used in expression should not be flagged" ); assert!( - !diags.iter().any(|d| d.rule == "unused_variable" && d.message.contains("`c`")), + !diags + .iter() + .any(|d| d.rule == "unused_variable" && d.message.contains("`c`")), "variable used in expression should not be flagged" ); } @@ -2197,7 +2266,10 @@ mod tests { let result = apply_fixes(source, &diagnostics, false); assert_eq!(result.safe_fixes_applied, 1); - assert!(result.source.contains("_x := 5"), "Expected fix to prefix with underscore"); + assert!( + result.source.contains("_x := 5"), + "Expected fix to prefix with underscore" + ); } #[test] @@ -2216,7 +2288,11 @@ mod tests { let result = apply_fixes(source, &diagnostics, false); // Should fix all unused variables - assert!(result.safe_fixes_applied >= 3, "Expected at least 3 fixes, got {}", result.safe_fixes_applied); + assert!( + result.safe_fixes_applied >= 3, + "Expected at least 3 fixes, got {}", + result.safe_fixes_applied + ); assert!(result.source.contains("_a := 1")); assert!(result.source.contains("_b := 2")); assert!(result.source.contains("_c := 3")); @@ -2235,8 +2311,13 @@ mod tests { .lint(&program); // Should have a prefer_fraction_turns diagnostic with a fix - let fraction_diag = diagnostics.iter().find(|d| d.rule == "prefer_fraction_turns"); - assert!(fraction_diag.is_some(), "Expected prefer_fraction_turns diagnostic"); + let fraction_diag = diagnostics + .iter() + .find(|d| d.rule == "prefer_fraction_turns"); + assert!( + fraction_diag.is_some(), + "Expected prefer_fraction_turns diagnostic" + ); let diag = fraction_diag.unwrap(); assert!(diag.fix.is_some(), "Expected fix to be available"); @@ -2261,13 +2342,16 @@ mod tests { let result = apply_fixes(source, &diagnostics, false); assert!(result.safe_fixes_applied >= 1); - assert!(result.source.contains("1/4 turns"), "Expected 0.25 to be replaced with 1/4"); + assert!( + result.source.contains("1/4 turns"), + "Expected 0.25 to be replaced with 1/4" + ); } #[test] fn test_has_safe_fix() { - let diag_with_safe_fix = LintDiagnostic::new("test", "msg", Severity::Warning) - .with_fix(LintFix { + let diag_with_safe_fix = + LintDiagnostic::new("test", "msg", Severity::Warning).with_fix(LintFix { start: 0, end: 1, replacement: "x".to_string(), @@ -2276,8 +2360,8 @@ mod tests { assert!(diag_with_safe_fix.has_safe_fix()); assert!(diag_with_safe_fix.has_fix()); - let diag_with_unsafe_fix = LintDiagnostic::new("test", "msg", Severity::Warning) - .with_fix(LintFix { + let diag_with_unsafe_fix = + LintDiagnostic::new("test", "msg", Severity::Warning).with_fix(LintFix { start: 0, end: 1, replacement: "x".to_string(), diff --git a/exp/zlup/src/logging.rs b/exp/zlup/src/logging.rs index c4a33dd39..d60037f78 100644 --- a/exp/zlup/src/logging.rs +++ b/exp/zlup/src/logging.rs @@ -81,9 +81,7 @@ pub fn init() { /// } /// ``` pub fn init_with_default(default_level: &str) { - env_logger::Builder::from_env( - env_logger::Env::default().default_filter_or(default_level) - ) + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(default_level)) .format(|buf, record| { let level_style = buf.default_level_style(record.level()); writeln!( diff --git a/exp/zlup/src/lsp/main.rs b/exp/zlup/src/lsp/main.rs index 2b198068b..bccca0645 100644 --- a/exp/zlup/src/lsp/main.rs +++ b/exp/zlup/src/lsp/main.rs @@ -252,10 +252,7 @@ impl LanguageServer for ZlupsServer { Ok(Some(CompletionResponse::Array(completions))) } - async fn formatting( - &self, - params: DocumentFormattingParams, - ) -> Result>> { + async fn formatting(&self, params: DocumentFormattingParams) -> Result>> { let docs = self.documents.read().await; let Some(doc) = docs.get(¶ms.text_document.uri) else { return Ok(None); @@ -290,7 +287,11 @@ impl LanguageServer for ZlupsServer { &self, params: GotoDefinitionParams, ) -> Result> { - let uri = params.text_document_position_params.text_document.uri.clone(); + let uri = params + .text_document_position_params + .text_document + .uri + .clone(); let position = params.text_document_position_params.position; let docs = self.documents.read().await; @@ -314,13 +315,14 @@ impl LanguageServer for ZlupsServer { // Look up the symbol if let Some(symbol) = analyzer.symbols.lookup(&word) - && let Some(loc) = &symbol.location { - let range = location_to_range(loc); - return Ok(Some(GotoDefinitionResponse::Scalar(Location { - uri, - range, - }))); - } + && let Some(loc) = &symbol.location + { + let range = location_to_range(loc); + return Ok(Some(GotoDefinitionResponse::Scalar(Location { + uri, + range, + }))); + } Ok(None) } @@ -722,11 +724,19 @@ fn format_line(line: &str) -> String { let next = chars.peek().copied(); if next == Some('=') || next == Some('>') { // Part of ==, =>, don't add space before - if !result.ends_with(' ') && !result.ends_with('!') && !result.ends_with('<') && !result.ends_with('>') { + if !result.ends_with(' ') + && !result.ends_with('!') + && !result.ends_with('<') + && !result.ends_with('>') + { result.push(' '); } result.push('='); - } else if prev_char == '!' || prev_char == '<' || prev_char == '>' || prev_char == '=' { + } else if prev_char == '!' + || prev_char == '<' + || prev_char == '>' + || prev_char == '=' + { // Part of !=, <=, >=, == result.push('='); if chars.peek() != Some(&' ') { @@ -746,7 +756,10 @@ fn format_line(line: &str) -> String { // Ensure space after colon in type annotations (but not ::) ':' => { result.push(':'); - if chars.peek() != Some(&':') && chars.peek() != Some(&' ') && chars.peek().is_some() { + if chars.peek() != Some(&':') + && chars.peek() != Some(&' ') + && chars.peek().is_some() + { result.push(' '); } } @@ -771,17 +784,33 @@ fn get_default_completions() -> Vec { // Keywords let keywords = [ - ("fn", "Function declaration", "fn ${1:name}(${2:params}) -> ${3:unit} {\n\treturn unit;\n}"), + ( + "fn", + "Function declaration", + "fn ${1:name}(${2:params}) -> ${3:unit} {\n\treturn unit;\n}", + ), ("mut", "Mutable binding", "mut ${1:name} := ${0:value};"), ("if", "Conditional", "if ${1:condition} {\n\t$0\n}"), ("else", "Else branch", "else {\n\t$0\n}"), - ("for", "Bounded for loop", "for ${1:i} in ${2:0}..${3:n} {\n\t$0\n}"), + ( + "for", + "Bounded for loop", + "for ${1:i} in ${2:0}..${3:n} {\n\t$0\n}", + ), ("return", "Return statement", "return ${0:value};"), ("defer", "Defer statement", "defer ${0:expr};"), ("errdefer", "Error defer", "errdefer ${0:expr};"), - ("struct", "Struct type", "struct {\n\t${1:field}: ${2:type},\n}"), + ( + "struct", + "Struct type", + "struct {\n\t${1:field}: ${2:type},\n}", + ), ("enum", "Enum type", "enum {\n\t${1:Variant},\n}"), - ("union", "Tagged union", "union(enum) {\n\t${1:Variant}: ${2:type},\n}"), + ( + "union", + "Tagged union", + "union(enum) {\n\t${1:Variant}: ${2:type},\n}", + ), ("error", "Error set", "error {\n\t${1:ErrorName},\n}"), ("set", "Set literal", "set { ${0:elements} }"), ]; @@ -799,7 +828,16 @@ fn get_default_completions() -> Vec { // Types let types = [ - "unit", "bool", "i32", "i64", "u32", "u64", "f32", "f64", "usize", "QubitArray", + "unit", + "bool", + "i32", + "i64", + "u32", + "u64", + "f32", + "f64", + "usize", + "QubitArray", ]; for ty in types { diff --git a/exp/zlup/src/lsp/semantic_tokens.rs b/exp/zlup/src/lsp/semantic_tokens.rs index 2f3e97c82..0eda82d08 100644 --- a/exp/zlup/src/lsp/semantic_tokens.rs +++ b/exp/zlup/src/lsp/semantic_tokens.rs @@ -40,25 +40,60 @@ const TT_PROPERTY: u32 = 9; /// Keywords in Zlup const KEYWORDS: &[&str] = &[ - "fn", "mut", "if", "else", "for", "return", "defer", "errdefer", - "struct", "enum", "union", "error", "try", "catch", "orelse", "break", "continue", - "comptime", "inline", "pub", "and", "or", "not", "true", "false", "null", "undefined", + "fn", + "mut", + "if", + "else", + "for", + "return", + "defer", + "errdefer", + "struct", + "enum", + "union", + "error", + "try", + "catch", + "orelse", + "break", + "continue", + "comptime", + "inline", + "pub", + "and", + "or", + "not", + "true", + "false", + "null", + "undefined", ]; /// Built-in types const TYPES: &[&str] = &[ - "unit", "bool", "i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64", "f32", "f64", - "usize", "isize", "QubitArray", "Qubit", + "unit", + "bool", + "i8", + "i16", + "i32", + "i64", + "u8", + "u16", + "u32", + "u64", + "f32", + "f64", + "usize", + "isize", + "QubitArray", + "Qubit", ]; /// Quantum gate names (functions) const GATES: &[&str] = &[ - "h", "H", "x", "X", "y", "Y", "z", "Z", "s", "S", "t", "T", - "cx", "CX", "cnot", "CNOT", "cz", "CZ", "cy", "CY", - "rx", "RX", "ry", "RY", "rz", "RZ", - "swap", "SWAP", "ccx", "CCX", "toffoli", - "mz", "mx", "my", "measure", "pz", - "qalloc", + "h", "H", "x", "X", "y", "Y", "z", "Z", "s", "S", "t", "T", "cx", "CX", "cnot", "CNOT", "cz", + "CZ", "cy", "CY", "rx", "RX", "ry", "RY", "rz", "RZ", "swap", "SWAP", "ccx", "CCX", "toffoli", + "mz", "mx", "my", "measure", "pz", "qalloc", ]; /// Built-in functions @@ -218,7 +253,9 @@ pub fn token_types_for_source(source: &str) -> Vec { // Operators (multi-char) if is_operator_char(c) { let start = char_idx; - while (char_idx as usize) < chars.len() && is_operator_char(chars[char_idx as usize]) { + while (char_idx as usize) < chars.len() + && is_operator_char(chars[char_idx as usize]) + { char_idx += 1; } let len = char_idx - start; diff --git a/exp/zlup/src/main.rs b/exp/zlup/src/main.rs index d4af62f73..7b40a3815 100644 --- a/exp/zlup/src/main.rs +++ b/exp/zlup/src/main.rs @@ -46,7 +46,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use miette::{Diagnostic, NamedSource, SourceSpan}; use zlup::codegen::SlrCodegen; -use zlup::config::{Config, TargetConfig, CONFIG_FILE_NAME}; +use zlup::config::{CONFIG_FILE_NAME, Config, TargetConfig}; use zlup::semantic::SemanticAnalyzer; // ============================================================================= @@ -352,9 +352,9 @@ impl Target { /// Default log elision level for this target. fn default_log_elision(&self) -> Option { match self { - Target::Simulator => None, // Keep all logs - Target::Hardware => Some(300), // Warn and above only - Target::Emulator => Some(200), // Info and above + Target::Simulator => None, // Keep all logs + Target::Hardware => Some(300), // Warn and above only + Target::Emulator => Some(200), // Info and above } } @@ -366,7 +366,7 @@ impl Target { fn sim_mode(&self) -> zlup::codegen::slr::SimMode { use zlup::codegen::slr::SimMode; match self { - Target::Simulator => SimMode::Emit, // Output actual sim commands + Target::Simulator => SimMode::Emit, // Output actual sim commands Target::Hardware => SimMode::Barrier, // Emit barrier to preserve ordering Target::Emulator => SimMode::Barrier, // Emit barrier to preserve ordering } @@ -425,8 +425,8 @@ impl Mode { /// Log elision adjustment for this mode (added to target's default). fn log_elision_adjustment(&self) -> Option { match self { - Mode::Debug => None, // Don't elide beyond target default - Mode::Release => Some(100), // Bump elision by one level + Mode::Debug => None, // Don't elide beyond target default + Mode::Release => Some(100), // Bump elision by one level } } @@ -446,7 +446,7 @@ fn effective_settings(target: Target, mode: Mode) -> (bool, Option) { // Log elision: start with target default, then apply mode adjustment let log_elision = match (target.default_log_elision(), mode.log_elision_adjustment()) { - (Some(t), Some(m)) => Some(t.max(m)), // Take the higher (more elision) + (Some(t), Some(m)) => Some(t.max(m)), // Take the higher (more elision) (Some(t), None) => Some(t), (None, Some(m)) => Some(m), (None, None) => None, @@ -546,10 +546,12 @@ enum CliError { fn read_source(path: &PathBuf) -> Result<(String, String), CliError> { if path.as_os_str() == "-" { let mut source = String::new(); - io::stdin().read_to_string(&mut source).map_err(|e| CliError::ReadError { - path: "".to_string(), - source: e, - })?; + io::stdin() + .read_to_string(&mut source) + .map_err(|e| CliError::ReadError { + path: "".to_string(), + source: e, + })?; Ok((source, "".to_string())) } else { let source = fs::read_to_string(path).map_err(|e| CliError::ReadError { @@ -634,14 +636,12 @@ fn write_output(path: Option<&PathBuf>, content: &str) -> Result<(), CliError> { source: e, }) } - _ => { - io::stdout() - .write_all(content.as_bytes()) - .map_err(|e| CliError::WriteError { - path: "".to_string(), - source: e, - }) - } + _ => io::stdout() + .write_all(content.as_bytes()) + .map_err(|e| CliError::WriteError { + path: "".to_string(), + source: e, + }), } } @@ -724,7 +724,11 @@ pub fn main() -> unit { source: e, })?; - eprintln!("Created new project '{}' at {}", name, project_dir.display()); + eprintln!( + "Created new project '{}' at {}", + name, + project_dir.display() + ); eprintln!(" {} - project configuration", CONFIG_FILE_NAME); eprintln!(" main.zlp - main source file"); eprintln!(); @@ -747,11 +751,10 @@ fn cmd_build( source: e, })?; - let (config, config_path) = Config::find_and_load(¤t_dir).map_err(|e| { - CliError::ConfigError { + let (config, config_path) = + Config::find_and_load(¤t_dir).map_err(|e| CliError::ConfigError { message: e.to_string(), - } - })?; + })?; let project_root = Config::project_root(&config_path); @@ -796,10 +799,7 @@ fn cmd_build( #[cfg(feature = "hugr")] Format::Hugr => "hugr", }; - let output_name = entry_path - .file_stem() - .unwrap_or_default() - .to_string_lossy(); + let output_name = entry_path.file_stem().unwrap_or_default().to_string_lossy(); let output_path = output_dir.join(format!("{}.{}", output_name, output_ext)); // Compute effective settings @@ -816,9 +816,20 @@ fn cmd_build( ); // Compile - cmd_compile(entry_path, Some(output_path.clone()), target, format, mode, compact, Some(strict), None, false)?; + cmd_compile( + entry_path, + Some(output_path.clone()), + target, + format, + mode, + compact, + Some(strict), + None, + false, + )?; - eprintln!("Built {} -> {}", + eprintln!( + "Built {} -> {}", project_root.join(&config.package.entry).display(), output_path.display() ); @@ -908,18 +919,19 @@ fn cmd_compile( }; codegen.set_sim_mode(sim_mode); - let slr_program = codegen - .compile(&program) - .map_err(|e: zlup::codegen::slr::SlrError| CliError::CodegenError { - message: e.to_string(), - })?; - - if compact { + let slr_program = codegen - .to_json_compact(&slr_program) + .compile(&program) .map_err(|e: zlup::codegen::slr::SlrError| CliError::CodegenError { message: e.to_string(), - })? + })?; + + if compact { + codegen.to_json_compact(&slr_program).map_err( + |e: zlup::codegen::slr::SlrError| CliError::CodegenError { + message: e.to_string(), + }, + )? } else { codegen .to_json(&slr_program) @@ -932,11 +944,12 @@ fn cmd_compile( use zlup::codegen::PhirJsonCodegen; let mut codegen = PhirJsonCodegen::new(); - let phir_json_program = codegen - .compile(&program) - .map_err(|e| CliError::CodegenError { - message: e.to_string(), - })?; + let phir_json_program = + codegen + .compile(&program) + .map_err(|e| CliError::CodegenError { + message: e.to_string(), + })?; if compact { codegen @@ -1054,10 +1067,8 @@ fn cmd_parse(input: PathBuf, format: AstFormat) -> Result<(), CliError> { // Output let output = match format { AstFormat::Debug => format!("{:#?}", program), - AstFormat::Json => { - serde_json::to_string_pretty(&program) - .unwrap_or_else(|e| format!("JSON serialization error: {}", e)) - } + AstFormat::Json => serde_json::to_string_pretty(&program) + .unwrap_or_else(|e| format!("JSON serialization error: {}", e)), }; println!("{}", output); @@ -1066,7 +1077,7 @@ fn cmd_parse(input: PathBuf, format: AstFormat) -> Result<(), CliError> { /// Execute the format command. fn cmd_format(input: PathBuf, write: bool, check: bool) -> Result<(), CliError> { - use zlup::formatter::{format, FormatOptions}; + use zlup::formatter::{FormatOptions, format}; let (source, filename) = read_source(&input)?; let options = FormatOptions::default(); @@ -1120,7 +1131,7 @@ fn cmd_lint( show_diff: bool, statistics_only: bool, ) -> Result<(), CliError> { - use zlup::linter::{apply_fixes, FixSafety, LintConfig, Linter, Severity}; + use zlup::linter::{FixSafety, LintConfig, Linter, Severity, apply_fixes}; let (source, filename) = read_source(&input)?; @@ -1142,19 +1153,31 @@ fn cmd_lint( }; // Run linter (with source for fix computation) - let diagnostics = Linter::new(config) - .with_source(&source) - .lint(&program); + let diagnostics = Linter::new(config).with_source(&source).lint(&program); // Statistics-only mode if statistics_only { - let errors = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)).count(); - let warnings = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(); - let hints = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Hint)).count(); + let errors = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(); + let warnings = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + let hints = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Hint)) + .count(); let fixable_safe = diagnostics.iter().filter(|d| d.has_safe_fix()).count(); - let fixable_unsafe = diagnostics.iter().filter(|d| { - d.fix.as_ref().is_some_and(|f| f.safety == FixSafety::Unsafe) - }).count(); + let fixable_unsafe = diagnostics + .iter() + .filter(|d| { + d.fix + .as_ref() + .is_some_and(|f| f.safety == FixSafety::Unsafe) + }) + .count(); println!("File: {}", filename); println!("Errors: {}", errors); @@ -1204,11 +1227,21 @@ fn cmd_lint( } // Still return error if there are issues - let has_errors = diagnostics.iter().any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); - let has_warnings = diagnostics.iter().any(|d| matches!(d.severity, Severity::Warning)); + let has_errors = diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Warning)); if has_errors || (deny_warnings && has_warnings) { - let errors = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)).count(); - let warnings = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(); + let errors = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(); + let warnings = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); return Err(CliError::LintFailed { path: filename, error_count: errors, @@ -1228,7 +1261,10 @@ fn cmd_lint( // Can't write back to stdin return Err(CliError::WriteError { path: "".to_string(), - source: io::Error::new(io::ErrorKind::InvalidInput, "cannot apply fixes to stdin"), + source: io::Error::new( + io::ErrorKind::InvalidInput, + "cannot apply fixes to stdin", + ), }); } @@ -1257,7 +1293,8 @@ fn cmd_lint( // Re-lint to show remaining issues let program = zlup::parse_file(&fix_result.source, &filename).map_err(|e| { - let start = e.location.line.saturating_sub(1) as usize * 80 + e.location.column as usize; + let start = + e.location.line.saturating_sub(1) as usize * 80 + e.location.column as usize; CliError::ParseError { src: NamedSource::new(&filename, fix_result.source.clone()), span: SourceSpan::from(start..start + 1), @@ -1287,9 +1324,18 @@ fn cmd_lint( LintFormat::Compact => print_diagnostics_compact(&remaining_diagnostics, &filename), } - let errors = remaining_diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)).count(); - let warnings = remaining_diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(); - let hints = remaining_diagnostics.iter().filter(|d| matches!(d.severity, Severity::Hint)).count(); + let errors = remaining_diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(); + let warnings = remaining_diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + let hints = remaining_diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Hint)) + .count(); eprintln!(); eprintln!( @@ -1297,8 +1343,12 @@ fn cmd_lint( errors, warnings, hints, filename ); - let has_errors = remaining_diagnostics.iter().any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); - let has_warnings = remaining_diagnostics.iter().any(|d| matches!(d.severity, Severity::Warning)); + let has_errors = remaining_diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = remaining_diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Warning)); if has_errors || (deny_warnings && has_warnings) { return Err(CliError::LintFailed { @@ -1315,8 +1365,12 @@ fn cmd_lint( } // Check for errors - let has_errors = diagnostics.iter().any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); - let has_warnings = diagnostics.iter().any(|d| matches!(d.severity, Severity::Warning)); + let has_errors = diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Error | Severity::Deny)); + let has_warnings = diagnostics + .iter() + .any(|d| matches!(d.severity, Severity::Warning)); // Output diagnostics match format { @@ -1327,9 +1381,18 @@ fn cmd_lint( // Summary if !diagnostics.is_empty() { - let errors = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)).count(); - let warnings = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Warning)).count(); - let hints = diagnostics.iter().filter(|d| matches!(d.severity, Severity::Hint)).count(); + let errors = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Error | Severity::Deny)) + .count(); + let warnings = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Warning)) + .count(); + let hints = diagnostics + .iter() + .filter(|d| matches!(d.severity, Severity::Hint)) + .count(); let fixable_safe = diagnostics.iter().filter(|d| d.has_safe_fix()).count(); let fixable_total = diagnostics.iter().filter(|d| d.has_fix()).count(); @@ -1378,7 +1441,7 @@ fn print_diagnostics_pretty(diagnostics: &[zlup::linter::LintDiagnostic], filena for diag in diagnostics { let (severity_str, color) = match diag.severity { - Severity::Hint => ("hint", "\x1b[36m"), // Cyan + Severity::Hint => ("hint", "\x1b[36m"), // Cyan Severity::Warning => ("warning", "\x1b[33m"), // Yellow Severity::Error => ("error", "\x1b[31m"), // Red Severity::Deny => ("deny", "\x1b[91m"), // Bright Red @@ -1389,7 +1452,15 @@ fn print_diagnostics_pretty(diagnostics: &[zlup::linter::LintDiagnostic], filena if let Some(ref loc) = diag.location { eprintln!( "{}{}{}:{}{}: {}{}{}: {}", - bold, filename, reset, loc.line, loc.column, color, severity_str, reset, diag.message + bold, + filename, + reset, + loc.line, + loc.column, + color, + severity_str, + reset, + diag.message ); } else { eprintln!( @@ -1446,7 +1517,10 @@ fn print_diagnostics_compact(diagnostics: &[zlup::linter::LintDiagnostic], filen filename, loc.line, loc.column, severity, diag.rule, diag.message ); } else { - println!("{}: {} [{}] {}", filename, severity, diag.rule, diag.message); + println!( + "{}: {} [{}] {}", + filename, severity, diag.rule, diag.message + ); } } } @@ -1462,7 +1536,7 @@ fn print_unified_diff(original: &str, fixed: &str, filename: &str) { let cyan = "\x1b[36m"; let reset = "\x1b[0m"; - println!("{}--- a/{}{}",cyan, filename, reset); + println!("{}--- a/{}{}", cyan, filename, reset); println!("{}+++ b/{}{}", cyan, filename, reset); // Simple diff: find changed lines @@ -1514,8 +1588,14 @@ fn print_unified_diff(original: &str, fixed: &str, filename: &str) { if has_changes { // Print hunk header - let orig_start = orig_hunk.first().map(|(n, _)| *n + 1).unwrap_or(hunk_start + 1); - let fixed_start = fixed_hunk.first().map(|(n, _)| *n + 1).unwrap_or(hunk_start + 1); + let orig_start = orig_hunk + .first() + .map(|(n, _)| *n + 1) + .unwrap_or(hunk_start + 1); + let fixed_start = fixed_hunk + .first() + .map(|(n, _)| *n + 1) + .unwrap_or(hunk_start + 1); println!( "{}@@ -{},{} +{},{} @@{}", cyan, @@ -1551,7 +1631,9 @@ fn print_unified_diff(original: &str, fixed: &str, filename: &str) { // ============================================================================= fn cmd_analyze(input: PathBuf, format: AnalyzeFormat, verbose: bool) -> Result<(), CliError> { - use zlup::analysis::{analyze_parallelism, AllocatorAnalysis, DependencyGraph, OperationTagger}; + use zlup::analysis::{ + AllocatorAnalysis, DependencyGraph, OperationTagger, analyze_parallelism, + }; let (source, filename) = read_source(&input)?; @@ -1581,9 +1663,14 @@ fn cmd_analyze(input: PathBuf, format: AnalyzeFormat, verbose: bool) -> Result<( println!(" (none)"); } else { for (name, info) in &allocator_analysis.allocators { - let size_str = info.size.map(|s| format!("[{}]", s)).unwrap_or_else(|| "[?]".to_string()); - println!(" {} {}qubit (scope depth: {}, line: {})", - name, size_str, info.scope_depth, info.defined_at_line); + let size_str = info + .size + .map(|s| format!("[{}]", s)) + .unwrap_or_else(|| "[?]".to_string()); + println!( + " {} {}qubit (scope depth: {}, line: {})", + name, size_str, info.scope_depth, info.defined_at_line + ); } } println!(); @@ -1609,42 +1696,56 @@ fn cmd_analyze(input: PathBuf, format: AnalyzeFormat, verbose: bool) -> Result<( AnalyzeFormat::Json => { use serde_json::json; - let allocators: Vec<_> = allocator_analysis.allocators.iter().map(|(name, info)| { - json!({ - "name": name, - "size": info.size, - "scope_depth": info.scope_depth, - "defined_at_line": info.defined_at_line, + let allocators: Vec<_> = allocator_analysis + .allocators + .iter() + .map(|(name, info)| { + json!({ + "name": name, + "size": info.size, + "scope_depth": info.scope_depth, + "defined_at_line": info.defined_at_line, + }) }) - }).collect(); - - let functions: Vec<_> = summaries.iter().map(|s| { - json!({ - "name": s.function_name, - "total_ops": s.total_ops, - "quantum_ops": s.quantum_ops, - "classical_ops": s.classical_ops, - "num_layers": s.num_layers, - "max_parallelism": s.max_parallelism, + .collect(); + + let functions: Vec<_> = summaries + .iter() + .map(|s| { + json!({ + "name": s.function_name, + "total_ops": s.total_ops, + "quantum_ops": s.quantum_ops, + "classical_ops": s.classical_ops, + "num_layers": s.num_layers, + "max_parallelism": s.max_parallelism, + }) }) - }).collect(); + .collect(); let layers = dep_graph.parallel_layers(); - let layer_details: Vec<_> = layers.iter().enumerate().map(|(i, ops)| { - let op_details: Vec<_> = ops.iter().map(|&id| { - let op = &dep_graph.operations[id]; + let layer_details: Vec<_> = layers + .iter() + .enumerate() + .map(|(i, ops)| { + let op_details: Vec<_> = ops + .iter() + .map(|&id| { + let op = &dep_graph.operations[id]; + json!({ + "id": op.id, + "description": op.description, + "line": op.line, + "is_quantum": op.touches_qubits(), + }) + }) + .collect(); json!({ - "id": op.id, - "description": op.description, - "line": op.line, - "is_quantum": op.touches_qubits(), + "layer": i, + "operations": op_details, }) - }).collect(); - json!({ - "layer": i, - "operations": op_details, }) - }).collect(); + .collect(); let output = json!({ "file": filename, @@ -1672,10 +1773,12 @@ fn cmd_eval(expr: String, verbose: bool) -> Result<(), CliError> { // Read expression from stdin if - let input = if expr == "-" { let mut buf = String::new(); - io::stdin().read_to_string(&mut buf).map_err(|e| CliError::ReadError { - path: "".to_string(), - source: e, - })?; + io::stdin() + .read_to_string(&mut buf) + .map_err(|e| CliError::ReadError { + path: "".to_string(), + source: e, + })?; buf } else { expr @@ -1757,7 +1860,7 @@ fn cmd_eval(expr: String, verbose: bool) -> Result<(), CliError> { // ============================================================================= fn cmd_doc(input: PathBuf, output: Option, all: bool) -> Result<(), CliError> { - use zlup::docgen::{extract_doc_items, generate_markdown, DocConfig}; + use zlup::docgen::{DocConfig, extract_doc_items, generate_markdown}; let (source, filename) = read_source(&input)?; @@ -1777,7 +1880,8 @@ fn cmd_doc(input: PathBuf, output: Option, all: bool) -> Result<(), Cli }; let items = extract_doc_items(&program, &config); - let module_name = input.file_stem() + let module_name = input + .file_stem() .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "module".to_string()); let markdown = generate_markdown(&items, &module_name); @@ -1796,7 +1900,7 @@ fn cmd_test( strict: bool, verbose: bool, ) -> Result<(), CliError> { - use zlup::test_runner::{format_results, TestOutcome, TestRunConfig, TestRunner}; + use zlup::test_runner::{TestOutcome, TestRunConfig, TestRunner, format_results}; let (source, filename) = read_source(&input)?; @@ -1822,7 +1926,9 @@ fn cmd_test( print!("{}", output); // Exit with failure if any tests failed - let has_failures = results.iter().any(|r| matches!(r.outcome, TestOutcome::Fail(_))); + let has_failures = results + .iter() + .any(|r| matches!(r.outcome, TestOutcome::Fail(_))); if has_failures { return Err(CliError::CodegenError { message: "some tests failed".to_string(), @@ -1863,7 +1969,9 @@ fn main() -> ExitCode { strict, log_level, elide_sim, - } => cmd_compile(input, output, target, format, mode, compact, strict, log_level, elide_sim), + } => cmd_compile( + input, output, target, format, mode, compact, strict, log_level, elide_sim, + ), Commands::Check { input, strict } => cmd_check(input, strict), @@ -1884,15 +1992,33 @@ fn main() -> ExitCode { unsafe_fixes, diff, statistics, - } => cmd_lint(input, level, deny_warnings, format, fix, unsafe_fixes, diff, statistics), + } => cmd_lint( + input, + level, + deny_warnings, + format, + fix, + unsafe_fixes, + diff, + statistics, + ), Commands::Eval { expr, verbose } => cmd_eval(expr, verbose), - Commands::Analyze { input, format, verbose } => cmd_analyze(input, format, verbose), + Commands::Analyze { + input, + format, + verbose, + } => cmd_analyze(input, format, verbose), Commands::Doc { input, output, all } => cmd_doc(input, output, all), - Commands::Test { input, filter, strict, verbose } => cmd_test(input, filter, strict, verbose), + Commands::Test { + input, + filter, + strict, + verbose, + } => cmd_test(input, filter, strict, verbose), }; match result { diff --git a/exp/zlup/src/module.rs b/exp/zlup/src/module.rs index 7907a238a..180b95134 100644 --- a/exp/zlup/src/module.rs +++ b/exp/zlup/src/module.rs @@ -329,10 +329,12 @@ impl ModuleLoader { // Try std/std.zlp (directory with entry file) let std_dir_entry = stdlib.join("std").join("std.zlp"); if std_dir_entry.exists() { - return std_dir_entry.canonicalize().map_err(|e| ModuleError::ReadError { - path: std_dir_entry.display().to_string(), - source: e, - }); + return std_dir_entry + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); } // Try std.zlp directly @@ -354,10 +356,12 @@ impl ModuleLoader { // Try lib/std/std.zlp let std_dir_entry = lib_dir.join("std").join("std.zlp"); if std_dir_entry.exists() { - return std_dir_entry.canonicalize().map_err(|e| ModuleError::ReadError { - path: std_dir_entry.display().to_string(), - source: e, - }); + return std_dir_entry + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); } // Try lib/std.zlp @@ -375,10 +379,12 @@ impl ModuleLoader { // Try std/std.zlp let std_dir_entry = search_path.join("std").join("std.zlp"); if std_dir_entry.exists() { - return std_dir_entry.canonicalize().map_err(|e| ModuleError::ReadError { - path: std_dir_entry.display().to_string(), - source: e, - }); + return std_dir_entry + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: std_dir_entry.display().to_string(), + source: e, + }); } // Try std.zlp @@ -420,16 +426,22 @@ impl ModuleLoader { .file_name() .and_then(|s| s.to_str()) .unwrap_or(import_path); - candidates.push(base_dir.join(import_path).join(format!("{}.zlp", module_name))); + candidates.push( + base_dir + .join(import_path) + .join(format!("{}.zlp", module_name)), + ); } // Try candidates relative to base_dir for candidate in &candidates { if candidate.exists() { - return candidate.canonicalize().map_err(|e| ModuleError::ReadError { - path: candidate.display().to_string(), - source: e, - }); + return candidate + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: candidate.display().to_string(), + source: e, + }); } } @@ -444,16 +456,20 @@ impl ModuleLoader { .unwrap_or(import_path); vec![ search_path.join(format!("{}.zlp", import_path)), - search_path.join(import_path).join(format!("{}.zlp", module_name)), + search_path + .join(import_path) + .join(format!("{}.zlp", module_name)), ] }; for candidate in search_candidates { if candidate.exists() { - return candidate.canonicalize().map_err(|e| ModuleError::ReadError { - path: candidate.display().to_string(), - source: e, - }); + return candidate + .canonicalize() + .map_err(|e| ModuleError::ReadError { + path: candidate.display().to_string(), + source: e, + }); } } } diff --git a/exp/zlup/src/optimize.rs b/exp/zlup/src/optimize.rs index 02760a434..4504b89f7 100644 --- a/exp/zlup/src/optimize.rs +++ b/exp/zlup/src/optimize.rs @@ -26,9 +26,9 @@ use std::collections::BTreeSet; use crate::ast::{ - Attribute, BinaryExpr, BinaryOp, Binding, Block, BoolLit, ElseBranch, Expr, FloatLit, - ForRange, ForStmt, GateKind, GateOp, IfStmt, IntLit, Program, SlotRef, Stmt, TopLevelDecl, - UnaryExpr, UnaryOp, + Attribute, BinaryExpr, BinaryOp, Binding, Block, BoolLit, ElseBranch, Expr, FloatLit, ForRange, + ForStmt, GateKind, GateOp, IfStmt, IntLit, Program, SlotRef, Stmt, TopLevelDecl, UnaryExpr, + UnaryOp, }; use crate::comptime::{ComptimeEvaluator, ComptimeValue}; @@ -207,9 +207,7 @@ impl Optimizer { fn_decl.body = self.optimize_block(fn_decl.body); TopLevelDecl::Fn(fn_decl) } - TopLevelDecl::Binding(binding) => { - TopLevelDecl::Binding(self.optimize_binding(binding)) - } + TopLevelDecl::Binding(binding) => TopLevelDecl::Binding(self.optimize_binding(binding)), other => other, } } @@ -253,11 +251,12 @@ impl Optimizer { // Check for identity gates (e.g., rz(0)) before optimization if self.config.identity_removal && let Expr::Gate(ref gate) = expr_stmt.expr - && let Some(angle) = gate.params.first() - && self.is_zero_angle(angle) { - self.stats.identities_removed += 1; - return None; // Remove the identity gate - } + && let Some(angle) = gate.params.first() + && self.is_zero_angle(angle) + { + self.stats.identities_removed += 1; + return None; // Remove the identity gate + } let optimized = self.optimize_expr(expr_stmt.expr); Some(Stmt::Expr(crate::ast::ExprStmt { expr: optimized, @@ -340,10 +339,11 @@ impl Optimizer { fn optimize_expr(&mut self, expr: Expr) -> Expr { // Try constant folding first if self.config.constant_folding - && let Some(folded) = self.try_fold_constant(&expr) { - self.stats.constants_folded += 1; - return folded; - } + && let Some(folded) = self.try_fold_constant(&expr) + { + self.stats.constants_folded += 1; + return folded; + } // Recursively optimize subexpressions match expr { @@ -360,10 +360,11 @@ impl Optimizer { }; if self.config.constant_folding - && let Some(folded) = self.try_fold_binary(&new_bin) { - self.stats.constants_folded += 1; - return folded; - } + && let Some(folded) = self.try_fold_binary(&new_bin) + { + self.stats.constants_folded += 1; + return folded; + } Expr::Binary(Box::new(new_bin)) } @@ -378,16 +379,21 @@ impl Optimizer { }; if self.config.constant_folding - && let Some(folded) = self.try_fold_unary(&new_un) { - self.stats.constants_folded += 1; - return folded; - } + && let Some(folded) = self.try_fold_unary(&new_un) + { + self.stats.constants_folded += 1; + return folded; + } Expr::Unary(Box::new(new_un)) } Expr::Call(mut call) => { - call.args = call.args.into_iter().map(|a| self.optimize_expr(a)).collect(); + call.args = call + .args + .into_iter() + .map(|a| self.optimize_expr(a)) + .collect(); Expr::Call(call) } @@ -403,12 +409,20 @@ impl Optimizer { } Expr::Tuple(mut tuple) => { - tuple.elements = tuple.elements.into_iter().map(|e| self.optimize_expr(e)).collect(); + tuple.elements = tuple + .elements + .into_iter() + .map(|e| self.optimize_expr(e)) + .collect(); Expr::Tuple(tuple) } Expr::BracketArray(mut arr) => { - arr.elements = arr.elements.into_iter().map(|e| self.optimize_expr(e)).collect(); + arr.elements = arr + .elements + .into_iter() + .map(|e| self.optimize_expr(e)) + .collect(); Expr::BracketArray(arr) } @@ -499,9 +513,11 @@ impl Optimizer { fn try_fold_unary(&mut self, un: &UnaryExpr) -> Option { // Double negation: --x = x, !!x = x if let Expr::Unary(inner) = &un.operand - && un.op == inner.op && matches!(un.op, UnaryOp::Neg | UnaryOp::Not) { - return Some(inner.operand.clone()); - } + && un.op == inner.op + && matches!(un.op, UnaryOp::Neg | UnaryOp::Not) + { + return Some(inner.operand.clone()); + } // Try full constant evaluation self.try_fold_constant(&Expr::Unary(Box::new(un.clone()))) @@ -548,22 +564,21 @@ impl Optimizer { // Check for constant condition if self.config.dead_code_elimination - && let Expr::BoolLit(BoolLit { value, .. }) = &condition { - self.stats.dead_code_removed += 1; - if *value { - // Condition is always true - keep then branch - return Some(Stmt::Block(self.optimize_block(if_stmt.then_body))); - } else { - // Condition is always false - keep else branch or remove - return match if_stmt.else_body { - Some(ElseBranch::Else(block)) => { - Some(Stmt::Block(self.optimize_block(block))) - } - Some(ElseBranch::ElseIf(nested)) => self.optimize_if(*nested), - None => None, - }; - } + && let Expr::BoolLit(BoolLit { value, .. }) = &condition + { + self.stats.dead_code_removed += 1; + if *value { + // Condition is always true - keep then branch + return Some(Stmt::Block(self.optimize_block(if_stmt.then_body))); + } else { + // Condition is always false - keep else branch or remove + return match if_stmt.else_body { + Some(ElseBranch::Else(block)) => Some(Stmt::Block(self.optimize_block(block))), + Some(ElseBranch::ElseIf(nested)) => self.optimize_if(*nested), + None => None, + }; } + } let then_body = self.optimize_block(if_stmt.then_body); let else_body = match if_stmt.else_body { @@ -681,7 +696,10 @@ impl Optimizer { Stmt::Binding(binding) => Stmt::Binding(Binding { name: binding.name.clone(), ty: binding.ty.clone(), - value: binding.value.as_ref().map(|e| self.substitute_in_expr(e, var_name, value)), + value: binding + .value + .as_ref() + .map(|e| self.substitute_in_expr(e, var_name, value)), is_mutable: binding.is_mutable, is_pub: binding.is_pub, doc_comment: binding.doc_comment.clone(), @@ -719,7 +737,9 @@ impl Optimizer { ElseBranch::Else(self.substitute_in_block(block, var_name, value)) } ElseBranch::ElseIf(nested) => { - if let Stmt::If(nested_if) = self.substitute_in_stmt(&Stmt::If(*nested.clone()), var_name, value) { + if let Stmt::If(nested_if) = + self.substitute_in_stmt(&Stmt::If(*nested.clone()), var_name, value) + { ElseBranch::ElseIf(Box::new(nested_if)) } else { eb.clone() @@ -749,7 +769,10 @@ impl Optimizer { Stmt::Block(block) => Stmt::Block(self.substitute_in_block(block, var_name, value)), Stmt::Return(ret) => Stmt::Return(crate::ast::ReturnStmt { - value: ret.value.as_ref().map(|e| self.substitute_in_expr(e, var_name, value)), + value: ret + .value + .as_ref() + .map(|e| self.substitute_in_expr(e, var_name, value)), location: ret.location.clone(), }), @@ -763,7 +786,11 @@ impl Optimizer { Stmt::Tick(tick) => Stmt::Tick(crate::ast::TickStmt { label: tick.label.clone(), attrs: tick.attrs.clone(), - body: tick.body.iter().map(|s| self.substitute_in_stmt(s, var_name, value)).collect(), + body: tick + .body + .iter() + .map(|s| self.substitute_in_stmt(s, var_name, value)) + .collect(), location: tick.location.clone(), }), @@ -829,28 +856,46 @@ impl Optimizer { Expr::Call(call) => Expr::Call(Box::new(crate::ast::CallExpr { callee: self.substitute_in_expr(&call.callee, var_name, value), - args: call.args.iter().map(|a| self.substitute_in_expr(a, var_name, value)).collect(), + args: call + .args + .iter() + .map(|a| self.substitute_in_expr(a, var_name, value)) + .collect(), location: call.location.clone(), })), Expr::Tuple(tuple) => Expr::Tuple(Box::new(crate::ast::TupleExpr { - elements: tuple.elements.iter().map(|e| self.substitute_in_expr(e, var_name, value)).collect(), + elements: tuple + .elements + .iter() + .map(|e| self.substitute_in_expr(e, var_name, value)) + .collect(), location: tuple.location.clone(), })), Expr::BracketArray(arr) => Expr::BracketArray(Box::new(crate::ast::BracketArrayExpr { - elements: arr.elements.iter().map(|e| self.substitute_in_expr(e, var_name, value)).collect(), + elements: arr + .elements + .iter() + .map(|e| self.substitute_in_expr(e, var_name, value)) + .collect(), location: arr.location.clone(), })), Expr::Gate(gate) => Expr::Gate(Box::new(crate::ast::GateExpr { kind: gate.kind, - params: gate.params.iter().map(|p| self.substitute_in_expr(p, var_name, value)).collect(), + params: gate + .params + .iter() + .map(|p| self.substitute_in_expr(p, var_name, value)) + .collect(), target: self.substitute_in_expr(&gate.target, var_name, value), location: gate.location.clone(), })), - Expr::SlotRef(slot) => Expr::SlotRef(Box::new(self.substitute_in_slot_ref(slot, var_name, value))), + Expr::SlotRef(slot) => { + Expr::SlotRef(Box::new(self.substitute_in_slot_ref(slot, var_name, value))) + } Expr::If(if_expr) => Expr::If(Box::new(crate::ast::IfExpr { condition: self.substitute_in_expr(&if_expr.condition, var_name, value), @@ -878,10 +923,11 @@ impl Optimizer { // Remove identity rotations (rotation by 0) if self.config.identity_removal && let Some(angle) = gate_op.params.first() - && self.is_zero_angle(angle) { - self.stats.identities_removed += 1; - return None; - } + && self.is_zero_angle(angle) + { + self.stats.identities_removed += 1; + return None; + } Some(Stmt::Gate(gate_op)) } @@ -934,22 +980,24 @@ impl Optimizer { // Check if this and the next statement are inverse gates that can cancel // Gates can be either Stmt::Gate(GateOp) or Stmt::Expr(ExprStmt { expr: Expr::Gate(...) }) if i + 1 < statements.len() - && let (Some((g1, attrs1, no_opt1)), Some((g2, attrs2, no_opt2))) = - (self.extract_gate_info(stmt), self.extract_gate_info(&statements[i + 1])) + && let (Some((g1, attrs1, no_opt1)), Some((g2, attrs2, no_opt2))) = ( + self.extract_gate_info(stmt), + self.extract_gate_info(&statements[i + 1]), + ) + { + // Don't cancel if either gate is wrapped in @no_optimize or has preserve attr + if !no_opt1 + && !no_opt2 + && !self.has_preserve_attr(&attrs1) + && !self.has_preserve_attr(&attrs2) + && !self.is_optimization_barrier(&statements[i + 1]) + && self.are_inverse_gate_exprs(&g1, &g2) { - // Don't cancel if either gate is wrapped in @no_optimize or has preserve attr - if !no_opt1 - && !no_opt2 - && !self.has_preserve_attr(&attrs1) - && !self.has_preserve_attr(&attrs2) - && !self.is_optimization_barrier(&statements[i + 1]) - && self.are_inverse_gate_exprs(&g1, &g2) - { - self.stats.gates_cancelled += 2; - i += 2; // Skip both gates - continue; - } + self.stats.gates_cancelled += 2; + i += 2; // Skip both gates + continue; } + } result.push(statements[i].clone()); i += 1; @@ -960,7 +1008,10 @@ impl Optimizer { /// Extract gate info from a statement (handles both Stmt::Gate and Stmt::Expr with Expr::Gate). /// Also handles @no_optimize(gate_expr) wrapped gates. - fn extract_gate_info(&self, stmt: &Stmt) -> Option<(crate::ast::GateExpr, Vec, bool)> { + fn extract_gate_info( + &self, + stmt: &Stmt, + ) -> Option<(crate::ast::GateExpr, Vec, bool)> { match stmt { Stmt::Gate(gate_op) => { // Convert GateOp to GateExpr-like structure @@ -992,14 +1043,16 @@ impl Optimizer { Stmt::Expr(expr_stmt) => { // Check for @no_optimize(gate_expr) builtin if let Expr::Builtin(builtin) = &expr_stmt.expr - && builtin.name == "no_optimize" && builtin.args.len() == 1 - && let Expr::Gate(gate_expr) = &builtin.args[0] { - return Some(( - *gate_expr.clone(), - expr_stmt.attrs.clone(), - true, // wrapped in @no_optimize - should not be cancelled - )); - } + && builtin.name == "no_optimize" + && builtin.args.len() == 1 + && let Expr::Gate(gate_expr) = &builtin.args[0] + { + return Some(( + *gate_expr.clone(), + expr_stmt.attrs.clone(), + true, // wrapped in @no_optimize - should not be cancelled + )); + } // Regular gate expression if let Expr::Gate(gate_expr) = &expr_stmt.expr { Some((*gate_expr.clone(), expr_stmt.attrs.clone(), false)) @@ -1012,11 +1065,7 @@ impl Optimizer { } /// Check if two gate expressions are inverses (cancel each other). - fn are_inverse_gate_exprs( - &self, - g1: &crate::ast::GateExpr, - g2: &crate::ast::GateExpr, - ) -> bool { + fn are_inverse_gate_exprs(&self, g1: &crate::ast::GateExpr, g2: &crate::ast::GateExpr) -> bool { // Must have same target if !self.same_gate_target(&g1.target, &g2.target) { return false; @@ -1052,9 +1101,7 @@ impl Optimizer { // Rotation cancellation: RX(a) RX(-a) = I (GateKind::RX, GateKind::RX) | (GateKind::RY, GateKind::RY) - | (GateKind::RZ, GateKind::RZ) => { - self.are_inverse_rotations(&g1.params, &g2.params) - } + | (GateKind::RZ, GateKind::RZ) => self.are_inverse_rotations(&g1.params, &g2.params), _ => false, } @@ -1064,21 +1111,18 @@ impl Optimizer { fn same_gate_target(&self, t1: &Expr, t2: &Expr) -> bool { match (t1, t2) { // Single qubit: compare SlotRef - (Expr::SlotRef(s1), Expr::SlotRef(s2)) => { - self.same_slot_ref(s1, s2) - } + (Expr::SlotRef(s1), Expr::SlotRef(s2)) => self.same_slot_ref(s1, s2), // Index expressions (q[0], q[1], etc.) - (Expr::Index(idx1), Expr::Index(idx2)) => { - self.same_index_expr(idx1, idx2) - } + (Expr::Index(idx1), Expr::Index(idx2)) => self.same_index_expr(idx1, idx2), // Tuple targets (for multi-qubit gates like cx (q[0], q[1])) (Expr::Tuple(tup1), Expr::Tuple(tup2)) => { if tup1.elements.len() != tup2.elements.len() { return false; } - tup1.elements.iter().zip(tup2.elements.iter()).all(|(a, b)| { - self.same_gate_target(a, b) - }) + tup1.elements + .iter() + .zip(tup2.elements.iter()) + .all(|(a, b)| self.same_gate_target(a, b)) } _ => false, } @@ -1108,10 +1152,9 @@ impl Optimizer { } // Compare indices match (&*s1.index, &*s2.index) { - ( - Expr::IntLit(IntLit { value: v1, .. }), - Expr::IntLit(IntLit { value: v2, .. }), - ) => v1 == v2, + (Expr::IntLit(IntLit { value: v1, .. }), Expr::IntLit(IntLit { value: v2, .. })) => { + v1 == v2 + } _ => false, } } @@ -1159,9 +1202,10 @@ impl Optimizer { for attr in attrs { if attr.name == "round" - && let Some(crate::ast::AttributeValue::Int(n)) = &attr.value { - return Some(*n); - } + && let Some(crate::ast::AttributeValue::Int(n)) = &attr.value + { + return Some(*n); + } } None } @@ -1203,9 +1247,7 @@ impl Optimizer { // Rotation cancellation: RX(a) RX(-a) = I (GateKind::RX, GateKind::RX) | (GateKind::RY, GateKind::RY) - | (GateKind::RZ, GateKind::RZ) => { - self.are_inverse_rotations(&g1.params, &g2.params) - } + | (GateKind::RZ, GateKind::RZ) => self.are_inverse_rotations(&g1.params, &g2.params), _ => false, } @@ -1221,16 +1263,18 @@ impl Optimizer { match (¶ms1[0], ¶ms2[0]) { (Expr::IntLit(IntLit { value: a, .. }), Expr::Unary(un)) => { if un.op == UnaryOp::Neg - && let Expr::IntLit(IntLit { value: b, .. }) = &un.operand { - return *a == *b; - } + && let Expr::IntLit(IntLit { value: b, .. }) = &un.operand + { + return *a == *b; + } false } (Expr::Unary(un), Expr::IntLit(IntLit { value: b, .. })) => { if un.op == UnaryOp::Neg - && let Expr::IntLit(IntLit { value: a, .. }) = &un.operand { - return *a == *b; - } + && let Expr::IntLit(IntLit { value: a, .. }) = &un.operand + { + return *a == *b; + } false } ( @@ -1252,7 +1296,10 @@ impl Optimizer { } // Compare indices match (&*a.index, &*b.index) { - (Expr::IntLit(IntLit { value: v1, .. }), Expr::IntLit(IntLit { value: v2, .. })) => { + ( + Expr::IntLit(IntLit { value: v1, .. }), + Expr::IntLit(IntLit { value: v2, .. }), + ) => { if v1 != v2 { return false; } @@ -1845,7 +1892,11 @@ pub fn main() -> unit { // Verify the function body no longer contains an if statement if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { - let has_if = fn_decl.body.statements.iter().any(|s| matches!(s, Stmt::If(_))); + let has_if = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::If(_))); assert!(!has_if, "if statement should have been eliminated"); } } @@ -1870,7 +1921,11 @@ pub fn main() -> unit { // The if should be replaced by just the then body block if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { - let has_if = fn_decl.body.statements.iter().any(|s| matches!(s, Stmt::If(_))); + let has_if = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::If(_))); assert!(!has_if, "if true should be replaced by then block"); } } @@ -1895,7 +1950,11 @@ pub fn main() -> unit { // The if should be replaced by just the else body block if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { - let has_if = fn_decl.body.statements.iter().any(|s| matches!(s, Stmt::If(_))); + let has_if = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::If(_))); assert!(!has_if, "if false should be replaced by else block"); } } @@ -1959,9 +2018,12 @@ pub fn main() -> unit { // Verify no H gates remain if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { - let h_count = fn_decl.body.statements.iter().filter(|s| { - matches!(s, Stmt::Gate(g) if g.kind == GateKind::H) - }).count(); + let h_count = fn_decl + .body + .statements + .iter() + .filter(|s| matches!(s, Stmt::Gate(g) if g.kind == GateKind::H)) + .count(); assert_eq!(h_count, 0, "Both H gates should be cancelled"); } } @@ -2107,7 +2169,10 @@ pub fn main() -> unit { let mut optimizer = Optimizer::new(); let _optimized = optimizer.optimize(ast); - assert_eq!(optimizer.stats.gates_cancelled, 2, "SWAP SWAP should cancel"); + assert_eq!( + optimizer.stats.gates_cancelled, 2, + "SWAP SWAP should cancel" + ); } #[test] @@ -2126,19 +2191,27 @@ pub fn main() -> unit { let optimized = optimizer.optimize(ast); // Should NOT cancel - different qubits - assert_eq!(optimizer.stats.gates_cancelled, 0, "H on different qubits should not cancel"); + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "H on different qubits should not cancel" + ); // Verify both H gates remain // Note: Gates are represented as Stmt::Expr containing Expr::Gate if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { - let h_count = fn_decl.body.statements.iter().filter(|s| { - if let Stmt::Expr(expr_stmt) = s { - if let Expr::Gate(gate) = &expr_stmt.expr { - return gate.kind == GateKind::H; + let h_count = fn_decl + .body + .statements + .iter() + .filter(|s| { + if let Stmt::Expr(expr_stmt) = s { + if let Expr::Gate(gate) = &expr_stmt.expr { + return gate.kind == GateKind::H; + } } - } - false - }).count(); + false + }) + .count(); assert_eq!(h_count, 2, "Both H gates on different qubits should remain"); } } @@ -2214,7 +2287,10 @@ pub fn main() -> unit { let _optimized = optimizer.optimize(ast); // The first H is wrapped in @no_optimize, so cancellation shouldn't happen - assert_eq!(optimizer.stats.gates_cancelled, 0, "@no_optimize should prevent cancellation"); + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "@no_optimize should prevent cancellation" + ); } #[test] @@ -2233,7 +2309,10 @@ pub fn main() -> unit { let mut optimizer = Optimizer::new(); let _optimized = optimizer.optimize(ast); - assert_eq!(optimizer.stats.gates_cancelled, 0, "@no_optimize on second gate should prevent cancellation"); + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "@no_optimize on second gate should prevent cancellation" + ); } #[test] @@ -2252,7 +2331,10 @@ pub fn main() -> unit { let mut optimizer = Optimizer::new(); let _optimized = optimizer.optimize(ast); - assert_eq!(optimizer.stats.gates_cancelled, 0, "Both @no_optimize should prevent cancellation"); + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "Both @no_optimize should prevent cancellation" + ); } #[test] @@ -2274,7 +2356,10 @@ pub fn main() -> unit { let _optimized = optimizer.optimize(ast); // tick {} is a barrier, so the two H gates shouldn't cancel - assert_eq!(optimizer.stats.gates_cancelled, 0, "tick should act as barrier"); + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "tick should act as barrier" + ); } #[test] @@ -2295,7 +2380,10 @@ pub fn main() -> unit { let _optimized = optimizer.optimize(ast); // Within the same tick, gates CAN cancel - assert_eq!(optimizer.stats.gates_cancelled, 2, "H H within same tick should cancel"); + assert_eq!( + optimizer.stats.gates_cancelled, 2, + "H H within same tick should cancel" + ); } // ========================================================================= @@ -2361,13 +2449,18 @@ pub fn main() -> unit { let mut optimizer = Optimizer::new(); let optimized = optimizer.optimize(ast); - assert!(optimizer.stats.unused_bindings_removed > 0, "unused binding should be removed"); + assert!( + optimizer.stats.unused_bindings_removed > 0, + "unused binding should be removed" + ); // Verify the binding was removed if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { - let has_binding = fn_decl.body.statements.iter().any(|s| { - matches!(s, Stmt::Binding(b) if b.name == "unused") - }); + let has_binding = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::Binding(b) if b.name == "unused")); assert!(!has_binding, "unused binding should be eliminated"); } } @@ -2386,9 +2479,11 @@ pub fn main() -> i32 { // Verify the binding was NOT removed if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { - let has_binding = fn_decl.body.statements.iter().any(|s| { - matches!(s, Stmt::Binding(b) if b.name == "used") - }); + let has_binding = fn_decl + .body + .statements + .iter() + .any(|s| matches!(s, Stmt::Binding(b) if b.name == "used")); assert!(has_binding, "used binding should be kept"); } } @@ -2411,7 +2506,10 @@ pub fn main() -> unit { let mut optimizer = Optimizer::new(); let optimized = optimizer.optimize(ast); - assert!(optimizer.stats.identities_removed > 0, "rz(0) should be removed as identity"); + assert!( + optimizer.stats.identities_removed > 0, + "rz(0) should be removed as identity" + ); // Verify the gate was removed // Note: Gates are represented as Stmt::Expr containing Expr::Gate @@ -2443,8 +2541,10 @@ pub fn main() -> unit { // Constant folding disabled - should still have binary expression if let TopLevelDecl::Binding(binding) = &optimized.declarations[0] { - assert!(matches!(&binding.value, Some(Expr::Binary(_))), - "with constant_folding disabled, should keep binary expr"); + assert!( + matches!(&binding.value, Some(Expr::Binary(_))), + "with constant_folding disabled, should keep binary expr" + ); } assert_eq!(optimizer.stats.constants_folded, 0); } @@ -2466,20 +2566,31 @@ pub fn main() -> unit { let mut optimizer = Optimizer::with_config(config); let optimized = optimizer.optimize(ast); - assert_eq!(optimizer.stats.gates_cancelled, 0, "gate_cancellation disabled"); + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "gate_cancellation disabled" + ); // Both H gates should remain // Note: Gates are represented as Stmt::Expr containing Expr::Gate if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { - let h_count = fn_decl.body.statements.iter().filter(|s| { - if let Stmt::Expr(expr_stmt) = s { - if let Expr::Gate(gate) = &expr_stmt.expr { - return gate.kind == GateKind::H; + let h_count = fn_decl + .body + .statements + .iter() + .filter(|s| { + if let Stmt::Expr(expr_stmt) = s { + if let Expr::Gate(gate) = &expr_stmt.expr { + return gate.kind == GateKind::H; + } } - } - false - }).count(); - assert_eq!(h_count, 2, "Both H gates should remain when cancellation disabled"); + false + }) + .count(); + assert_eq!( + h_count, 2, + "Both H gates should remain when cancellation disabled" + ); } } @@ -2533,7 +2644,10 @@ pub fn main() -> unit { let mut optimizer = Optimizer::new(); let _optimized = optimizer.optimize(ast); - assert_eq!(optimizer.stats.gates_cancelled, 6, "All 3 pairs should cancel"); + assert_eq!( + optimizer.stats.gates_cancelled, 6, + "All 3 pairs should cancel" + ); } #[test] @@ -2553,7 +2667,10 @@ pub fn main() -> unit { let _optimized = optimizer.optimize(ast); // H and H are not adjacent, so should not cancel - assert_eq!(optimizer.stats.gates_cancelled, 0, "Non-adjacent gates should not cancel"); + assert_eq!( + optimizer.stats.gates_cancelled, 0, + "Non-adjacent gates should not cancel" + ); } #[test] @@ -2632,7 +2749,11 @@ pub fn main() -> unit { let gate_count = count_gates_recursive(&fn_decl.body.statements); // Should have pz + 4 h gates // At minimum, we should have 4 h gates from unrolling (pz may or may not count) - assert!(gate_count >= 4, "Expected at least 4 gates, got {}", gate_count); + assert!( + gate_count >= 4, + "Expected at least 4 gates, got {}", + gate_count + ); } } diff --git a/exp/zlup/src/parser.rs b/exp/zlup/src/parser.rs index 7863981da..9c72030c6 100644 --- a/exp/zlup/src/parser.rs +++ b/exp/zlup/src/parser.rs @@ -10,8 +10,8 @@ //! The parser consumes tokens from the pest lexer and builds the AST. use crate::ast::*; -use pest::iterators::{Pair, Pairs}; use pest::Parser; +use pest::iterators::{Pair, Pairs}; use pest_derive::Parser; /// Pest parser generated from grammar. @@ -76,8 +76,8 @@ impl<'a> ParserState<'a> { }); } - let pairs = ZluppyParser::parse(Rule::program, self.source) - .map_err(|e| self.pest_error(e))?; + let pairs = + ZluppyParser::parse(Rule::program, self.source).map_err(|e| self.pest_error(e))?; self.parse_program(pairs) } @@ -85,8 +85,12 @@ impl<'a> ParserState<'a> { /// Convert a pest error to our error type. fn pest_error(&self, e: pest::error::Error) -> ParseError { let (line, column, end_line, end_column) = match e.line_col { - pest::error::LineColLocation::Pos((l, c)) => (l as u32, c as u32, l as u32, c as u32 + 1), - pest::error::LineColLocation::Span((l, c), (el, ec)) => (l as u32, c as u32, el as u32, ec as u32), + pest::error::LineColLocation::Pos((l, c)) => { + (l as u32, c as u32, l as u32, c as u32 + 1) + } + pest::error::LineColLocation::Span((l, c), (el, ec)) => { + (l as u32, c as u32, el as u32, ec as u32) + } }; ParseError { message: e.to_string(), @@ -134,9 +138,9 @@ impl<'a> ParserState<'a> { /// Returns an error if the pair has no inner elements. fn expect_inner(&self, pair: Pair<'a, Rule>, context: &str) -> ParseResult> { let location = self.location(&pair); - pair.into_inner() - .next() - .ok_or_else(|| self.error_at(location, format!("expected inner element in {}", context))) + pair.into_inner().next().ok_or_else(|| { + self.error_at(location, format!("expected inner element in {}", context)) + }) } /// Expect the next element from an iterator. @@ -147,8 +151,12 @@ impl<'a> ParserState<'a> { location: &SourceLocation, context: &str, ) -> ParseResult> { - iter.next() - .ok_or_else(|| self.error_at(location.clone(), format!("expected {} but found end of input", context))) + iter.next().ok_or_else(|| { + self.error_at( + location.clone(), + format!("expected {} but found end of input", context), + ) + }) } // ========================================================================= @@ -197,7 +205,9 @@ impl<'a> ParserState<'a> { Rule::error_set_decl => Ok(TopLevelDecl::ErrorSet(self.parse_error_set_decl(inner)?)), Rule::fault_set_decl => Ok(TopLevelDecl::FaultSet(self.parse_fault_set_decl(inner)?)), Rule::test_decl => Ok(TopLevelDecl::Test(self.parse_test_decl(inner)?)), - Rule::declare_gate_decl => Ok(TopLevelDecl::DeclareGate(self.parse_declare_gate_decl(inner)?)), + Rule::declare_gate_decl => Ok(TopLevelDecl::DeclareGate( + self.parse_declare_gate_decl(inner)?, + )), Rule::gate_decl => Ok(TopLevelDecl::Gate(self.parse_gate_decl(inner)?)), _ => Err(self.error(&inner, format!("unexpected {:?}", inner.as_rule()))), } @@ -458,7 +468,10 @@ impl<'a> ParserState<'a> { } other => { return Err(ParseError { - message: format!("unexpected rule {:?}, expected param (self_param or regular_param)", other), + message: format!( + "unexpected rule {:?}, expected param (self_param or regular_param)", + other + ), location: location.unwrap_or_default(), }); } @@ -466,7 +479,11 @@ impl<'a> ParserState<'a> { } /// Parse a regular (non-self) parameter. - fn parse_regular_param(&self, pair: Pair, location: Option) -> ParseResult { + fn parse_regular_param( + &self, + pair: Pair, + location: Option, + ) -> ParseResult { let inner = pair.into_inner(); let mut is_comptime = false; @@ -1113,7 +1130,10 @@ impl<'a> ParserState<'a> { Rule::errdefer_stmt => Ok(Stmt::Errdefer(self.parse_errdefer_stmt(inner)?)), Rule::block => Ok(Stmt::Block(self.parse_block(inner)?)), Rule::expr_stmt => Ok(Stmt::Expr(self.parse_expr_stmt(inner)?)), - _ => Err(self.error(&inner, format!("unexpected statement {:?}", inner.as_rule()))), + _ => Err(self.error( + &inner, + format!("unexpected statement {:?}", inner.as_rule()), + )), } } @@ -1139,7 +1159,10 @@ impl<'a> ParserState<'a> { } let name = name.ok_or_else(|| { - self.error_at(location.clone().unwrap_or_default(), "alias requires a name") + self.error_at( + location.clone().unwrap_or_default(), + "alias requires a name", + ) })?; let source = source.ok_or_else(|| { self.error_at( @@ -1250,7 +1273,11 @@ impl<'a> ParserState<'a> { for entry in item.into_inner() { if entry.as_rule() == Rule::attrs_entry { let mut entry_inner = entry.into_inner(); - let name = entry_inner.next().expect("attrs_entry must have key").as_str().to_string(); + let name = entry_inner + .next() + .expect("attrs_entry must have key") + .as_str() + .to_string(); let value_pair = entry_inner.next().expect("attrs_entry must have value"); let value = Some(self.parse_attr_value(value_pair)?); attrs.push(Attribute { @@ -1279,7 +1306,9 @@ impl<'a> ParserState<'a> { // Parse the number text - handle floats vs integers let s = inner.as_str().replace('_', ""); if s.contains('.') || s.contains('e') || s.contains('E') { - let value: f64 = s.parse().map_err(|_| self.error(&inner, "invalid float literal"))?; + let value: f64 = s + .parse() + .map_err(|_| self.error(&inner, "invalid float literal"))?; Ok(AttributeValue::Float(value)) } else if s.starts_with("0x") || s.starts_with("0X") { let value = i64::from_str_radix(&s[2..], 16) @@ -1294,7 +1323,9 @@ impl<'a> ParserState<'a> { .map_err(|_| self.error(&inner, "invalid octal literal"))?; Ok(AttributeValue::Int(value)) } else { - let value: i64 = s.parse().map_err(|_| self.error(&inner, "invalid integer literal"))?; + let value: i64 = s + .parse() + .map_err(|_| self.error(&inner, "invalid integer literal"))?; Ok(AttributeValue::Int(value)) } } @@ -1497,8 +1528,15 @@ impl<'a> ParserState<'a> { // if value := expr { ... } (Go-style unwrapping) let first_loc = self.location(&first); let mut clause_inner = first.into_inner(); - let capture_name = self.expect_next(&mut clause_inner, &first_loc, "capture name")?.as_str().to_string(); - let expr = self.parse_expr(self.expect_next(&mut clause_inner, &first_loc, "unwrap expression")?)?; + let capture_name = self + .expect_next(&mut clause_inner, &first_loc, "capture name")? + .as_str() + .to_string(); + let expr = self.parse_expr(self.expect_next( + &mut clause_inner, + &first_loc, + "unwrap expression", + )?)?; (expr, Some(capture_name)) } Rule::if_condition => { @@ -1508,7 +1546,10 @@ impl<'a> ParserState<'a> { } other => { return Err(ParseError { - message: format!("unexpected rule {:?}, expected if_unwrap_clause or if_condition", other), + message: format!( + "unexpected rule {:?}, expected if_unwrap_clause or if_condition", + other + ), location: location.unwrap_or_default(), }); } @@ -1674,7 +1715,10 @@ impl<'a> ParserState<'a> { /// Parse range bound term. /// range_bound_term = { number_literal | range_bound_field_access | identifier | "(" ~ ws ~ range_bound ~ ws ~ ")" } fn parse_range_bound_term(&self, pair: Pair) -> ParseResult { - let inner = pair.into_inner().next().expect("range_bound_term needs content"); + let inner = pair + .into_inner() + .next() + .expect("range_bound_term needs content"); match inner.as_rule() { Rule::number_literal | Rule::identifier => self.parse_primary_expr(inner), Rule::range_bound_field_access => self.parse_range_bound_field_access(inner), @@ -1698,7 +1742,8 @@ impl<'a> ParserState<'a> { // Remaining member names are field accesses for field_pair in inner { - if field_pair.as_rule() == Rule::identifier || field_pair.as_rule() == Rule::member_name { + if field_pair.as_rule() == Rule::identifier || field_pair.as_rule() == Rule::member_name + { result = Expr::Field(Box::new(FieldExpr { object: result, field: field_pair.as_str().to_string(), @@ -1731,7 +1776,11 @@ impl<'a> ParserState<'a> { } } - Ok(Expr::Range(Box::new(RangeExpr { start, end, location }))) + Ok(Expr::Range(Box::new(RangeExpr { + start, + end, + location, + }))) } /// Parse capture list. @@ -1815,7 +1864,11 @@ impl<'a> ParserState<'a> { /// Parse return statement. fn parse_return_stmt(&self, pair: Pair) -> ParseResult { let location = Some(self.location(&pair)); - let value = pair.into_inner().next().map(|p| self.parse_expr(p)).transpose()?; + let value = pair + .into_inner() + .next() + .map(|p| self.parse_expr(p)) + .transpose()?; Ok(ReturnStmt { value, location }) } @@ -1887,10 +1940,12 @@ impl<'a> ParserState<'a> { } } - let body = body.ok_or_else(|| self.error_at( - location.clone().unwrap_or_default(), - "errdefer requires a body" - ))?; + let body = body.ok_or_else(|| { + self.error_at( + location.clone().unwrap_or_default(), + "errdefer requires a body", + ) + })?; Ok(ErrDeferStmt { body, @@ -1983,7 +2038,8 @@ impl<'a> ParserState<'a> { fn parse_or_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let mut left = self.parse_catch_expr(self.expect_next(&mut inner, &loc, "or operand")?)?; + let mut left = + self.parse_catch_expr(self.expect_next(&mut inner, &loc, "or operand")?)?; while let Some(next_pair) = inner.next() { // Skip the or_kw operator rule @@ -2010,7 +2066,8 @@ impl<'a> ParserState<'a> { let location = Some(self.location(&pair)); let loc = location.clone().unwrap_or_default(); let mut inner = pair.into_inner(); - let mut left = self.parse_orelse_expr(self.expect_next(&mut inner, &loc, "catch operand")?)?; + let mut left = + self.parse_orelse_expr(self.expect_next(&mut inner, &loc, "catch operand")?)?; while let Some(next_pair) = inner.next() { // Skip the catch_kw operator rule if present @@ -2024,7 +2081,11 @@ impl<'a> ParserState<'a> { if next_pair.as_rule() == Rule::identifier { // This is the capture variable: catch |err| handler let capture = Some(next_pair.as_str().to_string()); - let handler = self.parse_orelse_expr(self.expect_next(&mut inner, &loc, "catch handler body")?)?; + let handler = self.parse_orelse_expr(self.expect_next( + &mut inner, + &loc, + "catch handler body", + )?)?; left = Expr::Catch(Box::new(CatchExpr { operand: left, capture, @@ -2051,7 +2112,8 @@ impl<'a> ParserState<'a> { fn parse_orelse_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let mut left = self.parse_and_expr(self.expect_next(&mut inner, &loc, "orelse operand")?)?; + let mut left = + self.parse_and_expr(self.expect_next(&mut inner, &loc, "orelse operand")?)?; while let Some(next_pair) = inner.next() { // Skip the orelse_kw operator rule @@ -2102,7 +2164,8 @@ impl<'a> ParserState<'a> { fn parse_binary_chain(&self, pair: Pair<'a, Rule>, op: BinaryOp) -> ParseResult { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let mut left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "binary operand")?)?; + let mut left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "binary operand")?)?; while let Some(next_pair) = inner.next() { // Skip operator rules (symbol operators) @@ -2151,11 +2214,16 @@ impl<'a> ParserState<'a> { fn parse_cmp_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "cmp operand")?)?; + let left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "cmp operand")?)?; if let Some(op_pair) = inner.next() { let op = self.parse_cmp_op(op_pair)?; - let right = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "cmp right operand")?)?; + let right = self.parse_next_precedence(self.expect_next( + &mut inner, + &loc, + "cmp right operand", + )?)?; Ok(Expr::Binary(Box::new(BinaryExpr { op, left, @@ -2194,7 +2262,8 @@ impl<'a> ParserState<'a> { fn parse_shift_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let mut left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "shift operand")?)?; + let mut left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "shift operand")?)?; while let Some(op_pair) = inner.next() { if op_pair.as_rule() != Rule::shift_op { @@ -2205,7 +2274,11 @@ impl<'a> ParserState<'a> { ">>" => BinaryOp::Shr, _ => continue, }; - let right = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "shift right operand")?)?; + let right = self.parse_next_precedence(self.expect_next( + &mut inner, + &loc, + "shift right operand", + )?)?; left = Expr::Binary(Box::new(BinaryExpr { op, left, @@ -2221,7 +2294,8 @@ impl<'a> ParserState<'a> { fn parse_add_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let mut left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "add operand")?)?; + let mut left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "add operand")?)?; while let Some(op_pair) = inner.next() { if op_pair.as_rule() != Rule::add_op { @@ -2232,7 +2306,11 @@ impl<'a> ParserState<'a> { "-" => BinaryOp::Sub, _ => continue, }; - let right = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "add right operand")?)?; + let right = self.parse_next_precedence(self.expect_next( + &mut inner, + &loc, + "add right operand", + )?)?; left = Expr::Binary(Box::new(BinaryExpr { op, left, @@ -2248,7 +2326,8 @@ impl<'a> ParserState<'a> { fn parse_mul_expr(&self, pair: Pair<'a, Rule>) -> ParseResult { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let mut left = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "mul operand")?)?; + let mut left = + self.parse_next_precedence(self.expect_next(&mut inner, &loc, "mul operand")?)?; while let Some(op_pair) = inner.next() { if op_pair.as_rule() != Rule::mul_op { @@ -2260,7 +2339,11 @@ impl<'a> ParserState<'a> { "%" => BinaryOp::Mod, _ => continue, }; - let right = self.parse_next_precedence(self.expect_next(&mut inner, &loc, "mul right operand")?)?; + let right = self.parse_next_precedence(self.expect_next( + &mut inner, + &loc, + "mul right operand", + )?)?; left = Expr::Binary(Box::new(BinaryExpr { op, left, @@ -2279,39 +2362,44 @@ impl<'a> ParserState<'a> { let mut inner = pair.into_inner(); // Parse the expression part - let value = self.parse_next_precedence(self.expect_next(&mut inner, &location, "expression in suffixed expr")?)?; + let value = self.parse_next_precedence(self.expect_next( + &mut inner, + &location, + "expression in suffixed expr", + )?)?; // Check for optional suffix (angle unit or type) if let Some(suffix_pair) = inner.next() - && suffix_pair.as_rule() == Rule::expr_suffix { - // Get the inner rule (angle_unit or type_suffix) - let inner_suffix = self.expect_inner(suffix_pair, "expr_suffix")?; - match inner_suffix.as_rule() { - Rule::angle_unit => { - let unit = match inner_suffix.as_str() { - "turns" => AngleUnit::Turns, - "rad" => AngleUnit::Rad, - _ => return Err(self.error(&inner_suffix, "unknown angle unit")), - }; - return Ok(Expr::AngleLit(Box::new(AngleLit { - value, - unit, - location: Some(location), - }))); - } - Rule::type_ascription_suffix => { - // Get the actual type keyword - let type_inner = self.expect_inner(inner_suffix, "type_ascription_suffix")?; - let type_name = type_inner.as_str().to_string(); - return Ok(Expr::TypeAscription(Box::new(TypeAscription { - value, - type_name, - location: Some(location), - }))); - } - _ => {} + && suffix_pair.as_rule() == Rule::expr_suffix + { + // Get the inner rule (angle_unit or type_suffix) + let inner_suffix = self.expect_inner(suffix_pair, "expr_suffix")?; + match inner_suffix.as_rule() { + Rule::angle_unit => { + let unit = match inner_suffix.as_str() { + "turns" => AngleUnit::Turns, + "rad" => AngleUnit::Rad, + _ => return Err(self.error(&inner_suffix, "unknown angle unit")), + }; + return Ok(Expr::AngleLit(Box::new(AngleLit { + value, + unit, + location: Some(location), + }))); + } + Rule::type_ascription_suffix => { + // Get the actual type keyword + let type_inner = self.expect_inner(inner_suffix, "type_ascription_suffix")?; + let type_name = type_inner.as_str().to_string(); + return Ok(Expr::TypeAscription(Box::new(TypeAscription { + value, + type_name, + location: Some(location), + }))); } + _ => {} } + } // No suffix - return the value as-is Ok(value) @@ -2425,7 +2513,10 @@ impl<'a> ParserState<'a> { })); } Rule::field_access => { - let field = self.expect_inner(actual_op, "field_access")?.as_str().to_string(); + let field = self + .expect_inner(actual_op, "field_access")? + .as_str() + .to_string(); expr = Expr::Field(Box::new(FieldExpr { object: expr, field, @@ -2820,34 +2911,51 @@ impl<'a> ParserState<'a> { let s = num_str.replace('_', ""); // Check for float (must check before extracting suffix changes things) - let is_float = s.contains('.') || - (s.contains('e') || s.contains('E')) && !s.starts_with("0x") && !s.starts_with("0X"); + let is_float = s.contains('.') + || (s.contains('e') || s.contains('E')) && !s.starts_with("0x") && !s.starts_with("0X"); if is_float { - let value: f64 = s.parse().map_err(|_| { - self.error_at(loc.clone(), "invalid float literal") - })?; - Ok(Expr::FloatLit(FloatLit { value, suffix, location: Some(loc) })) + let value: f64 = s + .parse() + .map_err(|_| self.error_at(loc.clone(), "invalid float literal"))?; + Ok(Expr::FloatLit(FloatLit { + value, + suffix, + location: Some(loc), + })) } else if s.starts_with("0x") || s.starts_with("0X") { - let value = i128::from_str_radix(&s[2..], 16).map_err(|_| { - self.error_at(loc.clone(), "invalid hex literal") - })?; - Ok(Expr::IntLit(IntLit { value, suffix, location: Some(loc) })) + let value = i128::from_str_radix(&s[2..], 16) + .map_err(|_| self.error_at(loc.clone(), "invalid hex literal"))?; + Ok(Expr::IntLit(IntLit { + value, + suffix, + location: Some(loc), + })) } else if s.starts_with("0b") || s.starts_with("0B") { - let value = i128::from_str_radix(&s[2..], 2).map_err(|_| { - self.error_at(loc.clone(), "invalid binary literal") - })?; - Ok(Expr::IntLit(IntLit { value, suffix, location: Some(loc) })) + let value = i128::from_str_radix(&s[2..], 2) + .map_err(|_| self.error_at(loc.clone(), "invalid binary literal"))?; + Ok(Expr::IntLit(IntLit { + value, + suffix, + location: Some(loc), + })) } else if s.starts_with("0o") || s.starts_with("0O") { - let value = i128::from_str_radix(&s[2..], 8).map_err(|_| { - self.error_at(loc.clone(), "invalid octal literal") - })?; - Ok(Expr::IntLit(IntLit { value, suffix, location: Some(loc) })) + let value = i128::from_str_radix(&s[2..], 8) + .map_err(|_| self.error_at(loc.clone(), "invalid octal literal"))?; + Ok(Expr::IntLit(IntLit { + value, + suffix, + location: Some(loc), + })) } else { - let value: i128 = s.parse().map_err(|_| { - self.error_at(loc.clone(), "invalid integer literal") - })?; - Ok(Expr::IntLit(IntLit { value, suffix, location: Some(loc) })) + let value: i128 = s + .parse() + .map_err(|_| self.error_at(loc.clone(), "invalid integer literal"))?; + Ok(Expr::IntLit(IntLit { + value, + suffix, + location: Some(loc), + })) } } @@ -2856,8 +2964,8 @@ impl<'a> ParserState<'a> { fn extract_number_suffix<'b>(&self, s: &'b str) -> (&'b str, Option) { // Integer suffixes (check longer ones first) const INT_SUFFIXES: &[&str] = &[ - "u128", "i128", "usize", "isize", - "u64", "i64", "u32", "i32", "u16", "i16", "u8", "i8", "u1", "i1", + "u128", "i128", "usize", "isize", "u64", "i64", "u32", "i32", "u16", "i16", "u8", "i8", + "u1", "i1", ]; // Float suffixes const FLOAT_SUFFIXES: &[&str] = &["f128", "f64", "f32", "f16", "a64"]; @@ -2867,12 +2975,17 @@ impl<'a> ParserState<'a> { // Check for _suffix pattern let with_underscore = format!("_{}", suffix); if s.ends_with(&with_underscore) { - return (&s[..s.len() - with_underscore.len()], Some(suffix.to_string())); + return ( + &s[..s.len() - with_underscore.len()], + Some(suffix.to_string()), + ); } // Check for direct suffix (no underscore) if let Some(prefix) = s.strip_suffix(suffix) { // Make sure we're not matching part of a hex digit - if !prefix.is_empty() && (prefix.ends_with(|c: char| c.is_ascii_digit()) || prefix.ends_with('_')) { + if !prefix.is_empty() + && (prefix.ends_with(|c: char| c.is_ascii_digit()) || prefix.ends_with('_')) + { return (prefix, Some(suffix.to_string())); } } @@ -2957,7 +3070,9 @@ impl<'a> ParserState<'a> { } } - let expr = expr.ok_or_else(|| self.error_at(location, "expected expression in f-string interpolation"))?; + let expr = expr.ok_or_else(|| { + self.error_at(location, "expected expression in f-string interpolation") + })?; Ok((expr, format)) } @@ -3003,7 +3118,10 @@ impl<'a> ParserState<'a> { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let name = self.expect_next(&mut inner, &loc, "builtin name")?.as_str().to_string(); + let name = self + .expect_next(&mut inner, &loc, "builtin name")? + .as_str() + .to_string(); let location = Some(loc); let args = if let Some(arg_list) = inner.next() { self.parse_arg_list(arg_list)? @@ -3029,7 +3147,10 @@ impl<'a> ParserState<'a> { Rule::anon_struct_init => self.parse_anon_struct_init(inner_pair, location), other => { return Err(ParseError { - message: format!("unexpected rule {:?}, expected typed_struct_init or anon_struct_init", other), + message: format!( + "unexpected rule {:?}, expected typed_struct_init or anon_struct_init", + other + ), location: location.unwrap_or_default(), }); } @@ -3103,7 +3224,10 @@ impl<'a> ParserState<'a> { let loc = self.location(&pair); let mut inner = pair.into_inner(); - let name = self.expect_next(&mut inner, &loc, "field name")?.as_str().to_string(); + let name = self + .expect_next(&mut inner, &loc, "field name")? + .as_str() + .to_string(); let location = Some(loc); // Rust-style: `field: value` or shorthand `field` (when var name matches) @@ -3238,10 +3362,15 @@ impl<'a> ParserState<'a> { location: else_parsed.location, })) } - _ => return Err(ParseError { - message: format!("Expected block or if_expr in else branch, got {:?}", else_item.as_rule()), - location: self.location(&else_item), - }), + _ => { + return Err(ParseError { + message: format!( + "Expected block or if_expr in else branch, got {:?}", + else_item.as_rule() + ), + location: self.location(&else_item), + }); + } }; Ok(Expr::If(Box::new(IfExpr { @@ -3304,16 +3433,17 @@ impl<'a> ParserState<'a> { // Check for type_suffix (error union: E!T where E is error, T is payload) if let Some(suffix_pair) = inner.next() - && suffix_pair.as_rule() == Rule::type_suffix { - // type_suffix = { "!" ~ type_prefix } - // In E!T syntax: base_type is E (error), suffix is T (payload) - let payload_type_pair = self.expect_inner(suffix_pair, "type_suffix")?; - let payload_type = self.parse_type_prefix(payload_type_pair)?; - return Ok(TypeExpr::ErrorUnion(Box::new(ErrorUnionType { - error_type: base_type, - payload_type, - }))); - } + && suffix_pair.as_rule() == Rule::type_suffix + { + // type_suffix = { "!" ~ type_prefix } + // In E!T syntax: base_type is E (error), suffix is T (payload) + let payload_type_pair = self.expect_inner(suffix_pair, "type_suffix")?; + let payload_type = self.parse_type_prefix(payload_type_pair)?; + return Ok(TypeExpr::ErrorUnion(Box::new(ErrorUnionType { + error_type: base_type, + payload_type, + }))); + } Ok(base_type) } @@ -3325,7 +3455,8 @@ impl<'a> ParserState<'a> { match inner.as_rule() { Rule::optional_type => { - let inner_type = self.parse_type_prefix(self.expect_inner(inner, "optional_type")?)?; + let inner_type = + self.parse_type_prefix(self.expect_inner(inner, "optional_type")?)?; Ok(TypeExpr::Optional(Box::new(inner_type))) } Rule::pointer_type => self.parse_pointer_type(inner), @@ -3521,7 +3652,10 @@ impl<'a> ParserState<'a> { /// Parse array size term. /// array_size_term = { number_literal | identifier | "(" ~ ws ~ array_size ~ ws ~ ")" } fn parse_array_size_term(&self, pair: Pair) -> ParseResult { - let inner = pair.into_inner().next().expect("array_size_term needs content"); + let inner = pair + .into_inner() + .next() + .expect("array_size_term needs content"); match inner.as_rule() { Rule::number_literal | Rule::identifier => self.parse_primary_expr(inner), Rule::array_size => self.parse_array_size_expr(inner), @@ -3592,12 +3726,13 @@ impl<'a> ParserState<'a> { // Check for Self type first (has inner rule) let inner = pair.clone().into_inner().next(); if let Some(inner_pair) = inner - && inner_pair.as_rule() == Rule::self_type { - return Ok(TypeExpr::Named(TypePath { - segments: vec!["Self".to_string()], - location: Some(self.location(&pair)), - })); - } + && inner_pair.as_rule() == Rule::self_type + { + return Ok(TypeExpr::Named(TypePath { + segments: vec!["Self".to_string()], + location: Some(self.location(&pair)), + })); + } let s = pair.as_str(); @@ -3644,14 +3779,16 @@ impl<'a> ParserState<'a> { // Valid bit widths are 1-128 if let Some(bits_str) = s.strip_prefix('u') { if let Ok(bits) = bits_str.parse::() - && (1..=128).contains(&bits) { - return Some(PrimitiveType::UInt { bits }); - } + && (1..=128).contains(&bits) + { + return Some(PrimitiveType::UInt { bits }); + } } else if let Some(bits_str) = s.strip_prefix('i') && let Ok(bits) = bits_str.parse::() - && (1..=128).contains(&bits) { - return Some(PrimitiveType::IInt { bits }); - } + && (1..=128).contains(&bits) + { + return Some(PrimitiveType::IInt { bits }); + } None } @@ -3692,7 +3829,10 @@ impl<'a> ParserState<'a> { let expr = value.expect("channel_arg requires expression"); if let Some(n) = name { - Ok(ChannelArg::Named { name: n, value: expr }) + Ok(ChannelArg::Named { + name: n, + value: expr, + }) } else { Ok(ChannelArg::Positional(expr)) } @@ -3747,20 +3887,22 @@ impl<'a> ParserState<'a> { location: location.clone(), })) } - Rule::paren_or_tuple => { - self.parse_primary_expr(target_inner)? - } + Rule::paren_or_tuple => self.parse_primary_expr(target_inner)?, Rule::bracket_array => self.parse_primary_expr(target_inner)?, Rule::postfix_expr => self.parse_postfix_expr(target_inner)?, Rule::gate_qubit_target => { // gate_qubit_target = { !operator_keyword ~ postfix_expr } // Just parse the inner postfix_expr - let inner_expr = self.expect_inner(target_inner, "gate_qubit_target")?; + let inner_expr = + self.expect_inner(target_inner, "gate_qubit_target")?; self.parse_postfix_expr(inner_expr)? } other => { return Err(ParseError { - message: format!("unexpected rule {:?}, expected gate_target", other), + message: format!( + "unexpected rule {:?}, expected gate_target", + other + ), location: location.clone().unwrap_or_default(), }); } @@ -3845,17 +3987,13 @@ impl<'a> ParserState<'a> { /// Known gate names for suggestions. const KNOWN_GATE_NAMES: &[&str] = &[ - "x", "y", "z", "h", "t", "tdg", "sx", "sy", "sz", "sxdg", "sydg", "szdg", - "rx", "ry", "rz", "cx", "cy", "cz", "ch", "sxx", "syy", "szz", - "sxxdg", "syydg", "szzdg", "rzz", "crz", "swap", "iswap", "ccx", - "f", "fdg", "f4", "f4dg", "pz", + "x", "y", "z", "h", "t", "tdg", "sx", "sy", "sz", "sxdg", "sydg", "szdg", "rx", "ry", "rz", + "cx", "cy", "cz", "ch", "sxx", "syy", "szz", "sxxdg", "syydg", "szzdg", "rzz", "crz", "swap", + "iswap", "ccx", "f", "fdg", "f4", "f4dg", "pz", ]; /// Deprecated gate name mappings. -const DEPRECATED_GATES: &[(&str, &str)] = &[ - ("s", "sz"), - ("sdg", "szdg"), -]; +const DEPRECATED_GATES: &[(&str, &str)] = &[("s", "sz"), ("sdg", "szdg")]; /// Suggest a gate name for a misspelled or deprecated gate keyword. pub fn suggest_gate_name(unknown: &str) -> Option<&'static str> { @@ -3898,10 +4036,12 @@ pub fn edit_distance(a: &str, b: &str) -> usize { let mut curr = vec![0usize; n + 1]; curr[0] = i; for j in 1..=n { - let cost = if a_bytes[i - 1] == b_bytes[j - 1] { 0 } else { 1 }; - curr[j] = (prev[j] + 1) - .min(curr[j - 1] + 1) - .min(prev[j - 1] + cost); + let cost = if a_bytes[i - 1] == b_bytes[j - 1] { + 0 + } else { + 1 + }; + curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost); } prev = curr; } @@ -3915,7 +4055,10 @@ pub fn parse(source: &str) -> ParseResult { let result = ParserState::new(source).parse(); match &result { Ok(program) => { - log::debug!("Parsed {} top-level declarations", program.declarations.len()); + log::debug!( + "Parsed {} top-level declarations", + program.declarations.len() + ); log::trace!("AST: {:?}", program); } Err(e) => { @@ -3932,7 +4075,11 @@ pub fn parse_file(source: &str, filename: impl Into) -> ParseResult { - log::debug!("Parsed {} top-level declarations from '{}'", program.declarations.len(), filename); + log::debug!( + "Parsed {} top-level declarations from '{}'", + program.declarations.len(), + filename + ); } Err(e) => { log::debug!("Parse error in '{}': {}", filename, e); @@ -3991,7 +4138,10 @@ mod tests { panic!("Value should be Call, got: {:?}", binding.value); } } else { - panic!("Should be a Binding declaration, got: {:?}", program.declarations[0]); + panic!( + "Should be a Binding declaration, got: {:?}", + program.declarations[0] + ); } } @@ -4049,6 +4199,10 @@ mod tests { let source = "fn main() -> unit { return unit; }"; assert!(source.len() < MAX_SOURCE_SIZE); let result = parse(source); - assert!(result.is_ok(), "Expected normal source to parse: {:?}", result); + assert!( + result.is_ok(), + "Expected normal source to parse: {:?}", + result + ); } } diff --git a/exp/zlup/src/pretty.rs b/exp/zlup/src/pretty.rs index 66c4a23b2..d72a463b1 100644 --- a/exp/zlup/src/pretty.rs +++ b/exp/zlup/src/pretty.rs @@ -113,12 +113,16 @@ impl PrettyPrinter { TopLevelDecl::DeclareGate(g) => { self.write(&format!("declare gate {}(", g.name)); for (i, p) in g.params.iter().enumerate() { - if i > 0 { self.write(", "); } + if i > 0 { + self.write(", "); + } self.write(&p.name); } self.write(")("); for (i, q) in g.qubits.iter().enumerate() { - if i > 0 { self.write(", "); } + if i > 0 { + self.write(", "); + } self.write(&q.name); } self.write(");"); @@ -127,12 +131,16 @@ impl PrettyPrinter { TopLevelDecl::Gate(g) => { self.write(&format!("gate {}(", g.name)); for (i, p) in g.params.iter().enumerate() { - if i > 0 { self.write(", "); } + if i > 0 { + self.write(", "); + } self.write(&p.name); } self.write(")("); for (i, q) in g.qubits.iter().enumerate() { - if i > 0 { self.write(", "); } + if i > 0 { + self.write(", "); + } self.write(&q.name); } self.write(") "); @@ -218,16 +226,17 @@ impl PrettyPrinter { // Check for Rust-style self parameter if param.name == "self" && let TypeExpr::Pointer(ptr) = ¶m.ty - && let TypeExpr::Named(path) = &ptr.pointee - && path.segments == vec!["Self".to_string()] { - // This is a self parameter - print as &self or &mut self - if ptr.is_const { - self.write("&self"); - } else { - self.write("&mut self"); - } - return; - } + && let TypeExpr::Named(path) = &ptr.pointee + && path.segments == vec!["Self".to_string()] + { + // This is a self parameter - print as &self or &mut self + if ptr.is_const { + self.write("&self"); + } else { + self.write("&mut self"); + } + return; + } // Regular parameter if param.is_comptime { @@ -1128,7 +1137,8 @@ impl PrettyPrinter { self.write(", "); } // Check for shorthand: field name matches identifier value - let is_shorthand = matches!(&field.value, Expr::Ident(ident) if ident.name == field.name); + let is_shorthand = + matches!(&field.value, Expr::Ident(ident) if ident.name == field.name); if is_shorthand { // Shorthand: just `name` instead of `name: name` self.write(&field.name); diff --git a/exp/zlup/src/rational.rs b/exp/zlup/src/rational.rs index 01868757f..386e33b4b 100644 --- a/exp/zlup/src/rational.rs +++ b/exp/zlup/src/rational.rs @@ -94,11 +94,7 @@ impl Rational { /// Convert to an integer if exact, otherwise None. pub fn to_integer(&self) -> Option { - if self.den == 1 { - Some(self.num) - } else { - None - } + if self.den == 1 { Some(self.num) } else { None } } /// Convert to f64. @@ -405,11 +401,7 @@ impl Rational { let result = if diff1 <= diff2 { bound1 } else { bound2 }; - if self.num < 0 { - -result - } else { - result - } + if self.num < 0 { -result } else { result } } /// Smart float-to-rational conversion. @@ -438,9 +430,10 @@ impl Rational { // 3. Check for common fractions (most quantum angles) if let Some(r) = Self::from_f64_common(value) - && r.den <= max_denominator { - return Some(r); - } + && r.den <= max_denominator + { + return Some(r); + } // 4. Try exact conversion with limit if let Some(exact) = Self::from_f64_exact(value) { @@ -715,7 +708,10 @@ mod tests { assert_eq!(Rational::from_f64_common(0.25), Some(Rational::new(1, 4))); assert_eq!(Rational::from_f64_common(0.125), Some(Rational::new(1, 8))); assert_eq!(Rational::from_f64_common(0.5), Some(Rational::new(1, 2))); - assert_eq!(Rational::from_f64_common(1.0 / 3.0), Some(Rational::new(1, 3))); + assert_eq!( + Rational::from_f64_common(1.0 / 3.0), + Some(Rational::new(1, 3)) + ); } #[test] @@ -776,7 +772,10 @@ mod tests { fn test_limit_denominator() { // 0.1 exact representation has large denominator let exact = Rational::from_f64_exact(0.1).unwrap(); - assert!(exact.denominator() > 10, "0.1 exact should have large denominator"); + assert!( + exact.denominator() > 10, + "0.1 exact should have large denominator" + ); // Limit to 10 should give 1/10 let limited = exact.limit_denominator(10); @@ -811,9 +810,15 @@ mod tests { #[test] fn test_from_f64_best() { // Common fractions should be recognized - assert_eq!(Rational::from_f64_best(0.25, 100), Some(Rational::new(1, 4))); + assert_eq!( + Rational::from_f64_best(0.25, 100), + Some(Rational::new(1, 4)) + ); assert_eq!(Rational::from_f64_best(0.5, 100), Some(Rational::new(1, 2))); - assert_eq!(Rational::from_f64_best(1.0 / 3.0, 100), Some(Rational::new(1, 3))); + assert_eq!( + Rational::from_f64_best(1.0 / 3.0, 100), + Some(Rational::new(1, 3)) + ); // Integers assert_eq!(Rational::from_f64_best(5.0, 100), Some(Rational::new(5, 1))); @@ -827,18 +832,30 @@ mod tests { fn test_quantum_angle_fractions() { // Common quantum computing angles as fractions of a turn // T-gate: 1/8 turn - assert_eq!(Rational::from_f64_best(0.125, 100), Some(Rational::new(1, 8))); + assert_eq!( + Rational::from_f64_best(0.125, 100), + Some(Rational::new(1, 8)) + ); // S-gate: 1/4 turn - assert_eq!(Rational::from_f64_best(0.25, 100), Some(Rational::new(1, 4))); + assert_eq!( + Rational::from_f64_best(0.25, 100), + Some(Rational::new(1, 4)) + ); // Z-gate: 1/2 turn assert_eq!(Rational::from_f64_best(0.5, 100), Some(Rational::new(1, 2))); // T-dagger: 7/8 turn - assert_eq!(Rational::from_f64_best(0.875, 100), Some(Rational::new(7, 8))); + assert_eq!( + Rational::from_f64_best(0.875, 100), + Some(Rational::new(7, 8)) + ); // S-dagger: 3/4 turn - assert_eq!(Rational::from_f64_best(0.75, 100), Some(Rational::new(3, 4))); + assert_eq!( + Rational::from_f64_best(0.75, 100), + Some(Rational::new(3, 4)) + ); } } diff --git a/exp/zlup/src/semantic.rs b/exp/zlup/src/semantic.rs index f921ee66d..74076dfd1 100644 --- a/exp/zlup/src/semantic.rs +++ b/exp/zlup/src/semantic.rs @@ -36,13 +36,12 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; use thiserror::Error; -use crate::module::{ModuleLoader, ExportedSymbol}; use crate::ast::{ - self, BinaryOp, Binding, Block, ElseBranch, Expr, FnDecl, ForRange, FStringPart, - PrimitiveType, Program, SourceLocation, Stmt, StructDecl, TopLevelDecl, TypeExpr, - UnaryOp, + self, BinaryOp, Binding, Block, ElseBranch, Expr, FStringPart, FnDecl, ForRange, PrimitiveType, + Program, SourceLocation, Stmt, StructDecl, TopLevelDecl, TypeExpr, UnaryOp, }; use crate::comptime::{ComptimeEvaluator, ComptimeValue}; +use crate::module::{ExportedSymbol, ModuleLoader}; // ============================================================================= // Semantic Errors @@ -76,10 +75,14 @@ pub enum SemanticError { location: SourceLocation, }, - #[error("empty array literal requires explicit type annotation: use `[]: [0]T` or provide elements")] + #[error( + "empty array literal requires explicit type annotation: use `[]: [0]T` or provide elements" + )] EmptyArrayNeedsType { location: SourceLocation }, - #[error("empty set literal requires explicit type annotation: use `set{{}} as Set(T)` or provide elements")] + #[error( + "empty set literal requires explicit type annotation: use `set{{}} as Set(T)` or provide elements" + )] EmptySetNeedsType { location: SourceLocation }, #[error("invalid integer bit width {bits}: must be between 1 and 128")] @@ -93,8 +96,13 @@ pub enum SemanticError { location: SourceLocation, }, - #[error("ambiguous target for multi-qubit gate '{gate}': use explicit qubit pairs like '{gate} (q[0], q[1])' or batch '{gate} {{(q[0], q[1]), ...}}'")] - AmbiguousGateTarget { gate: String, location: SourceLocation }, + #[error( + "ambiguous target for multi-qubit gate '{gate}': use explicit qubit pairs like '{gate} (q[0], q[1])' or batch '{gate} {{(q[0], q[1]), ...}}'" + )] + AmbiguousGateTarget { + gate: String, + location: SourceLocation, + }, #[error("invalid gate syntax: use '{gate} {hint}' instead of '{gate}(...)'")] InvalidGateSyntax { @@ -175,13 +183,17 @@ pub enum SemanticError { location: SourceLocation, }, - #[error("cannot call .child() on immutable allocator '{name}' - declare with 'mut' to partition: mut {name} := qalloc(...)")] + #[error( + "cannot call .child() on immutable allocator '{name}' - declare with 'mut' to partition: mut {name} := qalloc(...)" + )] ChildRequiresMutableParent { name: String, location: SourceLocation, }, - #[error("cannot assign to immutable variable '{name}' - declare with 'mut' to allow modification: mut {name} := ...")] + #[error( + "cannot assign to immutable variable '{name}' - declare with 'mut' to allow modification: mut {name} := ..." + )] ImmutableAssignment { name: String, location: SourceLocation, @@ -225,7 +237,9 @@ pub enum SemanticError { location: SourceLocation, }, - #[error("measurement type mismatch: declared [{declared}]{element} but measuring {actual} qubit(s)")] + #[error( + "measurement type mismatch: declared [{declared}]{element} but measuring {actual} qubit(s)" + )] MeasurementSizeMismatch { declared: String, element: String, @@ -247,13 +261,17 @@ pub enum SemanticError { location: SourceLocation, }, - #[error("pack mode requires compile-time verifiable type size, but '{ty}' has unknown bit capacity")] + #[error( + "pack mode requires compile-time verifiable type size, but '{ty}' has unknown bit capacity" + )] MeasurementPackUnknownSize { ty: String, location: SourceLocation, }, - #[error("qubit '{allocator}[{index}]' used multiple times within tick block - parallel operations cannot target the same qubit")] + #[error( + "qubit '{allocator}[{index}]' used multiple times within tick block - parallel operations cannot target the same qubit" + )] DuplicateQubitInTick { allocator: String, index: usize, @@ -276,7 +294,9 @@ pub enum SemanticError { location: SourceLocation, }, - #[error("inline for range must be comptime-evaluable, but '{expr}' cannot be evaluated at compile time")] + #[error( + "inline for range must be comptime-evaluable, but '{expr}' cannot be evaluated at compile time" + )] InlineForRangeNotComptime { expr: String, location: SourceLocation, @@ -285,10 +305,14 @@ pub enum SemanticError { #[error("'break' is not allowed in inline for loops - inline for is unrolled at compile time")] BreakInInlineFor { location: SourceLocation }, - #[error("'continue' is not allowed in inline for loops - inline for is unrolled at compile time")] + #[error( + "'continue' is not allowed in inline for loops - inline for is unrolled at compile time" + )] ContinueInInlineFor { location: SourceLocation }, - #[error("alias '{new_alias}' overlaps with existing alias '{existing_alias}' on source '{source_var}'")] + #[error( + "alias '{new_alias}' overlaps with existing alias '{existing_alias}' on source '{source_var}'" + )] OverlappingAlias { new_alias: String, existing_alias: String, @@ -303,19 +327,25 @@ pub enum SemanticError { location: SourceLocation, }, - #[error("alias range must be comptime-evaluable for overlap checking, but '{expr}' cannot be evaluated at compile time")] + #[error( + "alias range must be comptime-evaluable for overlap checking, but '{expr}' cannot be evaluated at compile time" + )] AliasRangeNotComptime { expr: String, location: SourceLocation, }, - #[error("missing return statement in function '{name}' - all code paths must have explicit returns (use 'return unit;' for unit functions)")] + #[error( + "missing return statement in function '{name}' - all code paths must have explicit returns (use 'return unit;' for unit functions)" + )] MissingReturn { name: String, location: SourceLocation, }, - #[error("'return;' without a value is only allowed in functions that return unit, but this function returns '{expected}'")] + #[error( + "'return;' without a value is only allowed in functions that return unit, but this function returns '{expected}'" + )] ReturnWithoutValue { expected: String, location: SourceLocation, @@ -355,19 +385,25 @@ pub enum SemanticError { // ========================================================================= // Reference Safety Errors (safe-by-constraint memory model) // ========================================================================= - #[error("cannot return reference to local variable '{name}' - local variables are deallocated when the function returns")] + #[error( + "cannot return reference to local variable '{name}' - local variables are deallocated when the function returns" + )] ReturnReferenceToLocal { name: String, location: SourceLocation, }, - #[error("cannot return slice of local array '{name}' - local arrays are deallocated when the function returns")] + #[error( + "cannot return slice of local array '{name}' - local arrays are deallocated when the function returns" + )] ReturnSliceOfLocal { name: String, location: SourceLocation, }, - #[error("cannot store reference to local '{name}' in outer scope - would create dangling reference")] + #[error( + "cannot store reference to local '{name}' in outer scope - would create dangling reference" + )] ReferenceEscapesScope { name: String, location: SourceLocation, @@ -605,10 +641,14 @@ pub enum Type { // Primitives Bool, // Arbitrary-width integers (like Zig: u1, u4, u7, u128, etc.) - UInt { bits: BitWidth }, // Unsigned integer with N bits - IInt { bits: BitWidth }, // Signed integer with N bits - Usize, // Platform-dependent unsigned size - Isize, // Platform-dependent signed size + UInt { + bits: BitWidth, + }, // Unsigned integer with N bits + IInt { + bits: BitWidth, + }, // Signed integer with N bits + Usize, // Platform-dependent unsigned size + Isize, // Platform-dependent signed size // Floating point F16, F32, @@ -620,7 +660,9 @@ pub enum Type { Qubit, Bit, /// Allocator with known capacity - Allocator { capacity: Option }, + Allocator { + capacity: Option, + }, // Compound types Array { @@ -696,10 +738,10 @@ pub enum Type { AnyFault, // Special types - Unit, // Unit type - has exactly one value - Type, // The metatype (type of types) + Unit, // Unit type - has exactly one value + Type, // The metatype (type of types) Comptime(Box), // Comptime-known value of this type - Never, // Bottom type (for functions that don't return) + Never, // Bottom type (for functions that don't return) /// Imported module type Module { @@ -755,10 +797,7 @@ impl Type { /// Check if this type is a quantum type. pub fn is_quantum(&self) -> bool { - matches!( - self, - Type::Qubit | Type::Bit | Type::Allocator { .. } - ) + matches!(self, Type::Qubit | Type::Bit | Type::Allocator { .. }) } /// Get the display name for error messages. @@ -814,7 +853,11 @@ impl Type { return_type, } => { let params_str: Vec<_> = params.iter().map(|p| p.display_name()).collect(); - format!("fn({}) {}", params_str.join(", "), return_type.display_name()) + format!( + "fn({}) {}", + params_str.join(", "), + return_type.display_name() + ) } Type::Struct { name, .. } => name.clone(), Type::Enum { name, .. } => name.clone(), @@ -881,9 +924,10 @@ impl Type { // Collections of types Type::Tuple { elements } => elements.iter().any(|e| e.contains_unknown()), - Type::Function { params, return_type } => { - params.iter().any(|p| p.contains_unknown()) || return_type.contains_unknown() - } + Type::Function { + params, + return_type, + } => params.iter().any(|p| p.contains_unknown()) || return_type.contains_unknown(), // Named types with fields Type::Struct { fields, .. } => fields.iter().any(|(_, ty)| ty.contains_unknown()), @@ -895,9 +939,7 @@ impl Type { Type::Enum { .. } | Type::ErrorSet { .. } | Type::FaultSet { .. } => false, // Module exports - Type::Module { exports, .. } => { - exports.values().any(|(_, ty)| ty.contains_unknown()) - } + Type::Module { exports, .. } => exports.values().any(|(_, ty)| ty.contains_unknown()), } } @@ -1172,12 +1214,13 @@ impl SymbolTable { for symbol in scope.symbols.values() { if let SymbolKind::TypeDef { ty } = &symbol.kind && let Type::ErrorSet { name, errors } = ty - && errors.iter().any(|(n, _)| n == variant_name) { - return Some(Type::ErrorSet { - name: name.clone(), - errors: errors.clone(), - }); - } + && errors.iter().any(|(n, _)| n == variant_name) + { + return Some(Type::ErrorSet { + name: name.clone(), + errors: errors.clone(), + }); + } } scope_idx = scope.parent; } @@ -1193,12 +1236,13 @@ impl SymbolTable { for symbol in scope.symbols.values() { if let SymbolKind::TypeDef { ty } = &symbol.kind && let Type::FaultSet { name, faults } = ty - && faults.iter().any(|(n, _)| n == variant_name) { - return Some(Type::FaultSet { - name: name.clone(), - faults: faults.clone(), - }); - } + && faults.iter().any(|(n, _)| n == variant_name) + { + return Some(Type::FaultSet { + name: name.clone(), + faults: faults.clone(), + }); + } } scope_idx = scope.parent; } @@ -1363,12 +1407,13 @@ impl QubitStateTracker { index: usize, location: &SourceLocation, ) -> SemanticResult<()> { - let alloc = self.allocators.get(allocator).ok_or_else(|| { - SemanticError::AllocatorNotFound { - name: allocator.to_string(), - location: location.clone(), - } - })?; + let alloc = + self.allocators + .get(allocator) + .ok_or_else(|| SemanticError::AllocatorNotFound { + name: allocator.to_string(), + location: location.clone(), + })?; // Check bounds if !alloc.is_in_bounds(index) { @@ -1383,13 +1428,14 @@ impl QubitStateTracker { // Check state (only if capacity is known) if alloc.capacity.is_some() && let Some(state) = alloc.get_state(index) - && !state.can_apply_gate() { - return Err(SemanticError::QubitNotPrepared { - allocator: allocator.to_string(), - index, - location: location.clone(), - }); - } + && !state.can_apply_gate() + { + return Err(SemanticError::QubitNotPrepared { + allocator: allocator.to_string(), + index, + location: location.clone(), + }); + } Ok(()) } @@ -1597,7 +1643,12 @@ impl SemanticAnalyzer { let _ = self.symbols.define(Symbol { name: "qalloc".to_string(), kind: SymbolKind::Function { - params: vec![("capacity".to_string(), Type::UInt { bits: BitWidth::BITS_32 })], + params: vec![( + "capacity".to_string(), + Type::UInt { + bits: BitWidth::BITS_32, + }, + )], return_type: Type::Allocator { capacity: None }, is_pub: true, comptime_param_indices: vec![], @@ -1624,19 +1675,39 @@ impl SemanticAnalyzer { fn populate_builtin_gates(&mut self) { let builtin_gates: &[(&str, usize, usize)] = &[ // (name, num_params, num_qubits) - ("x", 0, 1), ("y", 0, 1), ("z", 0, 1), + ("x", 0, 1), + ("y", 0, 1), + ("z", 0, 1), ("h", 0, 1), - ("t", 0, 1), ("tdg", 0, 1), - ("sx", 0, 1), ("sy", 0, 1), ("sz", 0, 1), - ("sxdg", 0, 1), ("sydg", 0, 1), ("szdg", 0, 1), - ("rx", 1, 1), ("ry", 1, 1), ("rz", 1, 1), - ("cx", 0, 2), ("cy", 0, 2), ("cz", 0, 2), ("ch", 0, 2), - ("sxx", 0, 2), ("syy", 0, 2), ("szz", 0, 2), - ("sxxdg", 0, 2), ("syydg", 0, 2), ("szzdg", 0, 2), + ("t", 0, 1), + ("tdg", 0, 1), + ("sx", 0, 1), + ("sy", 0, 1), + ("sz", 0, 1), + ("sxdg", 0, 1), + ("sydg", 0, 1), + ("szdg", 0, 1), + ("rx", 1, 1), + ("ry", 1, 1), + ("rz", 1, 1), + ("cx", 0, 2), + ("cy", 0, 2), + ("cz", 0, 2), + ("ch", 0, 2), + ("sxx", 0, 2), + ("syy", 0, 2), + ("szz", 0, 2), + ("sxxdg", 0, 2), + ("syydg", 0, 2), + ("szzdg", 0, 2), ("rzz", 1, 2), - ("swap", 0, 2), ("iswap", 0, 2), + ("swap", 0, 2), + ("iswap", 0, 2), ("ccx", 0, 3), - ("f", 0, 1), ("fdg", 0, 1), ("f4", 0, 1), ("f4dg", 0, 1), + ("f", 0, 1), + ("fdg", 0, 1), + ("f4", 0, 1), + ("f4dg", 0, 1), ("pz", 0, 1), ]; @@ -1765,7 +1836,8 @@ impl SemanticAnalyzer { for func in &self.user_functions { if !visited.contains(func) { - if let Some(cycle_func) = self.dfs_detect_cycle(func, &mut visited, &mut rec_stack) { + if let Some(cycle_func) = self.dfs_detect_cycle(func, &mut visited, &mut rec_stack) + { return Err(SemanticError::RecursionDetected { name: cycle_func, location: SourceLocation::default(), @@ -1814,7 +1886,9 @@ impl SemanticAnalyzer { for symbol in scope.symbols.values() { let (ty, context) = match &symbol.kind { SymbolKind::Variable { ty, .. } => (ty, "variable"), - SymbolKind::Function { return_type, .. } => (return_type, "function return type"), + SymbolKind::Function { return_type, .. } => { + (return_type, "function return type") + } SymbolKind::TypeDef { ty } => (ty, "type definition"), SymbolKind::Parameter { ty, .. } => (ty, "parameter"), SymbolKind::Allocator { .. } => continue, @@ -1840,7 +1914,10 @@ impl SemanticAnalyzer { if param_ty.contains_unknown() { self.errors.push(SemanticError::UnresolvedType { ty: param_ty.display_name(), - context: format!("parameter '{}' of function '{}'", param_name, symbol.name), + context: format!( + "parameter '{}' of function '{}'", + param_name, symbol.name + ), location: symbol.location.clone().unwrap_or_default(), }); } @@ -2071,7 +2148,10 @@ impl SemanticAnalyzer { .variants .iter() .map(|v| { - let data_type = v.data_type.as_ref().map(|ty| Box::new(self.resolve_type(ty))); + let data_type = v + .data_type + .as_ref() + .map(|ty| Box::new(self.resolve_type(ty))); (v.name.clone(), data_type) }) .collect(); @@ -2093,7 +2173,10 @@ impl SemanticAnalyzer { .variants .iter() .map(|v| { - let data_type = v.data_type.as_ref().map(|ty| Box::new(self.resolve_type(ty))); + let data_type = v + .data_type + .as_ref() + .map(|ty| Box::new(self.resolve_type(ty))); (v.name.clone(), data_type) }) .collect(); @@ -2114,12 +2197,7 @@ impl SemanticAnalyzer { let fields: Vec<(String, Option)> = union_decl .fields .iter() - .map(|f| { - ( - f.name.clone(), - f.ty.as_ref().map(|t| self.resolve_type(t)), - ) - }) + .map(|f| (f.name.clone(), f.ty.as_ref().map(|t| self.resolve_type(t)))) .collect(); // tag: None = untagged, Some(None) = auto-tagged, Some(Some(_)) = external tag @@ -2220,10 +2298,8 @@ impl SemanticAnalyzer { self.current_function = Some(fn_decl.name.clone()); // Track function in call stack for recursion detection (always enforced) - self.recursion_tracker.enter_function( - &fn_decl.name, - &fn_decl.location.clone().unwrap_or_default(), - )?; + self.recursion_tracker + .enter_function(&fn_decl.name, &fn_decl.location.clone().unwrap_or_default())?; // Define parameters for param in &fn_decl.params { @@ -2239,10 +2315,7 @@ impl SemanticAnalyzer { } // Set return type for return statement checking - let return_type = fn_decl - .return_type - .as_ref() - .map(|t| self.resolve_type(t)); + let return_type = fn_decl.return_type.as_ref().map(|t| self.resolve_type(t)); self.current_return_type = return_type.clone(); // Analyze body @@ -2334,7 +2407,10 @@ impl SemanticAnalyzer { if !has_else { return false; } - switch_stmt.prongs.iter().all(|p| self.expr_always_returns(&p.body)) + switch_stmt + .prongs + .iter() + .all(|p| self.expr_always_returns(&p.body)) } Stmt::For(for_stmt) => { @@ -2449,17 +2525,17 @@ impl SemanticAnalyzer { .register_allocator(AllocatorInfo::new(&binding.name, capacity)); } // Register child allocator if this is base.child(n) - else if let Some((parent, capacity)) = - self.try_extract_child_allocator(value) + else if let Some((parent, capacity)) = self.try_extract_child_allocator(value) { // Check that the parent allocator is mutable if let Some(symbol) = self.symbols.lookup(&parent) - && let SymbolKind::Variable { is_const: true, .. } = &symbol.kind { - return Err(SemanticError::ChildRequiresMutableParent { - name: parent, - location: binding.location.clone().unwrap_or_default(), - }); - } + && let SymbolKind::Variable { is_const: true, .. } = &symbol.kind + { + return Err(SemanticError::ChildRequiresMutableParent { + name: parent, + location: binding.location.clone().unwrap_or_default(), + }); + } self.qubit_states.register_allocator(AllocatorInfo::child( &binding.name, parent, @@ -2475,7 +2551,8 @@ impl SemanticAnalyzer { evaluator.context.define(name, comptime_val.clone()); } if let Ok(comptime_val) = evaluator.eval_expr(value) { - self.comptime_values.insert(binding.name.clone(), comptime_val); + self.comptime_values + .insert(binding.name.clone(), comptime_val); } } @@ -2658,7 +2735,8 @@ impl SemanticAnalyzer { let _value_ty = self.analyze_expr(&switch_stmt.value)?; // Track seen case values for duplicate detection - let mut seen_cases: std::collections::BTreeSet = std::collections::BTreeSet::new(); + let mut seen_cases: std::collections::BTreeSet = + std::collections::BTreeSet::new(); for prong in &switch_stmt.prongs { for case in &prong.cases { @@ -2816,10 +2894,7 @@ impl SemanticAnalyzer { } // Track state transitions - if let Some(alloc) = self - .qubit_states - .get_allocator_mut(&prepare_op.allocator) - { + if let Some(alloc) = self.qubit_states.get_allocator_mut(&prepare_op.allocator) { if let Some(slots) = &prepare_op.slots { // Prepare specific slots for &slot in slots { @@ -2832,7 +2907,10 @@ impl SemanticAnalyzer { allocator: prepare_op.allocator.clone(), index: idx, capacity: alloc.capacity.unwrap_or(0), - location: prepare_op.location.clone().unwrap_or_default(), + location: prepare_op + .location + .clone() + .unwrap_or_default(), }); } } @@ -2863,7 +2941,8 @@ impl SemanticAnalyzer { } // Transition to unprepared after measurement - if let Some(alloc) = self.qubit_states.get_allocator_mut(&target.allocator) { + if let Some(alloc) = self.qubit_states.get_allocator_mut(&target.allocator) + { alloc.measure_slot(index); } } @@ -2921,7 +3000,9 @@ impl SemanticAnalyzer { if let Some(suffix) = &lit.suffix { Ok(int_suffix_to_type(suffix)) } else { - Ok(Type::IInt { bits: BitWidth::BITS_64 }) + Ok(Type::IInt { + bits: BitWidth::BITS_64, + }) } } Expr::FloatLit(lit) => { @@ -2985,7 +3066,9 @@ impl SemanticAnalyzer { } Expr::BoolLit(_) => Ok(Type::Bool), Expr::StringLit(_) => Ok(Type::Slice { - element: Box::new(Type::UInt { bits: BitWidth::BITS_8 }), + element: Box::new(Type::UInt { + bits: BitWidth::BITS_8, + }), }), Expr::FString(fstr) => { // Analyze all interpolated expressions for errors @@ -2996,10 +3079,14 @@ impl SemanticAnalyzer { } // F-strings produce string slices Ok(Type::Slice { - element: Box::new(Type::UInt { bits: BitWidth::BITS_8 }), + element: Box::new(Type::UInt { + bits: BitWidth::BITS_8, + }), }) } - Expr::CharLit(_) => Ok(Type::UInt { bits: BitWidth::BITS_8 }), + Expr::CharLit(_) => Ok(Type::UInt { + bits: BitWidth::BITS_8, + }), Expr::Null(_) => Ok(Type::Optional { inner: Box::new(Type::Unknown), }), @@ -3020,9 +3107,9 @@ impl SemanticAnalyzer { return_type: Box::new(return_type.clone()), }), SymbolKind::TypeDef { ty } => Ok(ty.clone()), - SymbolKind::Allocator { capacity } => { - Ok(Type::Allocator { capacity: *capacity }) - } + SymbolKind::Allocator { capacity } => Ok(Type::Allocator { + capacity: *capacity, + }), } } else { // Check if it's a built-in constant @@ -3136,7 +3223,9 @@ impl SemanticAnalyzer { }); // Now we can use the extracted data with a mutable borrow - if let Some((comptime_param_indices, original_decl, fn_return_type)) = generic_info { + if let Some((comptime_param_indices, original_decl, fn_return_type)) = + generic_info + { // Evaluate comptime arguments let mut comptime_args = Vec::new(); for &idx in &comptime_param_indices { @@ -3174,7 +3263,10 @@ impl SemanticAnalyzer { } match callee_ty { - Type::Function { params, return_type } => { + Type::Function { + params, + return_type, + } => { // Validate argument count if call.args.len() != params.len() { return Err(SemanticError::ArgumentCountMismatch { @@ -3185,17 +3277,15 @@ impl SemanticAnalyzer { } // Validate argument types - for (arg, param_ty) in - call.args.iter().zip(params.iter()) - { + for (arg, param_ty) in call.args.iter().zip(params.iter()) { let arg_ty = self.analyze_expr(arg)?; if !self.types_compatible(&arg_ty, param_ty) { return Err(SemanticError::TypeMismatch { expected: param_ty.display_name(), found: arg_ty.display_name(), - location: arg - .get_location() - .unwrap_or_else(|| call.location.clone().unwrap_or_default()), + location: arg.get_location().unwrap_or_else(|| { + call.location.clone().unwrap_or_default() + }), }); } } @@ -3228,19 +3318,20 @@ impl SemanticAnalyzer { // Validate target arity if we know the gate if let Some(ref name) = gate_name - && let Some(info) = get_gate_info(name) { - for target in &batch.targets { - let target_arity = self.count_target_elements(target); - if target_arity != info.arity { - return Err(SemanticError::GateArityMismatch { - gate: name.clone(), - expected: info.arity, - found: target_arity, - location: batch.location.clone().unwrap_or_default(), - }); - } + && let Some(info) = get_gate_info(name) + { + for target in &batch.targets { + let target_arity = self.count_target_elements(target); + if target_arity != info.arity { + return Err(SemanticError::GateArityMismatch { + gate: name.clone(), + expected: info.arity, + found: target_arity, + location: batch.location.clone().unwrap_or_default(), + }); } } + } for target in &batch.targets { self.analyze_expr(target)?; @@ -3313,10 +3404,12 @@ impl SemanticAnalyzer { Some(s) => *s, None => { return Err(SemanticError::InvalidMeasurementType { - ty: format!("[_]{} - use explicit size like [{}]{}", + ty: format!( + "[_]{} - use explicit size like [{}]{}", element.display_name(), target_count, - element.display_name()), + element.display_name() + ), location, }); } @@ -3378,22 +3471,23 @@ impl SemanticAnalyzer { // For multi-qubit gates (arity > 1), reject bare allocator targets // e.g., `cx q` is ambiguous - use `cx (q[0], q[1])` or `cx {(q[0], q[1]), ...}` if gate_kind.arity() > 1 - && let Expr::Ident(ident) = &gate.target { - // Check if this is an allocator - if let Some(symbol) = self.symbols.lookup(&ident.name) { - let is_allocator = match &symbol.kind { - SymbolKind::Variable { ty, .. } => matches!(ty, Type::Allocator { .. }), - SymbolKind::Allocator { .. } => true, - _ => false, - }; - if is_allocator { - return Err(SemanticError::AmbiguousGateTarget { - gate: format!("{:?}", gate_kind).to_lowercase(), - location: gate.location.clone().unwrap_or_default(), - }); - } + && let Expr::Ident(ident) = &gate.target + { + // Check if this is an allocator + if let Some(symbol) = self.symbols.lookup(&ident.name) { + let is_allocator = match &symbol.kind { + SymbolKind::Variable { ty, .. } => matches!(ty, Type::Allocator { .. }), + SymbolKind::Allocator { .. } => true, + _ => false, + }; + if is_allocator { + return Err(SemanticError::AmbiguousGateTarget { + gate: format!("{:?}", gate_kind).to_lowercase(), + location: gate.location.clone().unwrap_or_default(), + }); } } + } // Check gate target arity let expected_arity = gate_kind.arity(); @@ -3439,7 +3533,10 @@ impl SemanticAnalyzer { self.prepare_gate_targets(&gate.target); } else if self.strict_mode { // In strict mode, verify qubits are prepared before non-prepare gates - self.validate_gate_target_states(&gate.target, &gate.location.clone().unwrap_or_default())?; + self.validate_gate_target_states( + &gate.target, + &gate.location.clone().unwrap_or_default(), + )?; } // Gate operations are statements, return unit @@ -3493,7 +3590,9 @@ impl SemanticAnalyzer { // Allocator methods match field.field.as_str() { "child" => Ok(Type::Function { - params: vec![Type::UInt { bits: BitWidth::BITS_32 }], + params: vec![Type::UInt { + bits: BitWidth::BITS_32, + }], return_type: Box::new(Type::Allocator { capacity: None }), }), "release" => Ok(Type::Function { @@ -3501,19 +3600,23 @@ impl SemanticAnalyzer { return_type: Box::new(Type::Unit), }), // Deprecated: use `pz q` or `pz {q[0], q[1]}` instead - "prepare_all" | "prepare" => { - Err(SemanticError::DeprecatedSyntax { - old: format!("{}.{}()", - if let Expr::Ident(id) = &field.object { &id.name } else { "allocator" }, - field.field), - new: if field.field == "prepare_all" { - "pz ".to_string() + "prepare_all" | "prepare" => Err(SemanticError::DeprecatedSyntax { + old: format!( + "{}.{}()", + if let Expr::Ident(id) = &field.object { + &id.name } else { - "pz {q[i], q[j], ...}".to_string() + "allocator" }, - location: field.location.clone().unwrap_or_default(), - }) - } + field.field + ), + new: if field.field == "prepare_all" { + "pz ".to_string() + } else { + "pz {q[i], q[j], ...}".to_string() + }, + location: field.location.clone().unwrap_or_default(), + }), _ => Ok(Type::Unknown), } } @@ -3633,7 +3736,9 @@ impl SemanticAnalyzer { "sizeOf" => Ok(Type::Usize), "typeInfo" => Ok(Type::Type), "typeName" => Ok(Type::Slice { - element: Box::new(Type::UInt { bits: BitWidth::BITS_8 }), + element: Box::new(Type::UInt { + bits: BitWidth::BITS_8, + }), }), "swap" => { // @swap(&a, &b) - swap two values in place @@ -3649,7 +3754,10 @@ impl SemanticAnalyzer { let ty2 = self.analyze_expr(&builtin.args[1])?; // Both must be pointers to the same type match (&ty1, &ty2) { - (Type::Pointer { pointee: e1, .. }, Type::Pointer { pointee: e2, .. }) => { + ( + Type::Pointer { pointee: e1, .. }, + Type::Pointer { pointee: e2, .. }, + ) => { if e1 != e2 { return Err(SemanticError::TypeMismatch { expected: format!("*{:?}", e1), @@ -3918,10 +4026,14 @@ impl SemanticAnalyzer { Expr::FnLit(func) => { // Function literal - return function type // At comptime, these can return types (type constructors) - let param_types: Vec = func.params.iter() + let param_types: Vec = func + .params + .iter() .map(|p| self.resolve_type(&p.ty)) .collect(); - let return_type = func.return_type.as_ref() + let return_type = func + .return_type + .as_ref() .map(|ty| self.resolve_type(ty)) .unwrap_or(Type::Unit); Ok(Type::Function { @@ -3959,10 +4071,10 @@ impl SemanticAnalyzer { TypeExpr::Primitive(prim) => match prim { PrimitiveType::Bool => Type::Bool, PrimitiveType::UInt { bits } => Type::UInt { - bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64) + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64), }, PrimitiveType::IInt { bits } => Type::IInt { - bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64) + bits: BitWidth::new(*bits).unwrap_or(BitWidth::BITS_64), }, PrimitiveType::Usize => Type::Usize, PrimitiveType::Isize => Type::Isize, @@ -4024,7 +4136,11 @@ impl SemanticAnalyzer { Type::Tuple { elements: resolved } } TypeExpr::Fn(fn_type) => { - let params: Vec = fn_type.params.iter().map(|t| self.resolve_type(t)).collect(); + let params: Vec = fn_type + .params + .iter() + .map(|t| self.resolve_type(t)) + .collect(); let return_type = fn_type .return_type .as_ref() @@ -4038,9 +4154,10 @@ impl SemanticAnalyzer { TypeExpr::Named(path) => { let name = path.segments.join("."); if let Some(symbol) = self.symbols.lookup(&name) - && let SymbolKind::TypeDef { ty } = &symbol.kind { - return ty.clone(); - } + && let SymbolKind::TypeDef { ty } = &symbol.kind + { + return ty.clone(); + } // Report error for undefined type name self.errors.push(SemanticError::UndefinedType { name: name.clone(), @@ -4056,7 +4173,9 @@ impl SemanticAnalyzer { }, TypeExpr::Struct(s) => { // Anonymous struct type - let fields = s.fields.iter() + let fields = s + .fields + .iter() .map(|f| (f.name.clone(), self.resolve_type(&f.ty))) .collect(); Type::Struct { @@ -4066,9 +4185,7 @@ impl SemanticAnalyzer { } TypeExpr::Enum(e) => { // Anonymous enum type - let variants = e.variants.iter() - .map(|v| v.name.clone()) - .collect(); + let variants = e.variants.iter().map(|v| v.name.clone()).collect(); Type::Enum { name: String::new(), // Anonymous variants, @@ -4160,10 +4277,11 @@ impl SemanticAnalyzer { // Allow null (?unknown) to be assigned to any optional type ?T if let (Type::Optional { .. }, Type::Optional { inner }) = (target, value) - && **inner == Type::Unknown { - // null (which is ?unknown) can be assigned to any ?T - return Ok(()); - } + && **inner == Type::Unknown + { + // null (which is ?unknown) can be assigned to any ?T + return Ok(()); + } // Allow numeric coercion between numeric types only // (but NOT from numeric to bool or vice versa) @@ -4173,17 +4291,27 @@ impl SemanticAnalyzer { // Allow T to be assigned to T!E (returning success from error union function) if let Type::ErrorUnion { payload, .. } = target - && self.check_assignable(payload.as_ref(), value, location.clone()).is_ok() { - return Ok(()); - } + && self + .check_assignable(payload.as_ref(), value, location.clone()) + .is_ok() + { + return Ok(()); + } // Allow error value to be assigned to T!E (returning error from error union function) if let Type::ErrorUnion { error, .. } = target { // Check if value is an error type that's compatible with the expected error type match value { // Same error set - always compatible - Type::ErrorSet { name: value_name, errors: value_errors } => { - if let Type::ErrorSet { name: expected_name, errors: expected_errors } = error.as_ref() { + Type::ErrorSet { + name: value_name, + errors: value_errors, + } => { + if let Type::ErrorSet { + name: expected_name, + errors: expected_errors, + } = error.as_ref() + { // Exact match if value_name == expected_name { return Ok(()); @@ -4206,8 +4334,16 @@ impl SemanticAnalyzer { // Allow fault value to be assigned to T!F (returning fault from fault union function) if let Type::ErrorUnion { error, .. } = target { - if let Type::FaultSet { name: value_name, faults: value_faults } = value { - if let Type::FaultSet { name: expected_name, faults: expected_faults } = error.as_ref() { + if let Type::FaultSet { + name: value_name, + faults: value_faults, + } = value + { + if let Type::FaultSet { + name: expected_name, + faults: expected_faults, + } = error.as_ref() + { // Exact match if value_name == expected_name { return Ok(()); @@ -4251,9 +4387,10 @@ impl SemanticAnalyzer { // Allow T to be passed where ?T is expected if let Type::Optional { inner } = expected - && self.types_compatible(value, inner) { - return true; - } + && self.types_compatible(value, inner) + { + return true; + } false } @@ -4476,8 +4613,14 @@ impl SemanticAnalyzer { element: l_elem.clone(), }) } else if let ( - Type::ErrorSet { name: l_name, errors: l_errors }, - Type::ErrorSet { name: r_name, errors: r_errors }, + Type::ErrorSet { + name: l_name, + errors: l_errors, + }, + Type::ErrorSet { + name: r_name, + errors: r_errors, + }, ) = (left, right) { // Error set union: ErrorA || ErrorB @@ -4501,8 +4644,14 @@ impl SemanticAnalyzer { errors: combined_errors, }) } else if let ( - Type::FaultSet { name: l_name, faults: l_faults }, - Type::FaultSet { name: r_name, faults: r_faults }, + Type::FaultSet { + name: l_name, + faults: l_faults, + }, + Type::FaultSet { + name: r_name, + faults: r_faults, + }, ) = (left, right) { // Fault set union: FaultA || FaultB @@ -4692,15 +4841,16 @@ impl SemanticAnalyzer { // Get capacity from qubit_states (where qalloc capacity is tracked) if let Some(alloc_info) = self.qubit_states.get_allocator(&slot_ref.allocator) && let Some(capacity) = alloc_info.capacity - && let Some(index) = self.try_extract_constant_usize(&slot_ref.index) - && index >= capacity { - return Err(SemanticError::QubitIndexOutOfBounds { - allocator: slot_ref.allocator.clone(), - index, - capacity, - location: slot_ref.location.clone().unwrap_or_default(), - }); - } + && let Some(index) = self.try_extract_constant_usize(&slot_ref.index) + && index >= capacity + { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: slot_ref.allocator.clone(), + index, + capacity, + location: slot_ref.location.clone().unwrap_or_default(), + }); + } Ok(()) } @@ -4716,9 +4866,12 @@ impl SemanticAnalyzer { // Check if this is an allocator let is_allocator = if let Some(symbol) = self.symbols.lookup(allocator_name) { - matches!(&symbol.kind, - SymbolKind::Variable { ty: Type::Allocator { .. }, .. } | - SymbolKind::Allocator { .. } + matches!( + &symbol.kind, + SymbolKind::Variable { + ty: Type::Allocator { .. }, + .. + } | SymbolKind::Allocator { .. } ) } else { false @@ -4728,15 +4881,16 @@ impl SemanticAnalyzer { // Get capacity from qubit_states if let Some(alloc_info) = self.qubit_states.get_allocator(allocator_name) && let Some(capacity) = alloc_info.capacity - && let Some(index) = self.try_extract_constant_usize(&index_expr.index) - && index >= capacity { - return Err(SemanticError::QubitIndexOutOfBounds { - allocator: allocator_name.clone(), - index, - capacity, - location: index_expr.location.clone().unwrap_or_default(), - }); - } + && let Some(index) = self.try_extract_constant_usize(&index_expr.index) + && index >= capacity + { + return Err(SemanticError::QubitIndexOutOfBounds { + allocator: allocator_name.clone(), + index, + capacity, + location: index_expr.location.clone().unwrap_or_default(), + }); + } } } Ok(()) @@ -4778,7 +4932,8 @@ impl SemanticAnalyzer { // Validate each qubit is prepared for (allocator, index) in qubit_ids { - self.qubit_states.validate_for_gate(&allocator, index, location)?; + self.qubit_states + .validate_for_gate(&allocator, index, location)?; } Ok(()) @@ -4887,14 +5042,15 @@ impl SemanticAnalyzer { Expr::Call(call) => { // Check if this is a gate call if let Expr::Ident(ident) = &call.callee - && is_gate_name(&ident.name) { - // Collect qubit IDs from arguments - return call - .args - .iter() - .flat_map(|arg| self.extract_qubit_ids_from_arg(arg)) - .collect(); - } + && is_gate_name(&ident.name) + { + // Collect qubit IDs from arguments + return call + .args + .iter() + .flat_map(|arg| self.extract_qubit_ids_from_arg(arg)) + .collect(); + } Vec::new() } // Batch apply: h { q[0], q[1] } or rz(pi/4) { q[0], q[1] } @@ -4912,13 +5068,14 @@ impl SemanticAnalyzer { _ => None, }; if let Some(name) = gate_name - && is_gate_name(name) { - return batch - .targets - .iter() - .flat_map(|target| self.extract_qubit_ids_from_arg(target)) - .collect(); - } + && is_gate_name(name) + { + return batch + .targets + .iter() + .flat_map(|target| self.extract_qubit_ids_from_arg(target)) + .collect(); + } Vec::new() } _ => Vec::new(), @@ -4931,9 +5088,10 @@ impl SemanticAnalyzer { // Index expression: q[0] Expr::Index(index_expr) => { if let Expr::Ident(ident) = &index_expr.object - && let Some(idx) = self.try_extract_constant_usize(&index_expr.index) { - return vec![(ident.name.clone(), idx)]; - } + && let Some(idx) = self.try_extract_constant_usize(&index_expr.index) + { + return vec![(ident.name.clone(), idx)]; + } Vec::new() } // Tuple of qubits: (q[0], q[1]) for two-qubit gates @@ -4970,9 +5128,11 @@ impl SemanticAnalyzer { fn try_extract_allocator_capacity(&self, expr: &Expr) -> Option { if let Expr::Call(call) = expr && let Expr::Ident(ident) = &call.callee - && ident.name == "qalloc" && call.args.len() == 1 { - return self.try_extract_constant_usize(&call.args[0]); - } + && ident.name == "qalloc" + && call.args.len() == 1 + { + return self.try_extract_constant_usize(&call.args[0]); + } None } @@ -4980,11 +5140,13 @@ impl SemanticAnalyzer { fn try_extract_child_allocator(&self, expr: &Expr) -> Option<(String, usize)> { if let Expr::Call(call) = expr && let Expr::Field(field) = &call.callee - && field.field == "child" && call.args.len() == 1 - && let Expr::Ident(parent_ident) = &field.object { - let capacity = self.try_extract_constant_usize(&call.args[0])?; - return Some((parent_ident.name.clone(), capacity)); - } + && field.field == "child" + && call.args.len() == 1 + && let Expr::Ident(parent_ident) = &field.object + { + let capacity = self.try_extract_constant_usize(&call.args[0])?; + return Some((parent_ident.name.clone(), capacity)); + } None } @@ -5000,12 +5162,12 @@ impl SemanticAnalyzer { is_const: true, .. } = &symbol.kind - { - // Look up the value in the comptime evaluator context - if let Some(val) = self.comptime.context.lookup(&ident.name) { - return val.to_usize(); - } + { + // Look up the value in the comptime evaluator context + if let Some(val) = self.comptime.context.lookup(&ident.name) { + return val.to_usize(); } + } None } // For other expressions, try comptime evaluation @@ -5079,7 +5241,8 @@ impl SemanticAnalyzer { // Validate qubit states if in strict mode for (allocator, index) in &targets { if self.strict_mode { - self.qubit_states.validate_for_gate(allocator, *index, &location)?; + self.qubit_states + .validate_for_gate(allocator, *index, &location)?; } // Transition to unprepared after measurement if let Some(alloc) = self.qubit_states.get_allocator_mut(allocator) { @@ -5111,9 +5274,10 @@ impl SemanticAnalyzer { // Parse arbitrary unsigned integer type: u if let Some(bits_str) = ident.name.strip_prefix('u') && let Ok(bits) = bits_str.parse::() - && let Some(bw) = BitWidth::new(bits) { - return Ok(Type::UInt { bits: bw }); - } + && let Some(bw) = BitWidth::new(bits) + { + return Ok(Type::UInt { bits: bw }); + } Err(SemanticError::InvalidMeasurementType { ty: ident.name.clone(), location, @@ -5127,7 +5291,9 @@ impl SemanticAnalyzer { if arr.elements.is_empty() { // Need to get element type from context Ok(Type::Slice { - element: Box::new(Type::UInt { bits: BitWidth::BITS_1 }), // Default to u1 + element: Box::new(Type::UInt { + bits: BitWidth::BITS_1, + }), // Default to u1 }) } else { Err(SemanticError::InvalidMeasurementType { @@ -5144,11 +5310,12 @@ impl SemanticAnalyzer { // Parse arbitrary unsigned integer type from allocator name if let Some(bits_str) = slot_ref.allocator.strip_prefix('u') && let Ok(bits) = bits_str.parse::() - && let Some(bw) = BitWidth::new(bits) { - return Ok(Type::Slice { - element: Box::new(Type::UInt { bits: bw }), - }); - } + && let Some(bw) = BitWidth::new(bits) + { + return Ok(Type::Slice { + element: Box::new(Type::UInt { bits: bw }), + }); + } Err(SemanticError::InvalidMeasurementType { ty: format!("[]{}", slot_ref.allocator), location, @@ -5251,23 +5418,21 @@ impl SemanticAnalyzer { } // Address-of array: &[q[0], q[1]] - Expr::Unary(unary) if matches!(unary.op, UnaryOp::AddrOf) => { - match &unary.operand { - Expr::BracketArray(arr) => { - let mut targets = Vec::new(); - for elem in &arr.elements { - if let Expr::Index(index) = elem { - let (allocator, idx) = self.extract_qubit_from_index(index)?; - targets.push((allocator, idx)); - } else { - return Err(SemanticError::InvalidQubitRef { location }); - } + Expr::Unary(unary) if matches!(unary.op, UnaryOp::AddrOf) => match &unary.operand { + Expr::BracketArray(arr) => { + let mut targets = Vec::new(); + for elem in &arr.elements { + if let Expr::Index(index) = elem { + let (allocator, idx) = self.extract_qubit_from_index(index)?; + targets.push((allocator, idx)); + } else { + return Err(SemanticError::InvalidQubitRef { location }); } - Ok(targets) } - _ => Err(SemanticError::InvalidQubitRef { location }), + Ok(targets) } - } + _ => Err(SemanticError::InvalidQubitRef { location }), + }, _ => Err(SemanticError::InvalidQubitRef { location }), } @@ -5284,7 +5449,8 @@ impl SemanticAnalyzer { }; // Get index (must be comptime-known for uniqueness checking) - let idx = self.try_extract_constant_usize(&index.index) + let idx = self + .try_extract_constant_usize(&index.index) .ok_or(SemanticError::InvalidQubitRef { location })?; Ok((allocator, idx)) @@ -5347,48 +5513,51 @@ impl SemanticAnalyzer { let mut exports = std::collections::BTreeMap::new(); for (name, export) in &module_exports { let (kind, ty) = match export { - ExportedSymbol::Function { params, return_type, .. } => { + ExportedSymbol::Function { + params, + return_type, + .. + } => { // Extract function signature from AST - let param_types: Vec = params - .iter() - .map(|(_, ty)| self.resolve_type(ty)) - .collect(); + let param_types: Vec = + params.iter().map(|(_, ty)| self.resolve_type(ty)).collect(); let ret_type = return_type .as_ref() .map(|t| self.resolve_type(t)) .unwrap_or(Type::Unit); - (ModuleExportKind::Function, Type::Function { - params: param_types, - return_type: Box::new(ret_type), - }) - } - ExportedSymbol::Const { .. } => { - (ModuleExportKind::Const, Type::Unknown) - } - ExportedSymbol::Type { .. } => { - (ModuleExportKind::Type, Type::Type) + ( + ModuleExportKind::Function, + Type::Function { + params: param_types, + return_type: Box::new(ret_type), + }, + ) } + ExportedSymbol::Const { .. } => (ModuleExportKind::Const, Type::Unknown), + ExportedSymbol::Type { .. } => (ModuleExportKind::Type, Type::Type), ExportedSymbol::ErrorSet { variants, .. } => { // Imported error sets don't carry associated data types - let errors: Vec<(String, Option>)> = variants - .iter() - .map(|v| (v.clone(), None)) - .collect(); - (ModuleExportKind::ErrorSet, Type::ErrorSet { - name: name.clone(), - errors, - }) + let errors: Vec<(String, Option>)> = + variants.iter().map(|v| (v.clone(), None)).collect(); + ( + ModuleExportKind::ErrorSet, + Type::ErrorSet { + name: name.clone(), + errors, + }, + ) } ExportedSymbol::FaultSet { variants, .. } => { // Imported fault sets don't carry associated data types - let faults: Vec<(String, Option>)> = variants - .iter() - .map(|v| (v.clone(), None)) - .collect(); - (ModuleExportKind::FaultSet, Type::FaultSet { - name: name.clone(), - faults, - }) + let faults: Vec<(String, Option>)> = + variants.iter().map(|v| (v.clone(), None)).collect(); + ( + ModuleExportKind::FaultSet, + Type::FaultSet { + name: name.clone(), + faults, + }, + ) } }; exports.insert(name.clone(), (kind, ty)); @@ -5432,8 +5601,7 @@ impl SemanticAnalyzer { source_var: source_name.clone(), overlap_range: format!( "{}..{} overlaps with {}..{}", - new_range.0, new_range.1, - existing_range.0, existing_range.1 + new_range.0, new_range.1, existing_range.0, existing_range.1 ), location, }); @@ -5639,7 +5807,7 @@ impl SemanticAnalyzer { kind: SymbolKind::Function { params, return_type, - is_pub: false, // Specialized functions are internal + is_pub: false, // Specialized functions are internal comptime_param_indices: vec![], // No longer generic original_decl: None, }, @@ -5681,27 +5849,60 @@ struct GateInfo { fn get_gate_info(name: &str) -> Option { match name { // Single-qubit Pauli gates (non-parameterized, arity 1) - "h" | "x" | "y" | "z" => Some(GateInfo { arity: 1, parameterized: false }), + "h" | "x" | "y" | "z" => Some(GateInfo { + arity: 1, + parameterized: false, + }), // Square root gates (sx, sy, sz and their daggers) // Note: S gate is "sz" not "s", Sdg is "szdg" not "sdg" - "sx" | "sy" | "sz" | "sxdg" | "sydg" | "szdg" => Some(GateInfo { arity: 1, parameterized: false }), + "sx" | "sy" | "sz" | "sxdg" | "sydg" | "szdg" => Some(GateInfo { + arity: 1, + parameterized: false, + }), // T gates (fourth root of Z) - "t" | "tdg" => Some(GateInfo { arity: 1, parameterized: false }), + "t" | "tdg" => Some(GateInfo { + arity: 1, + parameterized: false, + }), // F gates - "f" | "fdg" | "f4" | "f4dg" => Some(GateInfo { arity: 1, parameterized: false }), + "f" | "fdg" | "f4" | "f4dg" => Some(GateInfo { + arity: 1, + parameterized: false, + }), // Rotation gates (parameterized, arity 1) - "rx" | "ry" | "rz" => Some(GateInfo { arity: 1, parameterized: true }), + "rx" | "ry" | "rz" => Some(GateInfo { + arity: 1, + parameterized: true, + }), // Two-qubit gates (non-parameterized, arity 2) - "cx" | "cy" | "cz" | "ch" => Some(GateInfo { arity: 2, parameterized: false }), - "swap" | "iswap" => Some(GateInfo { arity: 2, parameterized: false }), + "cx" | "cy" | "cz" | "ch" => Some(GateInfo { + arity: 2, + parameterized: false, + }), + "swap" | "iswap" => Some(GateInfo { + arity: 2, + parameterized: false, + }), // Square-root two-qubit gates - "sxx" | "syy" | "szz" | "sxxdg" | "syydg" | "szzdg" => Some(GateInfo { arity: 2, parameterized: false }), + "sxx" | "syy" | "szz" | "sxxdg" | "syydg" | "szzdg" => Some(GateInfo { + arity: 2, + parameterized: false, + }), // Controlled rotation (parameterized, arity 2) - "crz" | "rzz" => Some(GateInfo { arity: 2, parameterized: true }), + "crz" | "rzz" => Some(GateInfo { + arity: 2, + parameterized: true, + }), // Three-qubit gates - "ccx" => Some(GateInfo { arity: 3, parameterized: false }), + "ccx" => Some(GateInfo { + arity: 3, + parameterized: false, + }), // Special operations (handled separately but recognized as gates) - "mz" | "pz" => Some(GateInfo { arity: 1, parameterized: false }), + "mz" | "pz" => Some(GateInfo { + arity: 1, + parameterized: false, + }), _ => None, } } @@ -5764,13 +5965,14 @@ fn resolve_builtin_type_name(name: &str) -> Option { return None; } } else if let Some(bits_str) = name.strip_prefix('i') - && let Ok(bits) = bits_str.parse::() { - if let Some(bw) = BitWidth::new(bits) { - return Some(Type::IInt { bits: bw }); - } - // Invalid bit width - return None to trigger error - return None; + && let Ok(bits) = bits_str.parse::() + { + if let Some(bw) = BitWidth::new(bits) { + return Some(Type::IInt { bits: bw }); } + // Invalid bit width - return None to trigger error + return None; + } None } @@ -5791,18 +5993,22 @@ fn int_suffix_to_type(suffix: &str) -> Type { // Arbitrary-width integers: u or i if let Some(bits_str) = s.strip_prefix('u') { if let Ok(bits) = bits_str.parse::() - && let Some(bw) = BitWidth::new(bits) { - return Type::UInt { bits: bw }; - } - // Invalid bit width - fall through to default + && let Some(bw) = BitWidth::new(bits) + { + return Type::UInt { bits: bw }; + } + // Invalid bit width - fall through to default } else if let Some(bits_str) = s.strip_prefix('i') && let Ok(bits) = bits_str.parse::() - && let Some(bw) = BitWidth::new(bits) { - return Type::IInt { bits: bw }; - } - // Invalid bit width - fall through to default + && let Some(bw) = BitWidth::new(bits) + { + return Type::IInt { bits: bw }; + } + // Invalid bit width - fall through to default - Type::IInt { bits: BitWidth::BITS_64 } // Default fallback + Type::IInt { + bits: BitWidth::BITS_64, + } // Default fallback } /// Convert a float type suffix to a Type. @@ -5848,10 +6054,17 @@ mod tests { fn test_type_mismatch() { // First check that parsing works let program1 = parse("x: u32 = 42;").unwrap(); - assert!(!program1.declarations.is_empty(), "u32 version should have declarations"); + assert!( + !program1.declarations.is_empty(), + "u32 version should have declarations" + ); let program2 = parse("x: bool = 42;").unwrap(); - assert!(!program2.declarations.is_empty(), "bool version should have declarations: got {:?}", program2); + assert!( + !program2.declarations.is_empty(), + "bool version should have declarations: got {:?}", + program2 + ); // bool is not numeric, so int can't be assigned let result = analyze("x: bool = 42;"); @@ -5870,15 +6083,17 @@ mod tests { #[test] fn test_quantum_alloc() { - assert!(analyze( - r#" + assert!( + analyze( + r#" fn main() -> unit { mut q := qalloc(2); return unit; } "# - ) - .is_ok()); + ) + .is_ok() + ); } // ========================================================================= @@ -5904,7 +6119,12 @@ mod tests { assert_eq!(alloc.name, "q"); assert_eq!(alloc.capacity, Some(4)); assert_eq!(alloc.slot_states.len(), 4); - assert!(alloc.slot_states.iter().all(|s| *s == QubitState::Unprepared)); + assert!( + alloc + .slot_states + .iter() + .all(|s| *s == QubitState::Unprepared) + ); } #[test] @@ -5976,7 +6196,10 @@ mod tests { // Recursive call should fail let result = tracker.enter_function("foo", &loc); - assert!(matches!(result, Err(SemanticError::RecursionDetected { .. }))); + assert!(matches!( + result, + Err(SemanticError::RecursionDetected { .. }) + )); // Exit and re-enter should work tracker.exit_function("foo"); @@ -5995,7 +6218,10 @@ mod tests { // Large bound should fail let result = analyzer.check_loop_bound(MAX_LOOP_BOUND + 1, &loc); - assert!(matches!(result, Err(SemanticError::LoopBoundTooLarge { .. }))); + assert!(matches!( + result, + Err(SemanticError::LoopBoundTooLarge { .. }) + )); } // ========================================================================= @@ -6059,7 +6285,9 @@ mod tests { let program = parse(source).expect("parse failed"); let mut analyzer = SemanticAnalyzer::new(); - analyzer.analyze(&program).expect("analysis should succeed for immutable allocator with gates"); + analyzer + .analyze(&program) + .expect("analysis should succeed for immutable allocator with gates"); // Check allocator was registered assert!(analyzer.qubit_states.get_allocator("q").is_some()); @@ -6115,7 +6343,7 @@ mod tests { h q[0]; // No pz q; first - qubit is unprepared return unit; } - "# + "#, ); assert!(result.is_err(), "Expected QubitNotPrepared error"); assert!( @@ -6135,9 +6363,13 @@ mod tests { h q[0]; // Now this should succeed return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "PZ should prepare qubits for subsequent gates: {:?}", + result ); - assert!(result.is_ok(), "PZ should prepare qubits for subsequent gates: {:?}", result); } #[test] @@ -6151,9 +6383,13 @@ mod tests { h q[0]; // This should succeed return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "PZ should prepare specific qubit: {:?}", + result ); - assert!(result.is_ok(), "PZ should prepare specific qubit: {:?}", result); } // ========================================================================= @@ -6171,9 +6407,13 @@ mod tests { r := mz(u1) q[0]; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected typed measurement to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected typed measurement to pass: {:?}", result); } #[test] @@ -6187,9 +6427,13 @@ mod tests { results := mz([2]u1) [q[0], q[1]]; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected typed array measurement to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected typed array measurement to pass: {:?}", result); } #[test] @@ -6203,10 +6447,13 @@ mod tests { results := mz([3]u1) [q[0], q[1]]; return unit; } - "# + "#, + ); + assert!( + matches!(result, Err(SemanticError::MeasurementSizeMismatch { .. })), + "Expected MeasurementSizeMismatch error, got: {:?}", + result ); - assert!(matches!(result, Err(SemanticError::MeasurementSizeMismatch { .. })), - "Expected MeasurementSizeMismatch error, got: {:?}", result); } #[test] @@ -6220,10 +6467,13 @@ mod tests { results := mz(u1) [q[0], q[1]]; return unit; } - "# + "#, + ); + assert!( + matches!(result, Err(SemanticError::MeasurementArrayExpected { .. })), + "Expected MeasurementArrayExpected error, got: {:?}", + result ); - assert!(matches!(result, Err(SemanticError::MeasurementArrayExpected { .. })), - "Expected MeasurementArrayExpected error, got: {:?}", result); } #[test] @@ -6236,10 +6486,15 @@ mod tests { r := mz(); return unit; } - "# + "#, + ); + assert!( + matches!( + result, + Err(SemanticError::DeprecatedMeasurementSyntax { .. }) + ), + "Expected DeprecatedMeasurementSyntax error for old mz() call syntax" ); - assert!(matches!(result, Err(SemanticError::DeprecatedMeasurementSyntax { .. })), - "Expected DeprecatedMeasurementSyntax error for old mz() call syntax"); } #[test] @@ -6253,10 +6508,13 @@ mod tests { r := mz(f64) q[0]; return unit; } - "# + "#, + ); + assert!( + matches!(result, Err(SemanticError::InvalidMeasurementType { .. })), + "Expected InvalidMeasurementType error, got: {:?}", + result ); - assert!(matches!(result, Err(SemanticError::InvalidMeasurementType { .. })), - "Expected InvalidMeasurementType error, got: {:?}", result); } #[test] @@ -6270,9 +6528,13 @@ mod tests { bits := mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7]]; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected pack measurement to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected pack measurement to pass: {:?}", result); } #[test] @@ -6286,9 +6548,13 @@ mod tests { bits := mz(pack u8) [q[0], q[1], q[2], q[3]]; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected pack with extra capacity to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected pack with extra capacity to pass: {:?}", result); } #[test] @@ -6302,10 +6568,13 @@ mod tests { bits := mz(pack u8) [q[0], q[1], q[2], q[3], q[4], q[5], q[6], q[7], q[8], q[9]]; return unit; } - "# + "#, + ); + assert!( + matches!(result, Err(SemanticError::MeasurementPackCapacity { .. })), + "Expected MeasurementPackCapacity error, got: {:?}", + result ); - assert!(matches!(result, Err(SemanticError::MeasurementPackCapacity { .. })), - "Expected MeasurementPackCapacity error, got: {:?}", result); } #[test] @@ -6319,9 +6588,13 @@ mod tests { bits := mz(pack [2]u8) q; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected pack into array to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected pack into array to pass: {:?}", result); } // ========================================================================= @@ -6337,9 +6610,13 @@ mod tests { mut x: ?u32 = none; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected optional type to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected optional type to pass: {:?}", result); } #[test] @@ -6352,7 +6629,7 @@ mod tests { y: u32 = x orelse 42; return unit; } - "# + "#, ); assert!(result.is_ok(), "Expected orelse to pass: {:?}", result); } @@ -6367,10 +6644,12 @@ mod tests { y := x orelse true; return unit; } - "# + "#, + ); + assert!( + matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for orelse" ); - assert!(matches!(result, Err(SemanticError::TypeMismatch { .. })), - "Expected TypeMismatch error for orelse"); } #[test] @@ -6383,10 +6662,12 @@ mod tests { y := x orelse 42; return unit; } - "# + "#, + ); + assert!( + matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for non-optional" ); - assert!(matches!(result, Err(SemanticError::TypeMismatch { .. })), - "Expected TypeMismatch error for non-optional"); } #[test] @@ -6399,7 +6680,7 @@ mod tests { y := x.?; return unit; } - "# + "#, ); assert!(result.is_ok(), "Expected .? unwrap to pass: {:?}", result); } @@ -6419,7 +6700,7 @@ mod tests { } return unit; } - "# + "#, ); assert!(result.is_ok(), "Expected if-unwrap to pass: {:?}", result); } @@ -6435,10 +6716,12 @@ mod tests { y := value; } } - "# + "#, + ); + assert!( + matches!(result, Err(SemanticError::TypeMismatch { .. })), + "Expected TypeMismatch error for if-unwrap on non-optional" ); - assert!(matches!(result, Err(SemanticError::TypeMismatch { .. })), - "Expected TypeMismatch error for if-unwrap on non-optional"); } // ========================================================================= @@ -6454,9 +6737,13 @@ mod tests { x := comptime 2 + 3; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected comptime expression to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected comptime expression to pass: {:?}", result); } #[test] @@ -6469,9 +6756,13 @@ mod tests { y := comptime (10 + 20); return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected comptime expression to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected comptime expression to pass: {:?}", result); } #[test] @@ -6498,9 +6789,13 @@ mod tests { mut x: u32 = size; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected comptime param to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected comptime param to pass: {:?}", result); } #[test] @@ -6511,9 +6806,13 @@ mod tests { fn compute(comptime n: u32) -> u32 { return n * 2; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected comptime param in expr: {:?}", + result ); - assert!(result.is_ok(), "Expected comptime param in expr: {:?}", result); } #[test] @@ -6525,9 +6824,13 @@ mod tests { OutOfMemory, InvalidInput, }; - "# + "#, + ); + assert!( + result.is_ok(), + "Expected error set definition to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected error set definition to pass: {:?}", result); } #[test] @@ -6541,9 +6844,13 @@ mod tests { x: u32 = 42; return x; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected error union type to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected error union type to pass: {:?}", result); } #[test] @@ -6559,9 +6866,13 @@ mod tests { } return 42; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected error value return to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected error value return to pass: {:?}", result); } #[test] @@ -6575,7 +6886,7 @@ mod tests { fn risky() -> MyError!u32 { return error.NotFound; } - "# + "#, ); // This should fail because NotFound is from OtherError, not MyError assert!(result.is_err(), "Expected mismatched error set to fail"); @@ -6593,9 +6904,13 @@ mod tests { combined := IoError | NetworkError; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected error set union to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected error set union to pass: {:?}", result); } #[test] @@ -6608,9 +6923,13 @@ mod tests { PermissionDenied: struct { path: []u8, mode: u32 }, IoError, }; - "# + "#, + ); + assert!( + result.is_ok(), + "Expected error set with associated data to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected error set with associated data to pass: {:?}", result); } #[test] @@ -6623,16 +6942,24 @@ mod tests { BitFlip, PhaseError: struct { angle: f64 }, }; - "# + "#, + ); + assert!( + result.is_ok(), + "Expected fault set with associated data to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected fault set with associated data to pass: {:?}", result); } #[test] fn test_fault_set_definition_basic() { // Basic fault set definition should work let result = analyze("GateFaults := fault { Leakage, Depolarization };"); - assert!(result.is_ok(), "Expected fault set definition to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected fault set definition to pass: {:?}", + result + ); } #[test] @@ -6646,9 +6973,13 @@ mod tests { x := GateFaults; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected fault set as value to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected fault set as value to pass: {:?}", result); } #[test] @@ -6663,9 +6994,13 @@ mod tests { combined := GateFaults | MeasurementFaults; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected fault set union to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected fault set union to pass: {:?}", result); } #[test] @@ -6679,9 +7014,13 @@ mod tests { }; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected try collect block to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected try collect block to pass: {:?}", result); } #[test] @@ -6695,9 +7034,13 @@ mod tests { }; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected try! propagate block to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected try! propagate block to pass: {:?}", result); } #[test] @@ -6714,9 +7057,13 @@ mod tests { }; return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected try! with catch to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected try! with catch to pass: {:?}", result); } #[test] @@ -6730,9 +7077,13 @@ mod tests { } fn cleanup() -> unit { return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected basic errdefer to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected basic errdefer to pass: {:?}", result); } #[test] @@ -6746,9 +7097,13 @@ mod tests { } return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected errdefer with capture to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected errdefer with capture to pass: {:?}", result); } #[test] @@ -6761,9 +7116,13 @@ mod tests { Float: f64, None, }; - "# + "#, + ); + assert!( + result.is_ok(), + "Expected tagged union to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected tagged union to pass: {:?}", result); } #[test] @@ -6775,9 +7134,13 @@ mod tests { Int: i32, Float: f64, }; - "# + "#, + ); + assert!( + result.is_ok(), + "Expected untagged union to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected untagged union to pass: {:?}", result); } // ========================================================================= @@ -6834,7 +7197,11 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let utils_path = temp_dir.path().join("utils.zlp"); let mut file = std::fs::File::create(&utils_path).unwrap(); - writeln!(file, "pub fn add(a: u32, b: u32) -> u32 {{ return a + b; }}").unwrap(); + writeln!( + file, + "pub fn add(a: u32, b: u32) -> u32 {{ return a + b; }}" + ) + .unwrap(); // Create main file that imports and calls the function let main_path = temp_dir.path().join("main.zlp"); @@ -6851,7 +7218,11 @@ mod tests { let mut analyzer = SemanticAnalyzer::new(); analyzer.set_current_file(&main_path); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Expected module function call to succeed: {:?}", result); + assert!( + result.is_ok(), + "Expected module function call to succeed: {:?}", + result + ); } #[test] @@ -6863,7 +7234,11 @@ mod tests { let temp_dir = TempDir::new().unwrap(); let utils_path = temp_dir.path().join("utils.zlp"); let mut file = std::fs::File::create(&utils_path).unwrap(); - writeln!(file, "pub fn add(a: u32, b: u32) -> u32 {{ return a + b; }}").unwrap(); + writeln!( + file, + "pub fn add(a: u32, b: u32) -> u32 {{ return a + b; }}" + ) + .unwrap(); // Create main file that calls with wrong number of arguments let main_path = temp_dir.path().join("main.zlp"); @@ -6897,9 +7272,13 @@ mod tests { pair := (1, 2); return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected tuple literal to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected tuple literal to pass: {:?}", result); } #[test] @@ -6911,7 +7290,7 @@ mod tests { mixed := (42, true); return unit; } - "# + "#, ); assert!(result.is_ok(), "Expected mixed tuple to pass: {:?}", result); } @@ -6928,9 +7307,13 @@ mod tests { return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected tuple with qubits to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected tuple with qubits to pass: {:?}", result); } #[test] @@ -6942,9 +7325,13 @@ mod tests { mut pair: (i64, bool) = (42, true); return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected tuple type annotation to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected tuple type annotation to pass: {:?}", result); } #[test] @@ -6956,9 +7343,13 @@ mod tests { triple: (i64, i64, i64) = (1, 2, 3); return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected triple tuple type to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected triple tuple type to pass: {:?}", result); } #[test] @@ -6970,7 +7361,7 @@ mod tests { mut pair: (u32, bool) = (42, true); return unit; } - "# + "#, ); // Should fail because 42 is i64, not u32 assert!(result.is_err(), "Expected tuple type mismatch error"); @@ -6989,7 +7380,7 @@ mod tests { x: u32 = 42u32; return unit; } - "# + "#, ); assert!(result.is_ok(), "Expected u32 suffix to pass: {:?}", result); } @@ -7003,7 +7394,7 @@ mod tests { x: u64 = 1000_u64; return unit; } - "# + "#, ); assert!(result.is_ok(), "Expected _u64 suffix to pass: {:?}", result); } @@ -7017,7 +7408,7 @@ mod tests { x: f32 = 3.14f32; return unit; } - "# + "#, ); assert!(result.is_ok(), "Expected f32 suffix to pass: {:?}", result); } @@ -7031,9 +7422,13 @@ mod tests { pair: (u32, bool) = (42u32, true); return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected suffixed tuple to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected suffixed tuple to pass: {:?}", result); } #[test] @@ -7046,7 +7441,7 @@ mod tests { x: u32 = true; return unit; } - "# + "#, ); assert!(result.is_err(), "Expected type mismatch with bool and u32"); } @@ -7061,7 +7456,7 @@ mod tests { pair: (u32, bool) = (42, true); return unit; } - "# + "#, ); let result_with_suffix = analyze( r#" @@ -7069,13 +7464,20 @@ mod tests { pair: (u32, bool) = (42u32, true); return unit; } - "# + "#, ); // Tuple elements require exact type match (no numeric coercion) // Unsuffixed 42 is i64, so (i64, bool) doesn't match (u32, bool) - assert!(result_no_suffix.is_err(), "Expected unsuffixed tuple to fail type check"); + assert!( + result_no_suffix.is_err(), + "Expected unsuffixed tuple to fail type check" + ); // With suffix, types match exactly - assert!(result_with_suffix.is_ok(), "Expected suffixed tuple to pass: {:?}", result_with_suffix); + assert!( + result_with_suffix.is_ok(), + "Expected suffixed tuple to pass: {:?}", + result_with_suffix + ); } // ========================================================================= @@ -7096,7 +7498,7 @@ mod tests { } return unit; } - "# + "#, ); assert!(result.is_ok(), "Expected no duplicate error: {:?}", result); } @@ -7114,7 +7516,7 @@ mod tests { x q[0]; } } - "# + "#, ); assert!( matches!(result, Err(SemanticError::DuplicateQubitInTick { ref allocator, index, .. }) if allocator == "q" && index == 0), @@ -7136,7 +7538,7 @@ mod tests { cx (q[0], q[1]); } } - "# + "#, ); assert!( matches!(result, Err(SemanticError::DuplicateQubitInTick { .. })), @@ -7159,9 +7561,13 @@ mod tests { } return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Permissive mode should allow duplicate qubits: {:?}", + result ); - assert!(result.is_ok(), "Permissive mode should allow duplicate qubits: {:?}", result); } #[test] @@ -7180,7 +7586,7 @@ mod tests { } return unit; } - "# + "#, ); assert!(result.is_err(), "Expected nested tick error"); let err = result.unwrap_err(); @@ -7204,9 +7610,13 @@ mod tests { tick { cx (q[0], q[1]); } return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Sequential ticks should be valid: {:?}", + result ); - assert!(result.is_ok(), "Sequential ticks should be valid: {:?}", result); } // ========================================================================= @@ -7226,9 +7636,13 @@ mod tests { } return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected break inside loop to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected break inside loop to pass: {:?}", result); } #[test] @@ -7244,9 +7658,13 @@ mod tests { } return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected continue inside loop to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected continue inside loop to pass: {:?}", result); } #[test] @@ -7258,7 +7676,7 @@ mod tests { break; return unit; } - "# + "#, ); assert!( matches!(result, Err(SemanticError::BreakContinueOutsideLoop { ref keyword, .. }) if keyword == "break"), @@ -7276,7 +7694,7 @@ mod tests { continue; return unit; } - "# + "#, ); assert!( matches!(result, Err(SemanticError::BreakContinueOutsideLoop { ref keyword, .. }) if keyword == "continue"), @@ -7300,9 +7718,13 @@ mod tests { } return unit; } - "# + "#, + ); + assert!( + result.is_ok(), + "Expected break in nested loop to pass: {:?}", + result ); - assert!(result.is_ok(), "Expected break in nested loop to pass: {:?}", result); } #[test] @@ -7318,7 +7740,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected for loop with i64 range to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected for loop with i64 range to pass: {:?}", + result + ); } #[test] @@ -7334,7 +7760,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected for loop with u32 range to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected for loop with u32 range to pass: {:?}", + result + ); } #[test] @@ -7350,7 +7780,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected for loop with usize range to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected for loop with usize range to pass: {:?}", + result + ); } #[test] @@ -7364,7 +7798,11 @@ mod tests { return unit; } "#, ); - assert!(result.is_ok(), "Expected array with literal size to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected array with literal size to pass: {:?}", + result + ); } #[test] @@ -7378,7 +7816,11 @@ mod tests { return unit; } "#, ); - assert!(result.is_ok(), "Expected array with hex size to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected array with hex size to pass: {:?}", + result + ); } #[test] @@ -7406,7 +7848,11 @@ mod tests { return unit; } "#, ); - assert!(result.is_ok(), "Expected const propagation for array size to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected const propagation for array size to pass: {:?}", + result + ); } #[test] @@ -7421,7 +7867,11 @@ mod tests { return unit; } "#, ); - assert!(result.is_ok(), "Expected typed const propagation to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected typed const propagation to pass: {:?}", + result + ); } #[test] @@ -7437,7 +7887,11 @@ mod tests { return unit; } "#, ); - assert!(result.is_ok(), "Expected chained const propagation to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected chained const propagation to pass: {:?}", + result + ); } // ========================================================================= @@ -7524,7 +7978,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected valid gate syntax to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected valid gate syntax to pass: {:?}", + result + ); } #[test] @@ -7542,7 +8000,8 @@ mod tests { ); assert!( matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), - "Expected QubitIndexOutOfBounds error, got: {:?}", result + "Expected QubitIndexOutOfBounds error, got: {:?}", + result ); } @@ -7559,7 +8018,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected index at boundary to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected index at boundary to pass: {:?}", + result + ); } #[test] @@ -7576,8 +8039,16 @@ mod tests { "#, ); assert!( - matches!(result, Err(SemanticError::QubitIndexOutOfBounds { index: 2, capacity: 2, .. })), - "Expected QubitIndexOutOfBounds error for index at capacity, got: {:?}", result + matches!( + result, + Err(SemanticError::QubitIndexOutOfBounds { + index: 2, + capacity: 2, + .. + }) + ), + "Expected QubitIndexOutOfBounds error for index at capacity, got: {:?}", + result ); } @@ -7596,7 +8067,8 @@ mod tests { ); assert!( matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), - "Expected QubitIndexOutOfBounds error for measurement, got: {:?}", result + "Expected QubitIndexOutOfBounds error for measurement, got: {:?}", + result ); } @@ -7615,7 +8087,8 @@ mod tests { ); assert!( matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), - "Expected QubitIndexOutOfBounds error for array measurement, got: {:?}", result + "Expected QubitIndexOutOfBounds error for array measurement, got: {:?}", + result ); } @@ -7633,7 +8106,8 @@ mod tests { ); assert!( matches!(result, Err(SemanticError::QubitIndexOutOfBounds { ref allocator, index: 5, capacity: 2, .. }) if allocator == "q"), - "Expected QubitIndexOutOfBounds error for pz, got: {:?}", result + "Expected QubitIndexOutOfBounds error for pz, got: {:?}", + result ); } @@ -7651,7 +8125,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "h(q[0]) should be valid (parens are grouping): {:?}", result); + assert!( + result.is_ok(), + "h(q[0]) should be valid (parens are grouping): {:?}", + result + ); } #[test] @@ -7667,7 +8145,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "cx(q[0], q[1]) should be valid: {:?}", result); + assert!( + result.is_ok(), + "cx(q[0], q[1]) should be valid: {:?}", + result + ); } #[test] @@ -7683,7 +8165,10 @@ mod tests { } "#; let result = parse(source); - assert!(result.is_err(), "rx without angle should fail at parse time"); + assert!( + result.is_err(), + "rx without angle should fail at parse time" + ); } // ========================================================================= @@ -7701,7 +8186,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected type ascription `42 u32` to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected type ascription `42 u32` to pass: {:?}", + result + ); } #[test] @@ -7715,7 +8204,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected type ascription `3.14 f64` to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected type ascription `3.14 f64` to pass: {:?}", + result + ); } #[test] @@ -7729,7 +8222,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected type ascription `1/4 f64` to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected type ascription `1/4 f64` to pass: {:?}", + result + ); } // ========================================================================= @@ -7747,7 +8244,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected angle literal `0.25 turns` to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected angle literal `0.25 turns` to pass: {:?}", + result + ); } #[test] @@ -7761,7 +8262,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected angle literal `0.5 turns` to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected angle literal `0.5 turns` to pass: {:?}", + result + ); } #[test] @@ -7776,7 +8281,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected angle literal to be a64 type: {:?}", result); + assert!( + result.is_ok(), + "Expected angle literal to be a64 type: {:?}", + result + ); } // ========================================================================= @@ -7808,7 +8317,10 @@ mod tests { } "#, ); - assert!(result.is_err(), "Expected undefined symbol error in struct init"); + assert!( + result.is_err(), + "Expected undefined symbol error in struct init" + ); if let Err(e) = result { assert!( matches!(e, SemanticError::UndefinedSymbol { .. }), @@ -7855,7 +8367,10 @@ mod tests { } "#, ); - assert!(result.is_err(), "Recursion should be rejected even in permissive mode"); + assert!( + result.is_err(), + "Recursion should be rejected even in permissive mode" + ); if let Err(e) = result { assert!( matches!(e, SemanticError::RecursionDetected { .. }), @@ -7878,7 +8393,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected non-recursive call to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected non-recursive call to pass: {:?}", + result + ); } #[test] @@ -7917,7 +8436,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected catch on error union to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected catch on error union to pass: {:?}", + result + ); } #[test] @@ -7933,7 +8456,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected single qubit batch gate to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected single qubit batch gate to pass: {:?}", + result + ); } #[test] @@ -7949,7 +8476,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected two qubit batch gate to pass: {:?}", result); + assert!( + result.is_ok(), + "Expected two qubit batch gate to pass: {:?}", + result + ); } #[test] @@ -7965,10 +8496,20 @@ mod tests { } "#, ); - assert!(result.is_err(), "Expected two qubit gate with single qubit to fail"); + assert!( + result.is_err(), + "Expected two qubit gate with single qubit to fail" + ); if let Err(e) = result { assert!( - matches!(e, SemanticError::GateArityMismatch { expected: 2, found: 1, .. }), + matches!( + e, + SemanticError::GateArityMismatch { + expected: 2, + found: 1, + .. + } + ), "Expected GateArityMismatch, got {:?}", e ); @@ -7988,10 +8529,20 @@ mod tests { } "#, ); - assert!(result.is_err(), "Expected single qubit gate with pair to fail"); + assert!( + result.is_err(), + "Expected single qubit gate with pair to fail" + ); if let Err(e) = result { assert!( - matches!(e, SemanticError::GateArityMismatch { expected: 1, found: 2, .. }), + matches!( + e, + SemanticError::GateArityMismatch { + expected: 1, + found: 2, + .. + } + ), "Expected GateArityMismatch, got {:?}", e ); @@ -8006,8 +8557,18 @@ mod tests { fn test_contains_unknown_primitive_types() { // Primitives never contain Unknown assert!(!Type::Bool.contains_unknown()); - assert!(!Type::UInt { bits: BitWidth::BITS_32 }.contains_unknown()); - assert!(!Type::IInt { bits: BitWidth::BITS_64 }.contains_unknown()); + assert!( + !Type::UInt { + bits: BitWidth::BITS_32 + } + .contains_unknown() + ); + assert!( + !Type::IInt { + bits: BitWidth::BITS_64 + } + .contains_unknown() + ); assert!(!Type::F64.contains_unknown()); assert!(!Type::Qubit.contains_unknown()); assert!(!Type::Unit.contains_unknown()); @@ -8031,7 +8592,9 @@ mod tests { // Array with concrete element doesn't contain unknown let arr_u32 = Type::Array { - element: Box::new(Type::UInt { bits: BitWidth::BITS_32 }), + element: Box::new(Type::UInt { + bits: BitWidth::BITS_32, + }), size: Some(10), }; assert!(!arr_u32.contains_unknown()); @@ -8042,7 +8605,9 @@ mod tests { // Tuple with Unknown element let tuple_with_unknown = Type::Tuple { elements: vec![ - Type::UInt { bits: BitWidth::BITS_32 }, + Type::UInt { + bits: BitWidth::BITS_32, + }, Type::Unknown, Type::Bool, ], @@ -8052,7 +8617,9 @@ mod tests { // Tuple without Unknown let tuple_concrete = Type::Tuple { elements: vec![ - Type::UInt { bits: BitWidth::BITS_32 }, + Type::UInt { + bits: BitWidth::BITS_32, + }, Type::Bool, ], }; @@ -8076,7 +8643,9 @@ mod tests { // Unknown in error position let unknown_error = Type::ErrorUnion { error: Box::new(Type::Unknown), - payload: Box::new(Type::UInt { bits: BitWidth::BITS_32 }), + payload: Box::new(Type::UInt { + bits: BitWidth::BITS_32, + }), }; assert!(unknown_error.contains_unknown()); @@ -8090,7 +8659,9 @@ mod tests { // Neither contains Unknown let concrete_union = Type::ErrorUnion { error: Box::new(Type::AnyError), - payload: Box::new(Type::UInt { bits: BitWidth::BITS_32 }), + payload: Box::new(Type::UInt { + bits: BitWidth::BITS_32, + }), }; assert!(!concrete_union.contains_unknown()); } @@ -8098,7 +8669,9 @@ mod tests { #[test] fn test_resolve_success() { // Concrete type resolves successfully - let ty = Type::UInt { bits: BitWidth::BITS_32 }; + let ty = Type::UInt { + bits: BitWidth::BITS_32, + }; let resolved = ty.resolve(); assert!(resolved.is_some()); assert_eq!(resolved.unwrap().display_name(), "u32"); @@ -8122,7 +8695,9 @@ mod tests { #[test] fn test_resolved_type_methods() { - let ty = Type::UInt { bits: BitWidth::BITS_64 }; + let ty = Type::UInt { + bits: BitWidth::BITS_64, + }; let resolved = ty.resolve().unwrap(); // Check wrapper methods work correctly @@ -8214,7 +8789,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected defined type to work: {:?}", result); + assert!( + result.is_ok(), + "Expected defined type to work: {:?}", + result + ); } // ========================================================================= @@ -8238,7 +8817,11 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected normal nesting to work: {:?}", result); + assert!( + result.is_ok(), + "Expected normal nesting to work: {:?}", + result + ); } #[test] @@ -8256,21 +8839,37 @@ mod tests { } "#, ); - assert!(result.is_ok(), "Expected normal symbol usage to work: {:?}", result); + assert!( + result.is_ok(), + "Expected normal symbol usage to work: {:?}", + result + ); } #[test] fn test_max_scope_depth_constant() { // Verify the constant is reasonable - assert!(MAX_SCOPE_DEPTH >= 64, "MAX_SCOPE_DEPTH should be at least 64"); - assert!(MAX_SCOPE_DEPTH <= 1024, "MAX_SCOPE_DEPTH should not be excessive"); + assert!( + MAX_SCOPE_DEPTH >= 64, + "MAX_SCOPE_DEPTH should be at least 64" + ); + assert!( + MAX_SCOPE_DEPTH <= 1024, + "MAX_SCOPE_DEPTH should not be excessive" + ); } #[test] fn test_max_symbol_count_constant() { // Verify the constant is reasonable - assert!(MAX_SYMBOL_COUNT >= 10_000, "MAX_SYMBOL_COUNT should be at least 10000"); - assert!(MAX_SYMBOL_COUNT <= 10_000_000, "MAX_SYMBOL_COUNT should not be excessive"); + assert!( + MAX_SYMBOL_COUNT >= 10_000, + "MAX_SYMBOL_COUNT should be at least 10000" + ); + assert!( + MAX_SYMBOL_COUNT <= 10_000_000, + "MAX_SYMBOL_COUNT should not be excessive" + ); } // ========================================================================= @@ -8294,10 +8893,15 @@ mod tests { assert!(result.is_err()); let errors = result.unwrap_err(); - assert!(errors.len() >= 2, "Expected at least 2 errors, got {}", errors.len()); + assert!( + errors.len() >= 2, + "Expected at least 2 errors, got {}", + errors.len() + ); // Check that both undefined types are reported - let error_names: Vec<_> = errors.iter() + let error_names: Vec<_> = errors + .iter() .filter_map(|e| { if let SemanticError::UndefinedType { name, .. } = e { Some(name.as_str()) @@ -8306,8 +8910,14 @@ mod tests { } }) .collect(); - assert!(error_names.contains(&"UndefinedType1"), "Should report UndefinedType1"); - assert!(error_names.contains(&"UndefinedType2"), "Should report UndefinedType2"); + assert!( + error_names.contains(&"UndefinedType1"), + "Should report UndefinedType1" + ); + assert!( + error_names.contains(&"UndefinedType2"), + "Should report UndefinedType2" + ); } #[test] @@ -8361,7 +8971,8 @@ mod tests { assert_eq!(errors.len(), 2); assert!(!errors.is_empty()); - let names: Vec<_> = errors.iter() + let names: Vec<_> = errors + .iter() .filter_map(|e| { if let SemanticError::UndefinedType { name, .. } = e { Some(name.clone()) @@ -8403,7 +9014,11 @@ mod tests { } "#; let result = analyze(source); - assert!(result.is_ok(), "Slice element access should work: {:?}", result.err()); + assert!( + result.is_ok(), + "Slice element access should work: {:?}", + result.err() + ); } // ========================================================================= @@ -8424,7 +9039,11 @@ mod tests { } "#; let result = analyze(source); - assert!(result.is_ok(), "Valid inline for should pass: {:?}", result.err()); + assert!( + result.is_ok(), + "Valid inline for should pass: {:?}", + result.err() + ); } #[test] @@ -8441,7 +9060,11 @@ mod tests { } "#; let result = analyze(source); - assert!(result.is_ok(), "Inline for with comptime expr should pass: {:?}", result.err()); + assert!( + result.is_ok(), + "Inline for with comptime expr should pass: {:?}", + result.err() + ); } #[test] @@ -8546,7 +9169,9 @@ mod tests { use crate::comptime::ComptimeValue; let args = vec![ - ComptimeValue::Type(Type::UInt { bits: BitWidth::must(32) }), + ComptimeValue::Type(Type::UInt { + bits: BitWidth::must(32), + }), ComptimeValue::Uint(4), ]; let mangled = SemanticAnalyzer::mangle_generic_name("make_array", &args); @@ -8559,10 +9184,7 @@ mod tests { fn test_serialize_comptime_args() { use crate::comptime::ComptimeValue; - let args = vec![ - ComptimeValue::Int(42), - ComptimeValue::Bool(true), - ]; + let args = vec![ComptimeValue::Int(42), ComptimeValue::Bool(true)]; let serialized = SemanticAnalyzer::serialize_comptime_args(&args); assert!(serialized.contains("42")); assert!(serialized.contains("true")); @@ -8587,21 +9209,43 @@ mod tests { "First param should be comptime (parser should set this). Got: {:?}", fn_decl.params[0] ); - assert!(!fn_decl.params[1].is_comptime, "Second param should not be comptime"); + assert!( + !fn_decl.params[1].is_comptime, + "Second param should not be comptime" + ); } else { panic!("Expected function declaration"); } let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Generic function should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "Generic function should parse: {:?}", + result.err() + ); // Check that the function was registered with comptime param info if let Some(symbol) = analyzer.symbols.lookup("repeat") { - if let SymbolKind::Function { comptime_param_indices, original_decl, .. } = &symbol.kind { - assert_eq!(comptime_param_indices.len(), 1, "Should have 1 comptime param"); - assert_eq!(comptime_param_indices[0], 0, "First param should be comptime"); - assert!(original_decl.is_some(), "Should store original decl for generic"); + if let SymbolKind::Function { + comptime_param_indices, + original_decl, + .. + } = &symbol.kind + { + assert_eq!( + comptime_param_indices.len(), + 1, + "Should have 1 comptime param" + ); + assert_eq!( + comptime_param_indices[0], 0, + "First param should be comptime" + ); + assert!( + original_decl.is_some(), + "Should store original decl for generic" + ); } else { panic!("Expected function symbol"); } @@ -8623,9 +9267,20 @@ mod tests { let _ = analyzer.analyze(&program); if let Some(symbol) = analyzer.symbols.lookup("add") { - if let SymbolKind::Function { comptime_param_indices, original_decl, .. } = &symbol.kind { - assert!(comptime_param_indices.is_empty(), "Should have no comptime params"); - assert!(original_decl.is_none(), "Should not store original decl for non-generic"); + if let SymbolKind::Function { + comptime_param_indices, + original_decl, + .. + } = &symbol.kind + { + assert!( + comptime_param_indices.is_empty(), + "Should have no comptime params" + ); + assert!( + original_decl.is_none(), + "Should not store original decl for non-generic" + ); } } } @@ -8664,7 +9319,11 @@ mod tests { let program = crate::parse(source).unwrap(); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Non-overlapping aliases should work: {:?}", result); + assert!( + result.is_ok(), + "Non-overlapping aliases should work: {:?}", + result + ); } #[test] @@ -8702,7 +9361,11 @@ mod tests { let program = crate::parse(source).unwrap(); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Adjacent ranges should not overlap: {:?}", result); + assert!( + result.is_ok(), + "Adjacent ranges should not overlap: {:?}", + result + ); } #[test] @@ -8720,7 +9383,11 @@ mod tests { let program = crate::parse(source).unwrap(); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Different sources can have same ranges: {:?}", result); + assert!( + result.is_ok(), + "Different sources can have same ranges: {:?}", + result + ); } #[test] @@ -8740,7 +9407,11 @@ mod tests { let program = crate::parse(source).unwrap(); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Alias should be usable as slice: {:?}", result); + assert!( + result.is_ok(), + "Alias should be usable as slice: {:?}", + result + ); } #[test] @@ -8759,13 +9430,15 @@ mod tests { #[test] fn test_array_index_out_of_bounds() { - let result = analyze(r#" + let result = analyze( + r#" fn main() -> unit { arr: [3]i64 = [1, 2, 3]; x := arr[5]; return; } - "#); + "#, + ); assert!(result.is_err(), "Expected ArrayIndexOutOfBounds error"); let err = result.unwrap_err(); assert!( @@ -8777,37 +9450,53 @@ mod tests { #[test] fn test_array_index_at_boundary() { // arr[2] on [3]i64 is valid (indices 0, 1, 2) - let result = analyze(r#" + let result = analyze( + r#" fn main() -> unit { arr: [3]i64 = [1, 2, 3]; x := arr[2]; return; } - "#); - assert!(result.is_ok(), "arr[2] on [3]i64 should be valid, got: {:?}", result); + "#, + ); + assert!( + result.is_ok(), + "arr[2] on [3]i64 should be valid, got: {:?}", + result + ); } #[test] fn test_array_index_literal_at_size() { // arr[3] on [3]i64 is out of bounds (valid indices are 0, 1, 2) - let result = analyze(r#" + let result = analyze( + r#" fn main() -> unit { arr: [3]i64 = [1, 2, 3]; x := arr[3]; return; } - "#); - assert!(result.is_err(), "Expected ArrayIndexOutOfBounds for index == size"); + "#, + ); + assert!( + result.is_err(), + "Expected ArrayIndexOutOfBounds for index == size" + ); } #[test] fn test_array_dynamic_index_no_error() { // Dynamic index should not produce a compile-time error - assert!(analyze(r#" + assert!( + analyze( + r#" fn get(arr: [3]i64, i: i64) -> i64 { return arr[i]; } - "#).is_ok()); + "# + ) + .is_ok() + ); } // ========================================================================= @@ -8817,51 +9506,78 @@ mod tests { #[test] fn test_declare_gate_registered() { // declare gate should be registered in the gate registry - assert!(analyze(r#" + assert!( + analyze( + r#" declare gate my_rx(theta)(q); - "#).is_ok()); + "# + ) + .is_ok() + ); } #[test] fn test_declare_gate_no_params() { - assert!(analyze(r#" + assert!( + analyze( + r#" declare gate my_x()(q); - "#).is_ok()); + "# + ) + .is_ok() + ); } #[test] fn test_declare_gate_multi_qubit() { - assert!(analyze(r#" + assert!( + analyze( + r#" declare gate cnot()(control, target); - "#).is_ok()); + "# + ) + .is_ok() + ); } #[test] fn test_composite_gate_basic() { - assert!(analyze(r#" + assert!( + analyze( + r#" gate my_h()(q) { h q; } - "#).is_ok()); + "# + ) + .is_ok() + ); } #[test] fn test_composite_gate_multi_qubit() { - assert!(analyze(r#" + assert!( + analyze( + r#" gate bell()(q0, q1) { h q0; cx (q0, q1); } - "#).is_ok()); + "# + ) + .is_ok() + ); } #[test] fn test_declare_gate_duplicate_rejected() { // Defining same gate name twice should fail - let result = analyze(r#" + let result = analyze( + r#" declare gate my_gate()(q); declare gate my_gate()(q); - "#); + "#, + ); assert!(result.is_err(), "Duplicate gate declaration should fail"); } @@ -8902,24 +9618,36 @@ mod tests { fn test_declare_gate_builtin_exact_signature_allowed() { // Redeclaring a built-in with its exact signature is a harmless no-op. // `rz` is a 1-parameter, 1-qubit built-in. - assert!(analyze(r#" + assert!( + analyze( + r#" declare gate rz(angle)(q); - "#).is_ok()); + "# + ) + .is_ok() + ); } #[test] fn test_declare_gate_builtin_mismatched_signature_rejected() { // `rz` is a 1-parameter built-in; redeclaring it with no parameters // would be uncallable under the fixed built-in parameterization. - assert!(analyze(r#" + assert!( + analyze( + r#" declare gate rz()(q); - "#).is_err()); + "# + ) + .is_err() + ); } #[test] fn test_builtin_gates_still_work() { // Built-in gates should still work alongside custom gate declarations - assert!(analyze(r#" + assert!( + analyze( + r#" declare gate custom_rx(theta)(q); fn apply(q: qubit) -> unit { @@ -8927,6 +9655,9 @@ mod tests { x q; return; } - "#).is_ok()); + "# + ) + .is_ok() + ); } } diff --git a/exp/zlup/src/test_runner.rs b/exp/zlup/src/test_runner.rs index f228005f0..68e9331e3 100644 --- a/exp/zlup/src/test_runner.rs +++ b/exp/zlup/src/test_runner.rs @@ -214,9 +214,18 @@ pub fn format_results(results: &[TestResult]) -> String { } } - let pass_count = results.iter().filter(|r| r.outcome == TestOutcome::Pass).count(); - let fail_count = results.iter().filter(|r| matches!(r.outcome, TestOutcome::Fail(_))).count(); - let skip_count = results.iter().filter(|r| matches!(r.outcome, TestOutcome::Skip(_))).count(); + let pass_count = results + .iter() + .filter(|r| r.outcome == TestOutcome::Pass) + .count(); + let fail_count = results + .iter() + .filter(|r| matches!(r.outcome, TestOutcome::Fail(_))) + .count(); + let skip_count = results + .iter() + .filter(|r| matches!(r.outcome, TestOutcome::Skip(_))) + .count(); let total = results.len(); out.push_str(&format!( diff --git a/exp/zlup/src/tests.rs b/exp/zlup/src/tests.rs index 48999cf78..25cceea5a 100644 --- a/exp/zlup/src/tests.rs +++ b/exp/zlup/src/tests.rs @@ -769,7 +769,7 @@ fn test_block_expression() { fn test_builtin_calls() { assert_parses!(r#"std := @import("std");"#); // Self is now a keyword (like Rust), no need for @This() alias - assert_parses!("x := Self;"); // Self can be used as a type value + assert_parses!("x := Self;"); // Self can be used as a type value assert_parses!("size := @sizeOf(u32);"); } @@ -835,13 +835,15 @@ fn test_array_size_expressions() { // Parenthesized expression assert_parses!("h: [(N + 1) * 2]u8 = undefined;"); // In function returning comptime type - assert_parses!(r#" + assert_parses!( + r#" pub MyArray := fn(comptime N: usize) -> type { struct { data: [N + 1]u8 = undefined, } }; - "#); + "# + ); } #[test] @@ -1681,7 +1683,7 @@ fn test_suggest_gate_name_typo() { use crate::parser::suggest_gate_name; // Close misspellings should suggest the correct gate assert_eq!(suggest_gate_name("cx"), Some("cx")); // exact match (dist 0) - assert_eq!(suggest_gate_name("hh"), Some("h")); // edit distance 1 + assert_eq!(suggest_gate_name("hh"), Some("h")); // edit distance 1 assert_eq!(suggest_gate_name("swp"), Some("swap")); // edit distance 1 } @@ -1708,37 +1710,27 @@ fn test_edit_distance() { #[test] fn test_parse_declare_gate() { - assert_parses!( - "declare gate my_gate(theta)(q);" - ); + assert_parses!("declare gate my_gate(theta)(q);"); } #[test] fn test_parse_declare_gate_no_params() { - assert_parses!( - "declare gate my_x()(q);" - ); + assert_parses!("declare gate my_x()(q);"); } #[test] fn test_parse_declare_gate_multiple_qubits() { - assert_parses!( - "declare gate cnot()(control, target);" - ); + assert_parses!("declare gate cnot()(control, target);"); } #[test] fn test_parse_declare_gate_multiple_params() { - assert_parses!( - "declare gate u3(theta, phi, lambda)(q);" - ); + assert_parses!("declare gate u3(theta, phi, lambda)(q);"); } #[test] fn test_parse_declare_gate_pub() { - assert_parses!( - "pub declare gate rz(angle)(q);" - ); + assert_parses!("pub declare gate rz(angle)(q);"); } #[test] @@ -1804,7 +1796,5 @@ fn test_parse_declare_gate_and_fn_together() { #[test] fn test_parse_composite_gate_with_typed_param() { - assert_parses!( - "gate rz(theta: a64)(q) { h q; }" - ); + assert_parses!("gate rz(theta: a64)(q) { h q; }"); } diff --git a/exp/zlup/tests/cli.rs b/exp/zlup/tests/cli.rs index 2d751c845..3b0277659 100644 --- a/exp/zlup/tests/cli.rs +++ b/exp/zlup/tests/cli.rs @@ -1,8 +1,8 @@ //! CLI integration tests for Zlup. use std::fs; -use std::process::Command; use std::path::PathBuf; +use std::process::Command; /// Get the path to the zlup binary. fn zlup_bin() -> Command { @@ -28,10 +28,7 @@ fn test_help() { #[test] fn test_version() { - let output = zlup_bin() - .arg("--version") - .output() - .expect("failed to run"); + let output = zlup_bin().arg("--version").output().expect("failed to run"); assert!(output.status.success()); let stdout = String::from_utf8_lossy(&output.stdout); @@ -429,8 +426,12 @@ fn temp_dir(test_name: &str) -> PathBuf { .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - let dir = std::env::temp_dir().join(format!("zlup-test-{}-{}-{}", - test_name, std::process::id(), timestamp)); + let dir = std::env::temp_dir().join(format!( + "zlup-test-{}-{}-{}", + test_name, + std::process::id(), + timestamp + )); fs::create_dir_all(&dir).expect("failed to create temp dir"); dir } @@ -467,12 +468,22 @@ fn test_init_creates_project() { .output() .expect("failed to run"); - assert!(output.status.success(), "init failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "init failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); // Check files were created assert!(project_dir.exists(), "project dir should exist"); - assert!(project_dir.join("zlup.toml").exists(), "zlup.toml should exist"); - assert!(project_dir.join("main.zlp").exists(), "main.zlp should exist"); + assert!( + project_dir.join("zlup.toml").exists(), + "zlup.toml should exist" + ); + assert!( + project_dir.join("main.zlp").exists(), + "main.zlp should exist" + ); // Check zlup.toml content let toml_content = fs::read_to_string(project_dir.join("zlup.toml")).expect("read zlup.toml"); @@ -505,7 +516,11 @@ fn test_init_project_exists_error() { assert!(!output.status.success(), "should fail when project exists"); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("exists"), "should report project exists: {}", stderr); + assert!( + stderr.contains("exists"), + "should report project exists: {}", + stderr + ); // Cleanup cleanup_temp_dir(&temp); @@ -541,8 +556,11 @@ fn test_build_no_config_error() { assert!(!output.status.success(), "should fail without zlup.toml"); let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("config") || stderr.contains("not found"), - "should report config not found: {}", stderr); + assert!( + stderr.contains("config") || stderr.contains("not found"), + "should report config not found: {}", + stderr + ); // Cleanup cleanup_temp_dir(&temp); @@ -561,7 +579,11 @@ fn test_init_then_build() { .output() .expect("failed to run init"); - assert!(output.status.success(), "init failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "init failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); // Build project let output = zlup_bin() @@ -570,11 +592,19 @@ fn test_init_then_build() { .output() .expect("failed to run build"); - assert!(output.status.success(), "build failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "build failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); // Check output file was created let output_file = project_dir.join("build").join("main.slr.json"); - assert!(output_file.exists(), "output file should exist at {:?}", output_file); + assert!( + output_file.exists(), + "output file should exist at {:?}", + output_file + ); // Verify it's valid JSON let content = fs::read_to_string(&output_file).expect("read output"); @@ -598,7 +628,11 @@ fn test_build_with_strict_override() { .output() .expect("failed to run init"); - assert!(output.status.success(), "init failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "init failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); // Build with strict override let output = zlup_bin() @@ -611,7 +645,11 @@ fn test_build_with_strict_override() { // Note: The actual success/failure depends on semantic analyzer's strict mode behavior // which may flag qubit operations that aren't fully tracked let stderr = String::from_utf8_lossy(&output.stderr); - assert!(stderr.contains("strict"), "should show strict mode in output: {}", stderr); + assert!( + stderr.contains("strict"), + "should show strict mode in output: {}", + stderr + ); // Cleanup cleanup_temp_dir(&temp); @@ -629,7 +667,11 @@ fn examples_dir() -> PathBuf { /// Test that an example file parses and semantic checks correctly fn test_example_checks(filename: &str) { let example_path = examples_dir().join(filename); - assert!(example_path.exists(), "Example file should exist: {:?}", example_path); + assert!( + example_path.exists(), + "Example file should exist: {:?}", + example_path + ); let output = zlup_bin() .args(["check", example_path.to_str().unwrap()]) @@ -648,10 +690,21 @@ fn test_example_checks(filename: &str) { /// Test that an example file compiles to SLR-AST fn test_example_compiles_slr(filename: &str) { let example_path = examples_dir().join(filename); - assert!(example_path.exists(), "Example file should exist: {:?}", example_path); + assert!( + example_path.exists(), + "Example file should exist: {:?}", + example_path + ); let output = zlup_bin() - .args(["compile", example_path.to_str().unwrap(), "--format", "slr", "-o", "-"]) + .args([ + "compile", + example_path.to_str().unwrap(), + "--format", + "slr", + "-o", + "-", + ]) .output() .expect("failed to run compile"); @@ -678,10 +731,21 @@ fn test_example_compiles_slr(filename: &str) { /// Test that an example file compiles to QASM fn test_example_compiles_qasm(filename: &str) { let example_path = examples_dir().join(filename); - assert!(example_path.exists(), "Example file should exist: {:?}", example_path); + assert!( + example_path.exists(), + "Example file should exist: {:?}", + example_path + ); let output = zlup_bin() - .args(["compile", example_path.to_str().unwrap(), "--format", "qasm", "-o", "-"]) + .args([ + "compile", + example_path.to_str().unwrap(), + "--format", + "qasm", + "-o", + "-", + ]) .output() .expect("failed to run compile"); @@ -841,13 +905,20 @@ fn test_analyze_stdin_text() { } let output = child.wait_with_output().expect("failed to wait"); - assert!(output.status.success(), "analyze failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "analyze failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); let stdout = String::from_utf8_lossy(&output.stdout); assert!(stdout.contains("Parallelism Analysis"), "Missing header"); assert!(stdout.contains("Allocators:"), "Missing allocators section"); assert!(stdout.contains("q"), "Missing allocator q"); - assert!(stdout.contains("Function Analysis:"), "Missing function analysis"); + assert!( + stdout.contains("Function Analysis:"), + "Missing function analysis" + ); assert!(stdout.contains("main"), "Missing main function"); } @@ -874,18 +945,31 @@ fn test_analyze_stdin_json() { } let output = child.wait_with_output().expect("failed to wait"); - assert!(output.status.success(), "analyze failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "analyze failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); let stdout = String::from_utf8_lossy(&output.stdout); // Parse as JSON to verify structure - let json: serde_json::Value = serde_json::from_str(&stdout) - .expect("Output should be valid JSON"); + let json: serde_json::Value = + serde_json::from_str(&stdout).expect("Output should be valid JSON"); - assert!(json["allocators"].is_array(), "Should have allocators array"); + assert!( + json["allocators"].is_array(), + "Should have allocators array" + ); assert!(json["functions"].is_array(), "Should have functions array"); - assert!(json["parallel_layers"].is_array(), "Should have parallel_layers array"); - assert!(json["total_operations"].is_number(), "Should have total_operations"); + assert!( + json["parallel_layers"].is_array(), + "Should have parallel_layers array" + ); + assert!( + json["total_operations"].is_number(), + "Should have total_operations" + ); } #[test] @@ -912,11 +996,21 @@ fn test_analyze_verbose() { } let output = child.wait_with_output().expect("failed to wait"); - assert!(output.status.success(), "analyze failed: {:?}", String::from_utf8_lossy(&output.stderr)); + assert!( + output.status.success(), + "analyze failed: {:?}", + String::from_utf8_lossy(&output.stderr) + ); let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Dependency Graph"), "Verbose should include dependency graph"); - assert!(stdout.contains("Parallel Layers"), "Verbose should include parallel layers"); + assert!( + stdout.contains("Dependency Graph"), + "Verbose should include dependency graph" + ); + assert!( + stdout.contains("Parallel Layers"), + "Verbose should include parallel layers" + ); } #[test] @@ -957,14 +1051,19 @@ fn test_analyze_disjoint_allocators() { // Check max parallelism > 1 (H gates on different allocators can run in parallel) let functions = json["functions"].as_array().expect("functions array"); let main_func = &functions[0]; - let max_parallelism = main_func["max_parallelism"].as_u64().expect("max_parallelism"); - assert!(max_parallelism >= 2, "Disjoint allocators should enable parallelism"); + let max_parallelism = main_func["max_parallelism"] + .as_u64() + .expect("max_parallelism"); + assert!( + max_parallelism >= 2, + "Disjoint allocators should enable parallelism" + ); } #[test] fn test_analyze_parse_error() { // Invalid syntax should fail - let source = "fn main( { }"; // Missing closing paren and return type + let source = "fn main( { }"; // Missing closing paren and return type let mut child = zlup_bin() .args(["analyze", "-"]) @@ -1033,5 +1132,5 @@ fn test_analyze_semantic_error() { let output = child.wait_with_output().expect("failed to wait"); // Note: semantic errors in permissive mode may still allow analysis // This test verifies the command handles the input without crashing - let _ = output.status; // May or may not succeed depending on strictness + let _ = output.status; // May or may not succeed depending on strictness } diff --git a/exp/zlup/tests/proptest.rs b/exp/zlup/tests/proptest.rs index 24cdb60ce..ecfe19c99 100644 --- a/exp/zlup/tests/proptest.rs +++ b/exp/zlup/tests/proptest.rs @@ -675,7 +675,10 @@ fn recursion_rejected_even_permissive() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new_permissive(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Recursion should be rejected even in permissive mode"); + assert!( + result.is_err(), + "Recursion should be rejected even in permissive mode" + ); } // ============================================================================= @@ -743,21 +746,25 @@ fn unclosed_paren_rejected() { ]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Unclosed paren should be rejected: {}", input); + assert!( + result.is_err(), + "Unclosed paren should be rejected: {}", + input + ); } } /// Unclosed brackets should fail to parse. #[test] fn unclosed_bracket_rejected() { - let inputs = [ - "x := [1, 2, 3;", - "x := a[0;", - "x: [4]u8 = [1, 2;", - ]; + let inputs = ["x := [1, 2, 3;", "x := a[0;", "x: [4]u8 = [1, 2;"]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Unclosed bracket should be rejected: {}", input); + assert!( + result.is_err(), + "Unclosed bracket should be rejected: {}", + input + ); } } @@ -771,18 +778,28 @@ fn unclosed_brace_rejected() { ]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Unclosed brace should be rejected: {}", input); + assert!( + result.is_err(), + "Unclosed brace should be rejected: {}", + input + ); } } /// Reserved keywords as identifiers should fail. #[test] fn keyword_as_identifier_rejected() { - let keywords = ["fn", "if", "else", "for", "return", "struct", "enum", "true", "false"]; + let keywords = [ + "fn", "if", "else", "for", "return", "struct", "enum", "true", "false", + ]; for kw in keywords { let source = format!("{} := 42;", kw); let result = zlup::parse(&source); - assert!(result.is_err(), "Keyword '{}' as identifier should be rejected", kw); + assert!( + result.is_err(), + "Keyword '{}' as identifier should be rejected", + kw + ); } } @@ -790,14 +807,18 @@ fn keyword_as_identifier_rejected() { #[test] fn invalid_number_literal_rejected() { let inputs = [ - "x := 0x;", // hex with no digits - "x := 0b;", // binary with no digits - "x := 0o;", // octal with no digits - "x := 1.;", // trailing dot with no fraction + "x := 0x;", // hex with no digits + "x := 0b;", // binary with no digits + "x := 0o;", // octal with no digits + "x := 1.;", // trailing dot with no fraction ]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Invalid number literal should be rejected: {}", input); + assert!( + result.is_err(), + "Invalid number literal should be rejected: {}", + input + ); } } @@ -814,19 +835,26 @@ fn deprecated_measurement_syntax_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Deprecated mz(type, target) syntax should be rejected"); + assert!( + result.is_err(), + "Deprecated mz(type, target) syntax should be rejected" + ); } /// Empty function body is valid but missing semicolon is not. #[test] fn missing_semicolon_rejected() { let inputs = [ - "x := 42", // missing semicolon on binding - "fn foo() -> u32 { return 42 }", // missing semicolon on return + "x := 42", // missing semicolon on binding + "fn foo() -> u32 { return 42 }", // missing semicolon on return ]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Missing semicolon should be rejected: {}", input); + assert!( + result.is_err(), + "Missing semicolon should be rejected: {}", + input + ); } } @@ -848,7 +876,10 @@ fn batch_gate_wrong_element_arity_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "H gate with tuple elements should be rejected"); + assert!( + result.is_err(), + "H gate with tuple elements should be rejected" + ); } /// CX batch with single qubits instead of pairs should be rejected. @@ -865,7 +896,10 @@ fn batch_cx_wrong_element_type_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "CX gate with single qubit elements should be rejected"); + assert!( + result.is_err(), + "CX gate with single qubit elements should be rejected" + ); } /// Using a non-qubit type where qubit is expected should be rejected. @@ -947,7 +981,10 @@ fn duplicate_qubit_in_tick_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Same qubit used twice in tick should be rejected"); + assert!( + result.is_err(), + "Same qubit used twice in tick should be rejected" + ); } /// Catch on non-error type should be rejected. @@ -962,7 +999,10 @@ fn catch_on_non_error_type_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Catch on non-error type should be rejected"); + assert!( + result.is_err(), + "Catch on non-error type should be rejected" + ); } /// Array index with non-integer should be rejected. @@ -977,7 +1017,10 @@ fn array_index_non_integer_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Array index with boolean should be rejected"); + assert!( + result.is_err(), + "Array index with boolean should be rejected" + ); } /// Field access on non-struct should be rejected. @@ -992,7 +1035,10 @@ fn field_access_on_non_struct_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Field access on integer should be rejected"); + assert!( + result.is_err(), + "Field access on integer should be rejected" + ); } /// Accessing undefined struct field should be rejected. @@ -1011,7 +1057,10 @@ fn undefined_struct_field_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Accessing undefined field should be rejected"); + assert!( + result.is_err(), + "Accessing undefined field should be rejected" + ); } /// Duplicate struct field in initialization should be rejected. @@ -1029,7 +1078,10 @@ fn duplicate_struct_field_init_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Duplicate field in struct init should be rejected"); + assert!( + result.is_err(), + "Duplicate field in struct init should be rejected" + ); } /// Binary operation with incompatible types should be rejected. @@ -1075,7 +1127,10 @@ fn return_value_from_unit_function_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Returning value from unit function should be rejected"); + assert!( + result.is_err(), + "Returning value from unit function should be rejected" + ); } /// Wrong type in struct field initialization should be rejected. @@ -1110,7 +1165,10 @@ fn qubit_already_prepared_rejected() { let mut analyzer = SemanticAnalyzer::new(); analyzer.set_strict_mode(true); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Double prepare should be rejected in strict mode"); + assert!( + result.is_err(), + "Double prepare should be rejected in strict mode" + ); } /// Duplicate qubit in measurement should be rejected. @@ -1126,7 +1184,10 @@ fn duplicate_qubit_in_measurement_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Duplicate qubit in measurement should be rejected"); + assert!( + result.is_err(), + "Duplicate qubit in measurement should be rejected" + ); } /// Measurement size mismatch should be rejected. @@ -1142,7 +1203,10 @@ fn measurement_size_mismatch_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Measurement size mismatch should be rejected"); + assert!( + result.is_err(), + "Measurement size mismatch should be rejected" + ); } /// Using orelse on non-optional should be rejected. @@ -1171,7 +1235,10 @@ fn empty_array_no_type_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Empty array without type should be rejected"); + assert!( + result.is_err(), + "Empty array without type should be rejected" + ); } /// For loop with non-iterable should be rejected. @@ -1201,7 +1268,10 @@ fn comparison_type_mismatch_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Comparing int to string should be rejected"); + assert!( + result.is_err(), + "Comparing int to string should be rejected" + ); } /// If condition must be boolean. @@ -1217,7 +1287,10 @@ fn if_non_boolean_condition_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "If with integer condition should be rejected"); + assert!( + result.is_err(), + "If with integer condition should be rejected" + ); } // ============================================================================= @@ -1228,12 +1301,16 @@ fn if_non_boolean_condition_rejected() { #[test] fn invalid_string_escape_rejected() { let inputs = [ - r#"x := "\q";"#, // \q is not a valid escape - r#"x := "\u1234";"#, // \u is not supported (use \x) + r#"x := "\q";"#, // \q is not a valid escape + r#"x := "\u1234";"#, // \u is not supported (use \x) ]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Invalid escape should be rejected: {}", input); + assert!( + result.is_err(), + "Invalid escape should be rejected: {}", + input + ); } } @@ -1241,12 +1318,16 @@ fn invalid_string_escape_rejected() { #[test] fn missing_separator_rejected() { let inputs = [ - "x := 1 y := 2;", // missing semicolon between statements - "fn foo() {} bar", // junk after function + "x := 1 y := 2;", // missing semicolon between statements + "fn foo() {} bar", // junk after function ]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Missing separator should be rejected: {}", input); + assert!( + result.is_err(), + "Missing separator should be rejected: {}", + input + ); } } @@ -1254,13 +1335,17 @@ fn missing_separator_rejected() { #[test] fn invalid_type_syntax_rejected() { let inputs = [ - "x: [;", // incomplete array type - "x: *;", // pointer to nothing - "x: ?;", // optional of nothing + "x: [;", // incomplete array type + "x: *;", // pointer to nothing + "x: ?;", // optional of nothing ]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Invalid type should be rejected: {}", input); + assert!( + result.is_err(), + "Invalid type should be rejected: {}", + input + ); } } @@ -1268,13 +1353,17 @@ fn invalid_type_syntax_rejected() { #[test] fn invalid_param_syntax_rejected() { let inputs = [ - "fn foo(,) {}", // just a comma - "fn foo(x:) {}", // missing type - "fn foo(: i32) {}", // missing name + "fn foo(,) {}", // just a comma + "fn foo(x:) {}", // missing type + "fn foo(: i32) {}", // missing name ]; for input in inputs { let result = zlup::parse(input); - assert!(result.is_err(), "Invalid param should be rejected: {}", input); + assert!( + result.is_err(), + "Invalid param should be rejected: {}", + input + ); } } @@ -1312,7 +1401,10 @@ fn zero_array_access_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Accessing element of zero-size array should be rejected"); + assert!( + result.is_err(), + "Accessing element of zero-size array should be rejected" + ); } /// Using undefined label with break should be rejected. @@ -1328,7 +1420,10 @@ fn undefined_break_label_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Break with undefined label should be rejected"); + assert!( + result.is_err(), + "Break with undefined label should be rejected" + ); } /// Assigning to a constant/comptime value should be rejected. @@ -1343,7 +1438,10 @@ fn assign_to_comptime_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Assigning to comptime value should be rejected"); + assert!( + result.is_err(), + "Assigning to comptime value should be rejected" + ); } /// Division by zero at comptime should be rejected. @@ -1358,7 +1456,10 @@ fn comptime_division_by_zero_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Comptime division by zero should be rejected"); + assert!( + result.is_err(), + "Comptime division by zero should be rejected" + ); } /// Invalid unary operator application should be rejected. @@ -1374,7 +1475,11 @@ fn invalid_unary_op_rejected() { let program = zlup::parse(&wrapped).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Invalid unary op {} should be rejected", desc); + assert!( + result.is_err(), + "Invalid unary op {} should be rejected", + desc + ); } } @@ -1390,7 +1495,10 @@ fn deref_non_pointer_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Dereferencing non-pointer should be rejected"); + assert!( + result.is_err(), + "Dereferencing non-pointer should be rejected" + ); } /// Taking address of a literal should be rejected. @@ -1445,7 +1553,10 @@ fn gate_wrong_param_type_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Gate with string parameter should be rejected"); + assert!( + result.is_err(), + "Gate with string parameter should be rejected" + ); } /// Enum variant that doesn't exist should be rejected. @@ -1474,7 +1585,10 @@ fn type_as_value_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Using type in arithmetic should be rejected"); + assert!( + result.is_err(), + "Using type in arithmetic should be rejected" + ); } /// Bitwise operations on floats should be rejected. @@ -1530,7 +1644,10 @@ fn switch_duplicate_bool_case_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Duplicate bool switch case should be rejected"); + assert!( + result.is_err(), + "Duplicate bool switch case should be rejected" + ); } /// Switch with duplicate string cases should be rejected. @@ -1550,7 +1667,10 @@ fn switch_duplicate_string_case_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Duplicate string switch case should be rejected"); + assert!( + result.is_err(), + "Duplicate string switch case should be rejected" + ); } /// Switch with unique cases should be allowed. @@ -1571,7 +1691,11 @@ fn switch_unique_cases_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Switch with unique cases should be allowed: {:?}", result); + assert!( + result.is_ok(), + "Switch with unique cases should be allowed: {:?}", + result + ); } /// Missing struct fields in initialization should be rejected. @@ -1610,7 +1734,11 @@ fn log_standard_levels_parse() { } "#; let result = zlup::parse(source); - assert!(result.is_ok(), "Standard log levels should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "Standard log levels should parse: {:?}", + result.err() + ); } /// Log with sub-namespace should parse correctly. @@ -1624,7 +1752,11 @@ fn log_with_namespace_parses() { } "#; let result = zlup::parse(source); - assert!(result.is_ok(), "Log with namespace should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "Log with namespace should parse: {:?}", + result.err() + ); } /// Bare log.info without @emit prefix is parsed as method call (not channel). @@ -1642,7 +1774,10 @@ fn bare_log_not_channel() { let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); // Semantic analysis should fail because `log` is not defined - assert!(result.is_err(), "Undefined variable 'log' should cause semantic error"); + assert!( + result.is_err(), + "Undefined variable 'log' should cause semantic error" + ); } /// Log with data parameter should parse correctly. @@ -1657,7 +1792,11 @@ fn log_with_data_parses() { } "#; let result = zlup::parse(source); - assert!(result.is_ok(), "Log with data should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "Log with data should parse: {:?}", + result.err() + ); } /// Custom log level with @emit.log.at should parse correctly. @@ -1671,7 +1810,11 @@ fn log_custom_level_parses() { } "#; let result = zlup::parse(source); - assert!(result.is_ok(), "Custom log level should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "Custom log level should parse: {:?}", + result.err() + ); } /// Log expressions should pass semantic analysis. @@ -1692,7 +1835,11 @@ fn log_passes_semantic_analysis() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Log expressions should pass semantic analysis: {:?}", result.err()); + assert!( + result.is_ok(), + "Log expressions should pass semantic analysis: {:?}", + result.err() + ); } /// Log with f-string interpolation should work. @@ -1709,7 +1856,11 @@ fn log_fstring_interpolation_works() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Log with f-string interpolation should work: {:?}", result.err()); + assert!( + result.is_ok(), + "Log with f-string interpolation should work: {:?}", + result.err() + ); } /// Log SLR codegen should emit LogStmt nodes. @@ -1728,9 +1879,18 @@ fn log_slr_codegen_emits_log_stmt() { let slr_program = codegen.compile(&program).expect("Should compile"); let json = codegen.to_json(&slr_program).expect("Should serialize"); - assert!(json.contains("LogStmt"), "SLR output should contain LogStmt"); - assert!(json.contains("debug"), "SLR output should contain log level"); - assert!(json.contains("test message"), "SLR output should contain message"); + assert!( + json.contains("LogStmt"), + "SLR output should contain LogStmt" + ); + assert!( + json.contains("debug"), + "SLR output should contain log level" + ); + assert!( + json.contains("test message"), + "SLR output should contain message" + ); } /// Log SLR codegen should handle namespaces. @@ -1750,7 +1910,10 @@ fn log_slr_codegen_handles_namespace() { let slr_program = codegen.compile(&program).expect("Should compile"); let json = codegen.to_json(&slr_program).expect("Should serialize"); - assert!(json.contains("testmod::myns"), "SLR output should contain combined namespace"); + assert!( + json.contains("testmod::myns"), + "SLR output should contain combined namespace" + ); } /// Log elision in release mode should remove all logs. @@ -1773,14 +1936,17 @@ fn log_elision_release_removes_all() { let slr_program = codegen.compile(&program).expect("Should compile"); let json = codegen.to_json(&slr_program).expect("Should serialize"); - assert!(!json.contains("LogStmt"), "Release mode should elide all logs"); + assert!( + !json.contains("LogStmt"), + "Release mode should elide all logs" + ); } /// Log elision with custom level should filter appropriately. #[test] fn log_elision_custom_level_filters() { - use zlup::codegen::slr::LogElisionLevel; use zlup::codegen::SlrCodegen; + use zlup::codegen::slr::LogElisionLevel; let source = r#" pub fn main() -> unit { @@ -1958,9 +2124,21 @@ fn result_generates_slr() { let json = codegen.to_json(&slr).expect("Should serialize"); // Verify SendStmt with channel "result" is in output - assert!(json.contains("SendStmt"), "Should contain SendStmt: {}", json); - assert!(json.contains("\"channel\": \"result\""), "Should have result channel: {}", json); - assert!(json.contains("\"key\": \"answer\""), "Should contain key: {}", json); + assert!( + json.contains("SendStmt"), + "Should contain SendStmt: {}", + json + ); + assert!( + json.contains("\"channel\": \"result\""), + "Should have result channel: {}", + json + ); + assert!( + json.contains("\"key\": \"answer\""), + "Should contain key: {}", + json + ); } /// Sim commands should generate SendStmt with channel "sim" for simulator target. @@ -1983,16 +2161,32 @@ fn sim_generates_slr_for_simulator() { let json = codegen.to_json(&slr).expect("Should serialize"); // Verify SendStmt with channel "sim" is in output - assert!(json.contains("SendStmt"), "Should contain SendStmt: {}", json); - assert!(json.contains("\"channel\": \"sim\""), "Should have sim channel: {}", json); - assert!(json.contains("\"key\": \"noise_enable\""), "Should contain noise_enable: {}", json); - assert!(json.contains("\"key\": \"seed\""), "Should contain seed key: {}", json); + assert!( + json.contains("SendStmt"), + "Should contain SendStmt: {}", + json + ); + assert!( + json.contains("\"channel\": \"sim\""), + "Should have sim channel: {}", + json + ); + assert!( + json.contains("\"key\": \"noise_enable\""), + "Should contain noise_enable: {}", + json + ); + assert!( + json.contains("\"key\": \"seed\""), + "Should contain seed key: {}", + json + ); } /// Sim commands should emit barriers for hardware target (default). #[test] fn sim_emits_barrier_for_hardware() { - use zlup::codegen::slr::{SlrCodegen, SimMode}; + use zlup::codegen::slr::{SimMode, SlrCodegen}; let source = r#" pub fn main() -> unit { @@ -2008,14 +2202,22 @@ fn sim_emits_barrier_for_hardware() { let json = codegen.to_json(&slr).expect("Should serialize"); // Verify sim SendStmt is NOT in output, but BarrierOp IS - assert!(!json.contains("\"channel\": \"sim\""), "Should NOT contain sim channel for hardware: {}", json); - assert!(json.contains("BarrierOp"), "Should contain BarrierOp for ordering: {}", json); + assert!( + !json.contains("\"channel\": \"sim\""), + "Should NOT contain sim channel for hardware: {}", + json + ); + assert!( + json.contains("BarrierOp"), + "Should contain BarrierOp for ordering: {}", + json + ); } /// Sim commands can be completely elided with explicit opt-in. #[test] fn sim_fully_elided_when_requested() { - use zlup::codegen::slr::{SlrCodegen, SimMode}; + use zlup::codegen::slr::{SimMode, SlrCodegen}; let source = r#" pub fn main() -> unit { @@ -2031,14 +2233,22 @@ fn sim_fully_elided_when_requested() { let json = codegen.to_json(&slr).expect("Should serialize"); // Verify neither sim SendStmt nor BarrierOp in output - assert!(!json.contains("\"channel\": \"sim\""), "Should NOT contain sim channel: {}", json); - assert!(!json.contains("BarrierOp"), "Should NOT contain BarrierOp: {}", json); + assert!( + !json.contains("\"channel\": \"sim\""), + "Should NOT contain sim channel: {}", + json + ); + assert!( + !json.contains("BarrierOp"), + "Should NOT contain BarrierOp: {}", + json + ); } /// Sim barrier is scoped to allocators in current scope. #[test] fn sim_barrier_is_scoped_to_allocators() { - use zlup::codegen::slr::{SlrCodegen, SimMode}; + use zlup::codegen::slr::{SimMode, SlrCodegen}; let source = r#" pub fn main() -> unit { @@ -2055,15 +2265,27 @@ fn sim_barrier_is_scoped_to_allocators() { let json = codegen.to_json(&slr).expect("Should serialize"); // Verify BarrierOp includes the allocator "q" - assert!(json.contains("BarrierOp"), "Should contain BarrierOp: {}", json); - assert!(json.contains("\"allocators\""), "Should have allocators field: {}", json); - assert!(json.contains("\"q\""), "Should include allocator 'q': {}", json); + assert!( + json.contains("BarrierOp"), + "Should contain BarrierOp: {}", + json + ); + assert!( + json.contains("\"allocators\""), + "Should have allocators field: {}", + json + ); + assert!( + json.contains("\"q\""), + "Should include allocator 'q': {}", + json + ); } /// Result is NEVER elided, even with full log elision in release mode. #[test] fn result_never_elided_in_release() { - use zlup::codegen::slr::{SlrCodegen, SimMode}; + use zlup::codegen::slr::{SimMode, SlrCodegen}; let source = r#" pub fn main() -> unit { @@ -2085,11 +2307,27 @@ fn result_never_elided_in_release() { // Log should be elided assert!(!json.contains("LogStmt"), "Log should be elided: {}", json); // Sim should be elided - assert!(!json.contains("\"channel\": \"sim\""), "Sim should be elided: {}", json); + assert!( + !json.contains("\"channel\": \"sim\""), + "Sim should be elided: {}", + json + ); // Result should ALWAYS be present - assert!(json.contains("SendStmt"), "Result should be present: {}", json); - assert!(json.contains("\"channel\": \"result\""), "Result channel should be present: {}", json); - assert!(json.contains("\"key\": \"answer\""), "Result key should be present: {}", json); + assert!( + json.contains("SendStmt"), + "Result should be present: {}", + json + ); + assert!( + json.contains("\"channel\": \"result\""), + "Result channel should be present: {}", + json + ); + assert!( + json.contains("\"key\": \"answer\""), + "Result key should be present: {}", + json + ); } /// All three channels work together in a realistic program. @@ -2122,16 +2360,30 @@ fn all_channels_together() { "#; let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new_permissive(); - analyzer.analyze(&program).expect("Should pass semantic analysis"); + analyzer + .analyze(&program) + .expect("Should pass semantic analysis"); let mut codegen = SlrCodegen::new(); let slr = codegen.compile(&program).expect("Should compile"); let json = codegen.to_json(&slr).expect("Should serialize"); // All three channel types should be present - assert!(json.contains("LogStmt"), "Should have log statements: {}", json); - assert!(json.contains("\"channel\": \"result\""), "Should have result channel: {}", json); - assert!(json.contains("\"channel\": \"sim\""), "Should have sim channel: {}", json); + assert!( + json.contains("LogStmt"), + "Should have log statements: {}", + json + ); + assert!( + json.contains("\"channel\": \"result\""), + "Should have result channel: {}", + json + ); + assert!( + json.contains("\"channel\": \"sim\""), + "Should have sim channel: {}", + json + ); } // ============================================================================= @@ -2167,7 +2419,11 @@ fn swap_builtin_semantic_analysis() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new_permissive(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "@swap should pass semantic analysis: {:?}", result.err()); + assert!( + result.is_ok(), + "@swap should pass semantic analysis: {:?}", + result.err() + ); } /// @swap with wrong number of arguments should fail. @@ -2244,7 +2500,8 @@ fn return_reference_to_local_rejected() { let err = result.unwrap_err(); assert!( format!("{}", err).contains("local variable"), - "Error should mention local variable: {}", err + "Error should mention local variable: {}", + err ); } @@ -2261,7 +2518,10 @@ fn return_reference_to_local_rejected_permissive() { let mut analyzer = SemanticAnalyzer::new_permissive(); let result = analyzer.analyze(&program); // Safe-by-constraint: no escape hatch for returning dangling references - assert!(result.is_err(), "Returning reference to local should fail even in permissive mode"); + assert!( + result.is_err(), + "Returning reference to local should fail even in permissive mode" + ); } /// Returning a parameter reference should be allowed (caller owns the data). @@ -2275,7 +2535,11 @@ fn return_reference_to_param_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); // strict mode let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Returning parameter reference should be allowed: {:?}", result.err()); + assert!( + result.is_ok(), + "Returning parameter reference should be allowed: {:?}", + result.err() + ); } /// Returning a value (not reference) of a local is fine. @@ -2290,7 +2554,11 @@ fn return_value_of_local_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); // strict mode let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Returning value of local should be allowed: {:?}", result.err()); + assert!( + result.is_ok(), + "Returning value of local should be allowed: {:?}", + result.err() + ); } /// Returning a slice of a local array should be rejected. @@ -2305,7 +2573,10 @@ fn return_slice_of_local_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Returning slice of local array should fail"); + assert!( + result.is_err(), + "Returning slice of local array should fail" + ); } /// Slice syntax with open-ended ranges should parse correctly. @@ -2354,7 +2625,11 @@ fn slice_of_param_allowed() { let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); // Slicing a parameter is safe - the caller owns the data - assert!(result.is_ok(), "Slicing parameter array should be allowed: {:?}", result.err()); + assert!( + result.is_ok(), + "Slicing parameter array should be allowed: {:?}", + result.err() + ); } /// Returning a tuple containing a reference to local should be rejected. @@ -2369,7 +2644,10 @@ fn return_tuple_with_local_reference_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Returning tuple with reference to local should fail"); + assert!( + result.is_err(), + "Returning tuple with reference to local should fail" + ); } /// Returning a struct containing a reference to local should be rejected. @@ -2385,7 +2663,10 @@ fn return_struct_with_local_reference_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Returning struct with reference to local should fail"); + assert!( + result.is_err(), + "Returning struct with reference to local should fail" + ); } /// Returning an array containing references to locals should be rejected. @@ -2401,7 +2682,10 @@ fn return_array_with_local_references_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Returning array with references to locals should fail"); + assert!( + result.is_err(), + "Returning array with references to locals should fail" + ); } /// Taking address of parameter and returning is allowed (caller owns it). @@ -2417,7 +2701,11 @@ fn return_address_of_param_allowed() { let result = analyzer.analyze(&program); // Parameters are owned by caller, so this is safe // (the reference is valid for the caller's scope) - assert!(result.is_ok(), "Returning address of parameter should be allowed: {:?}", result.err()); + assert!( + result.is_ok(), + "Returning address of parameter should be allowed: {:?}", + result.err() + ); } // ============================================================================= @@ -2481,7 +2769,10 @@ fn duplicate_qubit_in_tick_measurement_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Duplicate qubit in tick should fail in strict mode"); + assert!( + result.is_err(), + "Duplicate qubit in tick should fail in strict mode" + ); } /// Using the same qubit twice in different gates within a tick should be rejected. @@ -2502,7 +2793,10 @@ fn duplicate_qubit_in_tick_gates_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Using same qubit in multiple gates within tick should fail"); + assert!( + result.is_err(), + "Using same qubit in multiple gates within tick should fail" + ); } /// Gate on unprepared qubit should be rejected in strict mode. @@ -2518,7 +2812,10 @@ fn gate_on_unprepared_qubit_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Gate on unprepared qubit should fail in strict mode"); + assert!( + result.is_err(), + "Gate on unprepared qubit should fail in strict mode" + ); } /// Gate after pz (prepare) should succeed. @@ -2535,7 +2832,11 @@ fn gate_after_prepare_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Gate after prepare should succeed: {:?}", result.err()); + assert!( + result.is_ok(), + "Gate after prepare should succeed: {:?}", + result.err() + ); } /// Mixed gate and measurement on same qubit in tick should be rejected. @@ -2556,7 +2857,10 @@ fn mixed_gate_measurement_same_qubit_in_tick_rejected() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Gate and measurement on same qubit in tick should fail"); + assert!( + result.is_err(), + "Gate and measurement on same qubit in tick should fail" + ); } /// Different qubits in tick operations should succeed. @@ -2581,7 +2885,11 @@ fn different_qubits_in_tick_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Different qubits in tick should succeed: {:?}", result.err()); + assert!( + result.is_ok(), + "Different qubits in tick should succeed: {:?}", + result.err() + ); } // ============================================================================= @@ -2598,7 +2906,10 @@ fn slice_with_variable_bounds_parses() { return unit; } "#; - assert!(zlup::parse(source).is_ok(), "Slice with variable bounds should parse"); + assert!( + zlup::parse(source).is_ok(), + "Slice with variable bounds should parse" + ); } /// Slice with expression bounds should parse. @@ -2612,7 +2923,10 @@ fn slice_with_expression_bounds_parses() { return unit; } "#; - assert!(zlup::parse(source).is_ok(), "Slice with expression bounds should parse"); + assert!( + zlup::parse(source).is_ok(), + "Slice with expression bounds should parse" + ); } /// Slice used in assignment should parse. @@ -2626,7 +2940,11 @@ fn slice_assignment_parses() { } "#; let result = zlup::parse(source); - assert!(result.is_ok(), "Slice assignment should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "Slice assignment should parse: {:?}", + result.err() + ); } /// Multiple slicing operations should parse. @@ -2643,7 +2961,11 @@ fn multiple_slices_parse() { } "#; let result = zlup::parse(source); - assert!(result.is_ok(), "Multiple slices should parse: {:?}", result.err()); + assert!( + result.is_ok(), + "Multiple slices should parse: {:?}", + result.err() + ); } // ============================================================================= @@ -2662,7 +2984,11 @@ fn reslice_returns_slice_type() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Re-slicing should return slice type: {:?}", result.err()); + assert!( + result.is_ok(), + "Re-slicing should return slice type: {:?}", + result.err() + ); } /// Indexing a slice with an integer should return the element type. @@ -2677,7 +3003,11 @@ fn slice_index_returns_element_type() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Slice index should return element type: {:?}", result.err()); + assert!( + result.is_ok(), + "Slice index should return element type: {:?}", + result.err() + ); } /// Chained slicing: arr[1..5][0..2] should work. @@ -2691,7 +3021,11 @@ fn chained_slicing_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Chained slicing should be allowed: {:?}", result.err()); + assert!( + result.is_ok(), + "Chained slicing should be allowed: {:?}", + result.err() + ); } /// Array type [N]T is distinct from slice type []T. @@ -2708,7 +3042,10 @@ fn array_type_distinct_from_slice() { let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); // This should fail because [3]i32 is not []i32 - assert!(result.is_err(), "Array should not be assignable to slice type"); + assert!( + result.is_err(), + "Array should not be assignable to slice type" + ); } /// Slicing an array produces a slice type. @@ -2722,7 +3059,11 @@ fn slicing_array_produces_slice() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Slicing array should produce slice: {:?}", result.err()); + assert!( + result.is_ok(), + "Slicing array should produce slice: {:?}", + result.err() + ); } /// Nested slice types: [][]i32 should work. @@ -2736,7 +3077,11 @@ fn nested_slice_type_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Nested slice types should be allowed: {:?}", result.err()); + assert!( + result.is_ok(), + "Nested slice types should be allowed: {:?}", + result.err() + ); } /// Open-ended slice of parameter is allowed. @@ -2750,7 +3095,11 @@ fn open_slice_of_param_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Full slice of param should be allowed: {:?}", result.err()); + assert!( + result.is_ok(), + "Full slice of param should be allowed: {:?}", + result.err() + ); } /// Slice with start index only. @@ -2764,7 +3113,11 @@ fn slice_from_start_index() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Slice from start index should work: {:?}", result.err()); + assert!( + result.is_ok(), + "Slice from start index should work: {:?}", + result.err() + ); } /// Slice with end index only. @@ -2778,7 +3131,11 @@ fn slice_to_end_index() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Slice to end index should work: {:?}", result.err()); + assert!( + result.is_ok(), + "Slice to end index should work: {:?}", + result.err() + ); } // ============================================================================= @@ -2848,7 +3205,11 @@ fn variable_named_s_allowed() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Variable 's' should be allowed: {:?}", result.err()); + assert!( + result.is_ok(), + "Variable 's' should be allowed: {:?}", + result.err() + ); } // ============================================================================= @@ -2873,7 +3234,11 @@ fn optimizer_sz_szdg_cancellation() { let mut optimizer = Optimizer::new(); let _optimized = optimizer.optimize(ast); - assert_eq!(optimizer.stats().gates_cancelled, 2, "SZ and SZdg should cancel"); + assert_eq!( + optimizer.stats().gates_cancelled, + 2, + "SZ and SZdg should cancel" + ); } /// SZdg followed by SZ should also cancel. @@ -2894,7 +3259,11 @@ fn optimizer_szdg_sz_cancellation() { let mut optimizer = Optimizer::new(); let _optimized = optimizer.optimize(ast); - assert_eq!(optimizer.stats().gates_cancelled, 2, "SZdg and SZ should cancel"); + assert_eq!( + optimizer.stats().gates_cancelled, + 2, + "SZdg and SZ should cancel" + ); } // ============================================================================= @@ -2912,7 +3281,11 @@ fn triple_chained_slicing() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Triple-chained slicing should work: {:?}", result.err()); + assert!( + result.is_ok(), + "Triple-chained slicing should work: {:?}", + result.err() + ); } /// Deeply nested slice types should work. @@ -2926,7 +3299,11 @@ fn deeply_nested_slice_types() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "3D nested slice types should work: {:?}", result.err()); + assert!( + result.is_ok(), + "3D nested slice types should work: {:?}", + result.err() + ); } /// Indexing 3D nested slice returns 2D slice. @@ -2940,7 +3317,11 @@ fn nested_slice_indexing_returns_correct_type() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Indexing 2D slice should return 1D slice: {:?}", result.err()); + assert!( + result.is_ok(), + "Indexing 2D slice should return 1D slice: {:?}", + result.err() + ); } /// Slicing then indexing should work. @@ -2954,7 +3335,11 @@ fn slice_then_index() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Slice then index should work: {:?}", result.err()); + assert!( + result.is_ok(), + "Slice then index should work: {:?}", + result.err() + ); } /// Indexing then slicing should work on nested types. @@ -2968,7 +3353,11 @@ fn index_then_slice_nested() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Index then slice on nested should work: {:?}", result.err()); + assert!( + result.is_ok(), + "Index then slice on nested should work: {:?}", + result.err() + ); } // ============================================================================= @@ -2987,7 +3376,10 @@ fn array_not_coercible_to_slice() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Array should not be directly coercible to slice"); + assert!( + result.is_err(), + "Array should not be directly coercible to slice" + ); } /// Slicing an array parameter to get a slice should work. @@ -3002,7 +3394,11 @@ fn array_sliced_to_slice() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Array param sliced with [..] should produce slice: {:?}", result.err()); + assert!( + result.is_ok(), + "Array param sliced with [..] should produce slice: {:?}", + result.err() + ); } /// Passing array where slice parameter expected should fail. @@ -3022,7 +3418,10 @@ fn array_param_not_coercible_to_slice_param() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Array should not be passable where slice expected"); + assert!( + result.is_err(), + "Array should not be passable where slice expected" + ); } /// Passing sliced array to slice parameter should work. @@ -3042,7 +3441,11 @@ fn sliced_array_to_slice_param() { let program = zlup::parse(source).expect("Should parse"); let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_ok(), "Sliced array should be passable as slice: {:?}", result.err()); + assert!( + result.is_ok(), + "Sliced array should be passable as slice: {:?}", + result.err() + ); } // ============================================================================= @@ -3068,7 +3471,8 @@ fn array_slice_mismatch_error_message() { // Error should mention the type mismatch assert!( error_msg.contains("TypeMismatch") || error_msg.contains("mismatch"), - "Error should indicate type mismatch: {}", error_msg + "Error should indicate type mismatch: {}", + error_msg ); } Ok(_) => panic!("Should have failed with type mismatch"), @@ -3109,12 +3513,16 @@ fn slice_element_type_mismatch_error() { let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); - assert!(result.is_err(), "Should fail: returning i32 where bool expected"); + assert!( + result.is_err(), + "Should fail: returning i32 where bool expected" + ); if let Err(e) = result { let error_msg = format!("{:?}", e); assert!( error_msg.contains("i32") || error_msg.contains("bool"), - "Error should mention the types involved: {}", error_msg + "Error should mention the types involved: {}", + error_msg ); } } diff --git a/exp/zluppy/src/lib.rs b/exp/zluppy/src/lib.rs index da77bd83e..70757b5a9 100644 --- a/exp/zluppy/src/lib.rs +++ b/exp/zluppy/src/lib.rs @@ -96,9 +96,7 @@ fn compile_to_slr(py: Python<'_>, source: &str, strict: bool) -> PyResult &'static str { /// Read and return the contents of a Zluppy source file. fn read_file(path: &str) -> PyResult { - std::fs::read_to_string(path).map_err(|e| PyIOError::new_err(format!("Failed to read {}: {}", path, e))) + std::fs::read_to_string(path) + .map_err(|e| PyIOError::new_err(format!("Failed to read {}: {}", path, e))) } /// Get the filename from a path for error reporting. @@ -252,9 +251,7 @@ fn compile_file(py: Python<'_>, path: &str, strict: bool) -> PyResult> let slr_program = codegen.compile(&program).map_err(codegen_error_to_py)?; // Convert to JSON then to Python dict - let json_str = codegen - .to_json(&slr_program) - .map_err(codegen_error_to_py)?; + let json_str = codegen.to_json(&slr_program).map_err(codegen_error_to_py)?; let json_module = py.import("json")?; let result = json_module.call_method1("loads", (json_str,))?; @@ -350,7 +347,11 @@ fn check_file(path: &str, strict: bool) -> PyResult<()> { /// ZluppyError: If parsing, semantic analysis, or codegen fails #[pyfunction] #[pyo3(signature = (source, strict = false))] -fn compile_to_hugr(py: Python<'_>, source: &str, strict: bool) -> PyResult> { +fn compile_to_hugr( + py: Python<'_>, + source: &str, + strict: bool, +) -> PyResult> { let program = ::zlup::parse(source).map_err(parse_error_to_py)?; let mut analyzer = if strict { @@ -383,7 +384,11 @@ fn compile_to_hugr(py: Python<'_>, source: &str, strict: bool) -> PyResult, path: &str, strict: bool) -> PyResult> { +fn compile_file_hugr( + py: Python<'_>, + path: &str, + strict: bool, +) -> PyResult> { let source = read_file(path)?; let filename = filename_from_path(path); @@ -773,8 +778,7 @@ impl ZlupProgram { location: None, }; - self.statements - .push(::zlup::ast::Stmt::Prepare(prepare_op)); + self.statements.push(::zlup::ast::Stmt::Prepare(prepare_op)); self.clone() } @@ -805,8 +809,7 @@ impl ZlupProgram { location: None, }; - self.statements - .push(::zlup::ast::Stmt::Measure(measure_op)); + self.statements.push(::zlup::ast::Stmt::Measure(measure_op)); self.clone() } @@ -891,9 +894,8 @@ impl ZlupProgram { /// IOError: If the file cannot be written fn save(&self, path: &str) -> PyResult<()> { let source = self.to_source(); - std::fs::write(path, source).map_err(|e| { - PyIOError::new_err(format!("Failed to write {}: {}", path, e)) - }) + std::fs::write(path, source) + .map_err(|e| PyIOError::new_err(format!("Failed to write {}: {}", path, e))) } /// Compile via source code generation and parsing. From f3ca9adc4e99af8566770986178a665970e937b8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 15:39:10 -0600 Subject: [PATCH 279/388] Forward has_dynamic_qubit_count through Box (round-4 Finding-D guard was inert because the boxed engine masked QisEngine's override) and mark PhirEngine dynamic too (round-5 Finding B) --- crates/pecos-engines/src/classical.rs | 4 ++++ crates/pecos-phir/src/execution/engine.rs | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/crates/pecos-engines/src/classical.rs b/crates/pecos-engines/src/classical.rs index 45e2856f4..e906c0314 100644 --- a/crates/pecos-engines/src/classical.rs +++ b/crates/pecos-engines/src/classical.rs @@ -122,6 +122,10 @@ impl ClassicalEngine for Box { (**self).set_num_qubits_hint(num_qubits); } + fn has_dynamic_qubit_count(&self) -> bool { + (**self).has_dynamic_qubit_count() + } + fn generate_commands(&mut self) -> Result { (**self).generate_commands() } diff --git a/crates/pecos-phir/src/execution/engine.rs b/crates/pecos-phir/src/execution/engine.rs index 8eac518d0..a73f2c410 100644 --- a/crates/pecos-phir/src/execution/engine.rs +++ b/crates/pecos-phir/src/execution/engine.rs @@ -131,6 +131,12 @@ impl ClassicalEngine for PhirEngine { self.processor.get_qubit_count() } + fn has_dynamic_qubit_count(&self) -> bool { + // PHIR allocates qubits during command generation, so the count is 0 + // before execution and grows as qalloc operations are discovered. + true + } + fn generate_commands(&mut self) -> std::result::Result { const MAX_BATCH_SIZE: usize = 100; From e4e9b3d9d6ab2a9bdd91a0c9a839f290b2afd861 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 15:39:10 -0600 Subject: [PATCH 280/388] Guard the remaining unguarded >64-observable matching-decoder construction paths: FusionBlossom add_edge/add_boundary_edge (covers from_check_matrix + manual builder) and make UfDecoder::from_matching_graph return Result instead of overflow-panicking (round-5 Finding A) --- crates/pecos-fusion-blossom/src/decoder.rs | 16 ++++++++++++++++ .../examples/profile_decode.rs | 6 +++--- crates/pecos-uf-decoder/src/bp_uf.rs | 4 ++-- crates/pecos-uf-decoder/src/css_decoder.rs | 4 ++-- crates/pecos-uf-decoder/src/decoder.rs | 17 ++++++++++++----- .../tests/cross_decoder_tests.rs | 14 ++++++-------- .../tests/integration_tests.rs | 18 +++++++++--------- .../src/fault_tolerance_bindings.rs | 12 +++++------- 8 files changed, 55 insertions(+), 36 deletions(-) diff --git a/crates/pecos-fusion-blossom/src/decoder.rs b/crates/pecos-fusion-blossom/src/decoder.rs index 75fa0f154..573eeba58 100644 --- a/crates/pecos-fusion-blossom/src/decoder.rs +++ b/crates/pecos-fusion-blossom/src/decoder.rs @@ -689,6 +689,20 @@ impl FusionBlossomDecoder { /// Returns [`FusionBlossomError::InvalidGraph`] if: /// - Either node index is out of bounds /// - The weight is negative + /// Fail loud if any observable index is `>= 64`: this decoder packs + /// observable flips into a `u64` (`1 << index`) in `build_obs_masks`, so a + /// wider index would overflow-panic. Reject it where observables enter the + /// decoder (every edge-construction path) rather than at the later shift. + fn check_observable_indices(observables: &[usize]) -> Result<()> { + if let Some(&o) = observables.iter().find(|&&o| o >= 64) { + return Err(FusionBlossomError::InvalidGraph(format!( + "observable index {o} exceeds the 64 this decoder packs into a u64; use the \ + 'pymatching' decoder or LogicalSubgraphDecoder for wider observable sets" + ))); + } + Ok(()) + } + pub fn add_edge( &mut self, node1: usize, @@ -704,6 +718,7 @@ impl FusionBlossomDecoder { self.num_nodes - 1 ))); } + Self::check_observable_indices(observables)?; let weight_int = if let Some(w) = weight { if w < 0.0 { @@ -754,6 +769,7 @@ impl FusionBlossomDecoder { self.num_nodes - 1 ))); } + Self::check_observable_indices(observables)?; // Create a virtual boundary node if not already created if self.boundary_node.is_none() { diff --git a/crates/pecos-uf-decoder/examples/profile_decode.rs b/crates/pecos-uf-decoder/examples/profile_decode.rs index 78bcd54fa..7bcd30ff5 100644 --- a/crates/pecos-uf-decoder/examples/profile_decode.rs +++ b/crates/pecos-uf-decoder/examples/profile_decode.rs @@ -16,7 +16,7 @@ fn shots_as_f64(num_shots: usize) -> f64 { fn profile_decoder(name: &str, dem: &str, num_shots: usize) { let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()).unwrap(); let num_det = graph.num_detectors; // Generate random syndromes @@ -59,7 +59,7 @@ fn profile_phases(name: &str, dem: &str, num_shots: usize) { .collect(); // Phase 1: measure reset + syndrome loading only - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()).unwrap(); let t0 = Instant::now(); for syn in &syndromes { dec.syndrome_validate(syn); // reset + grow (no peel) @@ -126,7 +126,7 @@ fn main() { println!(); println!("=== Balanced (Prim MST) ==="); let graph = DemMatchingGraph::from_dem_str(D5_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()).unwrap(); let num_det = graph.num_detectors; let mut rng = fastrand::Rng::with_seed(42); diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs index 1630d0507..4ba969e84 100644 --- a/crates/pecos-uf-decoder/src/bp_uf.rs +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -153,7 +153,7 @@ impl BpUfDecoder { let graph = DemMatchingGraph::from_dem_str(dem)?; graph.ensure_observables_fit_u64()?; UfDecoder::check_non_negative_weights(&graph)?; - let uf = UfDecoder::from_matching_graph(&graph, config.uf_config); + let uf = UfDecoder::from_matching_graph(&graph, config.uf_config)?; // Build mechanism → edge mapping. // Each mechanism in the check matrix corresponds to a column. @@ -256,7 +256,7 @@ impl BpUfDecoder { let match_graph = DemMatchingGraph::from_dem_str(matching_dem)?; match_graph.ensure_observables_fit_u64()?; UfDecoder::check_non_negative_weights(&match_graph)?; - let uf = UfDecoder::from_matching_graph(&match_graph, config.uf_config); + let uf = UfDecoder::from_matching_graph(&match_graph, config.uf_config)?; // Map BP mechanisms (non-decomposed) → matching graph edges (decomposed). let mut mechanism_to_edge = vec![None; bp_dcm.num_mechanisms]; diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs index 08011a756..4658cb382 100644 --- a/crates/pecos-uf-decoder/src/css_decoder.rs +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -122,8 +122,8 @@ impl CssUfDecoder { let qubit_map = Self::build_qubit_mapping(&x_graph, &z_graph); let x_num_detectors = x_graph.num_detectors; - let x_decoder = UfDecoder::from_matching_graph(&x_graph, config); - let z_decoder = UfDecoder::from_matching_graph(&z_graph, config); + let x_decoder = UfDecoder::from_matching_graph(&x_graph, config)?; + let z_decoder = UfDecoder::from_matching_graph(&z_graph, config)?; Ok(Self { x_decoder, diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 3a1bb14f0..44ea2ff82 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -238,7 +238,14 @@ impl UfDecoder { /// holding untrusted DEM input should pre-validate with /// [`Self::check_non_negative_weights`] to get an error instead. #[must_use] - pub fn from_matching_graph(graph: &DemMatchingGraph, config: UfDecoderConfig) -> Self { + pub fn from_matching_graph( + graph: &DemMatchingGraph, + config: UfDecoderConfig, + ) -> Result { + // Fail loud rather than overflow-panic at the `1 << o` packing below: this + // decoder packs observable flips into a u64 and supports at most 64. + graph.ensure_observables_fit_u64()?; + let num_detectors = graph.num_detectors; let num_nodes = num_detectors + 1; let boundary_node = num_detectors as u32; @@ -298,7 +305,7 @@ impl UfDecoder { } adj_offset.push(adj_data.len() as u32); - Self { + Ok(Self { edges, adj_data, adj_offset, @@ -320,7 +327,7 @@ impl UfDecoder { subtree_parity: vec![false; num_nodes], correction_edges: Vec::new(), weight_swap: Vec::new(), - } + }) } /// Build from a DEM string. @@ -332,9 +339,9 @@ impl UfDecoder { /// proofs do not admit). pub fn from_dem(dem: &str, config: UfDecoderConfig) -> Result { let graph = DemMatchingGraph::from_dem_str(dem)?; - graph.ensure_observables_fit_u64()?; Self::check_non_negative_weights(&graph)?; - Ok(Self::from_matching_graph(&graph, config)) + // `from_matching_graph` performs the >64-observable guard. + Self::from_matching_graph(&graph, config) } /// Reset per-shot state. Uses bulk fill operations for cache efficiency. diff --git a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs index d7bec84d7..1f09aec70 100644 --- a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs +++ b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs @@ -30,13 +30,11 @@ const D3_DEM: &str = fn test_ensemble_of_identical_decoders_matches_single() { let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut single = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut single = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let members: Vec> = (0..3) .map(|_| { - Box::new(UfDecoder::from_matching_graph( - &graph, - UfDecoderConfig::default(), - )) as Box + Box::new(UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap()) + as Box }) .collect(); let mut ensemble = EnsembleDecoder::new(members); @@ -64,8 +62,8 @@ fn test_ensemble_of_identical_decoders_matches_single() { fn test_deterministic_results() { let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut dec1 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); - let mut dec2 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec1 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); + let mut dec2 = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let mut rng = fastrand::Rng::with_seed(999); for _ in 0..200 { @@ -89,7 +87,7 @@ fn test_matching_agrees_with_observable() { use pecos_decoder_core::correlated_decoder::MatchingDecoder; let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let mut rng = fastrand::Rng::with_seed(777); for _ in 0..200 { diff --git a/crates/pecos-uf-decoder/tests/integration_tests.rs b/crates/pecos-uf-decoder/tests/integration_tests.rs index fc75d7ae1..ba4b9f845 100644 --- a/crates/pecos-uf-decoder/tests/integration_tests.rs +++ b/crates/pecos-uf-decoder/tests/integration_tests.rs @@ -37,7 +37,7 @@ fn test_real_dem_construction() { graph.edges.len() ); - let dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); assert_eq!(dec.num_detectors(), graph.num_detectors); assert_eq!(dec.num_edges(), graph.edges.len()); } @@ -46,7 +46,7 @@ fn test_real_dem_construction() { #[test] fn test_real_dem_no_errors() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let syndrome = vec![0u8; graph.num_detectors]; assert_eq!(dec.decode_syndrome(&syndrome), 0); } @@ -56,7 +56,7 @@ fn test_real_dem_no_errors() { #[test] fn test_real_dem_single_defects() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); for d in 0..graph.num_detectors { let mut syndrome = vec![0u8; graph.num_detectors]; @@ -75,7 +75,7 @@ fn test_real_dem_single_defects() { #[test] fn test_real_dem_adjacent_pairs() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); for edge in &graph.edges { let mut syndrome = vec![0u8; graph.num_detectors]; @@ -93,7 +93,7 @@ fn test_real_dem_adjacent_pairs() { #[test] fn test_real_dem_random_syndromes() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let mut rng = fastrand::Rng::with_seed(42); for _ in 0..1000 { @@ -121,7 +121,7 @@ fn test_real_dem_random_syndromes() { #[test] fn test_observable_decoder_trait_real_dem() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let syndrome = vec![0u8; graph.num_detectors]; let result = dec.decode_to_observables(&syndrome); assert!(result.is_ok()); @@ -133,7 +133,7 @@ fn test_observable_decoder_trait_real_dem() { fn test_matching_decoder_trait_real_dem() { use pecos_decoder_core::correlated_decoder::MatchingDecoder; let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); // Two adjacent defects. let edge = &graph.edges[0]; @@ -154,7 +154,7 @@ fn test_matching_decoder_trait_real_dem() { #[test] fn test_buffer_reuse_correctness() { let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); - let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()).unwrap(); let zero_syndrome = vec![0u8; graph.num_detectors]; let mut defect_syndrome = vec![0u8; graph.num_detectors]; @@ -195,7 +195,7 @@ fn test_from_dem_rejects_negative_weight_priors() { fn test_from_matching_graph_asserts_non_negative_weights() { let dem = "error(0.6) D0 L0\n"; let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); - let _ = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()); + let _ = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::balanced()).unwrap(); } /// Weight-zero edges (p = 0.5) are benign: the shortcut proofs hold as ties. diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 605d466ac..5729e4162 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -2761,18 +2761,16 @@ fn create_observable_decoder( |e| PyErr::new::(e.to_string()), )?; - // Reject negative-weight edges (error priors p > 0.5) and >64-observable - // DEMs as Python errors rather than panicking in `from_matching_graph` - // (negative-weight assert / `1 << o` overflow). - graph - .ensure_observables_fit_u64() - .map_err(|e| PyErr::new::(e.to_string()))?; + // Reject negative-weight edges (error priors p > 0.5) as a Python error + // rather than panicking on the negative-weight assert; the + // >64-observable guard now lives in `from_matching_graph`. pecos_decoders::UfDecoder::check_non_negative_weights(&graph) .map_err(|e| PyErr::new::(e.to_string()))?; let uf = pecos_decoders::UfDecoder::from_matching_graph( &graph, pecos_decoders::UfDecoderConfig::balanced(), - ); + ) + .map_err(|e| PyErr::new::(e.to_string()))?; let two_pass = TwoPassDecoder::new(uf, base_weights, corr_table); Ok(Box::new(two_pass)) } From e6da1b503cf14f21db0c701aefa717926b92cf45 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 15:39:10 -0600 Subject: [PATCH 281/388] Route the remaining raw library-discovery bypasses (Windows DLL search dirs + JIT import lib, public get_qis_ffi_lib_path) through the pinned paths so they match the cache-key/loaded identity (round-5 Finding C) --- crates/pecos-qis/src/executor.rs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index a9faa97d7..3d9bebed3 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -1155,11 +1155,13 @@ impl QisHeliosInterface { // We use AddDllDirectory to temporarily add the directories containing // our FFI DLLs to the search path. - // Find the directories containing our dependency DLLs - let qis_ffi_path = Self::find_pecos_qis_lib().ok(); + // Find the directories containing our dependency DLLs (use the pinned + // paths so dependency search dirs match the libraries hashed in the cache + // key and loaded by the singletons). + let qis_ffi_path = Self::pinned_qis_ffi_lib_path().ok(); let qis_ffi_dir = qis_ffi_path.as_ref().and_then(|p| p.parent()); - let shim_path = crate::shim::get_shim_library_path(); + let shim_path = Self::pinned_shim_lib_path(); let shim_dir = shim_path.as_ref().and_then(|p| p.parent()); // Combine both directories (they may be different) @@ -1762,8 +1764,10 @@ entry: ) })?; - // Find the pecos_qis_ffi.dll.lib import library - let pecos_qis_lib_path = Self::find_pecos_qis_lib()?; + // Find the pecos_qis_ffi.dll.lib import library (pinned, so the link + // import library matches the FFI library hashed in the cache key). + let pecos_qis_lib_path = + Self::pinned_qis_ffi_lib_path().map_err(InterfaceError::LoadError)?; let qis_ffi_import_lib = pecos_qis_lib_path.with_extension("dll.lib"); if !qis_ffi_import_lib.exists() { @@ -2366,7 +2370,7 @@ impl QisInterface for QisHeliosInterface { } fn get_qis_ffi_lib_path(&self) -> Option { - Self::find_pecos_qis_lib().ok() + Self::pinned_qis_ffi_lib_path().ok() } fn get_execution_context_ptr(&self) -> Option<*mut std::ffi::c_void> { From cb868f5d9f0d77ab83179f10895cc9be2679a372 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 15:45:31 -0600 Subject: [PATCH 282/388] Scope SZZ hosted metadata by counted round --- .../quantum-pecos/src/pecos/guppy/surface.py | 256 +++++++++++++++++- .../tests/qec/surface/test_check_plan.py | 63 +++++ 2 files changed, 306 insertions(+), 13 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 2677fbdab..3d765988d 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -720,6 +720,7 @@ def _append_szz_layer( ancilla_expr: Callable[[str, int], str], data_expr: Callable[[int], str], pending_by_data: dict[int, tuple[int, int]] | None = None, + host_label_scope: str | None = None, ) -> None: def compose_data(data_q: int, gates: tuple[OpType, ...]) -> None: if pending_by_data is None: @@ -770,7 +771,12 @@ def discharge_data_for_szz( ) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG - host_label = f"szz:r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + host_label_core = f"r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" + host_label = ( + f"szz:{host_label_core}" + if host_label_scope is None + else f"szz:{host_label_scope}:{host_label_core}" + ) discharge_data_for_szz(data_q, host_label=host_label) if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX @@ -888,6 +894,7 @@ def discharge_data_for_szz( _szz_ancilla_expr, _szz_data_expr, pending_by_data=szz_syndrome_pending_by_data, + host_label_scope="syndrome_extraction", ) lines.append("") @@ -999,6 +1006,7 @@ def discharge_data_for_szz( ], _szz_data_expr, pending_by_data=szz_syndrome_pending_by_data, + host_label_scope="syndrome_extraction", ) if interaction_basis == "cx": @@ -1120,6 +1128,7 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: _szz_ancilla_expr, _szz_data_expr, pending_by_data=szz_init_pending_by_data, + host_label_scope=function_name, ) lines.append("") @@ -1197,6 +1206,7 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], _szz_data_expr, pending_by_data=szz_init_pending_by_data, + host_label_scope=function_name, ) if interaction_basis == "cx": @@ -1326,8 +1336,218 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: ], ) - # Generate memory experiment factories - lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds, interaction_basis)) + def _append_inline_szz_syndrome_extraction( + target: list[str], + indent: str, + *, + host_label_scope: str, + ) -> None: + """Append one SZZ syndrome-extraction body with unique hosted ids.""" + if interaction_basis != "szz": + msg = "inline SZZ syndrome extraction requires interaction_basis='szz'" + raise ValueError(msg) + target.append(f"{indent}# Inline SZZ syndrome extraction ({host_label_scope})") + target.append(f"{indent}# Unpack data qubits") + _append_szz_data_unpack(target, indent) + pending_by_data = dict.fromkeys(range(num_data), _SZZ_FLOW_IDENTITY) + + if not constrained: + target.append(f"{indent}# Allocate ancilla qubits (one per stabilizer)") + target.extend(f"{indent}ax{stab.index} = qubit()" for stab in geom.x_stabilizers) + target.extend(f"{indent}az{stab.index} = qubit()" for stab in geom.z_stabilizers) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas") + target.extend(f"{indent}h(ax{stab.index})" for stab in geom.x_stabilizers) + target.extend(f"{indent}h(az{stab.index})" for stab in geom.z_stabilizers) + + for rnd_idx, rnd_gates in enumerate(rounds): + _append_szz_layer( + target, + indent, + rnd_idx, + list(rnd_gates), + _szz_ancilla_expr, + _szz_data_expr, + pending_by_data=pending_by_data, + host_label_scope=host_label_scope, + ) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas") + target.extend(f"{indent}h(ax{stab.index})" for stab in geom.x_stabilizers) + target.extend(f"{indent}h(az{stab.index})" for stab in geom.z_stabilizers) + + target.append("") + target.append(f"{indent}# Measure ancillas") + idx = 0 + for stab in geom.x_stabilizers: + target.append(f"{indent}sx{stab.index} = measure(ax{stab.index})") + target.append(f'{indent}result("sx{stab.index}:meas:{idx}", sx{stab.index})') + idx += 1 + for stab in geom.z_stabilizers: + target.append(f"{indent}sz{stab.index} = measure(az{stab.index})") + target.append(f'{indent}result("sz{stab.index}:meas:{idx}", sz{stab.index})') + idx += 1 + else: + batches = batched_stabilizers( + patch, + effective_budget, + ancilla_schedule=ancilla_schedule, + ) + idx = 0 + for batch_idx, batch in enumerate(batches): + target.append("") + target.append(f"{indent}# Batch {batch_idx + 1}/{len(batches)} of stabilizers") + batch_anc_var: dict[tuple[str, int], str] = {} + for pos, (stab_type, stab_idx) in enumerate(batch): + var = f"_a_b{batch_idx}_p{pos}" + batch_anc_var[(stab_type, stab_idx)] = var + target.append(f"{indent}{var} = qubit()") + + target.append(f"{indent}# Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: + target.append(f"{indent}h({batch_anc_var[(stab_type, stab_idx)]})") + + batch_keys = set(batch_anc_var.keys()) + for rnd_idx, rnd_gates in enumerate(rounds): + rnd_in_batch = [ + (stab_type, stab_idx, data_q) + for stab_type, stab_idx, data_q in rnd_gates + if (stab_type, stab_idx) in batch_keys + ] + if not rnd_in_batch: + continue + target.append("") + target.append(f"{indent}# Batch {batch_idx + 1} round {rnd_idx + 1}") + _append_szz_layer( + target, + indent, + rnd_idx, + rnd_in_batch, + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (stab_type, stab_idx) + ], + _szz_data_expr, + pending_by_data=pending_by_data, + host_label_scope=host_label_scope, + ) + + target.append("") + target.append(f"{indent}# Hadamard on SZZ ancillas in this batch") + for stab_type, stab_idx in batch: + target.append(f"{indent}h({batch_anc_var[(stab_type, stab_idx)]})") + + target.append("") + target.append(f"{indent}# Measure batch {batch_idx + 1} ancillas") + for stab_type, stab_idx in batch: + anc = batch_anc_var[(stab_type, stab_idx)] + syn_var = f"sx{stab_idx}" if stab_type == "X" else f"sz{stab_idx}" + target.append(f"{indent}{syn_var} = measure({anc})") + target.append(f'{indent}result("{syn_var}:meas:{idx}", {syn_var})') + idx += 1 + + x_calls = ", ".join(f"sx{s.index}" for s in geom.x_stabilizers) + z_calls = ", ".join(f"sz{s.index}" for s in geom.z_stabilizers) + target.extend(["", f"{indent}synx = array({x_calls})", f"{indent}synz = array({z_calls})", ""]) + _append_szz_flush_data_frame( + target, + indent, + pending_by_data, + reason=f"{host_label_scope} inline syndrome", + ) + target.append(f"{indent}surf = SurfaceCode_{dx}x{dz}({szz_data_args})") + + def _render_plain_szz_round_helper(round_idx: int) -> list[str]: + helper_name = f"syndrome_extraction_memory_r{round_idx}" + body = [ + "", + "", + "@guppy", + ( + f"def {helper_name}(surf: SurfaceCode_{dx}x{dz} @ owned) " + f"-> tuple[SurfaceCode_{dx}x{dz}, Syndrome_{dx}x{dz}]:" + ), + ( + f' """Extract counted SZZ syndrome round {round_idx} ' + 'with round-scoped hosted metadata."""' + ), + ] + _append_inline_szz_syndrome_extraction( + body, + " ", + host_label_scope=f"memory_r{round_idx}", + ) + body.append(f" return surf, Syndrome_{dx}x{dz}(synx, synz)") + return body + + def _render_plain_szz_memory_block( + basis: str, + basis_upper: str, + rendered_num_rounds: int, + ) -> list[str]: + init_func = "init_z_basis" if basis == "z" else "init_x_basis" + init_tag = "init_synx" if basis == "z" else "init_synz" + body: list[str] = [ + ( + f' """{basis_upper}-basis SZZ memory experiment for ' + f"dx={dx}, dz={dz}, num_rounds={rendered_num_rounds}." + '"""' + ), + f" surf = prep_{basis}_basis()", + f" surf, init_syn = {init_func}(surf)", + f' result("{init_tag}", init_syn)', + "", + ] + for round_idx in range(rendered_num_rounds): + body.append(f" # === Counted syndrome round {round_idx} ===") + body.append(f" surf, syn = syndrome_extraction_memory_r{round_idx}(surf)") + body.append(' result("synx", syn.synx)') + body.append(' result("synz", syn.synz)') + body.append("") + + body.extend( + [ + f" final = measure_{basis}_basis(surf)", + ' result("final", final)', + ], + ) + return [ + f"def make_memory_{basis}(num_rounds: int):", + f' """Create {basis_upper}-basis SZZ memory experiment.', + "", + f" num_rounds must equal {rendered_num_rounds} -- the body was unrolled at", + " source-generation time so hosted-operation metadata is unique per", + " counted syndrome round. Mismatched values raise ValueError.", + ' """', + f" if num_rounds != {rendered_num_rounds}:", + ( + f' msg = f"this generated module was unrolled for ' + f'num_rounds={rendered_num_rounds}, got {{num_rounds!r}}"' + ), + " raise ValueError(msg)", + "", + " @guppy", + f" def memory_{basis}() -> None:", + *body, + "", + f" return memory_{basis}", + "", + "", + ] + + # Generate memory experiment factories. Plain SZZ memory programs are + # unrolled when the round count is available so hosted metadata identifies + # the concrete counted syndrome round instead of the reusable helper body. + if twirl is None and interaction_basis == "szz" and num_rounds is not None: + lines.extend(["# === Counted SZZ Syndrome Helpers ==="]) + for round_idx in range(num_rounds): + lines.extend(_render_plain_szz_round_helper(round_idx)) + lines.extend(["# === Memory Experiments ===", ""]) + for basis, basis_upper in (("z", "Z"), ("x", "X")): + lines.extend(_render_plain_szz_memory_block(basis, basis_upper, num_rounds)) + else: + lines.extend(_render_memory_experiments(patch, dx, dz, num_data, twirl, rng, num_rounds, interaction_basis)) return "\n".join(lines) @@ -1999,7 +2219,9 @@ def _guppy_module_cache_key( Twirled source is Python-time unrolled, so the cache key includes ``num_rounds`` in addition to the structural twirl fields, runtime frame-output mode, RNG seed, activation-probability threshold, and - the SZZ runtime-barrier policy. + the SZZ runtime-barrier policy. Plain SZZ source is also unrolled when + ``num_rounds`` is supplied so hosted-operation metadata can identify the + concrete counted syndrome round. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -2028,6 +2250,8 @@ def _guppy_module_cache_key( f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}{runtime_barrier_part}" ) if twirl is None: + if interaction_basis == "szz" and num_rounds is not None: + return f"{base}_r{int(num_rounds)}" return base if rng is None or num_rounds is None: msg = "twirled Guppy module cache keys require both rng and num_rounds" @@ -2168,14 +2392,18 @@ def generate_memory_experiment( Returns: Guppy function for the experiment """ + resolved_plan = _resolve_surface_check_plan( + interaction_basis=interaction_basis, + check_plan=check_plan, + ) module = _load_guppy_module( patch, ancilla_budget=ancilla_budget, twirl=twirl, rng=rng, - num_rounds=num_rounds if twirl is not None else None, - interaction_basis=interaction_basis, - check_plan=check_plan, + num_rounds=num_rounds if twirl is not None or resolved_plan.interaction_basis == "szz" else None, + interaction_basis=resolved_plan.interaction_basis, + check_plan=resolved_plan.plan_id if check_plan is not None else None, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, ) @@ -2447,15 +2675,17 @@ def make_surface_code( msg = f"basis must be 'Z' or 'X', got {basis!r}" raise ValueError(msg) - module = get_surface_code_module( - distance, + from pecos.qec.surface import SurfacePatch + + _validate_surface_memory_distance(distance) + patch = SurfacePatch.create(distance=distance) + return generate_memory_experiment( + patch, + num_rounds, + basis, ancilla_budget=ancilla_budget, interaction_basis=interaction_basis, check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, ) - - factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] - - return factory(num_rounds) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 8af97250c..6ea69257f 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -351,6 +351,69 @@ def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: assert prefix_index < prefix_host_index < role_index < host_index < host_id_index +def test_szz_hosted_metadata_labels_include_helper_scope() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + ) + + assert '"host_id", "szz:init_z_basis:' in source + assert '"host_id", "szz:init_x_basis:' in source + assert '"host_id", "szz:syndrome_extraction:' in source + + +def test_plain_szz_memory_source_unrolls_hosted_metadata_by_counted_round() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + source = generate_guppy_source( + SurfacePatch.create(distance=3), + check_plan="szz_current_v1", + num_rounds=2, + ) + + assert "for _t in range(comptime(num_rounds))" not in source + assert "if num_rounds != 2:" in source + assert "def syndrome_extraction_memory_r0" in source + assert "def syndrome_extraction_memory_r1" in source + assert '"host_id", "szz:memory_r0:' in source + assert '"host_id", "szz:memory_r1:' in source + assert '"host_id", "szz:memory_r2:' not in source + + +def test_plain_szz_memory_cache_key_includes_counted_rounds() -> None: + from pecos.guppy.surface import _guppy_module_cache_key + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + + key_one = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + num_rounds=1, + ) + key_two = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + num_rounds=2, + ) + key_generic = _guppy_module_cache_key( + patch, + 8, + check_plan="szz_current_v1", + ) + + assert key_one.endswith("_r1") + assert key_two.endswith("_r2") + assert key_one != key_two + assert key_generic not in {key_one, key_two} + + def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> None: from pecos.qec.surface import SurfacePatch from pecos.qec.surface.circuit_builder import ( From b103696050bc15f4977c532ba14adb0e96366353 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 18:42:10 -0600 Subject: [PATCH 283/388] Use physical SZZ hosted prefixes in Guppy --- .../quantum-pecos/src/pecos/guppy/surface.py | 86 ++++++++++++++++--- .../src/pecos/qec/surface/circuit_builder.py | 4 + .../quantum-pecos/src/pecos/quantum/hosted.py | 10 ++- .../tests/guppy/test_surface_twirl_render.py | 14 +-- .../tests/pecos/test_hosted_operations.py | 41 +++++++++ .../qec/surface/test_szz_interaction_basis.py | 8 +- 6 files changed, 144 insertions(+), 19 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 3d765988d..1d16bb756 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -252,8 +252,10 @@ def generate_guppy_source( ) from pecos.qec.surface.circuit_builder import ( _SZZ_FLOW_IDENTITY, + _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING, OpType, _resolve_szz_clifford_frame_for_builder, + _szz_flow_clifford_name, _szz_flow_compose_pending_gate, _szz_flow_is_virtual_z, _szz_memory_physical_axis_for_data, @@ -334,7 +336,7 @@ def generate_guppy_source( "from guppylang import guppy", "from guppylang.std.angles import angle", "from guppylang.std.builtins import array, owned, result", - "from guppylang.std.qsystem.functional import zz_phase", + "from guppylang.std.qsystem.functional import phased_x, rz, zz_phase", "from guppylang.std.quantum import discard, h, measure, measure_array, qubit, s, sdg, v, vdg, x, y, z", ] else: @@ -597,6 +599,67 @@ def _append_szz_flow_gate( ) target.append(f"{indent}{op_name}({qubit_expr})") + def _append_szz_physical_prefix_gate( + target: list[str], + indent: str, + op_type: OpType, + qubit_expr: str, + *, + source_label: str, + host_label: str, + ) -> None: + """Append one hosted physical SZZ prefix pulse. + + Guppy's public quantum stdlib does not expose PECOS ``F``/``SY`` + names directly. The hardware-level SZZ forward-flow table still has + a one-pulse interpretation: ``SY`` is a Y-axis sqrt pulse, and ``F`` + is an X-axis sqrt pulse plus a virtual Z-frame update. We attach the + hosted metadata only to the physical pulse so scheduling diagnostics + track the operation that must remain adjacent to its SZZ/SZZdg host. + """ + _append_szz_gate_trace_metadata( + target, + indent, + source_kind="szz_data_prefix", + source_label=source_label, + qubit_expr=qubit_expr, + host_label=host_label, + local_role="basis_prefix", + gate=op_type, + ) + if op_type == OpType.H: + target.append(f"{indent}h({qubit_expr})") + elif op_type == OpType.SX: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.0))") + elif op_type == OpType.SXDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(-0.5), angle(0.0))") + elif op_type == OpType.SY: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.5))") + elif op_type == OpType.SYDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(-0.5), angle(0.5))") + elif op_type == OpType.F: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(0.0))") + target.append(f"{indent}{qubit_expr} = rz({qubit_expr}, angle(0.5))") + elif op_type == OpType.FDG: + target.append(f"{indent}{qubit_expr} = phased_x({qubit_expr}, angle(0.5), angle(-0.5))") + target.append(f"{indent}{qubit_expr} = rz({qubit_expr}, angle(-0.5))") + else: + msg = f"unsupported hosted SZZ physical prefix gate {op_type.name}" + raise ValueError(msg) + + def _szz_guppy_physical_prefix_for_pending( + pending: tuple[int, int], + ) -> tuple[OpType | None, OpType]: + """Return the source-level physical-prefix lowering for ``pending``.""" + try: + return _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[pending] + except KeyError as exc: + msg = ( + "SZZ Guppy hosted-prefix lowering cannot lower pending " + f"Clifford {_szz_flow_clifford_name(pending)}" + ) + raise ValueError(msg) from exc + _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} _szz_guppy_prefix_generators = ( OpType.H, @@ -742,16 +805,17 @@ def discharge_data_for_szz( pending = pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) if pending == _SZZ_FLOW_IDENTITY or _szz_flow_is_virtual_z(pending): return - prefix = _szz_guppy_prefix_gates_for_pending(pending) - for prefix_idx, gate in enumerate(prefix): - _append_szz_flow_gate( - target, - indent, - gate, - data_expr(data_q), - source_label=f"{host_label}:prefix:{prefix_idx}:{gate.name}", - host_label=host_label, - ) + virtual_gate, physical_gate = _szz_guppy_physical_prefix_for_pending(pending) + if virtual_gate is not None: + _append_szz_flow_gate(target, indent, virtual_gate, data_expr(data_q)) + _append_szz_physical_prefix_gate( + target, + indent, + physical_gate, + data_expr(data_q), + source_label=f"{host_label}:prefix:0:{physical_gate.name}", + host_label=host_label, + ) pending_by_data[data_q] = _SZZ_FLOW_IDENTITY target.append("") diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index b3fbe5c5c..fc3df1622 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -756,6 +756,10 @@ def discharge_for_measurement(q: int, host_index: int, op: SurfaceCircuitStep) - (-3, 1): (None, OpType.SY), (2, 1): (None, OpType.F), (-2, 1): (OpType.Z, OpType.F), + (1, 2): (None, OpType.SXDG), + (-1, 2): (OpType.Z, OpType.SXDG), + (3, 2): (None, OpType.FDG), + (-3, 2): (OpType.Z, OpType.FDG), } diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py index 48a01606e..009f88db7 100644 --- a/python/quantum-pecos/src/pecos/quantum/hosted.py +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -19,6 +19,14 @@ HOST_ID_META_KEY = "host_id" LOCAL_ROLE_META_KEY = "local_role" +HOSTED_PROVENANCE_META_KEYS = ( + "source_kind", + "source_label", + "source_gate", + "szz_host_label", + "source_lowering_required", + "label", +) @dataclass(frozen=True) @@ -240,7 +248,7 @@ def _hosted_gate_records( tick_circuit, tick_index, gate_index, - keys=(host_id_key, local_role_key), + keys=(host_id_key, local_role_key, *HOSTED_PROVENANCE_META_KEYS), ) host_id = _metadata_text(metadata, host_id_key) local_role = _metadata_text(metadata, local_role_key) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 845e573a2..0c85e5cd4 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -18,7 +18,6 @@ ) from pecos.qec.surface.patch import SurfacePatch - _SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubit_hugr(" _SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubit_hugr(" @@ -32,7 +31,7 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: """Assert a real SZZ data-prefix pulse is fenced before its host.""" lines = src.splitlines() for index, line in enumerate(lines): - if "vdg(d1)" not in line: + if "phased_x(" not in line: continue prefix_meta = next( i @@ -50,7 +49,7 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: assert prefix_meta < index < barrier_index < host_meta < zz_phase_index return - msg = "expected an SZZ touch with a Vdg data-prefix pulse" + msg = "expected an SZZ touch with a hosted phased_x data-prefix pulse" raise AssertionError(msg) @@ -69,7 +68,8 @@ def test_szz_source_uses_signed_zz_phase_template(patch: SurfacePatch) -> None: src = generate_guppy_source(patch, interaction_basis="szz") assert "from guppylang.std.angles import angle" in src - assert "from guppylang.std.qsystem.functional import zz_phase" in src + assert "from guppylang.std.qsystem.functional import phased_x, rz, zz_phase" in src + assert "phased_x(" in src assert "zz_phase(" in src assert "angle(0.5)" in src assert "angle(-0.5)" in src @@ -138,8 +138,10 @@ def test_szz_basis_forks_guppy_module_cache_key(patch: SurfacePatch) -> None: def test_szz_source_keeps_reusable_memory_body_and_flushes_helper_frame(patch: SurfacePatch) -> None: src = generate_guppy_source(patch, interaction_basis="szz", num_rounds=2) - assert "for _t in range(comptime(num_rounds)):" in src - assert "surf, syn = syndrome_extraction(surf)" in src + assert "def syndrome_extraction_memory_r0" in src + assert "def syndrome_extraction_memory_r1" in src + assert "surf, syn = syndrome_extraction_memory_r0(surf)" in src + assert "surf, syn = syndrome_extraction_memory_r1(surf)" in src assert "# Flush SZZ data frame before syndrome return" in src assert "# Flush SZZ data frame before init_z_basis return" in src assert "# Flush SZZ data frame before init_x_basis return" in src diff --git a/python/quantum-pecos/tests/pecos/test_hosted_operations.py b/python/quantum-pecos/tests/pecos/test_hosted_operations.py index fb53aa460..cd8f42fa2 100644 --- a/python/quantum-pecos/tests/pecos/test_hosted_operations.py +++ b/python/quantum-pecos/tests/pecos/test_hosted_operations.py @@ -70,6 +70,47 @@ def test_validate_hosted_operations_binds_local_to_later_host() -> None: assert bindings[0].tick_separation == 2 +def test_validate_hosted_operations_preserves_source_provenance_metadata() -> None: + circuit = FakeTickCircuit( + [ + [ + FakeGate( + "SXdg", + [2], + meta={ + "host_id": "host:a", + "local_role": "basis_prefix", + "source_kind": "szz_data_prefix", + "source_label": "host:a:prefix:1:SXDG", + "source_gate": "SXDG", + }, + ), + ], + [ + FakeGate( + "SZZ", + [2, 5], + meta={ + "host_id": "host:a", + "source_kind": "szz_host", + "source_label": "host:a", + "source_gate": "SZZ", + }, + ), + ], + ], + ) + + binding = validate_hosted_operations(circuit)[0] + + assert binding.local.metadata["source_kind"] == "szz_data_prefix" + assert binding.local.metadata["source_label"] == "host:a:prefix:1:SXDG" + assert binding.local.metadata["source_gate"] == "SXDG" + assert binding.host.metadata["source_kind"] == "szz_host" + assert binding.host.metadata["source_label"] == "host:a" + assert binding.host.metadata["source_gate"] == "SZZ" + + def test_validate_hosted_operations_selects_later_shared_host_record() -> None: circuit = FakeTickCircuit( [ diff --git a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py index 11b72a283..e96fcc4b1 100644 --- a/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py +++ b/python/quantum-pecos/tests/qec/surface/test_szz_interaction_basis.py @@ -753,7 +753,13 @@ def test_szz_prefix_lowering_emits_dedicated_prefix_ticks(basis: str) -> None: else: saw_physical_prefix = True assert all(label.startswith("szz_physical_prefix:") for label in prefix_labels) - assert {gate.gate_type.name for gate in tick.gate_batches()} <= {"H", "F", "SY"} + assert {gate.gate_type.name for gate in tick.gate_batches()} <= { + "H", + "F", + "Fdg", + "SXdg", + "SY", + } for gate_index, _gate in enumerate(tick.gate_batches()): assert tick_circuit.get_gate_meta(tick_index, gate_index, PHYSICAL_DURATION_META_KEY) is None From 969ade0f615d19df950b334056845d9347aaebdb Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 20:35:37 -0600 Subject: [PATCH 284/388] Allow SZZ Guppy builds without trace metadata --- .../quantum-pecos/src/pecos/guppy/surface.py | 62 ++++++++++++++++--- .../tests/qec/surface/test_check_plan.py | 19 ++++++ 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 1d16bb756..6789bf8a2 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -174,6 +174,7 @@ def generate_guppy_source( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> str: """Generate Guppy source code for a surface code patch. @@ -238,6 +239,10 @@ def generate_guppy_source( no ideal-unitary effect, but give runtimes a principled scheduling boundary between selected local data-frame pulses and their entangling host. + trace_metadata: Emit PECOS trace metadata helpers for SZZ/SZZdg + hosted-operation diagnostics and strict DEM construction. Disable + only for execution-only builds whose compiler/linker cannot resolve + PECOS trace metadata helper symbols. Returns: Python/Guppy source code as a string. @@ -370,21 +375,28 @@ def generate_guppy_source( lines.extend(_render_inline_pcg32()) if interaction_basis == "szz": - lines.extend( + helper_declarations: list[str] = [] + if trace_metadata: + helper_declarations.extend( + [ + "@guppy.declare", + ( + "def pecos_qis_trace_metadata_qubit_hugr(" + "q: qubit @ owned, key: str, value: str" + ") -> qubit: ..." + ), + "", + ], + ) + helper_declarations.extend( [ - "@guppy.declare", - ( - "def pecos_qis_trace_metadata_qubit_hugr(" - "q: qubit @ owned, key: str, value: str" - ") -> qubit: ..." - ), - "", "@guppy.declare", "def pecos_qis_runtime_barrier_qubit_hugr(q: qubit @ owned) -> qubit: ...", "", "", ], ) + lines.extend(helper_declarations) # Generate struct definitions. lines.extend( @@ -531,6 +543,8 @@ def _append_szz_trace_metadata( value: str, qubit_expr: str, ) -> None: + if not trace_metadata: + return target.append( f'{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr({qubit_expr}, "{key}", "{value}")', ) @@ -2272,6 +2286,7 @@ def _guppy_module_cache_key( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> str: """Filesystem-safe cache key spanning full patch identity + budget + twirl. @@ -2309,9 +2324,11 @@ def _guppy_module_cache_key( if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE else f"_szzrb-{szz_runtime_barrier_policy}" ) + trace_metadata_part = "" if trace_metadata else "_trace-metadata-off" base = ( f"{patch.dx}x{patch.dz}_{geom.orientation.name}_{rotated}" - f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}{runtime_barrier_part}" + f"_b{effective_budget}{interaction_part}{check_plan_part}{frame_part}" + f"{runtime_barrier_part}{trace_metadata_part}" ) if twirl is None: if interaction_basis == "szz" and num_rounds is not None: @@ -2340,6 +2357,7 @@ def _load_guppy_module( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> dict: """Load a Guppy module for a patch, using caching. @@ -2363,6 +2381,10 @@ def _load_guppy_module( clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Module dictionary with generated functions @@ -2387,6 +2409,7 @@ def _load_guppy_module( check_plan=resolved_plan.plan_id if check_plan is not None else None, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) if cache_key in _state.module_cache: @@ -2402,6 +2425,7 @@ def _load_guppy_module( check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) # Write to temp file (required for Guppy introspection). @@ -2436,6 +2460,7 @@ def generate_memory_experiment( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> object: """Generate a memory experiment for a patch. @@ -2452,6 +2477,10 @@ def generate_memory_experiment( clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Guppy function for the experiment @@ -2470,6 +2499,7 @@ def generate_memory_experiment( check_plan=resolved_plan.plan_id if check_plan is not None else None, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) if basis.upper() == "Z": @@ -2564,6 +2594,7 @@ def generate_surface_code_module( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> str: """Generate source code for a distance-d surface code module. @@ -2577,6 +2608,8 @@ def generate_surface_code_module( clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Returns: Python/Guppy source code as a string @@ -2593,6 +2626,7 @@ def generate_surface_code_module( check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) @@ -2604,6 +2638,7 @@ def _surface_code_module_for_patch( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> dict: """Load + cache a surface-code module for an arbitrary patch. @@ -2634,6 +2669,7 @@ def _surface_code_module_for_patch( resolved_plan.plan_id, None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), szz_runtime_barrier_policy, + trace_metadata, ) if cache_key in _state.distance_module_cache: @@ -2646,6 +2682,7 @@ def _surface_code_module_for_patch( check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barrier_policy, + trace_metadata=trace_metadata, ) # Metadata derived from the actual patch geometry. @@ -2658,6 +2695,7 @@ def _surface_code_module_for_patch( module["clifford_frame_policy"] = clifford_frame_policy module["szz_runtime_barriers"] = szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE module["szz_runtime_barrier_policy"] = szz_runtime_barrier_policy + module["trace_metadata"] = trace_metadata module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash @@ -2713,6 +2751,7 @@ def make_surface_code( check_plan: str | None = None, clifford_frame_policy: str | None = None, szz_runtime_barriers: bool | str = False, + trace_metadata: bool = True, ) -> object: """Create a surface code memory experiment. @@ -2731,6 +2770,10 @@ def make_surface_code( clifford_frame_policy: Optional source-level Clifford-deformation policy for SZZ/SZZdg surface-code generation. szz_runtime_barriers: SZZ/SZZdg scheduling-barrier policy. + trace_metadata: Emit PECOS trace metadata helpers in generated SZZ + source. Keep enabled for traced-QIS/DEM paths; disable for + execution-only builds whose compiler/linker cannot resolve the + metadata helper symbols. Returns: Compiled Guppy program @@ -2752,4 +2795,5 @@ def make_surface_code( check_plan=check_plan, clifford_frame_policy=clifford_frame_policy, szz_runtime_barriers=szz_runtime_barriers, + trace_metadata=trace_metadata, ) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 6ea69257f..eb110cbf4 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -314,6 +314,25 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert '"source_lowering_required", "true"' in guppy_source +def test_szz_guppy_source_can_disable_trace_metadata_for_execution() -> None: + from pecos.guppy.surface import generate_guppy_source + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=3) + guppy_source = generate_guppy_source( + patch, + num_rounds=1, + interaction_basis="szz", + check_plan="szz_current_v1", + trace_metadata=False, + ) + + assert "def pecos_qis_trace_metadata_qubit_hugr(" not in guppy_source + assert "pecos_qis_trace_metadata_qubit_hugr(" not in guppy_source + assert "zz_phase(" in guppy_source + assert "result(" in guppy_source + + def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: from pecos.guppy.surface import generate_guppy_source from pecos.qec.surface import SurfacePatch From f4dbff6044557425c5b3ff87e9b9ec170c8402c2 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 22:07:46 -0600 Subject: [PATCH 285/388] Use two-qubit runtime barrier for SZZ hosted prefixes --- crates/pecos-hugr-qis/src/compiler.rs | 10 ++++ crates/pecos-qis-ffi/src/ffi.rs | 46 +++++++++++++++++++ docs/development/hosted-operations.md | 15 ++++-- .../quantum-pecos/src/pecos/guppy/surface.py | 14 ++++-- .../tests/guppy/test_hugr_to_llvm_parsing.py | 31 +++++++++++++ .../tests/guppy/test_surface_twirl_render.py | 12 ++--- .../tests/qec/surface/test_check_plan.py | 6 +-- 7 files changed, 117 insertions(+), 17 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 4684cabf1..e98cf4ce1 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -70,6 +70,7 @@ const HUGR_SYMBOL_PREFIX: &str = "__hugr__."; const TRACE_METADATA_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_hugr"; const TRACE_METADATA_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_trace_metadata_qubit_hugr"; const RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubit_hugr"; +const RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL: &str = "pecos_qis_runtime_barrier_qubits2_hugr"; // Extension registry is defined in the parent module @@ -409,6 +410,7 @@ fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { TRACE_METADATA_HUGR_SYMBOL, TRACE_METADATA_QUBIT_HUGR_SYMBOL, RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, ]; let mut normalized = String::with_capacity(llvm_ir.len()); let mut cursor = 0; @@ -469,6 +471,8 @@ mod tests { "%q2 = call i64 @__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21(i64 %0, i8* %1, i8* %2)\n", "%q3 = call i64 @__hugr__.pecos_qis_runtime_barrier_qubit_hugr.22(i64 %0)\n", "%q4 = call i64 @__hugr__.__main__.pecos_qis_runtime_barrier_qubit_hugr.23(i64 %0)\n", + "%q5 = call { i64, i64 } @__hugr__.pecos_qis_runtime_barrier_qubits2_hugr.24(i64 %0, i64 %1)\n", + "%q6 = call { i64, i64 } @__hugr__.__main__.pecos_qis_runtime_barrier_qubits2_hugr.25(i64 %0, i64 %1)\n", "call void @__hugr__.other_helper.16()\n", ) .to_string(); @@ -490,6 +494,12 @@ mod tests { assert!( normalized.contains("%q4 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") ); + assert!(normalized.contains( + "%q5 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %1)" + )); + assert!(normalized.contains( + "%q6 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %1)" + )); assert!(normalized.contains("@__hugr__.other_helper.16")); } } diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index d2655fcbe..0035c2d63 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -8,6 +8,14 @@ use crate::{Operation, QuantumOp, TraceMetadata, with_interface}; use log::debug; use std::cell::Cell; +/// C ABI return value for helpers that consume and return two qubits. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct QubitPair { + pub first: i64, + pub second: i64, +} + // Thread-local counter to prevent infinite loops in collection mode. // After MAX_COLLECTION_READS, `___read_future_bool` returns true to break out of // loops like "repeat_until_one" (while not result: ... result = measure(q)). @@ -552,6 +560,27 @@ pub unsafe extern "C" fn pecos_qis_runtime_barrier_qubit_hugr(qubit: i64) -> i64 qubit } +/// Insert a runtime scheduling barrier after prior operations touching either qubit. +/// +/// The returned qubit pair gives Guppy/HUGR data dependencies on both inputs. A +/// caller can place this helper immediately before a hosted local pulse so that +/// the local pulse cannot be scheduled before the host qubit is ready. +/// +/// # Safety +/// Called from C/LLVM code. Qubits must be valid non-negative IDs. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn pecos_qis_runtime_barrier_qubits2_hugr( + first: i64, + second: i64, +) -> QubitPair { + let _ = i64_to_usize(first); + let _ = i64_to_usize(second); + with_interface(|interface| { + interface.queue_operation(Operation::Barrier); + }); + QubitPair { first, second } +} + /// Attach source/runtime metadata to the next lowerable quantum operation. /// /// This variant uses direct string data pointers instead of the tket2 string @@ -1658,6 +1687,23 @@ mod tests { }); } + #[test] + fn test_runtime_barrier_qubits2_hugr_returns_qubits_and_queues_barrier() { + setup_test(); + let returned = unsafe { pecos_qis_runtime_barrier_qubits2_hugr(17, 23) }; + assert_eq!( + returned, + QubitPair { + first: 17, + second: 23, + }, + ); + + with_interface(|iface| { + assert_eq!(iface.operations, vec![Operation::Barrier]); + }); + } + // --- Measurement and reset tests --- #[test] diff --git a/docs/development/hosted-operations.md b/docs/development/hosted-operations.md index 4f6ebf375..7acf0f0d5 100644 --- a/docs/development/hosted-operations.md +++ b/docs/development/hosted-operations.md @@ -104,11 +104,16 @@ Current state: - A minimal Guppy public `barrier(...)` probe is currently optimized away before PECOS QIS operation collection. The captured raw operation trace contains allocations, gates, measurements, and releases, but no `Barrier` operation. -- The generated surface SZZ/SZZdg path uses a PECOS-owned - `pecos_qis_runtime_barrier_qubit_hugr` helper for `szz_runtime_barriers`. - The helper returns its qubit argument to create a Guppy/HUGR data dependency - and queues a real `Operation::Barrier`, so Selene runtime lowering drains the - current batch before the following host operation. +- PECOS-owned runtime-barrier helpers such as + `pecos_qis_runtime_barrier_qubit_hugr` return their qubit arguments to create + Guppy/HUGR data dependencies and queue a real `Operation::Barrier`, so Selene + runtime lowering drains the current batch before later dependent operations. +- SZZ/SZZdg data-prefix barriers use the two-qubit + `pecos_qis_runtime_barrier_qubits2_hugr` helper. The helper consumes and + returns the host ancilla and data qubit, and the generated source places it + before the hosted data-prefix pulse. This is the dependency needed to prevent + the data-prefix pulse from being scheduled long before the host qubit is + ready. So barrier preservation requires a Guppy/HUGR/QIR/QIS bridge that lowers public barriers or qsystem `RuntimeBarrier` operations into `Operation::Barrier` diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 6789bf8a2..ef0fa9ff1 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -393,6 +393,13 @@ def generate_guppy_source( "@guppy.declare", "def pecos_qis_runtime_barrier_qubit_hugr(q: qubit @ owned) -> qubit: ...", "", + "@guppy.declare", + ( + "def pecos_qis_runtime_barrier_qubits2_hugr(" + "q0: qubit @ owned, q1: qubit @ owned" + ") -> tuple[qubit, qubit]: ..." + ), + "", "", ], ) @@ -855,15 +862,16 @@ def discharge_data_for_szz( if host_label_scope is None else f"szz:{host_label_scope}:{host_label_core}" ) - discharge_data_for_szz(data_q, host_label=host_label) if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX and has_data_prefix ): target.append( - f"{indent}{data_expr(data_q)} = " - f"pecos_qis_runtime_barrier_qubit_hugr({data_expr(data_q)})", + f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " + "pecos_qis_runtime_barrier_qubits2_hugr(" + f"{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)})", ) + discharge_data_for_szz(data_q, host_label=host_label) half_turns = "0.5" if sign > 0 else "-0.5" _append_szz_gate_trace_metadata( target, diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index 111f9fb33..e811012c1 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -87,3 +87,34 @@ def metadata_probe() -> None: assert "@pecos_qis_trace_metadata_qubit_hugr" in llvm_ir assert "@__hugr__.pecos_qis_trace_metadata_qubit_hugr" not in llvm_ir + + +def test_runtime_barrier_pair_helper_uses_public_symbol() -> None: + """Test that two-qubit runtime-barrier helpers compile to the public FFI symbol.""" + try: + from guppylang import guppy + from guppylang.std.builtins import owned + from guppylang.std.quantum import cx, h, measure, qubit + from pecos_rslib import compile_hugr_to_qis + except ImportError as e: + pytest.skip(f"Required imports not available: {e}") + + @guppy.declare + def pecos_qis_runtime_barrier_qubits2_hugr( + q0: qubit @ owned, + q1: qubit @ owned, + ) -> tuple[qubit, qubit]: ... + + @guppy + def barrier_pair_probe() -> tuple[bool, bool]: + q0 = qubit() + q1 = qubit() + h(q0) + q0, q1 = pecos_qis_runtime_barrier_qubits2_hugr(q0, q1) + cx(q0, q1) + return measure(q0), measure(q1) + + llvm_ir = compile_hugr_to_qis(barrier_pair_probe.compile().to_bytes()) + + assert "@pecos_qis_runtime_barrier_qubits2_hugr" in llvm_ir + assert "@__hugr__.pecos_qis_runtime_barrier_qubits2_hugr" not in llvm_ir diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 0c85e5cd4..97e01d4d0 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -18,8 +18,8 @@ ) from pecos.qec.surface.patch import SurfacePatch -_SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubit_hugr(" -_SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubit_hugr(" +_SZZ_RUNTIME_BARRIER_HELPER = "pecos_qis_runtime_barrier_qubits2_hugr(" +_SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubits2_hugr(" @pytest.fixture @@ -28,7 +28,7 @@ def patch() -> SurfacePatch: def _assert_szz_prefix_barrier_host_order(src: str) -> None: - """Assert a real SZZ data-prefix pulse is fenced before its host.""" + """Assert a real SZZ data-prefix pulse is fenced with its host qubits.""" lines = src.splitlines() for index, line in enumerate(lines): if "phased_x(" not in line: @@ -38,15 +38,15 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: for i in range(index - 1, -1, -1) if '"source_kind", "szz_data_prefix"' in lines[i] ) - barrier_index = next(i for i in range(index + 1, len(lines)) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) + barrier_index = next(i for i in range(prefix_meta - 1, -1, -1) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) host_meta = next( i - for i in range(barrier_index + 1, len(lines)) + for i in range(index + 1, len(lines)) if '"source_kind", "szz_host"' in lines[i] ) zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) - assert prefix_meta < index < barrier_index < host_meta < zz_phase_index + assert barrier_index < prefix_meta < index < host_meta < zz_phase_index return msg = "expected an SZZ touch with a hosted phased_x data-prefix pulse" diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index eb110cbf4..bd964421d 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -344,12 +344,12 @@ def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: szz_runtime_barriers="data-prefix", ) - prefix_index = source.index('"source_kind", "szz_data_prefix"') - barrier_index = source.index("= pecos_qis_runtime_barrier_qubit_hugr(") + barrier_index = source.index("= pecos_qis_runtime_barrier_qubits2_hugr(") + prefix_index = source.index('"source_kind", "szz_data_prefix"', barrier_index) host_index = source.index('"source_kind", "szz_host"', prefix_index) zz_phase_index = source.index("zz_phase(", barrier_index) - assert prefix_index < barrier_index < host_index < zz_phase_index + assert barrier_index < prefix_index < host_index < zz_phase_index def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: From 5187e7438fd8f61c8b384051ebf5874d05b241a5 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sat, 27 Jun 2026 22:57:47 -0600 Subject: [PATCH 286/388] Use native Selene runtime barriers --- crates/pecos-qis/src/selene_runtime.rs | 45 ++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 53803a964..931d61b10 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -870,6 +870,47 @@ impl SeleneRuntime { Ok(()) } + fn call_runtime_global_barrier(&self, sleep_time: u64) -> Result { + let lib = self + .library + .as_ref() + .ok_or_else(|| RuntimeError::FfiError("Selene runtime is not loaded".to_string()))?; + let instance = self.instance.ok_or_else(|| { + RuntimeError::FfiError("Selene runtime is not initialized".to_string()) + })?; + + unsafe { + let Ok(global_barrier_fn) = lib + .get:: i32>( + b"selene_runtime_global_barrier", + ) + else { + return Ok(false); + }; + let errno = global_barrier_fn(instance, sleep_time); + if errno != 0 { + return Err(RuntimeError::FfiError(format!( + "global_barrier failed with errno {errno}" + ))); + } + } + + Ok(true) + } + + fn lower_runtime_barrier(&mut self) -> Result> { + self.load_plugin()?; + + // Runtime-native barriers keep scheduler-specific ordering decisions + // inside the plugin. Falling back to a drain preserves compatibility + // with older plugins that do not expose Selene barrier symbols. + if self.call_runtime_global_barrier(0)? { + return Ok(Vec::new()); + } + + self.drain_runtime_operations() + } + fn submit_operation_to_runtime( &mut self, op: &Operation, @@ -1710,7 +1751,7 @@ impl QisRuntime for SeleneRuntime { for op in operations { if matches!(op, Operation::Barrier) { - lowered_ops.extend(self.drain_runtime_operations()?); + lowered_ops.extend(self.lower_runtime_barrier()?); } self.submit_operation_to_runtime(op, &mut lowered_ops)?; } @@ -1770,7 +1811,7 @@ impl QisRuntime for SeleneRuntime { ); } Operation::Barrier => { - let emitted_ops = self.drain_runtime_operations()?; + let emitted_ops = self.lower_runtime_barrier()?; Self::push_lowered_ops_with_source_metadata( &mut lowered_ops, emitted_ops, From 6b344c32abed70425f2d0faf6089031644c78595 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 27 Jun 2026 18:59:40 -0600 Subject: [PATCH 287/388] WIP: add pecos-phir dynamic-zero guard test + wide-observables matching-decoder qec tests --- crates/pecos-phir/src/execution/tests.rs | 33 ++++++++++++++++ .../tests/qec/test_wide_observables.py | 39 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/crates/pecos-phir/src/execution/tests.rs b/crates/pecos-phir/src/execution/tests.rs index 2016c3888..476875cd7 100644 --- a/crates/pecos-phir/src/execution/tests.rs +++ b/crates/pecos-phir/src/execution/tests.rs @@ -1728,3 +1728,36 @@ fn test_engine_rz_rxy_module() -> Result<(), Box> { Ok(()) } + +#[test] +fn test_dynamic_zero_engine_rejected_without_explicit_qubits() { + use super::phir_engine; + use pecos_engines::sim_builder; + + // A PhirEngine reports 0 qubits before execution but allocates qubits + // dynamically at runtime. Building a sim with the default (fixed-size, non- + // growing) quantum engine and no explicit qubit count must FAIL LOUD rather + // than build a 0-qubit engine that would panic on the first allocation. This + // exercises the whole inferred-zero guard end-to-end: the guard keys on + // `ClassicalEngine::has_dynamic_qubit_count`, which must be forwarded through + // the `Box` SimBuilder holds (a regression there + // silently makes the guard inert and this test fails). + let engine = phir_engine().program(create_test_module()); + let result = sim_builder().classical(engine).build(); + assert!( + result.is_err(), + "dynamic-zero engine + default quantum engine must be rejected" + ); + let msg = result.err().unwrap().to_string(); + assert!( + msg.contains("dynamic classical engine"), + "expected the inferred-zero guard error, got: {msg}" + ); + + // An explicit qubit count bypasses the guard and builds fine. + let engine = phir_engine().program(create_test_module()); + assert!( + sim_builder().classical(engine).qubits(1).build().is_ok(), + "explicit .qubits(n) must bypass the inferred-zero guard" + ); +} diff --git a/python/quantum-pecos/tests/qec/test_wide_observables.py b/python/quantum-pecos/tests/qec/test_wide_observables.py index 2c7cb664d..80cfaf70e 100644 --- a/python/quantum-pecos/tests/qec/test_wide_observables.py +++ b/python/quantum-pecos/tests/qec/test_wide_observables.py @@ -141,3 +141,42 @@ def test_decode_each_returns_python_ints() -> None: preds = batch.decode_each(dem, "pymatching") assert len(preds) == 8 assert all(isinstance(p, int) for p in preds) + + +@pytest.mark.parametrize("decoder_type", ["fusion_blossom_serial", "pecos_uf", "k_mwpm"]) +def test_matching_decoders_fail_loud_on_wide_dem(decoder_type: str) -> None: + # The graphlike matching decoders pack observables into a u64 (1 << index), so + # they support at most 64 observables. On a >64-observable DEM they must raise a + # clean RuntimeError directing to a wide decoder -- NOT overflow-panic. (A panic + # surfaces as a pyo3 PanicException, a BaseException that pytest.raises(RuntimeError) + # would not catch, so this test fails if the guard regresses to a panic.) + n = 70 + dem, _ = _wide_dem(n) + syn = [0] * n + syn[69] = 1 + batch = SampleBatch([syn, syn], [1 << 69, 1 << 69]) + with pytest.raises(RuntimeError, match="64"): + batch.decode_count(dem, decoder_type) + + +def test_fusion_blossom_direct_constructors_reject_wide() -> None: + # The FusionBlossom Python constructors (from_check_matrix, manual builder) sit + # outside the decoder_type sweep, so they need their own coverage: an observable + # index >= 64 must fail loud rather than overflow-panic when the u64 mask is packed. + import numpy as np + from pecos_rslib.decoders import FusionBlossomDecoder + + h = np.zeros((1, 65), dtype=np.uint8) + h[0, 64] = 1 # observable (column) 64 -> would overflow `1 << 64` + with pytest.raises(RuntimeError, match="64"): + FusionBlossomDecoder.from_check_matrix(h) + + d = FusionBlossomDecoder(num_nodes=2, num_observables=65) + with pytest.raises(RuntimeError, match="64"): + d.add_edge(0, 1, [64], 1.0) + with pytest.raises(RuntimeError, match="64"): + d.add_boundary_edge(0, [64], 1.0) + + # The 64-observable boundary (indices 0..63) must still construct fine. + ok = FusionBlossomDecoder(num_nodes=2, num_observables=64) + ok.add_edge(0, 1, [63], 1.0) From 124aa4c4ec020f93b41fc8d34aadead53773f760 Mon Sep 17 00:00:00 2001 From: ciaranra Date: Sun, 28 Jun 2026 02:59:30 -0600 Subject: [PATCH 288/388] Pack generated SZZ trace metadata --- crates/pecos-qis-ffi/src/ffi.rs | 75 +++++++++++++-- .../quantum-pecos/src/pecos/guppy/surface.py | 38 ++++---- .../tests/guppy/test_surface_twirl_render.py | 8 +- .../tests/qec/surface/test_check_plan.py | 95 ++++++++++++++----- 4 files changed, 167 insertions(+), 49 deletions(-) diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 0035c2d63..0b53ca3a1 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -34,6 +34,8 @@ fn i64_to_usize(value: i64) -> usize { usize::try_from(value).expect("Invalid ID: value must be non-negative and fit in usize") } +const PACKED_TRACE_METADATA_JSON_KEY: &str = "__pecos_trace_metadata_json_v1__"; + unsafe fn read_tket_string_arg( func_name: &str, arg_name: &str, @@ -414,9 +416,30 @@ pub unsafe extern "C" fn __quantum__rt__record(data: *const std::ffi::c_char) { } } -fn queue_trace_metadata(key: String, value: String, qubit: Option) { - let mut metadata = TraceMetadata::new(); - metadata.insert(key, value); +fn trace_metadata_from_key_value( + func_name: &str, + key: String, + value: String, +) -> Option { + if key != PACKED_TRACE_METADATA_JSON_KEY { + let mut metadata = TraceMetadata::new(); + metadata.insert(key, value); + return Some(metadata); + } + + match serde_json::from_str::(&value) { + Ok(metadata) => Some(metadata), + Err(err) => { + log::error!("{func_name}: invalid packed trace metadata JSON: {err}"); + None + } + } +} + +fn queue_trace_metadata(func_name: &str, key: String, value: String, qubit: Option) { + let Some(metadata) = trace_metadata_from_key_value(func_name, key, value) else { + return; + }; with_interface(|interface| { interface.queue_operation(Operation::TraceMetadata { metadata, qubit }); }); @@ -450,7 +473,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata( }) else { return; }; - queue_trace_metadata(key, value, None); + queue_trace_metadata("pecos_qis_trace_metadata", key, value, None); } /// Attach source/runtime metadata to the next lowerable quantum operation. @@ -490,7 +513,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_hugr(key_ptr: *const u8, value }) else { return; }; - queue_trace_metadata(key, value, None); + queue_trace_metadata("pecos_qis_trace_metadata_hugr", key, value, None); } /// Attach source/runtime metadata to the next operation on a specific qubit. @@ -538,7 +561,12 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_qubit_hugr( }) else { return qubit; }; - queue_trace_metadata(key, value, Some(i64_to_usize(qubit))); + queue_trace_metadata( + "pecos_qis_trace_metadata_qubit_hugr", + key, + value, + Some(i64_to_usize(qubit)), + ); qubit } @@ -612,7 +640,7 @@ pub unsafe extern "C" fn pecos_qis_trace_metadata_direct( }) else { return; }; - queue_trace_metadata(key, value, None); + queue_trace_metadata("pecos_qis_trace_metadata_direct", key, value, None); } // --- Selene-style FFI Functions --- @@ -1676,6 +1704,39 @@ mod tests { }); } + #[test] + fn test_trace_metadata_qubit_hugr_expands_packed_json_metadata() { + setup_test(); + let mut key = Vec::with_capacity(PACKED_TRACE_METADATA_JSON_KEY.len() + 1); + key.push(PACKED_TRACE_METADATA_JSON_KEY.len() as u8); + key.extend_from_slice(PACKED_TRACE_METADATA_JSON_KEY.as_bytes()); + let value = br#"{"host_id":"probe:host","source_kind":"szz_host"}"#; + let mut packed = Vec::with_capacity(value.len() + 1); + packed.push(value.len() as u8); + packed.extend_from_slice(value); + + let returned = + unsafe { pecos_qis_trace_metadata_qubit_hugr(19, key.as_ptr(), packed.as_ptr()) }; + assert_eq!(returned, 19); + + with_interface(|iface| { + assert_eq!(iface.operations.len(), 1); + let Operation::TraceMetadata { metadata, qubit } = &iface.operations[0] else { + panic!("expected trace metadata operation"); + }; + assert_eq!(*qubit, Some(19)); + assert_eq!( + metadata.get("source_kind").map(String::as_str), + Some("szz_host") + ); + assert_eq!( + metadata.get("host_id").map(String::as_str), + Some("probe:host") + ); + assert!(!metadata.contains_key(PACKED_TRACE_METADATA_JSON_KEY)); + }); + } + #[test] fn test_runtime_barrier_qubit_hugr_returns_qubit_and_queues_barrier() { setup_test(); diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index ef0fa9ff1..db4af438a 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -12,6 +12,7 @@ """ import importlib.util +import json import sys import tempfile from collections.abc import Callable @@ -50,6 +51,7 @@ class _ModuleState: _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX, }, ) +_PACKED_TRACE_METADATA_JSON_KEY = "__pecos_trace_metadata_json_v1__" def _normalize_szz_runtime_barrier_policy(value: bool | str) -> str: @@ -543,17 +545,20 @@ def _szz_touch_compensation_gates(axis: str, sign: int) -> tuple[OpType, ...]: msg = f"unsupported Pauli axis {axis!r}" raise ValueError(msg) - def _append_szz_trace_metadata( + def _append_szz_trace_metadata_payload( target: list[str], indent: str, - key: str, - value: str, + metadata: dict[str, str], qubit_expr: str, ) -> None: - if not trace_metadata: + if not trace_metadata or not metadata: return + payload = json.dumps(metadata, separators=(",", ":"), sort_keys=True) target.append( - f'{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr({qubit_expr}, "{key}", "{value}")', + f"{indent}{qubit_expr} = pecos_qis_trace_metadata_qubit_hugr(" + f"{qubit_expr}, " + f"{json.dumps(_PACKED_TRACE_METADATA_JSON_KEY)}, " + f"{json.dumps(payload)})", ) def _append_szz_gate_trace_metadata( @@ -568,23 +573,20 @@ def _append_szz_gate_trace_metadata( gate: OpType | None = None, lowering_required: bool = False, ) -> None: - _append_szz_trace_metadata(target, indent, "source_kind", source_kind, qubit_expr) - _append_szz_trace_metadata(target, indent, "source_label", source_label, qubit_expr) + metadata = { + "source_kind": source_kind, + "source_label": source_label, + } if host_label is not None: - _append_szz_trace_metadata(target, indent, "szz_host_label", host_label, qubit_expr) - _append_szz_trace_metadata(target, indent, "host_id", host_label, qubit_expr) + metadata["szz_host_label"] = host_label + metadata["host_id"] = host_label if local_role is not None: - _append_szz_trace_metadata(target, indent, "local_role", local_role, qubit_expr) + metadata["local_role"] = local_role if gate is not None: - _append_szz_trace_metadata(target, indent, "source_gate", gate.name, qubit_expr) + metadata["source_gate"] = gate.name if lowering_required: - _append_szz_trace_metadata( - target, - indent, - "source_lowering_required", - "true", - qubit_expr, - ) + metadata["source_lowering_required"] = "true" + _append_szz_trace_metadata_payload(target, indent, metadata, qubit_expr) def _append_szz_flow_gate( target: list[str], diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index 97e01d4d0..cc52065e9 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -22,6 +22,10 @@ _SZZ_RUNTIME_BARRIER_CALL = "= pecos_qis_runtime_barrier_qubits2_hugr(" +def _line_has_trace_metadata(line: str, key: str, value: str) -> bool: + return f'"{key}", "{value}"' in line or f'\\"{key}\\":\\"{value}\\"' in line + + @pytest.fixture def patch() -> SurfacePatch: return SurfacePatch.create(distance=3) @@ -36,13 +40,13 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: prefix_meta = next( i for i in range(index - 1, -1, -1) - if '"source_kind", "szz_data_prefix"' in lines[i] + if _line_has_trace_metadata(lines[i], "source_kind", "szz_data_prefix") ) barrier_index = next(i for i in range(prefix_meta - 1, -1, -1) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) host_meta = next( i for i in range(index + 1, len(lines)) - if '"source_kind", "szz_host"' in lines[i] + if _line_has_trace_metadata(lines[i], "source_kind", "szz_host") ) zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index bd964421d..98ffee709 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -5,11 +5,48 @@ from __future__ import annotations +import ast import hashlib +import json from dataclasses import replace import pytest +_PACKED_TRACE_METADATA_JSON_KEY = "__pecos_trace_metadata_json_v1__" + + +def _packed_trace_metadata_records(source: str) -> list[tuple[int, dict[str, str]]]: + records: list[tuple[int, dict[str, str]]] = [] + sentinel = f"{json.dumps(_PACKED_TRACE_METADATA_JSON_KEY)}, " + for line_index, line in enumerate(source.splitlines()): + if "pecos_qis_trace_metadata_qubit_hugr(" not in line or sentinel not in line: + continue + packed_literal = line.split(sentinel, 1)[1].rsplit(")", 1)[0] + records.append((line_index, json.loads(ast.literal_eval(packed_literal)))) + return records + + +def _first_metadata_line( + source: str, + *, + start_line: int = 0, + **expected: str, +) -> tuple[int, dict[str, str]]: + for line_index, metadata in _packed_trace_metadata_records(source): + if line_index < start_line: + continue + if all(metadata.get(key) == value for key, value in expected.items()): + return line_index, metadata + msg = f"could not find packed trace metadata after line {start_line}: {expected!r}" + raise AssertionError(msg) + + +def _has_metadata_prefix(source: str, key: str, prefix: str) -> bool: + return any( + isinstance(metadata.get(key), str) and metadata[key].startswith(prefix) + for _, metadata in _packed_trace_metadata_records(source) + ) + def test_check_plan_default_resolves_to_cx_metadata() -> None: from pecos.qec.surface._check_plan import ( @@ -306,12 +343,14 @@ def test_direct_surface_renderers_accept_check_plan_as_source_of_truth() -> None assert "Check plan: szz_current_v1" in guppy_source assert "def pecos_qis_trace_metadata_qubit_hugr(" in guppy_source assert " = pecos_qis_trace_metadata_qubit_hugr(" in guppy_source - assert '"source_kind", "szz_host"' in guppy_source - assert '"source_kind", "szz_data_prefix"' in guppy_source - assert '"szz_host_label", "szz:' in guppy_source - assert '"host_id", "szz:' in guppy_source - assert '"local_role", "basis_prefix"' in guppy_source - assert '"source_lowering_required", "true"' in guppy_source + assert _PACKED_TRACE_METADATA_JSON_KEY in guppy_source + records = [metadata for _, metadata in _packed_trace_metadata_records(guppy_source)] + assert any(metadata.get("source_kind") == "szz_host" for metadata in records) + assert any(metadata.get("source_kind") == "szz_data_prefix" for metadata in records) + assert any(str(metadata.get("szz_host_label", "")).startswith("szz:") for metadata in records) + assert any(str(metadata.get("host_id", "")).startswith("szz:") for metadata in records) + assert any(metadata.get("local_role") == "basis_prefix" for metadata in records) + assert any(metadata.get("source_lowering_required") == "true" for metadata in records) def test_szz_guppy_source_can_disable_trace_metadata_for_execution() -> None: @@ -344,10 +383,15 @@ def test_szz_runtime_barrier_fences_data_prefix_before_host() -> None: szz_runtime_barriers="data-prefix", ) - barrier_index = source.index("= pecos_qis_runtime_barrier_qubits2_hugr(") - prefix_index = source.index('"source_kind", "szz_data_prefix"', barrier_index) - host_index = source.index('"source_kind", "szz_host"', prefix_index) - zz_phase_index = source.index("zz_phase(", barrier_index) + lines = source.splitlines() + barrier_index = next(i for i, line in enumerate(lines) if "= pecos_qis_runtime_barrier_qubits2_hugr(" in line) + prefix_index, _ = _first_metadata_line( + source, + start_line=barrier_index, + source_kind="szz_data_prefix", + ) + host_index, _ = _first_metadata_line(source, start_line=prefix_index, source_kind="szz_host") + zz_phase_index = next(i for i in range(barrier_index, len(lines)) if "zz_phase(" in lines[i]) assert barrier_index < prefix_index < host_index < zz_phase_index @@ -361,13 +405,20 @@ def test_szz_data_prefixes_emit_generic_hosted_metadata() -> None: check_plan="szz_current_v1", ) - prefix_index = source.index('"source_kind", "szz_data_prefix"') - role_index = source.index('"local_role", "basis_prefix"', prefix_index) - prefix_host_index = source.index('"host_id", "szz:', prefix_index) - host_index = source.index('"source_kind", "szz_host"', prefix_index) - host_id_index = source.index('"host_id", "szz:', host_index) + prefix_index, prefix_metadata = _first_metadata_line( + source, + source_kind="szz_data_prefix", + ) + host_index, host_metadata = _first_metadata_line( + source, + start_line=prefix_index + 1, + source_kind="szz_host", + ) - assert prefix_index < prefix_host_index < role_index < host_index < host_id_index + assert prefix_metadata["local_role"] == "basis_prefix" + assert prefix_metadata["host_id"].startswith("szz:") + assert host_metadata["host_id"].startswith("szz:") + assert prefix_index < host_index def test_szz_hosted_metadata_labels_include_helper_scope() -> None: @@ -379,9 +430,9 @@ def test_szz_hosted_metadata_labels_include_helper_scope() -> None: check_plan="szz_current_v1", ) - assert '"host_id", "szz:init_z_basis:' in source - assert '"host_id", "szz:init_x_basis:' in source - assert '"host_id", "szz:syndrome_extraction:' in source + assert _has_metadata_prefix(source, "host_id", "szz:init_z_basis:") + assert _has_metadata_prefix(source, "host_id", "szz:init_x_basis:") + assert _has_metadata_prefix(source, "host_id", "szz:syndrome_extraction:") def test_plain_szz_memory_source_unrolls_hosted_metadata_by_counted_round() -> None: @@ -398,9 +449,9 @@ def test_plain_szz_memory_source_unrolls_hosted_metadata_by_counted_round() -> N assert "if num_rounds != 2:" in source assert "def syndrome_extraction_memory_r0" in source assert "def syndrome_extraction_memory_r1" in source - assert '"host_id", "szz:memory_r0:' in source - assert '"host_id", "szz:memory_r1:' in source - assert '"host_id", "szz:memory_r2:' not in source + assert _has_metadata_prefix(source, "host_id", "szz:memory_r0:") + assert _has_metadata_prefix(source, "host_id", "szz:memory_r1:") + assert not _has_metadata_prefix(source, "host_id", "szz:memory_r2:") def test_plain_szz_memory_cache_key_includes_counted_rounds() -> None: From efb8ce129bb3d01c2b0aa2c12bb035fe04ee74fe Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 10:34:17 -0600 Subject: [PATCH 289/388] Route pecos-cli CUDA Python install through cuda_python_group() after dev split the cuda extra into cuda12/cuda13 --- crates/pecos-cli/src/cli/python_cmd.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/pecos-cli/src/cli/python_cmd.rs b/crates/pecos-cli/src/cli/python_cmd.rs index 022f70dbd..d2a0818e6 100644 --- a/crates/pecos-cli/src/cli/python_cmd.rs +++ b/crates/pecos-cli/src/cli/python_cmd.rs @@ -259,7 +259,11 @@ fn run_build(profile: &str, rustflags: Option<&str>, cuda: bool) -> Result<()> { pip_cmd.args(["pip", "install", "--no-deps", "-e"]); if cuda { - pip_cmd.arg("./python/quantum-pecos[all,cuda]"); + // The CUDA Python extras are split by toolkit major (cuda12/cuda13); + // select the one matching the detected toolkit, the same choice + // `pecos cuda install` makes via cuda_python_group(). + let group = super::cuda_cmd::cuda_python_group(); + pip_cmd.arg(format!("./python/quantum-pecos[all,{group}]")); } else { pip_cmd.arg("./python/quantum-pecos[all]"); } From 87a6c15ec05bd78ed0b77c13d15846632097990c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 14:54:24 -0600 Subject: [PATCH 290/388] Reconcile CI LLVM cache config to 21.1 in pr-core-gate + python workflows (round-6 Finding B) --- .github/workflows/pr-core-gate.yml | 11 ++++++----- .github/workflows/python-release.yml | 4 ++-- .github/workflows/python-test.yml | 24 ++++++++++++------------ 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.github/workflows/pr-core-gate.yml b/.github/workflows/pr-core-gate.yml index fd93d6d26..1e3cab254 100644 --- a/.github/workflows/pr-core-gate.yml +++ b/.github/workflows/pr-core-gate.yml @@ -19,7 +19,8 @@ permissions: contents: read env: - LLVM_VERSION: "14.0.6" + LLVM_VERSION: "21.1" + LLVM_RELEASE_VERSION: "21.1.8" RUSTFLAGS: -C debuginfo=0 RUST_BACKTRACE: 1 PYTHONUTF8: 1 @@ -127,8 +128,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} if: steps.detect.outputs.run == 'true' @@ -229,8 +230,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} if: steps.detect.outputs.run == 'true' diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index b6f47802b..da0291fb4 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -163,8 +163,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-14.0.6-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM run: just ci-env diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index b11edeed6..af0aaa287 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -116,8 +116,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -126,7 +126,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Sync Python dependencies @@ -202,8 +202,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} if: github.event_name != 'pull_request' @@ -213,7 +213,7 @@ jobs: if: github.event_name != 'pull_request' && steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Run core Python tests (post-merge) @@ -325,8 +325,8 @@ jobs: id: cache-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} run: just ci-env @@ -335,7 +335,7 @@ jobs: if: steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Install Python build tooling @@ -456,8 +456,8 @@ jobs: if: matrix.include-llvm uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 - key: llvm-${{ env.LLVM_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v2 + path: ~/.pecos/deps/llvm-21.1 + key: llvm-${{ env.LLVM_RELEASE_VERSION }}-${{ runner.os }}-${{ runner.arch }}-v3 - name: Ensure LLVM ${{ env.LLVM_VERSION }} if: matrix.include-llvm @@ -467,7 +467,7 @@ jobs: if: matrix.include-llvm && steps.cache-llvm.outputs.cache-hit != 'true' && github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: - path: ~/.pecos/deps/llvm-14 + path: ~/.pecos/deps/llvm-21.1 key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Download Python compatibility smoke artifacts From de022131023a93a467931587e443dcd110b75dee Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 14:54:24 -0600 Subject: [PATCH 291/388] Build the dependency-free [all] extra in pecos python build and fix the misleading CUDA message (round-6 Finding D) --- crates/pecos-cli/src/cli/python_cmd.rs | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/crates/pecos-cli/src/cli/python_cmd.rs b/crates/pecos-cli/src/cli/python_cmd.rs index d2a0818e6..bcee5f358 100644 --- a/crates/pecos-cli/src/cli/python_cmd.rs +++ b/crates/pecos-cli/src/cli/python_cmd.rs @@ -26,7 +26,7 @@ pub fn run(command: &super::PythonCommands) -> Result<()> { /// Resolution order: /// - `--cuda` -> always on (caller knows what they want) /// - `--no-cuda` -> always off (caller opts out) -/// - neither -> auto-detect: include CUDA Python packages when both the +/// - neither -> auto-detect: build the CUDA (Rust) backend when both the /// toolkit and an NVIDIA GPU are present, otherwise skip fn resolve_cuda_choice(cuda: bool, no_cuda: bool) -> bool { if cuda { @@ -38,8 +38,10 @@ fn resolve_cuda_choice(cuda: bool, no_cuda: bool) -> bool { let detected = super::cuda_cmd::should_install_cuda_python(); if detected { println!( - "CUDA toolkit + NVIDIA GPU detected -- including CUDA Python packages \ - (cupy, cuquantum, pytket-cutensornet). Pass --no-cuda to skip." + "CUDA toolkit + NVIDIA GPU detected -- building the CUDA (Rust) backend. \ + CUDA Python packages (cupy, cuquantum, pytket-cutensornet) are installed \ + separately via `uv sync --group cuda12|cuda13` (e.g. `pecos cuda setup-python`). \ + Pass --no-cuda to skip." ); } detected @@ -258,15 +260,14 @@ fn run_build(profile: &str, rustflags: Option<&str>, cuda: bool) -> Result<()> { let mut pip_cmd = Command::new("uv"); pip_cmd.args(["pip", "install", "--no-deps", "-e"]); - if cuda { - // The CUDA Python extras are split by toolkit major (cuda12/cuda13); - // select the one matching the detected toolkit, the same choice - // `pecos cuda install` makes via cuda_python_group(). - let group = super::cuda_cmd::cuda_python_group(); - pip_cmd.arg(format!("./python/quantum-pecos[all,{group}]")); - } else { - pip_cmd.arg("./python/quantum-pecos[all]"); - } + // `--no-deps` (above) means this editable install pulls no dependencies, so + // naming a CUDA extra here would be inert: the CUDA Python stack + // (cupy/cuquantum/pytket-cutensornet) is installed separately via + // `uv sync --group cuda12|cuda13` (`just build`'s sync-deps, `pecos setup`, or + // `pecos cuda setup-python`), not by this command. Request the dependency-free + // `[all]` extra, which exists regardless of CUDA toolkit major and avoids an + // unknown-extra warning. + pip_cmd.arg("./python/quantum-pecos[all]"); pip_cmd.current_dir(&repo_root); pip_cmd.env_remove("CONDA_PREFIX"); From 7fa76a6e569cf333f57ddd5091129d768112c4a5 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 16:53:37 -0600 Subject: [PATCH 292/388] Make explicit --cuda build pecos-rslib-cuda (auto-detect informs only) so the build and message are accurate (round-7 Finding B) --- crates/pecos-cli/src/cli/python_cmd.rs | 49 ++++++++++++++------------ 1 file changed, 26 insertions(+), 23 deletions(-) diff --git a/crates/pecos-cli/src/cli/python_cmd.rs b/crates/pecos-cli/src/cli/python_cmd.rs index bcee5f358..84c6468a4 100644 --- a/crates/pecos-cli/src/cli/python_cmd.rs +++ b/crates/pecos-cli/src/cli/python_cmd.rs @@ -23,11 +23,16 @@ pub fn run(command: &super::PythonCommands) -> Result<()> { /// Decide whether to install CUDA Python packages for this build. /// -/// Resolution order: -/// - `--cuda` -> always on (caller knows what they want) -/// - `--no-cuda` -> always off (caller opts out) -/// - neither -> auto-detect: build the CUDA (Rust) backend when both the -/// toolkit and an NVIDIA GPU are present, otherwise skip +/// Decide whether `pecos python build` builds the CUDA (Rust) backend crate +/// (`pecos-rslib-cuda`). +/// +/// - `--cuda` -> build it. The caller opted in and is responsible for CUDA +/// setup first (e.g. `just build-cuda` runs `setup-quiet`, which installs the +/// cuQuantum SDK that crate's build needs). +/// - `--no-cuda` -> skip it. +/// - neither -> do NOT build it. The auto-detect path (e.g. `just build-lite`) +/// does no CUDA setup, so building the backend here could fail or be slow. When a +/// toolkit + GPU are present, just print a notice on how to enable CUDA. fn resolve_cuda_choice(cuda: bool, no_cuda: bool) -> bool { if cuda { return true; @@ -35,16 +40,16 @@ fn resolve_cuda_choice(cuda: bool, no_cuda: bool) -> bool { if no_cuda { return false; } - let detected = super::cuda_cmd::should_install_cuda_python(); - if detected { + if super::cuda_cmd::should_install_cuda_python() { println!( - "CUDA toolkit + NVIDIA GPU detected -- building the CUDA (Rust) backend. \ - CUDA Python packages (cupy, cuquantum, pytket-cutensornet) are installed \ - separately via `uv sync --group cuda12|cuda13` (e.g. `pecos cuda setup-python`). \ - Pass --no-cuda to skip." + "CUDA toolkit + NVIDIA GPU detected, but `pecos python build` only builds the \ + CUDA (Rust) backend when you pass --cuda. To enable CUDA: run \ + `pecos python build --cuda` (or `just build-cuda`) for the `pecos-rslib-cuda` \ + backend, and `uv sync --group cuda12|cuda13` (or `pecos cuda setup-python`) for \ + the CUDA Python packages." ); } - detected + false } /// Get the repository root @@ -170,23 +175,21 @@ fn run_build(profile: &str, rustflags: Option<&str>, cuda: bool) -> Result<()> { } // Build all rslib crates via maturin (incremental — cargo inside maturin - // handles change detection, skips recompilation when nothing changed) - let crates = ["pecos-rslib", "pecos-rslib-llvm"]; + // handles change detection, skips recompilation when nothing changed). + // The CUDA (Rust) backend is its own crate, built only on an explicit --cuda + // (`cuda` is true only then -- see resolve_cuda_choice); the auto-detect path + // does no CUDA setup, so it must not pull in pecos-rslib-cuda. + let mut crates = vec!["pecos-rslib", "pecos-rslib-llvm"]; + if cuda { + crates.push("pecos-rslib-cuda"); + } for crate_name in crates { let crate_dir = repo_root.join(format!("python/{crate_name}")); if !crate_dir.exists() { continue; } - println!( - "Building {crate_name} ({}{})...", - profile, - if cuda && crate_name == "pecos-rslib" { - " +cuda" - } else { - "" - } - ); + println!("Building {crate_name} ({profile})..."); remove_stale_extension_artifacts(&repo_root, profile, crate_name)?; From 14333085635b95da663b773dd20ed2a8975e4477 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 16:53:37 -0600 Subject: [PATCH 293/388] Check the cuda12/cuda13 split in check_python_workspace's CUDA extra/group invariant (round-7 Finding D) --- scripts/check_python_workspace.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/check_python_workspace.py b/scripts/check_python_workspace.py index 5d0259b09..f18a26e62 100644 --- a/scripts/check_python_workspace.py +++ b/scripts/check_python_workspace.py @@ -159,16 +159,23 @@ def check_cuda_extra_group(root_data: dict[str, Any], errors: list[str]) -> None project = root_data.get("project", {}) optional = project.get("optional-dependencies", {}) if isinstance(project, dict) else {} dependency_groups = root_data.get("dependency-groups", {}) - cuda_extra = optional.get("cuda") if isinstance(optional, dict) else None - cuda_group = dependency_groups.get("cuda") if isinstance(dependency_groups, dict) else None - - if cuda_extra is None or cuda_group is None: + if not isinstance(optional, dict) or not isinstance(dependency_groups, dict): return - if cuda_extra != cuda_group: - fail( - errors, - "pyproject.toml: [project.optional-dependencies].cuda and [dependency-groups].cuda must stay identical", - ) + + # The CUDA stack is split by toolkit major; each extra must stay identical to + # its matching dependency group so `pip install .[cuda13]` and + # `uv sync --group cuda13` resolve to the same packages. + for cuda_name in ("cuda12", "cuda13"): + cuda_extra = optional.get(cuda_name) + cuda_group = dependency_groups.get(cuda_name) + if cuda_extra is None or cuda_group is None: + continue + if cuda_extra != cuda_group: + fail( + errors, + f"pyproject.toml: [project.optional-dependencies].{cuda_name} and " + f"[dependency-groups].{cuda_name} must stay identical", + ) def main() -> int: From 29feec8cebd2ba98d5831910ccb1312ff1563db9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 17:25:42 -0600 Subject: [PATCH 294/388] Require both cuda12/cuda13 extra+group in check_python_workspace, rejecting deletion not just mismatch (round-8 Finding B) --- scripts/check_python_workspace.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/scripts/check_python_workspace.py b/scripts/check_python_workspace.py index f18a26e62..77fb00d85 100644 --- a/scripts/check_python_workspace.py +++ b/scripts/check_python_workspace.py @@ -162,15 +162,27 @@ def check_cuda_extra_group(root_data: dict[str, Any], errors: list[str]) -> None if not isinstance(optional, dict) or not isinstance(dependency_groups, dict): return - # The CUDA stack is split by toolkit major; each extra must stay identical to - # its matching dependency group so `pip install .[cuda13]` and - # `uv sync --group cuda13` resolve to the same packages. + # The CUDA stack is split by toolkit major; each major must be defined as BOTH a + # `[project.optional-dependencies]` extra AND a matching `[dependency-groups]` + # group, kept identical, so `pip install .[cuda13]` and `uv sync --group cuda13` + # resolve to the same packages. Both majors are required: the `pecos` CLI selects + # cuda12 or cuda13 by the detected toolkit (cuda_python_group), so deleting either + # the extra or the group breaks CUDA setup on the corresponding host -- a missing + # side is an error, not a silent skip. for cuda_name in ("cuda12", "cuda13"): cuda_extra = optional.get(cuda_name) cuda_group = dependency_groups.get(cuda_name) - if cuda_extra is None or cuda_group is None: - continue - if cuda_extra != cuda_group: + if cuda_extra is None: + fail( + errors, + f"pyproject.toml: missing required [project.optional-dependencies].{cuda_name}", + ) + if cuda_group is None: + fail( + errors, + f"pyproject.toml: missing required [dependency-groups].{cuda_name}", + ) + if cuda_extra is not None and cuda_group is not None and cuda_extra != cuda_group: fail( errors, f"pyproject.toml: [project.optional-dependencies].{cuda_name} and " From e801481305990cb55364127065ce5ea8dc64129a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 18:49:51 -0600 Subject: [PATCH 295/388] Normalize quoted __hugr__ helper symbols so function-local SZZ trace/runtime-barrier declarations lower to public FFI symbols --- crates/pecos-hugr-qis/src/compiler.rs | 101 ++++++++++++++++++++++---- 1 file changed, 85 insertions(+), 16 deletions(-) diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 6eb157012..963863a8c 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -435,6 +435,24 @@ fn is_llvm_symbol_char(ch: char) -> bool { /// so they need stable external symbols for dynamic linking. Keep this rewrite /// deliberately narrow: only PECOS-owned QIS helper declarations receive this /// treatment. +/// If `symbol` is a PECOS-owned helper lowered under the private `__hugr__.*` +/// namespace, return its stable public name; otherwise `None`. +/// +/// Guppy qualifies the symbol with the defining scope (module/function, plus a +/// `` segment for function-local declarations) and tket appends a numeric +/// node id, so the helper name sits second-from-last: +/// `__hugr__...`. +fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Option<&'a str> { + let rest = symbol.strip_prefix(HUGR_SYMBOL_PREFIX)?; + let mut parts = rest.rsplit('.'); + let _suffix = parts.next()?; + let helper_name = parts.next()?; + helper_symbols + .iter() + .copied() + .find(|helper| *helper == helper_name) +} + fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { let helper_symbols = [ TRACE_METADATA_HUGR_SYMBOL, @@ -448,8 +466,39 @@ fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { while let Some(relative_start) = llvm_ir[cursor..].find('@') { let start = cursor + relative_start; normalized.push_str(&llvm_ir[cursor..start]); + let after_at = start + 1; + + // Quoted LLVM symbol: `@"..."`. Guppy quotes the private symbol whenever + // the qualified name contains characters illegal in a bare LLVM identifier + // -- notably the `` segment of a function-local declaration. The + // bare-identifier scan below would skip these (a `"` is not a symbol char), + // leaving the `__hugr__.*` name unnormalized, so handle the quoted form + // explicitly. A literal `"` inside is LLVM-escaped as `\22`, so the next + // unescaped `"` always terminates the symbol. + if llvm_ir[after_at..].starts_with('"') { + let body_start = after_at + 1; + if let Some(rel_close) = llvm_ir[body_start..].find('"') { + let body_end = body_start + rel_close; + let symbol = &llvm_ir[body_start..body_end]; + if let Some(public) = pecos_helper_public_name(symbol, &helper_symbols) { + // The public name is a valid bare identifier, so drop the quotes. + normalized.push('@'); + normalized.push_str(public); + } else { + normalized.push_str("@\""); + normalized.push_str(symbol); + normalized.push('"'); + } + cursor = body_end + 1; + continue; + } + // Unterminated quote (malformed IR): emit the `@` and keep scanning. + normalized.push('@'); + cursor = after_at; + continue; + } - let symbol_start = start + 1; + let symbol_start = after_at; let symbol_len = llvm_ir[symbol_start..] .chars() .take_while(|ch| is_llvm_symbol_char(*ch)) @@ -463,22 +512,13 @@ fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { let symbol_end = symbol_start + symbol_len; let symbol = &llvm_ir[symbol_start..symbol_end]; - if let Some(rest) = symbol.strip_prefix(HUGR_SYMBOL_PREFIX) { - let mut parts = rest.rsplit('.'); - let suffix = parts.next(); - let helper_name = parts.next(); - if let (Some(_suffix), Some(helper_name)) = (suffix, helper_name) { - if helper_symbols.iter().any(|helper| helper == &helper_name) { - normalized.push('@'); - normalized.push_str(helper_name); - cursor = symbol_end; - continue; - } - } + if let Some(public) = pecos_helper_public_name(symbol, &helper_symbols) { + normalized.push('@'); + normalized.push_str(public); + } else { + normalized.push('@'); + normalized.push_str(symbol); } - - normalized.push('@'); - normalized.push_str(symbol); cursor = symbol_end; } @@ -532,6 +572,35 @@ mod tests { )); assert!(normalized.contains("@__hugr__.other_helper.16")); } + + #[test] + fn normalize_quoted_function_local_helper_symbol() { + // Function-local Guppy declarations qualify the symbol with a `` + // segment, whose angle brackets force LLVM to quote the whole symbol + // (`@"..."`). The normalizer must handle the quoted form, not skip it. + let llvm = concat!( + "declare { i64, i64 } @\"__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23\"(i64, i64)\n", + "%1 = call { i64, i64 } @\"__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23\"(i64 %0, i64 %2)\n", + "%q = call i64 @\"__hugr__.m.f..pecos_qis_trace_metadata_qubit_hugr.7\"(i64 %0, i8* %1, i8* %2)\n", + "%x = call i64 @\"__hugr__.m.f..other_helper.9\"(i64 %0)\n", + ) + .to_string(); + let normalized = normalize_pecos_helper_symbols_in_llvm(llvm); + assert!( + normalized + .contains("declare { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64, i64)") + ); + assert!(normalized.contains( + "%1 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %2)" + )); + assert!(normalized.contains( + "%q = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" + )); + // PECOS helpers lose the private prefix entirely (no `` residue). + assert!(!normalized.contains(".pecos_qis_")); + // A non-PECOS quoted symbol is left untouched (still quoted). + assert!(normalized.contains("@\"__hugr__.m.f..other_helper.9\"")); + } } /// Compile HUGR bytes to LLVM IR string From 60209e243e46b5284234da3602a53cf3e36755d9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 19:26:56 -0600 Subject: [PATCH 296/388] Normalize PECOS helper symbols at the LLVM module level so both text IR and bitcode export the public ABI symbols (round-10 Finding B) --- crates/pecos-hugr-qis/src/compiler.rs | 224 +++++++----------- .../tests/fixtures/szz_barrier_probe.hugr | Bin 0 -> 3253 bytes .../pecos-hugr-qis/tests/test_compilation.rs | 37 +++ 3 files changed, 118 insertions(+), 143 deletions(-) create mode 100644 crates/pecos-hugr-qis/tests/fixtures/szz_barrier_probe.hugr diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 963863a8c..03662874f 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -378,6 +378,11 @@ fn compile<'c, 'hugr: 'c>( // Create a new LLVM module using hugr-llvm let module = get_module_with_std_exts(args, ctx, namer, hugr)?; + // Rewrite PECOS helper declarations to their public ABI symbols in the module + // itself, so both the text and bitcode outputs link against pecos-qis-ffi's + // `pecos_qis_*` exports (a text-only rewrite would miss the bitcode path). + normalize_pecos_helper_symbols_in_module(&module); + // Get the target machine let target_machine = if let Some(ref triple) = args.target_triple { get_target_machine_from_triple(triple, args.opt_level)? @@ -424,17 +429,16 @@ fn compile<'c, 'hugr: 'c>( Ok(module) } -fn is_llvm_symbol_char(ch: char) -> bool { - ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | '$' | '-') -} +/// PECOS-owned QIS runtime ABI helpers. Guppy/HUGR lowers these under a private +/// `__hugr__.*` symbol; they must be rewritten to their stable public names so the +/// compiled module links against the `pecos_qis_*` symbols `pecos-qis-ffi` exports. +const PECOS_HELPER_SYMBOLS: &[&str] = &[ + TRACE_METADATA_HUGR_SYMBOL, + TRACE_METADATA_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, +]; -/// Normalize PECOS-owned helper declarations that Guppy/HUGR lowers under a -/// private `__hugr__.*` symbol name. -/// -/// These helpers are part of PECOS's runtime ABI, not ordinary user functions, -/// so they need stable external symbols for dynamic linking. Keep this rewrite -/// deliberately narrow: only PECOS-owned QIS helper declarations receive this -/// treatment. /// If `symbol` is a PECOS-owned helper lowered under the private `__hugr__.*` /// namespace, return its stable public name; otherwise `None`. /// @@ -453,77 +457,27 @@ fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Opt .find(|helper| *helper == helper_name) } -fn normalize_pecos_helper_symbols_in_llvm(llvm_ir: String) -> String { - let helper_symbols = [ - TRACE_METADATA_HUGR_SYMBOL, - TRACE_METADATA_QUBIT_HUGR_SYMBOL, - RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL, - RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL, - ]; - let mut normalized = String::with_capacity(llvm_ir.len()); - let mut cursor = 0; - - while let Some(relative_start) = llvm_ir[cursor..].find('@') { - let start = cursor + relative_start; - normalized.push_str(&llvm_ir[cursor..start]); - let after_at = start + 1; - - // Quoted LLVM symbol: `@"..."`. Guppy quotes the private symbol whenever - // the qualified name contains characters illegal in a bare LLVM identifier - // -- notably the `` segment of a function-local declaration. The - // bare-identifier scan below would skip these (a `"` is not a symbol char), - // leaving the `__hugr__.*` name unnormalized, so handle the quoted form - // explicitly. A literal `"` inside is LLVM-escaped as `\22`, so the next - // unescaped `"` always terminates the symbol. - if llvm_ir[after_at..].starts_with('"') { - let body_start = after_at + 1; - if let Some(rel_close) = llvm_ir[body_start..].find('"') { - let body_end = body_start + rel_close; - let symbol = &llvm_ir[body_start..body_end]; - if let Some(public) = pecos_helper_public_name(symbol, &helper_symbols) { - // The public name is a valid bare identifier, so drop the quotes. - normalized.push('@'); - normalized.push_str(public); - } else { - normalized.push_str("@\""); - normalized.push_str(symbol); - normalized.push('"'); - } - cursor = body_end + 1; - continue; - } - // Unterminated quote (malformed IR): emit the `@` and keep scanning. - normalized.push('@'); - cursor = after_at; - continue; - } - - let symbol_start = after_at; - let symbol_len = llvm_ir[symbol_start..] - .chars() - .take_while(|ch| is_llvm_symbol_char(*ch)) - .map(char::len_utf8) - .sum::(); - if symbol_len == 0 { - normalized.push('@'); - cursor = symbol_start; - continue; - } - - let symbol_end = symbol_start + symbol_len; - let symbol = &llvm_ir[symbol_start..symbol_end]; - if let Some(public) = pecos_helper_public_name(symbol, &helper_symbols) { - normalized.push('@'); - normalized.push_str(public); - } else { - normalized.push('@'); - normalized.push_str(symbol); +/// Rewrite PECOS helper declarations from their private `__hugr__.*` symbol to the +/// stable public `pecos_qis_*` name, in the LLVM module itself. +/// +/// Renaming the `FunctionValue`s (rather than rewriting printed text) means every +/// output format -- LLVM IR text AND bitcode -- carries the public ABI symbol that +/// `pecos-qis-ffi` exports. It also sidesteps text-only quoting: a function-local +/// Guppy declaration's raw module symbol +/// (`__hugr__....pecos_qis_..._hugr.`) is unquoted here, so the +/// same `pecos_helper_public_name` match applies. Renaming a function updates all of +/// its call sites automatically (they reference the value, not the name). +fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) { + for func in module.get_functions() { + let public = func + .get_name() + .to_str() + .ok() + .and_then(|name| pecos_helper_public_name(name, PECOS_HELPER_SYMBOLS)); + if let Some(public) = public { + func.as_global_value().set_name(public); } - cursor = symbol_end; } - - normalized.push_str(&llvm_ir[cursor..]); - normalized } #[cfg(test)] @@ -531,75 +485,59 @@ mod tests { use super::*; #[test] - fn normalize_trace_metadata_helper_symbol() { - let llvm = concat!( - "declare void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8*, i8*)\n", - "call void @__hugr__.pecos_qis_trace_metadata_hugr.16(i8* %0, i8* %1)\n", - "call void @__hugr__.__main__.pecos_qis_trace_metadata_hugr.18(i8* %0, i8* %1)\n", - "declare i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64, i8*, i8*)\n", - "%q = call i64 @__hugr__.pecos_qis_trace_metadata_qubit_hugr.19(i64 %0, i8* %1, i8* %2)\n", - "%q2 = call i64 @__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21(i64 %0, i8* %1, i8* %2)\n", - "%q3 = call i64 @__hugr__.pecos_qis_runtime_barrier_qubit_hugr.22(i64 %0)\n", - "%q4 = call i64 @__hugr__.__main__.pecos_qis_runtime_barrier_qubit_hugr.23(i64 %0)\n", - "%q5 = call { i64, i64 } @__hugr__.pecos_qis_runtime_barrier_qubits2_hugr.24(i64 %0, i64 %1)\n", - "%q6 = call { i64, i64 } @__hugr__.__main__.pecos_qis_runtime_barrier_qubits2_hugr.25(i64 %0, i64 %1)\n", - "call void @__hugr__.other_helper.16()\n", - ) - .to_string(); - let normalized = normalize_pecos_helper_symbols_in_llvm(llvm); - assert!(normalized.contains("declare void @pecos_qis_trace_metadata_hugr(i8*, i8*)")); - assert!(normalized.contains("call void @pecos_qis_trace_metadata_hugr(i8* %0, i8* %1)")); - assert!( - normalized.contains("declare i64 @pecos_qis_trace_metadata_qubit_hugr(i64, i8*, i8*)") + fn pecos_helper_public_name_matches_private_helper_symbols() { + // Bare, module-level declaration. + assert_eq!( + pecos_helper_public_name( + "__hugr__.pecos_qis_trace_metadata_hugr.16", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_trace_metadata_hugr"), ); - assert!(normalized.contains( - "%q = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" - )); - assert!(normalized.contains( - "%q2 = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" - )); - assert!( - normalized.contains("%q3 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") + // Module-qualified (e.g. `__main__`) declaration. + assert_eq!( + pecos_helper_public_name( + "__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_trace_metadata_qubit_hugr"), ); - assert!( - normalized.contains("%q4 = call i64 @pecos_qis_runtime_barrier_qubit_hugr(i64 %0)") + // Function-local declaration: the raw module symbol carries a `` + // segment (this is the case guppylang 0.21.11 produces; in LLVM text it is + // additionally quoted, but the module symbol seen here is unquoted). + assert_eq!( + pecos_helper_public_name( + "__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_runtime_barrier_qubits2_hugr"), + ); + assert_eq!( + pecos_helper_public_name( + "__hugr__.m.f..pecos_qis_runtime_barrier_qubit_hugr.7", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_runtime_barrier_qubit_hugr"), ); - assert!(normalized.contains( - "%q5 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %1)" - )); - assert!(normalized.contains( - "%q6 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %1)" - )); - assert!(normalized.contains("@__hugr__.other_helper.16")); } #[test] - fn normalize_quoted_function_local_helper_symbol() { - // Function-local Guppy declarations qualify the symbol with a `` - // segment, whose angle brackets force LLVM to quote the whole symbol - // (`@"..."`). The normalizer must handle the quoted form, not skip it. - let llvm = concat!( - "declare { i64, i64 } @\"__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23\"(i64, i64)\n", - "%1 = call { i64, i64 } @\"__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23\"(i64 %0, i64 %2)\n", - "%q = call i64 @\"__hugr__.m.f..pecos_qis_trace_metadata_qubit_hugr.7\"(i64 %0, i8* %1, i8* %2)\n", - "%x = call i64 @\"__hugr__.m.f..other_helper.9\"(i64 %0)\n", - ) - .to_string(); - let normalized = normalize_pecos_helper_symbols_in_llvm(llvm); - assert!( - normalized - .contains("declare { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64, i64)") + fn pecos_helper_public_name_ignores_non_helpers() { + // A non-PECOS helper under the private prefix is left alone. + assert_eq!( + pecos_helper_public_name("__hugr__.m.f..other_helper.9", PECOS_HELPER_SYMBOLS), + None, + ); + // A symbol that is not under the private prefix is not a candidate. + assert_eq!( + pecos_helper_public_name("pecos_qis_trace_metadata_hugr", PECOS_HELPER_SYMBOLS), + None, + ); + // Too few components to carry a `.` tail. + assert_eq!( + pecos_helper_public_name("__hugr__.foo", PECOS_HELPER_SYMBOLS), + None, ); - assert!(normalized.contains( - "%1 = call { i64, i64 } @pecos_qis_runtime_barrier_qubits2_hugr(i64 %0, i64 %2)" - )); - assert!(normalized.contains( - "%q = call i64 @pecos_qis_trace_metadata_qubit_hugr(i64 %0, i8* %1, i8* %2)" - )); - // PECOS helpers lose the private prefix entirely (no `` residue). - assert!(!normalized.contains(".pecos_qis_")); - // A non-PECOS quoted symbol is left untouched (still quoted). - assert!(normalized.contains("@\"__hugr__.m.f..other_helper.9\"")); } } @@ -634,9 +572,9 @@ pub fn compile_hugr_bytes_to_string_with_options( let module = compile(args, &context, &mut hugr) .map_err(|e| PecosError::Generic(format!("Compilation failed: {e}")))?; - // Get the module string + // Get the module string (PECOS helper symbols are already normalized to their + // public names in `compile`). let mut llvm_str = module.to_string(); - llvm_str = normalize_pecos_helper_symbols_in_llvm(llvm_str); // Workaround: Manually add the EntryPoint attribute if it's missing // This is needed because inkwell sometimes doesn't properly serialize string attributes diff --git a/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_probe.hugr b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_probe.hugr new file mode 100644 index 0000000000000000000000000000000000000000..8dc9b8b90f88257be36850106550af0fa834bc3c GIT binary patch literal 3253 zcmV;m3`+AzRYy{3NJ@4BK`6B^{Qy`MWdI7Bm>*Q27}a!uPt~--hYD}2w2P32x8P~u zQZ+3pu=3L%W-qY=>Y5%~Zhw>A<2G6gHBwCt!_!!{4-ZrCALKOjhOi{faAQ>7`z?)R`zjX$R1E8xhXE#7864 z-3GP#Swcr8RDcrdPv31OH#H*a)QYG>98sq>q#BV#MPi9MH7BYPPgHlUiB8I?MN#K_ zb=RN}sq;(fG^r%C&QGb^r_}i@N-K4KOD%m%-M*!|ze*>8&v&WwU+VTRwe&C5{ati4 zzWqy`kJ=ulx<8C=#5&R-w_nL0~vqk#zO{F++&n!0^W zb$^+QRn-Vey6&BPKSWc_o?%7-5%HZxw^jvUCQTs zb#5Q4?*G#j;M>Qx532LW>i)9MmSHsPlc)`66iG!S+5n=u&*X z2z5RQb%xMMIY;QE+@6HGJ&;=ZA9X&E=_aJ`!U>&}D!fqV%TQgIp~hfCoqt2EAxNE{ zL!B?A&flTBKMtLg^LME8d#Lk$sPloS?vFwzrOE@T+qY2XuTbZ=P~HE7PD&9!;bv4c z!ZLIi8SCgp2};WMdE+J+)E6RDYv5Zk0iQXP_qGqJQj)y%v+XyiQkFPZ`X5zF5|?tm zP^BEXl$uX&8*#_^8DvQL-25O_N)a!;kSgVL72ksc4@8ynxstDVBIXaNQofIVi7KVW zx#l??0hihVsEPnI005{;3n~B$8o&w)Kng8Ys)AAug^sw$q$V){pd1P{A<+~$euINv z7wV#g4nRUjB6Q`1Y9u;GD9cjQN+1M<3VSJ1KDWI0cv3xnB&9Q zKiQa*fpCqN;FQGC)R1@#Vv(op8owOMibAI(uBE@xCv$U(jT>}422Gc80v{2fDJ~_A zr+BGSDIf3|FF9?%t8*@84Oc-qigH4wWWZ5QsEJFdfSdS6qEaH@Gkhaak`gMV0iMF8 zEWk@(+~g!MeB@HTa4F?>VF{d)xc7I>rF`yl8SWNN1{ygFPDz~R)SZ)3<7WBys5K#+ zlDIItLe1hdI3;m2&p@j^%0Tb5LGNc3X3Z7qADoi7{Jq@X=Gma9WwX#Xn2R`YO5&sh zwRph_@9eSD#spgfk5yeUhC?iq z&9?otX4yOjVVSm|%(W_+7q-Ogx;?Dw_OL2z>e1ug;ZP#KDAWm+dWkuuWSnAlz=$6P z!@NNc28M-!_?yXOBkWp|qEK%f<{A>rFq;afkE6sao9)xa`g@&P-p;X~?S(i7)ZWHh zhp5=NK|BUo6VZ)1rA{%|sxGKKG0Z!zHQn!8mCDUA4Bj3+MIxv@6cNl@D6d?e#j%D0 zdRH%RQI25{MFLmh+6}7t4UO7xpR~H>EiMy-y8EOoo>I}2t5TGP`=kkY%6-z5r&4sq zWq2w@U;M?LgvM|#V#J^=JO=mTKIsZ~apAsE70#lCwy;o^6}r-bwpc+~_=*&o;#CFJ z+$WXNC*AyHqbI(lpedZhv$J$4TZJB$O_51sd=}#Efnx?5=kD$#)VY!-qT%?EAy2}I z5sv$i0fYN+5_tSa3{OGDSEy3DxXa0v+y)QgLVO4JVe=q1FT#g-67GaoDP8!~OsbRw zcu7lHsZw4qX)Z4)6m7-=Ha4wBMZ2j|w)k{kq4|`w>;+XRt(UY})jX(DviPQnp#4D6 zfK080D&+vaX);XhhAQQXm$V$5MGK+{sZySJN$a7c0Z|%KwJ1(#3YQU$l9~}*rQ94P zK14)%TfMI}eRzX8=7m`%lwsH~|LhFY+lnrij0=a?n#7g|Vw()q%0De|Tp4h{_CW6i z18UHUp0)XB+m~lEsBzegV^1$4h&>k$E$G<@Z0t*0F@G{|e`CyMmUVrj<^jXP#B}(w zyETSe1oh18!#}H-4qqY|j@!OI{9|9*hM5%`K`UbG$}up|3wL*|>dN-*VO@FS@xwc^ z%`6MzmfiSgTTpwVm_wORwli65CR%KBsCW0lK)h;spJv5oA`cG_&(6-yj*bo;I&^gC z(BWh9>FKemEAr4G4l|e`n1wmsr(Z9flarH^!vk}{Tpdr>bwfi#v)N2XNGOXL=cc8< zPv$0vH)v>RczAetc6N4re6G&5CRC3#`COiBO))(>Iyy5qH)AZOTXzU&W(a2nMWiDn zgmZ{fL8wEG?Mqu&#<4ZHHJGxhn}<9R%yF&iiXDip-k@h+Vxwrc2aXoQuzMB4MjDIl z4)YFB&#W>Kj?K3B;W5ZYP!7u1nuuE9_*#XThyJcLW%0u=tGcrggC5K0T9ePrg@xGV zS<|hc$ExnNrVGKfs=H^d9F7}tX4waD$k-3J zJea1y1!63QS*EWw5fKsHnR~a_@;;eRf%&K*J*A9{h=_=YNRlM!@dSi8rkHeB4-()Y zS(d{Xh(ZtuVIXDn)=W9Siulq#lmdq+YKf@F64BzU z!z3m=sOAf%eB^H>^(r8pg<_(*>BU0;8ITy)pW2J~*{(v9L!BLj%PP(V??8B)$wK8I z)~Mo9r(T-rjR}Nn>HqgiO$>9=*&VhMEafZ3M6gk30s@QRG2WN6CBC z8r+(jnpF-cCW(M0T!eKlqL9#1{j!eTT56j#J$QWE6&$Vb6@v2P3Z_6L`>fHEM1;=A z6c%Nm6+*YlP_|^RCSP#xW3<(l(O;84& z1OA8tUQVh5C5S1N)T(kqr(Td*F19ENB7n{SS`uXo;gkcv!L51wb?jfH0p`xII$^w$ zL$Cegz?7uHc69AwXU%=mG>Lb;oL1RzdLrM~;-^uCPJOI45xmI)_z(sCG>1Sc~Q z-JJb9leur2iYX#}%cDhO^uHf4+>iLoa)E4MIGHpmval}|qrtGMJL8=HeL%J>K9i1A zD&|X6`ocW;W#B+kqFt0bd7TQ?-@>j;L`bc8a7n->uPq(+db$=ZLq+K>>okC4RtZ!e z7q8u3yX3OnIIrD}kwj`ydzW;v^%Q>F)+Lgy5?ZVbiTsV%h9E;JL}~`v`JajN_>4>$ zDPZ14!B4TP>5S+qN#%;3r2`-dMIcSK?RB1KiHk*SC$nC3rvTW~1z$|0t8M} z(ZS=Z(VeI74h)38sPo_)IGph2DRe%E!kV_Yz+6RGWC`v1Eulv-HAlzw zqFF5X&N_t6lZikZAO;%XxQWcfjO`9}px&1vi` + // segment under the private `__hugr__.*` namespace; BOTH the text IR and the + // bitcode must expose the public `pecos_qis_runtime_barrier_qubits2_hugr` ABI + // symbol that pecos-qis-ffi exports, not the private name. (Regression guard for + // the bitcode path, which a text-only normalization would miss.) + let hugr = include_bytes!("fixtures/szz_barrier_probe.hugr"); + + let text = compile_hugr_bytes_to_string(hugr).expect("text compilation should succeed"); + assert!( + text.contains("@pecos_qis_runtime_barrier_qubits2_hugr("), + "text IR is missing the public helper symbol" + ); + assert!( + !text.contains(".pecos_qis_runtime_barrier_qubits2_hugr"), + "text IR still carries the private helper symbol" + ); + + let bitcode = compile_hugr_bytes_to_bitcode(hugr).expect("bitcode compilation should succeed"); + // LLVM stores symbol names contiguously in the bitcode string table, so a byte + // scan is reliable. The private form embeds the helper under `.`; the + // entry function `barrier_pair_probe` is also function-local but does not match + // this needle, so it is a clean discriminator. + let private = b".pecos_qis_runtime_barrier_qubits2_hugr"; + let public = b"pecos_qis_runtime_barrier_qubits2_hugr"; + assert!( + !bitcode.windows(private.len()).any(|w| w == private), + "bitcode still carries the private helper symbol" + ); + assert!( + bitcode.windows(public.len()).any(|w| w == public), + "bitcode is missing the public helper symbol" + ); +} From 6e0fed4a6631a0c103a4e8fc0b0a3c940d0145bb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 20:02:41 -0600 Subject: [PATCH 297/388] Merge duplicate PECOS helper declarations to one public ABI symbol instead of an LLVM-suffixed name (round-11 Finding B) --- crates/pecos-hugr-qis/src/compiler.rs | 36 ++++++++++++++--- .../tests/fixtures/szz_barrier_collision.hugr | Bin 0 -> 3610 bytes .../pecos-hugr-qis/tests/test_compilation.rs | 37 ++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) create mode 100644 crates/pecos-hugr-qis/tests/fixtures/szz_barrier_collision.hugr diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 03662874f..25d780f5b 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -467,15 +467,41 @@ fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Opt /// (`__hugr__....pecos_qis_..._hugr.`) is unquoted here, so the /// same `pecos_helper_public_name` match applies. Renaming a function updates all of /// its call sites automatically (they reference the value, not the name). +/// +/// The same helper can be declared in more than one scope within a single compiled +/// HUGR (Guppy emits one private declaration per scope), so two declarations can +/// normalize to the same public name. Renaming both would make LLVM uniquify the +/// second to `.1`, which `pecos-qis-ffi` does not export. To keep exactly one +/// unsuffixed public symbol, merge duplicates: redirect a duplicate's uses to the +/// function already holding the public name and erase the duplicate. fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) { - for func in module.get_functions() { - let public = func + // Collect first: we rename and (on collision) delete functions below, so we must + // not hold the module's function iterator while mutating the function list. + let funcs: Vec<_> = module.get_functions().collect(); + for func in funcs { + let Some(public) = func .get_name() .to_str() .ok() - .and_then(|name| pecos_helper_public_name(name, PECOS_HELPER_SYMBOLS)); - if let Some(public) = public { - func.as_global_value().set_name(public); + .and_then(|name| pecos_helper_public_name(name, PECOS_HELPER_SYMBOLS)) + else { + continue; + }; + + match module.get_function(public) { + // The public ABI symbol is already owned by an equivalent declaration: + // fold this duplicate into it so the module keeps a single unsuffixed + // public symbol. + Some(canonical) if canonical != func => { + func.replace_all_uses_with(canonical); + // SAFETY: all uses were just redirected to `canonical`, so this + // declaration is now unreferenced and safe to remove. + unsafe { func.delete() }; + } + // Already named with the public symbol (idempotent). + Some(_) => {} + // First occurrence: claim the public name. + None => func.as_global_value().set_name(public), } } } diff --git a/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_collision.hugr b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_collision.hugr new file mode 100644 index 0000000000000000000000000000000000000000..5c5ae79fb6883ce895c2d13bfd1e5b42972ed82e GIT binary patch literal 3610 zcmV+#4(0JkRYy{3NJ@4BK`6B^{Qy`!Z~$7c;3HO`lGWzIv7`%ou)`dJ5o);y zK)vITNq=@hJ^ELU^&V2({6LPZkOE8F1eju za=R@KEgS8YhwGNf?JSe~SthsJ?$Dyqon>;}2GQ9cZny2BHKRNG!*x4EXM?!i)`!-N zc00s%+vI+>$?a^D+iivD>t4E@m+t4K7u|X3x~qJCy4~)r7VWNb-T!`nyS-~6(C#gr zyWG!RUUVmOKZm&=0Ie3?IZWp=*FDAUcHB~R=P5d$x$ZQ#+f}qw?Y_G1F0PBtU)*kQ z(Q4@ga@={k&vUN34R3dyyMWHE!E@bn+#R6nE_8pw8$rJA;+55|d(m}Iulvn*yY5KW z-N$ve;C8!=R;{1k+|I^z*SXzp)2h||#dU{q-EXdY&h7TERqKoY?HuO1yIgme+wDEI zmf8fKlo+QL77c+32n!Svr&X=Cou?a!J3#3<88X^iCMjL38@aWfx>l~;>s-mL6{S1H zld!1OdhR#eT21}=l3VLZvpAC(9>lHHdY?B5Z{nrXt+mvq(%)J+uena4C4tZf0X+dT zuVR2t=@ZJEPF{c)5ud~7dv-oWr)U)5;{g7p!~-zmVfw|ymvDIiNc@PF|L(K=pLEH8 zB{kx`l~+N1H?}x?5XGOsACvMY(+A+atNaPdmtQNwh6i5EW$bUvY^lJ4k$^Z2C z438qocVCik;nn)UkspWri;(vrKjYQvE&biXqagAfk^hLi7kT&Eg?}mXDR}S>kjp%ldl!lCtYeUwT`yjqq6XoV=7TtGjlc>$Yy$mS%-tcW=egC zVxf)95kil))F%^#I=w+ZY7AqlD$CL_o3rC`QQyVF&P*NOvnVFNBr40QjFORmOBI-g(v9#UHM#-F1K|ji? zl`PK*ULl&hrb0tot&AmM(2w%QvV^I|7_}^6Dl`+JNk{!CkDRHpS;i=s(T_4|sY!t| z0hO6;0W&tL6)aRXJ7~@^B+ZPHB8>`6Bg64#v!yf;qa<-Xy)sKQG;@qfX4+Bz5beKy zpc?XD7 z;0R*Y3z%ym3HAs|GWGQ8BujDSBr|H1U$V|;T0(!8{nrn^Yz32C{kayBD+Khry6X?S zrsiJ}xITh*{rpe6aPd6d;`vtd;CLLZ(`L3N2Rs3~{cSW$b2-m-4)+Um``_mJM9WBh zcl*n9Oe4@WHD7bU*Yp83Z$P)di>~SRuj!hahiRLxsrdrBfNqpG(J8NhZhsOzfB@ay zv$V^JKOyrnF&_i-FecsviFaudE#K0Y<+*PETHC*C5#5XEF6C1)tTb&evJ_8o9Lv~i zwUUu(X{v0=-==S7h>B!N0q1phH_ErytwUpA-iF-?8en~`C*2^+*J{1yo`d0LZmqUI zZWY7Pe4GmyFdPr&@(XwCI3L^voCePYDZ#PeaY(oloC+*BB1K*}Bezx*-POI1Q^MuI z@xU?Rg6Oy=oh_-nONv|TiS8`T3dXIK>(A!s+*-l@Y!0As#pc?rRi;0ib{6Ty`na{? zcKhSj+D*d#upC$*D;5N|LT;@w-B}gf7P++s(4RdLV~toOw^pw|OT%Yx7-4^CDY3Y8 zG$}Wx94GMv>DDTuSKP;nThRHBTdPWc&OzruZmp>HJ|A*x4MIprC5lTF#Hlev$y^i7 zY%|I#u$hO@Oe;}*%49dP&;)VnjEQ8plZj$^C`P8Q76zaqDb8rflEkNoaUMePOvITi zZ{Xa6RWm0Uin7qeq=;?o;8}^!JlaHbOlvs9uxw0A9x57nWA_$hC_H(Vl%^PDT&Vz%d!oP6iu`RVissa!5&za(J&pu9T{gBCc_v;!yy!t zrFfp%v>9c5v(;`V4skY1v^CPm@D&rs5>~sNE|<#%1Ox>1qeF*|A00Y$2&IIAf-nm_ zd6Z^oW-`;bnLgrJ&Ss^LkB^U!izcJVTCP^Bb#!!eyWM__jPjX#cw*%^-tc6Tff_Yx zxLht55D<`%5cmV))G7$4gg_UFQ#L_AKR-A;JZY^~#=xT#XD>7JC`S;d@;Qbx%mQy_NQy8a5T}H2K$;oKM4SR~ z1Ys5k;uJ^HfK$+DTEAxRTY5T}rOU=|oV#TtlHpz6dW4-}I*^FT8UFL2`qk6}!t zl@G|sFg(p{D?yw>LP7#@KoAvU;%GC<0Wk}N$;h;XfjA`;gjwKO3(e4GyP`jWO*CU?mVrGm3+#beIMpMlyFH=_{_96-aJ5pcRs@10s3P+cB7zK|85t27 ziINTi3K)n*K^Td#v;z{rU}2c!Ac#VUA;d642r+~ZLx_M7LdcMbNKqcVM+!-ig%jy{ zY$Ob!(iUL^>z3Oc7%6=jKw9>&06SE;T*SU3>{s?JYrY;OI_^=is|M3oKOl`~IY?Yt zD1j2$;|j%S4lC(koWn6$19pU<;adr;6fSvzp;;3W?wc%(*wL2^6bzrbfP5G9Et-|b zz|eit_oe!=PV3`lJV8T5=C+)c=N_U^`+fau|1a7`z^Z<*tT=aF)*di1Lc7hBg<7v~#KOL8OTfX-q%3#I!cveYhf? zX&=h7Au4c0)PzKb!Tw+06KZMW3+g^Xd?iZQL0Y=vM62l?g8=$U%{Gd-&0jfOvErhex4)hk+!LCTLGwM`Z2kE#A7M3f zBehvHaN^FU-QO)~*ybrI)?K0KnI;kEh^a&rLh2>S)8hqcf_o}0Mx`13Xc8H;Qz{9| zZa@kRU>$PksS~q}TBBd0QWMJ@G5w*o1n*cwRTLjurjT`_)^G0|9#+;6@aZ=%i^;W=P2$SG5)b+6%3JGLK0hf)9BLpGmejj&ku{aI41?mbAlo;Ch1aQANbT3>2MTjI;JJ&7Djl0Mph~LK`^M?@P$4S21H) z+Jp$`R$<^+PL2b-PioOj4lwFOC?cH}N8ON7T|dBZJ_-QK4cfrqJ85XL;3qYqU_k4x zb8zQg6hjG}6o*l`gfq$VU>knc%XOSiXidaRV; zb$Z;ZO~6+-3CaR3UZaV2ENfe2yjC=}C^basW5BYtk_+8X7sj>fSFy|`ei|butL&kx z&}8HDaL2j#jx>byiCdz$oOuQHi18XY13O{I0N}=gkVPXE^%T3&iwf+iP7qL9VZjt6 zA-l)7EhFGOnaJ(TwoNoFl#~>5lOYs*ye>PM>L7Vfq(ew(=#(!YwgK8rdOmSR0kEKc zJxWaCT3D~Ou#0fNOy8Btvsh%1NtGiAW~qb>&v$|(jF!bTJkKSO8^5y!JP#V`$2k1Y zCU15mfFNTpe?7=1=n#P$&Vkrnj96UI7max(_4c=mwV&8@6EpY%j#Ee-$b;v$OzVR! zu@hwBMK;Jd4p-*@#|iGpzg{?c9OOq(HCClDTkKNA-xG3|G$W^i2wHNnJV?Wj3mO5W z<0uhhaKcYJ)05pJiY+~TIO*01rw=f3!n1eO#Jzr#Eq)21tYh;qwDXet#$NMGP)USW z?u2sT=Ai2v`iJF?W~m#`z#hA2J)v3y19W)K31G)`$6RGX@GZ?vdQrkEumHEKp#Q{E zsW=UQXml(9j>}ygbhFrdcWaHo>C{aZivAB8OPSZ67p8YP7N+6~$A#5yAA6Ag)&u9k z!XxM>%r1-zy|+4@eO!?O-dJ43Dte!t%7r4BawC@68YMj;bwl9p!KA5QW2XszoV^94 gIBH)6_^DSO0bgZnfKR4o` residue. + assert!( + !text.contains("pecos_qis_runtime_barrier_qubits2_hugr."), + "text IR has a suffixed or private helper symbol" + ); + + let bitcode = compile_hugr_bytes_to_bitcode(hugr).expect("bitcode compilation should succeed"); + let suffixed_or_private = b"pecos_qis_runtime_barrier_qubits2_hugr."; + let public = b"pecos_qis_runtime_barrier_qubits2_hugr"; + assert!( + !bitcode + .windows(suffixed_or_private.len()) + .any(|w| w == suffixed_or_private), + "bitcode has a suffixed or private helper symbol" + ); + assert!( + bitcode.windows(public.len()).any(|w| w == public), + "bitcode is missing the public helper symbol" + ); +} From 52aeaf16f5e412261015e6acc26b515a6a0d800b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 20:26:41 -0600 Subject: [PATCH 298/388] Reject conflicting PECOS helper signatures instead of silently merging into an ABI-mismatched symbol (round-12 Finding B) --- crates/pecos-hugr-qis/src/compiler.rs | 24 ++++++++++++---- .../fixtures/szz_barrier_wrong_signature.hugr | Bin 0 -> 3528 bytes .../pecos-hugr-qis/tests/test_compilation.rs | 27 ++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 crates/pecos-hugr-qis/tests/fixtures/szz_barrier_wrong_signature.hugr diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 25d780f5b..2cf2581d3 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -381,7 +381,7 @@ fn compile<'c, 'hugr: 'c>( // Rewrite PECOS helper declarations to their public ABI symbols in the module // itself, so both the text and bitcode outputs link against pecos-qis-ffi's // `pecos_qis_*` exports (a text-only rewrite would miss the bitcode path). - normalize_pecos_helper_symbols_in_module(&module); + normalize_pecos_helper_symbols_in_module(&module)?; // Get the target machine let target_machine = if let Some(ref triple) = args.target_triple { @@ -474,7 +474,7 @@ fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Opt /// second to `.1`, which `pecos-qis-ffi` does not export. To keep exactly one /// unsuffixed public symbol, merge duplicates: redirect a duplicate's uses to the /// function already holding the public name and erase the duplicate. -fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) { +fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) -> Result<()> { // Collect first: we rename and (on collision) delete functions below, so we must // not hold the module's function iterator while mutating the function list. let funcs: Vec<_> = module.get_functions().collect(); @@ -489,10 +489,23 @@ fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) { }; match module.get_function(public) { - // The public ABI symbol is already owned by an equivalent declaration: - // fold this duplicate into it so the module keeps a single unsuffixed - // public symbol. + // The public ABI symbol is already owned by another declaration: fold this + // duplicate into it so the module keeps a single unsuffixed public symbol. Some(canonical) if canonical != func => { + // The two declarations must be ABI-identical before merging. LLVM 21 + // opaque pointers let a call disagree with its callee declaration's + // type without failing `module.verify()`, so a mismatched redeclaration + // would silently emit a call to the public `pecos_qis_*` symbol with the + // wrong signature. Reject it here instead of shipping ABI-broken IR. + if func.get_type() != canonical.get_type() { + return Err(anyhow!( + "conflicting signatures for PECOS helper `{public}`: a HUGR \ + declares it as `{}` and also as `{}`. Declare the helper with a \ + single signature matching the pecos-qis-ffi ABI.", + canonical.get_type(), + func.get_type(), + )); + } func.replace_all_uses_with(canonical); // SAFETY: all uses were just redirected to `canonical`, so this // declaration is now unreferenced and safe to remove. @@ -504,6 +517,7 @@ fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) { None => func.as_global_value().set_name(public), } } + Ok(()) } #[cfg(test)] diff --git a/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_wrong_signature.hugr b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_wrong_signature.hugr new file mode 100644 index 0000000000000000000000000000000000000000..5241b938453077deed96dfd24f528963dde755cf GIT binary patch literal 3528 zcmV;(4L9;gRYy{3NJ@4BK`6B^{Qy|4Z2&r{s3KO7($(fHplpCBBm~(IMA<}9x`851 zibB#2L6k`i1qILz(4Ex;$DxT?8htMr^`dR5O)$#;e}JQh86Hw!g@kkN>f168X9*3Z zhZ{pemQxxmrmdu%WCsEW0u};3t_8{vzsB%Jip(?`CWBm=JWVXhz|tiXM>5EjfAB12 zT77Btn+m4YWG`nY6Fu;&DFkr^aO+~dkK&=gP7Rf0gSa(rc4 zs=8bvh-WU>QH}Xh%r|LTZ3VnH5K`A|>e9-016jGen!aRu6{c^0Zy>d+&SR@%%Q?2y zvjw27KC_BsPQ+Z1oc?2`N|;RMpR*lAxzJy@nQ! zuHM8}cVazvVmWtWyZwgNjrMeCu^I^LITXt|6x;1Qv}!cxP;B)moC9II-G`Q{IR}FC zD7Lx~w%dbfp}IN|wz?E6h4U%4+ly$OXd|lBYT`VvVr%W%o&?F-P+F_W*6Lze-qw1; zb}6hEV{2t$VO-c+W7t|{*jiV%yTWKuTIJitY9sT9m?g6U%uIw)zuWy$9RvGqflrH3~H?FIYsN(L+iuXp}lI zttj=bHT`0PeL?yShfMnsy$YRIlUPbyn$lWiDK#~vTi1?b>DH-CXi(~1txqharS>Wm zOX*6Rs8JfIL|96_^J=50Q);bZDJ6A@M6#4_U2pwDi-&^O!TA>iyo(mk(xsIr^#B2I z-?DiiKK|>ipV9%ozXCjriVpyQSCQfaK=A;ac+b9x|CJ`yfBF8qmv=cm_tQT4Wmx=; zd6xBQ_7=PNZ&Q3I=btZ5>IK4#?deBqCl5u zt|`6IF5I7*QdXb3#q^ZcXd2yOdX}biMyHxZM#Jb*Q~IJQr6vqV(0}T@y|<>+yRa!~ zzHIVOsa5Ddbz6UTN9oqpvYY3){m_5v$P6P#nTDbN)YUrwxXhC#f+L?Y*@&Xq&bI(#Hrp?CJ5eWUKE*!_6RLB4w$DWj# zf6Swy{!=H$QkfiI7&Op-Dg|cB#A6ssy+AY1d`ytUDsco&Y>wn=cA6qSfi8;ZQ>DW1 zvS4QYs=c$`FfWHbqbdj4Kmy zSfkNS*>_o3U!Gy{D{@4RCMGijRLnA2>`QS3UrI5XLfHlDT%NGke=eurQJ!J(C8sTD z)y!A0lk+>BGvtT#0YQGIyZ(=^`SCUVqsRAX37UbR5(Hg~?)p30L~k^|HO=-7bl11N zHH%(g=DWMTOsf)jRVSc&1iI@V(5mkGs#eW>Os`rs^D#O`H_Ok={E9~P%*@YpF}mxc zXcsNIxi`@%SUd}vmx=k96~6+-r)U%>{zO|7zjfEAwCi(Sg8LH9wR}snv^Y9gf?+tm zW~^Z1Ig+VLs;}xAiA_*EG1$89?q>P!yv?-Pw3MNqVw*~DPpNk;Y$WTVv;EALg4s!RdtxbN z(M@aTZ9;5Pwj(ww&AEy5_Y^TMR+iG+p4-S_DV1r@A?Ok0#dENwRDkvz1Uegq8}a-D z&P9N6B$m>cuAYLWwA=F}FYZG1C6-c{=G>^d6HDoe_Phrff8tOqrCoa-#d9L8S26J~ zE>1Q|JxqAzw3L?Ums)$NL|o;-QtHxPJ;GHameNxPJVZpJc}MfRs1TcCo`Gbh31t|U z%syt8jpi+3upBTJ-_2biD;#$<@2cuVl!>|@>-c(XYAxY_2W zVw55VVVJxkcp(6CW8*DzNCVCAoZZ}WVc|Ij!MHMJBN@Bq>mLWbT=r;w|xoWtB_>Nwf7`)<-kHA{UlTe1@1vzPaa$_;pR}V_tCt%QBNR zp?qt!S*^VIW^y!Jp-jZC7Z{E`vs$U6qob3PlaqrZM2HX^Awq=kpm=tIMiP@*%sXo2 z4H%BG*m!eub9193gUMhmomQ(gG&EGJ)o_G_a+z&vUSk;MsLA95Jb3WX(b3V#$;sj2 z`Eb6ATJ2mE&!_WUlxhbD2ZyGn=A6yO*EeKCGh{=vBEk_8!kM)BL5}2zZ*07>?86*I za};M>)|ZFCQVjB4)>pPfd`D9RZ;aVlUK140i(y>TMaoVd7hjXiM8uJ%W`ZF;fph?6 zp!{7F(MvpIE;0v=;dfEF3~}tTz8Z-|u!rZnC?1+mMq(IvQQwc9%lf{H`gVMm^_ex; zcTq$|F6(=h;Ox7oFIC<#$h>0CL1yl`4*1A%L;M0|;{-4fn!*mLsSEz2LcKZh$TUosj;*J65vpA7~>d-LWm&* zDMJV`gb+iBfDl5+kcmi99=t~iNs)yU>3D1;451P>A$fF*3LK=8hD;QO<9wngzbBU5?aHGI~vaoSekE;g$OhhF##9C z(&@Qy`dy=tf%jjw;NA2 zK66-@lb%DKg)&0Rt33Kf993HVRX^v*1Rz9mMtXZ~WH7Btu77}`NlTczmZ9Xz&~7nIYD))~)(+VSk^ zx(K&lIc}`@>CZQ`{wv(&+BrhwmGnz}>6w45n$bvY7pysRk9l_*Dkp((9k8! zL6|KJ`Hb`N$>vVl3Ba^zD&HiV`1|rDF{)q)mhUBO|5O=RjgwqqD-%jIlLMqV7>Y=@ z)zLI%l$H+w+>Zoixs5b1dz0pkEX1T56-+hVB5p+4hnL*qGiph#Fg{tPPtw7!jsppv zy(k#x6}(hu5j&Jrgw%Y2i<1MDZRwo=PS4S0V4F^A{Remp2{20*8M%19Hf?vX?MiwL zX*^tNv?5*hWJ@2H+$a}Ex9a;?U=kRH1j9~=Q;IaHep!XJvO&|C6@kS#T#$P#rT6Sj%fp4 zo@WpOFB1wA{d?KM4G&vN-Ym%&ZfwW)r|P4Mp81L4u+T_Y*l%aQ9mNDJCc&n%cByJ%2pIqx_uY^TT$EL3DRVBah!W0_rNcthDcT;wzAL8NF3Rth`A~0=He2yP* zv0>9kH&aYwusL5ud(t^H89q3KlFfUYR(mCDI-nyg)G(dxg_{oq?Im(v%`bX?2wIp;YXTQ=)#U@lJ>onQbP^FT8?U5|`&5{h=nvR?8w;)xV zA;cjsz&IHt#Va5oFdZ*Dj|gh|1w#ZYZ7horL96+Mlj+K$*^qrTledU7Gz+b`sL=|$ Cb-~d9 literal 0 HcmV?d00001 diff --git a/crates/pecos-hugr-qis/tests/test_compilation.rs b/crates/pecos-hugr-qis/tests/test_compilation.rs index 7e7aacf29..4f586e2b0 100644 --- a/crates/pecos-hugr-qis/tests/test_compilation.rs +++ b/crates/pecos-hugr-qis/tests/test_compilation.rs @@ -209,3 +209,30 @@ fn duplicate_helper_declarations_merge_to_one_public_symbol() { "bitcode is missing the public helper symbol" ); } + +#[test] +fn conflicting_helper_signatures_fail_loud() { + // Two declarations that normalize to `pecos_qis_runtime_barrier_qubit_hugr` with + // DIFFERENT signatures (`i64 -> i64` vs `i64 -> { i64, i64 }`). Merging them would + // emit a call to the public ABI symbol that disagrees with the pecos-qis-ffi + // export's signature -- and LLVM 21 opaque pointers + `module.verify()` do not + // catch it -- so compilation must fail loud instead of shipping ABI-broken IR. + let hugr = include_bytes!("fixtures/szz_barrier_wrong_signature.hugr"); + + let text = compile_hugr_bytes_to_string(hugr); + assert!( + text.is_err(), + "text compilation should fail on conflicting helper signatures" + ); + let msg = text.unwrap_err().to_string(); + assert!( + msg.contains("conflicting signatures") + && msg.contains("pecos_qis_runtime_barrier_qubit_hugr"), + "unexpected error message: {msg}" + ); + + assert!( + compile_hugr_bytes_to_bitcode(hugr).is_err(), + "bitcode compilation should fail on conflicting helper signatures" + ); +} From c4ab9e58d40c1bd774512b9fb054e7da1026781c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 21:33:07 -0600 Subject: [PATCH 299/388] Validate PECOS helper declarations against the fixed pecos-qis-ffi ABI, rejecting any wrong signature (round-13 Finding B) --- crates/pecos-hugr-qis/src/compiler.rs | 87 +++++++++++++----- .../szz_barrier_single_wrong_abi.hugr | Bin 0 -> 2768 bytes .../pecos-hugr-qis/tests/test_compilation.rs | 43 +++++++-- 3 files changed, 98 insertions(+), 32 deletions(-) create mode 100644 crates/pecos-hugr-qis/tests/fixtures/szz_barrier_single_wrong_abi.hugr diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 2cf2581d3..40d65cc54 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -37,13 +37,15 @@ enum ExactlyOneError { } use tket::hugr::envelope::EnvelopeConfig; use tket::hugr::llvm::extension::int::IntCodegenExtension; +use tket::hugr::llvm::inkwell::AddressSpace; use tket::hugr::llvm::inkwell::OptimizationLevel; -use tket::hugr::llvm::inkwell::context::Context; +use tket::hugr::llvm::inkwell::context::{Context, ContextRef}; use tket::hugr::llvm::inkwell::module::Module; use tket::hugr::llvm::inkwell::passes::PassBuilderOptions; use tket::hugr::llvm::inkwell::targets::{ CodeModel, InitializationConfig, RelocMode, Target, TargetMachine, TargetTriple, }; +use tket::hugr::llvm::inkwell::types::FunctionType; use tket::hugr::llvm::utils::fat::FatExt as _; use tket::hugr::llvm::{ CodegenExtsBuilder, @@ -457,6 +459,38 @@ fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Opt .find(|helper| *helper == helper_name) } +/// The fixed LLVM signature each PECOS helper must have, matching the +/// `pecos-qis-ffi` ABI export (`crates/pecos-qis-ffi/src/ffi.rs`): +/// +/// - `pecos_qis_trace_metadata_hugr(*const u8, *const u8)` -> `void (ptr, ptr)` +/// - `pecos_qis_trace_metadata_qubit_hugr(i64, *const u8, *const u8) -> i64` +/// -> `i64 (i64, ptr, ptr)` +/// - `pecos_qis_runtime_barrier_qubit_hugr(i64) -> i64` -> `i64 (i64)` +/// - `pecos_qis_runtime_barrier_qubits2_hugr(i64, i64) -> QubitPair{i64,i64}` +/// -> `{ i64, i64 } (i64, i64)` +/// +/// Returns `None` only if `helper` is not a recognized PECOS helper (a programming +/// error here, since callers pass a name already matched against `PECOS_HELPER_SYMBOLS`). +fn expected_pecos_helper_type<'ctx>( + ctx: &ContextRef<'ctx>, + helper: &str, +) -> Option> { + let i64t = ctx.i64_type(); + let ptr = ctx.ptr_type(AddressSpace::default()); + let ty = match helper { + TRACE_METADATA_HUGR_SYMBOL => ctx.void_type().fn_type(&[ptr.into(), ptr.into()], false), + TRACE_METADATA_QUBIT_HUGR_SYMBOL => { + i64t.fn_type(&[i64t.into(), ptr.into(), ptr.into()], false) + } + RUNTIME_BARRIER_QUBIT_HUGR_SYMBOL => i64t.fn_type(&[i64t.into()], false), + RUNTIME_BARRIER_QUBITS2_HUGR_SYMBOL => ctx + .struct_type(&[i64t.into(), i64t.into()], false) + .fn_type(&[i64t.into(), i64t.into()], false), + _ => return None, + }; + Some(ty) +} + /// Rewrite PECOS helper declarations from their private `__hugr__.*` symbol to the /// stable public `pecos_qis_*` name, in the LLVM module itself. /// @@ -468,13 +502,17 @@ fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Opt /// same `pecos_helper_public_name` match applies. Renaming a function updates all of /// its call sites automatically (they reference the value, not the name). /// -/// The same helper can be declared in more than one scope within a single compiled -/// HUGR (Guppy emits one private declaration per scope), so two declarations can -/// normalize to the same public name. Renaming both would make LLVM uniquify the -/// second to `.1`, which `pecos-qis-ffi` does not export. To keep exactly one -/// unsuffixed public symbol, merge duplicates: redirect a duplicate's uses to the -/// function already holding the public name and erase the duplicate. +/// Because this code claims the public ABI symbol, it also enforces the ABI: every +/// recognized helper declaration is validated against its fixed `pecos-qis-ffi` +/// signature. LLVM 21 opaque pointers let a call disagree with its callee declaration's +/// type without failing `module.verify()`, so a helper declared with the wrong +/// signature -- even a lone, self-consistent one -- would otherwise silently emit a +/// call to the public symbol with the wrong ABI. We fail loud instead. This also makes +/// duplicate declarations safe to merge: any two that pass the ABI check are identical, +/// so the duplicate's uses can be redirected to the first (avoiding LLVM uniquifying a +/// second declaration to `.1`, which `pecos-qis-ffi` does not export). fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) -> Result<()> { + let ctx = module.get_context(); // Collect first: we rename and (on collision) delete functions below, so we must // not hold the module's function iterator while mutating the function list. let funcs: Vec<_> = module.get_functions().collect(); @@ -488,24 +526,27 @@ fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) -> Result<()> { continue; }; + // Enforce the fixed PECOS helper ABI on every recognized declaration before + // claiming the public symbol. + let Some(expected) = expected_pecos_helper_type(&ctx, public) else { + return Err(anyhow!( + "internal error: no ABI signature registered for PECOS helper `{public}`" + )); + }; + if func.get_type() != expected { + return Err(anyhow!( + "PECOS helper `{public}` is declared as `{}`, but the pecos-qis-ffi ABI is \ + `{}`. Declare it with the exported signature.", + func.get_type(), + expected, + )); + } + match module.get_function(public) { - // The public ABI symbol is already owned by another declaration: fold this - // duplicate into it so the module keeps a single unsuffixed public symbol. + // The public ABI symbol is already owned by another declaration. Both passed + // the ABI check above, so they are identical: fold this duplicate into it so + // the module keeps a single unsuffixed public symbol. Some(canonical) if canonical != func => { - // The two declarations must be ABI-identical before merging. LLVM 21 - // opaque pointers let a call disagree with its callee declaration's - // type without failing `module.verify()`, so a mismatched redeclaration - // would silently emit a call to the public `pecos_qis_*` symbol with the - // wrong signature. Reject it here instead of shipping ABI-broken IR. - if func.get_type() != canonical.get_type() { - return Err(anyhow!( - "conflicting signatures for PECOS helper `{public}`: a HUGR \ - declares it as `{}` and also as `{}`. Declare the helper with a \ - single signature matching the pecos-qis-ffi ABI.", - canonical.get_type(), - func.get_type(), - )); - } func.replace_all_uses_with(canonical); // SAFETY: all uses were just redirected to `canonical`, so this // declaration is now unreferenced and safe to remove. diff --git a/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_single_wrong_abi.hugr b/crates/pecos-hugr-qis/tests/fixtures/szz_barrier_single_wrong_abi.hugr new file mode 100644 index 0000000000000000000000000000000000000000..7d6366369ba59e333fa74a16ea97d7f3b54893ab GIT binary patch literal 2768 zcmV;>3NQ6YRYy{3NJ@4BK`6B^{Qy|)RRH>b%o$3+R#6+covnXG$#&NjB7YZc+la5o zR9|I(x+{Oz+hC#);aaaORH}8qll{~cxhh0sz=6n7SB!#B`jtE}6D$BGXY8PX2<+dq z1A_sA0gVAqZ?-L4vhnrgg?qV^GIGBiC`4O z4#q|tjAxK@9D`*W*!9R^B8Y>DTb6Gs6&Or|*am|wQJKmjQ-4OwYD%c4=TY(gTb^a5 z{`0J0CfZV2S}KB;`lf1n9_rD5V(CM%PFhmZmtyHpvHu_CEuAX%fBHyCXN#o*ES=SM zvsyY|?EmyyIN!||OQ%dX57_^SYEAfV9C9O=mF)jK(ULM|mayGiVd?C#bgr=fGeb*C zm2c6b0z~C;kkA}UX|$}QG(GM478n+SN}V32>?SWVu4fS8Ykn2;IaW%Te@Q*mqga8h zlm#C3;CGLU`O!-G^7Lg`DgRMl<8S;9D<$f??_pnvmC`=yi#*I1c_UWJ0gw74s8`Z` zlhaeLdnha|Eix-BD`odC(}L$RbGoTxr93@qmgr`Wm9phM)XY{&052uYSt(gw%K6mm zR{mpC29A^_kIoxbN|i@V9ac*E1SXFU^M{qvHT!l zvp_+OK~N18s03>DTq-qH0Rvd7=TZfnYJh*K;WJpMFa_!hssdoCDz*-o^+fB6sdg>( zJt-^T>flvTuS#)k;7LhswR`#cx9Xs?Rf?vO_2kKR$P()GsBB}`k4KyjDR189>94$$sgLk0 zHSge6Udje9rR+0o;_JyX{rrC^Ox<3wxMSZ&&sJv|E*T46@{7`6$KpC%?leb z#G2=>K^vM|(>ToCZe#7c0G9afnCA{s=Ng6!_4O+;-*R2m@eXM!8Q8OpsDm8e=nZz< zH(%MtuJMSwDW&g5ML~7*UDl^JDDwQ`yQweyE-U8CHmtv-p(Rmo&_&d_A7$9=vp813 zsUOTFzol%$+GXY4e_AIzfuA!~=4WPVf~WbJnL6V=_@@=-8F>HqA3!DW2V|;Df|>xP z9(b6S`tp`Qo&U5-`LuRFpsNGE3s3_*fam$f5@sTp3=CuA4OY{e8^Ll|)1U~$SGLDf=VY+>{0mNy4=*L9-S6(-O@+AYYy)||9c7x! zGf~gA-LE*C>C7LV7y0Jc)mU~rChAvjP!u!29p#(5*>d8= zH}lRzo-S z`dr^l-3$c<1?}|oj5gbreMmk#NIp9&rJ$oDoVkw*zWK(Nwr?$`=v()#DO}cZpHF#@mqj)>0$BSuhhw<7-;^NzRexdgcEEi#0u7e>SaUpOSm%p1*lIf8K z>$`r%|FRqcP@+R~RPNJY^61M+kT?fjP}b&9;v&03DF?%f?;8F%YH9Fd3^X(? zL7u|JNig&ULNWxDgl&pe%0PWtHJi(@0i=4r@U64TtD=Y;J5Ov&5HBVT?U4 zE*!g8P7Etv$2s8EzX*3zhEZrPXKSj{4a~8qX11p`PPU`CTeEw`N^)`YG@5mnGk2*l z$vL7;WOisNi%2Pz@ z7LQhlQI~$eBtJ3=mg~~M$c{9AvaBKXqhMs|<~bp8AKZu+pFtzlgz;r%eaQ}f2@a$s z*+p4%UcjarU$8UNAf(MOB^vOiA%Q6Q;x(Jr9?95VlCMh} z4<@zC%KU`c$_T%1!6n>QsRpbxiMWrQV{#4(x0}Gne3s9-x*q{4>6`q5)}CU0(#ch8I<_J_};d(smG4=)SWPm%j@oQ%>p99 literal 0 HcmV?d00001 diff --git a/crates/pecos-hugr-qis/tests/test_compilation.rs b/crates/pecos-hugr-qis/tests/test_compilation.rs index 4f586e2b0..bac97b269 100644 --- a/crates/pecos-hugr-qis/tests/test_compilation.rs +++ b/crates/pecos-hugr-qis/tests/test_compilation.rs @@ -212,27 +212,52 @@ fn duplicate_helper_declarations_merge_to_one_public_symbol() { #[test] fn conflicting_helper_signatures_fail_loud() { - // Two declarations that normalize to `pecos_qis_runtime_barrier_qubit_hugr` with - // DIFFERENT signatures (`i64 -> i64` vs `i64 -> { i64, i64 }`). Merging them would - // emit a call to the public ABI symbol that disagrees with the pecos-qis-ffi - // export's signature -- and LLVM 21 opaque pointers + `module.verify()` do not - // catch it -- so compilation must fail loud instead of shipping ABI-broken IR. + // Two declarations that normalize to `pecos_qis_runtime_barrier_qubit_hugr`, one + // with the wrong signature (`i64 -> { i64, i64 }` vs the ABI `i64 -> i64`). The + // wrong declaration must be rejected against the fixed pecos-qis-ffi ABI -- LLVM 21 + // opaque pointers + `module.verify()` do not catch a call that disagrees with the + // export -- so compilation must fail loud instead of shipping ABI-broken IR. let hugr = include_bytes!("fixtures/szz_barrier_wrong_signature.hugr"); let text = compile_hugr_bytes_to_string(hugr); assert!( text.is_err(), - "text compilation should fail on conflicting helper signatures" + "text compilation should fail on a wrong-ABI helper declaration" ); let msg = text.unwrap_err().to_string(); assert!( - msg.contains("conflicting signatures") - && msg.contains("pecos_qis_runtime_barrier_qubit_hugr"), + msg.contains("pecos-qis-ffi ABI") && msg.contains("pecos_qis_runtime_barrier_qubit_hugr"), "unexpected error message: {msg}" ); assert!( compile_hugr_bytes_to_bitcode(hugr).is_err(), - "bitcode compilation should fail on conflicting helper signatures" + "bitcode compilation should fail on a wrong-ABI helper declaration" + ); +} + +#[test] +fn single_wrong_helper_signature_fails_loud() { + // A SINGLE declaration of a recognized helper with a self-consistent but wrong ABI + // (`pecos_qis_runtime_barrier_qubits2_hugr` declared `i64 -> i64` instead of the + // exported `(i64, i64) -> { i64, i64 }`). There is no sibling to compare against, + // so this is caught only by validating the lone declaration against the fixed + // pecos-qis-ffi ABI. Both text IR and bitcode compilation must fail loud. + let hugr = include_bytes!("fixtures/szz_barrier_single_wrong_abi.hugr"); + + let text = compile_hugr_bytes_to_string(hugr); + assert!( + text.is_err(), + "text compilation should fail on a lone wrong-ABI helper declaration" + ); + let msg = text.unwrap_err().to_string(); + assert!( + msg.contains("pecos-qis-ffi ABI") && msg.contains("pecos_qis_runtime_barrier_qubits2_hugr"), + "unexpected error message: {msg}" + ); + + assert!( + compile_hugr_bytes_to_bitcode(hugr).is_err(), + "bitcode compilation should fail on a lone wrong-ABI helper declaration" ); } From d56ab076e6d174659341a35e107925e959631552 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 28 Jun 2026 22:22:27 -0600 Subject: [PATCH 300/388] Clarify the generic-barrier xfail reason: name the supported SZZ runtime-barrier path and why it stays strict --- python/quantum-pecos/tests/qec/test_from_guppy_dem.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index cb0abfdd8..a19f7724e 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -98,9 +98,13 @@ def forbidden_stabilizer(): @pytest.mark.xfail( reason=( - "Guppy public barrier(...) is currently optimized away before PECOS " - "QIS operation collection; hosted SZZ prefix scheduling needs a " - "barrier-preserving or hosted-operation lowering path." + "Guppy's generic public barrier(...) is optimized away (tket DCE) before PECOS " + "collects QIS operations, so no Barrier survives this path. The supported " + "barrier-preserving path is the SZZ runtime-barrier helper " + "(szz_runtime_barriers=...), covered by " + "test_szz_runtime_barrier_survives_into_qis_operation_trace below. strict=True so " + "this XPASSes (and must be removed) if a future tket/Guppy lowering ever " + "preserves the generic barrier." ), strict=True, ) From fa6ab6ec94c9ab01e6ded1737d6de3c2bd819e27 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 29 Jun 2026 21:21:45 -0600 Subject: [PATCH 301/388] Apply ruff/black formatting and fix ruff lint across never-linted SZZ/zlup/examples code --- docs/development/noise-event-replay.md | 1 - .../surface/compare_surface_sweep_json.py | 9 +- .../surface/dem_decomposition_diagnostics.py | 39 +++---- .../graphlike_dem_projection_benchmark.py | 6 +- .../surface/szz_circuit_quality_report.py | 3 +- exp/guppy-zlup/docs/examples/bell-state.md | 8 +- exp/guppy-zlup/docs/rules.md | 27 +++++ exp/guppy-zlup/ir.json | 2 +- exp/guppy-zlup/scripts/validate_guppy.py | 11 +- exp/zlup/docs/future/guppy-compat.md | 12 +- exp/zlup/examples/bell_state.json | 2 +- exp/zlup/examples/grover_2qubit.json | 2 +- exp/zlup/examples/qft_3qubit.json | 2 +- exp/zlup/examples/simple_qec.json | 2 +- exp/zlup/examples/teleportation.json | 2 +- exp/zlup/scripts/check_docs.py | 106 ++++++++++-------- exp/zluppy/README.md | 6 +- exp/zluppy/python/zluppy/__init__.py | 20 +++- exp/zluppy/tests/test_zluppy.py | 16 +-- .../src/pecos/_engine_builders.py | 7 +- .../quantum-pecos/src/pecos/guppy/surface.py | 57 +++------- .../quantum-pecos/src/pecos/qec/__init__.py | 2 +- .../pecos/qec/surface/_ancilla_batching.py | 5 +- .../qec/surface/_clifford_deformation.py | 15 +-- .../pecos/qec/surface/_detection_events.py | 10 +- .../src/pecos/qec/surface/_twirl_config.py | 5 +- .../src/pecos/qec/surface/circuit_builder.py | 12 +- .../src/pecos/qec/surface/decode.py | 87 +++++--------- .../quantum-pecos/src/pecos/quantum/hosted.py | 20 +--- .../tests/guppy/test_surface_twirl_render.py | 8 +- .../tests/qec/surface/test_check_plan.py | 15 ++- .../qec/surface/test_clifford_deformation.py | 12 +- ...test_logical_subgraph_region_comparison.py | 4 +- .../qec/surface/test_pauli_mask_harvest.py | 6 +- .../tests/qec/surface/test_surface_decoder.py | 30 ++--- .../qec/surface/test_surface_metadata.py | 4 +- .../tests/qec/test_dem_metadata_fail_loud.py | 2 +- .../tests/qec/test_from_guppy_dem.py | 24 +--- .../tests/qec/test_sim_neo_explicit_config.py | 9 +- .../qec/test_sim_neo_path_enumeration.py | 8 +- .../qec/test_sim_neo_subset_simulation.py | 14 +-- .../qec/test_traced_qis_clifford_pipeline.py | 8 +- .../qec/test_traced_qis_slow_integration.py | 14 ++- ruff.toml | 20 ++++ 44 files changed, 295 insertions(+), 379 deletions(-) diff --git a/docs/development/noise-event-replay.md b/docs/development/noise-event-replay.md index 3b8ed9955..02ec873c5 100644 --- a/docs/development/noise-event-replay.md +++ b/docs/development/noise-event-replay.md @@ -143,4 +143,3 @@ Recommended defaults: which should be explicitly tied to the lowered/traced circuit version? - How should traces link back to DEM contribution/source records when one sampled event corresponds to multiple detector/observable effects? - diff --git a/examples/surface/compare_surface_sweep_json.py b/examples/surface/compare_surface_sweep_json.py index 5bbf8b137..c7407bd7c 100644 --- a/examples/surface/compare_surface_sweep_json.py +++ b/examples/surface/compare_surface_sweep_json.py @@ -97,11 +97,7 @@ def jeffreys_interval(errors: int, shots: int, confidence: float = 0.95) -> tupl alpha = (1.0 - confidence) / 2.0 lower = 0.0 if errors == 0 else float(beta.ppf(alpha, errors + 0.5, shots - errors + 0.5)) - upper = ( - 1.0 - if errors == shots - else float(beta.ppf(1.0 - alpha, errors + 0.5, shots - errors + 0.5)) - ) + upper = 1.0 if errors == shots else float(beta.ppf(1.0 - alpha, errors + 0.5, shots - errors + 0.5)) return lower, upper @@ -123,8 +119,7 @@ def standard_error(errors: int, shots: int) -> float: def z_score(left: Point, right: Point) -> float: denom = math.sqrt( - standard_error(left.errors, left.shots) ** 2 - + standard_error(right.errors, right.shots) ** 2, + standard_error(left.errors, left.shots) ** 2 + standard_error(right.errors, right.shots) ** 2, ) if denom == 0.0: return math.nan diff --git a/examples/surface/dem_decomposition_diagnostics.py b/examples/surface/dem_decomposition_diagnostics.py index c1564562a..bde4bdfad 100644 --- a/examples/surface/dem_decomposition_diagnostics.py +++ b/examples/surface/dem_decomposition_diagnostics.py @@ -685,9 +685,7 @@ def run_case( ), ] decoders.extend( - _timed_decode(name, callback, shots) - for name, callback in graphlike_decoder_specs - if name in decoder_names + _timed_decode(name, callback, shots) for name, callback in graphlike_decoder_specs if name in decoder_names ) return CaseResult( @@ -710,15 +708,17 @@ def run_case( "terminal_decomposed": dem_stats(terminal_decomposed), }, decoders=decoders, - pair_analysis=two_fault_pair_analysis( - native_raw=native_raw, - native_decomposed=native_decomposed, - stim_decomposed=stim_decomposed, - terminal_decomposed=terminal_decomposed, - max_effects=pair_analysis_max_effects, - ) - if pair_analysis - else None, + pair_analysis=( + two_fault_pair_analysis( + native_raw=native_raw, + native_decomposed=native_decomposed, + stim_decomposed=stim_decomposed, + terminal_decomposed=terminal_decomposed, + max_effects=pair_analysis_max_effects, + ) + if pair_analysis + else None + ), ) @@ -737,8 +737,7 @@ def print_case(result: CaseResult) -> None: ) if raw.only_native == 0 and raw.only_stim == 0 and raw.max_rel_probability_diff > 0: print( - " raw structures match; probability deltas reflect " - "combination/rounding conventions.", + " raw structures match; probability deltas reflect combination/rounding conventions.", ) print("DEM stats:") @@ -852,13 +851,10 @@ def load_cached_results(path: Path) -> tuple[list[dict[str, Any]], dict[tuple[An """Load resumable diagnostic results from a previous JSON file.""" payload = json.loads(path.read_text(encoding="utf-8")) if not isinstance(payload, list): - raise ValueError(f"Expected a list of case results in {path}") + msg = f"Expected a list of case results in {path}" + raise TypeError(msg) results = [item for item in payload if isinstance(item, dict)] - by_key = { - key: result - for result in results - if (key := cached_case_key(result)) is not None - } + by_key = {key: result for result in results if (key := cached_case_key(result)) is not None} return results, by_key @@ -902,7 +898,8 @@ def parse_args() -> argparse.Namespace: def main() -> int: args = parse_args() if args.resume and args.save_json is None: - raise ValueError("--resume requires --save-json so completed cases have a source") + msg = "--resume requires --save-json so completed cases have a source" + raise ValueError(msg) results: list[CaseResult | dict[str, Any]] = [] cached_by_key: dict[tuple[Any, ...], dict[str, Any]] = {} diff --git a/examples/surface/graphlike_dem_projection_benchmark.py b/examples/surface/graphlike_dem_projection_benchmark.py index c1bab4fe5..9e90d979f 100644 --- a/examples/surface/graphlike_dem_projection_benchmark.py +++ b/examples/surface/graphlike_dem_projection_benchmark.py @@ -113,8 +113,7 @@ def build_case( ) print( - f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} " - f"p={p:g} shots={shots} ===", + f"\n=== d={distance} r={rounds} basis={basis} basis2q={interaction_basis} p={p:g} shots={shots} ===", flush=True, ) setup_timings: list[TimedValue] = [] @@ -177,8 +176,7 @@ def build_case( and raw_comparison["max_rel_probability_diff"] > 0 ): print( - "raw structures match; max_rel is a probability-combination/" - "rounding delta.", + "raw structures match; max_rel is a probability-combination/rounding delta.", flush=True, ) diff --git a/examples/surface/szz_circuit_quality_report.py b/examples/surface/szz_circuit_quality_report.py index 493661e79..cba6aac91 100644 --- a/examples/surface/szz_circuit_quality_report.py +++ b/examples/surface/szz_circuit_quality_report.py @@ -432,8 +432,7 @@ def build_case( and comparison["max_rel_probability_diff"] > 0 ): print( - "raw structures match; max_rel is a probability-combination/" - "rounding delta.", + "raw structures match; max_rel is a probability-combination/rounding delta.", flush=True, ) diff --git a/exp/guppy-zlup/docs/examples/bell-state.md b/exp/guppy-zlup/docs/examples/bell-state.md index a259ac50b..0f9d58977 100644 --- a/exp/guppy-zlup/docs/examples/bell-state.md +++ b/exp/guppy-zlup/docs/examples/bell-state.md @@ -11,10 +11,10 @@ A Bell state is a maximally entangled two-qubit state. Here's the Guppy code: # bell.py def bell() -> None: """Prepare and measure a Bell state |Φ+⟩ = (|00⟩ + |11⟩) / √2""" - q = qubit[2] # Allocate 2 qubits - h(q[0]) # Hadamard on first qubit - cx(q[0], q[1]) # CNOT: control=q[0], target=q[1] - m = measure(q) # Measure both qubits + q = qubit[2] # Allocate 2 qubits + h(q[0]) # Hadamard on first qubit + cx(q[0], q[1]) # CNOT: control=q[0], target=q[1] + m = measure(q) # Measure both qubits result("measurements", m) # Emit results to runtime ``` diff --git a/exp/guppy-zlup/docs/rules.md b/exp/guppy-zlup/docs/rules.md index 15842835b..c71616b5c 100644 --- a/exp/guppy-zlup/docs/rules.md +++ b/exp/guppy-zlup/docs/rules.md @@ -43,11 +43,13 @@ def bad(): while True: # ZLUP001: unbounded loop do_something() + # BAD: Effectively unbounded def also_bad(): while some_condition(): # ZLUP001: cannot prove termination do_something() + # GOOD: Bounded by break with finite iterations def acceptable(): for i in range(100): @@ -55,6 +57,7 @@ def acceptable(): break do_something() + # GOOD: Clear termination condition def good(): for i in range(n): # Bounded by n @@ -89,15 +92,18 @@ def factorial(n: int) -> int: return 1 return n * factorial(n - 1) # ZLUP002: recursive call + # BAD: Indirect recursion def ping(n: int) -> int: if n <= 0: return 0 return pong(n - 1) # ZLUP002: mutual recursion + def pong(n: int) -> int: return ping(n - 1) # ZLUP002: mutual recursion + # GOOD: Iterative version def factorial(n: int) -> int: result = 1 @@ -136,18 +142,21 @@ def bad(): q = qubit[2] # ZLUP003: allocation in loop h(q[0]) + # BAD: List growth in loop def also_bad(): results = [] for i in range(n): results.append(measure(q)) # ZLUP003: dynamic growth + # GOOD: Pre-allocate outside loop def good(): q = qubit[20] # Allocate once for i in range(10): h(q[i * 2]) + # GOOD: Fixed-size collection def also_good(n: int): q = qubit[n] @@ -184,18 +193,22 @@ def bad(obj, method_name: str): func = getattr(obj, method_name) # ZLUP004: dynamic dispatch func() + # BAD: eval def also_bad(code: str): eval(code) # ZLUP004: eval + # BAD: Subscript dispatch def dispatch(funcs, i: int): funcs[i]() # ZLUP004: dynamic dispatch + # GOOD: Static dispatch def good(obj): obj.known_method() # Static, known at compile time + # GOOD: Match/if for dispatch def also_good(choice: int): if choice == 0: @@ -230,10 +243,12 @@ error handling to prevent unexpected crashes. def bad(a: int, b: int) -> int: return a / b # ZLUP005: possible division by zero + # OK: Division by literal def ok(a: int) -> int: return a / 2 # Literal cannot be zero + # GOOD: Wrapped in try/except def good(a: int, b: int) -> int: try: @@ -241,6 +256,7 @@ def good(a: int, b: int) -> int: except ZeroDivisionError: return 0 + # GOOD: Explicit check def also_good(a: int, b: int) -> int: if b == 0: @@ -275,14 +291,17 @@ programs where runtime debugging is difficult. def bad(x: int): # ZLUP006: missing return type return x + 1 + # WARNING: Missing parameter type def also_bad(x) -> int: # ZLUP006: missing parameter type return x + 1 + # GOOD: Fully annotated def good(x: int) -> int: return x + 1 + # OK: self doesn't need annotation class Foo: def method(self, x: int) -> int: # self is fine @@ -341,15 +360,18 @@ def bad(x: int, y: int, z: int) -> int: # ... continues with more nesting # ZLUP007: complexity > 10 + # GOOD: Refactored into smaller functions def handle_positive_z(x: int, y: int) -> int: return 2 if x > y else 3 + def handle_positive_y(x: int, y: int, z: int) -> int: if z > 0: return 1 return handle_positive_z(x, y) + def good(x: int, y: int, z: int) -> int: if x <= 0: return 0 @@ -384,6 +406,7 @@ assertions and expressions simple and verifiable. def bad(): result = a(b(c(d(e())))) # ZLUP008: too deep + # GOOD: Intermediate variables def good(): e_result = e() @@ -425,6 +448,7 @@ def process(data): b = y + z return a + b # ZLUP009: no assertions + # GOOD: Has assertion for precondition def process(data): assert data is not None, "data must not be None" @@ -464,14 +488,17 @@ in concurrent execution (relevant for quantum-classical hybrid programs). # WARNING: Mutable global state counter = 0 # ZLUP010: lowercase module-level variable + def increment(): global counter # ZLUP010: global keyword counter += 1 + # GOOD: Constants allowed MAX_SIZE = 100 DEFAULT_VALUE = 42 + # GOOD: Pass state explicitly def increment(counter: int) -> int: return counter + 1 diff --git a/exp/guppy-zlup/ir.json b/exp/guppy-zlup/ir.json index 99ed6e744..ba6cd394a 100644 --- a/exp/guppy-zlup/ir.json +++ b/exp/guppy-zlup/ir.json @@ -93,4 +93,4 @@ } ], "source_file": "/tmp/test_nested_if.py" -} \ No newline at end of file +} diff --git a/exp/guppy-zlup/scripts/validate_guppy.py b/exp/guppy-zlup/scripts/validate_guppy.py index b7897aa6e..9747b42c6 100755 --- a/exp/guppy-zlup/scripts/validate_guppy.py +++ b/exp/guppy-zlup/scripts/validate_guppy.py @@ -13,9 +13,9 @@ 2 - File not found or other IO error """ -import sys -import json import importlib.util +import json +import sys from pathlib import Path @@ -41,7 +41,7 @@ def validate_guppy_file(filepath: str) -> tuple[bool, list[dict]]: compiled = [] for name in dir(module): obj = getattr(module, name) - if hasattr(obj, 'compile') and hasattr(obj, 'check'): + if hasattr(obj, "compile") and hasattr(obj, "check"): # This is a GuppyFunctionDefinition obj.compile() compiled.append(name) @@ -50,8 +50,6 @@ def validate_guppy_file(filepath: str) -> tuple[bool, list[dict]]: # No guppy functions found - just syntax checking passed return True, [] - return True, [] - except Exception as e: error_type = type(e).__name__ error_msg = str(e) @@ -65,11 +63,14 @@ def validate_guppy_file(filepath: str) -> tuple[bool, list[dict]]: # Parse guppy error details if available if "var=" in error_msg: import re + match = re.search(r"var='(\w+)'", error_msg) if match: error_info["variable"] = match.group(1) return False, [error_info] + else: + return True, [] def main(): diff --git a/exp/zlup/docs/future/guppy-compat.md b/exp/zlup/docs/future/guppy-compat.md index 702b90b4b..05633064f 100644 --- a/exp/zlup/docs/future/guppy-compat.md +++ b/exp/zlup/docs/future/guppy-compat.md @@ -55,7 +55,8 @@ while condition: # ❌ NOT Zlup-compatible while True: - if done: break + if done: + break # ✅ Zlup-compatible for i in range(100): @@ -63,7 +64,8 @@ for i in range(100): # ✅ Zlup-compatible (with early exit) for i in range(100): - if done: break + if done: + break ``` **Lint message:** @@ -85,9 +87,11 @@ help: replace with bounded loop ```python # ❌ NOT Zlup-compatible def factorial(n): - if n <= 1: return 1 + if n <= 1: + return 1 return n * factorial(n - 1) + # ✅ Zlup-compatible def factorial(n): result = 1 @@ -117,7 +121,7 @@ for round in range(100): # ✅ Zlup-compatible (fixed allocation) qubits = allocate(MAX_SIZE) for round in range(100): - use_qubits(qubits[:needed_size(round)]) + use_qubits(qubits[: needed_size(round)]) ``` ### 4. No Dynamic Python Features diff --git a/exp/zlup/examples/bell_state.json b/exp/zlup/examples/bell_state.json index 03371c30e..e7940139d 100644 --- a/exp/zlup/examples/bell_state.json +++ b/exp/zlup/examples/bell_state.json @@ -83,4 +83,4 @@ } ], "returns": [] -} \ No newline at end of file +} diff --git a/exp/zlup/examples/grover_2qubit.json b/exp/zlup/examples/grover_2qubit.json index 79ecd29ff..970bf7cc0 100644 --- a/exp/zlup/examples/grover_2qubit.json +++ b/exp/zlup/examples/grover_2qubit.json @@ -190,4 +190,4 @@ } ], "returns": [] -} \ No newline at end of file +} diff --git a/exp/zlup/examples/qft_3qubit.json b/exp/zlup/examples/qft_3qubit.json index 4d2ee878b..542aa09fc 100644 --- a/exp/zlup/examples/qft_3qubit.json +++ b/exp/zlup/examples/qft_3qubit.json @@ -195,4 +195,4 @@ } ], "returns": [] -} \ No newline at end of file +} diff --git a/exp/zlup/examples/simple_qec.json b/exp/zlup/examples/simple_qec.json index b50ca12ed..5bfe5396d 100644 --- a/exp/zlup/examples/simple_qec.json +++ b/exp/zlup/examples/simple_qec.json @@ -174,4 +174,4 @@ } ], "returns": [] -} \ No newline at end of file +} diff --git a/exp/zlup/examples/teleportation.json b/exp/zlup/examples/teleportation.json index 41fd8d5bd..faaf8576c 100644 --- a/exp/zlup/examples/teleportation.json +++ b/exp/zlup/examples/teleportation.json @@ -139,4 +139,4 @@ } ], "returns": [] -} \ No newline at end of file +} diff --git a/exp/zlup/scripts/check_docs.py b/exp/zlup/scripts/check_docs.py index 9c3279674..38182fa5e 100644 --- a/exp/zlup/scripts/check_docs.py +++ b/exp/zlup/scripts/check_docs.py @@ -13,11 +13,11 @@ """ import argparse -import glob import os import re import subprocess import sys +from pathlib import Path # --------------------------------------------------------------------------- # Config @@ -27,34 +27,45 @@ # Heuristic: lines starting with these indicate a complete (top-level) program _TOPLEVEL_PREFIXES = ( - "fn ", "pub ", "inline fn ", "@attr", "gate ", "declare gate", - "test ", "extern fn", + "fn ", + "pub ", + "inline fn ", + "@attr", + "gate ", + "declare gate", + "test ", + "extern fn", ) _TOPLEVEL_CONTAINS = ( - ":= struct", ":= enum", ":= error", ":= fault", - ":= union", ":= @import", + ":= struct", + ":= enum", + ":= error", + ":= fault", + ":= union", + ":= @import", ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- + def find_zlup_binary() -> str: """Locate the zlup binary (workspace target/debug or release).""" - script_dir = os.path.dirname(os.path.abspath(__file__)) - zlup_dir = os.path.dirname(script_dir) # exp/zlup + script_dir = Path(__file__).resolve().parent + zlup_dir = script_dir.parent # exp/zlup # Walk up to find workspace root (contains target/) candidate = zlup_dir for _ in range(5): - target = os.path.join(candidate, "target", "debug", "zlup") - if os.path.isfile(target): - return target - target_rel = os.path.join(candidate, "target", "release", "zlup") - if os.path.isfile(target_rel): - return target_rel - candidate = os.path.dirname(candidate) + target = candidate / "target" / "debug" / "zlup" + if target.is_file(): + return str(target) + target_rel = candidate / "target" / "release" / "zlup" + if target_rel.is_file(): + return str(target_rel) + candidate = candidate.parent # Fallback: hope it's on PATH return "zlup" @@ -64,25 +75,22 @@ def is_complete_program(source: str) -> bool: """Heuristic: does this snippet look like a complete top-level program?""" for line in source.splitlines(): stripped = line.strip() - if not stripped or stripped.startswith("//") or stripped.startswith("///"): + if not stripped or stripped.startswith(("//", "///")): continue # First non-comment line for prefix in _TOPLEVEL_PREFIXES: if stripped.startswith(prefix): return True - for pattern in _TOPLEVEL_CONTAINS: - if pattern in stripped: - return True - return False + return any(pattern in stripped for pattern in _TOPLEVEL_CONTAINS) return False def wrap_fragment(source: str) -> str: """Wrap a code fragment in a function body for parsing.""" # Replace `{ ... }` placeholder bodies with `{ }` so they parse - wrapped = re.sub(r'\{\s*\.\.\.\s*\}', '{ }', source) + wrapped = re.sub(r"\{\s*\.\.\.\s*\}", "{ }", source) # Also replace bare `// ...` comment-only placeholders - wrapped = re.sub(r'//\s*\.\.\.', '// placeholder', wrapped) + wrapped = re.sub(r"//\s*\.\.\.", "// placeholder", wrapped) return f"fn __snippet__() -> unit {{\n{wrapped}\nreturn;\n}}" @@ -90,13 +98,13 @@ def wrap_fragment(source: str) -> str: # Extraction # --------------------------------------------------------------------------- -FENCE_RE = re.compile(r'^```(\w+)?\s*$') +FENCE_RE = re.compile(r"^```(\w+)?\s*$") def extract_blocks(filepath: str) -> list[dict]: """Extract fenced code blocks from a markdown file.""" blocks = [] - with open(filepath, "r") as f: + with Path(filepath).open() as f: lines = f.readlines() in_block = False @@ -114,12 +122,14 @@ def extract_blocks(filepath: str) -> list[dict]: block_lines = [] else: if line.rstrip() == "```": - blocks.append({ - "file": filepath, - "line": start_line, - "tag": tag, - "source": "".join(block_lines), - }) + blocks.append( + { + "file": filepath, + "line": start_line, + "tag": tag, + "source": "".join(block_lines), + }, + ) in_block = False else: block_lines.append(line) @@ -131,6 +141,7 @@ def extract_blocks(filepath: str) -> list[dict]: # Checking # --------------------------------------------------------------------------- + def run_zlup(binary: str, cmd: str, source: str) -> tuple[bool, str]: """Run zlup check/parse on source via stdin. Returns (ok, output).""" try: @@ -140,13 +151,15 @@ def run_zlup(binary: str, cmd: str, source: str) -> tuple[bool, str]: capture_output=True, text=True, timeout=30, + check=False, ) - output = (result.stdout + result.stderr).strip() - return result.returncode == 0, output except subprocess.TimeoutExpired: return False, "TIMEOUT" except FileNotFoundError: return False, f"zlup binary not found: {binary}" + else: + output = (result.stdout + result.stderr).strip() + return result.returncode == 0, output def check_block(binary: str, block: dict) -> tuple[str, str]: @@ -184,17 +197,17 @@ def check_block(binary: str, block: dict) -> tuple[str, str]: # Main # --------------------------------------------------------------------------- + def main(): parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--verbose", "-v", action="store_true", - help="Show result for each snippet") + parser.add_argument("--verbose", "-v", action="store_true", help="Show result for each snippet") args = parser.parse_args() # Find docs directory relative to this script - script_dir = os.path.dirname(os.path.abspath(__file__)) - docs_dir = os.path.join(os.path.dirname(script_dir), "docs") + script_dir = Path(__file__).resolve().parent + docs_dir = script_dir.parent / "docs" - if not os.path.isdir(docs_dir): + if not docs_dir.is_dir(): print(f"Error: docs directory not found at {docs_dir}", file=sys.stderr) sys.exit(1) @@ -208,7 +221,7 @@ def main(): sys.exit(1) # Collect all markdown files - md_files = sorted(glob.glob(os.path.join(docs_dir, "**", "*.md"), recursive=True)) + md_files = sorted(str(p) for p in docs_dir.rglob("*.md")) if not md_files: print("No markdown files found in docs/", file=sys.stderr) sys.exit(1) @@ -223,7 +236,7 @@ def main(): status, detail = check_block(binary, block) counts[status] += 1 - rel = os.path.relpath(block["file"], os.path.dirname(docs_dir)) + rel = os.path.relpath(block["file"], docs_dir.parent) if args.verbose: tag_info = f"[{block['tag']}]" @@ -238,18 +251,19 @@ def main(): print(f" OK {rel}:{block['line']} {tag_info}") if status == "FAIL": - failures.append({ - "file": rel, - "line": block["line"], - "tag": block["tag"], - "detail": detail, - }) + failures.append( + { + "file": rel, + "line": block["line"], + "tag": block["tag"], + "detail": detail, + }, + ) # Summary total = counts["OK"] + counts["FAIL"] + counts["SKIP"] print() - print(f"check-docs: {total} snippets — " - f"{counts['OK']} ok, {counts['FAIL']} failed, {counts['SKIP']} skipped") + print(f"check-docs: {total} snippets — {counts['OK']} ok, {counts['FAIL']} failed, {counts['SKIP']} skipped") if failures: print() diff --git a/exp/zluppy/README.md b/exp/zluppy/README.md index a0e68128d..d19d23c9e 100644 --- a/exp/zluppy/README.md +++ b/exp/zluppy/README.md @@ -18,13 +18,15 @@ pip install zluppy import zluppy # Compile Zluppy source to SLR-AST (returns dict) -ast = zluppy.compile_to_slr(""" +ast = zluppy.compile_to_slr( + """ fn main() -> void { var q = qalloc(2); h(q[0]); cx(q[0], q[1]); } -""") +""" +) # Compile to SLR-AST JSON string json_str = zluppy.compile_to_slr_json(source) diff --git a/exp/zluppy/python/zluppy/__init__.py b/exp/zluppy/python/zluppy/__init__.py index b20163daa..f270c8975 100644 --- a/exp/zluppy/python/zluppy/__init__.py +++ b/exp/zluppy/python/zluppy/__init__.py @@ -7,13 +7,19 @@ >>> from pecos import hugr_engine >>> >>> # Compile and run a Bell state program - >>> hugr_bytes = zluppy.ZluppyEngine().source(''' + >>> hugr_bytes = ( + ... zluppy.ZluppyEngine() + ... .source( + ... ''' ... fn main() -> void { ... var q = qalloc(2); ... H(q[0]); ... CX(q[0], q[1]); ... } - ... ''').to_hugr_bytes() + ... ''' + ... ) + ... .to_hugr_bytes() + ... ) >>> >>> result = hugr_engine().hugr_bytes(hugr_bytes).to_sim().run(shots=100) """ @@ -75,13 +81,19 @@ class ZluppyEngine: through PECOS's hugr_engine. Example: - >>> result = zluppy.ZluppyEngine().source(''' + >>> result = ( + ... zluppy.ZluppyEngine() + ... .source( + ... ''' ... fn main() -> void { ... var q = qalloc(2); ... H(q[0]); ... CX(q[0], q[1]); ... } - ... ''').run(shots=100) + ... ''' + ... ) + ... .run(shots=100) + ... ) >>> print(result.to_dict()) Or with explicit steps: diff --git a/exp/zluppy/tests/test_zluppy.py b/exp/zluppy/tests/test_zluppy.py index 92f846492..77f71ed93 100644 --- a/exp/zluppy/tests/test_zluppy.py +++ b/exp/zluppy/tests/test_zluppy.py @@ -6,7 +6,6 @@ import zluppy - # ============================================================================= # Version Tests # ============================================================================= @@ -228,8 +227,6 @@ def test_parse_debug(): def test_parse_debug_error(): """Test that parse_debug raises on parse error.""" - source = "fn main() -> void { }" # Actually valid, use invalid syntax - with pytest.raises(zluppy.ZluppyError): zluppy.parse_debug("fn main( void { }") @@ -342,7 +339,7 @@ def test_slr_program_unknown_gate(): prog = zluppy.SlrProgram("test") prog.add_allocator("q", 1) - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="Unknown gate") as exc_info: prog.add_gate("UnknownGate", [("q", 0)]) assert "Unknown gate" in str(exc_info.value) @@ -532,7 +529,7 @@ def test_zluppy_engine_no_source_error(): """Test that to_hugr_bytes raises without source.""" engine = zluppy.ZluppyEngine() - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="No source compiled") as exc_info: engine.to_hugr_bytes() assert "No source compiled" in str(exc_info.value) @@ -593,7 +590,7 @@ def test_zluppy_engine_repr(): def test_zluppy_engine_file_not_found(): """Test ZluppyEngine raises on missing file.""" - with pytest.raises(OSError): + with pytest.raises(OSError, match="Failed to read"): zluppy.ZluppyEngine().file("/nonexistent/path/to/file.zlp") @@ -815,10 +812,7 @@ def test_zlup_program_compile_to_hugr(): def test_zlup_program_method_chaining(): """Test that ZlupProgram methods support chaining.""" prog = ( - zluppy.ZlupProgram("main") - .add_allocator("q", 2) - .add_gate("h", [("q", 0)]) - .add_gate("cx", [("q", 0), ("q", 1)]) + zluppy.ZlupProgram("main").add_allocator("q", 2).add_gate("h", [("q", 0)]).add_gate("cx", [("q", 0), ("q", 1)]) ) source = prog.to_source() @@ -895,7 +889,7 @@ def test_zlup_program_unknown_gate_error(): prog = zluppy.ZlupProgram("main") prog.add_allocator("q", 1) - with pytest.raises(ValueError) as exc_info: + with pytest.raises(ValueError, match="Unknown gate") as exc_info: prog.add_gate("unknown_gate", [("q", 0)]) assert "Unknown gate" in str(exc_info.value) diff --git a/python/quantum-pecos/src/pecos/_engine_builders.py b/python/quantum-pecos/src/pecos/_engine_builders.py index 3c0115e24..c73f7f919 100644 --- a/python/quantum-pecos/src/pecos/_engine_builders.py +++ b/python/quantum-pecos/src/pecos/_engine_builders.py @@ -233,12 +233,7 @@ def qis_engine() -> QisEngineBuilder: def _looks_like_library_path(value: str) -> bool: path = Path(value) - return ( - path.exists() - or "/" in value - or "\\" in value - or path.suffix in {".so", ".dylib", ".dll"} - ) + return path.exists() or "/" in value or "\\" in value or path.suffix in {".so", ".dylib", ".dll"} def _configure_selene_runtime(builder: object, runtime: object | None) -> object: diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index db4af438a..7693bb311 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -280,10 +280,7 @@ def generate_guppy_source( ancilla_schedule = ancilla_schedule_for_check_plan(resolved_plan) interaction_basis = resolved_plan.interaction_basis szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) - if ( - interaction_basis != "szz" - and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE - ): + if interaction_basis != "szz" and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE: msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" raise ValueError(msg) resolved_clifford_frame = _resolve_szz_clifford_frame_for_builder( @@ -677,10 +674,7 @@ def _szz_guppy_physical_prefix_for_pending( try: return _SZZ_FLOW_PHYSICAL_PREFIX_BY_PENDING[pending] except KeyError as exc: - msg = ( - "SZZ Guppy hosted-prefix lowering cannot lower pending " - f"Clifford {_szz_flow_clifford_name(pending)}" - ) + msg = f"SZZ Guppy hosted-prefix lowering cannot lower pending Clifford {_szz_flow_clifford_name(pending)}" raise ValueError(msg) from exc _szz_guppy_prefix_cache: dict[tuple[int, int], tuple[OpType, ...]] = {_SZZ_FLOW_IDENTITY: ()} @@ -853,20 +847,15 @@ def discharge_data_for_szz( if pending_by_data is None else pending_by_data.setdefault(data_q, _SZZ_FLOW_IDENTITY) ) - has_data_prefix = ( - pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) - ) + has_data_prefix = pending != _SZZ_FLOW_IDENTITY and not _szz_flow_is_virtual_z(pending) sign = szz_sign_by_touch[(stab_type, stab_idx, data_q)] host_gate = OpType.SZZ if sign > 0 else OpType.SZZDG host_label_core = f"r{rnd_idx + 1}:{stab_type}{stab_idx}:d{data_q}:{host_gate.name}" host_label = ( - f"szz:{host_label_core}" - if host_label_scope is None - else f"szz:{host_label_scope}:{host_label_core}" + f"szz:{host_label_core}" if host_label_scope is None else f"szz:{host_label_scope}:{host_label_core}" ) if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_ALL or ( - szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX - and has_data_prefix + szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_DATA_PREFIX and has_data_prefix ): target.append( f"{indent}{ancilla_expr(stab_type, stab_idx)}, {data_expr(data_q)} = " @@ -1089,9 +1078,7 @@ def discharge_data_for_szz( " ", rnd_idx, rnd_in_batch, - lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ - (stab_type, stab_idx) - ], + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[(stab_type, stab_idx)], _szz_data_expr, pending_by_data=szz_syndrome_pending_by_data, host_label_scope="syndrome_extraction", @@ -1289,13 +1276,13 @@ def append_init_syndrome_function(function_name: str, stab_type: str) -> None: " ", rnd_idx, rnd_in_batch, - lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ - (selected_type, stab_idx) - ], - _szz_data_expr, - pending_by_data=szz_init_pending_by_data, - host_label_scope=function_name, - ) + lambda selected_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ + (selected_type, stab_idx) + ], + _szz_data_expr, + pending_by_data=szz_init_pending_by_data, + host_label_scope=function_name, + ) if interaction_basis == "cx": if stab_type == "X": @@ -1513,9 +1500,7 @@ def _append_inline_szz_syndrome_extraction( indent, rnd_idx, rnd_in_batch, - lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[ - (stab_type, stab_idx) - ], + lambda stab_type, stab_idx, batch_anc_var=batch_anc_var: batch_anc_var[(stab_type, stab_idx)], _szz_data_expr, pending_by_data=pending_by_data, host_label_scope=host_label_scope, @@ -1556,10 +1541,7 @@ def _render_plain_szz_round_helper(round_idx: int) -> list[str]: f"def {helper_name}(surf: SurfaceCode_{dx}x{dz} @ owned) " f"-> tuple[SurfaceCode_{dx}x{dz}, Syndrome_{dx}x{dz}]:" ), - ( - f' """Extract counted SZZ syndrome round {round_idx} ' - 'with round-scoped hosted metadata."""' - ), + f' """Extract counted SZZ syndrome round {round_idx} with round-scoped hosted metadata."""', ] _append_inline_szz_syndrome_extraction( body, @@ -2320,19 +2302,14 @@ def _guppy_module_cache_key( ) interaction_basis = resolved_plan.interaction_basis szz_runtime_barrier_policy = _normalize_szz_runtime_barrier_policy(szz_runtime_barriers) - if ( - interaction_basis != "szz" - and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE - ): + if interaction_basis != "szz" and szz_runtime_barrier_policy != _SZZ_RUNTIME_BARRIER_POLICY_NONE: msg = "szz_runtime_barriers is only supported for interaction_basis='szz'" raise ValueError(msg) interaction_part = "" if interaction_basis == "cx" else f"_ib{interaction_basis}" check_plan_part = "" if check_plan is None else f"_cp{resolved_plan.plan_id}" frame_part = "" if clifford_frame_policy is None else f"_cf{str(clifford_frame_policy).lower().replace('-', '_')}" runtime_barrier_part = ( - "" - if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE - else f"_szzrb-{szz_runtime_barrier_policy}" + "" if szz_runtime_barrier_policy == _SZZ_RUNTIME_BARRIER_POLICY_NONE else f"_szzrb-{szz_runtime_barrier_policy}" ) trace_metadata_part = "" if trace_metadata else "_trace-metadata-off" base = ( diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 023a60792..fb1274d14 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -37,8 +37,8 @@ EquivalenceResult, FaultLocation, InfluenceBuilder, - PauliFrameLookup, ParsedDem, + PauliFrameLookup, assert_dems_equivalent, compare_dems_exact, compare_dems_statistical, diff --git a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py index 71314b8fa..af61f2ca3 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_ancilla_batching.py @@ -50,10 +50,7 @@ def normalize_ancilla_schedule(ancilla_schedule: str | None = None) -> str: return DEFAULT_ANCILLA_SCHEDULE normalized = str(ancilla_schedule).lower().replace("_", "-") if normalized not in SUPPORTED_ANCILLA_SCHEDULES: - msg = ( - f"ancilla_schedule must be one of {sorted(SUPPORTED_ANCILLA_SCHEDULES)}, " - f"got {ancilla_schedule!r}" - ) + msg = f"ancilla_schedule must be one of {sorted(SUPPORTED_ANCILLA_SCHEDULES)}, got {ancilla_schedule!r}" raise ValueError(msg) return normalized diff --git a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py index 5269a4dd3..7967e670b 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_clifford_deformation.py @@ -43,9 +43,7 @@ "checkerboard_zxxz", }, ) -_SUPPORTED_FRAME_POLICIES = ( - _SUPPORTED_GLOBAL_FRAME_POLICIES | _SUPPORTED_CHECKERBOARD_FRAME_POLICIES -) +_SUPPORTED_FRAME_POLICIES = _SUPPORTED_GLOBAL_FRAME_POLICIES | _SUPPORTED_CHECKERBOARD_FRAME_POLICIES @dataclass(frozen=True, order=True) @@ -212,10 +210,7 @@ def normalize_surface_frame_policy(policy: str) -> str: """Normalize and validate a named surface Clifford frame policy.""" normalized = str(policy).lower().replace("-", "_") if normalized not in _SUPPORTED_FRAME_POLICIES: - msg = ( - f"unknown surface Clifford frame policy {policy!r}; expected one of " - f"{sorted(_SUPPORTED_FRAME_POLICIES)}" - ) + msg = f"unknown surface Clifford frame policy {policy!r}; expected one of {sorted(_SUPPORTED_FRAME_POLICIES)}" raise ValueError(msg) return normalized @@ -244,11 +239,7 @@ def resolve_surface_clifford_frame( ) -> ResolvedSurfaceCliffordFrame: """Resolve source surface checks/logicals through a local Clifford frame.""" normalized = normalize_surface_frame_policy(policy) - frames = ( - tuple(data_frames) - if data_frames is not None - else _surface_frame_for_policy(patch, normalized) - ) + frames = tuple(data_frames) if data_frames is not None else _surface_frame_for_policy(patch, normalized) if len(frames) != patch.num_data: msg = f"data frame length {len(frames)} does not match patch.num_data={patch.num_data}" raise ValueError(msg) diff --git a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py index 06da3d14e..2f531d358 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_detection_events.py @@ -42,10 +42,7 @@ def extract_detection_events_and_observables( num_meas_meta = tick_circuit.get_meta("num_measurements") if num_meas_meta is None or num_meas_meta == "": - msg = ( - "extract_detection_events_and_observables requires " - "tick_circuit.get_meta('num_measurements') to be set" - ) + msg = "extract_detection_events_and_observables requires tick_circuit.get_meta('num_measurements') to be set" raise ValueError(msg) num_meas = int(num_meas_meta) @@ -54,10 +51,7 @@ def extract_detection_events_and_observables( for row in results: if len(row) != num_meas: - msg = ( - f"result row has length {len(row)} but tick_circuit metadata " - f"declares num_measurements={num_meas}" - ) + msg = f"result row has length {len(row)} but tick_circuit metadata declares num_measurements={num_meas}" raise ValueError(msg) fired_detectors: list[int] = [] diff --git a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py index c24fdf78e..8d1ae6b13 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py +++ b/python/quantum-pecos/src/pecos/qec/surface/_twirl_config.py @@ -99,10 +99,7 @@ def validate_runtime_supported(self) -> None: encoding-incompatible behavior. """ if self.scheme not in _SUPPORTED_SCHEMES: - msg = ( - f"TwirlConfig.scheme={self.scheme!r} is not supported; " - f"expected one of {_SUPPORTED_SCHEMES!r}" - ) + msg = f"TwirlConfig.scheme={self.scheme!r} is not supported; expected one of {_SUPPORTED_SCHEMES!r}" raise ValueError(msg) if self.site_schedule not in _SUPPORTED_SITE_SCHEDULES: msg = ( diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py index fc3df1622..9c945abe0 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -407,9 +407,7 @@ def _validate_szz_sign_vector( ) raise ValueError(msg) touch = next( - entry - for entry in signs - if entry.stabilizer_type == stabilizer_type and entry.data_qubit == data_qubit + entry for entry in signs if entry.stabilizer_type == stabilizer_type and entry.data_qubit == data_qubit ) gate = { ("X", 1): "SXDG", @@ -484,10 +482,7 @@ def _szz_memory_physical_axis( if resolved_clifford_frame is None: return source_basis # type: ignore[return-value] - axes = { - frame.image(source_basis).axis - for frame in resolved_clifford_frame.data_frames - } + axes = {frame.image(source_basis).axis for frame in resolved_clifford_frame.data_frames} if len(axes) != 1: msg = ( f"clifford frame policy {resolved_clifford_frame.policy!r} maps " @@ -1288,8 +1283,7 @@ def emit_gate_local_twirl_layer( cx_ops.append((data_q(data_idx), ancilla_q, f"Z{stab_idx}")) emit_gate_local_twirl_layer(ops, cx_ops) ops.extend( - SurfaceCircuitStep(OpType.CX, [control, target], label) - for control, target, label in cx_ops + SurfaceCircuitStep(OpType.CX, [control, target], label) for control, target, label in cx_ops ) ops.append(SurfaceCircuitStep(OpType.TICK)) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 933d6b8e5..5c6b8ee0b 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -914,20 +914,14 @@ def _validate_measurement_crosstalk_topology( return None if measurement_crosstalk_topology == "global_from_measurements": return measurement_crosstalk_topology - msg = ( - "measurement_crosstalk_topology must be None, 'runtime_payloads', " - "or 'global_from_measurements'" - ) + msg = "measurement_crosstalk_topology must be None, 'runtime_payloads', or 'global_from_measurements'" raise ValueError(msg) def _should_add_global_measurement_crosstalk_payload( measurement_crosstalk_topology: str | None, ) -> bool: - return ( - _validate_measurement_crosstalk_topology(measurement_crosstalk_topology) - == "global_from_measurements" - ) + return _validate_measurement_crosstalk_topology(measurement_crosstalk_topology) == "global_from_measurements" def _replay_qis_trace_into_tick_circuit( @@ -1662,10 +1656,7 @@ def _pauli_masks_as_int64(pauli_masks: Any) -> NDArray[np.int64]: """Return Pauli-mask input in the integer dtype accepted by Rust bindings.""" masks_arr = np.asarray(pauli_masks) if not np.issubdtype(masks_arr.dtype, np.integer): - msg = ( - "pauli_masks must be an integer array with values " - "0=I, 1=X, 2=Y, 3=Z" - ) + msg = "pauli_masks must be an integer array with values 0=I, 1=X, 2=Y, 3=Z" raise TypeError(msg) return np.asarray(masks_arr, dtype=np.int64) @@ -1944,9 +1935,7 @@ def _with_noise_compat( ) except TypeError as exc: unsupported = { - key: value - for key, value in noise_kwargs.items() - if key not in {"p_idle", "t1", "t2"} and value is not None + key: value for key, value in noise_kwargs.items() if key not in {"p_idle", "t1", "t2"} and value is not None } if unsupported: msg = ( @@ -2037,12 +2026,16 @@ def _surface_native_topology( tuple(_extract_measurement_order(tc)) if _metadata_uses_record_offsets(detectors_json, observables_json) else () ) num_measurements = int(tc.get_meta("num_measurements") or str(len(measurement_order))) - det_records = [ - _metadata_record_offsets(detector, num_measurements) for detector in json.loads(detectors_json) - ] if detectors_json else [] - obs_records = [ - _metadata_record_offsets(observable, num_measurements) for observable in json.loads(observables_json) - ] if observables_json else [] + det_records = ( + [_metadata_record_offsets(detector, num_measurements) for detector in json.loads(detectors_json)] + if detectors_json + else [] + ) + obs_records = ( + [_metadata_record_offsets(observable, num_measurements) for observable in json.loads(observables_json)] + if observables_json + else [] + ) pauli_frame_lookup = None num_pauli_sites = 0 @@ -2197,8 +2190,8 @@ def _cached_surface_native_dem_string( check_plan: str | None = None, resolved_check_plan_hash: str = "", clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool | str = False, *, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> str: @@ -2222,9 +2215,7 @@ def _cached_surface_native_dem_string( p_idle_z_quadratic_sine_rate=p_idle_z_quadratic_sine_rate, ) szz_physical_prefixes = ( - interaction_basis == "szz" - and circuit_source == "abstract" - and (p1 > 0.0 or include_idle_gates) + interaction_basis == "szz" and circuit_source == "abstract" and (p1 > 0.0 or include_idle_gates) ) topology = _cached_surface_native_topology( patch_key, @@ -2988,9 +2979,7 @@ def _get_circuit_level_dem(self, basis: str) -> str: DEM string in Stim format """ dem_decomposition: NativeDemDecomposition = ( - "terminal_graphlike" - if self.circuit_level_dem_mode == "native_terminal_graphlike" - else "source_graphlike" + "terminal_graphlike" if self.circuit_level_dem_mode == "native_terminal_graphlike" else "source_graphlike" ) dem = generate_circuit_level_dem_from_builder( self.patch, @@ -4305,8 +4294,8 @@ def build_native_sampler( ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", check_plan: str | None = None, clifford_frame_policy: str | None = None, - szz_runtime_barriers: bool | str = False, *, + szz_runtime_barriers: bool | str = False, require_hosted_operation_order: bool = False, max_hosted_tick_separation: int | None = None, ) -> NativeSampler: @@ -4531,10 +4520,7 @@ def decode_native_samples( dem_str = dem if dem is not None else sampler.dem_string if dem_str is None: - msg = ( - "decode_native_samples requires a DEM string; " - "pass dem= or build the sampler with sampling_model='dem'" - ) + msg = "decode_native_samples requires a DEM string; pass dem= or build the sampler with sampling_model='dem'" raise ValueError(msg) masks_arr = _pauli_masks_as_int64(pauli_masks) if pauli_masks is not None else None @@ -4600,22 +4586,13 @@ def demask_pauli_frame_records( expected_obs = pauli_frame_lookup.num_observables expected_sites = pauli_frame_lookup.num_pauli_sites if events_arr.shape[1] != expected_det: - msg = ( - f"raw_events width {events_arr.shape[1]} != " - f"pauli_frame_lookup.num_detectors {expected_det}" - ) + msg = f"raw_events width {events_arr.shape[1]} != pauli_frame_lookup.num_detectors {expected_det}" raise ValueError(msg) if obs_arr.shape[1] != expected_obs: - msg = ( - f"raw_obs width {obs_arr.shape[1]} != " - f"pauli_frame_lookup.num_observables {expected_obs}" - ) + msg = f"raw_obs width {obs_arr.shape[1]} != pauli_frame_lookup.num_observables {expected_obs}" raise ValueError(msg) if masks_arr.shape[1] != expected_sites: - msg = ( - f"pauli_masks width {masks_arr.shape[1]} != " - f"pauli_frame_lookup.num_pauli_sites {expected_sites}" - ) + msg = f"pauli_masks width {masks_arr.shape[1]} != pauli_frame_lookup.num_pauli_sites {expected_sites}" raise ValueError(msg) det_xor, obs_xor = pauli_frame_lookup.compute_mask_xor(masks_arr) @@ -4680,10 +4657,7 @@ def _extract_pauli_masks_from_results( raise ValueError(msg) per_gate = results[tag] if len(per_gate) != num_shots: - msg = ( - f"Pauli-mask tag {tag!r}: got {len(per_gate)} shots, " - f"expected {num_shots} shots" - ) + msg = f"Pauli-mask tag {tag!r}: got {len(per_gate)} shots, expected {num_shots} shots" raise ValueError(msg) bits = np.asarray(per_gate, dtype=np.uint8) @@ -4726,10 +4700,7 @@ def _extract_pauli_masks_from_results( raise ValueError(msg) per_round = results[tag] if len(per_round) != num_shots: - msg = ( - f"Pauli-mask tag {tag!r}: got {len(per_round)} shots, " - f"expected {num_shots} shots" - ) + msg = f"Pauli-mask tag {tag!r}: got {len(per_round)} shots, expected {num_shots} shots" raise ValueError(msg) bits = np.asarray(per_round, dtype=np.uint8) @@ -4826,10 +4797,7 @@ def _extract_pauli_activations_from_results( raise ValueError(msg) per_gate = results[tag] if len(per_gate) != num_shots: - msg = ( - f"Pauli-activation tag {tag!r}: got {len(per_gate)} shots, " - f"expected {num_shots} shots" - ) + msg = f"Pauli-activation tag {tag!r}: got {len(per_gate)} shots, expected {num_shots} shots" raise ValueError(msg) bits = np.asarray(per_gate, dtype=bool) if bits.ndim != 2 or bits.shape[1] != 2: @@ -4854,10 +4822,7 @@ def _extract_pauli_activations_from_results( raise ValueError(msg) per_round = results[tag] if len(per_round) != num_shots: - msg = ( - f"Pauli-activation tag {tag!r}: got {len(per_round)} shots, " - f"expected {num_shots} shots" - ) + msg = f"Pauli-activation tag {tag!r}: got {len(per_round)} shots, expected {num_shots} shots" raise ValueError(msg) bits = np.asarray(per_round, dtype=bool) if bits.ndim != 2 or bits.shape[1] != num_data: diff --git a/python/quantum-pecos/src/pecos/quantum/hosted.py b/python/quantum-pecos/src/pecos/quantum/hosted.py index 009f88db7..df642087a 100644 --- a/python/quantum-pecos/src/pecos/quantum/hosted.py +++ b/python/quantum-pecos/src/pecos/quantum/hosted.py @@ -139,11 +139,7 @@ def validate_hosted_operations( f"host gate{shared_clause} carrying the same host_id." ) raise ValueError(msg) - later_hosts = [ - candidate - for candidate in host_candidates - if _gate_order(candidate) > _gate_order(local) - ] + later_hosts = [candidate for candidate in host_candidates if _gate_order(candidate) > _gate_order(local)] if require_host_after_local and not later_hosts: nearest_host = host_candidates[-1] msg = ( @@ -206,17 +202,12 @@ def _raise_if_repeated_host_records( if record.local_role or not record.host_id: continue host_records_by_id.setdefault(record.host_id, []).append(record) - repeated = { - host_id: host_records - for host_id, host_records in host_records_by_id.items() - if len(host_records) > 1 - } + repeated = {host_id: host_records for host_id, host_records in host_records_by_id.items() if len(host_records) > 1} if not repeated: return host_id, host_records = next(iter(repeated.items())) first_locations = ", ".join( - f"{record.gate_name}@t{record.tick_index}/g{record.gate_index}" - for record in host_records[:4] + f"{record.gate_name}@t{record.tick_index}/g{record.gate_index}" for record in host_records[:4] ) extra_count = len(host_records) - 4 if extra_count > 0: @@ -293,10 +284,7 @@ def _iter_tick_gates( except AttributeError as exc: msg = f"{context}: expected TickCircuit ticks with gate_batches()." raise TypeError(msg) from exc - gate_locations.extend( - (tick_index, gate_index, gate) - for gate_index, gate in enumerate(gate_batches) - ) + gate_locations.extend((tick_index, gate_index, gate) for gate_index, gate in enumerate(gate_batches)) return tuple(gate_locations) diff --git a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py index cc52065e9..335f5fff4 100644 --- a/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py +++ b/python/quantum-pecos/tests/guppy/test_surface_twirl_render.py @@ -38,15 +38,11 @@ def _assert_szz_prefix_barrier_host_order(src: str) -> None: if "phased_x(" not in line: continue prefix_meta = next( - i - for i in range(index - 1, -1, -1) - if _line_has_trace_metadata(lines[i], "source_kind", "szz_data_prefix") + i for i in range(index - 1, -1, -1) if _line_has_trace_metadata(lines[i], "source_kind", "szz_data_prefix") ) barrier_index = next(i for i in range(prefix_meta - 1, -1, -1) if _SZZ_RUNTIME_BARRIER_CALL in lines[i]) host_meta = next( - i - for i in range(index + 1, len(lines)) - if _line_has_trace_metadata(lines[i], "source_kind", "szz_host") + i for i in range(index + 1, len(lines)) if _line_has_trace_metadata(lines[i], "source_kind", "szz_host") ) zz_phase_index = next(i for i in range(host_meta + 1, len(lines)) if "zz_phase(" in lines[i]) diff --git a/python/quantum-pecos/tests/qec/surface/test_check_plan.py b/python/quantum-pecos/tests/qec/surface/test_check_plan.py index 98ffee709..e3a85055a 100644 --- a/python/quantum-pecos/tests/qec/surface/test_check_plan.py +++ b/python/quantum-pecos/tests/qec/surface/test_check_plan.py @@ -80,9 +80,12 @@ def test_check_plan_default_resolves_to_cx_metadata() -> None: assert plan.resolved_metadata["hash_algorithm"] == "sha256" assert plan.resolved_metadata["hash_serialization"] == "canonical-json-v1" assert "metadata_version" not in plan.semantic_content - assert plan.resolved_hash == hashlib.sha256( - canonical_check_plan_json(plan.semantic_content).encode("utf-8"), - ).hexdigest() + assert ( + plan.resolved_hash + == hashlib.sha256( + canonical_check_plan_json(plan.semantic_content).encode("utf-8"), + ).hexdigest() + ) def test_check_plan_is_source_of_truth_for_basis() -> None: @@ -507,11 +510,7 @@ def test_boundary_first_szz_check_plan_changes_source_gates_not_metadata() -> No ) def szz_gate_signature(ops: object) -> list[tuple[str, tuple[int, ...], str]]: - return [ - (op.op_type.name, tuple(op.qubits), op.label) - for op in ops - if op.op_type in {OpType.SZZ, OpType.SZZDG} - ] + return [(op.op_type.name, tuple(op.qubits), op.label) for op in ops if op.op_type in {OpType.SZZ, OpType.SZZDG}] assert szz_gate_signature(boundary_first_ops) != szz_gate_signature(current_ops) assert sum(op.op_type == OpType.SZZDG for op in boundary_first_ops) == sum( diff --git a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py index e2c09de11..401d0fbff 100644 --- a/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py +++ b/python/quantum-pecos/tests/qec/surface/test_clifford_deformation.py @@ -157,16 +157,8 @@ def test_checkerboard_frames_emit_mixed_szz_check_scaffold( clifford_frame_policy=policy, ) - assert any( - op.op_type == OpType.H - and op.label == f"prep_x_basis_d{rotated_data}:to_z" - for op in ops - ) - assert any( - op.op_type == OpType.H - and op.label == f"measure_x_basis_d{rotated_data}:from_z" - for op in ops - ) + assert any(op.op_type == OpType.H and op.label == f"prep_x_basis_d{rotated_data}:to_z" for op in ops) + assert any(op.op_type == OpType.H and op.label == f"measure_x_basis_d{rotated_data}:from_z" for op in ops) assert not any(op.label == f"prep_x_basis_d{unrotated_data}:to_z" for op in ops) assert any(f"szz_x_touch_pre:X1:d{x_touch_data}:to_z" in op.label for op in ops) assert any(f"szz_touch_comp:SXDG:X:X1:d{x_touch_data}" in op.label for op in ops) diff --git a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py index 28394cb96..bc92b49ea 100644 --- a/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py +++ b/python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py @@ -379,11 +379,13 @@ def test_windowed_logical_subgraph_known_limitation_no_full_suppression(): # Still does not fully suppress (the paper's windowed-LOM limitation): LER does # not fall with distance (it in fact grows). When the anti-snake machinery # lands and this suppresses, flip the assertions and update the test. - assert ler_d5 >= ler_d3 * 0.7 and ler_d7 >= ler_d5 * 0.7, ( + suppression_msg = ( f"windowed logical-subgraph now suppresses (d3={ler_d3:.5f} " f"d5={ler_d5:.5f} d7={ler_d7:.5f}) -- anti-snake machinery appears to " "have landed; update this test." ) + assert ler_d5 >= ler_d3 * 0.7, suppression_msg + assert ler_d7 >= ler_d5 * 0.7, suppression_msg # Guard against regressing to the old catastrophic anti-suppression (the # naive-XOR decoder reached ~0.1-0.25 here); the core-commit rewrite keeps it # well below that across distances. diff --git a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py index eb1ac06d1..be804f15f 100644 --- a/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py +++ b/python/quantum-pecos/tests/qec/surface/test_pauli_mask_harvest.py @@ -45,11 +45,7 @@ def _canonicalize_raw_rows_from_masks( ) -> list[list[int]]: """Apply the same Pauli-frame rule as the generated canonical tracker.""" num_data = patch.geometry.num_data - init_count = ( - len(patch.geometry.x_stabilizers) - if basis.upper() == "Z" - else len(patch.geometry.z_stabilizers) - ) + init_count = len(patch.geometry.x_stabilizers) if basis.upper() == "Z" else len(patch.geometry.z_stabilizers) expected_width = init_count + num_rounds * patch.geometry.num_ancilla + num_data canonical_rows: list[list[int]] = [] diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py index eb3934c47..e50730eb2 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -229,7 +229,7 @@ def from_dem(cls, _dem: str) -> object: circuit_level_dem_mode="native_decomposed", ) - assert decoder._get_z_decoder() is not None # noqa: SLF001 + assert decoder._get_z_decoder() is not None assert seen == { "method": "from_dem_with_correlations", "dem": "error(0.01) D0 ^ D1 L0\n", @@ -270,7 +270,7 @@ def from_dem(cls, dem: str) -> object: circuit_level_dem_mode="native_decomposed", ) - assert decoder._get_z_decoder() is not None # noqa: SLF001 + assert decoder._get_z_decoder() is not None assert seen == { "method": "from_dem", "dem": "error(0.01) D0 ^ D1 L0\n", @@ -288,7 +288,7 @@ def test_correlated_pymatching_requires_circuit_level_dem(self) -> None: ) with pytest.raises(ValueError, match="requires circuit-level DEM"): - decoder._get_z_decoder() # noqa: SLF001 + decoder._get_z_decoder() def test_correlated_pymatching_requires_decomposed_dem_mode(self) -> None: """The explicit correlated option needs decomposed DEM metadata.""" @@ -302,18 +302,15 @@ def test_correlated_pymatching_requires_decomposed_dem_mode(self) -> None: ) with pytest.raises(ValueError, match="requires a decomposed"): - decoder._get_z_decoder() # noqa: SLF001 + decoder._get_z_decoder() def test_recommended_memory_workflow_uses_terminal_graphlike_for_pymatching(self) -> None: """The high-level memory helper should use the best measured graphlike projection.""" import pecos.qec.surface.decode as decode_module for decoder_type in ["pymatching", "pymatching_correlated", "pymatching_uncorrelated"]: - assert ( - decode_module._recommended_graphlike_decomposition_for_decoder(decoder_type) # noqa: SLF001 - == "terminal_graphlike" - ) - assert decode_module._recommended_graphlike_decomposition_for_decoder("tesseract") == "source_graphlike" # noqa: SLF001 + assert decode_module._recommended_graphlike_decomposition_for_decoder(decoder_type) == "terminal_graphlike" + assert decode_module._recommended_graphlike_decomposition_for_decoder("tesseract") == "source_graphlike" def test_get_dem(self) -> None: """Test DEM generation via decoder.""" @@ -769,12 +766,15 @@ def test_constrained_budget_dem_remains_strictly_decodable(self) -> None: dem = generate_dem_from_tick_circuit(tc, **params) sampler = ParsedDem.from_string(dem).to_dem_sampler() - assert sampler.sample_decode_count( - dem, - 16, - decoder_type="pymatching", - seed=1234, - ) >= 0 + assert ( + sampler.sample_decode_count( + dem, + 16, + decoder_type="pymatching", + seed=1234, + ) + >= 0 + ) def test_traced_qis_traces_the_given_patch_not_its_distance(self) -> None: """A non-rotated patch must be traced from its OWN Guppy program, not diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py index 1eed66782..7e01b5d1b 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -232,9 +232,7 @@ def test_tick_circuit_uses_explicit_prep_syndrome_baseline( ] assert init_tick_rounds - first_random_detector = next( - det for det in detectors if det["coords"][1] == detector_y and det["coords"][2] == 0 - ) + first_random_detector = next(det for det in detectors if det["coords"][1] == detector_y and det["coords"][2] == 0) assert len(first_random_detector["records"]) == 2 record_indices = [num_measurements + int(record) for record in first_random_detector["records"]] assert record_indices[0] >= init_count diff --git a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py index 584104062..ff2d2e80e 100644 --- a/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py +++ b/python/quantum-pecos/tests/qec/test_dem_metadata_fail_loud.py @@ -11,13 +11,13 @@ import pytest from pecos_rslib import DagCircuit -from pecos_rslib.quantum import Gate, GateType from pecos_rslib.qec import ( DagFaultAnalyzer, DemBuilder, DemSampler, DetectorErrorModel, ) +from pecos_rslib.quantum import Gate, GateType def _one_measurement_dag(*, num_measurements: str = "1") -> DagCircuit: diff --git a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py index a19f7724e..11af30ae6 100644 --- a/python/quantum-pecos/tests/qec/test_from_guppy_dem.py +++ b/python/quantum-pecos/tests/qec/test_from_guppy_dem.py @@ -114,11 +114,7 @@ def test_guppy_barrier_survives_into_qis_operation_trace() -> None: num_qubits=2, seed=0, ) - operations = [ - operation - for chunk in chunks - for operation in chunk.get("operations", []) - ] + operations = [operation for chunk in chunks for operation in chunk.get("operations", [])] assert any(operation == "Barrier" or "Barrier" in operation for operation in operations) @@ -136,22 +132,14 @@ def test_szz_runtime_barrier_survives_into_qis_operation_trace() -> None: num_qubits=get_num_qubits(d=3, interaction_basis="szz"), seed=0, ) - operations = [ - operation - for chunk in chunks - for operation in chunk.get("operations", []) - ] + operations = [operation for chunk in chunks for operation in chunk.get("operations", [])] assert any(operation == "Barrier" or "Barrier" in str(operation) for operation in operations) def test_qubit_trace_metadata_stays_ordered_before_gate() -> None: chunks = capture_guppy_operation_trace(_metadata_before_h_gate, num_qubits=1, seed=0) - lowered_ops = [ - op - for chunk in chunks - for op in chunk.get("lowered_quantum_ops", []) - ] + lowered_ops = [op for chunk in chunks for op in chunk.get("lowered_quantum_ops", [])] assert lowered_ops[1]["gate_type"] == "R1XY" assert lowered_ops[1]["metadata"] == { @@ -821,9 +809,9 @@ def test_from_guppy_constrained_surface_dem_byte_identical( budget=budget, noise=noise, ) - assert got == ref_dem, ( - f"constrained surface from_guppy not byte-identical for d={d}, budget={budget}, basis={basis}, rounds={rounds}" - ) + assert ( + got == ref_dem + ), f"constrained surface from_guppy not byte-identical for d={d}, budget={budget}, basis={basis}, rounds={rounds}" def test_constrained_surface_traced_metadata_matches_abstract() -> None: diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py b/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py index e0c4ddd6c..bb6e1facf 100644 --- a/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py +++ b/python/quantum-pecos/tests/qec/test_sim_neo_explicit_config.py @@ -51,14 +51,7 @@ def test_auto_selects_stabilizer_backend() -> None: def test_explicit_quantum_overrides_auto() -> None: - result = ( - sim_neo(one_qubit_circuit()) - .auto() - .quantum(stabilizer()) - .sampling(monte_carlo(2)) - .seed(7) - .run() - ) + result = sim_neo(one_qubit_circuit()).auto().quantum(stabilizer()).sampling(monte_carlo(2)).seed(7).run() assert result.num_shots == 2 diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py b/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py index 833374640..c227baff7 100644 --- a/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py +++ b/python/quantum-pecos/tests/qec/test_sim_neo_path_enumeration.py @@ -69,13 +69,7 @@ def test_deterministic_circuit_dedupes_to_one_path() -> None: def test_monte_carlo_results_have_no_weights() -> None: - result = ( - sim_neo(deterministic_circuit()) - .quantum(stabilizer()) - .sampling(monte_carlo(3)) - .seed(1) - .run() - ) + result = sim_neo(deterministic_circuit()).quantum(stabilizer()).sampling(monte_carlo(3)).seed(1).run() assert result.weights is None diff --git a/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py b/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py index d85f02f5d..ced5f4931 100644 --- a/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py +++ b/python/quantum-pecos/tests/qec/test_sim_neo_subset_simulation.py @@ -76,9 +76,7 @@ def test_subset_certain_event() -> None: sim_neo(x_circuit()) .auto() .sampling( - subset_simulation(200) - .score(lambda bits: float(sum(bits))) - .failure(lambda bits: bits[0] == 1), + subset_simulation(200).score(lambda bits: float(sum(bits))).failure(lambda bits: bits[0] == 1), ) .seed(7) .run() @@ -112,9 +110,7 @@ def test_subset_requires_score_and_failure() -> None: def test_subset_rejects_statevec_backend() -> None: with pytest.raises(ValueError, match="only the stabilizer"): sim_neo(three_h_circuit()).quantum(statevec()).sampling( - subset_simulation(100) - .score(lambda bits: float(sum(bits))) - .failure(lambda bits: all(b == 1 for b in bits)), + subset_simulation(100).score(lambda bits: float(sum(bits))).failure(lambda bits: all(b == 1 for b in bits)), ).run() @@ -151,11 +147,7 @@ def test_subset_multilevel_runs_with_opt_in() -> None: sim_neo(three_h_circuit()) .auto() .sampling( - subset_simulation(500) - .score(_ones_score) - .failure(_all_ones) - .max_levels(5) - .allow_biased_multilevel(), + subset_simulation(500).score(_ones_score).failure(_all_ones).max_levels(5).allow_biased_multilevel(), ) .seed(42) .run() diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py index 407a3d789..50116aba0 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -224,9 +224,7 @@ def test_normalize_traced_qis_tick_circuit_lowers_clifford_rzz(): normalize_traced_qis_tick_circuit(tc, context="test Clifford RZZ normalization") gate_names = [ - gate.gate_type.name - for tick_index in range(tc.num_ticks()) - for gate in tc.get_tick(tick_index).gate_batches() + gate.gate_type.name for tick_index in range(tc.num_ticks()) for gate in tc.get_tick(tick_index).gate_batches() ] assert "RZZ" not in gate_names assert "SZZ" in gate_names @@ -242,9 +240,7 @@ def test_normalize_traced_qis_tick_circuit_rejects_raw_rzz_after_lowering(): def _gate_names(tc): return [ - gate.gate_type.name - for tick_index in range(tc.num_ticks()) - for gate in tc.get_tick(tick_index).gate_batches() + gate.gate_type.name for tick_index in range(tc.num_ticks()) for gate in tc.get_tick(tick_index).gate_batches() ] diff --git a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py index c7f1787be..3b6b55f55 100644 --- a/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py +++ b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py @@ -137,7 +137,12 @@ def test_traced_qis_meas_sampling_ler_tracks_native_dem_pymatching(distance, rou matching = _pymatching_decoder(circuit, noise_args) raw_result = ( - sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).sampling(monte_carlo(shots)).seed(1234).run() + sim_neo(circuit) + .quantum(meas_sampling()) + .noise(_depolarizing_noise(noise_args)) + .sampling(monte_carlo(shots)) + .seed(1234) + .run() ) meas_errors = _decode_raw_measurements(raw_result, circuit, matching, shots) native_errors = _decode_native_dem_samples(circuit, noise_args, matching, shots, seed=5678) @@ -154,7 +159,12 @@ def test_d3_traced_qis_zero_noise_pymatching_pipeline_has_no_logical_errors(): shots = 64 raw_result = ( - sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).sampling(monte_carlo(shots)).seed(2468).run() + sim_neo(circuit) + .quantum(meas_sampling()) + .noise(_depolarizing_noise(noise_args)) + .sampling(monte_carlo(shots)) + .seed(2468) + .run() ) meas_errors = _decode_raw_measurements(raw_result, circuit, matching, shots) native_errors = _decode_native_dem_samples(circuit, noise_args, matching, shots, seed=1357) diff --git a/ruff.toml b/ruff.toml index 721f369aa..711cbdb60 100644 --- a/ruff.toml +++ b/ruff.toml @@ -197,6 +197,14 @@ ignore = [ "SLF001", # Private member access - accessing internal circuit/tick data "PLW0127", # Self-assignment - intentional pattern for influence propagation ] +# QEC tests that unit-test internal decoder methods directly (kept private; not +# promoted to the public API just to be testable) +"python/quantum-pecos/tests/qec/surface/test_surface_decoder.py" = [ + "SLF001", # Private member access - unit-testing internal DEM detection-event methods +] +"python/quantum-pecos/tests/qec/surface/test_logical_subgraph_region_comparison.py" = [ + "SLF001", # Private member access - unit-testing the internal graphlike-decomposition recommendation +] # SLR qeclib visualization - matplotlib lazy imports "python/quantum-pecos/src/pecos/slr/qeclib/color488/plot_layout.py" = [ "PLC0415", # Import inside function - matplotlib optional @@ -270,6 +278,18 @@ ignore = [ "S108", # Examples commonly default to /tmp output paths "S311", # Example sweeps use deterministic pseudo-random circuits, not crypto ] +# Experimental SLR/zlup crate scripts are scripts, not packages -- same relaxations +# as the top-level scripts/ (other rules, e.g. pathlib usage, are still enforced). +"exp/**/scripts/**/*.py" = [ + "ANN", # Command-line scripts do not need library-level annotations + "D", # Top-level module docstrings document scripts + "EXE001", # Scripts are commonly invoked through `python path/to/script.py` + "INP001", # Script files are not import packages + "S603", # Scripts may call trusted local tools (cargo, mdbook) + "PLC0415", # Lazy imports keep optional dependencies out of import time + "S301", # Scripts may read trusted local data artifacts + "BLE001", # CLI/report scripts often convert broad failures into user-facing output +] # Jupyter notebooks - exploratory code, less strict than library code From 8f403c9c723206625299a46c3376cec7be13acc7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 29 Jun 2026 21:22:12 -0600 Subject: [PATCH 302/388] Fix all clippy -D warnings across the workspace and apply rustfmt --- .../src/logical_algorithm.rs | 4 +- crates/pecos-fusion-blossom/src/decoder.rs | 15 +- crates/pecos-hugr-qis/src/compiler.rs | 130 ++++---- .../fault_tolerance/dem_builder/builder.rs | 31 +- .../dem_builder/dem_sampler.rs | 6 +- .../src/fault_tolerance/dem_builder/types.rs | 58 ++-- .../src/fault_tolerance/pauli_frame.rs | 12 +- crates/pecos-qec/tests/idle_noise_tests.rs | 8 + crates/pecos-qis-ffi/src/ffi.rs | 35 ++- crates/pecos-qis/src/executor.rs | 2 +- crates/pecos-qis/src/selene_runtime.rs | 26 +- crates/pecos-quantum/src/pass.rs | 16 +- crates/pecos-uf-decoder/src/decoder.rs | 7 +- exp/guppy-zlup/src/ir.rs | 36 ++- exp/guppy-zlup/src/linter/config.rs | 6 +- exp/guppy-zlup/src/linter/rules/zlup003.rs | 18 +- exp/zlup/src/codegen/hugr.rs | 18 +- exp/zlup/src/codegen/phir.rs | 6 +- exp/zlup/src/codegen/qasm.rs | 6 +- exp/zlup/src/codegen/slr.rs | 6 +- exp/zlup/src/comptime.rs | 16 +- exp/zlup/src/docgen.rs | 16 +- exp/zlup/src/linter.rs | 2 +- exp/zlup/src/main.rs | 57 ++-- exp/zlup/src/optimize.rs | 47 +-- exp/zlup/src/parser.rs | 105 +++---- exp/zlup/src/pretty.rs | 10 +- exp/zlup/src/rational.rs | 6 +- exp/zlup/src/semantic.rs | 284 +++++++++--------- exp/zlup/src/test_runner.rs | 38 +-- exp/zlup/src/tests.rs | 2 +- exp/zluppy/src/lib.rs | 85 +++--- .../pecos-rslib-exp/src/sim_neo_bindings.rs | 5 +- .../src/fault_tolerance_bindings.rs | 9 +- 34 files changed, 574 insertions(+), 554 deletions(-) diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs index e6507cfa3..5a8e4f685 100644 --- a/crates/pecos-decoder-core/src/logical_algorithm.rs +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -835,7 +835,7 @@ mod tests { // regions and were filtered out. Each reports its observable as local // bit 0; the strategy must flip the GLOBAL bit, not the list position. // (The pre-fix `1 << i` would have produced bits {0,1} = 0b0011.) - let mut strat = WindowedLogicalSubgraphStrategy::new( + let mut strategy = WindowedLogicalSubgraphStrategy::new( vec![ "error(0.1) D0 L0".to_string(), "error(0.1) D0 L0".to_string(), @@ -845,7 +845,7 @@ mod tests { |_dem| Ok(Box::new(FixedDecoder(1)) as Box), ) .unwrap(); - let obs = strat.decode(&[1, 1]).unwrap(); + let obs = strategy.decode(&[1, 1]).unwrap(); assert_eq!(obs, (1u64 << 1) | (1u64 << 3)); } diff --git a/crates/pecos-fusion-blossom/src/decoder.rs b/crates/pecos-fusion-blossom/src/decoder.rs index 573eeba58..547e4d8b3 100644 --- a/crates/pecos-fusion-blossom/src/decoder.rs +++ b/crates/pecos-fusion-blossom/src/decoder.rs @@ -682,13 +682,6 @@ impl FusionBlossomDecoder { Ok(decoder) } - /// Add an edge to the graph - /// - /// # Errors - /// - /// Returns [`FusionBlossomError::InvalidGraph`] if: - /// - Either node index is out of bounds - /// - The weight is negative /// Fail loud if any observable index is `>= 64`: this decoder packs /// observable flips into a `u64` (`1 << index`) in `build_obs_masks`, so a /// wider index would overflow-panic. Reject it where observables enter the @@ -703,6 +696,14 @@ impl FusionBlossomDecoder { Ok(()) } + /// Add an edge to the graph + /// + /// # Errors + /// + /// Returns [`FusionBlossomError::InvalidGraph`] if: + /// - Either node index is out of bounds + /// - The weight is negative + /// - Any observable index is `>= 64` pub fn add_edge( &mut self, node1: usize, diff --git a/crates/pecos-hugr-qis/src/compiler.rs b/crates/pecos-hugr-qis/src/compiler.rs index 40d65cc54..f24bc02dc 100644 --- a/crates/pecos-hugr-qis/src/compiler.rs +++ b/crates/pecos-hugr-qis/src/compiler.rs @@ -464,15 +464,15 @@ fn pecos_helper_public_name<'a>(symbol: &str, helper_symbols: &[&'a str]) -> Opt /// /// - `pecos_qis_trace_metadata_hugr(*const u8, *const u8)` -> `void (ptr, ptr)` /// - `pecos_qis_trace_metadata_qubit_hugr(i64, *const u8, *const u8) -> i64` -/// -> `i64 (i64, ptr, ptr)` +/// -> `i64 (i64, ptr, ptr)` /// - `pecos_qis_runtime_barrier_qubit_hugr(i64) -> i64` -> `i64 (i64)` /// - `pecos_qis_runtime_barrier_qubits2_hugr(i64, i64) -> QubitPair{i64,i64}` -/// -> `{ i64, i64 } (i64, i64)` +/// -> `{ i64, i64 } (i64, i64)` /// /// Returns `None` only if `helper` is not a recognized PECOS helper (a programming /// error here, since callers pass a name already matched against `PECOS_HELPER_SYMBOLS`). fn expected_pecos_helper_type<'ctx>( - ctx: &ContextRef<'ctx>, + ctx: ContextRef<'ctx>, helper: &str, ) -> Option> { let i64t = ctx.i64_type(); @@ -528,7 +528,7 @@ fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) -> Result<()> { // Enforce the fixed PECOS helper ABI on every recognized declaration before // claiming the public symbol. - let Some(expected) = expected_pecos_helper_type(&ctx, public) else { + let Some(expected) = expected_pecos_helper_type(ctx, public) else { return Err(anyhow!( "internal error: no ABI signature registered for PECOS helper `{public}`" )); @@ -561,67 +561,6 @@ fn normalize_pecos_helper_symbols_in_module(module: &Module<'_>) -> Result<()> { Ok(()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pecos_helper_public_name_matches_private_helper_symbols() { - // Bare, module-level declaration. - assert_eq!( - pecos_helper_public_name( - "__hugr__.pecos_qis_trace_metadata_hugr.16", - PECOS_HELPER_SYMBOLS, - ), - Some("pecos_qis_trace_metadata_hugr"), - ); - // Module-qualified (e.g. `__main__`) declaration. - assert_eq!( - pecos_helper_public_name( - "__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21", - PECOS_HELPER_SYMBOLS, - ), - Some("pecos_qis_trace_metadata_qubit_hugr"), - ); - // Function-local declaration: the raw module symbol carries a `` - // segment (this is the case guppylang 0.21.11 produces; in LLVM text it is - // additionally quoted, but the module symbol seen here is unquoted). - assert_eq!( - pecos_helper_public_name( - "__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23", - PECOS_HELPER_SYMBOLS, - ), - Some("pecos_qis_runtime_barrier_qubits2_hugr"), - ); - assert_eq!( - pecos_helper_public_name( - "__hugr__.m.f..pecos_qis_runtime_barrier_qubit_hugr.7", - PECOS_HELPER_SYMBOLS, - ), - Some("pecos_qis_runtime_barrier_qubit_hugr"), - ); - } - - #[test] - fn pecos_helper_public_name_ignores_non_helpers() { - // A non-PECOS helper under the private prefix is left alone. - assert_eq!( - pecos_helper_public_name("__hugr__.m.f..other_helper.9", PECOS_HELPER_SYMBOLS), - None, - ); - // A symbol that is not under the private prefix is not a candidate. - assert_eq!( - pecos_helper_public_name("pecos_qis_trace_metadata_hugr", PECOS_HELPER_SYMBOLS), - None, - ); - // Too few components to carry a `.` tail. - assert_eq!( - pecos_helper_public_name("__hugr__.foo", PECOS_HELPER_SYMBOLS), - None, - ); - } -} - /// Compile HUGR bytes to LLVM IR string /// /// This is the main entry point for the compiler. @@ -734,3 +673,64 @@ pub fn compile_hugr_bytes_to_bitcode_with_options( let bitcode = buffer.as_slice(); Ok(bitcode[..bitcode.len().saturating_sub(1)].to_vec()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pecos_helper_public_name_matches_private_helper_symbols() { + // Bare, module-level declaration. + assert_eq!( + pecos_helper_public_name( + "__hugr__.pecos_qis_trace_metadata_hugr.16", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_trace_metadata_hugr"), + ); + // Module-qualified (e.g. `__main__`) declaration. + assert_eq!( + pecos_helper_public_name( + "__hugr__.__main__.pecos_qis_trace_metadata_qubit_hugr.21", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_trace_metadata_qubit_hugr"), + ); + // Function-local declaration: the raw module symbol carries a `` + // segment (this is the case guppylang 0.21.11 produces; in LLVM text it is + // additionally quoted, but the module symbol seen here is unquoted). + assert_eq!( + pecos_helper_public_name( + "__hugr__.test_mod.test_fn..pecos_qis_runtime_barrier_qubits2_hugr.23", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_runtime_barrier_qubits2_hugr"), + ); + assert_eq!( + pecos_helper_public_name( + "__hugr__.m.f..pecos_qis_runtime_barrier_qubit_hugr.7", + PECOS_HELPER_SYMBOLS, + ), + Some("pecos_qis_runtime_barrier_qubit_hugr"), + ); + } + + #[test] + fn pecos_helper_public_name_ignores_non_helpers() { + // A non-PECOS helper under the private prefix is left alone. + assert_eq!( + pecos_helper_public_name("__hugr__.m.f..other_helper.9", PECOS_HELPER_SYMBOLS), + None, + ); + // A symbol that is not under the private prefix is not a candidate. + assert_eq!( + pecos_helper_public_name("pecos_qis_trace_metadata_hugr", PECOS_HELPER_SYMBOLS), + None, + ); + // Too few components to carry a `.` tail. + assert_eq!( + pecos_helper_public_name("__hugr__.foo", PECOS_HELPER_SYMBOLS), + None, + ); + } +} diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs index 81d38f1d2..2d70667c6 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -751,6 +751,12 @@ impl<'a> DemBuilder<'a> { /// /// This does **not** validate metadata refs; callers ingesting /// circuit-derived metadata must use [`Self::try_build`] instead. + /// + /// # Panics + /// + /// Panics if the configured replacement-branch approximation or measurement + /// crosstalk DEM mode is invalid. Use [`Self::try_build`] to receive those as + /// errors instead of panicking. #[must_use] pub fn build(&self) -> DetectorErrorModel { self.validate_replacement_branch_approximation() @@ -828,7 +834,7 @@ impl<'a> DemBuilder<'a> { .noise .p2_weights .as_ref() - .is_some_and(|weights| weights.has_replacement_entries()); + .is_some_and(super::types::PauliWeights::has_replacement_entries); if self.noise.p2_replacement_approximation == ReplacementBranchApproximation::ExactBranchReplay && has_replacement_branches @@ -942,7 +948,7 @@ impl<'a> DemBuilder<'a> { { continue; } - let result = self.hidden_mz_result_before_crosstalk_payload(context, loc)?; + let result = Self::hidden_mz_result_before_crosstalk_payload(context, loc)?; if !result.is_deterministic || !result.outcome.is_empty() { return Err(DemBuilderError::ConfigurationError(format!( "exact deterministic measurement crosstalk DEM replay requires a state-independent hidden MZ result at location {loc_idx} (node {}, qubit {:?}); got deterministic={}, dependencies={:?}", @@ -958,7 +964,6 @@ impl<'a> DemBuilder<'a> { } fn hidden_mz_result_before_crosstalk_payload( - &self, context: ExactBranchReplayContext<'_>, loc: &DagSpacetimeLocation, ) -> Result { @@ -1135,7 +1140,7 @@ impl<'a> DemBuilder<'a> { }; let pairs = || -> Result, DemBuilderError> { require(2)?; - if qubits.len() % 2 != 0 { + if !qubits.len().is_multiple_of(2) { return Err(DemBuilderError::ConfigurationError(format!( "measurement crosstalk replay expected gate {:?} at node {} to have an even number of qubits, got {}", gate_type, @@ -1248,8 +1253,7 @@ impl<'a> DemBuilder<'a> { | GateType::TrackedPauliMeta => {} _ => { return Err(DemBuilderError::ConfigurationError(format!( - "measurement crosstalk exact deterministic replay does not support gate {:?} before payload node {}", - gate_type, node + "measurement crosstalk exact deterministic replay does not support gate {gate_type:?} before payload node {node}" ))); } } @@ -1609,7 +1613,7 @@ impl<'a> DemBuilder<'a> { continue; }; let prob = self.noise.p2 * impact.relative_probability; - self.add_two_qubit_pauli_contribution( + Self::add_two_qubit_pauli_contribution( loc1, loc2, p1, @@ -1734,8 +1738,7 @@ impl<'a> DemBuilder<'a> { "measurement crosstalk exact deterministic mode was validated with context", ); let loc = &self.influence_map.locations[loc_idx]; - let hidden = self - .hidden_mz_result_before_crosstalk_payload(context, loc) + let hidden = Self::hidden_mz_result_before_crosstalk_payload(context, loc) .expect( "measurement crosstalk exact deterministic hidden result was validated", ); @@ -1954,7 +1957,7 @@ impl<'a> DemBuilder<'a> { if prob == 0.0 { continue; } - self.add_two_qubit_pauli_contribution( + Self::add_two_qubit_pauli_contribution( loc1, loc2, p1, p2, prob, &effects, loc1_meta, loc2_meta, dem, None, ); } @@ -2053,7 +2056,6 @@ impl<'a> DemBuilder<'a> { #[allow(clippy::too_many_arguments)] fn add_two_qubit_pauli_contribution( - &self, loc1: usize, loc2: usize, p1: u8, @@ -3106,7 +3108,7 @@ fn two_qubit_after_location_pairs(locations: &[DagSpacetimeLocation]) -> Vec<[us impl ExactBranchReplayContext<'_> { fn replacement_branch_requests( - &self, + self, locations: &[DagSpacetimeLocation], ) -> Result, DemBuilderError> { let mut requests = Vec::new(); @@ -3153,7 +3155,7 @@ impl ExactBranchReplayContext<'_> { #[cfg(test)] fn omitted_branch_location_pair( - &self, + self, request: ExactBranchReplayRequest, original_locations: &[DagSpacetimeLocation], ) -> Result<[usize; 2], DemBuilderError> { @@ -3192,8 +3194,7 @@ fn measurement_parity_differs_from_histories( let branch = measurement_parity_expression(branch_history, measurement_indices, "branch")?; if ideal.dependencies != branch.dependencies { return Err(DemBuilderError::ConfigurationError(format!( - "{context} changes measurement dependencies for parity {:?}; this branch is not representable as a single deterministic DEM event", - measurement_indices + "{context} changes measurement dependencies for parity {measurement_indices:?}; this branch is not representable as a single deterministic DEM event" ))); } Ok(ideal.flip ^ branch.flip) diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs index 637f60cf4..8f90260c1 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/dem_sampler.rs @@ -1917,8 +1917,8 @@ impl<'a> SamplingEngineBuilder<'a> { self.p2_gate_rates = noise.p2_gate_rates.clone(); self.p_meas = noise.p_meas; self.p_prep = noise.p_prep; - self.p1_weights = noise.p1_weights.clone(); - self.p2_weights = noise.p2_weights.clone(); + self.p1_weights.clone_from(&noise.p1_weights); + self.p2_weights.clone_from(&noise.p2_weights); self.p2_replacement_approximation = noise.p2_replacement_approximation; self.idle_noise = noise.uses_dedicated_idle_noise().then_some(noise); self @@ -2012,7 +2012,7 @@ impl<'a> SamplingEngineBuilder<'a> { && self .p2_weights .as_ref() - .is_some_and(|weights| weights.has_replacement_entries()) + .is_some_and(super::types::PauliWeights::has_replacement_entries) { panic!( "exact_branch_replay for starred p2 replacement branches requires a circuit-aware exact branch provider; use branch_impact or pauli_twirl_omitted_gate for the current Pauli-projected approximations" diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs index d59aefa75..703f6fb04 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -247,10 +247,6 @@ impl<'a> DirectSourceComponents<'a> { components: components.iter().collect(), } } - - fn as_slice(&self) -> &[&'a FaultMechanism] { - &self.components - } } impl FaultContribution { @@ -349,6 +345,9 @@ impl FaultContribution { debug_assert_eq!(source.location_indices.len(), source.paulis.len()); debug_assert_eq!(source.location_indices.len(), source.gate_types.len()); debug_assert_eq!(source.location_indices.len(), source.before_flags.len()); + // This constructor takes ownership of its inputs (like `effect`/`source`); + // unpack the component view into the underlying slice of references. + let DirectSourceComponents { components } = components; let component_refs = components.as_slice(); let non_empty_components: SmallVec<[&FaultMechanism; 4]> = component_refs .iter() @@ -1141,7 +1140,6 @@ impl GraphlikeDecompositionIndex { continue; } match candidate.detectors.as_slice() { - [] => {} [det] => { Self::add_path_edge( &mut path_adjacency, @@ -1178,7 +1176,7 @@ impl GraphlikeDecompositionIndex { }); } Self { - graphlike_set: graphlike_set.clone(), + graphlike_set, primitive_graphlike_set, derived_origins, candidates_by_detector, @@ -1488,7 +1486,7 @@ impl GraphlikeDecompositionIndex { .entry(outputs.clone()) .and_modify(|existing| { if prefer_graph_path_parts(&parts, existing) { - *existing = parts.clone(); + existing.clone_from(&parts); } }) .or_insert_with(|| parts.clone()); @@ -4080,6 +4078,10 @@ pub type MechanismTuple = (f64, Vec, Vec); /// Detector-coordinate tuple: `(detector_id, coordinates)`. pub type DetectorCoordinateTuple = (u32, Vec); +/// A coordinate-distance matching solution: +/// `(total_cost, paired_terminals, unpaired_singletons)`. +type CoordinateMatchSolution = (f64, Vec<(u32, u32)>, Vec); + impl DetectorErrorModel { /// Creates a new empty DEM. #[must_use] @@ -5160,16 +5162,12 @@ impl DetectorErrorModel { detectors: &[u32], detector_coords: &BTreeMap, ) -> (Vec<(u32, u32)>, Vec) { - if detectors.len() > 20 { - return Self::greedy_coordinate_terminal_pairs(detectors, detector_coords); - } - fn solve( mask: u64, detectors: &[u32], detector_coords: &BTreeMap, - memo: &mut BTreeMap, Vec)>, - ) -> (f64, Vec<(u32, u32)>, Vec) { + memo: &mut BTreeMap, + ) -> CoordinateMatchSolution { if let Some(cached) = memo.get(&mask) { return cached.clone(); } @@ -5181,7 +5179,7 @@ impl DetectorErrorModel { let index = mask.trailing_zeros() as usize; (0.0, Vec::new(), vec![detectors[index]]) } else if count % 2 == 1 { - let mut best: Option<(f64, Vec<(u32, u32)>, Vec)> = None; + let mut best: Option = None; for index in 0..detectors.len() { if mask & (1_u64 << index) == 0 { continue; @@ -5200,7 +5198,7 @@ impl DetectorErrorModel { } else { let first = mask.trailing_zeros() as usize; let rest_without_first = mask & !(1_u64 << first); - let mut best: Option<(f64, Vec<(u32, u32)>, Vec)> = None; + let mut best: Option = None; for second in first + 1..detectors.len() { if rest_without_first & (1_u64 << second) == 0 { continue; @@ -5230,6 +5228,10 @@ impl DetectorErrorModel { result } + if detectors.len() > 20 { + return Self::greedy_coordinate_terminal_pairs(detectors, detector_coords); + } + let mut memo = BTreeMap::new(); let mask = (1_u64 << detectors.len()) - 1; let (_, pairs, singles) = solve(mask, detectors, detector_coords, &mut memo); @@ -5319,7 +5321,7 @@ impl DetectorErrorModel { let last = parts .last_mut() .expect("non-empty parts checked before attaching observables"); - last.dem_outputs = effect.dem_outputs.clone(); + last.dem_outputs.clone_from(&effect.dem_outputs); } parts @@ -5436,16 +5438,16 @@ impl DetectorErrorModel { return Self::maybe_maximally_decompose_parts(vec![effect.clone()], singleton_set); } - if let Some(index) = source_graphlike_index { - if let Some(parts) = index.find_hyperedge_decomposition_with_remnants_for_origin_cached( + if let Some(index) = source_graphlike_index + && let Some(parts) = index.find_hyperedge_decomposition_with_remnants_for_origin_cached( effect, Some(effect), source_graphlike_path_cache, - ) { - let parts = Self::maybe_maximally_decompose_parts(parts, singleton_set); - if parts_are_detectable_graphlike(&parts) { - return parts; - } + ) + { + let parts = Self::maybe_maximally_decompose_parts(parts, singleton_set); + if parts_are_detectable_graphlike(&parts) { + return parts; } } @@ -5639,6 +5641,11 @@ impl DetectorErrorModel { .join(" ^ ") } + // Irreducible rendering inputs: the contribution plus several read-only + // decomposition indices/policies and two distinct `&mut` caches (path-search + // and render). A params struct would only relocate the same set behind a + // lifetime-laden wrapper without making any call site clearer. + #[allow(clippy::too_many_arguments)] fn contribution_render_details( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, @@ -5905,6 +5912,9 @@ impl DetectorErrorModel { (targets, strategy, recorded_component_targets) } + // Thin forwarder to `contribution_render_details`; carries the same + // irreducible parameter set (see that method's note). + #[allow(clippy::too_many_arguments)] fn contribution_targets( contrib: &FaultContribution, graphlike_index: &GraphlikeDecompositionIndex, @@ -6063,7 +6073,7 @@ impl DetectorErrorModel { .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) .or_insert(contrib.probability); } - if profile_enabled && rendered_contribs % 5000 == 0 { + if profile_enabled && rendered_contribs.is_multiple_of(5000) { let now = std::time::Instant::now(); eprintln!( "[pecos-dem-render] rendered_contributions={} render_cache={} path_cache={} target_buckets={} total={:.3}s", diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs index f2d7f5213..dc1b4e873 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_frame.rs @@ -29,6 +29,10 @@ use thiserror::Error; type MeasurementRecordMap = BTreeMap>; +/// Per-shot detector and observable XOR patterns: `(det_xor, obs_xor)`, where +/// row `i` of each is the mask-induced frame flip for shot `i`. +type DetectorObservableXor = (Vec>, Vec>); + /// Errors returned while building or applying a Pauli-frame lookup. #[derive(Debug, Error, Clone, PartialEq, Eq)] pub enum PauliFrameLookupError { @@ -151,7 +155,7 @@ impl PauliFrameLookup { } let tracked: Vec<(&pecos_quantum::PauliAnnotation, usize)> = tracked_annotations.into_iter().zip(meta_nodes).collect(); - if tracked.len() % 3 != 0 { + if !tracked.len().is_multiple_of(3) { return Err(PauliFrameLookupError::NonTripletTrackedPaulis { num_tracked_paulis: tracked.len(), }); @@ -173,9 +177,9 @@ impl PauliFrameLookup { let mut observable_rows = Vec::with_capacity(tracked.len()); for (ann, meta_node) in &tracked { - if !dag + if dag .gate(*meta_node) - .is_some_and(|gate| gate.gate_type == GateType::TrackedPauliMeta) + .is_none_or(|gate| gate.gate_type != GateType::TrackedPauliMeta) { return Err(PauliFrameLookupError::MetaNodeNotTrackedPauliMeta { meta_node: *meta_node, @@ -284,7 +288,7 @@ impl PauliFrameLookup { masks: &[u8], rows: usize, cols: usize, - ) -> Result<(Vec>, Vec>), PauliFrameLookupError> { + ) -> Result { let mut det_xor = vec![vec![false; self.num_detectors]; rows]; let mut obs_xor = vec![vec![false; self.num_observables]; rows]; self.apply_mask_values(masks, rows, cols, &mut det_xor, &mut obs_xor)?; diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs index 52fa340cb..7f994a40c 100644 --- a/crates/pecos-qec/tests/idle_noise_tests.rs +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -221,6 +221,10 @@ fn linear_memory_z_noise_uses_idle_duration_in_dem() { ); } +// px and py must be *exactly* zero for these Z-only memory models: the X/Y idle +// rates are 0, so the composed channel introduces no X/Y probability. An epsilon +// check would weaken that invariant, so compare against the exact 0.0 constant. +#[allow(clippy::float_cmp)] #[test] fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { let linear = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) @@ -246,6 +250,10 @@ fn idle_memory_pauli_probabilities_match_linear_and_quadratic_model() { assert!((pauli.pz - 0.06).abs() < 1e-15); } +// px and py must be *exactly* zero for this Z-only sine model: the X/Y idle rates +// are 0, so no X/Y probability is introduced. An epsilon check would weaken that +// invariant, so compare against the exact 0.0 constant. +#[allow(clippy::float_cmp)] #[test] fn idle_memory_pauli_probabilities_support_quadratic_sine_model() { let z_sine = NoiseConfig::new(0.0, 0.0, 0.0, 0.0) diff --git a/crates/pecos-qis-ffi/src/ffi.rs b/crates/pecos-qis-ffi/src/ffi.rs index 0b53ca3a1..f40c65ac1 100644 --- a/crates/pecos-qis-ffi/src/ffi.rs +++ b/crates/pecos-qis-ffi/src/ffi.rs @@ -55,12 +55,11 @@ unsafe fn read_tket_string_arg( // The pointer references the length byte, so skip it to read the payload. let data_ptr = unsafe { ptr.add(1) }; let bytes = unsafe { std::slice::from_raw_parts(data_ptr, len) }; - match std::str::from_utf8(bytes) { - Ok(value) => Some(value.to_string()), - Err(_) => { - log::error!("{func_name}: invalid UTF-8 in {arg_name}"); - None - } + if let Ok(value) = std::str::from_utf8(bytes) { + Some(value.to_string()) + } else { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None } } @@ -80,12 +79,11 @@ unsafe fn read_direct_string_arg( } let bytes = unsafe { std::slice::from_raw_parts(ptr, len) }; - match std::str::from_utf8(bytes) { - Ok(value) => Some(value.to_string()), - Err(_) => { - log::error!("{func_name}: invalid UTF-8 in {arg_name}"); - None - } + if let Ok(value) = std::str::from_utf8(bytes) { + Some(value.to_string()) + } else { + log::error!("{func_name}: invalid UTF-8 in {arg_name}"); + None } } @@ -1607,9 +1605,9 @@ mod tests { unsafe { pecos_qis_trace_metadata_direct( key.as_ptr(), - key.len() as i64, + i64::try_from(key.len()).expect("test key length fits in i64"), value.as_ptr(), - value.len() as i64, + i64::try_from(value.len()).expect("test value length fits in i64"), ); } @@ -1708,11 +1706,16 @@ mod tests { fn test_trace_metadata_qubit_hugr_expands_packed_json_metadata() { setup_test(); let mut key = Vec::with_capacity(PACKED_TRACE_METADATA_JSON_KEY.len() + 1); - key.push(PACKED_TRACE_METADATA_JSON_KEY.len() as u8); + // tket "pascal string" layout: a single-byte length prefix, so the key + // length must fit in u8 (< 256). + key.push( + u8::try_from(PACKED_TRACE_METADATA_JSON_KEY.len()).expect("key length fits in u8"), + ); key.extend_from_slice(PACKED_TRACE_METADATA_JSON_KEY.as_bytes()); let value = br#"{"host_id":"probe:host","source_kind":"szz_host"}"#; let mut packed = Vec::with_capacity(value.len() + 1); - packed.push(value.len() as u8); + // Single-byte length prefix (tket pascal-string layout): value length < 256. + packed.push(u8::try_from(value.len()).expect("value length fits in u8")); packed.extend_from_slice(value); let returned = diff --git a/crates/pecos-qis/src/executor.rs b/crates/pecos-qis/src/executor.rs index 3d9bebed3..5a135c1cd 100644 --- a/crates/pecos-qis/src/executor.rs +++ b/crates/pecos-qis/src/executor.rs @@ -982,7 +982,7 @@ impl QisHeliosInterface { Err(e) => Err(e.to_string()), } } - Err(e) => Err(e.to_string()), + Err(e) => Err(e.clone()), }); result diff --git a/crates/pecos-qis/src/selene_runtime.rs b/crates/pecos-qis/src/selene_runtime.rs index 4505b13fd..c33d7a80f 100644 --- a/crates/pecos-qis/src/selene_runtime.rs +++ b/crates/pecos-qis/src/selene_runtime.rs @@ -67,6 +67,10 @@ struct SourceTraceMetadata { metadata: TraceMetadata, } +// `_fn` postfix is intentional: this is a `#[repr(C)]` vtable of Selene runtime +// callback function pointers, where the suffix marks each field as a function +// pointer. Bare names like `reset`/`custom`/`measure` would be ambiguous here. +#[allow(clippy::struct_field_names)] #[repr(C)] struct SeleneRuntimeGetOperationInterface { rzz_fn: extern "C" fn(RuntimeGetOperationInstance, u64, u64, f64), @@ -414,7 +418,7 @@ impl SeleneRuntime { &raw mut instance, plugin_num_qubits as u64, 0, // start time - arg_ptrs.len() as u32, + u32::try_from(arg_ptrs.len()).expect("plugin argument count fits in u32"), argv, ); @@ -444,8 +448,7 @@ impl SeleneRuntime { } debug!( - "Reinitializing Selene plugin capacity from {} to {} qubits", - initialized_num_qubits, plugin_num_qubits + "Reinitializing Selene plugin capacity from {initialized_num_qubits} to {plugin_num_qubits} qubits" ); self.reset_plugin_instance()?; self.load_plugin() @@ -1264,7 +1267,8 @@ impl SeleneRuntime { } ( QuantumOp::ZZ(source_qubit_1, source_qubit_2), - QuantumOp::ZZ(lowered_qubit_1, lowered_qubit_2), + QuantumOp::ZZ(lowered_qubit_1, lowered_qubit_2) + | QuantumOp::RZZ(_, lowered_qubit_1, lowered_qubit_2), ) => Self::same_unordered_pair( *source_qubit_1, *source_qubit_2, @@ -1283,15 +1287,6 @@ impl SeleneRuntime { *lowered_qubit_2, ) } - ( - QuantumOp::ZZ(source_qubit_1, source_qubit_2), - QuantumOp::RZZ(_, lowered_qubit_1, lowered_qubit_2), - ) => Self::same_unordered_pair( - *source_qubit_1, - *source_qubit_2, - *lowered_qubit_1, - *lowered_qubit_2, - ), ( QuantumOp::Measure(source_qubit, source_result), QuantumOp::Measure(lowered_qubit, lowered_result), @@ -1620,7 +1615,7 @@ fn operation_capacity_with_mode( for op in operations { match op { Operation::Quantum(qop) if !uses_explicit_allocations => { - include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results) + include_quantum_op_capacity(qop, &mut num_qubits, &mut num_results); } Operation::Quantum(qop) => { include_quantum_result_capacity(qop, &mut num_results); @@ -1636,8 +1631,7 @@ fn operation_capacity_with_mode( Operation::RecordOutput { result_id, .. } => { include_result(&mut num_results, *result_id); } - Operation::TraceMetadata { .. } => {} - Operation::Barrier => {} + Operation::TraceMetadata { .. } | Operation::Barrier => {} } } if uses_explicit_allocations { diff --git a/crates/pecos-quantum/src/pass.rs b/crates/pecos-quantum/src/pass.rs index 879ab67ab..6b74064da 100644 --- a/crates/pecos-quantum/src/pass.rs +++ b/crates/pecos-quantum/src/pass.rs @@ -1306,7 +1306,7 @@ fn canonical_single_qubit_clifford_sequence(clifford: Clifford) -> Vec } fn flush_single_qubit_clifford_chain( - chain: SingleQubitCliffordChain, + chain: &SingleQubitCliffordChain, replacements: &mut BTreeMap<(usize, usize), GateType>, to_remove: &mut HashSet<(usize, usize)>, ) { @@ -1367,14 +1367,18 @@ impl CircuitPass for SimplifySingleQubitCliffordChains { for &qubit in &gate.qubits { if let Some(chain) = pending.remove(&qubit) { - flush_single_qubit_clifford_chain(chain, &mut replacements, &mut to_remove); + flush_single_qubit_clifford_chain( + &chain, + &mut replacements, + &mut to_remove, + ); } } } } for (_, chain) in pending { - flush_single_qubit_clifford_chain(chain, &mut replacements, &mut to_remove); + flush_single_qubit_clifford_chain(&chain, &mut replacements, &mut to_remove); } for (&(ti, gi), &gate_type) in &replacements { @@ -3166,7 +3170,11 @@ mod tests { SimplifySingleQubitCliffordChains.apply_tick(&mut tc); - assert!(tc.ticks().iter().all(|tick| tick.is_empty())); + assert!( + tc.ticks() + .iter() + .all(super::super::tick_circuit::Tick::is_empty) + ); } #[test] diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs index 44ea2ff82..53ce80700 100644 --- a/crates/pecos-uf-decoder/src/decoder.rs +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -230,6 +230,12 @@ impl UfDecoder { /// Build from a `DemMatchingGraph`. /// + /// # Errors + /// + /// Returns [`DecoderError`] if the matching graph carries more than 64 + /// observables: this decoder packs observable flips into a `u64`, so wider + /// observable sets are rejected rather than silently truncated. + /// /// # Panics /// /// Panics if any edge weight is negative or NaN: the predecoder's @@ -237,7 +243,6 @@ impl UfDecoder { /// premise must fail loudly rather than silently mis-decode. Callers /// holding untrusted DEM input should pre-validate with /// [`Self::check_non_negative_weights`] to get an error instead. - #[must_use] pub fn from_matching_graph( graph: &DemMatchingGraph, config: UfDecoderConfig, diff --git a/exp/guppy-zlup/src/ir.rs b/exp/guppy-zlup/src/ir.rs index 508cb2693..0c9f62c3a 100644 --- a/exp/guppy-zlup/src/ir.rs +++ b/exp/guppy-zlup/src/ir.rs @@ -452,29 +452,21 @@ impl IrValidator { match stmt.kind { StmtKind::Qalloc => { // Schema: requires name - if stmt.name.is_none() { + if let Some(name) = &stmt.name { + self.allocators.insert(name.clone()); + self.variables.insert(name.clone()); + } else { self.result.add_error(ValidationError::MissingField { kind: stmt.kind.clone(), field: "name", location: stmt.location.clone(), }); - } else { - let name = stmt.name.as_ref().unwrap(); - self.allocators.insert(name.clone()); - self.variables.insert(name.clone()); } } StmtKind::Gate => { // Schema: requires gate - if stmt.gate.is_none() { - self.result.add_error(ValidationError::MissingField { - kind: stmt.kind.clone(), - field: "gate", - location: stmt.location.clone(), - }); - } else { - let gate = stmt.gate.as_ref().unwrap(); + if let Some(gate) = &stmt.gate { // Check arity if stmt.targets.len() != gate.arity() { self.result.add_error(ValidationError::InvalidGateArity { @@ -484,6 +476,12 @@ impl IrValidator { location: stmt.location.clone(), }); } + } else { + self.result.add_error(ValidationError::MissingField { + kind: stmt.kind.clone(), + field: "gate", + location: stmt.location.clone(), + }); } // Validate targets reference known allocators for target in &stmt.targets { @@ -533,14 +531,14 @@ impl IrValidator { StmtKind::While => { // Schema: requires condition - if stmt.condition.is_none() { + if let Some(condition) = &stmt.condition { + self.validate_expr(condition); + } else { self.result.add_error(ValidationError::MissingField { kind: stmt.kind.clone(), field: "condition", location: stmt.location.clone(), }); - } else { - self.validate_expr(stmt.condition.as_ref().unwrap()); } for s in &stmt.body { self.validate_stmt(s); @@ -549,14 +547,14 @@ impl IrValidator { StmtKind::If => { // Schema: requires condition - if stmt.condition.is_none() { + if let Some(condition) = &stmt.condition { + self.validate_expr(condition); + } else { self.result.add_error(ValidationError::MissingField { kind: stmt.kind.clone(), field: "condition", location: stmt.location.clone(), }); - } else { - self.validate_expr(stmt.condition.as_ref().unwrap()); } for s in &stmt.then_body { self.validate_stmt(s); diff --git a/exp/guppy-zlup/src/linter/config.rs b/exp/guppy-zlup/src/linter/config.rs index 6ef9717bc..399d6cb05 100644 --- a/exp/guppy-zlup/src/linter/config.rs +++ b/exp/guppy-zlup/src/linter/config.rs @@ -158,8 +158,10 @@ mod tests { #[test] fn test_rule_enabled() { - let mut config = Config::default(); - config.enabled_rules = vec!["ZLUP001".to_string(), "ZLUP002".to_string()]; + let config = Config { + enabled_rules: vec!["ZLUP001".to_string(), "ZLUP002".to_string()], + ..Default::default() + }; assert!(config.is_rule_enabled("ZLUP001")); assert!(config.is_rule_enabled("ZLUP002")); diff --git a/exp/guppy-zlup/src/linter/rules/zlup003.rs b/exp/guppy-zlup/src/linter/rules/zlup003.rs index 1ac6d916d..a4ff7cb37 100644 --- a/exp/guppy-zlup/src/linter/rules/zlup003.rs +++ b/exp/guppy-zlup/src/linter/rules/zlup003.rs @@ -124,20 +124,14 @@ fn check_stmt( check_stmt(s, filename, source, loop_depth, diagnostics); } } - Stmt::Expr(expr_stmt) => { - if loop_depth > 0 { - check_expr(&expr_stmt.value, filename, source, diagnostics); - } + Stmt::Expr(expr_stmt) if loop_depth > 0 => { + check_expr(&expr_stmt.value, filename, source, diagnostics); } - Stmt::Assign(assign) => { - if loop_depth > 0 { - check_expr(&assign.value, filename, source, diagnostics); - } + Stmt::Assign(assign) if loop_depth > 0 => { + check_expr(&assign.value, filename, source, diagnostics); } - Stmt::AugAssign(aug) => { - if loop_depth > 0 { - check_expr(&aug.value, filename, source, diagnostics); - } + Stmt::AugAssign(aug) if loop_depth > 0 => { + check_expr(&aug.value, filename, source, diagnostics); } Stmt::AnnAssign(ann) => { if loop_depth > 0 diff --git a/exp/zlup/src/codegen/hugr.rs b/exp/zlup/src/codegen/hugr.rs index f0e4f379d..435cab614 100644 --- a/exp/zlup/src/codegen/hugr.rs +++ b/exp/zlup/src/codegen/hugr.rs @@ -446,12 +446,11 @@ impl HugrCodegen { fn collect_top_level(&mut self, decl: &TopLevelDecl) -> HugrResult<()> { match decl { - TopLevelDecl::Fn(fn_decl) => { + TopLevelDecl::Fn(fn_decl) // Only collect from main function for now - if fn_decl.name == "main" { + if fn_decl.name == "main" => { self.collect_block(&fn_decl.body)?; } - } TopLevelDecl::Binding(binding) => { self.collect_binding(binding)?; } @@ -2212,14 +2211,11 @@ mod tests { } "#; - // This should compile (same qubit used twice is a logic issue, not arity issue) - // The arity check happens at the gate expression level, not here - let result = compile_to_hugr(source); - // Accept either ok or specific error - match &result { - Ok(_) => {} // Valid syntax, even if logically odd - Err(_) => {} // Some backends may reject - } + // A repeated qubit (`cx q[0], q[0]`) is a logic issue, not a syntax/arity + // error (arity is checked at the gate expression level, not here), so + // either an Ok or an Err result is acceptable: this only requires that + // codegen does not panic on such input. + let _ = compile_to_hugr(source); } #[test] diff --git a/exp/zlup/src/codegen/phir.rs b/exp/zlup/src/codegen/phir.rs index f640ee376..35af45c7f 100644 --- a/exp/zlup/src/codegen/phir.rs +++ b/exp/zlup/src/codegen/phir.rs @@ -757,10 +757,8 @@ impl PhirJsonCodegen { fn collect_decl(&mut self, decl: &TopLevelDecl) -> PhirJsonResult<()> { match decl { - TopLevelDecl::Fn(fn_decl) => { - if fn_decl.name == "main" { - self.collect_block(&fn_decl.body)?; - } + TopLevelDecl::Fn(fn_decl) if fn_decl.name == "main" => { + self.collect_block(&fn_decl.body)?; } TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, _ => {} diff --git a/exp/zlup/src/codegen/qasm.rs b/exp/zlup/src/codegen/qasm.rs index c42f6360d..a4d2e2d1b 100644 --- a/exp/zlup/src/codegen/qasm.rs +++ b/exp/zlup/src/codegen/qasm.rs @@ -352,10 +352,8 @@ impl QasmCodegen { fn collect_decl(&mut self, decl: &TopLevelDecl) -> QasmResult<()> { match decl { - TopLevelDecl::Fn(fn_decl) => { - if fn_decl.name == "main" { - self.collect_block(&fn_decl.body)?; - } + TopLevelDecl::Fn(fn_decl) if fn_decl.name == "main" => { + self.collect_block(&fn_decl.body)?; } TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, _ => {} diff --git a/exp/zlup/src/codegen/slr.rs b/exp/zlup/src/codegen/slr.rs index 88ae28bd4..0a1ff2aa0 100644 --- a/exp/zlup/src/codegen/slr.rs +++ b/exp/zlup/src/codegen/slr.rs @@ -1436,10 +1436,8 @@ impl SlrCodegen { fn collect_decl(&mut self, decl: &TopLevelDecl) -> SlrResult<()> { match decl { - TopLevelDecl::Fn(fn_decl) => { - if fn_decl.name == "main" { - self.collect_block(&fn_decl.body)?; - } + TopLevelDecl::Fn(fn_decl) if fn_decl.name == "main" => { + self.collect_block(&fn_decl.body)?; } TopLevelDecl::Binding(binding) => self.collect_binding(binding)?, _ => {} diff --git a/exp/zlup/src/comptime.rs b/exp/zlup/src/comptime.rs index 10a9fde7f..98814632b 100644 --- a/exp/zlup/src/comptime.rs +++ b/exp/zlup/src/comptime.rs @@ -498,7 +498,11 @@ impl ComptimeEvaluator { let field_strs: Vec<_> = fields .iter() .map(|(k, v)| { - format!("{}:{}", k, Self::serialize_args_for_cache(&[v.clone()])) + format!( + "{}:{}", + k, + Self::serialize_args_for_cache(std::slice::from_ref(v)) + ) }) .collect(); format!("st{}[{}]", name, field_strs.join(";")) @@ -552,7 +556,7 @@ impl ComptimeEvaluator { Type::Tuple { elements } => { let elem_strs: Vec<_> = elements .iter() - .map(|e| Self::serialize_type_for_cache(e)) + .map(Self::serialize_type_for_cache) .collect(); format!("({})", elem_strs.join(",")) } @@ -2108,10 +2112,10 @@ impl ComptimeEvaluator { match expr { Expr::Ident(ident) => { // Check if it's a type in the context - if let Some(val) = self.context.lookup(&ident.name) { - if let ComptimeValue::Type(ty) = val { - return Ok(ty.clone()); - } + if let Some(val) = self.context.lookup(&ident.name) + && let ComptimeValue::Type(ty) = val + { + return Ok(ty.clone()); } // Try to resolve primitive types match ident.name.as_str() { diff --git a/exp/zlup/src/docgen.rs b/exp/zlup/src/docgen.rs index 317cd72d4..ac9f60826 100644 --- a/exp/zlup/src/docgen.rs +++ b/exp/zlup/src/docgen.rs @@ -5,7 +5,7 @@ use crate::ast::*; /// Configuration for documentation generation. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct DocConfig { /// Include private (non-pub) items pub include_private: bool, @@ -13,15 +13,6 @@ pub struct DocConfig { pub show_locations: bool, } -impl Default for DocConfig { - fn default() -> Self { - Self { - include_private: false, - show_locations: false, - } - } -} - /// Kind of documented item. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum DocItemKind { @@ -257,10 +248,7 @@ fn extract_union_doc(u: &UnionDecl) -> DocItem { .fields .iter() .map(|f| { - let desc = - f.ty.as_ref() - .map(|t| format_type_expr(t)) - .unwrap_or_default(); + let desc = f.ty.as_ref().map(format_type_expr).unwrap_or_default(); DocChild { name: f.name.clone(), description: desc, diff --git a/exp/zlup/src/linter.rs b/exp/zlup/src/linter.rs index feddb74a5..1950380d4 100644 --- a/exp/zlup/src/linter.rs +++ b/exp/zlup/src/linter.rs @@ -1233,7 +1233,7 @@ pub fn apply_fixes( // Sort by start position (descending) so we can apply from end to start // This prevents offset shifts from affecting earlier fixes - fixes.sort_by(|a, b| b.start.cmp(&a.start)); + fixes.sort_by_key(|f| std::cmp::Reverse(f.start)); // Check for overlapping fixes and remove conflicts let mut result = source.to_string(); diff --git a/exp/zlup/src/main.rs b/exp/zlup/src/main.rs index 7b40a3815..d4ff362e0 100644 --- a/exp/zlup/src/main.rs +++ b/exp/zlup/src/main.rs @@ -804,7 +804,7 @@ fn cmd_build( // Compute effective settings let (default_strict, _) = effective_settings(target, mode); - let strict = strict_override.unwrap_or_else(|| config.build.strict || default_strict); + let strict = strict_override.unwrap_or(config.build.strict || default_strict); eprintln!( "Building {} ({}) [{:?} -> {:?}, {}]", @@ -816,17 +816,17 @@ fn cmd_build( ); // Compile - cmd_compile( - entry_path, - Some(output_path.clone()), + cmd_compile(CompileOptions { + input: entry_path, + output: Some(output_path.clone()), target, format, mode, compact, - Some(strict), - None, - false, - )?; + strict_override: Some(strict), + log_level_override: None, + elide_sim: false, + })?; eprintln!( "Built {} -> {}", @@ -837,8 +837,8 @@ fn cmd_build( Ok(()) } -/// Execute the compile command. -fn cmd_compile( +/// Options for the compile command. +struct CompileOptions { input: PathBuf, output: Option, target: Target, @@ -848,7 +848,22 @@ fn cmd_compile( strict_override: Option, log_level_override: Option, elide_sim: bool, -) -> Result<(), CliError> { +} + +/// Execute the compile command. +fn cmd_compile(opts: CompileOptions) -> Result<(), CliError> { + let CompileOptions { + input, + output, + target, + format, + mode, + compact, + strict_override, + log_level_override, + elide_sim, + } = opts; + // Resolve settings from target + mode with overrides let (default_strict, default_log_level) = effective_settings(target, mode); let strict = strict_override.unwrap_or(default_strict); @@ -1843,10 +1858,8 @@ fn cmd_eval(expr: String, verbose: bool) -> Result<(), CliError> { } } } - zlup::ast::TopLevelDecl::Fn(func) => { - if verbose { - println!("fn {} defined", func.name); - } + zlup::ast::TopLevelDecl::Fn(func) if verbose => { + println!("fn {} defined", func.name); } _ => {} } @@ -1969,9 +1982,17 @@ fn main() -> ExitCode { strict, log_level, elide_sim, - } => cmd_compile( - input, output, target, format, mode, compact, strict, log_level, elide_sim, - ), + } => cmd_compile(CompileOptions { + input, + output, + target, + format, + mode, + compact, + strict_override: strict, + log_level_override: log_level, + elide_sim, + }), Commands::Check { input, strict } => cmd_check(input, strict), diff --git a/exp/zlup/src/optimize.rs b/exp/zlup/src/optimize.rs index 4504b89f7..e61486a4c 100644 --- a/exp/zlup/src/optimize.rs +++ b/exp/zlup/src/optimize.rs @@ -269,17 +269,18 @@ impl Optimizer { Stmt::For(for_stmt) => { // Try to unroll inline for loops - if self.config.inline_for_unrolling && for_stmt.is_inline { - if let Some(unrolled) = self.try_unroll_inline_for(&for_stmt) { - // Return the unrolled statements as a block - return Some(Stmt::Block(Block { - label: for_stmt.label.clone(), - attrs: vec![], - statements: unrolled, - trailing_expr: None, - location: for_stmt.location.clone(), - })); - } + if self.config.inline_for_unrolling + && for_stmt.is_inline + && let Some(unrolled) = self.try_unroll_inline_for(&for_stmt) + { + // Return the unrolled statements as a block + return Some(Stmt::Block(Block { + label: for_stmt.label.clone(), + attrs: vec![], + statements: unrolled, + trailing_expr: None, + location: for_stmt.location.clone(), + })); } Some(Stmt::For(self.optimize_for(for_stmt))) } @@ -2204,10 +2205,10 @@ pub fn main() -> unit { .statements .iter() .filter(|s| { - if let Stmt::Expr(expr_stmt) = s { - if let Expr::Gate(gate) = &expr_stmt.expr { - return gate.kind == GateKind::H; - } + if let Stmt::Expr(expr_stmt) = s + && let Expr::Gate(gate) = &expr_stmt.expr + { + return gate.kind == GateKind::H; } false }) @@ -2515,10 +2516,10 @@ pub fn main() -> unit { // Note: Gates are represented as Stmt::Expr containing Expr::Gate if let TopLevelDecl::Fn(fn_decl) = &optimized.declarations[0] { let has_rz = fn_decl.body.statements.iter().any(|s| { - if let Stmt::Expr(expr_stmt) = s { - if let Expr::Gate(gate) = &expr_stmt.expr { - return gate.kind == GateKind::RZ; - } + if let Stmt::Expr(expr_stmt) = s + && let Expr::Gate(gate) = &expr_stmt.expr + { + return gate.kind == GateKind::RZ; } false }); @@ -2579,10 +2580,10 @@ pub fn main() -> unit { .statements .iter() .filter(|s| { - if let Stmt::Expr(expr_stmt) = s { - if let Expr::Gate(gate) = &expr_stmt.expr { - return gate.kind == GateKind::H; - } + if let Stmt::Expr(expr_stmt) = s + && let Expr::Gate(gate) = &expr_stmt.expr + { + return gate.kind == GateKind::H; } false }) diff --git a/exp/zlup/src/parser.rs b/exp/zlup/src/parser.rs index 9c72030c6..3de50d842 100644 --- a/exp/zlup/src/parser.rs +++ b/exp/zlup/src/parser.rs @@ -248,10 +248,8 @@ impl<'a> ParserState<'a> { Rule::mut_keyword => { is_mutable = true; } - Rule::identifier => { - if name.is_empty() { - name = item.as_str().to_string(); - } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); } Rule::type_expr => { ty = Some(self.parse_type_expr(item)?); @@ -373,10 +371,8 @@ impl<'a> ParserState<'a> { // Remove quotes calling_convention = s[1..s.len() - 1].to_string(); } - Rule::identifier => { - if name.is_empty() { - name = item.as_str().to_string(); - } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); } Rule::param_list => { params = self.parse_param_list(item)?; @@ -423,7 +419,8 @@ impl<'a> ParserState<'a> { /// Parse a single parameter. fn parse_param(&self, pair: Pair<'a, Rule>) -> ParseResult { - let location = Some(self.location(&pair)); + let loc = self.location(&pair); + let location = Some(loc.clone()); let inner_pair = self.expect_inner(pair, "param")?; match inner_pair.as_rule() { @@ -466,15 +463,13 @@ impl<'a> ParserState<'a> { // Regular parameter: comptime? name: type self.parse_regular_param(inner_pair, location) } - other => { - return Err(ParseError { - message: format!( - "unexpected rule {:?}, expected param (self_param or regular_param)", - other - ), - location: location.unwrap_or_default(), - }); - } + other => Err(ParseError { + message: format!( + "unexpected rule {:?}, expected param (self_param or regular_param)", + other + ), + location: loc, + }), } } @@ -830,10 +825,8 @@ impl<'a> ParserState<'a> { Rule::doc_comment => { doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); } - Rule::identifier => { - if name.is_empty() { - name = item.as_str().to_string(); - } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); } Rule::error_set_body => { variants = self.parse_error_set_body(item)?; @@ -870,10 +863,8 @@ impl<'a> ParserState<'a> { Rule::doc_comment => { doc_comment = Some(item.as_str().trim_start_matches("///").trim().to_string()); } - Rule::identifier => { - if name.is_empty() { - name = item.as_str().to_string(); - } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); } Rule::error_set_body => { // Reuse error_set_body parsing for fault variants @@ -981,10 +972,8 @@ impl<'a> ParserState<'a> { Rule::pub_keyword => { is_pub = true; } - Rule::identifier => { - if name.is_empty() { - name = item.as_str().to_string(); - } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); } Rule::gate_param_list => { params = self.parse_gate_param_list(item)?; @@ -1029,10 +1018,8 @@ impl<'a> ParserState<'a> { Rule::pub_keyword => { is_pub = true; } - Rule::identifier => { - if name.is_empty() { - name = item.as_str().to_string(); - } + Rule::identifier if name.is_empty() => { + name = item.as_str().to_string(); } Rule::gate_param_list => { params = self.parse_gate_param_list(item)?; @@ -1068,10 +1055,8 @@ impl<'a> ParserState<'a> { let mut ty = None; for inner in item.into_inner() { match inner.as_rule() { - Rule::identifier => { - if name.is_empty() { - name = inner.as_str().to_string(); - } + Rule::identifier if name.is_empty() => { + name = inner.as_str().to_string(); } Rule::type_expr => { ty = Some(self.parse_type_expr(inner)?); @@ -1517,8 +1502,8 @@ impl<'a> ParserState<'a> { /// Parse if statement. fn parse_if_stmt(&self, pair: Pair<'a, Rule>) -> ParseResult { - let location = Some(self.location(&pair)); - let loc = location.clone().unwrap_or_default(); + let loc = self.location(&pair); + let location = Some(loc.clone()); let mut inner = pair.into_inner(); // First is either if_unwrap_clause or if_condition @@ -1550,7 +1535,7 @@ impl<'a> ParserState<'a> { "unexpected rule {:?}, expected if_unwrap_clause or if_condition", other ), - location: location.unwrap_or_default(), + location: loc, }); } }; @@ -1763,16 +1748,13 @@ impl<'a> ParserState<'a> { let mut end = None; for item in pair.into_inner() { - match item.as_rule() { - Rule::expr => { - // First expr is start, second is end - if start.is_none() { - start = Some(self.parse_expr(item)?); - } else { - end = Some(self.parse_expr(item)?); - } + if item.as_rule() == Rule::expr { + // First expr is start, second is end + if start.is_none() { + start = Some(self.parse_expr(item)?); + } else { + end = Some(self.parse_expr(item)?); } - _ => {} } } @@ -3138,22 +3120,21 @@ impl<'a> ParserState<'a> { /// Parse struct initialization. fn parse_struct_init(&self, pair: Pair<'a, Rule>) -> ParseResult { - let location = Some(self.location(&pair)); + let loc = self.location(&pair); + let location = Some(loc.clone()); let inner_pair = self.expect_inner(pair, "struct_init")?; // struct_init contains either typed_struct_init or anon_struct_init match inner_pair.as_rule() { Rule::typed_struct_init => self.parse_typed_struct_init(inner_pair, location), Rule::anon_struct_init => self.parse_anon_struct_init(inner_pair, location), - other => { - return Err(ParseError { - message: format!( - "unexpected rule {:?}, expected typed_struct_init or anon_struct_init", - other - ), - location: location.unwrap_or_default(), - }); - } + other => Err(ParseError { + message: format!( + "unexpected rule {:?}, expected typed_struct_init or anon_struct_init", + other + ), + location: loc, + }), } } @@ -4028,8 +4009,8 @@ pub fn edit_distance(a: &str, b: &str) -> usize { // Use single-row optimization let mut prev = vec![0usize; n + 1]; - for j in 0..=n { - prev[j] = j; + for (j, slot) in prev.iter_mut().enumerate() { + *slot = j; } for i in 1..=m { diff --git a/exp/zlup/src/pretty.rs b/exp/zlup/src/pretty.rs index d72a463b1..f44e2c917 100644 --- a/exp/zlup/src/pretty.rs +++ b/exp/zlup/src/pretty.rs @@ -709,11 +709,11 @@ impl PrettyPrinter { fn print_return_stmt(&mut self, r: &ReturnStmt) { self.write("return"); // Simplify `return unit;` to `return;` for cleaner output - if let Some(val) = &r.value { - if !matches!(val, Expr::Unit(_)) { - self.write(" "); - self.print_expr(val); - } + if let Some(val) = &r.value + && !matches!(val, Expr::Unit(_)) + { + self.write(" "); + self.print_expr(val); } self.write(";"); self.newline(); diff --git a/exp/zlup/src/rational.rs b/exp/zlup/src/rational.rs index 386e33b4b..2aa4c43f9 100644 --- a/exp/zlup/src/rational.rs +++ b/exp/zlup/src/rational.rs @@ -746,8 +746,10 @@ mod tests { #[test] fn test_from_f64_exact_roundtrip() { - // Any float should round-trip through exact conversion - let values = [0.1, 0.2, 0.3, 0.7, 1.1, 3.14159, 0.123456789]; + // Any float should round-trip through exact conversion. The values are + // deliberately arbitrary (not math constants) - the point is exact + // dyadic round-tripping, not the specific numbers. + let values = [0.1, 0.2, 0.3, 0.7, 1.1, 3.65432, 0.123456789]; for &v in &values { let r = Rational::from_f64_exact(v).unwrap(); assert_eq!(r.to_f64(), v, "Round-trip failed for {}", v); diff --git a/exp/zlup/src/semantic.rs b/exp/zlup/src/semantic.rs index 74076dfd1..4a26e55e3 100644 --- a/exp/zlup/src/semantic.rs +++ b/exp/zlup/src/semantic.rs @@ -311,15 +311,14 @@ pub enum SemanticError { ContinueInInlineFor { location: SourceLocation }, #[error( - "alias '{new_alias}' overlaps with existing alias '{existing_alias}' on source '{source_var}'" + "alias '{}' overlaps with existing alias '{}' on source '{}'", + .0.new_alias, .0.existing_alias, .0.source_var )] - OverlappingAlias { - new_alias: String, - existing_alias: String, - source_var: String, - overlap_range: String, - location: SourceLocation, - }, + // Boxed because this is the only variant whose inline payload (four `String`s + // plus a `SourceLocation`) pushes `SemanticError` over clippy's 128-byte + // `result_large_err` threshold; boxing keeps the enum (and every + // `SemanticResult`) small. + OverlappingAlias(Box), #[error("alias source must be a slice expression (e.g., arr[0..4]), found '{found}'")] AliasSourceNotSlice { @@ -410,6 +409,20 @@ pub enum SemanticError { }, } +/// Boxed payload for [`SemanticError::OverlappingAlias`]. +/// +/// Kept behind a `Box` so the large set of fields does not inflate +/// `SemanticError` (and therefore every `SemanticResult`) past clippy's +/// `result_large_err` size threshold. +#[derive(Debug, Clone)] +pub struct OverlappingAliasError { + pub new_alias: String, + pub existing_alias: String, + pub source_var: String, + pub overlap_range: String, + pub location: SourceLocation, +} + impl SemanticError { /// Get the source location of the error, if available. pub fn location(&self) -> Option<&SourceLocation> { @@ -455,7 +468,7 @@ impl SemanticError { Self::InlineForRangeNotComptime { location, .. } => Some(location), Self::BreakInInlineFor { location } => Some(location), Self::ContinueInInlineFor { location } => Some(location), - Self::OverlappingAlias { location, .. } => Some(location), + Self::OverlappingAlias(e) => Some(&e.location), Self::AliasSourceNotSlice { location, .. } => Some(location), Self::AliasRangeNotComptime { location, .. } => Some(location), Self::MissingReturn { location, .. } => Some(location), @@ -1835,14 +1848,13 @@ impl SemanticAnalyzer { let mut rec_stack = BTreeSet::new(); for func in &self.user_functions { - if !visited.contains(func) { - if let Some(cycle_func) = self.dfs_detect_cycle(func, &mut visited, &mut rec_stack) - { - return Err(SemanticError::RecursionDetected { - name: cycle_func, - location: SourceLocation::default(), - }); - } + if !visited.contains(func) + && let Some(cycle_func) = self.dfs_detect_cycle(func, &mut visited, &mut rec_stack) + { + return Err(SemanticError::RecursionDetected { + name: cycle_func, + location: SourceLocation::default(), + }); } } Ok(()) @@ -1865,10 +1877,10 @@ impl SemanticAnalyzer { return Some(callee.clone()); } // If not visited, recurse - if !visited.contains(callee) { - if let Some(cycle) = self.dfs_detect_cycle(callee, visited, rec_stack) { - return Some(cycle); - } + if !visited.contains(callee) + && let Some(cycle) = self.dfs_detect_cycle(callee, visited, rec_stack) + { + return Some(cycle); } } } @@ -2374,10 +2386,10 @@ impl SemanticAnalyzer { } // Also check trailing expression - if it always returns, block returns // (e.g., `if (cond) { return 1; } else { return 2; }` as trailing expr) - if let Some(trailing) = &block.trailing_expr { - if self.expr_always_returns(trailing) { - return true; - } + if let Some(trailing) = &block.trailing_expr + && self.expr_always_returns(trailing) + { + return true; } false } @@ -2745,13 +2757,13 @@ impl SemanticAnalyzer { // Try to get a string representation for duplicate detection // For literals and simple expressions, use their string form let case_key = self.case_value_key(&case.value); - if let Some(key) = case_key { - if !seen_cases.insert(key.clone()) { - return Err(SemanticError::DuplicateSwitchCase { - value: key, - location: case.location.clone().unwrap_or_default(), - }); - } + if let Some(key) = case_key + && !seen_cases.insert(key.clone()) + { + return Err(SemanticError::DuplicateSwitchCase { + value: key, + location: case.location.clone().unwrap_or_default(), + }); } } self.analyze_expr(&prong.body)?; @@ -2769,13 +2781,13 @@ impl SemanticAnalyzer { } else { // `return;` without a value is only allowed for unit functions // (equivalent to `return unit;`) - if let Some(expected) = &self.current_return_type { - if !matches!(expected, Type::Unit) { - return Err(SemanticError::ReturnWithoutValue { - expected: expected.display_name(), - location: ret.location.clone().unwrap_or_default(), - }); - } + if let Some(expected) = &self.current_return_type + && !matches!(expected, Type::Unit) + { + return Err(SemanticError::ReturnWithoutValue { + expected: expected.display_name(), + location: ret.location.clone().unwrap_or_default(), + }); } // If no return type specified, unit is implied - return; is valid } @@ -3190,13 +3202,13 @@ impl SemanticAnalyzer { } // Record call in call graph for mutual recursion detection - if let Some(ref caller) = self.current_function { - if self.user_functions.contains(&ident.name) { - self.call_graph - .entry(caller.clone()) - .or_default() - .insert(ident.name.clone()); - } + if let Some(ref caller) = self.current_function + && self.user_functions.contains(&ident.name) + { + self.call_graph + .entry(caller.clone()) + .or_default() + .insert(ident.name.clone()); } // Check if this is a generic function that needs instantiation @@ -3208,16 +3220,14 @@ impl SemanticAnalyzer { return_type: fn_return_type, .. } = &symbol.kind + && !comptime_param_indices.is_empty() + && let Some(original) = original_decl { - if !comptime_param_indices.is_empty() { - if let Some(original) = original_decl { - return Some(( - comptime_param_indices.clone(), - *original.clone(), - fn_return_type.clone(), - )); - } - } + return Some(( + comptime_param_indices.clone(), + *original.clone(), + fn_return_type.clone(), + )); } None }); @@ -3649,16 +3659,15 @@ impl SemanticAnalyzer { Ok(Type::Slice { element }) } else { // Bounds check: if both size and index are known at compile time - if let Some(n) = size { - if let Some(idx) = self.try_extract_constant_usize(&index.index) { - if idx >= n as usize { - return Err(SemanticError::ArrayIndexOutOfBounds { - index: idx, - size: n, - location: index.location.clone().unwrap_or_default(), - }); - } - } + if let Some(n) = size + && let Some(idx) = self.try_extract_constant_usize(&index.index) + && idx >= n as usize + { + return Err(SemanticError::ArrayIndexOutOfBounds { + index: idx, + size: n, + location: index.location.clone().unwrap_or_default(), + }); } // arr[0] returns an element Ok(*element) @@ -4333,31 +4342,30 @@ impl SemanticAnalyzer { } // Allow fault value to be assigned to T!F (returning fault from fault union function) - if let Type::ErrorUnion { error, .. } = target { - if let Type::FaultSet { + if let Type::ErrorUnion { error, .. } = target + && let Type::FaultSet { name: value_name, faults: value_faults, } = value + { + if let Type::FaultSet { + name: expected_name, + faults: expected_faults, + } = error.as_ref() { - if let Type::FaultSet { - name: expected_name, - faults: expected_faults, - } = error.as_ref() - { - // Exact match - if value_name == expected_name { - return Ok(()); - } - // Value's faults are a subset of expected's faults (union compatibility) - if value_faults.iter().all(|f| expected_faults.contains(f)) { - return Ok(()); - } + // Exact match + if value_name == expected_name { + return Ok(()); } - // Also allow if expected is AnyFault - if *error.as_ref() == Type::AnyFault { + // Value's faults are a subset of expected's faults (union compatibility) + if value_faults.iter().all(|f| expected_faults.contains(f)) { return Ok(()); } } + // Also allow if expected is AnyFault + if *error.as_ref() == Type::AnyFault { + return Ok(()); + } } Err(SemanticError::TypeMismatch { @@ -4466,10 +4474,10 @@ impl SemanticAnalyzer { // Index with range: arr[0..n] Expr::Index(index) => { // Check if this is a slice (index is a range) of a local array - if matches!(index.index, Expr::Range(_)) { - if let Some(name) = self.get_local_var_name(&index.object) { - return Err(SemanticError::ReturnSliceOfLocal { name, location }); - } + if matches!(index.index, Expr::Range(_)) + && let Some(name) = self.get_local_var_name(&index.object) + { + return Err(SemanticError::ReturnSliceOfLocal { name, location }); } } // Tuple/struct with references inside - check each element @@ -4953,10 +4961,10 @@ impl SemanticAnalyzer { } // Also handle the case where the target is a bare allocator (pz q; prepares all) - if let Expr::Ident(ident) = target { - if let Some(alloc) = self.qubit_states.get_allocator_mut(&ident.name) { - alloc.prepare_all(); - } + if let Expr::Ident(ident) = target + && let Some(alloc) = self.qubit_states.get_allocator_mut(&ident.name) + { + alloc.prepare_all(); } } @@ -5592,21 +5600,22 @@ impl SemanticAnalyzer { // Check for overlaps with existing aliases on the same source for (existing_name, existing_info) in &self.aliases { - if existing_info.source == source_name { - if let (Some(new_range), Some(existing_range)) = (range, existing_info.range) { - if Self::ranges_overlap(new_range, existing_range) { - return Err(SemanticError::OverlappingAlias { - new_alias: alias.name.clone(), - existing_alias: existing_name.clone(), - source_var: source_name.clone(), - overlap_range: format!( - "{}..{} overlaps with {}..{}", - new_range.0, new_range.1, existing_range.0, existing_range.1 - ), - location, - }); - } - } + if existing_info.source == source_name + && let (Some(new_range), Some(existing_range)) = (range, existing_info.range) + && Self::ranges_overlap(new_range, existing_range) + { + return Err(SemanticError::OverlappingAlias(Box::new( + OverlappingAliasError { + new_alias: alias.name.clone(), + existing_alias: existing_name.clone(), + source_var: source_name.clone(), + overlap_range: format!( + "{}..{} overlaps with {}..{}", + new_range.0, new_range.1, existing_range.0, existing_range.1 + ), + location, + }, + ))); } } @@ -8848,28 +8857,32 @@ mod tests { #[test] fn test_max_scope_depth_constant() { - // Verify the constant is reasonable - assert!( - MAX_SCOPE_DEPTH >= 64, - "MAX_SCOPE_DEPTH should be at least 64" - ); - assert!( - MAX_SCOPE_DEPTH <= 1024, - "MAX_SCOPE_DEPTH should not be excessive" - ); + // Verify the constant is reasonable (checked at compile time). + const { + assert!( + MAX_SCOPE_DEPTH >= 64, + "MAX_SCOPE_DEPTH should be at least 64" + ); + assert!( + MAX_SCOPE_DEPTH <= 1024, + "MAX_SCOPE_DEPTH should not be excessive" + ); + } } #[test] fn test_max_symbol_count_constant() { - // Verify the constant is reasonable - assert!( - MAX_SYMBOL_COUNT >= 10_000, - "MAX_SYMBOL_COUNT should be at least 10000" - ); - assert!( - MAX_SYMBOL_COUNT <= 10_000_000, - "MAX_SYMBOL_COUNT should not be excessive" - ); + // Verify the constant is reasonable (checked at compile time). + const { + assert!( + MAX_SYMBOL_COUNT >= 10_000, + "MAX_SYMBOL_COUNT should be at least 10000" + ); + assert!( + MAX_SYMBOL_COUNT <= 10_000_000, + "MAX_SYMBOL_COUNT should not be excessive" + ); + } } // ========================================================================= @@ -9266,22 +9279,21 @@ mod tests { let mut analyzer = SemanticAnalyzer::new(); let _ = analyzer.analyze(&program); - if let Some(symbol) = analyzer.symbols.lookup("add") { - if let SymbolKind::Function { + if let Some(symbol) = analyzer.symbols.lookup("add") + && let SymbolKind::Function { comptime_param_indices, original_decl, .. } = &symbol.kind - { - assert!( - comptime_param_indices.is_empty(), - "Should have no comptime params" - ); - assert!( - original_decl.is_none(), - "Should not store original decl for non-generic" - ); - } + { + assert!( + comptime_param_indices.is_empty(), + "Should have no comptime params" + ); + assert!( + original_decl.is_none(), + "Should not store original decl for non-generic" + ); } } @@ -9341,7 +9353,7 @@ mod tests { let mut analyzer = SemanticAnalyzer::new(); let result = analyzer.analyze(&program); assert!( - matches!(result, Err(SemanticError::OverlappingAlias { .. })), + matches!(result, Err(SemanticError::OverlappingAlias(_))), "Overlapping aliases should error: {:?}", result ); diff --git a/exp/zlup/src/test_runner.rs b/exp/zlup/src/test_runner.rs index 68e9331e3..52cd36522 100644 --- a/exp/zlup/src/test_runner.rs +++ b/exp/zlup/src/test_runner.rs @@ -9,7 +9,7 @@ use crate::ast::{Program, TestDecl, TopLevelDecl}; use crate::semantic::SemanticAnalyzer; /// Configuration for the test runner. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct TestRunConfig { /// Only run tests matching this pattern (substring match) pub filter: Option, @@ -19,16 +19,6 @@ pub struct TestRunConfig { pub verbose: bool, } -impl Default for TestRunConfig { - fn default() -> Self { - Self { - filter: None, - strict: false, - verbose: false, - } - } -} - /// Outcome of a single test. #[derive(Debug, Clone, PartialEq, Eq)] pub enum TestOutcome { @@ -61,10 +51,10 @@ impl TestRunner { let mut tests = Vec::new(); for decl in &program.declarations { if let TopLevelDecl::Test(test) = decl { - if let Some(ref filter) = self.config.filter { - if !test.name.contains(filter.as_str()) { - continue; - } + if let Some(ref filter) = self.config.filter + && !test.name.contains(filter.as_str()) + { + continue; } tests.push(test); } @@ -135,10 +125,8 @@ fn contains_quantum_ops(block: &crate::ast::Block) -> bool { Stmt::Gate(_) | Stmt::Prepare(_) | Stmt::Measure(_) | Stmt::Barrier(_) => { return true; } - Stmt::Expr(expr_stmt) => { - if expr_contains_quantum(&expr_stmt.expr) { - return true; - } + Stmt::Expr(expr_stmt) if expr_contains_quantum(&expr_stmt.expr) => { + return true; } Stmt::If(if_stmt) => { if contains_quantum_ops(&if_stmt.then_body) { @@ -159,15 +147,11 @@ fn contains_quantum_ops(block: &crate::ast::Block) -> bool { } } } - Stmt::For(for_stmt) => { - if contains_quantum_ops(&for_stmt.body) { - return true; - } + Stmt::For(for_stmt) if contains_quantum_ops(&for_stmt.body) => { + return true; } - Stmt::Block(block) => { - if contains_quantum_ops(block) { - return true; - } + Stmt::Block(block) if contains_quantum_ops(block) => { + return true; } Stmt::Tick(tick) => { // Tick blocks always contain quantum ops diff --git a/exp/zlup/src/tests.rs b/exp/zlup/src/tests.rs index 25cceea5a..5cd891ac4 100644 --- a/exp/zlup/src/tests.rs +++ b/exp/zlup/src/tests.rs @@ -1698,7 +1698,7 @@ fn test_edit_distance() { use crate::parser::edit_distance; assert_eq!(edit_distance("", ""), 0); assert_eq!(edit_distance("abc", "abc"), 0); - assert_eq!(edit_distance("abc", "abd"), 1); + assert_eq!(edit_distance("abc", "abx"), 1); assert_eq!(edit_distance("abc", "ab"), 1); assert_eq!(edit_distance("abc", "abcd"), 1); assert_eq!(edit_distance("kitten", "sitting"), 3); diff --git a/exp/zluppy/src/lib.rs b/exp/zluppy/src/lib.rs index 70757b5a9..3127d8110 100644 --- a/exp/zluppy/src/lib.rs +++ b/exp/zluppy/src/lib.rs @@ -1,4 +1,4 @@ -//! Python bindings for Zluppy via PyO3. +//! Python bindings for Zluppy via `PyO3`. //! //! This module provides Python access to Zluppy's compiler functionality: //! @@ -40,6 +40,9 @@ use ::zlup::semantic::SemanticAnalyzer; pyo3::create_exception!(_zluppy, ZluppyError, pyo3::exceptions::PyException); /// Convert a parse error to a Python exception. +// By-value error adapter used directly as `Result::map_err(parse_error_to_py)`, +// which requires `FnOnce(E) -> PyErr`; `&E` would force a closure at every call site. +#[allow(clippy::needless_pass_by_value)] fn parse_error_to_py(e: ::zlup::parser::ParseError) -> PyErr { ZluppyError::new_err(format!( "Parse error at {}:{}: {}", @@ -48,18 +51,27 @@ fn parse_error_to_py(e: ::zlup::parser::ParseError) -> PyErr { } /// Convert a semantic error to a Python exception. +// By-value error adapter used directly as `Result::map_err(semantic_error_to_py)`, +// which requires `FnOnce(E) -> PyErr`; `&E` would force a closure at every call site. +#[allow(clippy::needless_pass_by_value)] fn semantic_error_to_py(e: ::zlup::semantic::SemanticError) -> PyErr { - ZluppyError::new_err(format!("Semantic error: {}", e)) + ZluppyError::new_err(format!("Semantic error: {e}")) } /// Convert a codegen error to a Python exception. +// By-value error adapter used directly as `Result::map_err(codegen_error_to_py)`, +// which requires `FnOnce(E) -> PyErr`; `&E` would force a closure at every call site. +#[allow(clippy::needless_pass_by_value)] fn codegen_error_to_py(e: ::zlup::codegen::slr::SlrError) -> PyErr { - ZluppyError::new_err(format!("Codegen error: {}", e)) + ZluppyError::new_err(format!("Codegen error: {e}")) } /// Convert a HUGR codegen error to a Python exception. +// By-value error adapter used directly as `Result::map_err(hugr_error_to_py)`, +// which requires `FnOnce(E) -> PyErr`; `&E` would force a closure at every call site. +#[allow(clippy::needless_pass_by_value)] fn hugr_error_to_py(e: ::zlup::codegen::hugr::HugrError) -> PyErr { - ZluppyError::new_err(format!("HUGR error: {}", e)) + ZluppyError::new_err(format!("HUGR error: {e}")) } // ============================================================================= @@ -76,7 +88,7 @@ fn hugr_error_to_py(e: ::zlup::codegen::hugr::HugrError) -> PyErr { /// dict: SLR-AST as a Python dictionary /// /// Raises: -/// ZluppyError: If parsing, semantic analysis, or codegen fails +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails #[pyfunction] #[pyo3(signature = (source, strict = false))] fn compile_to_slr(py: Python<'_>, source: &str, strict: bool) -> PyResult> { @@ -115,7 +127,7 @@ fn compile_to_slr(py: Python<'_>, source: &str, strict: bool) -> PyResult PyResult { @@ -154,7 +166,7 @@ fn compile_to_slr_json(source: &str, strict: bool, compact: bool) -> PyResult PyResult<()> { @@ -183,11 +195,11 @@ fn check(source: &str, strict: bool) -> PyResult<()> { /// str: AST in Rust Debug format /// /// Raises: -/// ZluppyError: If parsing fails +/// `ZluppyError`: If parsing fails #[pyfunction] fn parse_debug(source: &str) -> PyResult { let program = ::zlup::parse(source).map_err(parse_error_to_py)?; - Ok(format!("{:#?}", program)) + Ok(format!("{program:#?}")) } /// Get the Zluppy version. @@ -206,15 +218,14 @@ fn version() -> &'static str { /// Read and return the contents of a Zluppy source file. fn read_file(path: &str) -> PyResult { std::fs::read_to_string(path) - .map_err(|e| PyIOError::new_err(format!("Failed to read {}: {}", path, e))) + .map_err(|e| PyIOError::new_err(format!("Failed to read {path}: {e}"))) } /// Get the filename from a path for error reporting. fn filename_from_path(path: &str) -> String { Path::new(path) .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| path.to_string()) + .map_or_else(|| path.to_string(), |s| s.to_string_lossy().to_string()) } /// Compile a Zluppy source file to SLR-AST and return as a Python dict. @@ -227,8 +238,8 @@ fn filename_from_path(path: &str) -> String { /// dict: SLR-AST as a Python dictionary /// /// Raises: -/// IOError: If the file cannot be read -/// ZluppyError: If parsing, semantic analysis, or codegen fails +/// `IOError`: If the file cannot be read +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails #[pyfunction] #[pyo3(signature = (path, strict = false))] fn compile_file(py: Python<'_>, path: &str, strict: bool) -> PyResult> { @@ -269,8 +280,8 @@ fn compile_file(py: Python<'_>, path: &str, strict: bool) -> PyResult> /// str: SLR-AST as a JSON string /// /// Raises: -/// IOError: If the file cannot be read -/// ZluppyError: If parsing, semantic analysis, or codegen fails +/// `IOError`: If the file cannot be read +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails #[pyfunction] #[pyo3(signature = (path, strict = false, compact = false))] fn compile_file_json(path: &str, strict: bool, compact: bool) -> PyResult { @@ -308,8 +319,8 @@ fn compile_file_json(path: &str, strict: bool, compact: bool) -> PyResult PyResult<()> { @@ -334,7 +345,7 @@ fn check_file(path: &str, strict: bool) -> PyResult<()> { /// Compile Zluppy source to HUGR bytes. /// -/// The returned bytes can be passed directly to hugr_engine() or sim(). +/// The returned bytes can be passed directly to `hugr_engine()` or `sim()`. /// /// Args: /// source: Zluppy source code as a string @@ -344,7 +355,7 @@ fn check_file(path: &str, strict: bool) -> PyResult<()> { /// bytes: HUGR in binary envelope format /// /// Raises: -/// ZluppyError: If parsing, semantic analysis, or codegen fails +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails #[pyfunction] #[pyo3(signature = (source, strict = false))] fn compile_to_hugr( @@ -370,7 +381,7 @@ fn compile_to_hugr( /// Compile a Zluppy source file to HUGR bytes. /// -/// The returned bytes can be passed directly to hugr_engine() or sim(). +/// The returned bytes can be passed directly to `hugr_engine()` or `sim()`. /// /// Args: /// path: Path to a .zlp file @@ -380,8 +391,8 @@ fn compile_to_hugr( /// bytes: HUGR in binary envelope format /// /// Raises: -/// IOError: If the file cannot be read -/// ZluppyError: If parsing, semantic analysis, or codegen fails +/// `IOError`: If the file cannot be read +/// `ZluppyError`: If parsing, semantic analysis, or codegen fails #[pyfunction] #[pyo3(signature = (path, strict = false))] fn compile_file_hugr( @@ -453,7 +464,7 @@ impl SlrProgram { /// /// Args: /// gate: Gate name (e.g., "H", "CX", "RZ") - /// targets: List of (allocator_name, index) tuples + /// targets: List of (`allocator_name`, index) tuples /// params: Optional list of parameter values (for parameterized gates) #[pyo3(signature = (gate, targets, params = None))] fn add_gate( @@ -508,7 +519,7 @@ impl SlrProgram { // Three-qubit gates "CCX" | "ccx" => "CCX", _ => { - return Err(PyValueError::new_err(format!("Unknown gate: {}", gate))); + return Err(PyValueError::new_err(format!("Unknown gate: {gate}"))); } }; @@ -557,10 +568,10 @@ impl SlrProgram { fn to_json(&self, compact: bool) -> PyResult { if compact { serde_json::to_string(&self.inner) - .map_err(|e| PyValueError::new_err(format!("JSON error: {}", e))) + .map_err(|e| PyValueError::new_err(format!("JSON error: {e}"))) } else { serde_json::to_string_pretty(&self.inner) - .map_err(|e| PyValueError::new_err(format!("JSON error: {}", e))) + .map_err(|e| PyValueError::new_err(format!("JSON error: {e}"))) } } @@ -668,7 +679,7 @@ impl ZlupProgram { /// /// Args: /// gate: Gate name (e.g., "h", "cx", "rz") - /// targets: List of (allocator_name, index) tuples + /// targets: List of (`allocator_name`, index) tuples /// params: Optional list of parameter values (for rotation gates) /// /// Returns: @@ -686,18 +697,16 @@ impl ZlupProgram { "y" => ::zlup::ast::GateKind::Y, "z" => ::zlup::ast::GateKind::Z, "h" => ::zlup::ast::GateKind::H, - // Phase gates - "s" => ::zlup::ast::GateKind::SZ, - "sdg" => ::zlup::ast::GateKind::SZdg, + // Phase gates ("s"/"sz" and "sdg"/"szdg" are aliases for the same gate) + "s" | "sz" => ::zlup::ast::GateKind::SZ, + "sdg" | "szdg" => ::zlup::ast::GateKind::SZdg, "t" => ::zlup::ast::GateKind::T, "tdg" => ::zlup::ast::GateKind::Tdg, // Square root gates "sx" => ::zlup::ast::GateKind::SX, "sy" => ::zlup::ast::GateKind::SY, - "sz" => ::zlup::ast::GateKind::SZ, "sxdg" => ::zlup::ast::GateKind::SXdg, "sydg" => ::zlup::ast::GateKind::SYdg, - "szdg" => ::zlup::ast::GateKind::SZdg, // Rotation gates "rx" => ::zlup::ast::GateKind::RX, "ry" => ::zlup::ast::GateKind::RY, @@ -720,7 +729,7 @@ impl ZlupProgram { "fdg" => ::zlup::ast::GateKind::Fdg, "f4" => ::zlup::ast::GateKind::F4, "f4dg" => ::zlup::ast::GateKind::F4dg, - _ => return Err(PyValueError::new_err(format!("Unknown gate: {}", gate))), + _ => return Err(PyValueError::new_err(format!("Unknown gate: {gate}"))), }; // Build slot references @@ -785,7 +794,7 @@ impl ZlupProgram { /// Add a measure operation. /// /// Args: - /// targets: List of (allocator_name, index) tuples to measure + /// targets: List of (`allocator_name`, index) tuples to measure /// /// Returns: /// self: For method chaining @@ -891,11 +900,11 @@ impl ZlupProgram { /// path: Path to write the .zlp file /// /// Raises: - /// IOError: If the file cannot be written + /// `IOError`: If the file cannot be written fn save(&self, path: &str) -> PyResult<()> { let source = self.to_source(); std::fs::write(path, source) - .map_err(|e| PyIOError::new_err(format!("Failed to write {}: {}", path, e))) + .map_err(|e| PyIOError::new_err(format!("Failed to write {path}: {e}"))) } /// Compile via source code generation and parsing. @@ -1035,7 +1044,7 @@ struct ZluppyEngine { #[pymethods] impl ZluppyEngine { - /// Create a new ZluppyEngine. + /// Create a new `ZluppyEngine`. /// /// Args: /// strict: Enable strict mode (NASA Power of 10 checks). Default: False diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs index 059139aab..d237a0df4 100644 --- a/python/pecos-rslib-exp/src/sim_neo_bindings.rs +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -2020,10 +2020,9 @@ fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { - // Identity/Idle gates: skip (no-op for simulation) + "I" | "Idle" | "TrackedPauli" | "TrackedPauliMeta" => { + // Identity/Idle and tracked-Pauli metadata gates: skip (no-op for simulation) } - "TrackedPauli" | "TrackedPauliMeta" => {} _ => { return Err(PyErr::new::(format!( "Unsupported gate type '{name}' in extract_commands. \ diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index 5729e4162..bbe89aa7f 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -76,6 +76,8 @@ use std::str::FromStr; type PyDemMechanismTuple = (f64, Vec, Vec); type PyDemFitResult = (Vec, Vec); +/// Per-shot detector rows paired with per-shot observable/DEM-output rows. +type PyDetectorObservableRows = (Vec>, Vec>); fn parse_p1_weights(weights: BTreeMap) -> PyResult { use pecos_core::pauli::{X, Y, Z}; @@ -123,8 +125,7 @@ fn parse_p2_weights(weights: BTreeMap) -> PyResult { let replacement_identity = replacement && label == "II"; if !replacement_identity && !PAULI_2Q_ORDER.contains(&label.as_str()) { let msg = format!( - "p2_weights keys must be one of {:?} or prefixed with '*' for replacement branches, got {label:?}", - PAULI_2Q_ORDER + "p2_weights keys must be one of {PAULI_2Q_ORDER:?} or prefixed with '*' for replacement branches, got {label:?}" ); return Err(pyo3::exceptions::PyValueError::new_err(msg)); } @@ -1139,7 +1140,7 @@ impl PyPauliFrameLookup { fn compute_mask_xor( &self, pauli_masks: &Bound<'_, pyo3::PyAny>, - ) -> PyResult<(Vec>, Vec>)> { + ) -> PyResult { let (values, rows, cols) = extract_pauli_mask_values(pauli_masks)?; self.inner .compute_mask_xor(&values, rows, cols) @@ -4379,7 +4380,7 @@ impl PyDemSampler { lookup: &PyPauliFrameLookup, pauli_masks: &Bound<'_, pyo3::PyAny>, seed: Option, - ) -> PyResult<(Vec>, Vec>)> { + ) -> PyResult { use pecos_random::PecosRng; use rand::RngExt; From 5fa104c0c09a69768e035b2a359899893fe917e6 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 29 Jun 2026 21:22:12 -0600 Subject: [PATCH 303/388] Resolve dependency-integrity: allowlist new unsafe roots, add vscode npm lock, refresh fuzz lock --- .../editors/vscode-zlup/package-lock.json | 15 + exp/zlup/fuzz/Cargo.lock | 468 +++++++++++++----- scripts/ci/unsafe-allowlist.txt | 5 + 3 files changed, 352 insertions(+), 136 deletions(-) create mode 100644 exp/zlup/editors/vscode-zlup/package-lock.json diff --git a/exp/zlup/editors/vscode-zlup/package-lock.json b/exp/zlup/editors/vscode-zlup/package-lock.json new file mode 100644 index 000000000..a455672c7 --- /dev/null +++ b/exp/zlup/editors/vscode-zlup/package-lock.json @@ -0,0 +1,15 @@ +{ + "name": "zlup", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "zlup", + "version": "0.1.0", + "engines": { + "vscode": "^1.74.0" + } + } + } +} diff --git a/exp/zlup/fuzz/Cargo.lock b/exp/zlup/fuzz/Cargo.lock index b34f747ac..7ddee9bc1 100644 --- a/exp/zlup/fuzz/Cargo.lock +++ b/exp/zlup/fuzz/Cargo.lock @@ -17,6 +17,65 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -52,9 +111,15 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.10.0" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -67,18 +132,25 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "2f3f6da4992df95bbcd9af42a6c7dcb994498fc9048230405f3b36ff7cd3f145" dependencies = [ + "bytes", "cfg_aliases", ] +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + [[package]] name = "cc" -version = "1.2.55" +version = "1.2.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" dependencies = [ "find-msvc-tools", "jobserver", @@ -98,6 +170,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -117,6 +195,38 @@ dependencies = [ "typenum", ] +[[package]] +name = "defmt" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6e524506490a1953d237cb87b1cfc1e46f88c18f10a22dfe0f507dc6bfc7f7f" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a27770e9c8f719a79d8b638281f4d828f77d8fd61e0bd94451b9b85e576a0b" +dependencies = [ + "defmt-parser", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -138,6 +248,29 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -151,7 +284,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -190,15 +323,15 @@ checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", "hashbrown", @@ -210,11 +343,42 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "34f877a98676d2fb664698d74cc6a51ce6c484ce8c770f05d0108ec9090aeb46" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0666b5ab5ecaca213fc2a85b8c0083d9004e84ee2d5f9a7e0017aaf50986f25f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "jobserver" @@ -228,15 +392,15 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.180" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", @@ -244,15 +408,21 @@ dependencies = [ [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "miette" @@ -302,17 +472,23 @@ dependencies = [ "memchr", ] +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + [[package]] name = "owo-colors" -version = "4.2.3" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c6901729fa79e91a0913333229e9ca5dc725089d1c363b2f4b4760709dc4a52" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" [[package]] name = "pest" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -320,9 +496,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -330,9 +506,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", @@ -343,14 +519,51 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.5" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -362,9 +575,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -375,6 +588,35 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -383,15 +625,15 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -426,9 +668,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -459,15 +701,15 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "smol_str" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f7a918bd2a9951d18ee6e48f076843e8e73a9a5d22cf05bcd4b7a81bdd04e17" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" dependencies = [ "borsh", "serde_core", @@ -496,9 +738,9 @@ checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" [[package]] name = "syn" -version = "2.0.114" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -507,12 +749,12 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.60.2", + "windows-sys", ] [[package]] @@ -531,7 +773,16 @@ version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", ] [[package]] @@ -545,6 +796,17 @@ dependencies = [ "syn", ] +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "toml" version = "0.8.23" @@ -588,9 +850,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "ucd-trie" @@ -600,9 +862,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-linebreak" @@ -622,6 +884,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "version_check" version = "0.9.5" @@ -630,9 +898,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] @@ -643,15 +911,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -661,97 +920,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "zlup" version = "0.1.0" dependencies = [ + "env_logger", + "log", "miette", "pest", "pest_derive", "serde", "serde_json", "smol_str", - "thiserror", + "thiserror 1.0.69", "toml", ] @@ -766,6 +962,6 @@ dependencies = [ [[package]] name = "zmij" -version = "1.0.18" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1966f8ac2c1f76987d69a74d0e0f929241c10e78136434e3be70ff7f58f64214" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/scripts/ci/unsafe-allowlist.txt b/scripts/ci/unsafe-allowlist.txt index 4282e3f9f..3bbec7c13 100644 --- a/scripts/ci/unsafe-allowlist.txt +++ b/scripts/ci/unsafe-allowlist.txt @@ -7,6 +7,7 @@ crates/pecos-cuquantum crates/pecos-cuquantum-sys crates/pecos-foreign crates/pecos-gpu-sims +crates/pecos-hugr-qis crates/pecos-ldpc-decoders crates/pecos-llvm crates/pecos-pymatching @@ -15,6 +16,10 @@ crates/pecos-qis-ffi crates/pecos-simulators crates/pecos-tesseract exp/pecos-experimental +# zlup revival (experimental): zlup contains one reviewed `unsafe fn new_unchecked` +# invariant constructor; zlup-ffi is a C-ABI boundary crate (Box::from_raw / slices). +exp/zlup +exp/zlup/ffi/zlup-ffi go/pecos-go-ffi julia/pecos-julia-ffi python/pecos-rslib From 15328b5e64092370e526b627848d920fde1360a4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 30 Jun 2026 14:50:16 -0600 Subject: [PATCH 304/388] Bump wasmtime from 45 to 46 --- Cargo.lock | 172 +++++++++++++++++++++++++++++++---------------------- Cargo.toml | 2 +- 2 files changed, 103 insertions(+), 71 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc279af02..ae64d060c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,27 +1095,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bc293b86236abcc45f2f72e2d18e2bd636f2a08b75eb286bae31e71e1430c91" +checksum = "e06aeba2c965fc446d13c56a6ccb2631b78445d7544543dd9a25289977630914" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b954c826eddaf1b001402cb8aecf1764c6f6d637ba69fb9e3311f1ebac965be6" +checksum = "ee2d2dde4ec1352715595b5cfa6fe2e5b8ebb9da3457b3ee8db0aa2808c069aa" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4053fa2575ef4a5c35d2708533df2200400ae979226cea9cc92a578b811bd4e7" +checksum = "03b4982ef9fa54ec9eee841e891e7ddc5434be1250e88de31572e000c888f30b" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -1123,9 +1123,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d216663191014aa63e1d2cffd058e609eaf207646d40b739d88250f65b2c4f69" +checksum = "529143118c4eeb58c39ecb02319557d512be6c61348486422974ab8e3906b8a8" dependencies = [ "serde", "serde_derive", @@ -1134,9 +1134,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a5e7e7aad6a425a51da1ad7ab9e5d280ea97eb7c7c4545fafb567915a75aadb" +checksum = "b7780677247ad3577e3a6a3ebf43f39b325a11d6393db72b2c9968a910d4d13d" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -1151,10 +1151,13 @@ dependencies = [ "hashbrown 0.17.1", "libm", "log", + "postcard", "pulley-interpreter", "regalloc2", "rustc-hash 2.1.2", "serde", + "serde_derive", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasmtime-internal-core", @@ -1162,9 +1165,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c421d80a9a85f806cb02a2983b5b5368a335c319795b1f1b4b771a24479af5b0" +checksum = "ac9645250416cbf92454fe61160e17e026e0ce405906a54500b114f923ddffc9" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -1175,24 +1178,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78fdb83ab012d0ee6a44ced7ca8788a444f17cf821c62f95d6ef87c9f0262518" +checksum = "20ee8d222ff0fd3681791979afbf88586ac9f49010d3db96b3cbe4c96759aee3" [[package]] name = "cranelift-control" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b75adc6eb7bb4ac6365106afb6cac4f12fe1ddfa02ddc9fd7015ca1469b471b" +checksum = "591abe6f5312bd2c4220f1b3bead56c2ad00257c52668015ba013b85dcf2a17a" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668e56db75a54816cbdd7c7b7bfc558b08bf7b2cda9d0846491517e92f3b393b" +checksum = "a5300c49cf940526fe771517b3b3eabd5d0ff164ee61698579cf403fe8d3af3c" dependencies = [ "cranelift-bitset", "serde", @@ -1202,11 +1205,12 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c63892dc1cc3ae48680183fa66997f60ffe7f1e200c8d390f8ee66edff4aef5a" +checksum = "da4adbf760207fdbbe130f1191cce01cdef66831a9f648b1f39ff2800d126d45" dependencies = [ "cranelift-codegen", + "hashbrown 0.17.1", "log", "smallvec", "target-lexicon", @@ -1214,15 +1218,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94eaf429c32a12715429c7c6ddfdd43c170f4cdd7e97bfa507bd68a652091087" +checksum = "8315b21ff018226a42a60a4702c2dd75f6447cac26e9bca622e14c22088c2ff5" [[package]] name = "cranelift-native" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd77674904ae9be11c1e1efdba54788b59f3d6658d747b97534bfbba2909aacc" +checksum = "d506ef23a60715bde451b06620b14402166ded3b648454fccbf04f3e46a4aa70" dependencies = [ "cranelift-codegen", "libc", @@ -1231,9 +1235,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.132.2" +version = "0.133.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cba7c0ff5941842c36653da155580ce41e675c204a67ac1b4e1c478a9347bbb7" +checksum = "48ed47e602652e3410f9387fc0db70fefadcee4d78a78881421aabcab4e26b89" [[package]] name = "crc" @@ -3406,12 +3410,9 @@ dependencies = [ [[package]] name = "mach2" -version = "0.4.3" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "malachite" @@ -5455,9 +5456,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d9880c1985ccccaed3646b0ef793dc39a4b117403ed4afc6fa3ef6027c5200f" +checksum = "38b92604caae1a1899b6a5b54967289dd538177c626004c91accf9d0ec7e4a12" dependencies = [ "cranelift-bitset", "log", @@ -5467,9 +5468,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee249346855ad102580e474da5463f86f8a7d449e6d49e00fefb304e448e2983" +checksum = "5a7ac85c0bb3fb351f10d531230aaa5e366b46d7c4e5328e5f02801d6dac1165" dependencies = [ "proc-macro2", "quote", @@ -5933,6 +5934,7 @@ dependencies = [ "hashbrown 0.17.1", "log", "rustc-hash 2.1.2", + "serde", "smallvec", ] @@ -7977,12 +7979,22 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.248.0" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" +checksum = "5a879a421bd17c528b74721b2abf4c62e8f1d1889c2ba8c3c50d02deaf2ce395" dependencies = [ "leb128fmt", - "wasmparser 0.248.0", + "wasmparser 0.251.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8185ae345fa5687c054626ff9a50e7089797a343d9904d1dc9820eb4c4d3196f" +dependencies = [ + "leb128fmt", + "wasmparser 0.252.0", ] [[package]] @@ -8011,9 +8023,9 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.248.0" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +checksum = "437970b35b1a85cfde9c74b2398352d8d653f3bd8e3a3db0c063ea8f5b4b36ff" dependencies = [ "bitflags 2.11.1", "hashbrown 0.17.1", @@ -8022,22 +8034,33 @@ dependencies = [ "serde", ] +[[package]] +name = "wasmparser" +version = "0.252.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3eb099dcadcde5be9eef55e3a337128efd4e44b4c93122487e4d2e4e1c6627c" +dependencies = [ + "bitflags 2.11.1", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "wasmprinter" -version = "0.248.0" +version = "0.251.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30b264a5410b008d4d199a92bf536eae703cbd614482fc1ec53831cf19e1c183" +checksum = "8798c1a699bd25648b6708eefe94d97c6f9891febb94b42cca1f7a4b086ea64e" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.248.0", + "wasmparser 0.251.0", ] [[package]] name = "wasmtime" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7ce9aa2c67f75fadcfdc6aa9097d03e7c39485dfe316f2ed6a7c0fd186c527" +checksum = "c4213d2f019a5e44aa8a61d8826dd33a505bff79f749b14a8bafd67321cb9351" dependencies = [ "addr2line 0.26.1", "async-trait", @@ -8045,6 +8068,7 @@ dependencies = [ "bumpalo", "cc", "cfg-if", + "futures", "libc", "log", "mach2", @@ -8058,7 +8082,7 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasmparser 0.248.0", + "wasmparser 0.251.0", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-cranelift", @@ -8073,9 +8097,9 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8fb157bd1fbf689ac89d570433a700db6f33bdfcb5ffc30e3f1c49e4c70de71" +checksum = "d45863de41977ec6453e859cf843d456fa3fcb45a659b66d16e794f90ec4f5b7" dependencies = [ "anyhow", "cpp_demangle", @@ -8089,22 +8113,30 @@ dependencies = [ "object 0.39.1", "postcard", "rustc-demangle", + "semver", "serde", "serde_derive", "sha2 0.10.9", "smallvec", "target-lexicon", - "wasm-encoder 0.248.0", - "wasmparser 0.248.0", + "wasm-encoder 0.251.0", + "wasmparser 0.251.0", "wasmprinter", + "wasmtime-internal-component-util", "wasmtime-internal-core", ] +[[package]] +name = "wasmtime-internal-component-util" +version = "46.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ad5abd5822a22dbf4014475cdfd1fe790707761cd732d74aaa3ba4d5ba489" + [[package]] name = "wasmtime-internal-core" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a1deaf6bc3430abd7497b00c64f06ca2b97ca0fe41af87836446ca30949965c" +checksum = "3fc28372e36eaf8cf70faa83b5779137f7e99c8d18569a125d1580e735cc9e4d" dependencies = [ "hashbrown 0.17.1", "libm", @@ -8113,9 +8145,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845f83b5b04b11bc48329b53eb4fa8cf9f28a43c71ed8e1203f68ffa9806d1b" +checksum = "a433efc6e35112a5457e1dc8bc4d8d39820ac7722267e89bc04e5df641f32124" dependencies = [ "cfg-if", "cranelift-codegen", @@ -8131,7 +8163,7 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.248.0", + "wasmparser 0.251.0", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-unwinder", @@ -8140,9 +8172,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10c8466f72965ae85c250f90aaa7992c089a2f8502009bd0d2c9e7d6409174a" +checksum = "18a1d3a39d0d210f6b8574ee96a4315e0a14c67f3a1fc3cd5372cb10d2fb4422" dependencies = [ "cc", "cfg-if", @@ -8155,9 +8187,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d3adfecf5621b14d8f8871f4cb4ed9f844197b1ddefc702ef4c859552cd9551" +checksum = "9f667288cb4dfa68a4639ffac4d5628535dda64ebdc2b990526efb12b30ba803" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -8165,9 +8197,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d3c1e9fb618ec45c9b3477ea683cd37bee427273d7b13bba5c66a1caaf1dd6" +checksum = "eba651d44ab0faad4c58106b3adb45068189fb65ef50f0c404b6d9e3bf81a357" dependencies = [ "cfg-if", "libc", @@ -8177,9 +8209,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa91132b81f1e172ec7e7c3c114ac34209ee6b3524b3a8d6943af99803f66c5" +checksum = "2ecc52563b0558af2a7487eb710de07cc4532564b55528876129238e83118cb1" dependencies = [ "cfg-if", "cranelift-codegen", @@ -8190,9 +8222,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "45.0.2" +version = "46.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ea811ffe23f597cc7708327ea25d9eb018dcf760ffe15ccb7d0b27ad635de61" +checksum = "e747f4a074699ba1b4e4d841fb263f9b7df5bd1555181c4752bf5990d21ba676" dependencies = [ "proc-macro2", "quote", @@ -8201,22 +8233,22 @@ dependencies = [ [[package]] name = "wast" -version = "248.0.0" +version = "252.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" +checksum = "942a3449d6a593fccc111a6241c8df52bda168af30e40bf9580d4394d7374c65" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width 0.2.2", - "wasm-encoder 0.248.0", + "wasm-encoder 0.252.0", ] [[package]] name = "wat" -version = "1.248.0" +version = "1.252.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" +checksum = "c72a4ba7088f7bac94cf516e49882bdf97068904a563768cf249efc839ec42cb" dependencies = [ "wast", ] diff --git a/Cargo.toml b/Cargo.toml index 7198f77ab..e67de3763 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,7 @@ tket = { version = "0.19", default-features = false } tket-qsystem = { version = "0.25", default-features = false } # --- WebAssembly --- -wasmtime = { version = "45", default-features = false, features = [ +wasmtime = { version = "46", default-features = false, features = [ "cranelift", "runtime", "wat", From 46620a5c9adbf51b3fa5a183065cf57232b92cb4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 30 Jun 2026 15:01:47 -0600 Subject: [PATCH 305/388] Bump rustworkx-core from 0.17 to 0.18 --- Cargo.lock | 27 +++++++++++++++------------ Cargo.toml | 2 +- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ae64d060c..99d8ffb80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2381,7 +2381,6 @@ dependencies = [ "allocator-api2", "equivalent", "foldhash 0.1.5", - "rayon", ] [[package]] @@ -2401,7 +2400,10 @@ version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", + "rayon", "serde", "serde_core", ] @@ -3713,6 +3715,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "rawpointer", + "rayon", ] [[package]] @@ -5745,21 +5748,21 @@ checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" -version = "0.5.1" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" +checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.9.4", + "rand 0.10.1", ] [[package]] name = "rand_pcg" -version = "0.9.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b48ac3f7ffaab7fac4d2376632268aa5f89abdb55f7ebf8f4d11fffccb2320f7" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] @@ -6348,19 +6351,19 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "rustworkx-core" -version = "0.17.1" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aaeee6f84153fd6f62507fc22bfe9499c8485075b44186dcbb918166ef75116f" +checksum = "4b213df2cbfa5f580ed2c0ac3619eda06692a4ff0211f0aebbb4008eaa9724a6" dependencies = [ "fixedbitset 0.5.7", "foldhash 0.1.5", - "hashbrown 0.15.5", + "hashbrown 0.17.1", "indexmap 2.14.0", - "ndarray 0.16.1", + "ndarray 0.17.2", "num-traits", "petgraph 0.8.3", "priority-queue 2.7.0", - "rand 0.9.4", + "rand 0.10.1", "rand_distr", "rand_pcg", "rayon", diff --git a/Cargo.toml b/Cargo.toml index e67de3763..b64920789 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,7 @@ pollster = "0.4" # --- Graph algorithms --- petgraph = "0.8" -rustworkx-core = "0.17" +rustworkx-core = "0.18" # --- Networking & archive handling --- reqwest = { version = "0.13", default-features = false, features = [ From 8b0dbe343dcf970444af2ff69c347da9e5038248 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Tue, 30 Jun 2026 15:09:06 -0600 Subject: [PATCH 306/388] Remove unused itertools workspace dependency --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index b64920789..e659998e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -109,7 +109,6 @@ bitflags = "2" bitvec = { version = "1", features = ["serde"] } bytemuck = { version = "1", features = ["derive"] } dyn-clone = "1" -itertools = "0.14" smallvec = "1" # --- Concurrency --- From af36f5c042c1da63cbaab4f614c2d117b0a35840 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 09:12:23 -0600 Subject: [PATCH 307/388] Adopt guppylang 0.21.16 with binary HUGR serialization and round-scoped generated Guppy modules --- python/quantum-pecos/pyproject.toml | 15 ++- .../src/pecos/compilation_pipeline.py | 106 ++--------------- python/quantum-pecos/src/pecos/guppy/color.py | 57 +++++++-- .../quantum-pecos/src/pecos/guppy/surface.py | 95 +++++++++++++-- .../src/pecos/guppy/transversal.py | 20 +++- .../src/pecos/programs/__init__.py | 11 +- .../tests/guppy/test_check_hugr_format.py | 40 ++----- .../guppy/test_guppy_result_mechanisms.py | 108 +++++------------ .../tests/guppy/test_guppy_with_results.py | 81 ++++--------- .../tests/guppy/test_hugr_compilation.py | 8 +- .../tests/guppy/test_hugr_structure.py | 40 +------ .../tests/guppy/test_multi_module_handling.py | 59 ++++------ .../guppy/test_python_side_compilation.py | 30 ++--- .../tests/guppy/test_selene_build_process.py | 41 ++----- .../guppy/test_selene_direct_integration.py | 55 ++------- uv.lock | 110 +++++++++--------- 16 files changed, 352 insertions(+), 524 deletions(-) diff --git a/python/quantum-pecos/pyproject.toml b/python/quantum-pecos/pyproject.toml index 960cae2fe..8f8dc7bb0 100644 --- a/python/quantum-pecos/pyproject.toml +++ b/python/quantum-pecos/pyproject.toml @@ -32,9 +32,18 @@ dependencies = [ "pecos-rslib-llvm==0.8.0.dev8", "phir>=0.3.3", "networkx>=2.1.0", - "guppylang>=0.21.6", - "tket<0.12.16", # 0.12.16 requires GLIBC 2.38 (manylinux_2_38) - "hugr>=0.13.0", + # Pin to the 0.21.x line (tket.bool era). guppylang 0.22+ emits the + # tket.measurement HUGR format, which our Rust tket (0.19) cannot read; do not + # uncap until the Rust tket stack is bumped to 0.20+. + "guppylang>=0.21.16,<0.22", + # tket 0.13.x pairs with the latest stable guppylang (0.21.16, tket.bool era). + # Do not bump past 0.13 until a stable guppylang emits tket.measurement (which + # needs Rust tket 0.20+). The old `tket<0.12.16` GLIBC-2.38 cap was stale. + "tket>=0.13,<0.14", + # hugr-py 0.16.x pairs with guppylang 0.21.16 / tket 0.13.x. The runtime relies on + # the binary Model envelope (`Package.to_bytes`/`from_bytes`); cap to the tested + # 0.16 line rather than accept an unverified newer serialization. + "hugr>=0.16,<0.17", "selene-sim~=0.2.0", ] classifiers = [ diff --git a/python/quantum-pecos/src/pecos/compilation_pipeline.py b/python/quantum-pecos/src/pecos/compilation_pipeline.py index e1593576b..1bdb470d0 100644 --- a/python/quantum-pecos/src/pecos/compilation_pipeline.py +++ b/python/quantum-pecos/src/pecos/compilation_pipeline.py @@ -27,8 +27,6 @@ def compile_guppy_to_hugr(guppy_function: Callable) -> bytes: ValueError: If function is not a Guppy function RuntimeError: If compilation fails """ - from guppylang import guppy as guppy_module - # Check if this is a Guppy function is_guppy = ( hasattr(guppy_function, "_guppy_compiled") @@ -41,111 +39,29 @@ def compile_guppy_to_hugr(guppy_function: Callable) -> bytes: msg = "Function must be decorated with @guppy" raise ValueError(msg) + # guppylang's compile()/compile_function() both return a hugr `Package`. + # Parametric functions must use compile_function() (compile() needs entry-point + # arguments); non-parametric functions use compile() for the entry point. try: - # Check if this is a parametric function (has arguments) import inspect sig = inspect.signature( - (guppy_function.__wrapped__ if hasattr(guppy_function, "__wrapped__") else guppy_function), + guppy_function.__wrapped__ if hasattr(guppy_function, "__wrapped__") else guppy_function, ) has_params = len(sig.parameters) > 0 - - if has_params: - # For parametric functions, use compile_function() which allows parameters - if hasattr(guppy_function, "compile_function"): - compiled = guppy_function.compile_function() - else: - # Fall back to regular compile and let it handle the error - compiled = guppy_function.compile() - else: - # For non-parametric functions, use compile() for entrypoint - if hasattr(guppy_function, "compile"): - # New API: function.compile() - compiled = guppy_function.compile() - else: - # Old API: guppy.compile(function) - compiled = guppy_module.compile(guppy_function) - - # Handle the return value - it might be a FuncDefnPointer or similar - # Use the new HUGR envelope methods (to_str/to_bytes) instead of deprecated to_json - if hasattr(compiled, "to_str"): - # Use string format for JSON compatibility with HUGR 0.13 compiler - return compiled.to_str().encode("utf-8") - if hasattr(compiled, "to_json"): - # Fallback to to_json for older versions (with deprecation warning) - return compiled.to_json().encode("utf-8") - - if hasattr(compiled, "package"): - if hasattr(compiled.package, "to_str"): - return compiled.package.to_str().encode("utf-8") - if hasattr(compiled.package, "to_json"): - return compiled.package.to_json().encode("utf-8") - return compiled.package.to_bytes() - - if hasattr(compiled, "to_package"): - package = compiled.to_package() - if hasattr(package, "to_str"): - return package.to_str().encode("utf-8") - if hasattr(package, "to_json"): - return package.to_json().encode("utf-8") - return package.to_bytes() - - # Try to serialize directly - return compiled.to_bytes() + compiled = guppy_function.compile_function() if has_params else guppy_function.compile() except Exception as e: msg = f"Failed to compile Guppy to HUGR: {e}" raise RuntimeError(msg) from e - -# Step 2: HUGR -> LLVM/QIR -def _update_tket_wasm_version(hugr_bytes: bytes) -> bytes: - """Update tket.wasm version from 0.3.0 to 0.4.1 for compatibility. - - Args: - hugr_bytes: HUGR package bytes - - Returns: - Updated HUGR bytes with tket.wasm 0.4.1 - """ - import json - - hugr_str = hugr_bytes.decode("utf-8") - - # Check if it starts with the envelope header - if hugr_str.startswith("HUGRiHJv"): - # Find where the JSON starts - json_start = hugr_str.find("{", 8) - if json_start != -1: - header = hugr_str[:json_start] - json_part = hugr_str[json_start:] - - # Parse the JSON - hugr_data = json.loads(json_part) - - # Update version in extensions - if "extensions" in hugr_data: - for ext in hugr_data["extensions"]: - if ext.get("name") == "tket.wasm" and ext.get("version") == "0.3.0": - ext["version"] = "0.4.1" - - # Update version in module metadata - if hugr_data.get("modules"): - module = hugr_data["modules"][0] - if "metadata" in module: - for meta_item in module["metadata"]: - if isinstance(meta_item, dict) and "core.used_extensions" in meta_item: - for ext in meta_item["core.used_extensions"]: - if ext.get("name") == "tket.wasm" and ext.get("version") == "0.3.0": - ext["version"] = "0.4.1" - - # Reconstruct the HUGR envelope - modified_json = json.dumps(hugr_data, separators=(",", ":")) - modified_hugr = header + modified_json - return modified_hugr.encode("utf-8") - - return hugr_bytes + # Serialize the Package as the BINARY HUGR envelope (Model format). The Selene/QIS + # engine's HUGR reader rejects hugr-py 0.16's S-expression *text* envelope + # (`to_str`) with "Failed to read HUGR", whereas the binary Model form round-trips + # cleanly, including CFG loops (while statements). + return compiled.to_bytes() +# Step 2: HUGR -> LLVM/QIR def compile_hugr_to_qis( hugr_bytes: bytes, *, diff --git a/python/quantum-pecos/src/pecos/guppy/color.py b/python/quantum-pecos/src/pecos/guppy/color.py index 7e2e198e9..9be15bf9c 100644 --- a/python/quantum-pecos/src/pecos/guppy/color.py +++ b/python/quantum-pecos/src/pecos/guppy/color.py @@ -10,6 +10,7 @@ import importlib.util import sys import tempfile +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, ClassVar @@ -22,7 +23,7 @@ class _ModuleState: """Container for module-level mutable state.""" temp_dir: ClassVar[Path | None] = None - module_cache: ClassVar[dict[int, dict]] = {} + module_cache: ClassVar[dict[tuple[int, int | None], dict]] = {} _state = _ModuleState() @@ -339,17 +340,21 @@ def generate_color_code_source(code: "ColorCode488") -> str: return "\n".join(lines) -def _load_color_code_module(d: int) -> dict: +def _load_color_code_module(d: int, *, num_rounds: int | None = None) -> dict: """Load a color code module for distance d, using caching. Args: d: Code distance + num_rounds: Optional memory-round count. When supplied, the generated + module name is round-scoped so Guppy's module-qualified function + names cannot reuse a stale factory-local comptime body. Returns: Module dictionary with generated functions """ - if d in _state.module_cache: - return _state.module_cache[d] + cache_key = (d, None if num_rounds is None else int(num_rounds)) + if cache_key in _state.module_cache: + return _state.module_cache[cache_key] from pecos.qec.color import ColorCode488 @@ -358,11 +363,12 @@ def _load_color_code_module(d: int) -> dict: # Write to temp file temp_dir = _get_temp_dir() - temp_file = temp_dir / f"color_d{d}.py" + round_suffix = "" if num_rounds is None else f"_r{int(num_rounds)}" + temp_file = temp_dir / f"color_d{d}{round_suffix}.py" temp_file.write_text(source) # Load module - module_name = f"pecos._generated.color_d{d}" + module_name = f"pecos._generated.color_d{d}{round_suffix}" spec = importlib.util.spec_from_file_location(module_name, temp_file) if spec is None or spec.loader is None: msg = f"Failed to create module spec for {temp_file}" @@ -372,13 +378,36 @@ def _load_color_code_module(d: int) -> dict: sys.modules[module_name] = module spec.loader.exec_module(module) - _state.module_cache[d] = vars(module) - return _state.module_cache[d] + _state.module_cache[cache_key] = vars(module) + return _state.module_cache[cache_key] + + +def _round_scoped_color_memory_factory(d: int, basis: str) -> Callable[[int], object]: + """Memory factory that scopes each call to a round-specific Guppy module. + + Backs :func:`get_color_code_module`: each ``factory(n)`` re-enters + :func:`_load_color_code_module` with the concrete ``num_rounds`` so it lands + in a distinct ``pecos._generated.color_d{d}_r{n}`` module, preventing a + cross-round guppy module-qualified-name collision. + """ + + def factory(num_rounds: int) -> object: + scoped = _load_color_code_module(d, num_rounds=int(num_rounds)) + return scoped[f"make_memory_{basis}"](num_rounds) + + return factory def get_color_code_module(d: int) -> dict: """Get a loaded color code module for distance d. + The returned ``make_memory_z``/``make_memory_x`` factories scope each call to + a round-specific module: ``factory(n)`` always produces the experiment in a + distinct ``pecos._generated.color_d{d}_r{n}`` module. The round count comes + solely from the factory argument, so building experiments at several round + counts in one process stays isolated (guppylang caches compiled functions by + module-qualified name). + Args: d: Code distance (must be odd >= 3) @@ -396,6 +425,16 @@ def get_color_code_module(d: int) -> dict: module["num_data"] = code.num_data module["num_stab"] = code.num_stabilizers + # Scope each factory call to a round-specific module (see + # _round_scoped_color_memory_factory). Copy first so the cached + # _load_color_code_module namespace keeps its real factories for the + # round-scoped re-entry. + module = dict(module) + for basis in ("z", "x"): + key = f"make_memory_{basis}" + if key in module: + module[key] = _round_scoped_color_memory_factory(d, basis) + return module @@ -429,7 +468,7 @@ def make_color_code(distance: int, num_rounds: int, basis: str) -> object: msg = f"basis must be 'Z' or 'X', got {basis!r}" raise ValueError(msg) - module = get_color_code_module(distance) + module = _load_color_code_module(distance, num_rounds=num_rounds) factory = module["make_memory_z"] if basis.upper() == "Z" else module["make_memory_x"] diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 7693bb311..fd2d5bcb7 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -2287,12 +2287,12 @@ def _guppy_module_cache_key( rotated flag. Keying on distance/dx-dz alone would collide a rotated and a non-rotated patch of the same shape onto one generated module. - Twirled source is Python-time unrolled, so the cache key includes - ``num_rounds`` in addition to the structural twirl fields, runtime - frame-output mode, RNG seed, activation-probability threshold, and - the SZZ runtime-barrier policy. Plain SZZ source is also unrolled when - ``num_rounds`` is supplied so hosted-operation metadata can identify the - concrete counted syndrome round. + Memory factories close over ``num_rounds`` as a Guppy comptime value, so + the cache key includes it whenever a memory program is requested. This + keeps Guppy's module-qualified function names round-specific instead of + reusing a stale compiled body from an earlier factory call. Twirled source + and plain SZZ source also Python-time unroll the round body, so they require + this keying for source identity as well. """ geom = patch.geometry rotated = "rot" if geom.rotated else "unrot" @@ -2318,7 +2318,7 @@ def _guppy_module_cache_key( f"{runtime_barrier_part}{trace_metadata_part}" ) if twirl is None: - if interaction_basis == "szz" and num_rounds is not None: + if num_rounds is not None: return f"{base}_r{int(num_rounds)}" return base if rng is None or num_rounds is None: @@ -2481,7 +2481,7 @@ def generate_memory_experiment( ancilla_budget=ancilla_budget, twirl=twirl, rng=rng, - num_rounds=num_rounds if twirl is not None or resolved_plan.interaction_basis == "szz" else None, + num_rounds=num_rounds, interaction_basis=resolved_plan.interaction_basis, check_plan=resolved_plan.plan_id if check_plan is not None else None, clifford_frame_policy=clifford_frame_policy, @@ -2617,10 +2617,45 @@ def generate_surface_code_module( ) +def _round_scoped_surface_memory_factory( + patch: "SurfacePatch", + basis: str, + *, + ancilla_budget: int | None, + interaction_basis: str | None, + check_plan: str | None, + clifford_frame_policy: str | None, + szz_runtime_barriers: bool | str, +) -> Callable[[int], object]: + """Memory factory that scopes each call to a round-specific Guppy module. + + Backs :func:`get_surface_code_module`: each ``factory(n)`` re-enters + :func:`_surface_code_module_for_patch` with the concrete ``num_rounds`` so it + lands in a distinct ``pecos._generated.patch_..._r{n}`` module. Without this, + a caller building memory experiments at more than one round count on one + round-agnostic module would collide on a single guppy module-qualified name. + """ + + def factory(num_rounds: int) -> object: + scoped = _surface_code_module_for_patch( + patch, + ancilla_budget=ancilla_budget, + num_rounds=int(num_rounds), + interaction_basis=interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + ) + return scoped[f"make_memory_{basis}"](num_rounds) + + return factory + + def _surface_code_module_for_patch( patch: "SurfacePatch", *, ancilla_budget: int | None = None, + num_rounds: int | None = None, interaction_basis: str | None = None, check_plan: str | None = None, clifford_frame_policy: str | None = None, @@ -2634,9 +2669,21 @@ def _surface_code_module_for_patch( unconstrained-via-``None`` / unconstrained-via-large-int cases share one entry. Module metadata is derived from the patch geometry (faithful for asymmetric / non-rotated patches), not from a scalar distance. + + ``num_rounds`` is threaded into both the cache key and the generated module + identity so that callers building memory experiments at different round + counts in one process get distinct Guppy modules (see + :func:`_guppy_module_cache_key`). When ``num_rounds`` is ``None`` the + returned ``make_memory_*`` factories are replaced with round-scoping + wrappers that re-enter this function with the concrete count, so the + round-agnostic getter path stays isolated across round counts too. """ from pecos.qec.surface._ancilla_batching import normalize_ancilla_budget + # Preserve the caller's original interaction-basis selector for the + # round-scoped factory wrappers below (the local is reassigned to the + # resolved basis just after). + original_interaction_basis = interaction_basis resolved_plan = _resolve_surface_check_plan( interaction_basis=interaction_basis, check_plan=check_plan, @@ -2657,6 +2704,7 @@ def _surface_code_module_for_patch( None if clifford_frame_policy is None else str(clifford_frame_policy).lower().replace("-", "_"), szz_runtime_barrier_policy, trace_metadata, + None if num_rounds is None else int(num_rounds), ) if cache_key in _state.distance_module_cache: @@ -2665,6 +2713,7 @@ def _surface_code_module_for_patch( module = _load_guppy_module( patch, ancilla_budget=ancilla_budget, + num_rounds=num_rounds, interaction_basis=interaction_basis, check_plan=resolved_plan.plan_id, clifford_frame_policy=clifford_frame_policy, @@ -2686,6 +2735,28 @@ def _surface_code_module_for_patch( module["resolved_check_plan"] = resolved_plan.resolved_metadata module["resolved_check_plan_hash"] = resolved_plan.resolved_hash + if num_rounds is None: + # Round-agnostic getter path: hand back memory factories that scope each + # call to a round-specific module. Calling e.g. make_memory_z(2) then + # make_memory_z(6) on a single round-agnostic module would define both + # factory-local @guppy bodies under the same module-qualified name, so + # guppylang would reuse the first compiled body (see + # _guppy_module_cache_key). Copy first so the shared _load_guppy_module + # namespace keeps its real factories for the round-scoped re-entry. + module = dict(module) + for basis in ("z", "x"): + key = f"make_memory_{basis}" + if key in module: + module[key] = _round_scoped_surface_memory_factory( + patch, + basis, + ancilla_budget=ancilla_budget, + interaction_basis=original_interaction_basis, + check_plan=check_plan, + clifford_frame_policy=clifford_frame_policy, + szz_runtime_barriers=szz_runtime_barriers, + ) + _state.distance_module_cache[cache_key] = module return module @@ -2701,6 +2772,14 @@ def get_surface_code_module( ) -> dict: """Get a loaded surface code module for distance d. + The returned ``make_memory_z``/``make_memory_x`` factories scope each call to + a round-specific module: ``factory(n)`` always produces (and caches) the + experiment in a distinct ``pecos._generated.patch_..._r{n}`` module. The + round count is therefore taken solely from the factory argument -- there is + no separate module-level round count to keep in sync -- so building + experiments at several round counts in one process stays isolated (guppylang + caches compiled functions by module-qualified name). + Args: d: Code distance (must be odd >= 3) ancilla_budget: Optional cap on simultaneously live ancillas diff --git a/python/quantum-pecos/src/pecos/guppy/transversal.py b/python/quantum-pecos/src/pecos/guppy/transversal.py index 27578603e..7b1918bdc 100644 --- a/python/quantum-pecos/src/pecos/guppy/transversal.py +++ b/python/quantum-pecos/src/pecos/guppy/transversal.py @@ -744,9 +744,19 @@ def _add_color_transversal_factory_functions( ) -def _load_css_transversal_module(code_type: CSSCodeType, d: int) -> dict: - """Load a transversal module for the given code type and distance.""" - cache_key = f"{code_type.value}_d{d}" +def _load_css_transversal_module(code_type: CSSCodeType, d: int, *, num_rounds: int | None = None) -> dict: + """Load a transversal module for the given code type and distance. + + The generated experiment factories (``make_transversal_cnot`` etc.) close + over ``num_rounds`` as a Guppy ``comptime`` value baked into a factory-local + ``@guppy`` function whose qualified name is constant across round counts. + guppylang keys compiled functions by ``__module__ + __qualname__`` in a + process-global store, so the module identity must encode ``num_rounds`` -- + otherwise a second round count reuses the first's compiled body (the same + cross-``num_rounds`` leak fixed for the surface/color memory modules). + """ + round_suffix = "" if num_rounds is None else f"_r{int(num_rounds)}" + cache_key = f"{code_type.value}_d{d}{round_suffix}" if cache_key in _state.css_transversal_cache: return _state.css_transversal_cache[cache_key] @@ -809,7 +819,7 @@ def make_css_transversal_cnot( msg = f"Distance must be odd >= 3, got {distance}" raise ValueError(msg) - module = _load_css_transversal_module(code_type, distance) + module = _load_css_transversal_module(code_type, distance, num_rounds=num_rounds) return module["make_transversal_cnot"](num_rounds) @@ -837,7 +847,7 @@ def make_css_transversal_cnot_with_x( msg = f"Distance must be odd >= 3, got {distance}" raise ValueError(msg) - module = _load_css_transversal_module(code_type, distance) + module = _load_css_transversal_module(code_type, distance, num_rounds=num_rounds) return module["make_transversal_cnot_with_x"](num_rounds) diff --git a/python/quantum-pecos/src/pecos/programs/__init__.py b/python/quantum-pecos/src/pecos/programs/__init__.py index 370edf342..9ff8102e2 100644 --- a/python/quantum-pecos/src/pecos/programs/__init__.py +++ b/python/quantum-pecos/src/pecos/programs/__init__.py @@ -130,10 +130,13 @@ def _to_program(self) -> "CompiledHugr": """Convert to the underlying Rust program type.""" if self._program is None: hugr_package = self._func.compile() - # Use JSON format (via to_str) instead of binary format (to_bytes) - # The JSON format is more reliably parsed and supports all HUGR features - # including CFG loops (while statements) - hugr_bytes = hugr_package.to_str().encode("utf-8") + # Use the BINARY HUGR envelope (Model format). The Selene/QIS engine's + # HUGR reader does not accept hugr-py 0.16's S-expression *text* envelope + # (`to_str`) -- loading it fails with "Failed to read HUGR" -- whereas the + # binary Model form round-trips cleanly, including CFG loops (while + # statements). (The `Hugr.from_bytes` sim loader is more permissive and + # accepts either, but the QIS engine path used for DEM tracing is not.) + hugr_bytes = hugr_package.to_bytes() self._program = pecos_rslib.Hugr.from_bytes(hugr_bytes) return self._program diff --git a/python/quantum-pecos/tests/guppy/test_check_hugr_format.py b/python/quantum-pecos/tests/guppy/test_check_hugr_format.py index d4f18b6e4..5098544db 100644 --- a/python/quantum-pecos/tests/guppy/test_check_hugr_format.py +++ b/python/quantum-pecos/tests/guppy/test_check_hugr_format.py @@ -1,7 +1,5 @@ """Check HUGR format from guppylang.""" -import json - import pytest @@ -19,36 +17,14 @@ def simple() -> bool: h(q) return measure(q) - # Compile to HUGR + # Compile to HUGR (a hugr.package.Package) hugr = simple.compile() - # Check binary format - hugr.to_bytes() - - # Check JSON/string format - # Note: to_str() returns HUGR envelope format with header, while to_json() returns pure JSON - if hasattr(hugr, "to_str"): - hugr_str = hugr.to_str() - # Check if it's the envelope format with header - if hugr_str.startswith("HUGRiHJv"): - # Skip header (8 bytes), format byte (1 byte), and extra byte (1 byte) - json_start = hugr_str.find("{", 9) # Find the start of JSON after header - if json_start != -1: - hugr_str = hugr_str[json_start:] - else: - msg = "Could not find JSON start in HUGR envelope" - raise ValueError(msg) - else: - hugr_str = hugr.to_json() - - hugr_dict = json.loads(hugr_str) - - # Check if it's a single HUGR or a Package - if "modules" in hugr_dict or "nodes" in hugr_dict: - pass - - # Save JSON for inspection - import tempfile + # Binary Model envelope should be non-empty bytes + hugr_bytes = hugr.to_bytes() + assert isinstance(hugr_bytes, bytes), "to_bytes() should return bytes" + assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(hugr_dict, f, indent=2) + # Inspect the Package structure directly + assert len(hugr.modules) >= 1, "HUGR should contain at least one module" + assert sum(1 for _ in hugr.modules[0].nodes()) > 0, "First module should contain at least one node" diff --git a/python/quantum-pecos/tests/guppy/test_guppy_result_mechanisms.py b/python/quantum-pecos/tests/guppy/test_guppy_result_mechanisms.py index 52e6c8570..2d40ebf76 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_result_mechanisms.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_result_mechanisms.py @@ -7,11 +7,11 @@ 4. What we should expect in Selene's result stream """ -import json import tempfile from pathlib import Path import pytest +from hugr.package import Package class TestGuppyResultMechanisms: @@ -90,24 +90,11 @@ def test_compile_to_hugr(self, guppy_functions: dict) -> None: assert hugr_bytes is not None, f"{name} should compile to HUGR bytes" assert len(hugr_bytes) > 0, f"{name} HUGR bytes should not be empty" - # Parse HUGR to verify structure - hugr_str = hugr_bytes.decode("utf-8") - - # Handle HUGR envelope format - if hugr_str.startswith("HUGRiHJv"): - json_start = hugr_str.find("{", 9) - assert json_start != -1, "HUGR envelope should contain JSON" - hugr_str = hugr_str[json_start:] - - # Verify it's valid JSON - try: - hugr_json = json.loads(hugr_str) - except json.JSONDecodeError as e: - pytest.fail(f"{name} HUGR is not valid JSON: {e}") - - # Verify basic HUGR structure - assert isinstance(hugr_json, dict), "HUGR should be a JSON object" - assert "nodes" in hugr_json or "modules" in hugr_json, "HUGR should contain nodes or modules" + # Load the binary Model envelope and verify basic structure + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, f"{name} HUGR should contain at least one module" + total_nodes = sum(1 for module in pkg.modules for _ in module.nodes()) + assert total_nodes > 0, f"{name} HUGR should contain at least one node" def test_hugr_contains_operations(self, guppy_functions: dict) -> None: """Test that HUGR contains expected quantum and result operations.""" @@ -118,37 +105,23 @@ def test_hugr_contains_operations(self, guppy_functions: dict) -> None: for name, func in guppy_functions.items(): hugr_bytes = compile_guppy_to_hugr(func) - hugr_str = hugr_bytes.decode("utf-8") - - # Handle HUGR envelope format - if hugr_str.startswith("HUGRiHJv"): - json_start = hugr_str.find("{", 9) - hugr_str = hugr_str[json_start:] + pkg = Package.from_bytes(hugr_bytes) - hugr_json = json.loads(hugr_str) + # Map of ExtOp gate name -> count across all modules. + ops = self._count_operations(pkg) - # Count different types of operations - ops = self._count_operations(hugr_json) + # Every fixture builds a Bell pair, so the compiled HUGR must contain + # the concrete Hadamard and CX gate ops plus a measurement -- not + # merely "some" nodes. + assert ops.get("H", 0) >= 1, f"{name} HUGR should contain an H gate, got {ops}" + assert ops.get("CX", 0) >= 1, f"{name} HUGR should contain a CX gate, got {ops}" + assert ops.get("MeasureFree", 0) >= 1, f"{name} HUGR should contain a measurement, got {ops}" - # Check if HUGR contains any operations at all - total_ops = sum(ops.values()) - - # If we found operations but no quantum ops, it might be a format issue - # The important thing is that the HUGR compiles and has structure - if total_ops == 0: - # Try to check if the HUGR has nodes which indicates it has content - has_nodes = "nodes" in hugr_json and len(hugr_json.get("nodes", [])) > 0 - has_modules = "modules" in hugr_json and len(str(hugr_json.get("modules", ""))) > 100 - - if not (has_nodes or has_modules): - pytest.fail( - f"{name} HUGR seems empty - no operations or nodes found", - ) - - # Functions with result() should have result/output operations + # Functions that tag outputs via result() must keep the result ops + # (guppy lowers result() to tket.result `result_bool`/`result_int`). if "result_tags" in name or "mixed" in name: - # We're being more lenient here since format may vary - pass # Just verify compilation succeeded above + result_ops = sum(count for op_name, count in ops.items() if op_name.startswith("result")) + assert result_ops >= 1, f"{name} HUGR should retain result() ops, got {ops}" def test_compile_to_llvm(self, guppy_functions: dict) -> None: """Test that HUGR compiles to LLVM successfully.""" @@ -276,39 +249,16 @@ def test_expected_output_formats(self) -> None: for type_name in format_info["expected_types"]: assert type_name in valid_types, f"{func_name} has invalid type: {type_name}" - def _count_operations(self, hugr_json: dict) -> dict[str, int]: - """Count different types of operations in HUGR JSON.""" - counts = { - "quantum": 0, - "result": 0, - "output": 0, - "io": 0, - } - - def search(obj: object) -> None: - if isinstance(obj, dict): - if "op" in obj: - op_str = str(obj["op"]).lower() - - # Count quantum operations - if any(q in op_str for q in ["quantum", "h", "cx", "measure"]): - counts["quantum"] += 1 - - # Count result/output operations - if "result" in op_str: - counts["result"] += 1 - if "output" in op_str: - counts["output"] += 1 - if "io" in op_str or "print" in op_str: - counts["io"] += 1 - - for value in obj.values(): - search(value) - elif isinstance(obj, list): - for item in obj: - search(item) - - search(hugr_json) + def _count_operations(self, pkg: Package) -> dict[str, int]: + """Count ExtOp gate names across all modules in the HUGR package.""" + counts: dict[str, int] = {} + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + op = module[n].op + op_def = getattr(op, "_op_def", None) + if op_def is not None: + counts[op_def.name] = counts.get(op_def.name, 0) + 1 return counts diff --git a/python/quantum-pecos/tests/guppy/test_guppy_with_results.py b/python/quantum-pecos/tests/guppy/test_guppy_with_results.py index 1588d64ba..db6a891a9 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_with_results.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_with_results.py @@ -4,11 +4,11 @@ that Selene can extract from the result stream. """ -import json import tempfile from pathlib import Path import pytest +from hugr.package import Package class TestGuppyWithResults: @@ -280,25 +280,12 @@ def test_with_outputs() -> None: hugr_bytes = compile_guppy_to_hugr(test_with_outputs) - # Parse HUGR to check for output operations - hugr_str = hugr_bytes.decode("utf-8") + # Load the binary Model envelope and count result() operations + pkg = Package.from_bytes(hugr_bytes) + output_ops = self._count_output_operations(pkg) - # Handle HUGR envelope format if present - if hugr_str.startswith("HUGRiHJv"): - json_start = hugr_str.find("{", 9) - if json_start != -1: - hugr_str = hugr_str[json_start:] - - try: - hugr_json = json.loads(hugr_str) - except json.JSONDecodeError as e: - pytest.fail(f"HUGR is not valid JSON: {e}") - - # Count output-related operations - output_ops = self._count_output_operations(hugr_json) - - # Should have some output/result/io operations - assert output_ops > 0, "HUGR should contain output/result operations" + # test_with_outputs tags two results, so both tket.result ops must survive. + assert output_ops >= 2, f"HUGR should retain the result() ops, found {output_ops}" def test_save_hugr_artifacts(self, check_guppy_imports: dict) -> None: """Test saving HUGR compilation artifacts for inspection.""" @@ -334,47 +321,27 @@ def simple_quantum() -> bool: assert hugr_file.exists(), "HUGR file should be created" assert hugr_file.stat().st_size > 0, "HUGR file should not be empty" - # Parse and save formatted JSON - hugr_str = hugr_bytes.decode("utf-8") - if hugr_str.startswith("HUGRiHJv"): - json_start = hugr_str.find("{", 9) - if json_start != -1: - hugr_str = hugr_str[json_start:] + # Load the saved artifact to confirm it is a valid HUGR + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "Saved HUGR should contain at least one module" - try: - hugr_json = json.loads(hugr_str) - formatted_file = tmpdir_path / "simple_quantum_formatted.json" - formatted_file.write_text(json.dumps(hugr_json, indent=2)) + def _count_output_operations(self, pkg: Package) -> int: + """Count ``result()`` operations across all modules in the HUGR package. - assert formatted_file.exists(), "Formatted JSON should be created" - assert formatted_file.stat().st_size > 0, "Formatted JSON should not be empty" - - # Verify JSON structure - assert isinstance(hugr_json, dict), "HUGR should be a JSON object" - - except json.JSONDecodeError: - # If not JSON, that's okay - just test raw bytes were saved - pass - - def _count_output_operations(self, hugr_json: dict) -> int: - """Count output-related operations in HUGR JSON.""" + guppy lowers ``result(tag, value)`` to concrete ``tket.result`` extension + ops (``result_bool``, ``result_int``, ...). Counting these -- rather than + any output/measurement node -- verifies the ``result()`` tags actually + survived compilation, which is the point of these tests. + """ count = 0 - - def search(obj: object) -> None: - nonlocal count - if isinstance(obj, dict): - if "op" in obj: - op_str = str(obj["op"]).lower() - if any(term in op_str for term in ["output", "result", "return", "io"]): - count += 1 - - for value in obj.values(): - search(value) - elif isinstance(obj, list): - for item in obj: - search(item) - - search(hugr_json) + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + op = module[n].op + op_def = getattr(op, "_op_def", None) + name = op_def.name if op_def is not None else type(op).__name__ + if name.startswith("result"): + count += 1 return count diff --git a/python/quantum-pecos/tests/guppy/test_hugr_compilation.py b/python/quantum-pecos/tests/guppy/test_hugr_compilation.py index 89afb372a..bc2c779ac 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_compilation.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_compilation.py @@ -193,12 +193,10 @@ def simple_circuit() -> bool: assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" assert isinstance(hugr_bytes, bytes), "Should return bytes" - # Check for HUGR format markers - hugr_str = hugr_bytes.decode("utf-8") - is_hugr_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") or "{" in hugr_str[:100] + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - assert is_hugr_envelope or is_json, "HUGR output should be envelope format or JSON" + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "HUGR bytes should load as a valid HUGR" class TestLLVMIRPatterns: diff --git a/python/quantum-pecos/tests/guppy/test_hugr_structure.py b/python/quantum-pecos/tests/guppy/test_hugr_structure.py index bbe4cda71..a2dfe779e 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_structure.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_structure.py @@ -1,13 +1,10 @@ """Test to understand HUGR 0.13 structure from guppylang.""" -import json -import tempfile - import pytest def test_hugr_json_structure() -> None: - """Examine HUGR JSON structure from guppylang.""" + """Examine HUGR structure from guppylang.""" try: from guppylang import guppy from guppylang.std.quantum import h, measure, qubit @@ -20,34 +17,9 @@ def simple_circuit() -> bool: h(q) return measure(q) - # Compile to HUGR - hugr = simple_circuit.compile() - - # Get JSON/string representation (use to_str if available) - if hasattr(hugr, "to_str"): - hugr_str = hugr.to_str() - # Check if it's the envelope format with header - if hugr_str.startswith("HUGRiHJv"): - # Skip header (8 bytes), format byte (1 byte), and find JSON start - json_start = hugr_str.find("{", 9) - if json_start != -1: - hugr_str = hugr_str[json_start:] - else: - msg = "Could not find JSON start in HUGR envelope" - raise ValueError(msg) - else: - hugr_str = hugr.to_json() - - hugr_dict = json.loads(hugr_str) - - if "modules" in hugr_dict: - for _i, module in enumerate(hugr_dict["modules"]): - if "nodes" in module: - # Print first few nodes - for _j, _node in enumerate(module["nodes"][:5]): - - pass + # Compile to HUGR (a hugr.package.Package) + pkg = simple_circuit.compile() - # Save to file for inspection - with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: - json.dump(hugr_dict, f, indent=2) + # Inspect the Package structure directly + assert len(pkg.modules) >= 1, "HUGR should contain at least one module" + assert sum(1 for _ in pkg.modules[0].nodes()) > 0, "First module should contain at least one node" diff --git a/python/quantum-pecos/tests/guppy/test_multi_module_handling.py b/python/quantum-pecos/tests/guppy/test_multi_module_handling.py index e001b70a4..51c71becb 100644 --- a/python/quantum-pecos/tests/guppy/test_multi_module_handling.py +++ b/python/quantum-pecos/tests/guppy/test_multi_module_handling.py @@ -10,6 +10,7 @@ import pytest from guppylang import GuppyModule, guppy +from hugr.package import Package from pecos_rslib import compile_hugr_to_qis as rust_compile from selene_hugr_qis_compiler import compile_to_llvm_ir as selene_compile @@ -20,40 +21,24 @@ from guppylang.std.quantum import cx, h, measure, qubit -def count_modules_in_hugr(hugr_str: str) -> tuple[int, list[str]]: - """Count modules and extract their function names from HUGR string. +def count_modules_in_hugr(pkg: Package) -> tuple[int, list[str]]: + """Count modules and extract their function names from a HUGR package. Args: - hugr_str: HUGR in string format (may be JSON or binary-prefixed) + pkg: Compiled HUGR as a hugr.package.Package Returns: (module_count, list_of_function_names) """ - try: - # HUGR string format seems to have a binary prefix, try to extract JSON - if hugr_str.startswith("HUGRi"): - # Find the JSON part after the binary prefix - json_start = hugr_str.find('{"modules"') - if json_start == -1: - return 0, [] - hugr_str = hugr_str[json_start:] - - data = json.loads(hugr_str) - modules = data.get("modules", []) - - # Extract function names from all modules - function_names = [ - node["name"] - for module in modules - for node in module.get("nodes", []) - if node.get("op") == "FuncDefn" and "name" in node and node["name"] != "__main__" - ] - - return len(modules), function_names - except (json.JSONDecodeError, KeyError, TypeError) as e: - print(f"Failed to parse HUGR: {e}") - print(f"First 200 chars: {hugr_str[:200]}") - return 0, [] + function_names: list[str] = [] + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + op = module[n].op + if type(op).__name__ == "FuncDefn" and op.f_name != "__main__": + function_names.append(op.f_name) + + return len(pkg.modules), function_names def extract_function_calls_from_llvm(llvm_ir: str) -> set[str]: @@ -92,11 +77,10 @@ def single_hadamard() -> bool: h(q) return measure(q) - hugr = single_hadamard.compile() - hugr_json = hugr.to_str() if hasattr(hugr, "to_str") else str(hugr) + pkg = single_hadamard.compile() # Analyze the HUGR structure - module_count, function_names = count_modules_in_hugr(hugr_json) + module_count, function_names = count_modules_in_hugr(pkg) print(f"Single module test - Modules: {module_count}, Functions: {function_names}") assert module_count >= 1, "Should have at least one module" @@ -126,15 +110,12 @@ def single_qubit_test() -> bool: return measure(q) # Compile each function separately - bell_hugr = create_bell_pair.compile() - single_hugr = single_qubit_test.compile() + bell_pkg = create_bell_pair.compile() + single_pkg = single_qubit_test.compile() # Analyze each HUGR structure - bell_hugr_str = bell_hugr.to_str() if hasattr(bell_hugr, "to_str") else str(bell_hugr) - single_hugr_str = single_hugr.to_str() if hasattr(single_hugr, "to_str") else str(single_hugr) - - bell_modules, bell_functions = count_modules_in_hugr(bell_hugr_str) - single_modules, single_functions = count_modules_in_hugr(single_hugr_str) + bell_modules, bell_functions = count_modules_in_hugr(bell_pkg) + single_modules, single_functions = count_modules_in_hugr(single_pkg) print(f"Bell pair - Modules: {bell_modules}, Functions: {bell_functions}") print(f"Single qubit - Modules: {single_modules}, Functions: {single_functions}") @@ -170,7 +151,7 @@ def test_function() -> tuple[bool, bool]: hugr_str = hugr.to_str() if hasattr(hugr, "to_str") else str(hugr) # Analyze HUGR structure - module_count, function_names = count_modules_in_hugr(hugr_str) + module_count, function_names = count_modules_in_hugr(hugr) print(f"HUGR Analysis - Modules: {module_count}, Functions: {function_names}") # Compile with both compilers diff --git a/python/quantum-pecos/tests/guppy/test_python_side_compilation.py b/python/quantum-pecos/tests/guppy/test_python_side_compilation.py index 3d59350b1..147588a5a 100644 --- a/python/quantum-pecos/tests/guppy/test_python_side_compilation.py +++ b/python/quantum-pecos/tests/guppy/test_python_side_compilation.py @@ -112,28 +112,14 @@ def test_compilation_output_structure(self, simple_circuit: object) -> None: assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" assert isinstance(hugr_bytes, bytes), "HUGR should be bytes" - # Check for HUGR markers - hugr_str = hugr_bytes.decode("utf-8") - is_hugr_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") or "{" in hugr_str[:100] - - assert is_hugr_envelope or is_json, "HUGR should be in envelope format or JSON" - - # If JSON, verify it can be parsed - if is_json or (is_hugr_envelope and "{" in hugr_str): - import json - - json_start = hugr_str.find("{") if is_hugr_envelope else 0 - if json_start != -1: - try: - json_data = json.loads(hugr_str[json_start:]) - assert isinstance( - json_data, - dict, - ), "HUGR JSON should be a dictionary" - assert len(json_data) > 0, "HUGR JSON should not be empty" - except json.JSONDecodeError as e: - pytest.fail(f"HUGR JSON is invalid: {e}") + # Binary HUGR Model envelope; load via Package to verify structure. + from hugr.package import Package + + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "HUGR should contain at least one module" + + total_nodes = sum(1 for module in pkg.modules for _ in module.nodes()) + assert total_nodes > 0, "HUGR should contain at least one node" class TestCompilationErrorHandling: diff --git a/python/quantum-pecos/tests/guppy/test_selene_build_process.py b/python/quantum-pecos/tests/guppy/test_selene_build_process.py index cd9b7e06b..752798ebc 100644 --- a/python/quantum-pecos/tests/guppy/test_selene_build_process.py +++ b/python/quantum-pecos/tests/guppy/test_selene_build_process.py @@ -4,7 +4,6 @@ HUGR from Guppy and create an executable that can be wrapped by SeleneExecutableEngine. """ -import json import tempfile import textwrap from pathlib import Path @@ -35,20 +34,10 @@ def simple_h() -> bool: assert hugr_bytes is not None, "HUGR compilation should succeed" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Parse HUGR to understand structure - hugr_str = hugr_bytes.decode("utf-8") - if hugr_str.startswith("HUGRiHJv"): - # Skip header and find JSON start - json_start = hugr_str.find("{", 9) - assert json_start != -1, "Should find JSON start in HUGR envelope" - hugr_str = hugr_str[json_start:] + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - # Validate JSON structure - try: - hugr_json = json.loads(hugr_str) - assert isinstance(hugr_json, dict), "HUGR should be valid JSON object" - except json.JSONDecodeError as e: - pytest.fail(f"HUGR should be valid JSON: {e}") + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "HUGR should load as a valid HUGR" with tempfile.TemporaryDirectory() as tmpdir: build_dir = Path(tmpdir) @@ -611,27 +600,13 @@ def simple_circuit() -> bool: return measure(q) hugr_bytes = compile_guppy_to_hugr(simple_circuit) - hugr_str = hugr_bytes.decode("utf-8") - - # Check format detection - is_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") - - assert is_envelope or is_json, "HUGR should be in envelope or JSON format" + assert hugr_bytes is not None, "Should produce HUGR bytes" + assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - if is_envelope: - # Verify envelope structure - assert len(hugr_str) > 9, "Envelope should have header and content" - json_start = hugr_str.find("{", 9) - assert json_start != -1, "Envelope should contain JSON" + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - # Extract and validate JSON - json_content = hugr_str[json_start:] - try: - parsed = json.loads(json_content) - assert isinstance(parsed, dict), "Should parse as JSON object" - except json.JSONDecodeError as e: - pytest.fail(f"Envelope JSON should be valid: {e}") + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "HUGR should load as a valid HUGR" def test_build_artifacts_structure(self) -> None: """Test the structure of build artifacts created.""" diff --git a/python/quantum-pecos/tests/guppy/test_selene_direct_integration.py b/python/quantum-pecos/tests/guppy/test_selene_direct_integration.py index d8d99bb87..618de0f5e 100644 --- a/python/quantum-pecos/tests/guppy/test_selene_direct_integration.py +++ b/python/quantum-pecos/tests/guppy/test_selene_direct_integration.py @@ -4,7 +4,6 @@ it with PECOS's ClassicalControlEngine infrastructure. """ -import json import tempfile from pathlib import Path from typing import Any @@ -296,35 +295,10 @@ def simple_h_gate() -> bool: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Try to understand HUGR format - hugr_str = hugr_bytes.decode("utf-8") + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - # Check if it's envelope format or JSON - is_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") - - assert is_envelope or is_json, "HUGR should be in envelope or JSON format" - - if is_json: - # Direct JSON format - try: - hugr_json = json.loads(hugr_str) - assert isinstance(hugr_json, dict), "HUGR JSON should be a dictionary" - assert len(hugr_json) > 0, "HUGR JSON should not be empty" - except json.JSONDecodeError as e: - pytest.fail(f"HUGR should be valid JSON: {e}") - - elif is_envelope: - # Envelope format - find JSON part - json_start = hugr_str.find("{", 9) - assert json_start != -1, "Envelope should contain JSON" - - json_part = hugr_str[json_start:] - try: - hugr_json = json.loads(json_part) - assert isinstance(hugr_json, dict), "HUGR JSON should be a dictionary" - except json.JSONDecodeError as e: - pytest.fail(f"Envelope JSON should be valid: {e}") + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "Should load as a valid HUGR" def test_multi_qubit_compilation(self) -> None: """Test compiling a multi-qubit program.""" @@ -342,15 +316,10 @@ def three_qubit_ghz() -> tuple[bool, bool, bool]: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 100, "Multi-qubit HUGR should be substantial" - # Verify it contains quantum operations - hugr_str = hugr_bytes.decode("utf-8") - - # Look for quantum operation indicators (might be in the JSON) - # These patterns might appear in operation names or types - quantum_indicators = ["quantum", "Quantum", "h", "cx", "measure"] + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - found_quantum = any(indicator in hugr_str for indicator in quantum_indicators) - assert found_quantum, "HUGR should contain quantum operation indicators" + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "Should load as a valid HUGR" def test_conditional_compilation(self) -> None: """Test compiling a program with conditional logic.""" @@ -369,13 +338,7 @@ def conditional_circuit() -> int: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Check that the HUGR represents control flow - hugr_str = hugr_bytes.decode("utf-8") - - # Control flow might appear as specific operation types - # Look for indicators of branching or conditionals + # Binary HUGR envelope (Model format); verify it is a valid, loadable HUGR. + import pecos_rslib - # At least check it's valid HUGR - assert "HUGRiHJv" in hugr_str or hugr_str.startswith( - "{", - ), "Should be valid HUGR format" + assert pecos_rslib.Hugr.from_bytes(hugr_bytes) is not None, "Should load as a valid HUGR" diff --git a/uv.lock b/uv.lock index 7a6a8bd7b..02d8404d7 100644 --- a/uv.lock +++ b/uv.lock @@ -1172,7 +1172,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11' or (python_full_version < '3.13' and extra != 'extra-13-quantum-pecos-cuda12' and extra != 'extra-13-quantum-pecos-cuda13') or (python_full_version >= '3.13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (python_full_version >= '3.13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-13-quantum-pecos-cuda13' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda13' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "typing-extensions", marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1304,7 +1304,7 @@ wheels = [ [[package]] name = "guppylang" -version = "0.21.11" +version = "0.21.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "guppylang-internals" }, @@ -1316,26 +1316,26 @@ dependencies = [ { name = "tqdm" }, { name = "types-tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/6d/dcfebfca39fc8fce2f5b27fc2f411ebfcd18e4509959215ac6d6a38afb5f/guppylang-0.21.11.tar.gz", hash = "sha256:5ff823484c9e8cc2a9c13279be0aec2dc68f3aa725bd9f125d912a393983747e", size = 68353, upload-time = "2026-04-01T13:23:19.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/79/e148d43eebdbd0e670cfe983d2806c1d8199d4b69e45680a352d2a6c6265/guppylang-0.21.16.tar.gz", hash = "sha256:37ecb3267734540104ada3be1e12726f631edfba2b013efa8f8064cc324d7fda", size = 70378, upload-time = "2026-06-04T14:57:10.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/73/7e6e9567d600cdb181273216ba82ab9b8b279da92c969685506b83181af9/guppylang-0.21.11-py3-none-any.whl", hash = "sha256:b00e8f1be52c846c349c576c4d264771fca01fe5f2f0a6a01434f92d4b7350c4", size = 65701, upload-time = "2026-04-01T13:23:18.265Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6a/7120b688f8543ef39ac1d42d325233aacde677a562fe72e3e51f10e433ee/guppylang-0.21.16-py3-none-any.whl", hash = "sha256:22ada5f013d96574102b89d88c290449b23f9972a8f0e88e92227308b8aa275c", size = 67031, upload-time = "2026-06-04T14:57:09.183Z" }, ] [[package]] name = "guppylang-internals" -version = "0.32.0" +version = "0.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, { name = "pytket" }, - { name = "tket" }, + { name = "tket", extra = ["pytket"] }, { name = "tket-exts" }, { name = "typing-extensions" }, { name = "wasmtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/99/ee9e0475e0597c06bd8a6a05ceb5b0a3dfb52c1830a84fd322d541db5ada/guppylang_internals-0.32.0.tar.gz", hash = "sha256:ecd074ba42903558c381d0e62891e7e749932a62003d56bda5678c06959f818e", size = 207836, upload-time = "2026-04-01T12:45:18.605Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/67/c2ea5658a28d1ea814a64e91cf0433197dd0e99a3eab1dc3538e14499634/guppylang_internals-0.36.1.tar.gz", hash = "sha256:3ee67ffdd7e97cf9273f9a65697c48fb192bb98f110c960931d6ea1f2b7f0934", size = 214605, upload-time = "2026-06-04T14:53:42.617Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/52/3196a4b254d58d66c56235ff4194e519e603927753ac6a6d13179cadf59e/guppylang_internals-0.32.0-py3-none-any.whl", hash = "sha256:8d711e4a60c28b726b92ffbb383ef7f836ac2a8135838b5ad9f49f6e4e840962", size = 260494, upload-time = "2026-04-01T12:45:16.369Z" }, + { url = "https://files.pythonhosted.org/packages/31/ac/f66326c93cc7e0c74ec8a195d23c78690cb470c77519e77a4d4876f5d355/guppylang_internals-0.36.1-py3-none-any.whl", hash = "sha256:d70dc644bdfaff46b5b94b308aa90e4aa667c60b85f72a82fbc1ea729d2dfe51", size = 267518, upload-time = "2026-06-04T14:53:40.954Z" }, ] [[package]] @@ -1377,7 +1377,7 @@ wheels = [ [[package]] name = "hugr" -version = "0.15.5" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "graphviz" }, @@ -1386,36 +1386,36 @@ dependencies = [ { name = "semver" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/32/01b0e17e2aade67a18180af4bc2b74942c3f526851ab13e3072ee0655550/hugr-0.15.5.tar.gz", hash = "sha256:538b50c0070fc2e45f3e1394fa862ec131b22390e9acfd89f48e5c1ad122d635", size = 1115293, upload-time = "2026-03-30T12:59:53.771Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/1e/e412e18a0bfd606be5e51b4bb3aba98c24d11c871e89a9a7aa59d84595e3/hugr-0.15.5-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b0959494e29cd9f48ce1e6fd11deb24b009569abb3a2c7216fd90602adf1a850", size = 3287674, upload-time = "2026-03-30T12:59:50.234Z" }, - { url = "https://files.pythonhosted.org/packages/03/d1/7a02ea4508d4fe36a028c52efaedf8141976a98ea194b3ae1fefdfae92a9/hugr-0.15.5-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:3d968983df1b1fa89f4523b45438d3bcba4cddfa0e7365ecffc0116613a8aba7", size = 2937104, upload-time = "2026-03-30T12:59:46.14Z" }, - { url = "https://files.pythonhosted.org/packages/4e/28/1c15cd8c33d0dbd2bf0f63a451951c9f0eec06d2fd4b61678253197b8026/hugr-0.15.5-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5a84c326d5a16bf6cb83db3872e73fbe2a3a0134a998aa4d00364a8cb5f1e2e", size = 3259115, upload-time = "2026-03-30T12:59:04.481Z" }, - { url = "https://files.pythonhosted.org/packages/ec/66/e2c335fb57e5f1d2ea09782a7dbd82de8e49e3290653d3f3e79e32754af5/hugr-0.15.5-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7a106cd91076aa7a8392ca7266aec9785b92cb7bedbe3a2d06569ca5783dcc19", size = 3243950, upload-time = "2026-03-30T12:59:08.566Z" }, - { url = "https://files.pythonhosted.org/packages/48/28/54219692e40e38cb3fac6afa336f898db033e1bf434fd452a867a40a2be1/hugr-0.15.5-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:86ae8263cc5b576fb2d1403e3b1326db9f8f01e316f4588842b53f6b2fa3868b", size = 3666466, upload-time = "2026-03-30T12:59:12.096Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0e/0e14fc7e7eb402256d906bcd6aa40cd06d3394d41680a960805c22757520/hugr-0.15.5-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:92e10d5be181a49c381b11082a2a2c5973cb5f139b5eb077bb0b3aabe217be84", size = 3739167, upload-time = "2026-03-30T12:59:16.236Z" }, - { url = "https://files.pythonhosted.org/packages/f6/1d/7d0f934e24e8a8ad0477d941529774cf09a2f13cf1e6cace5f9bd040be56/hugr-0.15.5-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:00d700296911b0d6dd7a9854899bd3cca7d77ebc24a999a4117d0120fead88d0", size = 3513669, upload-time = "2026-04-01T17:50:20.969Z" }, - { url = "https://files.pythonhosted.org/packages/b0/62/df3c0cf828757965da82abea5f3fe03e3eeb77452fdc88e31b2c35581675/hugr-0.15.5-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0a536c5eb547b64f4d820229397ef20f0fffcb929559ec00bf4ba8a66927143", size = 3541999, upload-time = "2026-04-01T17:50:24.524Z" }, - { url = "https://files.pythonhosted.org/packages/1d/ca/531363852d9fe16e14453e52b1e2420deda99a84fdcfb56f622c52357d0c/hugr-0.15.5-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d1b1cc6671da1f75f04205fceaf6d2096b3c2e3a584e9bc78ad4fa6694d6cb75", size = 3467535, upload-time = "2026-03-30T12:59:26.551Z" }, - { url = "https://files.pythonhosted.org/packages/8e/e5/a82d0007c3136319ae8280a9b752fe224a0b6700aeafdb6b641924e2dd60/hugr-0.15.5-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:b3cbeb904291afc99ec9cd617fbf0b87e4e1813db2d5b4c7a880c5c68f380ee9", size = 3523569, upload-time = "2026-03-30T12:59:30.427Z" }, - { url = "https://files.pythonhosted.org/packages/19/04/257cdcead28cf9279bc8864338a3d4c33dec454a6217c3ebd207f648c943/hugr-0.15.5-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:7b7fed0cf9ab91d238d42003a1eb33bf673a2005e9f838f6424fa857784f10ba", size = 3607051, upload-time = "2026-03-30T12:59:34.322Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9a/468df655f5e44bdb56c1cfa2e2489d0cf20862a03f878403bad9cf4c9e2b/hugr-0.15.5-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2ac6c9cfc2e2c3540108723e8f9f0bfd08b767d94195a7b5e02143345336988f", size = 3763498, upload-time = "2026-03-30T12:59:38.997Z" }, - { url = "https://files.pythonhosted.org/packages/29/63/8b01ae8d59f8220fd03504eb0ff771f40dd803fcf75160e3c8b65bacac05/hugr-0.15.5-cp310-abi3-win32.whl", hash = "sha256:5996a0d93d23ab3b192ce99c03215e456fc0754566d04694e1bb8404d7931c36", size = 2897922, upload-time = "2026-03-30T12:59:44.406Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4d/ba3a51e3dbacaf23a0d7c4bead0b8e52e7744754d942875102b6f7d91e3f/hugr-0.15.5-cp310-abi3-win_amd64.whl", hash = "sha256:4ca15068cd32199c52cd93225686f7a1416b478f534905026593ecf8ae82d38f", size = 3120930, upload-time = "2026-03-30T12:59:42.705Z" }, - { url = "https://files.pythonhosted.org/packages/10/aa/8791aee20794f282653b0d3d775265cdf72438c23d27e624172db92fb813/hugr-0.15.5-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:2a101e02e6288556029b269faf9338acafd30635b9832971fc8b2f9f465e9fc5", size = 3286019, upload-time = "2026-03-30T12:59:52.202Z" }, - { url = "https://files.pythonhosted.org/packages/1a/c3/592b85d664fc52a4ac5312c44b42e6421a02f8de1aec5f865603ec1b48b9/hugr-0.15.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e7efbd8a56ef70690ae161b292d96eb8ac766e8fa1dc466ceb8686803fb81d72", size = 2936026, upload-time = "2026-03-30T12:59:48.254Z" }, - { url = "https://files.pythonhosted.org/packages/ad/16/169de9863b41c6bec0493fd3cdc7c35d20d7cbf93079f234ff24bb7a9ba3/hugr-0.15.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c11e28b43569cdb049cbb21b99f8bee15be516157d0c336421b9fc5c270642a", size = 3256239, upload-time = "2026-03-30T12:59:06.557Z" }, - { url = "https://files.pythonhosted.org/packages/69/54/83d94589c289c2a1df7d871af548e23f23a658be1f455f68f4abb744a2a9/hugr-0.15.5-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:55f9fb9863733f347374afba74ce91f3cd8a1b39e7db2c2474def16004688e62", size = 3243114, upload-time = "2026-03-30T12:59:10.41Z" }, - { url = "https://files.pythonhosted.org/packages/d7/07/a13d1f373a6dcc6ee8fc079b936cc450c510333dbcf4a000112343e91c15/hugr-0.15.5-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6dacc70fc00fbc69cb24145027609c136d0380cb7de27e6f60755c990c8d64e1", size = 3511629, upload-time = "2026-03-30T12:59:19.719Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ac/173ea1a8b6c7768f3a496302a5e61581bfc1d636c1356d01b227d86ed8a1/hugr-0.15.5-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fe5b8ce5e17982adc6427ff590e9e4a535b2e449b009706ec760c2e9213f3f6", size = 3662265, upload-time = "2026-03-30T12:59:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/29/44/60e22b8d61dcc35009aa06ad63f300061ba8e24aeb9ea16193350eee1f38/hugr-0.15.5-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b9c4ad0d637edeb8a45610e1d9a71c4137e064d4a2dc1334e2099e9ffdeeaac", size = 3737613, upload-time = "2026-03-30T12:59:18.06Z" }, - { url = "https://files.pythonhosted.org/packages/67/49/4c648af0c5f9521fd089a8803f9f6c2dd1c31e636c21f0e701bfa2da9030/hugr-0.15.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ea20c8ab98aa0d865577a0af1b11598b782ae5e6e076b55e8421f318288f16c", size = 3534945, upload-time = "2026-03-30T12:59:23.222Z" }, - { url = "https://files.pythonhosted.org/packages/10/b0/4f2b061e78bcc871865e698a5120bd2504a45bb0aaa0007ae6f9fa2c2b06/hugr-0.15.5-cp313-cp313t-manylinux_2_28_i686.whl", hash = "sha256:68511a127b73c10c8cab08ca6e92b2f96fd111d7e2a3bc86eda64ecc7cb8e6ad", size = 3514687, upload-time = "2026-04-01T17:50:35.44Z" }, - { url = "https://files.pythonhosted.org/packages/cc/36/f11007c43ea13c5b5c7f50ce3b40dad1bb4551e53dd12e3ca908c071b483/hugr-0.15.5-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:f50260b4e330d3bb06c1e7acf87194dfbf090b3f608536e77fe9562f2ed8d57c", size = 3543491, upload-time = "2026-04-01T17:50:39.132Z" }, - { url = "https://files.pythonhosted.org/packages/d5/be/1d61f890ed50a5ad3b826dadb127fa297a1022d43573a122a619d09a3b2c/hugr-0.15.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0376cda72bb3075a94cdfab2d0dc4e1aec79284f21855178057bdf143b4c0ddd", size = 3465437, upload-time = "2026-03-30T12:59:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e2/3a13a9b44842baaef1efac6d470bb974d956eaa7f7ab602a34f557b2df8a/hugr-0.15.5-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2fd3dcc9731bc357a4efd71c667e7d33ddaac78c1a6bb3de987583a4429651b6", size = 3522732, upload-time = "2026-03-30T12:59:32.328Z" }, - { url = "https://files.pythonhosted.org/packages/52/62/a396c7c8a40acd2c8c394f25f32165cbbfbe8db989cc218b3d509844010f/hugr-0.15.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:37efbdd3cfd96a692a026bd4bd8bae2795ae3374786c57f053819c335a98f24a", size = 3607041, upload-time = "2026-03-30T12:59:36.787Z" }, - { url = "https://files.pythonhosted.org/packages/f0/2a/735cba4a2fd45aac30e678d196c91b9e6d8707a6a8f1a8a2157c73b11f22/hugr-0.15.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:291f48270b27e183cfca356602d63925ff1a9f51847d35de16366d04fbbfc5ca", size = 3763612, upload-time = "2026-03-30T12:59:40.945Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/ac/29/129d978423d9e74ee42a25a0099bea068b48e66c2d1eb4f7f5faa128fb67/hugr-0.16.0.tar.gz", hash = "sha256:de4827cfe27e9d6ee2a764382825ae7ef8bd3282947f3c59114cc0de5a198124", size = 986110, upload-time = "2026-04-01T13:04:20.063Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/54/c8593f1e41aedff29cd6fab66a60ffaa68380a8b8fc31841b53d1919f05c/hugr-0.16.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:270bb717d28310e297995f7e84335336d4a896b048468ca6d5a56e672e9c83f8", size = 3399386, upload-time = "2026-04-01T13:04:17.615Z" }, + { url = "https://files.pythonhosted.org/packages/9b/0d/e9d90f6895ab0a8e144f5030f2384f85a98af98dd34f616e157863b09ffc/hugr-0.16.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:759a52fd1a0e2c2a1defbddc88505294cd1ec945d422cb170e7c016c946fbcce", size = 3041962, upload-time = "2026-04-01T13:04:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/cd/41/e3ebf1fed1da84081f4e11bba9ae82a5270856288b5f38d5cbe1578bf226/hugr-0.16.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d484a7c872fb81bf359e8678def627d2f0fd64516b0e131a789d2198410117e2", size = 3362746, upload-time = "2026-04-01T13:03:43.218Z" }, + { url = "https://files.pythonhosted.org/packages/08/34/05d82ac6f872aa02cbd920927d7c1ce11163e8321fd9e053cd601bfe0d74/hugr-0.16.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e9693f512909e76abf69072b5504615cd022034ff828f9b7f30f95c51e05af74", size = 3340868, upload-time = "2026-04-01T13:03:45.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/2c/7401bb1edf930576f51e5678ae9a74d11f10c0ef83f42adf06397de2f3e1/hugr-0.16.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d66d2c07adc54065df2a244bcbc67c41c5adb9e682b8d5b3e9946bebb54d791d", size = 3776711, upload-time = "2026-04-01T13:03:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/748222a7c4c84353f8b5741240ca3c51c9e29d706884296bf44e403010cb/hugr-0.16.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e653ad5a93eaf9a9197e8d5126329ac95c4def3d878d10f5a9a9c18cebe788dd", size = 3855897, upload-time = "2026-04-01T13:03:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/eee150b3c7454ef022f1de4003ed7084af4ddebf3efadc5f6bb060306f73/hugr-0.16.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:02be6d8080372cae20c9683438e297b5f38ef1a3ab8488b34c614e575e00ee65", size = 3608483, upload-time = "2026-04-01T17:50:43.25Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/6da7d6d35db29f7782b692f9df79f5be6882d62da4067f408cdcae1dac5a/hugr-0.16.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44b750e5f725a827ac12524bd35199f956267c7ff383039cfcdb419e1ee22e31", size = 3656539, upload-time = "2026-04-01T17:50:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2e/d81e42a57de81020cb14089fe45f76337f59bc9aca9f1483e4552f3c2bee/hugr-0.16.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e8a4e6b0f17a5f56c387539cf02fb6eacbc66cbd86b5ce07b09e6a860b254ce5", size = 3569466, upload-time = "2026-04-01T13:04:00.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/0f/23845b23a6aeac0d02a89e06004dbcda286d0500314f68c73c34b07b5c78/hugr-0.16.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a087257e1d60ce6fc266a3f6d734935554a78b3dddd429698418b4f5eee49e09", size = 3617572, upload-time = "2026-04-01T13:04:03.33Z" }, + { url = "https://files.pythonhosted.org/packages/5b/43/84f8d9399fa77d04f6b4d1ef856b98b8520110f844bf1b65d6c98ac4edc1/hugr-0.16.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:8f9eaabf8439cbdf7e0d873fc10506d7f8db9673966d3d934fb8a1ec86bbc0a9", size = 3705137, upload-time = "2026-04-01T13:04:06.031Z" }, + { url = "https://files.pythonhosted.org/packages/da/9d/b3d75e12ad0265ea9cbfabb49cc9072e6109596d8777031e9a3d4d016f6d/hugr-0.16.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9781813e4222fe1498bd8adb3b31e8ce4e92e925bfd097ec24d8354b87f51d2c", size = 3881014, upload-time = "2026-04-01T13:04:08.983Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ca/d71f8374f62831472269927f2adf4b0d14e35d25769940dc05a23e8930e0/hugr-0.16.0-cp310-abi3-win32.whl", hash = "sha256:9649bf4552bbe9cf93eec21a344d50c7a4a8429aa15928e4647778a9bbf92488", size = 2954806, upload-time = "2026-04-01T13:04:13.447Z" }, + { url = "https://files.pythonhosted.org/packages/72/5a/3dbf899aacdf2978a57ccd2a5ee0832f92608b67fecf43440a1a125f0a48/hugr-0.16.0-cp310-abi3-win_amd64.whl", hash = "sha256:c75101ca5b9c846f20502d4cfa88286a2784bd7c0d653d8025040393e3a9e568", size = 3236422, upload-time = "2026-04-01T13:04:11.801Z" }, + { url = "https://files.pythonhosted.org/packages/31/23/98154df19a32bc8807d66d22ffe321f02c23fd814b225f0a5bc53c0671ac/hugr-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:c5fe2702d5e80e831c39c701b5eae3f277b089b85a92cdd55f53fba61851ecc1", size = 3399228, upload-time = "2026-04-01T13:04:18.798Z" }, + { url = "https://files.pythonhosted.org/packages/a0/73/7030d9185cf52a216f50fd2df829c0cf0ddc78205e2ca09a192f95c9b952/hugr-0.16.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:007694eb785cf0ce9eaf32dbcc94375ae8ec5c3da1938141bfa97f28e9afe0d4", size = 3042296, upload-time = "2026-04-01T13:04:16.285Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5c/ecc2637f528b14d0cf6e400978526e89c5dd51c1aa3b25da4f12b5380404/hugr-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a97b7cebec63bcc83e10747a94aa4a42a7f1b7127d56a12f8c9151014a8265c", size = 3363178, upload-time = "2026-04-01T13:03:44.559Z" }, + { url = "https://files.pythonhosted.org/packages/54/a8/1a76e227117acc914d6c53050b8c29332d2dd5f464f2bea9606550ee163c/hugr-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebaded1dbc81282fd519f29be7eab2e884f25dc60fb866b88bc4316e0998ea6a", size = 3342289, upload-time = "2026-04-01T13:03:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/de3f56e3edef9f2722071d9d48167be764d467733b4a7639d74171df4e6c/hugr-0.16.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6165273000247817467f0c97582555b96cabf2ddf667c10d9de11272e94531ae", size = 3603558, upload-time = "2026-04-01T13:03:54.839Z" }, + { url = "https://files.pythonhosted.org/packages/b4/16/faa2c8910b12d1fa71697fd5f342e5782a2ce55cbba17a9f62d0d617af17/hugr-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5be77f7840f1dde1e6e6f6d472c40f2c2726f4b20430482e76a943dc4270111e", size = 3773176, upload-time = "2026-04-01T13:03:50.302Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/f62ef65b21ff37bcd7eed15e1cdd777e940a1f2389eab670d1adac0bf062/hugr-0.16.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90c4dd63f0fa07b0fcbd5cf61b78567aa23cb5003092b571a84aae0c27fa5737", size = 3855081, upload-time = "2026-04-01T13:03:52.746Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/db53d482143e8388059eba9c9f14a128b3444b37f6fb1435c6eff0447047/hugr-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdce09aa90f305caccba667251d7fc6fcd2f469a840bd9ea810640f9cc3c3b21", size = 3641209, upload-time = "2026-04-01T13:03:57.805Z" }, + { url = "https://files.pythonhosted.org/packages/d4/72/583c458708bf156c8f69210b4bb04be19a217818e710bfec8c8933d06994/hugr-0.16.0-cp313-cp313t-manylinux_2_28_i686.whl", hash = "sha256:60fe672f0349fbc4be031e36b986e174bc68d9185ad7eeb5c3d67b71f98937be", size = 3606359, upload-time = "2026-04-01T17:50:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e4/72282c75cb607222e6aedc2fbb42cd61a8fa180d5d5a820e67682258241a/hugr-0.16.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a4bdc164c91111db8249d51126867be7ea2e774398cade7fe5432a48b5997279", size = 3650296, upload-time = "2026-04-01T17:50:57.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/12db6e42e24f0af8aad11b6aa87fdd731a0dfa53617fc879cb3bc7604f4d/hugr-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0faea63ade58f6fb051606a778fd06c397ae1cf18a1a7218123853e9ca8a6fe", size = 3569708, upload-time = "2026-04-01T13:04:02.12Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/106a5b8e618c1f4f9f501d55aae6783fb2afb77419f41a8b8b293ecfbe78/hugr-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:dceb496d1e120871c8df7d4e91f30d7ac91d19519690fd45f08c5f57e639e531", size = 3619769, upload-time = "2026-04-01T13:04:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/a608e321135e4ac0b84dd593497ee9fcd1098645884263296b42a80f695d/hugr-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c210a2bc18e65638623bb14b7c308957b7d0b9cdc8b2c8b244bc697a76966729", size = 3702898, upload-time = "2026-04-01T13:04:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/58/15/552629b919fc5d42b23f3c886bd66f33e401657346033860e5f847f5944f/hugr-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7b01e7f99b019cd77013bf4e55e0115862adf42d327c354297667ffd8023d209", size = 3873617, upload-time = "2026-04-01T13:04:10.568Z" }, ] [[package]] @@ -4080,8 +4080,8 @@ requires-dist = [ { name = "cupy-cuda13x", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=13.0.0" }, { name = "cuquantum-python-cu12", marker = "python_full_version >= '3.11' and extra == 'cuda12'", specifier = ">=25.3.0" }, { name = "cuquantum-python-cu13", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=25.9.0" }, - { name = "guppylang", specifier = ">=0.21.6" }, - { name = "hugr", specifier = ">=0.13.0" }, + { name = "guppylang", specifier = ">=0.21.16,<0.22" }, + { name = "hugr", specifier = ">=0.16,<0.17" }, { name = "matplotlib", marker = "extra == 'visualization'", specifier = ">=2.2.0" }, { name = "networkx", specifier = ">=2.1.0" }, { name = "pecos-rslib", editable = "python/pecos-rslib" }, @@ -4094,7 +4094,7 @@ requires-dist = [ { name = "quantum-pecos", extras = ["visualization"], marker = "extra == 'all'", editable = "python/quantum-pecos" }, { name = "selene-sim", specifier = "~=0.2.0" }, { name = "stim", marker = "extra == 'stim'", specifier = ">=1.15.0" }, - { name = "tket", specifier = "<0.12.16" }, + { name = "tket", specifier = ">=0.13,<0.14" }, ] provides-extras = ["stim", "visualization", "all", "cuda13", "cuda12"] @@ -4814,21 +4814,25 @@ wheels = [ [[package]] name = "tket" -version = "0.12.15" +version = "0.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, - { name = "pytket" }, { name = "tket-eccs" }, { name = "tket-exts" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/5d/0d063d5b0f90fe197c68fcda5bee384606a368666abe961cd1227ae1afee/tket-0.12.15.tar.gz", hash = "sha256:1ce7c0877c510810ae91be9c0a7a1b638de8b7f5008f065939aa200ca7cde5a5", size = 464051, upload-time = "2026-01-16T16:40:41.838Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/b0/b6f4659447c521c3b7fe066efa6b3204d2eb1129de8b31fb0ed2218d5596/tket-0.13.1.tar.gz", hash = "sha256:aaf21432939923962362b8783be8190e7f493ae5db3b783fdb93abd352d39ec4", size = 605360, upload-time = "2026-05-19T13:31:30.416Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f9/6f/1ca616db59681f08db1577eb7592ea5b2f3bcdeab423bea61cff30676a3c/tket-0.12.15-cp310-abi3-macosx_13_0_arm64.whl", hash = "sha256:a802b785a09c58a468cb0d7c605837eed328ac441e1ef9fade0913b3082a1a49", size = 10082145, upload-time = "2026-01-16T16:40:29.858Z" }, - { url = "https://files.pythonhosted.org/packages/8c/37/ba7e9d2b15c7d89ce33930640ac9f7fb86f2c1ffd8d63ba62e5819463c22/tket-0.12.15-cp310-abi3-macosx_15_0_x86_64.whl", hash = "sha256:8cd363d59e5d7bad90baf458bfc231db4e0a36329275e4c5cafb8052f3490c23", size = 11022474, upload-time = "2026-01-16T16:40:33.042Z" }, - { url = "https://files.pythonhosted.org/packages/52/1a/ce41490837bb9e4177104ca6e4f7afa837fbb1a32468bc8b57a2b46361eb/tket-0.12.15-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7fda0d91a4281a3a9825676f12c8b8ae466fa6e39fdf9a79b04b3305df34ccad", size = 13088827, upload-time = "2026-01-16T16:21:11.12Z" }, - { url = "https://files.pythonhosted.org/packages/69/39/43dddc3e0143d56af058af612c863b5c3bb09823ed35d915f64a33d4597f/tket-0.12.15-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:730cd5f8103db95ba3387caa2588f1925dfb4473cf3cc383d16b4bea69c6b47f", size = 14678972, upload-time = "2026-01-16T16:40:36.576Z" }, - { url = "https://files.pythonhosted.org/packages/2e/12/7f9fdff4543bc28228b20bb359f2518ec0067b4ab08ddff1fe6b10e5b9eb/tket-0.12.15-cp310-abi3-win_amd64.whl", hash = "sha256:eac19fade07aa07a2967b3775ac65506e197ae1b8723daba8666b1cc27f6abef", size = 9866960, upload-time = "2026-01-16T16:40:39.837Z" }, + { url = "https://files.pythonhosted.org/packages/44/d0/3c9a0d7152d3cf01efd76cf32ae92b79dca7acae14a44b64845ddeb6b6c2/tket-0.13.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2c64dc53ea903350491927260cfdaf8d77dd1400c786be9b83305b4b9dee2e96", size = 10441428, upload-time = "2026-05-19T13:31:18.158Z" }, + { url = "https://files.pythonhosted.org/packages/96/41/c00db203f438f14fb892a98387cc476e7c506ad7bff26a1756a53a07f173/tket-0.13.1-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:bcfcbc0f646f5f8c16e943819a1010aaaf312c79642ea6e737ad7b9559603f8b", size = 11378534, upload-time = "2026-05-19T13:31:20.779Z" }, + { url = "https://files.pythonhosted.org/packages/33/b0/90894ebb09b8eeda60da8205e74d892183007c93433e9ffab052a365abb7/tket-0.13.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d53963635634af92670537fa431450a58e36b79ffcc7d8c7b0731150443ed2ea", size = 12555543, upload-time = "2026-05-19T13:31:23.191Z" }, + { url = "https://files.pythonhosted.org/packages/bd/70/68edcddf60219455933938502377a5e224873ccdfdc9e2ed6381b01fb690/tket-0.13.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:172fd7b159129ff46652474e85bcceae2eabcf854a712ba465c25818964dc0ed", size = 13628232, upload-time = "2026-05-19T13:31:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f0/416bc9d59675a1330b2d08d2f975e40cf4a3d70de39776b4e7a99506e71c/tket-0.13.1-cp310-abi3-win_amd64.whl", hash = "sha256:7b8c16dbe4af5dbc5a4381c9d17cbba4b103fe60254273f1eec2de08b70736be", size = 10218380, upload-time = "2026-05-19T13:31:28.394Z" }, +] + +[package.optional-dependencies] +pytket = [ + { name = "pytket" }, ] [[package]] @@ -4842,14 +4846,14 @@ wheels = [ [[package]] name = "tket-exts" -version = "0.12.2" +version = "0.12.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/72/b4f7b227dda423c91a5e0a0256d518624d51cf685faf92fbc778104348b6/tket_exts-0.12.2.tar.gz", hash = "sha256:b71230cca9b702bb55de6fd4548211a83ba01349a1b6008ae9359b6fdd24f22b", size = 21670, upload-time = "2026-01-28T16:26:26.757Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/68/036b38ded9ad6ef8aedb93817127af0b5535e52eeb68ef8f19b368d0e035/tket_exts-0.12.3.tar.gz", hash = "sha256:de9faa87e35a7cc2250ab1022a026f129d05dc7bf41ad4fd86914d5a9c81541e", size = 21797, upload-time = "2026-04-07T15:26:51.422Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/71/74/c0be1c952d7ae4145dff4598fc554d17f08ef914bafcb25ea5ad84da01eb/tket_exts-0.12.2-py3-none-any.whl", hash = "sha256:297913ca71bda2488741d2cef09eec5fc5f387b6b18958e819dfa28159551146", size = 34322, upload-time = "2026-01-28T16:26:25.615Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/ab123f36707d0fb4deaa96c48a992b90f574348f79aefb94c120dd695f1b/tket_exts-0.12.3-py3-none-any.whl", hash = "sha256:34b03fa69160606bb7ef86735671aa8e7a70149947d630c48a7ebf2f61c49417", size = 34309, upload-time = "2026-04-07T15:26:49.84Z" }, ] [[package]] From 0cb11ea360a1651e7831f0446056f8207546658c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 14:18:42 -0600 Subject: [PATCH 308/388] Add pecos.guppy.variant_scoped to name-scope parameterized Guppy programs, fixing the cross-num_rounds Selene build-cache collision in the selene-parity tests --- .../quantum-pecos/src/pecos/guppy/__init__.py | 3 + .../quantum-pecos/src/pecos/guppy/variant.py | 81 +++++++++++++++++ .../tests/guppy/test_variant_scoped.py | 86 +++++++++++++++++++ .../tests/pecos/test_selene_sim_parity.py | 10 +-- 4 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 python/quantum-pecos/src/pecos/guppy/variant.py create mode 100644 python/quantum-pecos/tests/guppy/test_variant_scoped.py diff --git a/python/quantum-pecos/src/pecos/guppy/__init__.py b/python/quantum-pecos/src/pecos/guppy/__init__.py index 6b0400df9..b701bc214 100644 --- a/python/quantum-pecos/src/pecos/guppy/__init__.py +++ b/python/quantum-pecos/src/pecos/guppy/__init__.py @@ -46,6 +46,7 @@ make_surface_transversal_cnot, make_surface_transversal_cnot_with_x, ) +from pecos.guppy.variant import variant_scoped __all__ = [ # Surface code @@ -72,4 +73,6 @@ "make_color_transversal_cnot_with_x_d3", "make_surface_transversal_cnot", "make_surface_transversal_cnot_with_x", + # Variant-scoped program factories + "variant_scoped", ] diff --git a/python/quantum-pecos/src/pecos/guppy/variant.py b/python/quantum-pecos/src/pecos/guppy/variant.py new file mode 100644 index 000000000..60eeff391 --- /dev/null +++ b/python/quantum-pecos/src/pecos/guppy/variant.py @@ -0,0 +1,81 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Variant-scoped Guppy program factories. + +guppylang registers each ``@guppy`` function by its module-qualified name, and +the downstream Selene build cache keys compiled executables by the HUGR +entry-point name (that same function name). A factory that builds a ``@guppy`` +program closing over compile-time parameters therefore hands every +parameterization the *same* name:: + + def make_prog(num_rounds): + @guppy + def prog() -> None: # __qualname__ == "make_prog..prog" + for _ in range(comptime(num_rounds)): + ... + return prog + +Building two parameterizations in one process then collides: the second reuses +the first's cached compilation / Selene executable and silently runs the wrong +program. :func:`variant_scoped` gives each parameterization a distinct name so its +identity -- and thus its HUGR entry point and Selene build -- is unique. +""" + +from collections.abc import Callable +from typing import TypeVar + +F = TypeVar("F", bound=Callable) + +__all__ = ["variant_scoped"] + + +def _name_fragment(value: object) -> str: + """Render a variant value as an identifier-safe name fragment.""" + return "".join(ch if ch.isalnum() else "_" for ch in str(value)) + + +def variant_scoped(func: F, *variant: object) -> F: + """Rename ``func`` with a ``variant`` suffix for a parameterization-unique ``guppy()`` program. + + Define the program as a plain (undecorated) nested function and wrap it with + ``guppy()`` at the call site, passing the values that distinguish this + parameterization:: + + from guppylang import guppy + from pecos.guppy import variant_scoped + + def make_prog(num_rounds): + def prog() -> None: + for _ in range(comptime(num_rounds)): + ... + return guppy(variant_scoped(prog, num_rounds)) + + ``variant_scoped`` only renames ``func`` (setting ``__name__``/``__qualname__``) + and returns it; **you** apply ``guppy()`` to the result. This is required + because guppylang resolves the program's names (gates, ``result``, ...) against + the *caller's* module, so ``guppy()`` must be invoked where those names are in + scope -- a helper that called ``guppy()`` itself would fail to resolve them. + + The variant suffix gives each parameterization a distinct module-qualified + name (so guppylang does not reuse a stale compiled body) and a distinct HUGR + entry-point name (so the Selene build cache does not reuse a stale + executable). Without it, building two parameterizations in one process makes + the second silently execute the first's program. + + Args: + func: The undecorated program function to rename in place. + variant: One or more values identifying this parameterization (e.g. the + round count, distance). At least one is required; each is stringified + and sanitized into the name suffix. + + Returns: + ``func`` (renamed), ready to pass to ``guppy()`` in the caller's module. + """ + if not variant: + msg = "variant_scoped requires at least one distinguishing value" + raise ValueError(msg) + suffix = "_" + "_".join(_name_fragment(v) for v in variant) + func.__name__ = f"{func.__name__}{suffix}" + func.__qualname__ = f"{func.__qualname__}{suffix}" + return func diff --git a/python/quantum-pecos/tests/guppy/test_variant_scoped.py b/python/quantum-pecos/tests/guppy/test_variant_scoped.py new file mode 100644 index 000000000..27502978e --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_variant_scoped.py @@ -0,0 +1,86 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for :func:`pecos.guppy.variant_scoped`. + +``variant_scoped`` gives a factory-local ``@guppy`` program a variant-unique +name so that building several parameterizations in one process does not collide +in guppylang's compile cache / Selene's build cache (which key on the program's +module-qualified / entry-point name). See ``variant.py`` for the mechanism. +""" + +import pytest +from guppylang import guppy +from guppylang.std.builtins import array, comptime, result +from guppylang.std.quantum import h, measure, qubit +from pecos.guppy import variant_scoped + + +def test_variant_scoped_suffixes_name_and_qualname() -> None: + """The variant values are appended to both ``__name__`` and ``__qualname__``.""" + + def prog() -> None: + pass + + base_qualname = prog.__qualname__ + returned = variant_scoped(prog, 3) + + assert returned is prog + assert prog.__name__ == "prog_3" + assert prog.__qualname__ == f"{base_qualname}_3" + + +def test_variant_scoped_multiple_values() -> None: + """Multiple distinguishing values are joined into the suffix.""" + + def prog() -> None: + pass + + variant_scoped(prog, 5, "Z") + assert prog.__name__ == "prog_5_Z" + + +def test_variant_scoped_sanitizes_unsafe_characters() -> None: + """Non-alphanumeric characters in a variant value are replaced, keeping the name valid.""" + + def prog() -> None: + pass + + variant_scoped(prog, "a-b.c") + assert prog.__name__ == "prog_a_b_c" + assert prog.__name__.replace("_", "").isalnum() + + +def test_variant_scoped_requires_a_value() -> None: + """Calling with no distinguishing value fails loud.""" + + def prog() -> None: + pass + + with pytest.raises(ValueError, match="at least one distinguishing value"): + variant_scoped(prog) + + +def test_variant_scoped_isolates_parameterizations() -> None: + """Two round counts built in one process compile to distinct HUGR. + + Without the variant suffix both parameterizations share the factory-local + ``@guppy`` name, so the second silently reuses the first's build; with it, + each compiles to its own program. + """ + from pecos.compilation_pipeline import compile_guppy_to_hugr + + def make(num_rounds: int) -> object: + def memory() -> None: + for _ in range(comptime(num_rounds)): + q = qubit() + result("synx", array(measure(q))) + anc = qubit() + h(anc) + _ = measure(anc) + + return guppy(variant_scoped(memory, num_rounds)) + + hugr_two = compile_guppy_to_hugr(make(2)) + hugr_five = compile_guppy_to_hugr(make(5)) + assert hugr_two != hugr_five diff --git a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py index 3ec397f6d..cffda1e08 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -25,6 +25,7 @@ from guppylang import guppy from guppylang.std.builtins import array, comptime, result from guppylang.std.quantum import cx, h, measure, measure_array, qubit, x +from pecos.guppy import variant_scoped @guppy @@ -42,20 +43,18 @@ def tagged_bits_named_array() -> None: def make_repeated_single_bit_results(num_rounds: int) -> object: """Create a tiny program that records the same named result repeatedly.""" - @guppy def repeated_single_bit_results() -> None: for _ in range(comptime(num_rounds)): q = qubit() bit = measure(q) result("synx", array(bit)) - return repeated_single_bit_results + return guppy(variant_scoped(repeated_single_bit_results, num_rounds)) def make_tiny_x_syndrome_memory(num_rounds: int) -> object: """Create a tiny memory-style circuit with fresh ancilla allocation each round.""" - @guppy def tiny_x_syndrome_memory() -> None: data = qubit() h(data) @@ -72,7 +71,7 @@ def tiny_x_syndrome_memory() -> None: final = measure_array(array(data)) result("final", final) - return tiny_x_syndrome_memory + return guppy(variant_scoped(tiny_x_syndrome_memory, num_rounds)) def make_tiny_x_syndrome_memory_raw(num_rounds: int) -> object: @@ -82,7 +81,6 @@ def make_tiny_x_syndrome_memory_raw(num_rounds: int) -> object: "named result collection is wrong". """ - @guppy def tiny_x_syndrome_memory_raw() -> None: data = qubit() h(data) @@ -97,7 +95,7 @@ def tiny_x_syndrome_memory_raw() -> None: h(data) _ = measure(data) - return tiny_x_syndrome_memory_raw + return guppy(variant_scoped(tiny_x_syndrome_memory_raw, num_rounds)) @guppy From 5415911b4a9a9e271d7ba3e32b3500dbe2503edc Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 16:32:00 -0600 Subject: [PATCH 309/388] Fix dependency advisories in the uv/cargo locks (restoring dev's jupyter-chain bumps lost in the merge relock, re-locking zlup/zluppy with a corrected zluppy path dep, bumping anyhow) and add per-directory OSV-scanner ignore configs --- Cargo.lock | 4 +- exp/zlup/fuzz/osv-scanner.toml | 11 + exp/zlup/uv.lock | 30 +- exp/zluppy/osv-scanner.toml | 12 + exp/zluppy/pyproject.toml | 2 +- exp/zluppy/uv.lock | 685 ++++++++++++++++++++++++--------- osv-scanner.toml | 42 ++ uv.lock | 67 ++-- 8 files changed, 620 insertions(+), 233 deletions(-) create mode 100644 exp/zlup/fuzz/osv-scanner.toml create mode 100644 exp/zluppy/osv-scanner.toml diff --git a/Cargo.lock b/Cargo.lock index 99d8ffb80..dc41aca3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,9 +155,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "approx" diff --git a/exp/zlup/fuzz/osv-scanner.toml b/exp/zlup/fuzz/osv-scanner.toml new file mode 100644 index 000000000..a220564c9 --- /dev/null +++ b/exp/zlup/fuzz/osv-scanner.toml @@ -0,0 +1,11 @@ +# OSV-Scanner configuration for the zlup fuzz harness (config discovery is +# per-directory: the root osv-scanner.toml does not apply to this Cargo.lock). +# Same ignore-list policy as the root osv-scanner.toml: only entries we cannot +# fix from this repo, each documenting crate@version, chain, and upstream owner. + +[[IgnoredVulns]] +id = "RUSTSEC-2026-0173" +# proc-macro-error2@2.0.1 -- unmaintained proc-macro (fork of proc-macro-error). +# Chain: zlup-fuzz -> env_logger -> jiff -> defmt -> defmt-macros -> proc-macro-error2. +# Upstream owners: https://github.com/BurntSushi/jiff and https://github.com/knurling-rs/defmt. +reason = "Transitive via the fuzz harness's env_logger -> jiff -> defmt chain; compile-time proc-macro only, no runtime impact." diff --git a/exp/zlup/uv.lock b/exp/zlup/uv.lock index ec937db65..733865d70 100644 --- a/exp/zlup/uv.lock +++ b/exp/zlup/uv.lock @@ -172,11 +172,11 @@ wheels = [ [[package]] name = "idna" -version = "3.11" +version = "3.18" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] @@ -422,24 +422,24 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] name = "pymdown-extensions" -version = "10.20.1" +version = "11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1e/6c/9e370934bfa30e889d12e61d0dae009991294f40055c238980066a7fbd83/pymdown_extensions-10.20.1.tar.gz", hash = "sha256:e7e39c865727338d434b55f1dd8da51febcffcaebd6e1a0b9c836243f660740a", size = 852860, upload-time = "2026-01-24T05:56:56.758Z" } +sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/40/6d/b6ee155462a0156b94312bdd82d2b92ea56e909740045a87ccb98bf52405/pymdown_extensions-10.20.1-py3-none-any.whl", hash = "sha256:24af7feacbca56504b313b7b418c4f5e1317bb5fea60f03d57be7fcc40912aa0", size = 268768, upload-time = "2026-01-24T05:56:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" }, ] [[package]] @@ -532,7 +532,7 @@ wheels = [ [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -540,9 +540,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -556,11 +556,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] diff --git a/exp/zluppy/osv-scanner.toml b/exp/zluppy/osv-scanner.toml new file mode 100644 index 000000000..97059f7e2 --- /dev/null +++ b/exp/zluppy/osv-scanner.toml @@ -0,0 +1,12 @@ +# OSV-Scanner configuration for exp/zluppy (config discovery is per-directory: +# the root osv-scanner.toml does not apply to this uv.lock). +# Same ignore-list policy as the root osv-scanner.toml: only entries we cannot +# fix from this repo, each documenting crate@version, chain, and upstream owner. + +[[IgnoredVulns]] +id = "PYSEC-2026-151" +# wasmtime (PyPI) 38.0.0 -- Wasmtime stack-overflow bug class; the advisory +# lists NO fixed release (last_affected 43.0.0, the latest). +# Chain: zluppy dev/test group -> quantum-pecos -> guppylang -> guppylang-internals -> wasmtime. +# Upstream owner: https://github.com/bytecodealliance/wasmtime-py. +reason = "Transitive via guppylang; no fixed wasmtime-py release exists yet. PECOS only runs trusted, locally-generated Wasm through it. Re-check on guppylang bumps." diff --git a/exp/zluppy/pyproject.toml b/exp/zluppy/pyproject.toml index a676a9b91..2b2f3d0af 100644 --- a/exp/zluppy/pyproject.toml +++ b/exp/zluppy/pyproject.toml @@ -50,7 +50,7 @@ test = [ [tool.uv.sources] zluppy = { workspace = true } -quantum-pecos = { path = "../python/quantum-pecos", editable = true } +quantum-pecos = { path = "../../python/quantum-pecos", editable = true } [tool.pytest.ini_options] markers = [ diff --git a/exp/zluppy/uv.lock b/exp/zluppy/uv.lock index 148829767..3cbdc80f3 100644 --- a/exp/zluppy/uv.lock +++ b/exp/zluppy/uv.lock @@ -2,7 +2,8 @@ version = 1 revision = 3 requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", "python_full_version < '3.11'", ] @@ -15,111 +16,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] -[[package]] -name = "backports-zstd" -version = "1.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f4/b1/36a5182ce1d8ef9ef32bff69037bd28b389bbdb66338f8069e61da7028cb/backports_zstd-1.3.0.tar.gz", hash = "sha256:e8b2d68e2812f5c9970cabc5e21da8b409b5ed04e79b4585dbffa33e9b45ebe2", size = 997138, upload-time = "2025-12-29T17:28:06.143Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/76/70/766f6ebbb9db2ed75951f0a671ee15931dc69278c84d9f09b08dd6b67c3e/backports_zstd-1.3.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a2db17a6d9bf6b4dc223b3f6414aa9db6d1afe9de9bff61d582c2934ca456a0", size = 435664, upload-time = "2025-12-29T17:25:29.201Z" }, - { url = "https://files.pythonhosted.org/packages/55/f8/7b3fad9c6ee5ff3bcd7c941586675007330197ff4a388f01c73198ecc8bb/backports_zstd-1.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a7f16b98ba81780a9517ce6c493e1aea9b7d72de2b1efa08375136c270e1ecba", size = 362060, upload-time = "2025-12-29T17:25:30.94Z" }, - { url = "https://files.pythonhosted.org/packages/68/9e/cad0f508ed7c3fbd07398f22b5bf25aa0523fcf56c84c3def642909e80ae/backports_zstd-1.3.0-cp310-cp310-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:1124a169a647671ccb4654a0ef1d0b42d6735c45ce3d0adf609df22fb1f099db", size = 505958, upload-time = "2025-12-29T17:25:32.694Z" }, - { url = "https://files.pythonhosted.org/packages/b7/dc/96dc55c043b0d86e53ae9608b496196936244c1ecf7e95cdf66d0dbc0f23/backports_zstd-1.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8410fda08b36202d01ab4503f6787c763898888cb1a48c19fce94711563d3ee3", size = 475571, upload-time = "2025-12-29T17:25:33.9Z" }, - { url = "https://files.pythonhosted.org/packages/20/48/d9c8c8c2a5ac57fc5697f1945254af31407b0c5f80335a175a7c215b4118/backports_zstd-1.3.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab139d1fc0e91a697e82fa834e6404098802f11b6035607174776173ded9a2cc", size = 581199, upload-time = "2025-12-29T17:25:35.566Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/7fe70d2d39ed39e26a6c6f6c1dd229f1ab889500d5c90b17527702b1a21e/backports_zstd-1.3.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f3115d203f387f77c23b5461fb6678d282d4f276f9f39298ad242b00120afc7", size = 640846, upload-time = "2025-12-29T17:25:36.86Z" }, - { url = "https://files.pythonhosted.org/packages/0e/d8/5b8580469e70b72402212885bf19b9d31eaf23549b602e0c294edf380e25/backports_zstd-1.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:116f65cce84e215dfac0414924b051faf8d29dc7188cf3944dd1e5be8dd15a32", size = 491061, upload-time = "2025-12-29T17:25:38.721Z" }, - { url = "https://files.pythonhosted.org/packages/cc/dd/17a752263fccd1ba24184b7e89c14cd31553d512e2e5b065f38e63a0ba86/backports_zstd-1.3.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04def169e4a9ae291298124da4e097c6d6545d0e93164f934b716da04d24630a", size = 565071, upload-time = "2025-12-29T17:25:40.372Z" }, - { url = "https://files.pythonhosted.org/packages/1a/81/df23d3fe664b2497ab2ec01dc012cb9304e7d568c67f50b1b324fb2d8cbb/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:481b586291ef02a250f03d4c31a37c9881e5e93556568abbd20ca1ad720d443f", size = 481518, upload-time = "2025-12-29T17:25:41.925Z" }, - { url = "https://files.pythonhosted.org/packages/ba/cd/e50dd85fde890c5d79e1ed5dc241f1c45f87b6c12571fdb60add57f2ee66/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0290979eea67f7275fa42d5859cc5bea94f2c08cca6bc36396673476773d2bad", size = 509464, upload-time = "2025-12-29T17:25:43.844Z" }, - { url = "https://files.pythonhosted.org/packages/d3/bb/e429156e4b834837fe78b4f32ed512491aea39415444420c79ccd3aa0526/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:01c699d8c803dc9f9c9d6ede21b75ec99f45c3b411821011692befca538928cb", size = 585563, upload-time = "2025-12-29T17:25:45.038Z" }, - { url = "https://files.pythonhosted.org/packages/95/c0/1a0d245325827242aefe76f4f3477ec183b996b8db5105698564f8303481/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:2c662912cfc1a5ebd1d2162ac651549d58bd3c97a8096130ec13c703fca355f2", size = 562889, upload-time = "2025-12-29T17:25:46.576Z" }, - { url = "https://files.pythonhosted.org/packages/93/42/126b2bc7540a15452c3ebdf190ebfea8a8644e29b22f4e10e2a6aa2389e4/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:3180c8eb085396928e9946167e610aa625922b82c3e2263c5f17000556370168", size = 631423, upload-time = "2025-12-29T17:25:47.81Z" }, - { url = "https://files.pythonhosted.org/packages/dc/32/018e49657411582569032b7d1bb5d62e514aad8b44952de740ec6250588d/backports_zstd-1.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5b9a8c75a294e7ffa18fc8425a763facc366435a8b442e4dffdc19fa9499a22c", size = 495122, upload-time = "2025-12-29T17:25:49.377Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9e/cdd1d2e1d3612bb90d9cf9b23bea06f2155cdafccd8b6f28a1c4d7750004/backports_zstd-1.3.0-cp310-cp310-win32.whl", hash = "sha256:845defdb172385f17123d92a00d2e952d341e9ae310bfa2410c292bf03846034", size = 288573, upload-time = "2025-12-29T17:25:51.167Z" }, - { url = "https://files.pythonhosted.org/packages/55/7c/2e9c80f08375bd14262cefa69297a926134f517c9955c0795eec5e1d470e/backports_zstd-1.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:43a9fea6299c801da85221e387b32d90a9ad7c62aa2a34edf525359ce5ad8f3a", size = 313506, upload-time = "2025-12-29T17:25:52.778Z" }, - { url = "https://files.pythonhosted.org/packages/c5/5d/fa67e8174f54db44eb33498abb7f98bea4f2329e873b225391bda0113a5e/backports_zstd-1.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:df8473cb117e1316e6c6101f2724e025bd8f50af2dc009d0001c0aabfb5eb57c", size = 288688, upload-time = "2025-12-29T17:25:54.012Z" }, - { url = "https://files.pythonhosted.org/packages/ac/28/ed31a0e35feb4538a996348362051b52912d50f00d25c2d388eccef9242c/backports_zstd-1.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:249f90b39d3741c48620021a968b35f268ca70e35f555abeea9ff95a451f35f9", size = 435660, upload-time = "2025-12-29T17:25:55.207Z" }, - { url = "https://files.pythonhosted.org/packages/00/0d/3db362169d80442adda9dd563c4f0bb10091c8c1c9a158037f4ecd53988e/backports_zstd-1.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b0e71e83e46154a9d3ced6d4de9a2fea8207ee1e4832aeecf364dc125eda305c", size = 362056, upload-time = "2025-12-29T17:25:56.729Z" }, - { url = "https://files.pythonhosted.org/packages/bd/00/b67ba053a7d6f6dbe2f8a704b7d3a5e01b1d2e2e8edbc9b634f2702ef73c/backports_zstd-1.3.0-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:cbc6193acd21f96760c94dd71bf32b161223e8503f5277acb0a5ab54e5598957", size = 505957, upload-time = "2025-12-29T17:25:57.941Z" }, - { url = "https://files.pythonhosted.org/packages/6f/3e/2667c0ddb53ddf28667e330bf9fe92e8e17705a481c9b698e283120565f7/backports_zstd-1.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1df583adc0ae84a8d13d7139f42eade6d90182b1dd3e0d28f7df3c564b9fd55d", size = 475569, upload-time = "2025-12-29T17:25:59.075Z" }, - { url = "https://files.pythonhosted.org/packages/eb/86/4052473217bd954ccdffda5f7264a0e99e7c4ecf70c0f729845c6a45fc5a/backports_zstd-1.3.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d833fc23aa3cc2e05aeffc7cfadd87b796654ad3a7fb214555cda3f1db2d4dc2", size = 581196, upload-time = "2025-12-29T17:26:00.508Z" }, - { url = "https://files.pythonhosted.org/packages/e5/bd/064f6fdb61db3d2c473159ebc844243e650dc032de0f8208443a00127925/backports_zstd-1.3.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:142178fe981061f1d2a57c5348f2cd31a3b6397a35593e7a17dbda817b793a7f", size = 640888, upload-time = "2025-12-29T17:26:02.134Z" }, - { url = "https://files.pythonhosted.org/packages/d8/09/0822403f40932a165a4f1df289d41653683019e4fd7a86b63ed20e9b6177/backports_zstd-1.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5eed0a09a163f3a8125a857cb031be87ed052e4a47bc75085ed7fca786e9bb5b", size = 491100, upload-time = "2025-12-29T17:26:03.418Z" }, - { url = "https://files.pythonhosted.org/packages/a6/a3/f5ac28d74039b7e182a780809dc66b9dbfc893186f5d5444340bba135389/backports_zstd-1.3.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60aa483fef5843749e993dde01229e5eedebca8c283023d27d6bf6800d1d4ce3", size = 565071, upload-time = "2025-12-29T17:26:05.022Z" }, - { url = "https://files.pythonhosted.org/packages/e1/ac/50209aeb92257a642ee987afa1e61d5b6731ab6bf0bff70905856e5aede6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ea0886c1b619773544546e243ed73f6d6c2b1ae3c00c904ccc9903a352d731e1", size = 481519, upload-time = "2025-12-29T17:26:06.255Z" }, - { url = "https://files.pythonhosted.org/packages/08/1f/b06f64199fb4b2e9437cedbf96d0155ca08aeec35fe81d41065acd44762e/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5e137657c830a5ce99be40a1d713eb1d246bae488ada28ff0666ac4387aebdd5", size = 509465, upload-time = "2025-12-29T17:26:07.602Z" }, - { url = "https://files.pythonhosted.org/packages/f4/37/2c365196e61c8fffbbc930ffd69f1ada7aa1c7210857b3e565031c787ac6/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:94048c8089755e482e4b34608029cf1142523a625873c272be2b1c9253871a72", size = 585552, upload-time = "2025-12-29T17:26:08.911Z" }, - { url = "https://files.pythonhosted.org/packages/93/8d/c2c4f448bb6b6c9df17410eaedce415e8db0eb25b60d09a3d22a98294d09/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:d339c1ec40485e97e600eb9a285fb13169dbf44c5094b945788a62f38b96e533", size = 562893, upload-time = "2025-12-29T17:26:10.566Z" }, - { url = "https://files.pythonhosted.org/packages/74/e8/2110d4d39115130f7514cbbcec673a885f4052bb68d15e41bc96a7558856/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:8aeee9210c54cf8bf83f4d263a6d0d6e7a0298aeb5a14a0a95e90487c5c3157c", size = 631462, upload-time = "2025-12-29T17:26:11.99Z" }, - { url = "https://files.pythonhosted.org/packages/b9/a8/d64b59ae0714fdace14e43873f794eff93613e35e3e85eead33a4f44cd80/backports_zstd-1.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba7114a3099e5ea05cbb46568bd0e08bca2ca11e12c6a7b563a24b86b2b4a67f", size = 495125, upload-time = "2025-12-29T17:26:13.218Z" }, - { url = "https://files.pythonhosted.org/packages/ef/d8/bcff0a091fcf27172c57ae463e49d8dec6dc31e01d7e7bf1ae3aad9c3566/backports_zstd-1.3.0-cp311-cp311-win32.whl", hash = "sha256:08dfdfb85da5915383bfae680b6ac10ab5769ab22e690f9a854320720011ae8e", size = 288664, upload-time = "2025-12-29T17:26:14.791Z" }, - { url = "https://files.pythonhosted.org/packages/28/1a/379061e2abf8c3150ad51c1baab9ac723e01cf7538860a6a74c48f8b73ee/backports_zstd-1.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:d8aac2e7cdcc8f310c16f98a0062b48d0a081dbb82862794f4f4f5bdafde30a4", size = 313633, upload-time = "2025-12-29T17:26:16.31Z" }, - { url = "https://files.pythonhosted.org/packages/35/e7/eca40858883029fc716660106069b23253e2ec5fd34e86b4101c8cfe864b/backports_zstd-1.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:440ef1be06e82dc0d69dbb57177f2ce98bbd2151013ee7e551e2f2b54caa6120", size = 288814, upload-time = "2025-12-29T17:26:17.571Z" }, - { url = "https://files.pythonhosted.org/packages/72/d4/356da49d3053f4bc50e71a8535631b57bc9ca4e8c6d2442e073e0ab41c44/backports_zstd-1.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f4a292e357f3046d18766ce06d990ccbab97411708d3acb934e63529c2ea7786", size = 435972, upload-time = "2025-12-29T17:26:18.752Z" }, - { url = "https://files.pythonhosted.org/packages/30/8f/dbe389e60c7e47af488520f31a4aa14028d66da5bf3c60d3044b571eb906/backports_zstd-1.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fb4c386f38323698991b38edcc9c091d46d4713f5df02a3b5c80a28b40e289ea", size = 362124, upload-time = "2025-12-29T17:26:19.995Z" }, - { url = "https://files.pythonhosted.org/packages/55/4b/173beafc99e99e7276ce008ef060b704471e75124c826bc5e2092815da37/backports_zstd-1.3.0-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f52523d2bdada29e653261abdc9cfcecd9e5500d305708b7e37caddb24909d4e", size = 506378, upload-time = "2025-12-29T17:26:21.855Z" }, - { url = "https://files.pythonhosted.org/packages/df/c8/3f12a411d9a99d262cdb37b521025eecc2aa7e4a93277be3f4f4889adb74/backports_zstd-1.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3321d00beaacbd647252a7f581c1e1cdbdbda2407f2addce4bfb10e8e404b7c7", size = 476201, upload-time = "2025-12-29T17:26:23.047Z" }, - { url = "https://files.pythonhosted.org/packages/43/dc/73c090e4a2d5671422512e1b6d276ca6ea0cc0c45ec4634789106adc0d66/backports_zstd-1.3.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:88f94d238ef36c639c0ae17cf41054ce103da9c4d399c6a778ce82690d9f4919", size = 581659, upload-time = "2025-12-29T17:26:24.189Z" }, - { url = "https://files.pythonhosted.org/packages/08/4f/11bfcef534aa2bf3f476f52130217b45337f334d8a287edb2e06744a6515/backports_zstd-1.3.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97d8c78fe20c7442c810adccfd5e3ea6a4e6f4f1fa4c73da2bc083260ebead17", size = 640388, upload-time = "2025-12-29T17:26:25.47Z" }, - { url = "https://files.pythonhosted.org/packages/71/17/8faea426d4f49b63238bdfd9f211a9f01c862efe0d756d3abeb84265a4e2/backports_zstd-1.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eefda80c3dbfbd924f1c317e7b0543d39304ee645583cb58bae29e19f42948ed", size = 494173, upload-time = "2025-12-29T17:26:26.736Z" }, - { url = "https://files.pythonhosted.org/packages/ba/9d/901f19ac90f3cd999bdcfb6edb4d7b4dc383dfba537f06f533fc9ac4777b/backports_zstd-1.3.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2ab5d3b5a54a674f4f6367bb9e0914063f22cd102323876135e9cc7a8f14f17e", size = 568628, upload-time = "2025-12-29T17:26:28.12Z" }, - { url = "https://files.pythonhosted.org/packages/60/39/4d29788590c2465a570c2fae49dbff05741d1f0c8e4a0fb2c1c310f31804/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7558fb0e8c8197c59a5f80c56bf8f56c3690c45fd62f14e9e2081661556e3e64", size = 482233, upload-time = "2025-12-29T17:26:29.399Z" }, - { url = "https://files.pythonhosted.org/packages/d9/4b/24c7c9e8ef384b19d515a7b1644a500ceb3da3baeff6d579687da1a0f62b/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:27744870e38f017159b9c0241ea51562f94c7fefcfa4c5190fb3ec4a65a7fc63", size = 509806, upload-time = "2025-12-29T17:26:30.605Z" }, - { url = "https://files.pythonhosted.org/packages/3f/7e/7ba1aeecf0b5859f1855c0e661b4559566b64000f0627698ebd9e83f2138/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b099750755bb74c280827c7d68de621da0f245189082ab48ff91bda0ec2db9df", size = 586037, upload-time = "2025-12-29T17:26:32.201Z" }, - { url = "https://files.pythonhosted.org/packages/4a/1a/18f0402b36b9cfb0aea010b5df900cfd42c214f37493561dba3abac90c4e/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:5434e86f2836d453ae3e19a2711449683b7e21e107686838d12a255ad256ca99", size = 566220, upload-time = "2025-12-29T17:26:33.5Z" }, - { url = "https://files.pythonhosted.org/packages/dc/d9/44c098ab31b948bbfd909ec4ae08e1e44c5025a2d846f62991a62ab3ebea/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:407e451f64e2f357c9218f5be4e372bb6102d7ae88582d415262a9d0a4f9b625", size = 630847, upload-time = "2025-12-29T17:26:35.273Z" }, - { url = "https://files.pythonhosted.org/packages/30/33/e74cb2cfb162d2e9e00dad8bcdf53118ca7786cfd467925d6864732f79cc/backports_zstd-1.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:58a071f3c198c781b2df801070290b7174e3ff61875454e9df93ab7ea9ea832b", size = 498665, upload-time = "2025-12-29T17:26:37.123Z" }, - { url = "https://files.pythonhosted.org/packages/a2/a9/67a24007c333ed22736d5cd79f1aa1d7209f09be772ff82a8fd724c1978e/backports_zstd-1.3.0-cp312-cp312-win32.whl", hash = "sha256:21a9a542ccc7958ddb51ae6e46d8ed25d585b54d0d52aaa1c8da431ea158046a", size = 288809, upload-time = "2025-12-29T17:26:38.373Z" }, - { url = "https://files.pythonhosted.org/packages/42/24/34b816118ea913debb2ea23e71ffd0fb2e2ac738064c4ac32e3fb62c18bb/backports_zstd-1.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:89ea8281821123b071a06b30b80da8e4d8a2b40a4f57315a19850337a21297ac", size = 313815, upload-time = "2025-12-29T17:26:39.665Z" }, - { url = "https://files.pythonhosted.org/packages/4e/2f/babd02c9fc4ca35376ada7c291193a208165c7be2455f0f98bc1e1243f31/backports_zstd-1.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:f6843ecb181480e423b02f60fe29e393cbc31a95fb532acdf0d3a2c87bd50ce3", size = 288927, upload-time = "2025-12-29T17:26:40.923Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7d/53e8da5950cdfc5e8fe23efd5165ce2f4fed5222f9a3292e0cdb03dd8c0d/backports_zstd-1.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e86e03e3661900955f01afed6c59cae9baa63574e3b66896d99b7de97eaffce9", size = 435463, upload-time = "2025-12-29T17:26:42.152Z" }, - { url = "https://files.pythonhosted.org/packages/da/78/f98e53870f7404071a41e3d04f2ff514302eeeb3279d931d02b220f437aa/backports_zstd-1.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:41974dcacc9824c1effe1c8d2f9d762bcf47d265ca4581a3c63321c7b06c61f0", size = 361740, upload-time = "2025-12-29T17:26:43.377Z" }, - { url = "https://files.pythonhosted.org/packages/6d/ed/2c64706205a944c9c346d95c17f632d4e3468db3ce60efb6f5caa7c0dcae/backports_zstd-1.3.0-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:3090a97738d6ce9545d3ca5446df43370928092a962cbc0153e5445a947e98ed", size = 505651, upload-time = "2025-12-29T17:26:44.495Z" }, - { url = "https://files.pythonhosted.org/packages/7b/7b/22998f691dc6e0c7e6fa81d611eb4b1f6a72fb27327f322366d4a7ca8fb3/backports_zstd-1.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddc874638abf03ea1ff3b0525b4a26a8d0adf7cb46a448c3449f08e4abc276b3", size = 475859, upload-time = "2025-12-29T17:26:45.722Z" }, - { url = "https://files.pythonhosted.org/packages/0b/78/0cde898339a339530e5f932634872d2d64549969535447a48d3b98959e11/backports_zstd-1.3.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:db609e57b8ed88b3472930c87e93c08a4bbd5ffeb94608cd9c7c6f0ac0e166c6", size = 581339, upload-time = "2025-12-29T17:26:46.93Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1d/e0973e0eebe678c12c146473af2c54cda8a3e63b179785ca1a20727ad69c/backports_zstd-1.3.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f13033a3dd95f323c067199f2e61b4589a7880188ef4ef356c7ffbdb78a9f11", size = 642182, upload-time = "2025-12-29T17:26:48.545Z" }, - { url = "https://files.pythonhosted.org/packages/82/a2/ac67e79e137eb98aead66c7162bafe3cffcb82ef9cdeb6367ec18d88fbce/backports_zstd-1.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c4c7bcda5619a754726e7f5b391827f5efbe4bed8e62e9ec7490d42bff18aa6", size = 490807, upload-time = "2025-12-29T17:26:49.789Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/3514b1d065801ae7dce05246e9389003ed8fb1d7c3d71f85aa07a80f41e6/backports_zstd-1.3.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884a94c40f27affe986f394f219a4fd3cbbd08e1cff2e028d29d467574cd266e", size = 566103, upload-time = "2025-12-29T17:26:51.062Z" }, - { url = "https://files.pythonhosted.org/packages/1b/03/10ddb54cbf032e5fe390c0776d3392611b1fc772d6c3cb5a9bcdff4f915f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497f5765126f11a5b3fd8fedfdae0166d1dd867e7179b8148370a3313d047197", size = 481614, upload-time = "2025-12-29T17:26:52.255Z" }, - { url = "https://files.pythonhosted.org/packages/5c/13/21efa7f94c41447f43aee1563b05fc540a235e61bce4597754f6c11c2e97/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a6ff6769948bb29bba07e1c2e8582d5a9765192a366108e42d6581a458475881", size = 509207, upload-time = "2025-12-29T17:26:53.496Z" }, - { url = "https://files.pythonhosted.org/packages/de/e7/12da9256d9e49e71030f0ff75e9f7c258e76091a4eaf5b5f414409be6a57/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1623e5bff1acd9c8ef90d24fc548110f20df2d14432bfe5de59e76fc036824ef", size = 585765, upload-time = "2025-12-29T17:26:54.99Z" }, - { url = "https://files.pythonhosted.org/packages/24/bf/59ca9cb4e7be1e59331bb792e8ef1331828efe596b1a2f8cbbc4e3f70d75/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:622c28306dcc429c8f2057fc4421d5722b1f22968d299025b35d71b50cfd4e03", size = 563852, upload-time = "2025-12-29T17:26:56.371Z" }, - { url = "https://files.pythonhosted.org/packages/7c/ee/5a3eaed9a73bdf2c35dc0c7adc0616a99588e0de28f5ab52f3e0caaaa96f/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09a2785e410ed2e812cb39b684ef5eb55083a5897bfd0e6f5de3bbd2c6345f70", size = 632549, upload-time = "2025-12-29T17:26:57.598Z" }, - { url = "https://files.pythonhosted.org/packages/75/b9/c823633afc48a1ac56d6ad34289c8f51b0234685142531bfa8197ca91777/backports_zstd-1.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ade1f4127fdbe36a02f8067d75aa79c1ea1c8a306bf63c7b818bb7b530e1beaa", size = 495104, upload-time = "2025-12-29T17:26:58.826Z" }, - { url = "https://files.pythonhosted.org/packages/a3/8f/6f7030f18fa7307f87b0f57108a50a3a540b6350e2486d1739c0567629a3/backports_zstd-1.3.0-cp313-cp313-win32.whl", hash = "sha256:668e6fb1805b825cb7504c71436f7b28d4d792bb2663ee901ec9a2bb15804437", size = 288447, upload-time = "2025-12-29T17:27:00.036Z" }, - { url = "https://files.pythonhosted.org/packages/a2/82/b1df1bbbe4e6d3ffd364d0bcffdeb6c4361115c1eccd91238dbdd0c07fec/backports_zstd-1.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:385bdadf0ea8fe6ba780a95e4c7d7f018db7bafdd630932f0f9f0fad05d608ff", size = 313664, upload-time = "2025-12-29T17:27:01.267Z" }, - { url = "https://files.pythonhosted.org/packages/45/0f/60918fe4d3f2881de8f4088d73be4837df9e4c6567594109d355a2d548b6/backports_zstd-1.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:4321a8a367537224b3559fe7aeb8012b98aea2a60a737e59e51d86e2e856fe0a", size = 288678, upload-time = "2025-12-29T17:27:02.506Z" }, - { url = "https://files.pythonhosted.org/packages/a7/b9/35f423c0bcd85020d5e7be6ab8d7517843e3e4441071beb5c3bd8c5216cb/backports_zstd-1.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:10057d66fa4f0a7d3f6419ffb84b4fe61088da572e3ac4446134a1c8089e4166", size = 436155, upload-time = "2025-12-29T17:27:03.859Z" }, - { url = "https://files.pythonhosted.org/packages/f6/14/e504daea24e8916f14ecbc223c354b558d8410cfc846606668ab91d96b38/backports_zstd-1.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4abf29d706ba05f658ca0247eb55675bcc00e10f12bca15736e45b05f1f2d2dc", size = 362436, upload-time = "2025-12-29T17:27:05.076Z" }, - { url = "https://files.pythonhosted.org/packages/c4/f7/06e178dbab7edb88c2872aebd68b54137e07a169eba1aeedf614014f7036/backports_zstd-1.3.0-cp313-cp313t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:127b0d73c745b0684da3d95c31c0939570810dad8967dfe8231eea8f0e047b2f", size = 507600, upload-time = "2025-12-29T17:27:06.254Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f1/2ce499b81c4389d6fa1eeea7e76f6e0bad48effdbb239da7cbcdaaf24b76/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0205ef809fb38bb5ca7f59fa03993596f918768b9378fb7fbd8a68889a6ce028", size = 475496, upload-time = "2025-12-29T17:27:07.939Z" }, - { url = "https://files.pythonhosted.org/packages/18/1e/c82a586f2866aabf3a601a521af3c58756d83d98b724fda200016ac5e7e2/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1c389b667b0b07915781aa28beabf2481f11a6062a1a081873c4c443b98601a7", size = 580919, upload-time = "2025-12-29T17:27:09.1Z" }, - { url = "https://files.pythonhosted.org/packages/1b/a3/eb5d9b7c4cb69d1b8ccd011abe244ba6815693b70bed07ed4b77ddda4535/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8e7ac5ef693d49d6fb35cd7bbb98c4762cfea94a8bd2bf2ab112027004f70b11", size = 639913, upload-time = "2025-12-29T17:27:10.433Z" }, - { url = "https://files.pythonhosted.org/packages/11/2c/7296b99df79d9f31174a99c81c1964a32de8996ce2b3068f5bc66b413615/backports_zstd-1.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d5543945aae2a76a850b23f283249424f535de6a622d6002957b7d971e6a36d", size = 494800, upload-time = "2025-12-29T17:27:11.59Z" }, - { url = "https://files.pythonhosted.org/packages/f9/fc/b8ae6e104ba72d20cd5f9dfd9baee36675e89c81d432434927967114f30f/backports_zstd-1.3.0-cp313-cp313t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38be15ebce82737deda2c9410c1f942f1df9da74121049243a009810432db75", size = 570396, upload-time = "2025-12-29T17:27:13.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/56/60a7a9de7a5bc951ea1106358b413c95183c93480394f3abc541313c8679/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3e3f58c76f4730607a4e0130d629173aa114ae72a5c8d3d5ad94e1bf51f18d8", size = 481980, upload-time = "2025-12-29T17:27:14.317Z" }, - { url = "https://files.pythonhosted.org/packages/4b/bb/93fc1e8e81b8ecba58b0e53a14f7b44375cf837db6354410998f0c4cb6ff/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:b808bf889722d889b792f7894e19c1f904bb0e9092d8c0eb0787b939b08bad9a", size = 511358, upload-time = "2025-12-29T17:27:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/ae/0f/b165c2a6080d22306975cd86ce97270208493f31a298867e343110570370/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:f7be27d56f2f715bcd252d0c65c232146d8e1e039c7e2835b8a3ad3dc88bc508", size = 585492, upload-time = "2025-12-29T17:27:16.986Z" }, - { url = "https://files.pythonhosted.org/packages/26/76/85b4bde76e982b24a7eb57a2fb9868807887bef4d2114a3654a6530a67ef/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:cbe341c7fcc723893663a37175ba859328b907a4e6d2d40a4c26629cc55efb67", size = 568309, upload-time = "2025-12-29T17:27:18.28Z" }, - { url = "https://files.pythonhosted.org/packages/83/64/9490667827a320766fb883f358a7c19171fdc04f19ade156a8c341c36967/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:b4116a9e12dfcd834dd9132cf6a94657bf0d328cba5b295f26de26ea0ae1adc8", size = 630518, upload-time = "2025-12-29T17:27:19.525Z" }, - { url = "https://files.pythonhosted.org/packages/ea/43/258587233b728bbff457bdb0c52b3e08504c485a8642b3daeb0bdd5a76bc/backports_zstd-1.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1049e804cc8754290b24dab383d4d6ed0b7f794ad8338813ddcb3907d15a89d0", size = 499429, upload-time = "2025-12-29T17:27:21.063Z" }, - { url = "https://files.pythonhosted.org/packages/32/04/cfab76878f360f124dbb533779e1e4603c801a0f5ada72ae5c742b7c4d7d/backports_zstd-1.3.0-cp313-cp313t-win32.whl", hash = "sha256:7d3f0f2499d2049ec53d2674c605a4b3052c217cc7ee49c05258046411685adc", size = 289389, upload-time = "2025-12-29T17:27:22.287Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ff/dbcfb6c9c922ab6d98f3d321e7d0c7b34ecfa26f3ca71d930fe1ef639737/backports_zstd-1.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:eb2f8fab0b1ea05148394cb34a9e543a43477178765f2d6e7c84ed332e34935e", size = 314776, upload-time = "2025-12-29T17:27:23.458Z" }, - { url = "https://files.pythonhosted.org/packages/01/4b/82e4baae3117806639fe1c693b1f2f7e6133a7cefd1fa2e38018c8edcd68/backports_zstd-1.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:c66ad9eb5bfbe28c2387b7fc58ddcdecfb336d6e4e60bcba1694a906c1f21a6c", size = 289315, upload-time = "2025-12-29T17:27:24.601Z" }, - { url = "https://files.pythonhosted.org/packages/95/b7/e843d32122f25d9568e75d1e7a29c00eae5e5728015604f3f6d02259b3a5/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3ab0d5632b84eff4355c42a04668cfe6466f7d390890f718978582bd1ff36949", size = 409771, upload-time = "2025-12-29T17:27:48.869Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a5/d6a897d4b91732f54b4506858f1da65d7a5b2dc0dbe36a23992a64f09f5a/backports_zstd-1.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:6b97cea95dbb1a97c02afd718155fad93f747815069722107a429804c355e206", size = 339289, upload-time = "2025-12-29T17:27:50.055Z" }, - { url = "https://files.pythonhosted.org/packages/3f/b0/f0ce566ec221b284508eebbf574a779ba4a8932830db6ea03b6176f336a2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:477895f2642f9397aeba69618df2c91d7f336e02df83d1e623ac37c5d3a5115e", size = 420335, upload-time = "2025-12-29T17:27:51.455Z" }, - { url = "https://files.pythonhosted.org/packages/62/6d/bf55652c84c79b2565d3087265bcb097719540a313dee16359a54d83ab4e/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:330172aaf5fd3bfa53f49318abc6d1d4238cb043c384cf71f7b8f0fe2fb7ce31", size = 393880, upload-time = "2025-12-29T17:27:52.869Z" }, - { url = "https://files.pythonhosted.org/packages/be/e0/d1feebb70ffeb150e2891c6f09700079f4a60085ebc67529eb1ca72fb5c2/backports_zstd-1.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32974e71eff15897ed3f8b7766a753d9f3197ea4f1c9025d80f8de099a691b99", size = 413840, upload-time = "2025-12-29T17:27:54.527Z" }, - { url = "https://files.pythonhosted.org/packages/36/28/3b7be27ae51e418d3a724bbc4cb7fea77b6bd38b5007e333a56b0cb165c8/backports_zstd-1.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:993e3a34eaba5928a2065545e34bf75c65b9c34ecb67e43d5ef49b16cc182077", size = 299685, upload-time = "2025-12-29T17:27:56.149Z" }, - { url = "https://files.pythonhosted.org/packages/9a/d9/8c9c246e5ea79a4f45d551088b11b61f2dc7efcdc5dbe6df3be84a506e0c/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:968167d29f012cee7b112ad031a8925e484e97e99288e55e4d62962c3a1013e3", size = 409666, upload-time = "2025-12-29T17:27:57.37Z" }, - { url = "https://files.pythonhosted.org/packages/a4/4f/a55b33c314ca8c9074e99daab54d04c5d212070ae7dbc435329baf1b139e/backports_zstd-1.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d8f6fc7d62b71083b574193dd8fb3a60e6bb34880cc0132aad242943af301f7a", size = 339199, upload-time = "2025-12-29T17:27:58.542Z" }, - { url = "https://files.pythonhosted.org/packages/9d/13/ce31bd048b1c88d0f65d7af60b6cf89cfbed826c7c978f0ebca9a8a71cfc/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:e0f2eca6aac280fdb77991ad3362487ee91a7fb064ad40043fb5a0bf5a376943", size = 420332, upload-time = "2025-12-29T17:28:00.332Z" }, - { url = "https://files.pythonhosted.org/packages/cf/80/c0cdbc533d0037b57248588403a3afb050b2a83b8c38aa608e31b3a4d600/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:676eb5e177d4ef528cf3baaeea4fffe05f664e4dd985d3ac06960ef4619c81a9", size = 393879, upload-time = "2025-12-29T17:28:01.57Z" }, - { url = "https://files.pythonhosted.org/packages/0f/38/c97428867cac058ed196ccaeddfdf82ecd43b8a65965f2950a6e7547e77a/backports_zstd-1.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:199eb9bd8aca6a9d489c41a682fad22c587dffe57b613d0fe6d492d0d38ce7c5", size = 413842, upload-time = "2025-12-29T17:28:03.113Z" }, - { url = "https://files.pythonhosted.org/packages/8d/ec/6247be6536668fe1c7dfae3eaa9c94b00b956b716957c0fc986ba78c3cc4/backports_zstd-1.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2524bd6777a828d5e7ccd7bd1a57f9e7007ae654fc2bd1bc1a207f6428674e4a", size = 299684, upload-time = "2025-12-29T17:28:04.856Z" }, -] - [[package]] name = "colorama" version = "0.4.6" @@ -134,7 +30,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -152,77 +48,81 @@ wheels = [ [[package]] name = "guppylang" -version = "0.21.8" +version = "0.21.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "guppylang-internals" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "pytket" }, { name = "selene-hugr-qis-compiler" }, { name = "selene-sim" }, { name = "tqdm" }, { name = "types-tqdm" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f2/c3/750540bc30941ff7f9e0a3257acc340a4b10be31905b851c99b2fe4c7268/guppylang-0.21.8.tar.gz", hash = "sha256:4fef55ab273c47ada799896e49fadcc9390a02145471d6dd395e68411f126420", size = 63900, upload-time = "2026-01-09T12:02:30.612Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/79/e148d43eebdbd0e670cfe983d2806c1d8199d4b69e45680a352d2a6c6265/guppylang-0.21.16.tar.gz", hash = "sha256:37ecb3267734540104ada3be1e12726f631edfba2b013efa8f8064cc324d7fda", size = 70378, upload-time = "2026-06-04T14:57:10.594Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/36/0a/611cddcc5402652c6acab63c80550f1cd4f0cab23fd3590de43d6d84bddc/guppylang-0.21.8-py3-none-any.whl", hash = "sha256:d86365524f79d31dc63823f630fa529e3b4153bd06bf0b4c1c69e618376cfac9", size = 61076, upload-time = "2026-01-09T12:02:29.06Z" }, + { url = "https://files.pythonhosted.org/packages/3c/6a/7120b688f8543ef39ac1d42d325233aacde677a562fe72e3e51f10e433ee/guppylang-0.21.16-py3-none-any.whl", hash = "sha256:22ada5f013d96574102b89d88c290449b23f9972a8f0e88e92227308b8aa275c", size = 67031, upload-time = "2026-06-04T14:57:09.183Z" }, ] [[package]] name = "guppylang-internals" -version = "0.27.0" +version = "0.36.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, + { name = "pytket" }, + { name = "tket", extra = ["pytket"] }, { name = "tket-exts" }, { name = "typing-extensions" }, { name = "wasmtime" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cf/a1/39887e4c4d3b320bb25f3e441bb80c62127203ce9317d9e4553011f9e3e3/guppylang_internals-0.27.0.tar.gz", hash = "sha256:751cc83cfa74c0f790e75e254db30060b4f7d849f846d9efca76151fb517f23d", size = 190022, upload-time = "2026-01-09T10:58:12.094Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/67/c2ea5658a28d1ea814a64e91cf0433197dd0e99a3eab1dc3538e14499634/guppylang_internals-0.36.1.tar.gz", hash = "sha256:3ee67ffdd7e97cf9273f9a65697c48fb192bb98f110c960931d6ea1f2b7f0934", size = 214605, upload-time = "2026-06-04T14:53:42.617Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/b9/962eaebfee7256a6845b6ee9f11677cb4323a9524e801122355615cfa062/guppylang_internals-0.27.0-py3-none-any.whl", hash = "sha256:f6d758040768c5c3701e6a98ec16f24fc1ee66e360ab83de7cb5fe7af1b0dc8e", size = 243885, upload-time = "2026-01-09T10:58:10.386Z" }, + { url = "https://files.pythonhosted.org/packages/31/ac/f66326c93cc7e0c74ec8a195d23c78690cb470c77519e77a4d4876f5d355/guppylang_internals-0.36.1-py3-none-any.whl", hash = "sha256:d70dc644bdfaff46b5b94b308aa90e4aa667c60b85f72a82fbc1ea729d2dfe51", size = 267518, upload-time = "2026-06-04T14:53:40.954Z" }, ] [[package]] name = "hugr" -version = "0.15.0" +version = "0.16.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "graphviz" }, { name = "pydantic" }, { name = "pydantic-extra-types" }, - { name = "pyzstd" }, { name = "semver" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/65/29/83e6b0cec631dc051baaee7b25196bd659d4e406944cfdbf0be63d81e3c6/hugr-0.15.0.tar.gz", hash = "sha256:2ba5162fe3cc6b67d28c3d5236d6bfe85c6c1680361720fa3bf7cfd52c3f8003", size = 1107523, upload-time = "2026-01-05T15:37:36.459Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/29/129d978423d9e74ee42a25a0099bea068b48e66c2d1eb4f7f5faa128fb67/hugr-0.16.0.tar.gz", hash = "sha256:de4827cfe27e9d6ee2a764382825ae7ef8bd3282947f3c59114cc0de5a198124", size = 986110, upload-time = "2026-04-01T13:04:20.063Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/a2/a218acb5415d3917929ff9ee6b54e4d1a76152620f0dbc1c95da2995335d/hugr-0.15.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:582efc1c63cd6f5a4936efa96a78ee87f19839a8b8ad4c2126ce64a39843fd07", size = 3650398, upload-time = "2026-01-05T15:37:18.319Z" }, - { url = "https://files.pythonhosted.org/packages/d3/b2/6a37d4379deeba6cd81ae230b390c977b4ed93b700a153608fca3a3d7cf6/hugr-0.15.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:9cb910d8f6a21d8a7b83cd05aaef50ed05080c530ecf24b01f5ec10f223c94f0", size = 3274855, upload-time = "2026-01-05T15:37:14.007Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fd/dcc803aeb443327dfa292f4e3b87fc75be662a48430b2929a7a12a932cd9/hugr-0.15.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14fdcfadc9b7605be12f132834f89ef8748828aa95278b3dc0a7a260f509e5ed", size = 3618978, upload-time = "2026-01-05T15:36:52.667Z" }, - { url = "https://files.pythonhosted.org/packages/53/0e/501c4b1fc018fbeba3bce288e0924f2d54728b4b85b8149004d9ff7568d8/hugr-0.15.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fd872be2c18f1b2511e18b61c3dc1d6d7da570d29934a6c16ccd2d0d6f4bf289", size = 3588012, upload-time = "2026-01-05T15:36:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/f2/34/36a84b2a7288c3ec4a05a6e092976d26ac19d080a29cd215c468c3ecd0bd/hugr-0.15.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e560dde767728b1d33d214728804f836b88d9af1b9e83e96a41bff47ae9bb52e", size = 3869793, upload-time = "2026-01-05T15:37:07.305Z" }, - { url = "https://files.pythonhosted.org/packages/bb/82/d9caf62e81b31a3c6dcc01c850b6e7a4996921027ff14d65223e5e714459/hugr-0.15.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c40baaf57c38eb119123c1b1de6a5158f267f6b21521c95076bcc0b74a003a72", size = 4047330, upload-time = "2026-01-05T15:36:59.854Z" }, - { url = "https://files.pythonhosted.org/packages/d0/80/e61e41ac73e901bb435255a98cf3192cb6c849ebcee9760e102c7108057d/hugr-0.15.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b9f1fd41e34c721b3eafdfcb236d70520dfc13ea8067626f00691f1f5440acd", size = 4146426, upload-time = "2026-01-05T15:37:03.702Z" }, - { url = "https://files.pythonhosted.org/packages/34/83/fc58abf23bbea7b2253aa523d6b128309fc90b1f99dc09064dfbcef94639/hugr-0.15.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:11f47ba46331c8511c2f56790ea9ac05c76f631488f1ea6148ac15f8b565dd65", size = 3930571, upload-time = "2026-01-05T15:37:10.682Z" }, - { url = "https://files.pythonhosted.org/packages/58/c3/dae90e9646e3bddb6e23541c454d05fe20e84eb7e5bd8e14fce76504e155/hugr-0.15.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:774692491afbb7dcff6e6efaea03b0a90e76ebb4109735334d2e9a8ac6380cd6", size = 3838425, upload-time = "2026-01-05T15:37:21.726Z" }, - { url = "https://files.pythonhosted.org/packages/66/bd/75572fd29f3982b440de1febeeaa5256f743d7b8fce0400f849600b421fc/hugr-0.15.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:52b189e2ec0b00af823f26669119ada26e139176dfb4645fb82ffac7d0d5737e", size = 3856597, upload-time = "2026-01-05T15:37:25.622Z" }, - { url = "https://files.pythonhosted.org/packages/b9/18/4b6524aa8bf22374685f6dcd9938d1d453230a53be038c87c1ed97caf705/hugr-0.15.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:afffb871ec30f928714d642c5ef78739b47803ee8a83b55c65e09bd2b9d2211c", size = 3954804, upload-time = "2026-01-05T15:37:29.127Z" }, - { url = "https://files.pythonhosted.org/packages/39/2b/5a893a566861ebec8889b546e8e7d68df337cde60028465bcb1fc3b3f508/hugr-0.15.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3a773316c917b43aa50b5446fcb5273c3e27b513293fad13e2b157d0fc290fe0", size = 4173183, upload-time = "2026-01-05T15:37:32.833Z" }, - { url = "https://files.pythonhosted.org/packages/1a/6d/627f8d6a6571239eb5c139f2fa863773d09470fc60aa44c1b225e7214c5f/hugr-0.15.0-cp310-abi3-win32.whl", hash = "sha256:2a2ca91cbe40646c69c5041db51e3ec111846d4f4ed25dd65f402df2d2387fa0", size = 3197911, upload-time = "2026-01-05T15:37:39.874Z" }, - { url = "https://files.pythonhosted.org/packages/e5/56/fc758375451ffcc535e24ec58066e6a2b336ca5035f78f244532369309ac/hugr-0.15.0-cp310-abi3-win_amd64.whl", hash = "sha256:258fefc321ccf007d05f76c92e6e80f71fcc543189f0f0a0b7095ed2c6dd1aa4", size = 3489634, upload-time = "2026-01-05T15:37:38.147Z" }, - { url = "https://files.pythonhosted.org/packages/d9/28/5618ec62739e3cd925ae5a2a797004b98c4d03d66947ab597027309766b1/hugr-0.15.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:690084a62d02d564bee17704ea3e77a9066ad3a1b09cd75aed86c9c3d8439137", size = 3651038, upload-time = "2026-01-05T15:37:20.049Z" }, - { url = "https://files.pythonhosted.org/packages/a1/da/a02ffac8e5598beb177e7d33e29f82a4864d2c8b980aed8e81dda03deb44/hugr-0.15.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ca5a7934ec792bf9b989a75de484dec0d1746b12d6876616ff2642eef1756c13", size = 3275569, upload-time = "2026-01-05T15:37:15.925Z" }, - { url = "https://files.pythonhosted.org/packages/89/ab/918663e1dd5021f3fa1ae8db77718ae5ed18d6cd26ab8372ccfdebcddb4d/hugr-0.15.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5f530b246eb2bb69b5a5aec9c9a0134d120f9ae506cb8cf3a1871128d87b8330", size = 3622144, upload-time = "2026-01-05T15:36:54.662Z" }, - { url = "https://files.pythonhosted.org/packages/f5/f4/e9588b67cf552cc9c7d371c81f7b4e4efaf4a1a730b8f2b9336ecb0a1dab/hugr-0.15.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1ab7a5d33c31f4250af9528552b70169e0789e321a9c51886f42270c9903b89", size = 3591782, upload-time = "2026-01-05T15:36:58.13Z" }, - { url = "https://files.pythonhosted.org/packages/40/95/d8c6e68912867d48058e2658e32ac845835a087906e5f73bb3c1739f46d0/hugr-0.15.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:67a962e2003df25b6fcbe2481ff24d5bc41d3da922268692d9bce796a96f8443", size = 3876595, upload-time = "2026-01-05T15:37:08.878Z" }, - { url = "https://files.pythonhosted.org/packages/0c/7c/6544e710095e864855a60de3435009e7c3e78a6186cb8eb1e7ad53981cdb/hugr-0.15.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d58d2d3a0ceb37a48ca3858e7bf288ae1b949f70d5b54de68f8cc53adac9f6f", size = 4052329, upload-time = "2026-01-05T15:37:01.845Z" }, - { url = "https://files.pythonhosted.org/packages/95/b9/f81120477dac3c044b8224a3a781936ffa793664d71c2909c2970161f5be/hugr-0.15.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:00fdb45f1d16ea5904496fa4a59b3fd6b8b56befdf812f939e34a654563ff4fa", size = 4147108, upload-time = "2026-01-05T15:37:05.583Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ce/8c8848164d2a94f12ee7daa3d8ded9b345ff80dcccae1b725af4c39db1a9/hugr-0.15.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54c83434a1fcbe7b0a9828aedbd417a2913d477d1e39eb0dacd5f2a1d022cbd4", size = 3943356, upload-time = "2026-01-05T15:37:12.37Z" }, - { url = "https://files.pythonhosted.org/packages/a4/ad/b1609104ce4d618f1763e35eacae64c7557548e9013c5c194b47ff2bac9e/hugr-0.15.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:644adac47ac849236aad88e51736ced0f0cb2d9425c3cf37e8b7292a89f845a3", size = 3839886, upload-time = "2026-01-05T15:37:23.652Z" }, - { url = "https://files.pythonhosted.org/packages/23/cb/0c766a8ef2dd3bdef66f8363f806c81d6f999db808a4fe9de504e9c03d3d/hugr-0.15.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:1f43c65878fee451dc46e367b233a1ad1ac787fc3d907a91e07af262e2ca825d", size = 3862103, upload-time = "2026-01-05T15:37:27.464Z" }, - { url = "https://files.pythonhosted.org/packages/9d/ab/ad625821cd78399b59ba4b939d6b3c62c3e200c99a9ecda0c7fe19304984/hugr-0.15.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:939463bae24d58f34dbbe0d89aac26ba71db3250dc1eb342f82ef9721881170a", size = 3965228, upload-time = "2026-01-05T15:37:31.119Z" }, - { url = "https://files.pythonhosted.org/packages/01/ec/c4df028a4a741ee85bfd7b1c45c0fc4d5ca218ae09ce7533c47ceae6c1a2/hugr-0.15.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:1cd9a00c1a940de029d771df1d6da132b833879a3fee2485559275142db94e80", size = 4189216, upload-time = "2026-01-05T15:37:34.467Z" }, + { url = "https://files.pythonhosted.org/packages/31/54/c8593f1e41aedff29cd6fab66a60ffaa68380a8b8fc31841b53d1919f05c/hugr-0.16.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:270bb717d28310e297995f7e84335336d4a896b048468ca6d5a56e672e9c83f8", size = 3399386, upload-time = "2026-04-01T13:04:17.615Z" }, + { url = "https://files.pythonhosted.org/packages/9b/0d/e9d90f6895ab0a8e144f5030f2384f85a98af98dd34f616e157863b09ffc/hugr-0.16.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:759a52fd1a0e2c2a1defbddc88505294cd1ec945d422cb170e7c016c946fbcce", size = 3041962, upload-time = "2026-04-01T13:04:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/cd/41/e3ebf1fed1da84081f4e11bba9ae82a5270856288b5f38d5cbe1578bf226/hugr-0.16.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d484a7c872fb81bf359e8678def627d2f0fd64516b0e131a789d2198410117e2", size = 3362746, upload-time = "2026-04-01T13:03:43.218Z" }, + { url = "https://files.pythonhosted.org/packages/08/34/05d82ac6f872aa02cbd920927d7c1ce11163e8321fd9e053cd601bfe0d74/hugr-0.16.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e9693f512909e76abf69072b5504615cd022034ff828f9b7f30f95c51e05af74", size = 3340868, upload-time = "2026-04-01T13:03:45.909Z" }, + { url = "https://files.pythonhosted.org/packages/36/2c/7401bb1edf930576f51e5678ae9a74d11f10c0ef83f42adf06397de2f3e1/hugr-0.16.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d66d2c07adc54065df2a244bcbc67c41c5adb9e682b8d5b3e9946bebb54d791d", size = 3776711, upload-time = "2026-04-01T13:03:48.823Z" }, + { url = "https://files.pythonhosted.org/packages/19/86/748222a7c4c84353f8b5741240ca3c51c9e29d706884296bf44e403010cb/hugr-0.16.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e653ad5a93eaf9a9197e8d5126329ac95c4def3d878d10f5a9a9c18cebe788dd", size = 3855897, upload-time = "2026-04-01T13:03:51.508Z" }, + { url = "https://files.pythonhosted.org/packages/81/12/eee150b3c7454ef022f1de4003ed7084af4ddebf3efadc5f6bb060306f73/hugr-0.16.0-cp310-abi3-manylinux_2_28_i686.whl", hash = "sha256:02be6d8080372cae20c9683438e297b5f38ef1a3ab8488b34c614e575e00ee65", size = 3608483, upload-time = "2026-04-01T17:50:43.25Z" }, + { url = "https://files.pythonhosted.org/packages/96/48/6da7d6d35db29f7782b692f9df79f5be6882d62da4067f408cdcae1dac5a/hugr-0.16.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:44b750e5f725a827ac12524bd35199f956267c7ff383039cfcdb419e1ee22e31", size = 3656539, upload-time = "2026-04-01T17:50:47.956Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2e/d81e42a57de81020cb14089fe45f76337f59bc9aca9f1483e4552f3c2bee/hugr-0.16.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e8a4e6b0f17a5f56c387539cf02fb6eacbc66cbd86b5ce07b09e6a860b254ce5", size = 3569466, upload-time = "2026-04-01T13:04:00.777Z" }, + { url = "https://files.pythonhosted.org/packages/86/0f/23845b23a6aeac0d02a89e06004dbcda286d0500314f68c73c34b07b5c78/hugr-0.16.0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:a087257e1d60ce6fc266a3f6d734935554a78b3dddd429698418b4f5eee49e09", size = 3617572, upload-time = "2026-04-01T13:04:03.33Z" }, + { url = "https://files.pythonhosted.org/packages/5b/43/84f8d9399fa77d04f6b4d1ef856b98b8520110f844bf1b65d6c98ac4edc1/hugr-0.16.0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:8f9eaabf8439cbdf7e0d873fc10506d7f8db9673966d3d934fb8a1ec86bbc0a9", size = 3705137, upload-time = "2026-04-01T13:04:06.031Z" }, + { url = "https://files.pythonhosted.org/packages/da/9d/b3d75e12ad0265ea9cbfabb49cc9072e6109596d8777031e9a3d4d016f6d/hugr-0.16.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9781813e4222fe1498bd8adb3b31e8ce4e92e925bfd097ec24d8354b87f51d2c", size = 3881014, upload-time = "2026-04-01T13:04:08.983Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ca/d71f8374f62831472269927f2adf4b0d14e35d25769940dc05a23e8930e0/hugr-0.16.0-cp310-abi3-win32.whl", hash = "sha256:9649bf4552bbe9cf93eec21a344d50c7a4a8429aa15928e4647778a9bbf92488", size = 2954806, upload-time = "2026-04-01T13:04:13.447Z" }, + { url = "https://files.pythonhosted.org/packages/72/5a/3dbf899aacdf2978a57ccd2a5ee0832f92608b67fecf43440a1a125f0a48/hugr-0.16.0-cp310-abi3-win_amd64.whl", hash = "sha256:c75101ca5b9c846f20502d4cfa88286a2784bd7c0d653d8025040393e3a9e568", size = 3236422, upload-time = "2026-04-01T13:04:11.801Z" }, + { url = "https://files.pythonhosted.org/packages/31/23/98154df19a32bc8807d66d22ffe321f02c23fd814b225f0a5bc53c0671ac/hugr-0.16.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:c5fe2702d5e80e831c39c701b5eae3f277b089b85a92cdd55f53fba61851ecc1", size = 3399228, upload-time = "2026-04-01T13:04:18.798Z" }, + { url = "https://files.pythonhosted.org/packages/a0/73/7030d9185cf52a216f50fd2df829c0cf0ddc78205e2ca09a192f95c9b952/hugr-0.16.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:007694eb785cf0ce9eaf32dbcc94375ae8ec5c3da1938141bfa97f28e9afe0d4", size = 3042296, upload-time = "2026-04-01T13:04:16.285Z" }, + { url = "https://files.pythonhosted.org/packages/ba/5c/ecc2637f528b14d0cf6e400978526e89c5dd51c1aa3b25da4f12b5380404/hugr-0.16.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2a97b7cebec63bcc83e10747a94aa4a42a7f1b7127d56a12f8c9151014a8265c", size = 3363178, upload-time = "2026-04-01T13:03:44.559Z" }, + { url = "https://files.pythonhosted.org/packages/54/a8/1a76e227117acc914d6c53050b8c29332d2dd5f464f2bea9606550ee163c/hugr-0.16.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ebaded1dbc81282fd519f29be7eab2e884f25dc60fb866b88bc4316e0998ea6a", size = 3342289, upload-time = "2026-04-01T13:03:47.576Z" }, + { url = "https://files.pythonhosted.org/packages/f2/83/de3f56e3edef9f2722071d9d48167be764d467733b4a7639d74171df4e6c/hugr-0.16.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6165273000247817467f0c97582555b96cabf2ddf667c10d9de11272e94531ae", size = 3603558, upload-time = "2026-04-01T13:03:54.839Z" }, + { url = "https://files.pythonhosted.org/packages/b4/16/faa2c8910b12d1fa71697fd5f342e5782a2ce55cbba17a9f62d0d617af17/hugr-0.16.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5be77f7840f1dde1e6e6f6d472c40f2c2726f4b20430482e76a943dc4270111e", size = 3773176, upload-time = "2026-04-01T13:03:50.302Z" }, + { url = "https://files.pythonhosted.org/packages/c4/48/f62ef65b21ff37bcd7eed15e1cdd777e940a1f2389eab670d1adac0bf062/hugr-0.16.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:90c4dd63f0fa07b0fcbd5cf61b78567aa23cb5003092b571a84aae0c27fa5737", size = 3855081, upload-time = "2026-04-01T13:03:52.746Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d9/db53d482143e8388059eba9c9f14a128b3444b37f6fb1435c6eff0447047/hugr-0.16.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdce09aa90f305caccba667251d7fc6fcd2f469a840bd9ea810640f9cc3c3b21", size = 3641209, upload-time = "2026-04-01T13:03:57.805Z" }, + { url = "https://files.pythonhosted.org/packages/d4/72/583c458708bf156c8f69210b4bb04be19a217818e710bfec8c8933d06994/hugr-0.16.0-cp313-cp313t-manylinux_2_28_i686.whl", hash = "sha256:60fe672f0349fbc4be031e36b986e174bc68d9185ad7eeb5c3d67b71f98937be", size = 3606359, upload-time = "2026-04-01T17:50:52.597Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e4/72282c75cb607222e6aedc2fbb42cd61a8fa180d5d5a820e67682258241a/hugr-0.16.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:a4bdc164c91111db8249d51126867be7ea2e774398cade7fe5432a48b5997279", size = 3650296, upload-time = "2026-04-01T17:50:57.659Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/12db6e42e24f0af8aad11b6aa87fdd731a0dfa53617fc879cb3bc7604f4d/hugr-0.16.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0faea63ade58f6fb051606a778fd06c397ae1cf18a1a7218123853e9ca8a6fe", size = 3569708, upload-time = "2026-04-01T13:04:02.12Z" }, + { url = "https://files.pythonhosted.org/packages/6e/32/106a5b8e618c1f4f9f501d55aae6783fb2afb77419f41a8b8b293ecfbe78/hugr-0.16.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:dceb496d1e120871c8df7d4e91f30d7ac91d19519690fd45f08c5f57e639e531", size = 3619769, upload-time = "2026-04-01T13:04:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/cc/79/a608e321135e4ac0b84dd593497ee9fcd1098645884263296b42a80f695d/hugr-0.16.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:c210a2bc18e65638623bb14b7c308957b7d0b9cdc8b2c8b244bc697a76966729", size = 3702898, upload-time = "2026-04-01T13:04:07.547Z" }, + { url = "https://files.pythonhosted.org/packages/58/15/552629b919fc5d42b23f3c886bd66f33e401657346033860e5f847f5944f/hugr-0.16.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7b01e7f99b019cd77013bf4e55e0115862adf42d327c354297667ffd8023d209", size = 3873617, upload-time = "2026-04-01T13:04:10.568Z" }, ] [[package]] @@ -243,6 +143,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "lark" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/da/34/28fff3ab31ccff1fd4f6c7c7b0ceb2b6968d8ea4950663eadcb5720591a0/lark-1.3.1.tar.gz", hash = "sha256:b426a7a6d6d53189d318f2b6236ab5d6429eaf09259f1ca33eb716eed10d2905", size = 382732, upload-time = "2025-10-27T18:25:56.653Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3d/14ce75ef66813643812f3093ab17e46d3a206942ce7376d31ec2d36229e7/lark-1.3.1-py3-none-any.whl", hash = "sha256:c629b661023a014c37da873b4ff58a817398d12635d3bbb2c5a03be7fe5d1e12", size = 113151, upload-time = "2025-10-27T18:25:54.882Z" }, +] + [[package]] name = "lief" version = "0.17.2" @@ -315,6 +236,91 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, ] +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/4b/3541d44f3937ba468b75da9eebcae497dcf67adb65caa16760b0a6807ebb/markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559", size = 11631, upload-time = "2025-09-27T18:36:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/98/1b/fbd8eed11021cabd9226c37342fa6ca4e8a98d8188a8d9b66740494960e4/markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419", size = 12057, upload-time = "2025-09-27T18:36:07.165Z" }, + { url = "https://files.pythonhosted.org/packages/40/01/e560d658dc0bb8ab762670ece35281dec7b6c1b33f5fbc09ebb57a185519/markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695", size = 22050, upload-time = "2025-09-27T18:36:08.005Z" }, + { url = "https://files.pythonhosted.org/packages/af/cd/ce6e848bbf2c32314c9b237839119c5a564a59725b53157c856e90937b7a/markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591", size = 20681, upload-time = "2025-09-27T18:36:08.881Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2a/b5c12c809f1c3045c4d580b035a743d12fcde53cf685dbc44660826308da/markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c", size = 20705, upload-time = "2025-09-27T18:36:10.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/9427a68c82728d0a88c50f890d0fc072a1484de2f3ac1ad0bfc1a7214fd5/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f", size = 21524, upload-time = "2025-09-27T18:36:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/23578f29e9e582a4d0278e009b38081dbe363c5e7165113fad546918a232/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6", size = 20282, upload-time = "2025-09-27T18:36:12.573Z" }, + { url = "https://files.pythonhosted.org/packages/56/21/dca11354e756ebd03e036bd8ad58d6d7168c80ce1fe5e75218e4945cbab7/markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1", size = 20745, upload-time = "2025-09-27T18:36:13.504Z" }, + { url = "https://files.pythonhosted.org/packages/87/99/faba9369a7ad6e4d10b6a5fbf71fa2a188fe4a593b15f0963b73859a1bbd/markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa", size = 14571, upload-time = "2025-09-27T18:36:14.779Z" }, + { url = "https://files.pythonhosted.org/packages/d6/25/55dc3ab959917602c96985cb1253efaa4ff42f71194bddeb61eb7278b8be/markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8", size = 15056, upload-time = "2025-09-27T18:36:16.125Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9e/0a02226640c255d1da0b8d12e24ac2aa6734da68bff14c05dd53b94a0fc3/markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1", size = 13932, upload-time = "2025-09-27T18:36:17.311Z" }, + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + [[package]] name = "mdurl" version = "0.1.2" @@ -324,6 +330,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f", size = 508106, upload-time = "2023-03-07T16:47:11.061Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c", size = 536198, upload-time = "2023-03-07T16:47:09.197Z" }, +] + [[package]] name = "networkx" version = "3.4.2" @@ -341,7 +356,8 @@ name = "networkx" version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ @@ -418,7 +434,8 @@ name = "numpy" version = "2.4.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.11'", + "python_full_version >= '3.12'", + "python_full_version == '3.11.*'", ] sdist = { url = "https://files.pythonhosted.org/packages/24/62/ae72ff66c0f1fd959925b4c11f8c2dea61f47f6acaea75a08512cdfe3fed/numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690", size = 20721320, upload-time = "2026-01-10T06:44:59.619Z" } wheels = [ @@ -522,8 +539,8 @@ wheels = [ [[package]] name = "pecos-rslib" -version = "0.8.0.dev0" -source = { editable = "../pecos-rslib" } +version = "0.8.0.dev8" +source = { editable = "../../python/pecos-rslib" } [package.metadata] @@ -533,7 +550,18 @@ numpy-compat = [ { name = "numpy", specifier = ">=1.20" }, { name = "scipy", specifier = ">=1.7" }, ] -test = [{ name = "pytest", specifier = ">=7.0" }] +test = [{ name = "pytest", specifier = ">=9.0" }] + +[[package]] +name = "pecos-rslib-llvm" +version = "0.8.0.dev8" +source = { editable = "../../python/pecos-rslib-llvm" } + +[package.metadata] + +[package.metadata.requires-dev] +dev = [] +test = [{ name = "pytest", specifier = ">=9.0" }] [[package]] name = "phir" @@ -717,11 +745,11 @@ wheels = [ [[package]] name = "pygments" -version = "2.19.2" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] [[package]] @@ -735,7 +763,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.2" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -746,9 +774,46 @@ dependencies = [ { name = "pygments" }, { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytket" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "graphviz" }, + { name = "jinja2" }, + { name = "lark" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "qwasm" }, + { name = "scipy", version = "1.15.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "scipy", version = "1.17.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "scipy", version = "1.18.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "sympy" }, + { name = "typing-extensions" }, +] wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8d/7b860651db750067666a74400812b7610847ecd604636574b980a4400b9a/pytket-2.18.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ab80518ee545bc44dff7f40fb68d46cfa946a0d86cbe47227e0c704c57819391", size = 5549168, upload-time = "2026-05-15T08:57:35.796Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cb/5a2ca0e39fa3ffed7460ce446fd843824c82dc501966d067b8ab2e0e9988/pytket-2.18.0-cp310-cp310-macosx_15_0_x86_64.whl", hash = "sha256:392384e74f2df432428c12020eb358474cee201b0387eeddac51cace3b2bfd2f", size = 6238136, upload-time = "2026-05-15T08:57:38.089Z" }, + { url = "https://files.pythonhosted.org/packages/75/0f/ec940c12f8376686d24d0d7d51674a71e45d0441b50bdfada7260e65b6c3/pytket-2.18.0-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ada57f98fde63a43db5d49407cfda06b70c792c93c018bfee9fb1dd336e07eb6", size = 7583259, upload-time = "2026-05-15T08:57:39.899Z" }, + { url = "https://files.pythonhosted.org/packages/5c/80/0c66b92d7b14c990e90f9d226d69cc1a9d59816b515795ffe63f43045ca3/pytket-2.18.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6e50c5368516649e42723256a0b610112f688824fef9535e9adb31d83c1ac359", size = 8319658, upload-time = "2026-05-15T08:57:42.072Z" }, + { url = "https://files.pythonhosted.org/packages/c4/41/87498ac87aa2b5e330b188f6b4614466b451ca64012c7586c5411adcc354/pytket-2.18.0-cp310-cp310-win_amd64.whl", hash = "sha256:6d29b2d1b5277e47ad149b28d5bf13706dd2d3d0502ec577ca21cac4353cc499", size = 9790910, upload-time = "2026-05-15T08:57:44.243Z" }, + { url = "https://files.pythonhosted.org/packages/47/cc/cfcc64549767dee84172ebb5904e1b69d6246099d6ae170b2c91e0e0002c/pytket-2.18.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f700a97c4caf2ac98c47a2c8e37b71b28b9b35ca9218bbb0357bba499b23eeaa", size = 5550955, upload-time = "2026-05-15T08:57:47.038Z" }, + { url = "https://files.pythonhosted.org/packages/27/78/69295578749e0724647d3ba927f4d5466c48dc89218019e3298acb36842d/pytket-2.18.0-cp311-cp311-macosx_15_0_x86_64.whl", hash = "sha256:9349e1f6faddbe315fe32dd42da8609899b923b607034ff97ae40cb7f5a3d40c", size = 6240572, upload-time = "2026-05-15T08:57:49.046Z" }, + { url = "https://files.pythonhosted.org/packages/37/e1/6d75b33869b710699e939ab0b5193477d5e0f2d825613b07febf3c16c020/pytket-2.18.0-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b3af39d4f3ae3297de8d75773b940800820192e12392071dab7eb3b4c761857", size = 7584459, upload-time = "2026-05-15T08:57:50.761Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/9047f68f21a12f14ca0c1a4fde2e618935afc79f061e2288ded62c468dbb/pytket-2.18.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4817d27ff0ebb5481b64b86eb3ec759b619354c0dff5e4f1a4cfc6e4b888cf78", size = 8320126, upload-time = "2026-05-15T08:57:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/7dbbd09d136084cb07f4aff4fc8cae03791e16c026713e6a72b9b170c601/pytket-2.18.0-cp311-cp311-win_amd64.whl", hash = "sha256:60d95245695257642853ff9b0dec31fd520c9c07cdba4d4fb429a861914b97fe", size = 9791813, upload-time = "2026-05-15T08:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/15/39/28cd32ee7606b1b252b29464ba9afaa2c59cfa4a21fea715c9ed150dfe4e/pytket-2.18.0-cp312-abi3-macosx_14_0_arm64.whl", hash = "sha256:9673824105eb781ae05c4c80d3cd6786f4ab58c5a5def5b9ca7ae890dce9fd2d", size = 5531855, upload-time = "2026-05-15T08:57:57.559Z" }, + { url = "https://files.pythonhosted.org/packages/b7/33/03df743d1349f649a96e26eab7726288b03e9a84d2ecaf6eb9f6e25760e5/pytket-2.18.0-cp312-abi3-macosx_15_0_x86_64.whl", hash = "sha256:c819bae62bd96020053ad872a7e85150509781c2a83d7a66a69b9c6b5f6329cb", size = 6223192, upload-time = "2026-05-15T08:57:59.63Z" }, + { url = "https://files.pythonhosted.org/packages/a4/e5/9886826e5ccb5639e493567073e04dc2911972dd8ea095367305f546df13/pytket-2.18.0-cp312-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c805946d49d93d13bb3b4fe21f69e89be38f9b1d5739aea8f64cd1e70c85da4d", size = 7534626, upload-time = "2026-05-15T08:58:01.743Z" }, + { url = "https://files.pythonhosted.org/packages/01/00/b50f3b11b92d5c74713d37ddb57e310a0fcc63eb13549007167985801286/pytket-2.18.0-cp312-abi3-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ca100f33ca3b56f8fe9f1d72c904067789cfc58bc084439f9aa409ec6621fc3", size = 8262137, upload-time = "2026-05-15T08:58:03.839Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ce/87fe3c1d0cb981f076d0cccde68043734ec57587a3f8049a675aae0e29e8/pytket-2.18.0-cp312-abi3-win_amd64.whl", hash = "sha256:9557d36cfe4b4376c36c071b0ac792038314ebec396df8252143ac4a1972a7b3", size = 9764538, upload-time = "2026-05-15T08:58:05.654Z" }, ] [[package]] @@ -815,55 +880,57 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] -[[package]] -name = "pyzstd" -version = "0.19.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "backports-zstd", marker = "python_full_version < '3.14'" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/4c/66/59fed71d0d2065e02974b40296f836a237c364c8bbe07295f2d0dc33c278/pyzstd-0.19.1.tar.gz", hash = "sha256:36723d3c915b3981de9198d0a2c82b2f5fe3eaa36e4d8d586937830a8afc7d72", size = 69531, upload-time = "2025-12-13T08:15:33.455Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/62/f55d1363512d1b1cc122af6ca5951008e42605b63675c7d6781affc7c475/pyzstd-0.19.1-py3-none-any.whl", hash = "sha256:267e2eb0de0291dfcb7ccfebc4ffe75b2f9d84e7007ac38844f6ccafc6dce081", size = 23779, upload-time = "2025-12-13T08:15:31.979Z" }, -] - [[package]] name = "quantum-pecos" -version = "0.8.0.dev0" -source = { editable = "../quantum-pecos" } +version = "0.8.0.dev8" +source = { editable = "../../python/quantum-pecos" } dependencies = [ { name = "guppylang" }, { name = "hugr" }, { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pecos-rslib" }, + { name = "pecos-rslib-llvm" }, { name = "phir" }, { name = "selene-sim" }, + { name = "tket" }, ] [package.metadata] requires-dist = [ - { name = "cupy-cuda13x", marker = "python_full_version >= '3.11' and extra == 'cuda'", specifier = ">=13.0.0" }, - { name = "cuquantum-python-cu13", marker = "python_full_version >= '3.11' and extra == 'cuda'", specifier = ">=25.3.0" }, - { name = "guppylang", specifier = ">=0.21.0" }, - { name = "hugr", specifier = ">=0.13.0" }, + { name = "cupy-cuda12x", marker = "python_full_version >= '3.11' and extra == 'cuda12'", specifier = ">=13.0.0" }, + { name = "cupy-cuda13x", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=13.0.0" }, + { name = "cuquantum-python-cu12", marker = "python_full_version >= '3.11' and extra == 'cuda12'", specifier = ">=25.3.0" }, + { name = "cuquantum-python-cu13", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=25.9.0" }, + { name = "guppylang", specifier = ">=0.21.16,<0.22" }, + { name = "hugr", specifier = ">=0.16,<0.17" }, { name = "matplotlib", marker = "extra == 'visualization'", specifier = ">=2.2.0" }, { name = "networkx", specifier = ">=2.1.0" }, - { name = "pecos-rslib", editable = "../pecos-rslib" }, + { name = "pecos-rslib", editable = "../../python/pecos-rslib" }, + { name = "pecos-rslib-llvm", editable = "../../python/pecos-rslib-llvm" }, { name = "phir", specifier = ">=0.3.3" }, { name = "plotly", marker = "extra == 'visualization'", specifier = "~=5.9.0" }, - { name = "pytket-cutensornet", marker = "python_full_version >= '3.11' and extra == 'cuda'", specifier = ">=0.12.0" }, - { name = "quantum-pecos", extras = ["stim"], marker = "extra == 'all'" }, - { name = "quantum-pecos", extras = ["visualization"], marker = "extra == 'all'" }, + { name = "pytket-cutensornet", marker = "python_full_version >= '3.11' and extra == 'cuda12'", specifier = ">=0.12.0" }, + { name = "pytket-cutensornet", marker = "python_full_version >= '3.11' and extra == 'cuda13'", specifier = ">=0.12.0" }, + { name = "quantum-pecos", extras = ["stim"], marker = "extra == 'all'", editable = "../../python/quantum-pecos" }, + { name = "quantum-pecos", extras = ["visualization"], marker = "extra == 'all'", editable = "../../python/quantum-pecos" }, { name = "selene-sim", specifier = "~=0.2.0" }, - { name = "stim", marker = "extra == 'stim'", specifier = ">=1.12.0" }, + { name = "stim", marker = "extra == 'stim'", specifier = ">=1.15.0" }, + { name = "tket", specifier = ">=0.13,<0.14" }, ] -provides-extras = ["stim", "visualization", "all", "cuda"] +provides-extras = ["stim", "visualization", "all", "cuda13", "cuda12"] -[package.metadata.requires-dev] -numpy-compat = [{ name = "numpy", specifier = ">=1.15.0" }] -test = [] +[[package]] +name = "qwasm" +version = "1.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6f/a1/b52f356f907bedf02f536970c1b46aa69bc57280c4d17ed6b5c39180959f/qwasm-1.0.1.tar.gz", hash = "sha256:01f5dfe27159b7fdd9d02cd299833225d528fa383d1278268e5e1526357950fb", size = 13921, upload-time = "2022-10-05T09:46:56.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/e9/fbde58bf8dcc05dc9d09dfcd06e631f470aa1e6732870cf06cd34ab86eaf/qwasm-1.0.1-py3-none-any.whl", hash = "sha256:c4c82a3f962d29314634868e06375f0cb4676c3d5266fbe137f6cd67321b0ef1", size = 15322, upload-time = "2022-10-05T09:46:54.856Z" }, +] [[package]] name = "rich" @@ -878,6 +945,193 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, ] +[[package]] +name = "scipy" +version = "1.15.3" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.11'", +] +dependencies = [ + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/2f/4966032c5f8cc7e6a60f1b2e0ad686293b9474b65246b0c642e3ef3badd0/scipy-1.15.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a345928c86d535060c9c2b25e71e87c39ab2f22fc96e9636bd74d1dbf9de448c", size = 38702770, upload-time = "2025-05-08T16:04:20.849Z" }, + { url = "https://files.pythonhosted.org/packages/a0/6e/0c3bf90fae0e910c274db43304ebe25a6b391327f3f10b5dcc638c090795/scipy-1.15.3-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:ad3432cb0f9ed87477a8d97f03b763fd1d57709f1bbde3c9369b1dff5503b253", size = 30094511, upload-time = "2025-05-08T16:04:27.103Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b1/4deb37252311c1acff7f101f6453f0440794f51b6eacb1aad4459a134081/scipy-1.15.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:aef683a9ae6eb00728a542b796f52a5477b78252edede72b8327a886ab63293f", size = 22368151, upload-time = "2025-05-08T16:04:31.731Z" }, + { url = "https://files.pythonhosted.org/packages/38/7d/f457626e3cd3c29b3a49ca115a304cebb8cc6f31b04678f03b216899d3c6/scipy-1.15.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:1c832e1bd78dea67d5c16f786681b28dd695a8cb1fb90af2e27580d3d0967e92", size = 25121732, upload-time = "2025-05-08T16:04:36.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/0a/92b1de4a7adc7a15dcf5bddc6e191f6f29ee663b30511ce20467ef9b82e4/scipy-1.15.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:263961f658ce2165bbd7b99fa5135195c3a12d9bef045345016b8b50c315cb82", size = 35547617, upload-time = "2025-05-08T16:04:43.546Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/41991e503e51fc1134502694c5fa7a1671501a17ffa12716a4a9151af3df/scipy-1.15.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e2abc762b0811e09a0d3258abee2d98e0c703eee49464ce0069590846f31d40", size = 37662964, upload-time = "2025-05-08T16:04:49.431Z" }, + { url = "https://files.pythonhosted.org/packages/25/e1/3df8f83cb15f3500478c889be8fb18700813b95e9e087328230b98d547ff/scipy-1.15.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:ed7284b21a7a0c8f1b6e5977ac05396c0d008b89e05498c8b7e8f4a1423bba0e", size = 37238749, upload-time = "2025-05-08T16:04:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/b3257cf446f2a3533ed7809757039016b74cd6f38271de91682aa844cfc5/scipy-1.15.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5380741e53df2c566f4d234b100a484b420af85deb39ea35a1cc1be84ff53a5c", size = 40022383, upload-time = "2025-05-08T16:05:01.914Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/55bc4881973d3f79b479a5a2e2df61c8c9a04fcb986a213ac9c02cfb659b/scipy-1.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:9d61e97b186a57350f6d6fd72640f9e99d5a4a2b8fbf4b9ee9a841eab327dc13", size = 41259201, upload-time = "2025-05-08T16:05:08.166Z" }, + { url = "https://files.pythonhosted.org/packages/96/ab/5cc9f80f28f6a7dff646c5756e559823614a42b1939d86dd0ed550470210/scipy-1.15.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:993439ce220d25e3696d1b23b233dd010169b62f6456488567e830654ee37a6b", size = 38714255, upload-time = "2025-05-08T16:05:14.596Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/66ba30abe5ad1a3ad15bfb0b59d22174012e8056ff448cb1644deccbfed2/scipy-1.15.3-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:34716e281f181a02341ddeaad584205bd2fd3c242063bd3423d61ac259ca7eba", size = 30111035, upload-time = "2025-05-08T16:05:20.152Z" }, + { url = "https://files.pythonhosted.org/packages/4b/fa/a7e5b95afd80d24313307f03624acc65801846fa75599034f8ceb9e2cbf6/scipy-1.15.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3b0334816afb8b91dab859281b1b9786934392aa3d527cd847e41bb6f45bee65", size = 22384499, upload-time = "2025-05-08T16:05:24.494Z" }, + { url = "https://files.pythonhosted.org/packages/17/99/f3aaddccf3588bb4aea70ba35328c204cadd89517a1612ecfda5b2dd9d7a/scipy-1.15.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:6db907c7368e3092e24919b5e31c76998b0ce1684d51a90943cb0ed1b4ffd6c1", size = 25152602, upload-time = "2025-05-08T16:05:29.313Z" }, + { url = "https://files.pythonhosted.org/packages/56/c5/1032cdb565f146109212153339f9cb8b993701e9fe56b1c97699eee12586/scipy-1.15.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:721d6b4ef5dc82ca8968c25b111e307083d7ca9091bc38163fb89243e85e3889", size = 35503415, upload-time = "2025-05-08T16:05:34.699Z" }, + { url = "https://files.pythonhosted.org/packages/bd/37/89f19c8c05505d0601ed5650156e50eb881ae3918786c8fd7262b4ee66d3/scipy-1.15.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39cb9c62e471b1bb3750066ecc3a3f3052b37751c7c3dfd0fd7e48900ed52982", size = 37652622, upload-time = "2025-05-08T16:05:40.762Z" }, + { url = "https://files.pythonhosted.org/packages/7e/31/be59513aa9695519b18e1851bb9e487de66f2d31f835201f1b42f5d4d475/scipy-1.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:795c46999bae845966368a3c013e0e00947932d68e235702b5c3f6ea799aa8c9", size = 37244796, upload-time = "2025-05-08T16:05:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/10/c0/4f5f3eeccc235632aab79b27a74a9130c6c35df358129f7ac8b29f562ac7/scipy-1.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:18aaacb735ab38b38db42cb01f6b92a2d0d4b6aabefeb07f02849e47f8fb3594", size = 40047684, upload-time = "2025-05-08T16:05:54.22Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a7/0ddaf514ce8a8714f6ed243a2b391b41dbb65251affe21ee3077ec45ea9a/scipy-1.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:ae48a786a28412d744c62fd7816a4118ef97e5be0bee968ce8f0a2fba7acf3bb", size = 41246504, upload-time = "2025-05-08T16:06:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/683aa044c4162e10ed7a7ea30527f2cbd92e6999c10a8ed8edb253836e9c/scipy-1.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6ac6310fdbfb7aa6612408bd2f07295bcbd3fda00d2d702178434751fe48e019", size = 38766735, upload-time = "2025-05-08T16:06:06.471Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/f30be3d03de07f25dc0ec926d1681fed5c732d759ac8f51079708c79e680/scipy-1.15.3-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:185cd3d6d05ca4b44a8f1595af87f9c372bb6acf9c808e99aa3e9aa03bd98cf6", size = 30173284, upload-time = "2025-05-08T16:06:11.686Z" }, + { url = "https://files.pythonhosted.org/packages/07/9c/0ddb0d0abdabe0d181c1793db51f02cd59e4901da6f9f7848e1f96759f0d/scipy-1.15.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:05dc6abcd105e1a29f95eada46d4a3f251743cfd7d3ae8ddb4088047f24ea477", size = 22446958, upload-time = "2025-05-08T16:06:15.97Z" }, + { url = "https://files.pythonhosted.org/packages/af/43/0bce905a965f36c58ff80d8bea33f1f9351b05fad4beaad4eae34699b7a1/scipy-1.15.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:06efcba926324df1696931a57a176c80848ccd67ce6ad020c810736bfd58eb1c", size = 25242454, upload-time = "2025-05-08T16:06:20.394Z" }, + { url = "https://files.pythonhosted.org/packages/56/30/a6f08f84ee5b7b28b4c597aca4cbe545535c39fe911845a96414700b64ba/scipy-1.15.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c05045d8b9bfd807ee1b9f38761993297b10b245f012b11b13b91ba8945f7e45", size = 35210199, upload-time = "2025-05-08T16:06:26.159Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1f/03f52c282437a168ee2c7c14a1a0d0781a9a4a8962d84ac05c06b4c5b555/scipy-1.15.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:271e3713e645149ea5ea3e97b57fdab61ce61333f97cfae392c28ba786f9bb49", size = 37309455, upload-time = "2025-05-08T16:06:32.778Z" }, + { url = "https://files.pythonhosted.org/packages/89/b1/fbb53137f42c4bf630b1ffdfc2151a62d1d1b903b249f030d2b1c0280af8/scipy-1.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6cfd56fc1a8e53f6e89ba3a7a7251f7396412d655bca2aa5611c8ec9a6784a1e", size = 36885140, upload-time = "2025-05-08T16:06:39.249Z" }, + { url = "https://files.pythonhosted.org/packages/2e/2e/025e39e339f5090df1ff266d021892694dbb7e63568edcfe43f892fa381d/scipy-1.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0ff17c0bb1cb32952c09217d8d1eed9b53d1463e5f1dd6052c7857f83127d539", size = 39710549, upload-time = "2025-05-08T16:06:45.729Z" }, + { url = "https://files.pythonhosted.org/packages/e6/eb/3bf6ea8ab7f1503dca3a10df2e4b9c3f6b3316df07f6c0ded94b281c7101/scipy-1.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:52092bc0472cfd17df49ff17e70624345efece4e1a12b23783a1ac59a1b728ed", size = 40966184, upload-time = "2025-05-08T16:06:52.623Z" }, + { url = "https://files.pythonhosted.org/packages/73/18/ec27848c9baae6e0d6573eda6e01a602e5649ee72c27c3a8aad673ebecfd/scipy-1.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:2c620736bcc334782e24d173c0fdbb7590a0a436d2fdf39310a8902505008759", size = 38728256, upload-time = "2025-05-08T16:06:58.696Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/1aef2184948728b4b6e21267d53b3339762c285a46a274ebb7863c9e4742/scipy-1.15.3-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:7e11270a000969409d37ed399585ee530b9ef6aa99d50c019de4cb01e8e54e62", size = 30109540, upload-time = "2025-05-08T16:07:04.209Z" }, + { url = "https://files.pythonhosted.org/packages/5b/d8/59e452c0a255ec352bd0a833537a3bc1bfb679944c4938ab375b0a6b3a3e/scipy-1.15.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8c9ed3ba2c8a2ce098163a9bdb26f891746d02136995df25227a20e71c396ebb", size = 22383115, upload-time = "2025-05-08T16:07:08.998Z" }, + { url = "https://files.pythonhosted.org/packages/08/f5/456f56bbbfccf696263b47095291040655e3cbaf05d063bdc7c7517f32ac/scipy-1.15.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:0bdd905264c0c9cfa74a4772cdb2070171790381a5c4d312c973382fc6eaf730", size = 25163884, upload-time = "2025-05-08T16:07:14.091Z" }, + { url = "https://files.pythonhosted.org/packages/a2/66/a9618b6a435a0f0c0b8a6d0a2efb32d4ec5a85f023c2b79d39512040355b/scipy-1.15.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:79167bba085c31f38603e11a267d862957cbb3ce018d8b38f79ac043bc92d825", size = 35174018, upload-time = "2025-05-08T16:07:19.427Z" }, + { url = "https://files.pythonhosted.org/packages/b5/09/c5b6734a50ad4882432b6bb7c02baf757f5b2f256041da5df242e2d7e6b6/scipy-1.15.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c9deabd6d547aee2c9a81dee6cc96c6d7e9a9b1953f74850c179f91fdc729cb7", size = 37269716, upload-time = "2025-05-08T16:07:25.712Z" }, + { url = "https://files.pythonhosted.org/packages/77/0a/eac00ff741f23bcabd352731ed9b8995a0a60ef57f5fd788d611d43d69a1/scipy-1.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:dde4fc32993071ac0c7dd2d82569e544f0bdaff66269cb475e0f369adad13f11", size = 36872342, upload-time = "2025-05-08T16:07:31.468Z" }, + { url = "https://files.pythonhosted.org/packages/fe/54/4379be86dd74b6ad81551689107360d9a3e18f24d20767a2d5b9253a3f0a/scipy-1.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f77f853d584e72e874d87357ad70f44b437331507d1c311457bed8ed2b956126", size = 39670869, upload-time = "2025-05-08T16:07:38.002Z" }, + { url = "https://files.pythonhosted.org/packages/87/2e/892ad2862ba54f084ffe8cc4a22667eaf9c2bcec6d2bff1d15713c6c0703/scipy-1.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:b90ab29d0c37ec9bf55424c064312930ca5f4bde15ee8619ee44e69319aab163", size = 40988851, upload-time = "2025-05-08T16:08:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e9/7a879c137f7e55b30d75d90ce3eb468197646bc7b443ac036ae3fe109055/scipy-1.15.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3ac07623267feb3ae308487c260ac684b32ea35fd81e12845039952f558047b8", size = 38863011, upload-time = "2025-05-08T16:07:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/51/d1/226a806bbd69f62ce5ef5f3ffadc35286e9fbc802f606a07eb83bf2359de/scipy-1.15.3-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6487aa99c2a3d509a5227d9a5e889ff05830a06b2ce08ec30df6d79db5fcd5c5", size = 30266407, upload-time = "2025-05-08T16:07:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9b/f32d1d6093ab9eeabbd839b0f7619c62e46cc4b7b6dbf05b6e615bbd4400/scipy-1.15.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:50f9e62461c95d933d5c5ef4a1f2ebf9a2b4e83b0db374cb3f1de104d935922e", size = 22540030, upload-time = "2025-05-08T16:07:54.121Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/c278f699b095c1a884f29fda126340fcc201461ee8bfea5c8bdb1c7c958b/scipy-1.15.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:14ed70039d182f411ffc74789a16df3835e05dc469b898233a245cdfd7f162cb", size = 25218709, upload-time = "2025-05-08T16:07:58.506Z" }, + { url = "https://files.pythonhosted.org/packages/24/18/9e5374b617aba742a990581373cd6b68a2945d65cc588482749ef2e64467/scipy-1.15.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a769105537aa07a69468a0eefcd121be52006db61cdd8cac8a0e68980bbb723", size = 34809045, upload-time = "2025-05-08T16:08:03.929Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/9c4361e7ba2927074360856db6135ef4904d505e9b3afbbcb073c4008328/scipy-1.15.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db984639887e3dffb3928d118145ffe40eff2fa40cb241a306ec57c219ebbbb", size = 36703062, upload-time = "2025-05-08T16:08:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8e/038ccfe29d272b30086b25a4960f757f97122cb2ec42e62b460d02fe98e9/scipy-1.15.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:40e54d5c7e7ebf1aa596c374c49fa3135f04648a0caabcb66c52884b943f02b4", size = 36393132, upload-time = "2025-05-08T16:08:15.34Z" }, + { url = "https://files.pythonhosted.org/packages/10/7e/5c12285452970be5bdbe8352c619250b97ebf7917d7a9a9e96b8a8140f17/scipy-1.15.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:5e721fed53187e71d0ccf382b6bf977644c533e506c4d33c3fb24de89f5c3ed5", size = 38979503, upload-time = "2025-05-08T16:08:21.513Z" }, + { url = "https://files.pythonhosted.org/packages/81/06/0a5e5349474e1cbc5757975b21bd4fad0e72ebf138c5592f191646154e06/scipy-1.15.3-cp313-cp313t-win_amd64.whl", hash = "sha256:76ad1fb5f8752eabf0fa02e4cc0336b4e8f021e2d5f061ed37d6d264db35e3ca", size = 40308097, upload-time = "2025-05-08T16:08:27.627Z" }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version == '3.11.*'", +] +dependencies = [ + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" }, +] + +[[package]] +name = "scipy" +version = "1.18.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12'", +] +dependencies = [ + { name = "numpy", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a7/25/c2700dfaf6442b4effaa91af24ebce5dc9d31bb4a69706313aae70d72cd0/scipy-1.18.0.tar.gz", hash = "sha256:67b2ad2ad54c72ca6d04975a9b2df8c3638c34ddd5b28738e94fc2b57929d378", size = 30774447, upload-time = "2026-06-19T15:01:43.456Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/19/ca10ead60b0acc80b2b833c2c4a4f2ff753d0f58b811f70d911c7e94a25c/scipy-1.18.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:7bd21faaf5a1a3b2eff922d02db5f191b99a6518db9078a8fb23169f6d22259a", size = 31056519, upload-time = "2026-06-19T14:59:45.203Z" }, + { url = "https://files.pythonhosted.org/packages/96/72/1e6442a00cd2924d361aa1b642ab6373ec35c6fabf311a760be9f76e0f13/scipy-1.18.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:265915e79107de9f946b855e50d7470d5893ec3f54b342e1aa6201cbdcd8bb6b", size = 28681889, upload-time = "2026-06-19T14:59:48.103Z" }, + { url = "https://files.pythonhosted.org/packages/9b/2d/11dd93d21e147a73ba22bd75c0b9208d3a2e0ec76d53170ce7d9029b1015/scipy-1.18.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9ab7b758be6940954a713ee466e2043e9f6e2ed965c1fce5c91039f4be3d90a9", size = 20423580, upload-time = "2026-06-19T14:59:50.665Z" }, + { url = "https://files.pythonhosted.org/packages/9c/01/93552f75e0d2a7dd115a45e59209c51e8d514daff02fc887d2623be06fe1/scipy-1.18.0-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:97b6cddaaee0a779ef6b5ca83c9604b27cc16b2b8fc22c142652df8793319fb8", size = 23054441, upload-time = "2026-06-19T14:59:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/3c/23/21f5e703643d66f21faa6b4c73195bfcad70c55efcb4f1ab327cd7c4101a/scipy-1.18.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:52a96e21517c7292375c0e27dd796a811f03fcea5fd4d108fdfea8145dcf17ab", size = 33968720, upload-time = "2026-06-19T14:59:56.415Z" }, + { url = "https://files.pythonhosted.org/packages/dd/aa/1b939f6c67ed68635bb538e6752d3dacc02f66535182e939a89581a44e9c/scipy-1.18.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f55797419e16e7f30cf88ffb3113ce0467f00cfe3f70d5c281730b21769bfc2", size = 35287115, upload-time = "2026-06-19T14:59:59.411Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ff/eec46be7e9234208f801062b53e1983085eddebd693f6c9bfb03b459830d/scipy-1.18.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ad033410e2e0672ffdc1042110cef20e1c46f8fd0616cee1d44d8d58fad8fc11", size = 35577989, upload-time = "2026-06-19T15:00:02.235Z" }, + { url = "https://files.pythonhosted.org/packages/84/ca/210d4759c7210bb7d269437421959b39a33434e2776b60c5cb8a763bb30a/scipy-1.18.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4a55985d54c769c872e64b7f4c8a81cc30ef700cc04296abbbf3705439c126de", size = 37421717, upload-time = "2026-06-19T15:00:05.102Z" }, + { url = "https://files.pythonhosted.org/packages/2b/54/9a9edb45345bd6744da5ddfb6628e5d5185920494c6a67ec45b6381004cb/scipy-1.18.0-cp312-cp312-win_amd64.whl", hash = "sha256:71ccc8faa2dd16ac310233203474a8b5cb67f10dedd54a3116d34943f4b19132", size = 36597428, upload-time = "2026-06-19T15:00:08.112Z" }, + { url = "https://files.pythonhosted.org/packages/99/0e/33f32a2a58987e26aec0f7df252cbbad1e90ae77bdbc76f40dd4ed0cf0ea/scipy-1.18.0-cp312-cp312-win_arm64.whl", hash = "sha256:d88363fd9d8fbd3511bd273f1a49efb2a540773ddf92a91d57498ce7dd7f3e76", size = 24351481, upload-time = "2026-06-19T15:00:11.103Z" }, + { url = "https://files.pythonhosted.org/packages/05/52/9c0136c2de7ae0779b7b366447766cec6d9f0702c56bb8ffeb04c8fd3af4/scipy-1.18.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:09143f676d157d9f546d663504ef9c1becb819824f1afc018814176411942446", size = 31036107, upload-time = "2026-06-19T15:00:14.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/73/0291a64843270f4efb86cdcf2ee0f2048631b65ec6b405398b2b4dbf11bf/scipy-1.18.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5efe260f69417b97ddae455bfb5a95e8359f7f66ad7fa9522a60feb66f169520", size = 28663303, upload-time = "2026-06-19T15:00:16.819Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0f/10ffa0b697a572f4e0d48b92a88895d366422f019f723e7e14a84c050dac/scipy-1.18.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:68363b7eaacd8b5dd426df56d782cc156468ac79a127a1b87ca597d6e2e82197", size = 20404960, upload-time = "2026-06-19T15:00:19.635Z" }, + { url = "https://files.pythonhosted.org/packages/7e/d2/e896cea21ba8edd6c81d4c55b1ffcc717e79698dcbebf9641b4cfb4c6622/scipy-1.18.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:c5557d8be5da8e41353fcd4d21491fdbab83b062fc579e94dc09a7c8ab4f669b", size = 23034074, upload-time = "2026-06-19T15:00:22.107Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b2/e83ea34279a52c03374477c74006256ec78df65fc877baa4617d6de1d202/scipy-1.18.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0d13bca67c096d89fb95ced0d8921807300fce0275643aef9533cc63a0773468", size = 33942038, upload-time = "2026-06-19T15:00:24.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/af/e8fe5fb136f51e2b01678b92cb4106d10d8cd68ec147ead2e7cb0ac75398/scipy-1.18.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a46f9273dbd0eb1cefba61c9b8648b4dfe3cbc14a080176f9a73e44b8336dc7f", size = 35266390, upload-time = "2026-06-19T15:00:28.059Z" }, + { url = "https://files.pythonhosted.org/packages/3a/49/2c5cbb907b56695fc67517811d1db234dfd83381a84814ec220aded2794d/scipy-1.18.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5aba46108853ddfc77906b6557aac839d2b52e900c1d72a1180adaaab58d265f", size = 35551324, upload-time = "2026-06-19T15:00:31.014Z" }, + { url = "https://files.pythonhosted.org/packages/bb/73/eda39f7a2d306ff0ffc574afd13c0bbb6d10a603d9a413998ee269487a80/scipy-1.18.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6f758e35f12757b5d95c00bc6de2438e229c2664b7a92e96f205959d9f2dfa4", size = 37404785, upload-time = "2026-06-19T15:00:34.072Z" }, + { url = "https://files.pythonhosted.org/packages/b7/d2/ae881ee28d014f38e0ccbfd974a06a919ba9af34f1f74bf42b5301891d63/scipy-1.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:1afac4a847207c7ff8efd321734a50b06d0280b3b2a2c0fc2f413101747ad7c7", size = 36554943, upload-time = "2026-06-19T15:00:36.903Z" }, + { url = "https://files.pythonhosted.org/packages/70/3a/21154e2d54eb3639c6bf4dbae2e531c68356bfe95990daa30df33b30d556/scipy-1.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:c5dbddf60e58c2312316d097271a8e73d40eaf2eabfa4d95ed7d3695bbf2ce7b", size = 24350911, upload-time = "2026-06-19T15:00:40.062Z" }, + { url = "https://files.pythonhosted.org/packages/78/b5/915a19b3de2f7430062b509653563db1633ddbb6f021b06731521115d4e2/scipy-1.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4c256ee70c0d1a8a2ace807e199ccd4e3f57037433842abb3fb36bc17eaa9578", size = 31036253, upload-time = "2026-06-19T15:00:43.216Z" }, + { url = "https://files.pythonhosted.org/packages/d7/88/b72def7262e150d16be13fca37a96481138d624e700340bc3362a7588929/scipy-1.18.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:2ef3abc54a4ffc53765374b0d5728532dfdd2585ed23f6b11c206a1f0b1b9af8", size = 28673758, upload-time = "2026-06-19T15:00:46.663Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/2e636a61a525632c373cf6a9c24442a3ffb79e364d38e98b32042964ac32/scipy-1.18.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:f2a6af57bd9e4a75d70e4117e78a1bbee84f79ae3fbb6d0111005d6ebcc4cb8d", size = 20415514, upload-time = "2026-06-19T15:00:49.399Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/2135974442f6aba159d9d39d774a1c8cb19947016725d69fecc685df45bf/scipy-1.18.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:3f1ac564d3bf6c03d861d2cd87a1bea0da2887136f7fb1bf519c05a8971452d6", size = 23034398, upload-time = "2026-06-19T15:00:51.941Z" }, + { url = "https://files.pythonhosted.org/packages/f6/e6/ba89ec5abf6ee9257c0d1ec985573f3ae32742c24bc03e016388a40b1b15/scipy-1.18.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40395a5fcd1abee49a5c7aaa98c29db393eedc835138560a588c47ec16156690", size = 33998032, upload-time = "2026-06-19T15:00:54.838Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c4/bc41eb19b0fd0db868f4132920879019318d80cc522ad8f2bca4611af808/scipy-1.18.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ca01e8ae69f1b18e9a58d91afead31be3cef0dd905a10249dac559ee15460a0", size = 35283333, upload-time = "2026-06-19T15:00:58.152Z" }, + { url = "https://files.pythonhosted.org/packages/53/a4/cbdeef6eb3830a8462a9d4ada814de5fc984345cc9ecf17cbec51a036f1e/scipy-1.18.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7a7f3b01647384dbc3a711e8c6778e0aabbe93959249fef5c7393396bcac0867", size = 35610216, upload-time = "2026-06-19T15:01:01.155Z" }, + { url = "https://files.pythonhosted.org/packages/80/4d/b2b82502b65f661d1b789c1665dcdf315d5f12194e06fc0b37946294ebae/scipy-1.18.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6aa94e78ec192a30063a5e72e561c28af769dc311190b24fe91774eff1969709", size = 37418960, upload-time = "2026-06-19T15:01:04.155Z" }, + { url = "https://files.pythonhosted.org/packages/93/3e/902d836831474b0ab5a37d16404f7bc5fafd9efba632890e271ba952635f/scipy-1.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:2d8bbdc6c817f5b4006a54d799d4f5bab6f910193cbb9a1ff310833d4d270f61", size = 37288845, upload-time = "2026-06-19T15:01:07.822Z" }, + { url = "https://files.pythonhosted.org/packages/b6/43/8d73b337a3bdb14daa0314f0434210747c02d79d729ce1777574a817dcf6/scipy-1.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:18e9575f1569b2c54174e6159d32942e03731177f63dce7975f0a0c88d102f5b", size = 24988971, upload-time = "2026-06-19T15:01:11.076Z" }, + { url = "https://files.pythonhosted.org/packages/b4/b4/f11918b0508a2787031a0499a03fbe3546f3bb5ca05d01038c45b278c09a/scipy-1.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f351e0dd702687d12a402b867a1b4146a256923e1c38317cbc472f6372b94707", size = 31399325, upload-time = "2026-06-19T15:01:13.723Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d1/1f287b57c0ff0ee5185dff3946d92c8017d39b0e431f0ae79a3ff1859512/scipy-1.18.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:7c7a51b33ce387193c97f228320cf8e87361daa1bba750638677729598b3e677", size = 29092110, upload-time = "2026-06-19T15:01:16.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1a/7b74eb6c392fdcb27d414c0e7558a6d0231eb3b6d73571f479bb81ea8794/scipy-1.18.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:84031d7b052a54fae2f8632e0ec802073d385476eb9a63079bce6e23ef9283d4", size = 20833811, upload-time = "2026-06-19T15:01:20.488Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ad/f3941716320a7b9cb4d68734a903b45fe16eff5fb7da7e16f2e619304979/scipy-1.18.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:56abf29a7c067dde59be8b9a22d606a4ea1b2f2a4b756d9d903c62818f5dacce", size = 23396644, upload-time = "2026-06-19T15:01:23.364Z" }, + { url = "https://files.pythonhosted.org/packages/22/22/1446b62ffe07f9719b7d9b1b6a4e05a772833ae8f441fe4c22c34c9b250f/scipy-1.18.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ad44305cfa24b1ba5803cbbebf033590ccbac1aa5d612d727b785325ab408b0", size = 34079318, upload-time = "2026-06-19T15:01:26.002Z" }, + { url = "https://files.pythonhosted.org/packages/56/3b/b87da667098bb470fa30c7011b0ba351ee976dd395c78798c66e941665a3/scipy-1.18.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:945c1761b93f38d7f99ae81ae80c63e621471608c7eeead563f6df025585cd58", size = 35324320, upload-time = "2026-06-19T15:01:28.881Z" }, + { url = "https://files.pythonhosted.org/packages/f8/a1/c7932f91909759b0267f75fdea34e91309f96b895757534b76a90b6b4344/scipy-1.18.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1a4441f15d620578772a49e5ab48c0ee1f7a0220e387110283062729136b2553", size = 35699541, upload-time = "2026-06-19T15:01:31.968Z" }, + { url = "https://files.pythonhosted.org/packages/f7/86/5185061a1fcc41d18c5dc2463969b3a3964b31d9ac67b2fb05d4c7ff7670/scipy-1.18.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9aac6192fac56bf2ca534389d24623f07b39ff83317d58287285e7fbd622ff76", size = 37472480, upload-time = "2026-06-19T15:01:35.136Z" }, + { url = "https://files.pythonhosted.org/packages/31/8e/f04c68e39919a010d34f2ee1367fd705b0a25a02f609d755f0bfbc0a15fc/scipy-1.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e40baea28ae7f5475c779741e2d90b1247c78531207b49c7030e698ff81cee3f", size = 37365390, upload-time = "2026-06-19T15:01:38.091Z" }, + { url = "https://files.pythonhosted.org/packages/d5/19/969dc072906c84dd0a3b05dcf57ea750936087d7873549e408b35cfc3f97/scipy-1.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:368e0a705903c466aa5f08eefb39e6b1b6b2d659e7352a31fd9e2438365be0f8", size = 25279661, upload-time = "2026-06-19T15:01:40.817Z" }, +] + [[package]] name = "selene-core" version = "0.2.2" @@ -938,16 +1192,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" }, ] +[[package]] +name = "setuptools" +version = "82.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/db/cfac1baf10650ab4d1c111714410d2fbb77ac5a616db26775db562c8fab2/setuptools-82.0.1.tar.gz", hash = "sha256:7d872682c5d01cfde07da7bccc7b65469d3dca203318515ada1de5eda35efbf9", size = 1152316, upload-time = "2026-03-09T12:47:17.221Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, +] + +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, +] + +[[package]] +name = "tket" +version = "0.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hugr" }, + { name = "tket-eccs" }, + { name = "tket-exts" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/b0/b6f4659447c521c3b7fe066efa6b3204d2eb1129de8b31fb0ed2218d5596/tket-0.13.1.tar.gz", hash = "sha256:aaf21432939923962362b8783be8190e7f493ae5db3b783fdb93abd352d39ec4", size = 605360, upload-time = "2026-05-19T13:31:30.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/d0/3c9a0d7152d3cf01efd76cf32ae92b79dca7acae14a44b64845ddeb6b6c2/tket-0.13.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2c64dc53ea903350491927260cfdaf8d77dd1400c786be9b83305b4b9dee2e96", size = 10441428, upload-time = "2026-05-19T13:31:18.158Z" }, + { url = "https://files.pythonhosted.org/packages/96/41/c00db203f438f14fb892a98387cc476e7c506ad7bff26a1756a53a07f173/tket-0.13.1-cp310-abi3-macosx_11_0_x86_64.whl", hash = "sha256:bcfcbc0f646f5f8c16e943819a1010aaaf312c79642ea6e737ad7b9559603f8b", size = 11378534, upload-time = "2026-05-19T13:31:20.779Z" }, + { url = "https://files.pythonhosted.org/packages/33/b0/90894ebb09b8eeda60da8205e74d892183007c93433e9ffab052a365abb7/tket-0.13.1-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d53963635634af92670537fa431450a58e36b79ffcc7d8c7b0731150443ed2ea", size = 12555543, upload-time = "2026-05-19T13:31:23.191Z" }, + { url = "https://files.pythonhosted.org/packages/bd/70/68edcddf60219455933938502377a5e224873ccdfdc9e2ed6381b01fb690/tket-0.13.1-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:172fd7b159129ff46652474e85bcceae2eabcf854a712ba465c25818964dc0ed", size = 13628232, upload-time = "2026-05-19T13:31:25.617Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f0/416bc9d59675a1330b2d08d2f975e40cf4a3d70de39776b4e7a99506e71c/tket-0.13.1-cp310-abi3-win_amd64.whl", hash = "sha256:7b8c16dbe4af5dbc5a4381c9d17cbba4b103fe60254273f1eec2de08b70736be", size = 10218380, upload-time = "2026-05-19T13:31:28.394Z" }, +] + +[package.optional-dependencies] +pytket = [ + { name = "pytket" }, +] + +[[package]] +name = "tket-eccs" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bb/35/0433336cbe94775e79d1e27823e95f05c697be0010c4c06252bc562cabc4/tket_eccs-0.5.1.tar.gz", hash = "sha256:7e3118569c64f29bab09b9c2dbf20b769ceb1db80ca9b2cec59dfae09023e7d2", size = 7615434, upload-time = "2025-08-19T16:55:12.648Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/56/ef17291924c26e02ce19d062d4698234f499c156a7ed670a0ec556a37d96/tket_eccs-0.5.1-py3-none-any.whl", hash = "sha256:a28f66fde5fe0f52f286a5aca19b821a3182328596d79c7d26c7ede7a4659537", size = 7616506, upload-time = "2025-08-19T16:55:10.576Z" }, +] + [[package]] name = "tket-exts" -version = "0.12.1" +version = "0.12.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/c7/62e7b82d86251bcb6fa455e0707aec038a2141983d9e4974693dc6de470f/tket_exts-0.12.1.tar.gz", hash = "sha256:df2720b97fc9aa16142bdc2724aef66dc69db852a18c2d9df89e48c9b9dcbcd2", size = 21222, upload-time = "2026-01-06T14:18:11.52Z" } +sdist = { url = "https://files.pythonhosted.org/packages/10/68/036b38ded9ad6ef8aedb93817127af0b5535e52eeb68ef8f19b368d0e035/tket_exts-0.12.3.tar.gz", hash = "sha256:de9faa87e35a7cc2250ab1022a026f129d05dc7bf41ad4fd86914d5a9c81541e", size = 21797, upload-time = "2026-04-07T15:26:51.422Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0e/c2/d57e12b14e2a466c548f21e9b8a91266008a49cb5faa248cb51d1e89251c/tket_exts-0.12.1-py3-none-any.whl", hash = "sha256:8f58308b1f25423f3cf933fa6087800cc1fa5f7919bf26a20cfbebfb82c0786b", size = 34092, upload-time = "2026-01-06T14:18:10.153Z" }, + { url = "https://files.pythonhosted.org/packages/77/df/ab123f36707d0fb4deaa96c48a992b90f574348f79aefb94c120dd695f1b/tket_exts-0.12.3-py3-none-any.whl", hash = "sha256:34b03fa69160606bb7ef86735671aa8e7a70149947d630c48a7ebf2f61c49417", size = 34309, upload-time = "2026-04-07T15:26:49.84Z" }, ] [[package]] @@ -1063,11 +1370,11 @@ wheels = [ [[package]] name = "urllib3" -version = "2.6.3" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[package]] @@ -1132,9 +1439,9 @@ test = [ dev = [ { name = "patchelf", marker = "sys_platform != 'win32'" }, { name = "pytest", specifier = ">=9.0.2" }, - { name = "quantum-pecos", editable = "../quantum-pecos" }, + { name = "quantum-pecos", editable = "../../python/quantum-pecos" }, ] test = [ { name = "pytest", specifier = ">=7.0" }, - { name = "quantum-pecos", editable = "../quantum-pecos" }, + { name = "quantum-pecos", editable = "../../python/quantum-pecos" }, ] diff --git a/osv-scanner.toml b/osv-scanner.toml index 0a2685200..2b7b40a28 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -72,3 +72,45 @@ id = "RUSTSEC-2022-0104" # Chain: pecos-mwpf -> mwpf -> slp -> structopt 0.3. # Upstream owner: https://github.com/yuewuo/mwpf. reason = "Transitive via mwpf -> slp -> structopt; maintenance-mode warning for upstream CLI parser, no direct PECOS use." + +[[IgnoredVulns]] +id = "PYSEC-2026-151" +# wasmtime (PyPI) 42.0.0 (uv.lock) / 38.0.0 (exp/zluppy/uv.lock) -- Wasmtime +# stack-overflow bug class; the advisory lists NO fixed release (last_affected +# 43.0.0, the latest). +# Chain: quantum-pecos -> guppylang -> guppylang-internals -> wasmtime. +# Upstream owner: https://github.com/bytecodealliance/wasmtime-py. +reason = "Transitive via guppylang; no fixed wasmtime-py release exists yet. PECOS only runs trusted, locally-generated Wasm through it. Re-check on guppylang bumps." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0075" +# unic-char-range@0.9.0 -- unmaintained (whole unic project dormant). +# Chain: guppy-zlup -> rustpython-parser 0.4 -> unic-*. +# Upstream owner: https://github.com/RustPython/Parser (itself archived; the +# long-term fix is guppy-zlup moving off rustpython-parser). +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0080" +# unic-common@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0081" +# unic-char-property@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0090" +# unic-emoji-char@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0098" +# unic-ucd-version@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." + +[[IgnoredVulns]] +id = "RUSTSEC-2025-0100" +# unic-ucd-ident@0.9.0 -- unmaintained. Same chain/owner as RUSTSEC-2025-0075. +reason = "Transitive via rustpython-parser in the experimental zlup frontend; unmaintained-crate warning, parser-internal Unicode tables, no exploit path." diff --git a/uv.lock b/uv.lock index 02d8404d7..0e8d48345 100644 --- a/uv.lock +++ b/uv.lock @@ -239,14 +239,14 @@ wheels = [ [[package]] name = "bleach" -version = "6.3.0" +version = "6.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "webencodings" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/07/18/3c8523962314be6bf4c8989c79ad9531c825210dd13a8669f6b84336e8bd/bleach-6.3.0.tar.gz", hash = "sha256:6f3b91b1c0a02bb9a78b5a454c92506aa0fdf197e1d5e114d2e00c6f64306d22", size = 203533, upload-time = "2025-10-27T17:57:39.211Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/3c/e12ac860709702bd5ebeb9b56a4fe334f1001246ee1b8f2b7ee28912df7d/bleach-6.4.0.tar.gz", hash = "sha256:4202482733d85cedd04e59fcb2f89f4e4c7c385a78d3c3c23c30446843a37452", size = 204857, upload-time = "2026-06-05T13:01:13.734Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cd/3a/577b549de0cc09d95f11087ee63c739bba856cd3952697eec4c4bb91350a/bleach-6.3.0-py3-none-any.whl", hash = "sha256:fe10ec77c93ddf3d13a73b035abaac7a9f5e436513864ccdad516693213c65d6", size = 164437, upload-time = "2025-10-27T17:57:37.538Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/40b6267367182187139a4000b82a3b287d84d745bccd808e75d916920e9d/bleach-6.4.0-py3-none-any.whl", hash = "sha256:4b6b6a54fff2e69a3dde9d21cc6301220bee3c3cb792187d11403fd795031081", size = 165109, upload-time = "2026-06-05T13:01:12.504Z" }, ] [package.optional-dependencies] @@ -1679,6 +1679,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/64/285f20a31679bf547b75602702f7800e74dbabae36ef324f716c02804753/jupyter-1.1.1-py2.py3-none-any.whl", hash = "sha256:7a59533c22af65439b24bbe60373a4e95af8f16ac65a6c00820ad378e3f7cc83", size = 2657, upload-time = "2024-08-30T07:15:47.045Z" }, ] +[[package]] +name = "jupyter-builder" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jupyter-core" }, + { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/45/d0df8b43c10a61529c0f4a8af5e19ebe108f0c3af8f57e0fc358969907af/jupyter_builder-1.0.2.tar.gz", hash = "sha256:6155d78a5325010532a6419ffcba89eac643fd1aa56ea83115e661924d6f6aab", size = 968638, upload-time = "2026-06-12T02:33:25.767Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/b6/c418e0b3256f67c04933566b80bfce947350682db92c4b786a8653db32d6/jupyter_builder-1.0.2-py3-none-any.whl", hash = "sha256:b024f65d36e1d530542db597b00dd513261aa59842e0d0fbbb1015a9f1935e9c", size = 910789, upload-time = "2026-06-12T02:33:23.317Z" }, +] + [[package]] name = "jupyter-client" version = "8.8.0" @@ -1761,7 +1775,7 @@ wheels = [ [[package]] name = "jupyter-server" -version = "2.19.0" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1784,9 +1798,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/a0/eb3c511f54df7b54ca5fc7bff3f4d2277d69052d6a7f521643dfed5279d6/jupyter_server-2.19.0.tar.gz", hash = "sha256:1731236bc32b680223e1ceb9d68209a845203475012ef68773a81434b46a31a7", size = 754561, upload-time = "2026-05-29T11:21:26.057Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6b/dc/db3a582633170186f8c8b31298d7eb26ad0eb031a1f53476c258b64eed05/jupyter_server-2.20.0.tar.gz", hash = "sha256:b5778ba337d8015a3dc2b80803ecdd5ac18d3797fddf61a50ea5fb472b4ebe14", size = 756523, upload-time = "2026-06-17T12:09:09.435Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c1/78/d2881e68894cecdcd05912a9c585cfb776ef1fb38b62c8dba98f12ab3adc/jupyter_server-2.19.0-py3-none-any.whl", hash = "sha256:cb76591b76d7093584c2ad2ae72ac3d58614a4b597507a1bb04e1f9f683cf9ea", size = 392244, upload-time = "2026-05-29T11:21:23.871Z" }, + { url = "https://files.pythonhosted.org/packages/f3/71/8c002223e873a870f5c41dc69b0a7c922301123e4a31d5d01ecb700aef77/jupyter_server-2.20.0-py3-none-any.whl", hash = "sha256:c3b67c93c471e947c18b5026f04f21614218adb706df8f48227d3ee8e0a7cdcc", size = 393143, upload-time = "2026-06-17T12:09:07.234Z" }, ] [[package]] @@ -1804,27 +1818,27 @@ wheels = [ [[package]] name = "jupyterlab" -version = "4.5.7" +version = "4.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "async-lru" }, { name = "httpx" }, { name = "ipykernel" }, { name = "jinja2" }, + { name = "jupyter-builder" }, { name = "jupyter-core" }, { name = "jupyter-lsp" }, { name = "jupyter-server" }, { name = "jupyterlab-server" }, { name = "notebook-shim" }, { name = "packaging" }, - { name = "setuptools" }, { name = "tomli", marker = "python_full_version < '3.11' or (extra == 'extra-13-quantum-pecos-cuda12' and extra == 'extra-13-quantum-pecos-cuda13') or (extra == 'extra-15-pecos-workspace-cuda12' and extra == 'extra-15-pecos-workspace-cuda13') or (extra == 'group-15-pecos-workspace-cuda12' and extra == 'group-15-pecos-workspace-cuda13')" }, { name = "tornado" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/22/8440ec827762146e7cdecf04335bd348795899d29dc6ae82238707353a2c/jupyterlab-4.5.7.tar.gz", hash = "sha256:55a9822c4754da305f41e113452c68383e214dcf96de760146af89ce5d5117b0", size = 23992763, upload-time = "2026-04-29T16:43:51.328Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bc/2a/d6af53bfd45a43a5bfe7e40ba47ee7a8921a807daf4bb708e3a295bbb54d/jupyterlab-4.6.1.tar.gz", hash = "sha256:75315982ed28427edaa62bb85eadb5105e4043a757643c910efd787fe6ed0837", size = 28179125, upload-time = "2026-06-29T12:48:45.402Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/aa/537b8f7d80e799af19af35fb3ddfc970b951088a13c57dd9387dcfbb7f61/jupyterlab-4.5.7-py3-none-any.whl", hash = "sha256:fba4cb0e2c44a52859669d8c98b45de029d5e515f8407bf8534d2a8fc5f0964d", size = 12450123, upload-time = "2026-04-29T16:43:46.639Z" }, + { url = "https://files.pythonhosted.org/packages/5a/81/90ac6cc31d248e83a0d1eab343a5e6e68bc783d3f74fbe61640f42a61da4/jupyterlab-4.6.1-py3-none-any.whl", hash = "sha256:85a58546c831f3dce6cf919468c26874c9065e99c42279fb4abb8e1b552a98bb", size = 17164660, upload-time = "2026-06-29T12:48:41.21Z" }, ] [[package]] @@ -2633,18 +2647,19 @@ wheels = [ [[package]] name = "notebook" -version = "7.5.6" +version = "7.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "jupyter-builder" }, { name = "jupyter-server" }, { name = "jupyterlab" }, { name = "jupyterlab-server" }, { name = "notebook-shim" }, { name = "tornado" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/c2/cf59bd2e6f2c8b976b52477e3e53bf6f97bc714ed046a51821afb428eaee/notebook-7.5.6.tar.gz", hash = "sha256:621174aade80108f0020b0f00738000b215f75fa3cd90771ad7aa0f24536a4e1", size = 14170814, upload-time = "2026-04-30T11:46:26.613Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/44/d5c65783f490298473bb1c05722e05ee2256231389559c2c5ae0a3e5d975/notebook-7.6.0.tar.gz", hash = "sha256:ea13e79e601bf273074895fdfb17dd3f2da916d3c045e0b9c47d18b16ab62481", size = 5497344, upload-time = "2026-06-18T16:18:55.202Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/d6/1fd0646b9bbd9efbb0b8ae21b2325fbef515769a5621c03e31d8eb8da587/notebook-7.5.6-py3-none-any.whl", hash = "sha256:4dde3f8fb55fa8fb7946d58c6e869ce9baf46d00fc070664f62604569d0faca0", size = 14581730, upload-time = "2026-04-30T11:46:22.342Z" }, + { url = "https://files.pythonhosted.org/packages/93/d1/e617c40db57ff40e75f43a7d4d1c305e3a54c053ab5cb0534a6c314664f9/notebook-7.6.0-py3-none-any.whl", hash = "sha256:98aa2811b54ac191321d5dfce12ca700f8a511a33a26e4de2fa106a357c43d6a", size = 5544575, upload-time = "2026-06-18T16:18:52.551Z" }, ] [[package]] @@ -4912,19 +4927,19 @@ wheels = [ [[package]] name = "tornado" -version = "6.5.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/50/57/6d7303a77ae439d9189108f76c0c4fd89ee5e2cc8387bffb55232565c4ed/tornado-6.5.6.tar.gz", hash = "sha256:9a365179fe8ff6b8766f602c0f67c185d778193e9bdd828b19f0b6ed7764177d", size = 518139, upload-time = "2026-05-27T15:35:54.646Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/0d/b4f481e18c5a51864e6d12b9a05ecf72919696680b747c958c3fc1f4fbae/tornado-6.5.6-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:65fcfaafb079435c2c19dc9e07c0f1cf0fa9051759ed0a7d0a3ba7ea7f64919c", size = 447737, upload-time = "2026-05-27T15:35:38.122Z" }, - { url = "https://files.pythonhosted.org/packages/9e/9c/5430c39fcab1144d35860f457b15e9c08b4bc7ac86764354204e983d6183/tornado-6.5.6-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:38bc01b4acacded2de63ae78023548e41ebe6fbed3ec05a796d7ae3ad893887e", size = 445899, upload-time = "2026-05-27T15:35:40.519Z" }, - { url = "https://files.pythonhosted.org/packages/8b/79/fa7e14a2f939c807a8d30619b4eb604eab219601b78792516ebe22d40cf9/tornado-6.5.6-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b942e6a137fda31ff54bf8e6e2c8d1c37f1f50583f3ed53fb840b53b9601d104", size = 448964, upload-time = "2026-05-27T15:35:42.106Z" }, - { url = "https://files.pythonhosted.org/packages/a7/71/bd67d5f5199f937dafe03a49a37989f60f600ff6fef34c79412a829d97bd/tornado-6.5.6-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8666946e70171b8c3f1fc9b7876fac492e84822c4c7f3746f4e8f8bc9ac92a79", size = 449935, upload-time = "2026-05-27T15:35:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/cc/a4/c24388c9cf5b3c3a513b56a158af9f23092c9a2810d789e294310797df21/tornado-6.5.6-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1c34cfab7ad6d104f052f55de06d39bbafc5885cfeb4da688803308dbcfa90b7", size = 449767, upload-time = "2026-05-27T15:35:45.793Z" }, - { url = "https://files.pythonhosted.org/packages/a5/eb/6a07ad550c3f7b37244bd0becdf293ec3d3e961783d8b720a97df50de1b2/tornado-6.5.6-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:385f35e4e22fb52551dfcda4cdc8c30c61c2c001aef5ddad99cdfe116952efd3", size = 449174, upload-time = "2026-05-27T15:35:47.485Z" }, - { url = "https://files.pythonhosted.org/packages/bb/84/3469e098dccdb6763130e06aacd786bb4363fca7b590a55c101ddf34ed30/tornado-6.5.6-cp39-abi3-win32.whl", hash = "sha256:db475f1b67b2809b10bb16264829087724ca8d24fe4ed47f7b8675cae453ef86", size = 450230, upload-time = "2026-05-27T15:35:49.322Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3c/273a04e0b9dd9016f1685cca0c1c8795a71ac88a34a8c889a0b443483226/tornado-6.5.6-cp39-abi3-win_amd64.whl", hash = "sha256:6739bf1e8eb09230f1280ddbd3236f0309db70f2c551a8dbc40f62babdf82f79", size = 450667, upload-time = "2026-05-27T15:35:51.194Z" }, - { url = "https://files.pythonhosted.org/packages/02/98/0cffe22a224f60c5fb1e3aa0b76f9da2e1ca78b0e9545e3d077c68ce60a7/tornado-6.5.6-cp39-abi3-win_arm64.whl", hash = "sha256:2543597b24a695d72338a9a77818362d72387c03ae173f1f169eadc5c91466ac", size = 449690, upload-time = "2026-05-27T15:35:52.902Z" }, +version = "6.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/64/24/95ec527ad67b76d59299e5465b3935d05e4294b7e0290a3924b7487df30b/tornado-6.5.7.tar.gz", hash = "sha256:66c513a76cda70d53907bc27cf1447557699c2e95aa48ba27a442ff61c3ddfc2", size = 519252, upload-time = "2026-06-08T17:34:51.232Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/dc/c7043cab6fed8ae159fc1923ce829ada35c4dbd797d408a43858ffaf9639/tornado-6.5.7-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:148b2eb15c2c765a50796172c1e499649b35f30d2e3c3d3e15913cfa56bfb163", size = 448543, upload-time = "2026-06-08T17:34:38.052Z" }, + { url = "https://files.pythonhosted.org/packages/92/4f/090b1431e5a43df696feceffc268c5383cc079ecb5f08ce58f917109aafe/tornado-6.5.7-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:9da38de27f1da3b78a966f0dae12b5a1ea9afe72ca805d84ff06508272ddf100", size = 446707, upload-time = "2026-06-08T17:34:39.594Z" }, + { url = "https://files.pythonhosted.org/packages/37/d8/ef374952fd5da67d4463122c2b8e5a96536ec10b4b339254c6dcde81d01c/tornado-6.5.7-cp39-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8d759e71906ee783f8867b93bf26a265743da4c1e2f4a018464c1ba019862972", size = 449774, upload-time = "2026-06-08T17:34:41.204Z" }, + { url = "https://files.pythonhosted.org/packages/35/37/d434c73f4c6e014b745b9b37085f34f40c022f007efff3d7fe65991899f3/tornado-6.5.7-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a46347a18f23fb92b396beebe0fb78f61dda0cc302445202c16203d8a18848b", size = 450745, upload-time = "2026-06-08T17:34:42.531Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/56b9aff361d7f1ab728a805ec7d7ea835f8807afa9f5cc690ea0e630efb9/tornado-6.5.7-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7778b30bef919231265e91c69963ce0f49a1e9c07ac900bbe75b19ce2575ba92", size = 450578, upload-time = "2026-06-08T17:34:43.787Z" }, + { url = "https://files.pythonhosted.org/packages/02/30/a7444fb23aa76860a14198fab96ac79f1866b0a6e19e26c4381b0938e50f/tornado-6.5.7-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e726f0c75da7726eec023aa62751ff8878bd2737e34fbdd33b1ae5897d2200f5", size = 449985, upload-time = "2026-06-08T17:34:45.326Z" }, + { url = "https://files.pythonhosted.org/packages/5c/42/5f0e56c01e8d9d36f4e23f367b85ae6cae0c1ecddd5e6977d8388ad27488/tornado-6.5.7-cp39-abi3-win32.whl", hash = "sha256:f8de3bf12d3efdd0cbe7c8887868198f8a91415e3f29fcf258d9b8eb7b1d9ae4", size = 451047, upload-time = "2026-06-08T17:34:46.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a4/b393076ffb21b469eec5b328a0534cf03a3b90bfc6b1f09507cdd075d938/tornado-6.5.7-cp39-abi3-win_amd64.whl", hash = "sha256:de942f843533a039ef9fa3d9c88c7cd8a7c94553fb5ad0154270989b3d99a2c4", size = 451485, upload-time = "2026-06-08T17:34:48.248Z" }, + { url = "https://files.pythonhosted.org/packages/71/2e/7b1c769803121b809112cf9a00681c472eae1d80e32d7ec0e0bd61d0d0e1/tornado-6.5.7-cp39-abi3-win_arm64.whl", hash = "sha256:ff934fce95643af5f11efdae618eaa73d469dc588641e5c8d19295a0c65c4796", size = 450506, upload-time = "2026-06-08T17:34:49.702Z" }, ] [[package]] From 89c1e22571821643f5fee3e2a667abe57254d285 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 16:32:00 -0600 Subject: [PATCH 310/388] Update optional-dependency tests for binary HUGR envelopes and LLVM 21 opaque-pointer QIR, fixing the vacuously-passing mz/read_result pairing check --- .../guppy/test_selene_hugr_compilation.py | 170 +++++++----------- .../ast_guppy/test_tier2_semantic.py | 29 ++- .../unit/slr/test_complex_permutation.py | 18 +- .../unit/slr/test_measurement_permutation.py | 26 ++- .../unit/slr/test_measurement_unrolling.py | 12 +- .../unit/slr/test_quantum_permutation.py | 62 ++++--- .../unit/slr/test_register_permutation.py | 15 +- 7 files changed, 168 insertions(+), 164 deletions(-) diff --git a/python/quantum-pecos/tests/guppy/test_selene_hugr_compilation.py b/python/quantum-pecos/tests/guppy/test_selene_hugr_compilation.py index 39713031f..1710cff53 100644 --- a/python/quantum-pecos/tests/guppy/test_selene_hugr_compilation.py +++ b/python/quantum-pecos/tests/guppy/test_selene_hugr_compilation.py @@ -1,14 +1,30 @@ -"""Test HUGR compilation through Selene (HUGR 0.13 compatible).""" - -import json +"""Test HUGR compilation through Selene.""" import pytest from guppylang.decorator import guppy as guppy_decorator from guppylang.std.quantum import cx, h, measure, qubit, x +from hugr.package import Package from pecos import Guppy, sim from pecos.compilation_pipeline import compile_guppy_to_hugr from pecos_rslib import state_vector +# compile_guppy_to_hugr returns the BINARY HUGR envelope (Model format): the +# ASCII magic "HUGRiHJv", a format byte, then a compressed payload. It is not +# UTF-8 text, so these tests validate it by parsing with hugr's own reader. +HUGR_ENVELOPE_MAGIC = b"HUGRiHJv" + + +def _op_names(pkg: Package) -> set[str]: + """Collect the op names appearing in all modules of a HUGR package.""" + names = set() + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + op = module[n].op + op_def = getattr(op, "_op_def", None) + names.add(op_def.name if op_def is not None else type(op).__name__) + return names + @pytest.mark.optional_dependency class TestSeleneHUGRCompilation: @@ -71,33 +87,12 @@ def simple_circuit() -> bool: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Verify HUGR format - hugr_str = hugr_bytes.decode("utf-8") - - # Check if it's envelope format or direct JSON - is_envelope = hugr_str.startswith("HUGRiHJv") - is_json = hugr_str.startswith("{") - - assert is_envelope or is_json, "HUGR should be in valid format" + # Verify HUGR envelope format and parse it with hugr's own reader + assert hugr_bytes.startswith(HUGR_ENVELOPE_MAGIC), "HUGR should be in envelope format" - # Parse JSON content - if is_envelope: - json_start = hugr_str.find("{", 9) - assert json_start != -1, "Envelope should contain JSON" - json_content = hugr_str[json_start:] - else: - json_content = hugr_str - - try: - hugr_json = json.loads(json_content) - assert isinstance(hugr_json, dict), "HUGR should be valid JSON object" - - # Check for expected HUGR structure elements - # HUGR should have version info and graph structure - assert len(hugr_json) > 0, "HUGR JSON should not be empty" - - except json.JSONDecodeError as e: - pytest.fail(f"HUGR should contain valid JSON: {e}") + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "HUGR package should contain at least one module" + assert len(list(pkg.modules[0].nodes())) > 0, "HUGR module should have nodes" def test_complex_circuit_compilation(self) -> None: """Test compilation of more complex quantum circuits.""" @@ -135,14 +130,12 @@ def quantum_teleportation() -> tuple[bool, bool, bool]: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 100, "Complex circuit should produce substantial HUGR" - # Verify it contains quantum operations - hugr_str = hugr_bytes.decode("utf-8") - - # Look for quantum operation indicators - quantum_ops = ["quantum", "Quantum", "measure", "hadamard", "cnot"] - found_ops = [op for op in quantum_ops if op.lower() in hugr_str.lower()] + # Verify it contains the expected quantum operations + op_names = _op_names(Package.from_bytes(hugr_bytes)) - assert len(found_ops) > 0, "HUGR should contain quantum operation references" + assert "H" in op_names, f"HUGR should contain H ops, found {sorted(op_names)}" + assert "CX" in op_names, f"HUGR should contain CX ops, found {sorted(op_names)}" + assert any("Measure" in name for name in op_names), f"HUGR should contain measure ops, found {sorted(op_names)}" def test_parametric_circuit_compilation(self) -> None: """Test compilation of parametric quantum circuits.""" @@ -167,15 +160,10 @@ def parametric_circuit(n: int) -> int: assert hugr_bytes is not None, "Should produce HUGR bytes" assert len(hugr_bytes) > 0, "HUGR bytes should not be empty" - # Check for loop/iteration structures in HUGR - hugr_str = hugr_bytes.decode("utf-8") - - # HUGR might represent loops as specific node types - - # At minimum, verify it's valid HUGR - assert "HUGRiHJv" in hugr_str or hugr_str.startswith( - "{", - ), "Should be valid HUGR format" + # Verify the parametric circuit still produces a loadable HUGR package + assert hugr_bytes.startswith(HUGR_ENVELOPE_MAGIC), "Should be valid HUGR envelope" + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "HUGR package should contain at least one module" @pytest.mark.optional_dependency @@ -245,10 +233,10 @@ def test_llvm_ir_patterns(self) -> None: @pytest.mark.optional_dependency class TestHUGRVersionCompatibility: - """Test HUGR version compatibility.""" + """Test HUGR envelope format compatibility.""" def test_hugr_version_detection(self) -> None: - """Test detection of HUGR version from compiled output.""" + """Test detection of the HUGR envelope format from compiled output.""" @guppy_decorator def version_test() -> bool: @@ -257,31 +245,17 @@ def version_test() -> bool: return measure(q) hugr_bytes = compile_guppy_to_hugr(version_test) - hugr_str = hugr_bytes.decode("utf-8") - - # Check for version indicators - if hugr_str.startswith("HUGRiHJv"): - # Envelope format - version in header - # Format: HUGRiHJv... - version_part = hugr_str[8:10] # Next chars might be version - assert len(version_part) > 0, "Should have version info in envelope" - elif hugr_str.startswith("{"): - # JSON format - might have version field - hugr_json = json.loads(hugr_str) - - # Look for version field in various places - if "version" in hugr_json: - hugr_json["version"] - elif "hugr_version" in hugr_json: - hugr_json["hugr_version"] - elif "metadata" in hugr_json and "version" in hugr_json["metadata"]: - hugr_json["metadata"]["version"] - - # Version might not always be present, but structure should be valid - assert isinstance(hugr_json, dict), "Should be valid JSON structure" - - def test_hugr_0_13_compatibility(self) -> None: - """Test compatibility with HUGR 0.13 format.""" + + # Envelope header: 8-byte magic followed by a format byte + assert hugr_bytes.startswith(HUGR_ENVELOPE_MAGIC), "Envelope should start with the HUGR magic" + assert len(hugr_bytes) > len(HUGR_ENVELOPE_MAGIC), "Envelope should have a format header after the magic" + + # The format must be one the hugr reader understands + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "Envelope should decode to a package with modules" + + def test_hugr_package_structure(self) -> None: + """Test that the compiled package has a well-formed module structure.""" @guppy_decorator def compatibility_test() -> tuple[bool, bool]: @@ -294,33 +268,15 @@ def compatibility_test() -> tuple[bool, bool]: hugr_bytes = compile_guppy_to_hugr(compatibility_test) assert hugr_bytes is not None, "Should produce HUGR bytes" - # HUGR 0.13 specific checks - hugr_str = hugr_bytes.decode("utf-8") - - # HUGR 0.13 uses specific node types and operation formats - # These might appear in the JSON structure - if "{" in hugr_str: - # Extract JSON part - json_start = hugr_str.find("{") - json_part = hugr_str[json_start:] + pkg = Package.from_bytes(hugr_bytes) + assert len(pkg.modules) >= 1, "Package should contain at least one module" - try: - hugr_json = json.loads(json_part) - - # HUGR 0.13 should have nodes and edges structure - # The exact structure depends on the HUGR spec - assert isinstance(hugr_json, dict), "Should be valid HUGR structure" - - # Check for common HUGR elements - hugr_keys = list(hugr_json.keys()) - assert len(hugr_keys) > 0, "HUGR should have structure elements" - - except json.JSONDecodeError: - # Not JSON format, but still valid HUGR - pass + module = pkg.modules[0] + assert len(list(module.nodes())) > 0, "Module should have nodes" + assert module.entrypoint is not None, "Module should have an entrypoint" def test_hugr_metadata_preservation(self) -> None: - """Test that metadata is preserved through compilation.""" + """Test that the function name is preserved through compilation.""" @guppy_decorator def metadata_test() -> bool: @@ -329,12 +285,18 @@ def metadata_test() -> bool: h(q) return measure(q) - # Note: Guppy functions are frozen dataclasses, so we can't set attributes directly - # The metadata should come from the function definition itself - hugr_bytes = compile_guppy_to_hugr(metadata_test) - hugr_str = hugr_bytes.decode("utf-8") - - # Check if any metadata is preserved - # Function name should at least be preserved - assert "metadata_test" in hugr_str or len(hugr_bytes) > 50, "HUGR should preserve some function information" + pkg = Package.from_bytes(hugr_bytes) + + # The guppy function must survive as a named function definition + func_names = [] + for module in pkg.modules: + for node in module.nodes(): + n = node[0] if isinstance(node, tuple) else node + f_name = getattr(module[n].op, "f_name", None) + if f_name: + func_names.append(f_name) + + assert any( + name.endswith("metadata_test") for name in func_names + ), f"HUGR should preserve the function name, found {func_names}" diff --git a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py index bad58eb12..083bcec67 100644 --- a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py +++ b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py @@ -83,17 +83,25 @@ _SHOTS = 8 _SEED = 42 _BESPOKE = re.compile(r"create_creg|get_creg_bit|set_creg_bit|get_int_from_creg|set_creg_to_int|mz_to_creg_bit") -# Ordered mz/read_result events (slot in g1 for mz, g2 for read_result) so +# Ordered mz/read_result events (slot arg in g1 for mz, g2 for read_result) so # we can pin per-measurement slot correspondence, not just set membership. +# QIR uses LLVM opaque pointers: a result-slot constant is +# `ptr inttoptr (i64 N to ptr)`, except slot 0 which is `ptr null`. _MZ_RR_EVENT = re.compile( - r"@__quantum__qis__mz__body\(%Qubit\* [^,]+, " - r"%Result\* inttoptr \(i64 (\d+) to %Result\*\)\)" - r"|@__quantum__rt__read_result\(%Result\* inttoptr \(i64 (\d+) to %Result\*\)\)", + r"@__quantum__qis__mz__body\(ptr [^,]+, " + r"ptr (null|inttoptr \(i64 \d+ to ptr\))\)" + r"|@__quantum__rt__read_result\(ptr (null|inttoptr \(i64 \d+ to ptr\))\)", ) +def _slot(arg: str) -> int: + """Decode a `ptr` result-slot constant (`null` is slot 0).""" + m = re.search(r"i64 (\d+)", arg) + return int(m.group(1)) if m else 0 + + def _assert_mz_rr_pairing(label: str, ir: str) -> None: - """Per-measurement slot correspondence: mz `%Result*` slots are + """Per-measurement slot correspondence: mz result slots are monotonic 0..n-1, and EVERY `read_result` is immediately preceded (in emission order) by the `mz__body` of the SAME slot -- i.e. a read_result reuses *its own* measurement's static slot, not merely @@ -101,11 +109,16 @@ def _assert_mz_rr_pairing(label: str, ir: str) -> None: events: list[tuple[str, int]] = [] for m in _MZ_RR_EVENT.finditer(ir): if m.group(1) is not None: - events.append(("mz", int(m.group(1)))) + events.append(("mz", _slot(m.group(1)))) else: - events.append(("rr", int(m.group(2)))) + events.append(("rr", _slot(m.group(2)))) + # Tripwire: if the emitted pointer syntax ever drifts away from the + # event regex again, fail loud instead of vacuously passing. (Match + # call sites only -- the `declare` preamble is always present.) + if "call void @__quantum__qis__mz__body" in ir: + assert events, f"{label}: mz calls present but _MZ_RR_EVENT matched none; pattern out of sync with emitted IR" mz_slots = [s for kind, s in events if kind == "mz"] - assert mz_slots == list(range(len(mz_slots))), f"{label}: mz %Result* slots not monotonic 0..n-1: {mz_slots}" + assert mz_slots == list(range(len(mz_slots))), f"{label}: mz result slots not monotonic 0..n-1: {mz_slots}" for i, (kind, slot) in enumerate(events): if kind != "rr": continue diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py index 7dab7693a..7a6ce48eb 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_complex_permutation.py @@ -126,6 +126,16 @@ def test_permutation_with_conditional_qasm() -> None: # tracker's `.permute()`; QIR/Selene have no runtime permute # intrinsic). These pin the realized targeting from the actual # emitted QIR (qubit indices deterministic in declaration order). +# +# QIR uses LLVM opaque pointers: a qubit constant is +# `ptr inttoptr (i64 N to ptr)`, except index 0 which is `ptr null`. + +_QARG = r"ptr (?:null|inttoptr \(i64 (\d+) to ptr\))" + + +def _q(name: str, qir: str) -> list[int]: + """All qubit indices a single-qubit `name` gate is applied to.""" + return [int(m) if m else 0 for m in re.findall(rf"call void @__quantum__qis__{name}__body\({_QARG}\)", qir)] @pytest.mark.optional_dependency @@ -149,10 +159,8 @@ def test_multiple_permutations_qir() -> None: assert "; Permutation: a[0] -> a[1], a[1] -> a[0]" in qir, qir assert "; Permutation: a[1] -> b[0], b[0] -> a[1]" in qir, qir # Qubits: a=0,1,2 b=3,4,5. Both H(a[0]) -> q1; X(a[1]) -> b[0]=q3. - h = re.findall(r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) - x = re.findall(r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) - assert h == ["1", "1"], qir - assert x == ["3"], qir + assert _q("h", qir) == [1, 1], qir + assert _q("x", qir) == [3], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -183,6 +191,6 @@ def test_permutation_with_conditional_qir() -> None: qir, ), qir # X(a[0]) after the permute -> a[1] = q1. - assert re.findall(r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) == ["1"], qir + assert _q("x", qir) == [1], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py index 1b3fe97c9..6f906f3e8 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_permutation.py @@ -70,20 +70,28 @@ def test_register_measurement_permutation_qasm( # every qubit/classical-bit lowering (the bespoke # @create_creg/@set_creg_bit/@mz_to_creg_bit helpers the old tests # pinned were removed by the static CReg model; measurement is the standard 2-arg -# `@__quantum__qis__mz__body(%Qubit*, %Result*)` + read_result + +# `@__quantum__qis__mz__body(ptr, ptr)` + read_result + # store-into-creg-buffer). Pin the realized measurement targeting. +# +# QIR uses LLVM opaque pointers: a qubit/result constant is +# `ptr inttoptr (i64 N to ptr)`, except index 0 which is `ptr null`. -def _mz_then_store(qir: str) -> list[tuple[str, str, str]]: +def _mz_then_store(qir: str) -> list[tuple[int, str, int]]: """(qubit_idx, creg_name, creg_idx) for each measure+store.""" pat = ( - r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Result\* inttoptr \(i64 (\d+) to %Result\*\)\)\n" - r"\s*%(?:\.\d+) = call i1 @__quantum__rt__read_result\(%Result\* inttoptr \(i64 \2 to %Result\*\)\)\n" + r"call void @__quantum__qis__mz__body\(ptr (null|inttoptr \(i64 \d+ to ptr\)), " + r"(ptr (?:null|inttoptr \(i64 \d+ to ptr\)))\)\n" + r"\s*%(?:\.\d+) = call i1 @__quantum__rt__read_result\(\2\)\n" r"\s*%(\.\d+) = getelementptr \[\d+ x i1\], (?:ptr|\[\d+ x i1\]\*) %(\w+), i64 0, i64 (\d+)" ) - # groups: 1=qubit, 2=result idx, 3=gep var, 4=creg name, 5=creg idx - return [(m[0], m[3], m[4]) for m in re.findall(pat, qir)] + # groups: 1=qubit arg, 2=result arg (backreferenced into read_result), + # 3=gep var, 4=creg name, 5=creg idx + out = [] + for qubit_arg, _result_arg, _gep, creg_name, creg_idx in re.findall(pat, qir): + idx = re.search(r"i64 (\d+)", qubit_arg) + out.append((int(idx.group(1)) if idx else 0, creg_name, int(creg_idx))) + return out @pytest.mark.optional_dependency @@ -99,7 +107,7 @@ def test_individual_measurement_permutation_qir( # Qubits a=0,1 b=2,3. Measure(a[0]) -> b[0]=q2, result -> m[0] # which is relabelled to n[0]. Measure(a[1]) -> q1 (unpermuted), # result -> m[1] (unpermuted). - assert _mz_then_store(qir) == [("2", "n", "0"), ("1", "m", "1")], qir + assert _mz_then_store(qir) == [(2, "n", 0), (1, "m", 1)], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -116,6 +124,6 @@ def test_register_measurement_permutation_qir( assert "; Permutation: m[0] -> n[0], n[0] -> m[0]" in qir, qir # Measure(a) unrolls: a[0]->b[0]=q2 (-> m[0] relabelled to n[0]), # a[1]->q1 (-> m[1] unpermuted). - assert _mz_then_store(qir) == [("2", "n", "0"), ("1", "m", "1")], qir + assert _mz_then_store(qir) == [(2, "n", 0), (1, "m", 1)], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_unrolling.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_unrolling.py index b306ab817..298de00ba 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_unrolling.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_measurement_unrolling.py @@ -92,9 +92,17 @@ def test_measurement_unrolling_qir() -> None: assert "; Permutation: a[0] -> c[2], b[1] -> a[0], c[2] -> b[1]" in qir, qir assert "; Permutation: a <-> c" in qir, qir - mz = re.findall(r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), %Result\*", qir) + # QIR uses opaque pointers; a qubit constant is + # `ptr inttoptr (i64 N to ptr)`, except index 0 which is `ptr null`. + mz = [ + int(m) if m else 0 + for m in re.findall( + r"call void @__quantum__qis__mz__body\(ptr (?:null|inttoptr \(i64 (\d+) to ptr\)), ptr", + qir, + ) + ] # Measure(a) after both permutes -> a relabelled onto c's qubits # plus the element cycle: realized as q6, q7, q4. - assert mz == ["6", "7", "4"], qir + assert mz == [6, 7, 4], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_quantum_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_quantum_permutation.py index fc660ea91..04002695c 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_quantum_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_quantum_permutation.py @@ -65,14 +65,22 @@ def test_quantum_permutation_qasm(quantum_permutation_program: tuple) -> None: # `; Permutation:` comment is preserved. The bespoke # @set_creg_bit/@mz_to_creg_bit helpers the old tests pinned were # removed by the static CReg model (measurement is the standard 2-arg -# `@__quantum__qis__mz__body(%Qubit*, %Result*)`). +# `@__quantum__qis__mz__body(ptr, ptr)`). +# +# QIR uses LLVM opaque pointers: a qubit/result constant is +# `ptr inttoptr (i64 N to ptr)`, except index 0 which is `ptr null`. + +_QARG = r"ptr (?:null|inttoptr \(i64 (\d+) to ptr\))" + + +def _idx(m: str) -> int: + """Decode a captured pointer index; the empty capture is `ptr null` = 0.""" + return int(m) if m else 0 def _q(name: str, qir: str) -> list[int]: """All qubit indices a single-qubit `name` gate is applied to.""" - return [ - int(m) for m in re.findall(rf"call void @__quantum__qis__{name}__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) - ] + return [_idx(m) for m in re.findall(rf"call void @__quantum__qis__{name}__body\({_QARG}\)", qir)] @pytest.mark.optional_dependency @@ -86,12 +94,10 @@ def test_quantum_permutation_qir(quantum_permutation_program: tuple) -> None: # Qubits: a[0]=0, a[1]=1, b[0]=2, b[1]=3. After the swap, H(a[0]) # targets b[0]'s qubit (2) and CX(a[0], a[1]) -> cnot(2, 1). assert _q("h", qir) == [2], qir - cnot = re.findall( - r"call void @__quantum__qis__cnot__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - assert cnot == [("2", "1")], qir + cnot = [ + (_idx(c), _idx(t)) for c, t in re.findall(rf"call void @__quantum__qis__cnot__body\({_QARG}, {_QARG}\)", qir) + ] + assert cnot == [(2, 1)], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -126,11 +132,8 @@ def test_permutation_with_bell_circuit_qir() -> None: assert _q("h", qir) == [3], qir # Standard 2-arg measurement (the removed @mz_to_creg_bit is # gone): Measure(a[0]) reads q3, Measure(a[1]) reads q1. - mz = re.findall( - r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), %Result\*", - qir, - ) - assert mz == ["3", "1"], qir + mz = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__mz__body\({_QARG}, ptr", qir)] + assert mz == [3, 1], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -178,17 +181,12 @@ def test_comprehensive_qir_verification() -> None: assert _q("x", qir) == [1, 0], qir # initial a[1]=1, then ->a[0]=0 assert _q("y", qir) == [2, 3], qir # initial b[0]=2, then ->b[1]=3 assert _q("z", qir) == [3, 1], qir # initial b[1]=3, then ->a[1]=1 - cnot = re.findall( - r"call void @__quantum__qis__cnot__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - assert cnot == [("2", "1")], qir - mz = re.findall( - r"call void @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), %Result\*", - qir, - ) - assert mz == ["2", "1"], qir + cnot = [ + (_idx(c), _idx(t)) for c, t in re.findall(rf"call void @__quantum__qis__cnot__body\({_QARG}, {_QARG}\)", qir) + ] + assert cnot == [(2, 1)], qir + mz = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__mz__body\({_QARG}, ptr", qir)] + assert mz == [2, 1], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" @@ -216,13 +214,13 @@ def test_rotation_gates_with_permutation() -> None: qir = SlrConverter(prog).qir() assert "; Permutation: a[0] -> b[0], b[0] -> a[0]" in qir - rx = re.findall(r"call void @__quantum__qis__rx__body\(double [^,]+, %Qubit\* inttoptr \(i64 (\d+) ", qir) - ry = re.findall(r"call void @__quantum__qis__ry__body\(double [^,]+, %Qubit\* inttoptr \(i64 (\d+) ", qir) - assert rx == ["0", "2"], qir # initial a[0]=0, then ->b[0]=2 - assert ry == ["1", "0"], qir # initial a[1]=1, then ->a[0]=0 + rx = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__rx__body\(double [^,]+, {_QARG}\)", qir)] + ry = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__ry__body\(double [^,]+, {_QARG}\)", qir)] + assert rx == [0, 2], qir # initial a[0]=0, then ->b[0]=2 + assert ry == [1, 0], qir # initial a[1]=1, then ->a[0]=0 assert _q("t", qir) == [1], qir # T(a[1]) unpermuted - tdg = re.findall(r"call void @__quantum__qis__t__adj\(%Qubit\* inttoptr \(i64 (\d+) ", qir) - assert tdg == ["3"], qir # Tdg(b[1]) unpermuted + tdg = [_idx(m) for m in re.findall(rf"call void @__quantum__qis__t__adj\({_QARG}\)", qir)] + assert tdg == [3], qir # Tdg(b[1]) unpermuted assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py index 721a0806a..c96311c27 100644 --- a/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py +++ b/python/quantum-pecos/tests/slr_tests/pecos/unit/slr/test_register_permutation.py @@ -123,6 +123,13 @@ def test_mixed_permutation_qasm() -> None: # relabelled register's `[N x i1]` buffer). Works for whole-register # (QReg + CReg) and element-wise; pinned from the actual emitted QIR. +_QARG = r"ptr (?:null|inttoptr \(i64 (\d+) to ptr\))" + + +def _q(name: str, qir: str) -> list[int]: + """All qubit indices a single-qubit `name` gate is applied to.""" + return [int(m) if m else 0 for m in re.findall(rf"call void @__quantum__qis__{name}__body\({_QARG}\)", qir)] + @pytest.mark.optional_dependency def test_whole_register_permutation_qir() -> None: @@ -156,9 +163,9 @@ def test_mixed_permutation_qir() -> None: assert "; Permutation: a <-> b" in qir, qir # QRegs a=0-2, b=3-5, c=6-8. First Permute([a[0],c[1]],[c[1],a[0]]) # then whole Permute(a, b). Realized: H(a[0])->q3, X(b[1])->q1, - # Z(c[2])->q8. - assert re.findall(r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) == ["3"], qir - assert re.findall(r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) == ["1"], qir - assert re.findall(r"call void @__quantum__qis__z__body\(%Qubit\* inttoptr \(i64 (\d+) ", qir) == ["8"], qir + # Z(c[2])->q8. (QIR uses opaque pointers; index 0 is `ptr null`.) + assert _q("h", qir) == [3], qir + assert _q("x", qir) == [1], qir + assert _q("z", qir) == [8], qir assert qir == SlrConverter(prog).qir(), "QIR generation is not deterministic" From 138f039cf58db549fc814533da17650b98dab8b9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 16:32:00 -0600 Subject: [PATCH 311/388] Delete the stale tests/slr tree, superseded by slr_tests --- .../tests/slr/pecos/unit/slr/conftest.py | 158 ------ .../pecos/unit/slr/test_basic_permutation.py | 211 -------- .../unit/slr/test_complex_permutation.py | 225 --------- .../pecos/unit/slr/test_creg_permutation.py | 108 ----- .../unit/slr/test_measurement_permutation.py | 191 -------- .../unit/slr/test_measurement_unrolling.py | 152 ------ .../unit/slr/test_quantum_permutation.py | 453 ------------------ .../unit/slr/test_register_permutation.py | 190 -------- 8 files changed, 1688 deletions(-) delete mode 100644 python/quantum-pecos/tests/slr/pecos/unit/slr/conftest.py delete mode 100644 python/quantum-pecos/tests/slr/pecos/unit/slr/test_basic_permutation.py delete mode 100644 python/quantum-pecos/tests/slr/pecos/unit/slr/test_complex_permutation.py delete mode 100644 python/quantum-pecos/tests/slr/pecos/unit/slr/test_creg_permutation.py delete mode 100644 python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_permutation.py delete mode 100644 python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_unrolling.py delete mode 100644 python/quantum-pecos/tests/slr/pecos/unit/slr/test_quantum_permutation.py delete mode 100644 python/quantum-pecos/tests/slr/pecos/unit/slr/test_register_permutation.py diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/conftest.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/conftest.py deleted file mode 100644 index 480179312..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/conftest.py +++ /dev/null @@ -1,158 +0,0 @@ -"""Pytest fixtures for SLR tests.""" - -import pytest -from pecos.slr import CReg, Main, Permute, QReg -from pecos.slr.qeclib import qubit - - -@pytest.fixture -def basic_permutation_program() -> tuple: - """Create a basic program with permutation of classical registers.""" - a = CReg("a", 2) - b = CReg("b", 2) - - prog = Main( - a, - b, - Permute( - [a[0], b[1]], - [b[1], a[0]], - ), - a[0].set(1), # Should become b[1] = 1 after permutation - ) - - return prog, a, b - - -@pytest.fixture -def same_register_permutation_program() -> tuple: - """Create a program with permutation within the same register.""" - a = CReg("a", 3) - - prog = Main( - a, - Permute( - [a[0], a[1], a[2]], - [a[2], a[0], a[1]], - ), - a[0].set(1), # Should become a[2] = 1 - a[1].set(0), # Should become a[0] = 0 - a[2].set(1), # Should become a[1] = 1 - ) - - return prog, a - - -@pytest.fixture -def quantum_permutation_program() -> tuple: - """Create a program with permutation of quantum registers.""" - a = QReg("a", 2) - b = QReg("b", 2) - - prog = Main( - a, - b, - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - qubit.H(a[0]), # Should become H(b[0]) after permutation - qubit.CX(a[0], a[1]), # Should become CX(b[0], a[1]) after permutation - ) - - return prog, a, b - - -@pytest.fixture -def measurement_program() -> tuple: - """Create a program with permutation and measurements.""" - a = QReg("a", 2) - b = QReg("b", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - m, - n, - # Apply permutations to both quantum and classical registers - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - Permute( - [m[0], n[0]], - [n[0], m[0]], - ), - # Apply quantum operations - qubit.H(a[0]), - qubit.CX(a[0], b[0]), - ) - - return prog, a, b, m, n - - -@pytest.fixture -def individual_measurement_program() -> tuple: - """Create a program with permutation and individual measurements.""" - a = QReg("a", 2) - b = QReg("b", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - m, - n, - # Apply permutations to both quantum and classical registers - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - Permute( - [m[0], n[0]], - [n[0], m[0]], - ), - # Apply quantum operations - qubit.H(a[0]), - qubit.CX(a[0], b[0]), - # Add individual measurements - qubit.Measure(a[0]) > m[0], - qubit.Measure(a[1]) > m[1], - ) - - return prog, a, b, m, n - - -@pytest.fixture -def register_measurement_program() -> tuple: - """Create a program with permutation and register-wide measurements.""" - a = QReg("a", 2) - b = QReg("b", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - m, - n, - # Apply permutations to both quantum and classical registers - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - Permute( - [m[0], n[0]], - [n[0], m[0]], - ), - # Apply quantum operations - qubit.H(a[0]), - qubit.CX(a[0], b[0]), - # Add register-wide measurement - qubit.Measure(a) > m, - ) - - return prog, a, b, m, n diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_basic_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_basic_permutation.py deleted file mode 100644 index 627caba05..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_basic_permutation.py +++ /dev/null @@ -1,211 +0,0 @@ -"""Tests for basic permutation functionality in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import CReg, Main, Permute, SlrConverter - -# Test fixtures - - -def create_basic_permutation_program() -> tuple: - """Create a basic program with permutation of classical registers.""" - a = CReg("a", 2) - b = CReg("b", 2) - - prog = Main( - a, - b, - Permute( - [a[0], b[1]], - [b[1], a[0]], - ), - a[0].set(1), # Should become b[1] = 1 after permutation - ) - - return prog, a, b - - -def create_same_register_permutation_program() -> tuple: - """Create a program with permutation within the same register.""" - a = CReg("a", 3) - - prog = Main( - a, - Permute( - [a[0], a[1], a[2]], - [a[2], a[0], a[1]], - ), - a[0].set(1), # Should become a[2] = 1 - a[1].set(0), # Should become a[0] = 0 - a[2].set(1), # Should become a[1] = 1 - ) - - return prog, a - - -# QASM Tests - - -def test_permutation_consistency_for_bits_in_qasm() -> None: - """Test that permutation is consistent across multiple QASM generations.""" - prog = Main( - a := CReg("a", 2), - b := CReg("b", 2), - Permute( - [a[0], b[1]], - [b[1], a[0]], - ), - a[0].set(1), - ) - - qasm1 = SlrConverter(prog).qasm() - qasm2 = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm1) - - assert qasm1 == qasm2 - assert "a[0] = 1;" in qasm1 - - # Verify that the bit permutation is using the temporary bit approach, not XOR swap - assert "creg _bit_swap[1];" in qasm1 - assert "_bit_swap[0] = a[0];" in qasm1 - assert "a[0] = b[1];" in qasm1 - assert "b[1] = _bit_swap[0];" in qasm1 - assert "a[0] = a[0] ^ b[1];" not in qasm1 # Make sure XOR swap is not used - - -def test_basic_permutation_qasm(basic_permutation_program: tuple) -> None: - """Test basic permutation functionality in QASM generation.""" - prog, _, _ = basic_permutation_program - - # Generate QASM - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Verify that the QASM contains the correct permuted operation - # For classical bit permutations, operations still refer to the original bit names - assert "a[0] = 1;" in qasm - - # Verify that the bit permutation is using the temporary bit approach, not XOR swap - assert "creg _bit_swap[1];" in qasm - assert "_bit_swap[0] = a[0];" in qasm - assert "a[0] = b[1];" in qasm - assert "b[1] = _bit_swap[0];" in qasm - assert "a[0] = a[0] ^ b[1];" not in qasm # Make sure XOR swap is not used - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -def test_same_register_permutation_qasm( - same_register_permutation_program: tuple, -) -> None: - """Test permutation of elements within the same register in QASM.""" - prog, _ = same_register_permutation_program - - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # For classical bit permutations, operations still refer to the original bit names - assert "a[0] = 1;" in qasm - assert "a[1] = 0;" in qasm - assert "a[2] = 1;" in qasm - - # Verify that the bit permutation is using the temporary bit approach, not XOR swap - assert "creg _bit_swap[1];" in qasm - assert "_bit_swap[0] = a[0];" in qasm - assert "a[0] = a[2];" in qasm # Part of the cycle - assert "a[2] = a[1];" in qasm # Part of the cycle - assert "a[1] = _bit_swap[0];" in qasm # Completing the cycle - assert "a[0] = a[0] ^ a[1];" not in qasm # Make sure XOR swap is not used - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_basic_permutation_qir(basic_permutation_program: tuple) -> None: - """Test basic permutation functionality in QIR generation.""" - prog, _, _ = basic_permutation_program - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a[0] -> b[1], b[1] -> a[0]" in qir - - # Extract the register and index used in the set_creg_bit call - # This should be setting a[0] (register %a, index 0) since the permutation - # is not being applied to the operations in the QIR generator - set_creg_calls = re.findall( - r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 1\)", - qir, - ) - - # We should have at least one set_creg_bit call - assert len(set_creg_calls) >= 1, "No set_creg_bit call found" - - # Get the register and index - reg_name, index = set_creg_calls[0] - - # Verify that the set_creg_bit call is setting a[0] since the permutation - # is not being applied to the operations in the QIR generator - assert reg_name == "a", f"set_creg_bit applied to register {reg_name}, expected a" - assert index == "0", f"set_creg_bit applied to index {index}, expected 0" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_same_register_permutation_qir( - same_register_permutation_program: tuple, -) -> None: - """Test permutation of elements within the same register in QIR.""" - prog, _ = same_register_permutation_program - - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a[0] -> a[2], a[1] -> a[0], a[2] -> a[1]" in qir - - # Extract the register and indices used in the set_creg_bit calls - set_creg_calls = re.findall( - r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 (\d+)\)", - qir, - ) - - # We should have at least three set_creg_bit calls - assert len(set_creg_calls) >= 3, f"Expected at least 3 set_creg_bit calls, found {len(set_creg_calls)}" - - # Create a dictionary to store the values set for each index - set_values = {} - for reg_name, index, value in set_creg_calls: - assert reg_name == "a", f"set_creg_bit applied to register {reg_name}, expected a" - set_values[int(index)] = int(value) - - # Verify that the set_creg_bit calls are setting the correct values - # Since the permutation is not being applied to the operations in the QIR generator, - # we expect the original operations to be executed - assert set_values.get(0) == 1, "a[0] should be set to 1" - assert set_values.get(1) == 0, "a[1] should be set to 0" - assert set_values.get(2) == 1, "a[2] should be set to 1" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_complex_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_complex_permutation.py deleted file mode 100644 index 9a14a3fb4..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_complex_permutation.py +++ /dev/null @@ -1,225 +0,0 @@ -"""Tests for complex permutation scenarios in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import CReg, If, Main, Permute, QReg, SlrConverter -from pecos.slr.qeclib import qubit - -# QASM Tests - - -def test_complex_permutation_circuit() -> None: - """Test a more complex circuit with multiple permutations at different stages.""" - prog = Main( - a := QReg("a", 3), - b := QReg("b", 3), - c := QReg("c", 3), - # Initial operations - Layer 1 - qubit.H(a[0]), # Hadamard on a[0] - qubit.X(b[1]), # X gate on b[1] - qubit.Z(c[2]), # Z gate on c[2] - # First permutation: rotate registers - Permute( - [a[0], a[1], a[2], b[0], b[1], b[2], c[0], c[1], c[2]], - [b[0], b[1], b[2], c[0], c[1], c[2], a[0], a[1], a[2]], - ), - # Operations after first permutation - Layer 2 - # a[0] -> b[0], b[1] -> c[1], c[2] -> a[2] - qubit.X(a[0]), # X gate on a[0] -> should become X on b[0] - qubit.Y(b[1]), # Y gate on b[1] -> should become Y on c[1] - qubit.Z(c[2]), # Z gate on c[2] -> should become Z on a[2] - ) - - qasm = SlrConverter(prog).qasm() - - # Check that the permutation was applied correctly - assert "h a[0];" in qasm.lower() # Initial operation - assert "x b[1];" in qasm.lower() # Initial operation - assert "z c[2];" in qasm.lower() # Initial operation - - assert "x b[0];" in qasm.lower() # After first permutation - assert "y c[1];" in qasm.lower() # After first permutation - assert "z a[2];" in qasm.lower() # After first permutation - - -def test_multiple_permutations_qasm() -> None: - """Test multiple sequential permutations in QASM generation.""" - # Create a program with multiple sequential permutations - a = QReg("a", 3) - b = QReg("b", 3) - - prog = Main( - a, - b, - # First permutation - Permute( - [a[0], a[1]], - [a[1], a[0]], - ), - # Apply an operation - qubit.H(a[0]), # Should become H(a[1]) after first permutation - # Second permutation - Permute( - [a[1], b[0]], - [b[0], a[1]], - ), - # Apply another operation - qubit.H(a[0]), # Should still be H(a[1]) after first permutation only - qubit.X(a[1]), # Should become X(b[0]) after both permutations - ) - - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM Output:") - # print(qasm) - - # Verify that the QASM contains the correct permuted operations - assert "h a[1];" in qasm # First H gate - # The second H gate is applied to a[0] which is mapped to b[0] after both permutations - assert "h b[0];" in qasm # Second H gate - # The X gate is applied to a[1] which is mapped to a[0] after both permutations - assert "x a[0];" in qasm # X gate after both permutations - - -def test_permutation_with_conditional_qasm() -> None: - """Test permutation with conditional operations in QASM generation.""" - # Create a program with permutation and conditional operations - a = QReg("a", 2) - b = CReg("b", 2) - - prog = Main( - a, - b, - # Set a classical bit - b[0].set(1), - # Apply a permutation - Permute( - [a[0], a[1], b[0], b[1]], - [a[1], a[0], b[1], b[0]], - ), - # Apply a conditional operation - # After permutation: b[0] -> b[1], a[0] -> a[1] - # So the condition should be on b[1] and the operation should be on a[1] - If(b[0] == 1).Then(qubit.X(a[0])), - ) - - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM Output:") - # print(qasm) - - # Verify that the QASM contains the correct permuted operations - assert "b[0] = 1;" in qasm # The classical bit assignment happens before permutation - # The condition and operation should both be permuted - assert "if(b[1] == 1) x a[1];" in qasm - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_multiple_permutations_qir() -> None: - """Test multiple sequential permutations in QIR generation.""" - # Create a program with multiple sequential permutations - a = QReg("a", 3) - b = QReg("b", 3) - - prog = Main( - a, - b, - # First permutation - Permute( - [a[0], a[1]], - [a[1], a[0]], - ), - # Apply an operation - qubit.H(a[0]), # Should become H(a[1]) after first permutation - # Second permutation - Permute( - [a[1], b[0]], - [b[0], a[1]], - ), - # Apply another operation - qubit.H(a[0]), # Should still be H(a[1]) after first permutation only - qubit.X(a[1]), # Should become X(b[0]) after both permutations - ) - - qir = SlrConverter(prog).qir() - - # Verify that the QIR contains comments about the permutations - assert "Permutation: a[0] -> a[1], a[1] -> a[0]" in qir - assert "Permutation: a[1] -> b[0], b[0] -> a[1]" in qir - - # Extract the quantum operations - h_calls = re.findall( - r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - x_calls = re.findall( - r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # We should have at least two H calls and one X call - assert len(h_calls) >= 2, f"Expected at least 2 H gate calls, found {len(h_calls)}" - assert len(x_calls) >= 1, f"Expected at least 1 X gate call, found {len(x_calls)}" - - -@pytest.mark.optional_dependency -def test_permutation_with_conditional_qir() -> None: - """Test permutation with conditional operations in QIR generation.""" - # Create a program with permutation and conditional operations - a = QReg("a", 2) - b = CReg("b", 2) - - prog = Main( - a, - b, - # Set a classical bit - b[0].set(1), - # Apply a permutation - Permute( - [a[0], a[1], b[0], b[1]], - [a[1], a[0], b[1], b[0]], - ), - # Apply a conditional operation - # After permutation: b[0] -> b[1], a[0] -> a[1] - # So the condition should be on b[1] and the operation should be on a[1] - If(b[0] == 1).Then(qubit.X(a[0])), - ) - - qir = SlrConverter(prog).qir() - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a[0] -> a[1], a[1] -> a[0], b[0] -> b[1], b[1] -> b[0]" in qir - - # Extract the set_creg_bit call - set_creg_calls = re.findall( - r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 1\)", - qir, - ) - - # We should have at least one set_creg_bit call - assert len(set_creg_calls) >= 1, "No set_creg_bit call found" - - # Get the register and index - reg_name, index = set_creg_calls[0] - - # Verify that the set_creg_bit call is setting b[0] (not permuted, as it happens before the permutation) - assert reg_name == "b", f"set_creg_bit applied to register {reg_name}, expected b" - assert index == "0", f"set_creg_bit applied to index {index}, expected 0" - - # Extract the conditional X operation - # In QIR, conditionals use __quantum__rt__array_get_element_ptr_1d to access the condition - # and then branch based on the condition - # This is a simplified check that just verifies an X gate is called somewhere after the condition check - x_calls = re.findall( - r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # We should have at least one X call - assert len(x_calls) >= 1, "No X gate call found" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_creg_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_creg_permutation.py deleted file mode 100644 index a3fc4b527..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_creg_permutation.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Tests for classical register permutation functionality.""" - -import re - -import pecos.slr -import pytest -from pecos.slr.slr_converter import SlrConverter - - -def create_creg_permutation_program() -> tuple: - """Create a program with permutation of whole classical registers followed by both bit and register operations.""" - a = pecos.slr.CReg("a", size=1) - b = pecos.slr.CReg("b", size=1) - - return pecos.slr.Main( - a, - b, - pecos.slr.Permute(a, b), - a[0].set(1), # Bit-level operation - a.set(1), # Register-level operation - ) - - -def test_creg_permutation_qasm() -> None: - """Test permutation of whole classical registers followed by both bit and register operations in QASM.""" - prog = create_creg_permutation_program() - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Verify the XOR swap operations are generated - assert "a = a ^ b;" in qasm, f"Expected 'a = a ^ b;' not found in QASM:\n{qasm}" - assert "b = b ^ a;" in qasm, f"Expected 'b = b ^ a;' not found in QASM:\n{qasm}" - assert "a = a ^ b;" in qasm, f"Expected 'a = a ^ b;' not found in QASM:\n{qasm}" - - # Verify the temporary bit approach is NOT used for whole register permutations - assert "creg _bit_swap[1];" not in qasm, f"Unexpected 'creg _bit_swap[1];' found in QASM:\n{qasm}" - - # Verify the permutation comment is correct - assert "// Permutation: a <-> b" in qasm, f"Expected permutation comment not found in QASM:\n{qasm}" - - # Verify the operations after the permutation - # For classical bit permutations, we're physically moving the values, - # Since we're not updating the permutation map for classical register permutations, - # both bit-level and register-level operations should still refer to the original registers. - assert "a[0] = 1;" in qasm, f"Expected 'a[0] = 1;' not found in QASM:\n{qasm}" - assert "a = 1;" in qasm, f"Expected 'a = 1;' not found in QASM:\n{qasm}" - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_creg_permutation_qir() -> None: - """Test permutation of whole classical registers followed by both bit and register operations in QIR.""" - prog = create_creg_permutation_program() - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a <-> b" in qir, "Expected permutation comment not found in QIR" - - # Verify that the XOR operations are present - assert "xor" in qir, "Expected XOR operations not found in QIR" - - # Verify the temporary bit approach is NOT used for whole register permutations - assert ( - "_bit_swap = call i1* @create_creg(i64 1)" not in qir - ), "Unexpected '_bit_swap = call i1* @create_creg(i64 1)' found in QIR" - - # Extract the register and index used in the set_creg_bit call for the bit-level operation - set_creg_bit_calls = re.findall( - r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 1\)", - qir, - ) - assert len(set_creg_bit_calls) >= 1, "No set_creg_bit call found for bit-level operation" - - # Get the register and index for the bit-level operation - reg_name, index = set_creg_bit_calls[0] - - # In QIR, unlike QASM, the permutation is not applied to bit-level operations - # So a[0].set(1) still refers to register a, index 0 - assert reg_name == "a", f"set_creg_bit applied to register {reg_name}, expected a" - assert index == "0", f"set_creg_bit applied to index {index}, expected 0" - - # Extract the register used in the set_creg call for the register-level operation - set_creg_calls = re.findall( - r"call void @set_creg_to_int\(i1\* %(\w+), i64 1\)", - qir, - ) - assert len(set_creg_calls) >= 1, "No set_creg_to_int call found for register-level operation" - - # Get the register for the register-level operation - reg_name = set_creg_calls[0] - - # For register-level operations, the original register name is used - # So a.set(1) still refers to register a - assert reg_name == "a", f"set_creg_to_int applied to register {reg_name}, expected a" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_permutation.py deleted file mode 100644 index 2a3bd73aa..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_permutation.py +++ /dev/null @@ -1,191 +0,0 @@ -"""Tests for measurement with permutation functionality in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import SlrConverter - -# QASM Tests - - -def test_individual_measurement_permutation_qasm( - individual_measurement_program: tuple, -) -> None: - """Test individual measurements with permutations in QASM generation.""" - prog, _, _, _, _ = individual_measurement_program - - # Generate QASM - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Verify that the QASM contains the correct permuted measurements - # After permutation: a[0] -> b[0], m[0] -> n[0] - # For classical bit permutations, operations still refer to the original bit names - # For quantum registers, we still use the permutation map approach - assert "measure b[0] -> m[0];" in qasm - assert "measure a[1] -> m[1];" in qasm - - # Verify that the bit permutation is using the temporary bit approach, not XOR swap - assert "creg _bit_swap[1];" in qasm - assert "_bit_swap[0] = m[0];" in qasm - assert "m[0] = n[0];" in qasm - assert "n[0] = _bit_swap[0];" in qasm - assert "m[0] = m[0] ^ n[0];" not in qasm # Make sure XOR swap is not used - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -def test_register_measurement_permutation_qasm( - register_measurement_program: tuple, -) -> None: - """Test register-wide measurements with permutations in QASM generation.""" - prog, _, _, _, _ = register_measurement_program - - # Generate QASM - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Register-wide measurements are now unrolled correctly with permutations - # The expected behavior is: - assert "measure b[0] -> m[0];" in qasm, f"Expected 'measure b[0] -> m[0];' not found in QASM:\n{qasm}" - assert "measure a[1] -> m[1];" in qasm, f"Expected 'measure a[1] -> m[1];' not found in QASM:\n{qasm}" - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_individual_measurement_permutation_qir( - individual_measurement_program: tuple, -) -> None: - """Test individual measurements with permutations in QIR generation.""" - prog, _, _, _, _ = individual_measurement_program - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains comments about the permutations - assert "; Permutation: a[0] -> b[0], b[0] -> a[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - assert "; Permutation: m[0] -> n[0], n[0] -> m[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct classical bit permutation using a temporary bit - assert ( - "%_bit_swap = call i1* @create_creg(i64 1)" in qir - ), f"Expected temporary bit creation not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct quantum operations after permutation - # H gate should be applied to b[0] after permutation - h_gate_pattern = r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - h_gates = re.findall(h_gate_pattern, qir) - assert len(h_gates) >= 1, f"Expected at least one H gate, found {len(h_gates)}" - - # CX gate should be applied to b[0] and a[0] after permutation - cx_gate_pattern = ( - r"call void @__quantum__qis__cnot__body\(" - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - ) - cx_gates = re.findall(cx_gate_pattern, qir) - assert len(cx_gates) >= 1, f"Expected at least one CX gate, found {len(cx_gates)}" - - # Extract the measurement operations - # In QIR, measurements are done with mz_to_creg_bit - mz_to_creg_pattern = ( - r"call void @mz_to_creg_bit\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), i1\* %(\w+), i64 (\d+)\)" - ) - mz_to_creg_calls = re.findall(mz_to_creg_pattern, qir) - - # We should have at least two measurement calls (one for each qubit in register a) - assert len(mz_to_creg_calls) >= 2, f"Expected at least 2 measurement calls, found {len(mz_to_creg_calls)}" - - # Create a dictionary to store the measurements - measurements = {} - for qubit_idx, creg_name, creg_idx in mz_to_creg_calls: - if creg_name == "m": - measurements[int(creg_idx)] = (creg_name, int(qubit_idx)) - - # Verify that the correct qubits are measured into the correct classical bits - assert 0 in measurements, f"Expected measurement to m[0], found measurements to {list(measurements.keys())}" - assert 1 in measurements, f"Expected measurement to m[1], found measurements to {list(measurements.keys())}" - - # Verify that different qubits are measured into different classical bits - measured_qubits = [idx for _, idx in measurements.values()] - assert len(set(measured_qubits)) == len( - measured_qubits, - ), f"Expected all measurements to be from different qubits, found duplicates: {measured_qubits}" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_register_measurement_permutation_qir( - register_measurement_program: tuple, -) -> None: - """Test register-wide measurements with permutations in QIR generation.""" - prog, _, _, _, _ = register_measurement_program - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains comments about the permutations - assert "; Permutation: a[0] -> b[0], b[0] -> a[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - assert "; Permutation: m[0] -> n[0], n[0] -> m[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct classical bit permutation using a temporary bit - assert ( - "%_bit_swap = call i1* @create_creg(i64 1)" in qir - ), f"Expected temporary bit creation not found in QIR:\n{qir}" - assert ( - "call void @set_creg_bit(i1* %_bit_swap, i64 0, i1 %.4)" in qir - ), f"Expected temporary bit assignment not found in QIR:\n{qir}" - assert "call void @set_creg_bit(i1* %m, i64 0, i1 %.6)" in qir, f"Expected bit assignment not found in QIR:\n{qir}" - assert "call void @set_creg_bit(i1* %n, i64 0, i1 %.8)" in qir, f"Expected bit assignment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct quantum operations after permutation - # H gate should be applied to b[0] (qubit 2) after permutation - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 2 to %Qubit*))" in qir - ), f"Expected H gate on permuted qubit not found in QIR:\n{qir}" - - # CNOT gate should be applied to b[0] (qubit 2) and a[0] (qubit 0) after permutation - assert ( - "call void @__quantum__qis__cnot__body(" - "%Qubit* inttoptr (i64 2 to %Qubit*), %Qubit* inttoptr (i64 0 to %Qubit*))" in qir - ), f"Expected CNOT gate on permuted qubits not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct measurements after permutation - # a[0] should be measured as b[0] (qubit 2) after permutation - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 2 to %Qubit*), i1* %m, i64 0)" in qir - ), f"Expected measurement of permuted qubit not found in QIR:\n{qir}" - - # a[1] should be measured as a[1] (qubit 1) since it's not permuted - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 1 to %Qubit*), i1* %m, i64 1)" in qir - ), f"Expected measurement of non-permuted qubit not found in QIR:\n{qir}" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_unrolling.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_unrolling.py deleted file mode 100644 index becdd8704..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_measurement_unrolling.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Tests for measurement unrolling with permutations in both QASM and QIR generation.""" - -import pytest -from pecos.slr import CReg, Main, Permute, QReg, SlrConverter -from pecos.slr.qeclib import qubit - - -def create_measurement_unrolling_program() -> tuple: - """Create a program with permutations and register-wide measurements.""" - a = QReg("a", 3) - b = QReg("b", 3) - c = QReg("c", 3) - m = CReg("m", 3) - - return Main( - a, - b, - c, - m, - # Initial gates - qubit.H(a), - qubit.X(b[1]), - # First permutation - Permute( - [a[0], b[1], c[2]], - [c[2], a[0], b[1]], - ), - # Gates after first permutation - qubit.CX(a[0], b[0]), # Should be CX(c[2], b[0]) - qubit.Z(b[1]), # Should be Z(a[0]) - # Second permutation - Permute(a, c), - # Gates after second permutation - qubit.H(a[1]), # Should be H(c[1]) - qubit.CX(c[0], b[2]), # Should be CX(a[0], b[2]) - # Register-wide measurement - should be unrolled correctly - qubit.Measure(a) > m, - ) - - -def test_measurement_unrolling_qasm() -> None: - """Test measurement unrolling with permutations in QASM generation.""" - prog = create_measurement_unrolling_program() - - # Print the program structure for debugging - # print("\nProgram structure:") - # print(f"Operations: {[type(op).__name__ for op in prog.ops]}") - - # Get the last operation (should be the Measure operation) - prog.ops[-1] - # print(f"\nMeasure operation: {type(measure_op).__name__}") - # print(f"qargs: {measure_op.qargs}") - # print(f"cout: {measure_op.cout}") - - # Generate QASM using SlrConverter - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - # print("\nQASM output:") - # print(qasm) - - # Verify that the register-wide measurement is unrolled correctly - # After permutation composition: - # First perm: a[0] -> c[2], b[1] -> a[0], c[2] -> b[1] - # Second perm (a <-> c swap): compose with first - # Result: a[0] -> a[2], a[1] -> c[1], a[2] -> c[2] - assert "measure a[2] -> m[0];" in qasm, f"Expected 'measure a[2] -> m[0];' not found in QASM:\n{qasm}" - assert "measure c[1] -> m[1];" in qasm, f"Expected 'measure c[1] -> m[1];' not found in QASM:\n{qasm}" - assert "measure c[2] -> m[2];" in qasm, f"Expected 'measure c[2] -> m[2];' not found in QASM:\n{qasm}" - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_measurement_unrolling_qir() -> None: - """Test measurement unrolling with permutations in QIR generation.""" - prog = create_measurement_unrolling_program() - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - # print("\nQIR output:") - # print(qir) - - # Verify that the QIR contains comments about the permutations - assert ( - "; Permutation: a[0] -> c[2], b[1] -> a[0], c[2] -> b[1]" in qir - ), f"Expected permutation comment not found in QIR:\n{qir}" - assert "; Permutation: a <-> c" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct quantum operations after permutations - # H gates should be applied to a[0], a[1], a[2] (qubits 0, 1, 2) initially - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 0 to %Qubit*))" in qir - ), f"Expected H gate on a[0] not found in QIR:\n{qir}" - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))" in qir - ), f"Expected H gate on a[1] not found in QIR:\n{qir}" - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 2 to %Qubit*))" in qir - ), f"Expected H gate on a[2] not found in QIR:\n{qir}" - - # X gate should be applied to b[1] (qubit 4) initially - assert ( - "call void @__quantum__qis__x__body(%Qubit* inttoptr (i64 4 to %Qubit*))" in qir - ), f"Expected X gate on b[1] not found in QIR:\n{qir}" - - # After first permutation: - # CNOT gate should be applied to c[2] (qubit 8) and b[0] (qubit 3) - assert ( - "call void @__quantum__qis__cnot__body(" - "%Qubit* inttoptr (i64 8 to %Qubit*), %Qubit* inttoptr (i64 3 to %Qubit*))" in qir - ), f"Expected CNOT gate on permuted qubits not found in QIR:\n{qir}" - - # Z gate should be applied to a[0] (qubit 0) after first permutation - assert ( - "call void @__quantum__qis__z__body(%Qubit* inttoptr (i64 0 to %Qubit*))" in qir - ), f"Expected Z gate on permuted qubit not found in QIR:\n{qir}" - - # After second permutation: - # H gate should be applied to c[1] (qubit 7) after both permutations - assert ( - "call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 7 to %Qubit*))" in qir - ), f"Expected H gate on permuted qubit not found in QIR:\n{qir}" - - # CNOT gate should be applied to a[0] (qubit 0) and b[2] (qubit 5) after both permutations - assert ( - "call void @__quantum__qis__cnot__body(" - "%Qubit* inttoptr (i64 0 to %Qubit*), %Qubit* inttoptr (i64 5 to %Qubit*))" in qir - ), f"Expected CNOT gate on permuted qubits not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct measurements after permutations - # Register-wide measurement of a should be unrolled to individual measurements - # a[0] should be measured as c[0] (qubit 2) after both permutations - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 2 to %Qubit*), i1* %m, i64 0)" in qir - ), f"Expected measurement of a[0] as c[0] not found in QIR:\n{qir}" - - # a[1] should be measured as c[1] (qubit 7) after both permutations - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 7 to %Qubit*), i1* %m, i64 1)" in qir - ), f"Expected measurement of a[1] as c[1] not found in QIR:\n{qir}" - - # a[2] should be measured as c[2] (qubit 8) after both permutations - assert ( - "call void @mz_to_creg_bit(%Qubit* inttoptr (i64 8 to %Qubit*), i1* %m, i64 2)" in qir - ), f"Expected measurement of a[2] as c[2] not found in QIR:\n{qir}" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_quantum_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_quantum_permutation.py deleted file mode 100644 index 755e607a5..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_quantum_permutation.py +++ /dev/null @@ -1,453 +0,0 @@ -"""Tests for quantum permutation functionality in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import CReg, Main, Permute, QReg, SlrConverter, rad -from pecos.slr.qeclib import qubit - -# QASM Tests - - -def test_permutation_consistency_with_multiple_calls() -> None: - """Test that multiple calls to qasm() produce the same result.""" - prog = Main( - a := QReg("a", 2), - b := QReg("b", 2), - Permute( - [a[0], a[1], b[0], b[1]], - [b[0], b[1], a[0], a[1]], - ), - qubit.H(a[0]), # Should become H b[0]; - qubit.X(a[1]), # Should become X b[1]; - qubit.Z(b[0]), # Should become Z a[0]; - qubit.Y(b[1]), # Should become Y a[1]; - ) - - qasm1 = SlrConverter(prog).qasm() - qasm2 = SlrConverter(prog).qasm() - qasm3 = SlrConverter(prog).qasm() - - assert qasm1 == qasm2 - assert qasm2 == qasm3 - - # Check that the permutation was applied correctly - assert "h b[0];" in qasm1.lower() - assert "x b[1];" in qasm1.lower() - assert "z a[0];" in qasm1.lower() - assert "y a[1];" in qasm1.lower() - - -def test_quantum_permutation_qasm(quantum_permutation_program: tuple) -> None: - """Test permutation with quantum gates in QASM generation.""" - prog, _, _ = quantum_permutation_program - - # Generate QASM - qasm = SlrConverter(prog).qasm() - - # Verify that the QASM contains the correct permuted quantum operations - assert "h b[0];" in qasm - assert "cx b[0], a[1];" in qasm - - # Verify that running QASM generation twice produces consistent results - qasm2 = SlrConverter(prog).qasm() - assert qasm == qasm2, "QASM generation is not deterministic" - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_quantum_permutation_qir(quantum_permutation_program: tuple) -> None: - """Test permutation with quantum gates in QIR generation.""" - prog, _, _ = quantum_permutation_program - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for analysis - # print("\nQIR Output for quantum_permutation_qir:") - # print(qir) - - # Verify that the QIR contains a comment about the permutation - assert "Permutation: a[0] -> b[0], b[0] -> a[0]" in qir - - # Extract the qubit indices used in the H and CNOT operations - h_calls = re.findall( - r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - cnot_calls = re.findall( - r"call void @__quantum__qis__cnot__body\(" - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # print(f"H calls found: {h_calls}") - # print(f"CNOT calls found: {cnot_calls}") - - # We should have at least one H call and one CNOT call - assert len(h_calls) >= 1, "No H gate call found" - assert len(cnot_calls) >= 1, "No CNOT gate call found" - - # Get the qubit indices - h_qubit = int(h_calls[0]) - cnot_control, _cnot_target = map(int, cnot_calls[0]) - - # Verify that the H and CNOT operations are applied to the correct qubits after permutation - # The exact indices will depend on how qubits are allocated in the QIR generator - # We can't assert the exact indices without knowing the allocation strategy - # But we can verify that the CNOT control qubit is the same as the H qubit - assert h_qubit == cnot_control, f"H applied to qubit {h_qubit}, but CNOT control is qubit {cnot_control}" - - # Verify that running QIR generation twice produces consistent results - qir2 = SlrConverter(prog).qir() - assert qir == qir2, "QIR generation is not deterministic" - - -@pytest.mark.optional_dependency -def test_permutation_with_bell_circuit_qir() -> None: - """Test permutation functionality with a Bell circuit in QIR generation.""" - # Create a program with permutations and a Bell circuit - a = QReg("a", 2) - b = QReg("b", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - m, - n, - # Permute quantum registers - Permute( - [a[0], b[1]], - [b[1], a[0]], - ), - # Permute classical registers - Permute( - [m[0], n[0]], - [n[0], m[0]], - ), - # Apply H gate to a[0] - should be applied to b[1] after permutation - qubit.H(a[0]), - # Apply CX gate from a[0] to a[1] - should be from b[1] to a[1] after permutation - qubit.CX(a[0], a[1]), - # Measure individual qubits to individual bits - qubit.Measure(a[0]) > m[0], - qubit.Measure(a[1]) > m[1], - ) - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for analysis - # print("\nQIR Output for bell_circuit_qir:") - # print(qir) - - # Verify that the QIR contains comments about the permutations - assert "Permutation: a[0] -> b[1], b[1] -> a[0]" in qir - assert "Permutation: m[0] -> n[0], n[0] -> m[0]" in qir - - # Extract the quantum operations - h_calls = re.findall( - r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - cx_calls = re.findall( - r"call void @__quantum__qis__cnot__body\(" - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # print(f"H calls found: {h_calls}") - # print(f"CX calls found: {cx_calls}") - - # We should have at least one H call and one CX call - assert len(h_calls) >= 1, "No H gate call found" - assert len(cx_calls) >= 1, "No CX gate call found" - - # Extract the measurement operations - mz_calls = re.findall( - r"call %Result\* @__quantum__qis__mz__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - mz_to_creg_calls = re.findall( - r"call void @mz_to_creg_bit\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), i1\* %(\w+), i64 (\d+)\)", - qir, - ) - - # print(f"MZ calls found: {mz_calls}") - # print(f"MZ to creg calls found: {mz_to_creg_calls}") - - # We should have at least two measurement calls (one for each qubit) - assert len(mz_calls) + len(mz_to_creg_calls) >= 2, ( - f"Expected at least 2 measurement calls, found {len(mz_calls)} mz calls " - f"and {len(mz_to_creg_calls)} mz_to_creg calls" - ) - - -@pytest.mark.optional_dependency -def test_comprehensive_qir_verification() -> None: - """Test comprehensive verification of QIR generation with permutations.""" - # Create a program with a variety of operations to test permutation effects - a = QReg("a", 2) - b = QReg("b", 2) - c = QReg("c", 2) - m = CReg("m", 2) - n = CReg("n", 2) - - prog = Main( - a, - b, - c, - m, - n, - # Apply some initial gates to track qubit allocation - qubit.H(a[0]), # Track as "original a[0]" - qubit.X(a[1]), # Track as "original a[1]" - qubit.Y(b[0]), # Track as "original b[0]" - qubit.Z(b[1]), # Track as "original b[1]" - # First permutation: swap a[0] and b[0] - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - # Apply gates after first permutation - qubit.H(a[0]), # Should apply to "original b[0]" - qubit.X(b[0]), # Should apply to "original a[0]" - # Second permutation: swap a[1] and b[1] - Permute( - [a[1], b[1]], - [b[1], a[1]], - ), - # Apply gates after second permutation - qubit.Y(a[1]), # Should apply to "original b[1]" - qubit.Z(b[1]), # Should apply to "original a[1]" - # Apply some two-qubit gates to test cross-register operations - qubit.CX(a[0], b[1]), # Should be CX from "original b[0]" to "original a[1]" - # Measure qubits to classical bits - qubit.Measure(a[0]) > m[0], # Should measure "original b[0]" to m[0] - qubit.Measure(b[1]) > n[0], # Should measure "original a[1]" to n[0] - ) - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for analysis - # print("\nQIR Output for comprehensive_qir_verification:") - # print(qir) - - # Extract all gate operations to track qubit allocation - h_calls = re.findall( - r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - x_calls = re.findall( - r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - y_calls = re.findall( - r"call void @__quantum__qis__y__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - z_calls = re.findall( - r"call void @__quantum__qis__z__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - cx_calls = re.findall( - r"call void @__quantum__qis__cnot__body\(" - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), " - r"%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - mz_to_creg_calls = re.findall( - r"call void @mz_to_creg_bit\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\), i1\* %(\w+), i64 (\d+)\)", - qir, - ) - - # print(f"H calls: {h_calls}") - # print(f"X calls: {x_calls}") - # print(f"Y calls: {y_calls}") - # print(f"Z calls: {z_calls}") - # print(f"CX calls: {cx_calls}") - # print(f"MZ to creg calls: {mz_to_creg_calls}") - - # Based on the initial gates, we can infer the qubit allocation: - # The first H call should be for "original a[0]" - # The first X call should be for "original a[1]" - # The first Y call should be for "original b[0]" - # The first Z call should be for "original b[1]" - if len(h_calls) >= 1 and len(x_calls) >= 1 and len(y_calls) >= 1 and len(z_calls) >= 1: - original_a0 = int(h_calls[0]) - original_a1 = int(x_calls[0]) - original_b0 = int(y_calls[0]) - original_b1 = int(z_calls[0]) - - # print("Inferred qubit allocation:") - # print(f" original a[0] -> physical qubit {original_a0}") - # print(f" original a[1] -> physical qubit {original_a1}") - # print(f" original b[0] -> physical qubit {original_b0}") - # print(f" original b[1] -> physical qubit {original_b1}") - - # Now we can verify that the gates after permutations are applied to the correct qubits - # The second H call should be for "original b[0]" - # The second X call should be for "original a[0]" - if len(h_calls) >= 2 and len(x_calls) >= 2: - assert int(h_calls[1]) == original_b0, ( - f"Second H gate should be applied to original b[0] " - f"(physical qubit {original_b0}), but was applied to physical qubit {h_calls[1]}" - ) - assert int(x_calls[1]) == original_a0, ( - f"Second X gate should be applied to original a[0] " - f"(physical qubit {original_a0}), but was applied to physical qubit {x_calls[1]}" - ) - - # The second Y call should be for "original b[1]" - # The second Z call should be for "original a[1]" - if len(y_calls) >= 2 and len(z_calls) >= 2: - assert int(y_calls[1]) == original_b1, ( - f"Second Y gate should be applied to original b[1] " - f"(physical qubit {original_b1}), but was applied to physical qubit {y_calls[1]}" - ) - assert int(z_calls[1]) == original_a1, ( - f"Second Z gate should be applied to original a[1] " - f"(physical qubit {original_a1}), but was applied to physical qubit {z_calls[1]}" - ) - - # The CX gate should be from "original b[0]" to "original a[1]" - if len(cx_calls) >= 1: - cx_control, cx_target = map(int, cx_calls[0]) - assert ( - cx_control == original_b0 - ), f"CX control should be original b[0] (physical qubit {original_b0}), but was physical qubit {cx_control}" - assert ( - cx_target == original_a1 - ), f"CX target should be original a[1] (physical qubit {original_a1}), but was physical qubit {cx_target}" - - # The measurements should be from "original b[0]" to m[0] and from "original a[1]" to n[0] - if len(mz_to_creg_calls) >= 2: - mz1_qubit, mz1_reg, mz1_idx = mz_to_creg_calls[0] - mz2_qubit, mz2_reg, mz2_idx = mz_to_creg_calls[1] - - # Check if either measurement matches our expectations - b0_to_m0 = (int(mz1_qubit) == original_b0 and mz1_reg == "m" and int(mz1_idx) == 0) or ( - int(mz2_qubit) == original_b0 and mz2_reg == "m" and int(mz2_idx) == 0 - ) - a1_to_n0 = (int(mz1_qubit) == original_a1 and mz1_reg == "n" and int(mz1_idx) == 0) or ( - int(mz2_qubit) == original_a1 and mz2_reg == "n" and int(mz2_idx) == 0 - ) - - assert b0_to_m0, ( - f"Expected measurement from original b[0] (physical qubit {original_b0}) to m[0], " - f"but found measurements: {mz_to_creg_calls}" - ) - assert a1_to_n0, ( - f"Expected measurement from original a[1] (physical qubit {original_a1}) to n[0], " - f"but found measurements: {mz_to_creg_calls}" - ) - - -@pytest.mark.optional_dependency -def test_rotation_gates_with_permutation() -> None: - """Test that permutations work correctly with rotation gates in QIR generation.""" - # Create a program with rotation gates and permutations - a = QReg("a", 2) - b = QReg("b", 2) - - prog = Main( - a, - b, - # Apply initial gates to track qubit allocation - qubit.RX(rad(0.1), a[0]), # Track as "original a[0]" - qubit.RY(rad(0.2), a[1]), # Track as "original a[1]" - qubit.RZ(rad(0.3), b[0]), # Track as "original b[0]" - qubit.SZ(b[1]), # Track as "original b[1]" - # Apply permutation - Permute( - [a[0], b[0]], - [b[0], a[0]], - ), - # Apply gates after permutation - qubit.RX(rad(0.4), a[0]), # Should apply to "original b[0]" - qubit.RY(rad(0.5), b[0]), # Should apply to "original a[0]" - qubit.T(a[1]), # Should apply to "original a[1]" - qubit.Tdg(b[1]), # Should apply to "original b[1]" - ) - - # Generate QIR - qir = SlrConverter(prog).qir() - - # Print the QIR for analysis - # print("\nQIR Output for rotation_gates_with_permutation:") - # print(qir) - - # Extract all gate operations to track qubit allocation - rx_calls = re.findall( - r"call void @__quantum__qis__rx__body\(double (0x[0-9a-f]+), %Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - ry_calls = re.findall( - r"call void @__quantum__qis__ry__body\(double (0x[0-9a-f]+), %Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - rz_calls = re.findall( - r"call void @__quantum__qis__rz__body\(double (0x[0-9a-f]+), %Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - s_calls = re.findall( - r"call void @__quantum__qis__s__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - t_calls = re.findall( - r"call void @__quantum__qis__t__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - tdg_calls = re.findall( - r"call void @__quantum__qis__t__adj\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)", - qir, - ) - - # print(f"Rx calls: {rx_calls}") - # print(f"Ry calls: {ry_calls}") - # print(f"Rz calls: {rz_calls}") - # print(f"S calls: {s_calls}") - # print(f"T calls: {t_calls}") - # print(f"Tdg calls: {tdg_calls}") - - # Based on the initial gates, we can infer the qubit allocation: - if len(rx_calls) >= 1 and len(ry_calls) >= 1 and len(rz_calls) >= 1 and len(s_calls) >= 1: - # Extract the qubit indices from the first calls - original_a0 = int(rx_calls[0][1]) - original_a1 = int(ry_calls[0][1]) - original_b0 = int(rz_calls[0][1]) - original_b1 = int(s_calls[0]) - - # print("Inferred qubit allocation:") - # print(f" original a[0] -> physical qubit {original_a0}") - # print(f" original a[1] -> physical qubit {original_a1}") - # print(f" original b[0] -> physical qubit {original_b0}") - # print(f" original b[1] -> physical qubit {original_b1}") - - # Now we can verify that the gates after permutations are applied to the correct qubits - if len(rx_calls) >= 2 and len(ry_calls) >= 2: - assert int(rx_calls[1][1]) == original_b0, ( - f"Second Rx gate should be applied to original b[0] " - f"(physical qubit {original_b0}), but was applied to physical qubit {rx_calls[1][1]}" - ) - assert int(ry_calls[1][1]) == original_a0, ( - f"Second Ry gate should be applied to original a[0] " - f"(physical qubit {original_a0}), but was applied to physical qubit {ry_calls[1][1]}" - ) - - if len(t_calls) >= 1 and len(tdg_calls) >= 1: - assert int(t_calls[0]) == original_a1, ( - f"T gate should be applied to original a[1] " - f"(physical qubit {original_a1}), but was applied to physical qubit {t_calls[0]}" - ) - assert int(tdg_calls[0]) == original_b1, ( - f"Tdg gate should be applied to original b[1] " - f"(physical qubit {original_b1}), but was applied to physical qubit {tdg_calls[0]}" - ) diff --git a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_register_permutation.py b/python/quantum-pecos/tests/slr/pecos/unit/slr/test_register_permutation.py deleted file mode 100644 index 177486356..000000000 --- a/python/quantum-pecos/tests/slr/pecos/unit/slr/test_register_permutation.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Tests for whole register permutation functionality in both QASM and QIR generation.""" - -import re - -import pytest -from pecos.slr import CReg, Main, Permute, QReg, SlrConverter -from pecos.slr.qeclib import qubit - -# Test fixtures - - -def create_whole_register_permutation_program() -> tuple: - """Create a program with permutation of whole registers.""" - a = CReg("a", 5) - b = CReg("b", 5) - - return Main( - a, - b, - Permute( - a, - b, - ), - b[2].set(1), # After permutation, this still refers to b[2] - a[3].set(0), # After permutation, this still refers to a[3] - ) - - -def create_mixed_permutation_program() -> tuple: - """Create a program with both whole register and element permutations.""" - a = QReg("a", 3) - b = QReg("b", 3) - c = QReg("c", 3) - - return Main( - a, - b, - c, - # First permute specific elements - Permute( - [a[0], c[1]], - [c[1], a[0]], - ), - # Then permute whole registers a and b - Permute( - a, - b, - ), - # Apply gates to see the effect of permutations - qubit.H(a[0]), # Should apply to c[1] after both permutations - qubit.X(b[1]), # Should apply to a[1] after the whole register permutation - qubit.Z(c[2]), # Should apply to c[2] since it's not permuted - ) - - -# QASM Tests - - -def test_whole_register_permutation_qasm() -> None: - """Test permutation of whole registers in QASM generation.""" - prog = create_whole_register_permutation_program() - qasm = SlrConverter(prog).qasm() - - # Print the QASM for debugging - print("\nQASM output:") - print(qasm) - - # Verify the permutation comment is correct - assert ( - "// Permutation: a <-> b" in qasm or "// Permuting: a <-> b" in qasm - ), f"Expected permutation comment not found in QASM:\n{qasm}" - - # Verify the XOR swap operations are generated - assert "a = a ^ b;" in qasm, f"Expected 'a = a ^ b;' not found in QASM:\n{qasm}" - assert "b = b ^ a;" in qasm, f"Expected 'b = b ^ a;' not found in QASM:\n{qasm}" - assert "a = a ^ b;" in qasm, f"Expected 'a = a ^ b;' not found in QASM:\n{qasm}" - - # Verify the temporary bit approach is NOT used for whole register permutations - assert "creg _bit_swap[1];" not in qasm, f"Unexpected 'creg _bit_swap[1];' found in QASM:\n{qasm}" - - # For classical registers, we're using XOR swap, which swaps the values, not the references. - # For bit-level operations, the permutation is applied, so b[2].set(1) becomes b[2] = 1; - # For register-level operations, the original register name is used, so a[3].set(0) becomes a[3] = 0; - assert "b[2] = 1;" in qasm, f"Expected 'b[2] = 1;' not found in QASM:\n{qasm}" - assert "a[3] = 0;" in qasm, f"Expected 'a[3] = 0;' not found in QASM:\n{qasm}" - - -def test_mixed_permutation_qasm() -> None: - """Test mixed whole register and element permutations in QASM generation.""" - prog = create_mixed_permutation_program() - qasm = SlrConverter(prog).qasm() - - # Verify the permutation comments are correct - assert ( - "// Permutation: a <-> b" in qasm or "// Permuting: a <-> b" in qasm - ), f"Expected permutation comment not found in QASM:\n{qasm}" - assert ( - "// Permutation: a[0] -> c[1], c[1] -> a[0]" in qasm - ), f"Expected permutation comment not found in QASM:\n{qasm}" - - # For QRegs, we're using the permutation map approach, not XOR swap - # So we shouldn't see XOR operations for QRegs - assert "a = a ^ b;" not in qasm, f"Unexpected XOR operation found in QASM:\n{qasm}" - - # Verify the operations after the permutation - # For quantum registers, we're using the permutation map approach - # So H(a[0]) should become H(c[1]) after both permutations - # X(b[1]) should become X(a[1]) after the whole register permutation - # Z(c[2]) remains Z(c[2]) since it's not permuted - assert "h c[1]" in qasm, f"Expected 'h c[1]' not found in QASM:\n{qasm}" - assert "x a[1]" in qasm, f"Expected 'x a[1]' not found in QASM:\n{qasm}" - assert "z c[2]" in qasm, f"Expected 'z c[2]' not found in QASM:\n{qasm}" - - -# QIR Tests - - -@pytest.mark.optional_dependency -def test_whole_register_permutation_qir() -> None: - """Test permutation of whole registers in QIR generation.""" - prog = create_whole_register_permutation_program() - qir = SlrConverter(prog).qir() - - # Verify the permutation comment is present - assert "; Permutation: a <-> b" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify the XOR operations are present - assert "xor" in qir, f"Expected XOR operations not found in QIR:\n{qir}" - - # Verify the temporary bit approach is NOT used for whole register permutations - assert ( - "_bit_swap = call i1* @create_creg(i64 1)" not in qir - ), f"Unexpected '_bit_swap = call i1* @create_creg(i64 1)' found in QIR:\n{qir}" - assert "call i1 @get_creg_bit" not in qir, f"Unexpected 'call i1 @get_creg_bit' found in QIR:\n{qir}" - - # Verify the set operations are present - set_pattern = r"call void @set_creg_bit\(i1\* %(\w+), i64 (\d+), i1 (\d+)\)" - set_calls = re.findall(set_pattern, qir) - - # Verify the operations after the permutation - # Since we're swapping values, not references, the operations should still refer to the original registers - b2_set = False - a3_set = False - for reg, idx, val in set_calls: - if reg == "b" and idx == "2" and val == "1": - b2_set = True - if reg == "a" and idx == "3" and val == "0": - a3_set = True - - assert b2_set, f"Expected set_creg_bit(b, 2, 1) not found in QIR:\n{qir}" - assert a3_set, f"Expected set_creg_bit(a, 3, 0) not found in QIR:\n{qir}" - - -@pytest.mark.optional_dependency -def test_mixed_permutation_qir() -> None: - """Test mixed whole register and element permutations in QIR generation.""" - prog = create_mixed_permutation_program() - qir = SlrConverter(prog).qir() - - # Print the QIR for debugging - print("\nQIR output:") - print(qir) - - # Verify the permutation comments are present - assert "; Permutation: a[0] -> c[1], c[1] -> a[0]" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - assert "; Permutation: a <-> b" in qir, f"Expected permutation comment not found in QIR:\n{qir}" - - # Verify that the QIR contains the correct quantum operations after permutations - # H gate should be applied to c[1] (after both permutations) - # The exact qubit index depends on how QIR allocates qubits, but we can check for the pattern - h_gate_pattern = r"call void @__quantum__qis__h__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - h_gates = re.findall(h_gate_pattern, qir) - assert len(h_gates) >= 1, f"Expected at least one H gate, found {len(h_gates)}" - - # X gate should be applied to a[1] (after the whole register permutation) - x_gate_pattern = r"call void @__quantum__qis__x__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - x_gates = re.findall(x_gate_pattern, qir) - assert len(x_gates) >= 1, f"Expected at least one X gate, found {len(x_gates)}" - - # Z gate should be applied to c[2] (which is not permuted) - z_gate_pattern = r"call void @__quantum__qis__z__body\(%Qubit\* inttoptr \(i64 (\d+) to %Qubit\*\)\)" - z_gates = re.findall(z_gate_pattern, qir) - assert len(z_gates) >= 1, f"Expected at least one Z gate, found {len(z_gates)}" - - # Verify that the qubit indices for the gates are different - # This ensures that the permutations are being applied correctly - all_gates = h_gates + x_gates + z_gates - assert len(set(all_gates)) == len( - all_gates, - ), f"Expected all gates to be applied to different qubits, found duplicates: {all_gates}" From d3d03a2c38be46a0d3761ae8f84bbf26cb462c20 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 18:46:26 -0600 Subject: [PATCH 312/388] Fix guppy constant rotation angles silently executing as zero-angle gates by tracing the static angle extraction through guppy's 1-tuple wrap, with a Rust regression test and the real-quantum-circuits tests rewritten to the actual result contract --- crates/pecos-hugr/src/engine.rs | 35 ++++++ crates/pecos-quantum/src/hugr_convert.rs | 17 ++- .../tests/test_data/hugr/ry_angle_tuple.hugr | Bin 0 -> 3023 bytes .../tests/guppy/test_real_quantum_circuits.py | 111 ++++++------------ 4 files changed, 88 insertions(+), 75 deletions(-) create mode 100644 crates/pecos/tests/test_data/hugr/ry_angle_tuple.hugr diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 49785da0d..c0bc567c3 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1849,6 +1849,41 @@ mod tests { assert!(engine.quantum_ops.is_empty()); } + #[test] + fn test_ry_angle_through_tuple_wrap() { + // Guppy lowers `ry(q, angle(0.5))` with the angle constant wrapped in + // a 1-tuple (Const -> LoadConstant -> MakeTuple -> UnpackTuple -> + // from_halfturns_unchecked -> Ry). The static angle extraction must + // trace through the tuple wrap/unwrap; a miss used to silently become + // RY(0) (issue observed as all-|0> Bell statistics in + // test_real_quantum_circuits.py::test_rotation_gates). + let hugr_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/ry_angle_tuple.hugr" + ); + let engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); + + let ry_ops: Vec<_> = engine + .quantum_ops + .values() + .filter(|op| op.gate_type == GateType::RY) + .collect(); + assert_eq!(ry_ops.len(), 1, "Expected exactly one RY op"); + + // angle(0.5) = 0.5 half-turns = pi/2 radians. + let params = &ry_ops[0].params; + assert_eq!( + params.len(), + 1, + "RY angle was not statically extracted (tuple wrap/unwrap not traced)" + ); + let angle = params[0]; + assert!( + (angle - std::f64::consts::FRAC_PI_2).abs() < 1e-12, + "RY angle should be pi/2 radians, got {angle}" + ); + } + #[test] fn test_load_single_hadamard() { // Load the single_hadamard.hugr test file diff --git a/crates/pecos-quantum/src/hugr_convert.rs b/crates/pecos-quantum/src/hugr_convert.rs index 1d3404c3f..395549fb7 100644 --- a/crates/pecos-quantum/src/hugr_convert.rs +++ b/crates/pecos-quantum/src/hugr_convert.rs @@ -28,7 +28,7 @@ use tket::TketOp; use tket::extension::rotation::ConstRotation; use tket::hugr::builder::{DFGBuilder, Dataflow, DataflowHugr}; use tket::hugr::extension::prelude::qb_t; -use tket::hugr::ops::OpType; +use tket::hugr::ops::{OpTrait, OpType}; use tket::hugr::types::Signature; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, NodeIndex, PortIndex, Wire}; @@ -462,6 +462,21 @@ fn trace_back_for_const(hugr: &Hugr, node: Node, depth: usize) -> Option<(f64, b if let Some((src_node, _)) = hugr.single_linked_output(node, input_port) { return trace_back_for_const(hugr, src_node, depth + 1); } + } else if format!("{op:?}").contains("MakeTuple") + && op + .dataflow_signature() + .is_some_and(|sig| sig.input_count() == 1) + { + // Guppy wraps a rotation angle in a 1-tuple between the constant and + // `from_halfturns_unchecked` (Const -> LoadConstant -> MakeTuple -> + // UnpackTuple -> from_halfturns_unchecked), so trace through the + // single element. Multi-element tuples are ambiguous here (the + // element index is not tracked), so those fall through to the + // runtime value path. + let input_port = IncomingPort::from(0); + if let Some((src_node, _)) = hugr.single_linked_output(node, input_port) { + return trace_back_for_const(hugr, src_node, depth + 1); + } } // For Call nodes, try to evaluate if it's an arithmetic operation diff --git a/crates/pecos/tests/test_data/hugr/ry_angle_tuple.hugr b/crates/pecos/tests/test_data/hugr/ry_angle_tuple.hugr new file mode 100644 index 0000000000000000000000000000000000000000..41f41934cec26f523621096bb2ed37c81d189450 GIT binary patch literal 3023 zcmV;=3o!IZRYy{3NJ@4BK`6B^{Qy|yT>#39h#Xa*7S*H!z$h>pNCIMV53B&oj1dBc znCJ$$0d9hVtRSfH8yxlPAe)5R64Rw`wzlq8IgZ<)(Z@9y4Z6W?uC`*-Bm>EgY`pd$ z`)xDdheaa&H`z%Bu>qt3uK}MTEk5P%sJ$+hLmZR%6lvd8ec0tVe2TQ!uMaARE{`i^ zkLy8wH>y5tMD{rBdVSB{9s1x?q;-23+yG_seXEqMtA$fTBYcXq4r88Dm!mP}Ddp>v zhk`yunzt(552req;8UdiB_#9r(Y@+y59;-mjzW2yZJ|&@a?X-DM>v@go7jCwO~w+a zj3!VLQlK)fKv8Ics^9`;AqFa=3>1bMsEjvI8gigEoVsPyfy&qe&0-H!#vkY(c{n-n zEdD@cB$7oS=pTJJMQ|C3P#KA!StNqWSOop!5GMyNBN9}`A~cId=qMJUe>~#Uz-3&5 z$}BoT|ER>t#$|+p$}C1f|M#=xmtVh~V?$xw;u_%R7i-6KqfW<03Gde9$x zIdzXb1R4@1NMfiF#0U_Sgmn_rja|}Q^aMpM7Bd1kv|E6WqFsrWqutp@@n;lY zvxE{CPGSxuL})k4Qz8*5v4|`dq1`7RMJKWt1??`mHi}u$ZjyJ)OD)4D?H+k|ywoHn zgLcc0u$Tvc;g|rEq1~7^!g$c`KLzU{F&x?*UxHcM9ks{DI1%sxTucE*Q{^K%1DI1w znE z*TjIEBW}vpbkVxyYi9KG9LyNmYf7OhwRu*d)1nAW|&oT|A#C4V7TNWMIkf(&G8XHy&t zW1#Y^{jLjdl_Vr2w9RZ==2Bee0@ATn)5fFnLd>&O(vD6wZD7lO*5!KT+F{I7Z}?+f zxOy^MHMzJi1zy!Qq?)!N)wEU85S`zG3Z~Gv8!@FA88M|BHA*vrz<{0gzMDM`=B2Vc z+r3<_khN8lp$}*K-5l**76mhN=QQva6VnQ_XTzUL653{zXq0Ab)wFq-1HT(&tE8>l zs;OnWE$eMVbcR3=b19&AGtfn;e!);ig1Mov-iB_=dMOcHd)y|=acC{ujY;D$ev?__ zWzv9|FNb-Vq+d=2{_@ND@)s?}FXsU1sO_e4nd6>4#9QgoQIac zX%fh*WDwT}Nep@AhDbBM#QeKE+;6~;=ZUy6E zuYz2ywTM} z3cn0`c&4s8+f!G#ewU{edKI))b!V_V?#rjGnl@M7hE&zfHtcMWTQ5)bY^rA4ldFe0 z5WJrY=+>93^#ZG{_rv+ru{?`PaUJGqy}+EDoE#h+9Gn{Z;H{xa;jWXRxnBi4cI?Q> z$;rXN!P(i_ZnjE_(W#`JEoZBw89FyNH#0OeVXK8xTTsnRP|Zw?&qcaH8g{6ptguju_LB#YLh+27AFej~J_@-Weo`EhY+UR*XRv9k51L19^3CmfeQNcSK8w^Rg6R5lUN-DN_8ijQq=fRF3nR8?(5yi_W1LKK%K}zL4)dQpa zkp5|jeA=i=tjJzLXahhLaw61M07b3KUva5rNLDc&WwQi|vCcAz?JbX&HI!@PZOZh4 ziQACiGs0KD%1?DwHNtw!>BT4()h>ObK=jNgx6r8t4ep zY+-NZ`+?xr3H!?Lm$d=|EDUfqkCGduox{MA4nh>mNDhuZWISZ-(En!cdM^R!qo%yU zZ=HFcw-jR)+QQO(Vxg6nffI8Q-_b4F6f!yVvB+N$saqed6Qf@K0h9QV=~%AI21fSM z7|OE0)Y}Flu{)sSBK867UVMf-QqhMmUC_7h;5X`lG;UlJ&htWQs_B{?{stlSq~oH_ zrXgFp1>y8|Dr25>^wrXVi!})*au%=Av^F|s8=ri=Y+Sz7pzK5%%+}GQNlTlxps2_z{1xfTb(9Vy;BXO-$-;Y`!n2J9 zWFdO9#I`dt?3u+EJ>UC{w~EY!s0k=v;Uz&5O4*gq>4T#6$U0$pCX5_IyVkdN@y#)j z%4B`kxa`F#PoJ*pDQ`u%=trX`0X=xbBQnP;T#F%?%+5Jmc5BuxCqqH#cU&J%EO>6@ zC{p-IHc%b6!F}*=vyY24vPbcGb3`(2a1|g~ZtZ)wjy-!F^QQil@t*z1{FNJYl^A?3 zzzbRlwwns;F=Qvk9!tPh7NEy4YUAe};l#FV^}RcsMfMnFDjG7;C>*FhTzXnTXGp(1 zPcsVdN_h^R#uFhH6%Do>NhaUdMC<+j;iYELo`*z|0w6hxBvhAV?JtUCkHGm@r8O^j zOYO*v_NtTZFi4Z~Nkx$aD|KR2`%gH=WXCpVM)4v72bPVRuJ0O%WsIm5viqnJ+0d+- RWbZpl-6YxcR@j7ngc>IctHA&O literal 0 HcmV?d00001 diff --git a/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py b/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py index 6992c35fe..b83a79da7 100644 --- a/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py +++ b/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py @@ -33,23 +33,13 @@ def prepare_bell_state() -> tuple[bool, bool]: # Use seed for reproducibility shot_vec = sim(Guppy(prepare_bell_state)).qubits(2).quantum(state_vector()).seed(42).run(1000) assert shot_vec is not None, "Should get results" - results = shot_vec.to_dict() - # Count outcomes - both_zero = 0 - both_one = 0 - anti_correlated = 0 - - # Results come as a dict with measurement keys - m1_list = results.get("measurement_0", []) - m2_list = results.get("measurement_1", []) - - for m1, m2 in zip(m1_list, m2_list, strict=False): - if m1 == 0 and m2 == 0: - both_zero += 1 - elif m1 == 1 and m2 == 1: - both_one += 1 - else: - anti_correlated += 1 + # "measurements" holds one row per shot, ordered like the returned tuple. + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" + + both_zero = sum(1 for m1, m2 in shots if (m1, m2) == (0, 0)) + both_one = sum(1 for m1, m2 in shots if (m1, m2) == (1, 1)) + anti_correlated = len(shots) - both_zero - both_one # Bell state should only produce correlated outcomes assert anti_correlated == 0, f"Bell state should not produce anti-correlated outcomes, got {anti_correlated}" @@ -87,24 +77,14 @@ def prepare_ghz_state() -> tuple[bool, bool, bool]: # Run simulation with state_vector backend shot_vec = sim(Guppy(prepare_ghz_state)).qubits(3).quantum(state_vector()).seed(42).run(1000) assert shot_vec is not None, "Should get results" - results = shot_vec.to_dict() + # "measurements" holds one row per shot, ordered like the returned tuple. + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" # GHZ state should give either all 0s or all 1s - all_zero = 0 - all_one = 0 - other = 0 - - m1_list = results.get("measurement_0", []) - m2_list = results.get("measurement_1", []) - m3_list = results.get("measurement_2", []) - - for m1, m2, m3 in zip(m1_list, m2_list, m3_list, strict=False): - if m1 == 0 and m2 == 0 and m3 == 0: - all_zero += 1 - elif m1 == 1 and m2 == 1 and m3 == 1: - all_one += 1 - else: - other += 1 + all_zero = sum(1 for m1, m2, m3 in shots if (m1, m2, m3) == (0, 0, 0)) + all_one = sum(1 for m1, m2, m3 in shots if (m1, m2, m3) == (1, 1, 1)) + other = len(shots) - all_zero - all_one # GHZ state should only produce |000⟩ or |111⟩ assert other == 0, f"GHZ state should not produce mixed outcomes, got {other}" @@ -143,25 +123,17 @@ def phase_kickback_circuit() -> tuple[bool, bool]: return (m1, m2) # Run simulation with state_vector backend - results = sim(Guppy(phase_kickback_circuit)).qubits(2).quantum(state_vector()).seed(42).run(1000) - assert results is not None, "Should get results" + shot_vec = sim(Guppy(phase_kickback_circuit)).qubits(2).quantum(state_vector()).seed(42).run(1000) + assert shot_vec is not None, "Should get results" + # "measurements" holds one row per shot, ordered like the returned tuple. + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" # The control qubit should measure |1⟩ in X basis (due to phase kickback) # The target should remain in |1⟩ - control_one_count = 0 - target_one_count = 0 - total = 0 - - if hasattr(results, "__getitem__"): - m1_list = results.get("measurement_0", []) - m2_list = results.get("measurement_1", []) - - for m1, m2 in zip(m1_list, m2_list, strict=False): - total += 1 - if m1 == 1: - control_one_count += 1 - if m2 == 1: - target_one_count += 1 + control_one_count = sum(1 for m1, _ in shots if m1 == 1) + target_one_count = sum(1 for _, m2 in shots if m2 == 1) + total = len(shots) # Control should be predominantly |1⟩ due to phase kickback assert ( @@ -192,19 +164,15 @@ def quantum_interferometer() -> bool: return measure(q) # Run simulation with state_vector backend - results = sim(Guppy(quantum_interferometer)).qubits(1).quantum(state_vector()).seed(42).run(1000) - assert results is not None, "Should get results" + shot_vec = sim(Guppy(quantum_interferometer)).qubits(1).quantum(state_vector()).seed(42).run(1000) + assert shot_vec is not None, "Should get results" + # "measurements" holds one single-element row per shot. + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" # Due to interference, should measure |1⟩ ~100% of the time - one_count = 0 - total = 0 - - if hasattr(results, "__getitem__"): - measurements = results.get("measurement_0", []) - for m in measurements: - total += 1 - if m == 1: - one_count += 1 + one_count = sum(1 for (m,) in shots if m == 1) + total = len(shots) assert one_count / total > 0.95, f"Should measure |1⟩ due to interference, got {one_count / total}" @@ -229,24 +197,19 @@ def rotation_circuit() -> bool: return measure(q) # Run simulation with state_vector backend - results = sim(Guppy(rotation_circuit)).qubits(1).quantum(state_vector()).seed(42).run(1000) + shot_vec = sim(Guppy(rotation_circuit)).qubits(1).quantum(state_vector()).seed(42).run(1000) - assert results is not None, "Should get results" + assert shot_vec is not None, "Should get results" + # "measurements" holds one single-element row per shot. + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 1000, "Should have one measurement row per shot" # After Ry(π/2), should be in equal superposition # Rz just adds phase, doesn't change measurement probabilities - zero_count = 0 - one_count = 0 - - if hasattr(results, "__getitem__"): - measurements = results.get("measurement_0", []) - for m in measurements: - if m == 0: - zero_count += 1 - else: - one_count += 1 - - total = zero_count + one_count + zero_count = sum(1 for (m,) in shots if m == 0) + one_count = len(shots) - zero_count + + total = len(shots) # Should be roughly 50/50 after Ry(π/2) assert 0.4 < zero_count / total < 0.6, f"Should be ~50% |0⟩ after Ry(π/2), got {zero_count / total}" assert 0.4 < one_count / total < 0.6, f"Should be ~50% |1⟩ after Ry(π/2), got {one_count / total}" From 06c7e29bb9fc14438afe3c6826ea53e14b7aa653 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 19:10:51 -0600 Subject: [PATCH 313/388] Strengthen the tier2 mz/read_result tripwire to count-check both regex halves and correct the measurements-ordering comments to the actual qubit-id-order contract --- .../tests/guppy/test_real_quantum_circuits.py | 9 ++++++--- .../ast_guppy/test_tier2_semantic.py | 20 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py b/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py index b83a79da7..99108710d 100644 --- a/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py +++ b/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py @@ -33,7 +33,8 @@ def prepare_bell_state() -> tuple[bool, bool]: # Use seed for reproducibility shot_vec = sim(Guppy(prepare_bell_state)).qubits(2).quantum(state_vector()).seed(42).run(1000) assert shot_vec is not None, "Should get results" - # "measurements" holds one row per shot, ordered like the returned tuple. + # "measurements" holds one row per shot in qubit-id order (each qubit is + # measured once here, so this matches the returned tuple's order). shots = shot_vec.to_dict()["measurements"] assert len(shots) == 1000, "Should have one measurement row per shot" @@ -77,7 +78,8 @@ def prepare_ghz_state() -> tuple[bool, bool, bool]: # Run simulation with state_vector backend shot_vec = sim(Guppy(prepare_ghz_state)).qubits(3).quantum(state_vector()).seed(42).run(1000) assert shot_vec is not None, "Should get results" - # "measurements" holds one row per shot, ordered like the returned tuple. + # "measurements" holds one row per shot in qubit-id order (each qubit is + # measured once here, so this matches the returned tuple's order). shots = shot_vec.to_dict()["measurements"] assert len(shots) == 1000, "Should have one measurement row per shot" @@ -125,7 +127,8 @@ def phase_kickback_circuit() -> tuple[bool, bool]: # Run simulation with state_vector backend shot_vec = sim(Guppy(phase_kickback_circuit)).qubits(2).quantum(state_vector()).seed(42).run(1000) assert shot_vec is not None, "Should get results" - # "measurements" holds one row per shot, ordered like the returned tuple. + # "measurements" holds one row per shot in qubit-id order (each qubit is + # measured once here, so this matches the returned tuple's order). shots = shot_vec.to_dict()["measurements"] assert len(shots) == 1000, "Should have one measurement row per shot" diff --git a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py index 083bcec67..16918a7d1 100644 --- a/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py +++ b/python/quantum-pecos/tests/slr_tests/ast_guppy/test_tier2_semantic.py @@ -112,11 +112,21 @@ def _assert_mz_rr_pairing(label: str, ir: str) -> None: events.append(("mz", _slot(m.group(1)))) else: events.append(("rr", _slot(m.group(2)))) - # Tripwire: if the emitted pointer syntax ever drifts away from the - # event regex again, fail loud instead of vacuously passing. (Match - # call sites only -- the `declare` preamble is always present.) - if "call void @__quantum__qis__mz__body" in ir: - assert events, f"{label}: mz calls present but _MZ_RR_EVENT matched none; pattern out of sync with emitted IR" + # Tripwire: if the emitted pointer syntax ever drifts away from EITHER + # half of the event regex, fail loud instead of vacuously passing. + # Count raw call sites (the `declare` preamble lines do not contain + # "call") and require the regex to have matched every one of them. + mz_calls = ir.count("call void @__quantum__qis__mz__body") + rr_calls = ir.count("call i1 @__quantum__rt__read_result") + mz_events = sum(1 for kind, _ in events if kind == "mz") + rr_events = sum(1 for kind, _ in events if kind == "rr") + assert ( + mz_events == mz_calls + ), f"{label}: _MZ_RR_EVENT matched {mz_events} of {mz_calls} mz calls; pattern out of sync with emitted IR" + assert rr_events == rr_calls, ( + f"{label}: _MZ_RR_EVENT matched {rr_events} of {rr_calls} read_result calls; " + f"pattern out of sync with emitted IR" + ) mz_slots = [s for kind, s in events if kind == "mz"] assert mz_slots == list(range(len(mz_slots))), f"{label}: mz result slots not monotonic 0..n-1: {mz_slots}" for i, (kind, slot) in enumerate(events): From 795c53f206c07e736cb3ac43aa3b970a9eb82101 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 23:50:49 -0600 Subject: [PATCH 314/388] Fail loud on unresolvable rotation angles instead of silently emitting zero-angle gates, fixing the runtime angle chain (tuple-valued constants, prelude classical-op port counts, static fallback for expression angles) with fixture-backed engine regression tests --- crates/pecos-hugr/src/engine.rs | 96 ++++++++++++++++-- crates/pecos-hugr/src/engine/analysis.rs | 14 ++- crates/pecos-hugr/src/engine/handlers.rs | 4 +- .../pecos-hugr/src/engine/handlers/quantum.rs | 40 ++++++-- crates/pecos-hugr/src/engine/propagation.rs | 86 +++++++++++----- .../test_data/hugr/rx_pi_tuple_const.hugr | Bin 0 -> 3067 bytes 6 files changed, 191 insertions(+), 49 deletions(-) create mode 100644 crates/pecos/tests/test_data/hugr/rx_pi_tuple_const.hugr diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index c0bc567c3..86bfc0d64 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1195,7 +1195,7 @@ impl HugrEngine { let qubits = self.resolve_qubits(&hugr, current_node, &op); // Emit the gate operation - if self.emit_quantum_gate(&hugr, current_node, &op, &qubits) { + if self.emit_quantum_gate(&hugr, current_node, &op, &qubits)? { hit_measurement = true; } @@ -1243,7 +1243,7 @@ impl HugrEngine { node: Node, op: &QuantumOp, qubits: &[QubitId], - ) -> bool { + ) -> Result { let mut hit_measurement = false; match op.gate_type { @@ -1293,17 +1293,17 @@ impl HugrEngine { ); } GateType::RX => { - let angle = self.resolve_rotation_angle(hugr, node, op); + let angle = self.resolve_rotation_angle(hugr, node, op)?; self.message_builder .rx(Angle64::from_radians(angle), &[qubits[0].0]); } GateType::RY => { - let angle = self.resolve_rotation_angle(hugr, node, op); + let angle = self.resolve_rotation_angle(hugr, node, op)?; self.message_builder .ry(Angle64::from_radians(angle), &[qubits[0].0]); } GateType::RZ => { - let angle = self.resolve_rotation_angle(hugr, node, op); + let angle = self.resolve_rotation_angle(hugr, node, op)?; self.message_builder .rz(Angle64::from_radians(angle), &[qubits[0].0]); } @@ -1356,7 +1356,7 @@ impl HugrEngine { ); } GateType::CRZ => { - let angle = self.resolve_rotation_angle(hugr, node, op); + let angle = self.resolve_rotation_angle(hugr, node, op)?; let half_angle = angle / 2.0; self.message_builder .rz(Angle64::from_radians(half_angle), &[qubits[1].0]); @@ -1424,7 +1424,7 @@ impl HugrEngine { } } - hit_measurement + Ok(hit_measurement) } /// Resolve a rotation angle for a quantum gate. @@ -1434,10 +1434,15 @@ impl HugrEngine { /// which is needed when angles are computed dynamically (e.g., guppylang's CH /// decomposition passes rotation values through MakeTuple/UnpackTuple chains /// that can't be statically traced). - fn resolve_rotation_angle(&self, hugr: &Hugr, node: Node, op: &QuantumOp) -> f64 { + fn resolve_rotation_angle( + &self, + hugr: &Hugr, + node: Node, + op: &QuantumOp, + ) -> Result { // Try statically extracted params first (already in radians) if let Some(&angle) = op.params.first() { - return angle; + return Ok(angle); } // Fall back to runtime classical value at the angle input port. // The angle port is after all qubit inputs. @@ -1445,9 +1450,14 @@ impl HugrEngine { && let Some(halfturns) = value.as_rotation() { // Convert half-turns to radians: halfturns * pi - return halfturns * std::f64::consts::PI; + return Ok(halfturns * std::f64::consts::PI); } - 0.0 + // No silent default: a zero angle would turn the gate into a no-op and + // corrupt the simulated physics without any visible failure. + Err(PecosError::Input(format!( + "{:?} at {node:?}: rotation angle unavailable (no static extraction, no runtime value); refusing to default to 0", + op.gate_type + ))) } /// Queue ready successor nodes after processing a node. @@ -1849,6 +1859,39 @@ mod tests { assert!(engine.quantum_ops.is_empty()); } + #[test] + fn test_ry_angle_tuple_runtime_execution() { + // End-to-end guard for the RUNTIME classical value chain of guppy's + // tuple-wrapped rotation angle (Const -> LoadConstant -> MakeTuple -> + // UnpackTuple -> from_halfturns_unchecked -> Ry). The tuple prelude + // ops execute as classical ops whose num_inputs must come from the + // dataflow signature -- the portgraph count includes the order port, + // which used to starve the chain and (before the fail-loud hardening) + // silently zero the angle. With the hardening, a starved chain makes + // this generate_commands call error instead of passing. + let hugr_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/ry_angle_tuple.hugr" + ); + let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); + + let msg = engine + .generate_commands() + .expect("Failed to generate commands"); + let ops = msg.quantum_ops().expect("Failed to parse quantum ops"); + + let ry_cmd = ops + .iter() + .find(|g| g.gate_type == GateType::RY) + .expect("Expected an RY command"); + assert_eq!(ry_cmd.angles.len(), 1, "RY command should carry its angle"); + let radians = ry_cmd.angles[0].to_radians(); + assert!( + (radians - std::f64::consts::FRAC_PI_2).abs() < 1e-9, + "RY command should have angle pi/2, got {radians}", + ); + } + #[test] fn test_ry_angle_through_tuple_wrap() { // Guppy lowers `ry(q, angle(0.5))` with the angle constant wrapped in @@ -1884,6 +1927,37 @@ mod tests { ); } + #[test] + fn test_rx_pi_tuple_const_runtime_execution() { + // Like test_ry_angle_tuple_runtime_execution, but for guppy's `pi` + // constant, which lowers the angle as a TUPLE-VALUED Const + // (Const(Tuple(FloatVal)) -> LoadConstant -> UnpackTuple -> + // from_halfturns_unchecked -> Rx) with no MakeTuple node. The runtime + // constant loader must convert tuple constants element-wise or the + // chain starves and (with the fail-loud hardening) this errors. + let hugr_path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/rx_pi_tuple_const.hugr" + ); + let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); + + let msg = engine + .generate_commands() + .expect("Failed to generate commands"); + let ops = msg.quantum_ops().expect("Failed to parse quantum ops"); + + let rx_cmd = ops + .iter() + .find(|g| g.gate_type == GateType::RX) + .expect("Expected an RX command"); + assert_eq!(rx_cmd.angles.len(), 1, "RX command should carry its angle"); + let radians = rx_cmd.angles[0].to_radians(); + assert!( + (radians - std::f64::consts::PI).abs() < 1e-9, + "RX command should have angle pi, got {radians}", + ); + } + #[test] fn test_load_single_hadamard() { // Load the single_hadamard.hugr test file diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index d7b128062..f782f87ac 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -35,7 +35,7 @@ use pecos_core::gate_type::GateType; use pecos_quantum::hugr_convert::{ hugr_op_to_gate_type, is_rotation_gate, try_extract_rotation_angle, }; -use tket::hugr::ops::OpType; +use tket::hugr::ops::{OpTrait, OpType}; use tket::hugr::{Hugr, HugrView, Node}; use super::types::{ @@ -654,8 +654,16 @@ pub fn extract_classical_ops(hugr: &Hugr) -> BTreeMap { }, // Prelude extension (tuples, etc.) "prelude" => { - let num_inputs = hugr.num_inputs(node); - let num_outputs = hugr.num_outputs(node); + // Use the dataflow-signature port counts, NOT hugr.num_inputs/ + // num_outputs: the portgraph counts include the order port, and + // an inflated num_inputs makes handle_classical_op wait forever + // on an order-port "value" that never arrives (starving every + // consumer downstream of the tuple). + let Some(sig) = op.dataflow_signature() else { + continue; + }; + let num_inputs = sig.input_count(); + let num_outputs = sig.output_count(); match op_name.as_str() { "MakeTuple" => (ClassicalOpType::MakeTuple, num_inputs, 1, None), "UnpackTuple" => (ClassicalOpType::UnpackTuple, 1, num_outputs, None), diff --git a/crates/pecos-hugr/src/engine/handlers.rs b/crates/pecos-hugr/src/engine/handlers.rs index e36eff453..3667cde75 100644 --- a/crates/pecos-hugr/src/engine/handlers.rs +++ b/crates/pecos-hugr/src/engine/handlers.rs @@ -94,7 +94,9 @@ impl HugrEngine { /// /// # Returns /// - /// Returns `true` if the operation was handled, `false` otherwise. + /// Returns `true` if the operation was handled, `false` otherwise + /// (including when a handler's inputs are not ready yet -- the caller + /// defers such nodes for retry). pub(crate) fn handle_extension_op(&mut self, hugr: &Hugr, node: Node) -> bool { let op = hugr.get_optype(node); let Some(ext_op) = op.as_extension_op() else { diff --git a/crates/pecos-hugr/src/engine/handlers/quantum.rs b/crates/pecos-hugr/src/engine/handlers/quantum.rs index 8eb839872..111f48e1a 100644 --- a/crates/pecos-hugr/src/engine/handlers/quantum.rs +++ b/crates/pecos-hugr/src/engine/handlers/quantum.rs @@ -24,6 +24,7 @@ //! not through these extension handlers. use log::debug; +use pecos_quantum::hugr_convert::try_extract_rotation_angle; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; @@ -133,17 +134,33 @@ impl HugrEngine { } /// Handle `tket.rotation` operations. + /// + /// Missing inputs DEFER the node (return `false` so the engine's + /// pending/retry mechanism picks it up) instead of fabricating a + /// silent 0.0 rotation. `from_halfturns` additionally falls back to + /// the static angle tracer: some angle chains are only evaluable + /// statically (e.g. guppy expression angles like `pi / 4` lower to + /// Call nodes the runtime does not execute). If neither source can + /// produce the value, the node stays deferred and the gate consumer + /// (`resolve_rotation_angle`) fails loud if the statically extracted + /// gate angle is also missing. pub(crate) fn handle_rotation_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { debug!("Processing tket.rotation operation: {op_name} at {node:?}"); match op_name { "from_halfturns" | "from_halfturns_unchecked" => { // from_halfturns: float64 -> Rotation - // Convert a float (in half-turns) to a Rotation type - let halfturns = self + // Convert a float (in half-turns) to a Rotation type. + // try_extract_rotation_angle traces this node's input 0 (0 + // qubit inputs) and returns full turns; halfturns = 2 * turns. + let Some(halfturns) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - .unwrap_or(0.0); + .or_else(|| try_extract_rotation_angle(hugr, node, 0).map(|turns| turns * 2.0)) + else { + debug!("tket.rotation.{op_name} at {node:?}: input not ready, deferring"); + return false; + }; self.wire_state .classical_values @@ -155,10 +172,13 @@ impl HugrEngine { "to_halfturns" => { // to_halfturns: Rotation -> float64 // Convert a Rotation to a float (in half-turns) - let halfturns = self + let Some(halfturns) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_rotation()) - .unwrap_or(0.0); + else { + debug!("tket.rotation.to_halfturns at {node:?}: input not ready, deferring"); + return false; + }; self.wire_state .classical_values @@ -172,12 +192,14 @@ impl HugrEngine { // Add two rotations let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_rotation()) - .unwrap_or(0.0); + .and_then(|v| v.as_rotation()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_rotation()) - .unwrap_or(0.0); + .and_then(|v| v.as_rotation()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.rotation.radd at {node:?}: inputs not ready, deferring"); + return false; + }; // Rotation addition, normalized to [0, 2) half-turns let sum = (a + b).rem_euclid(2.0); diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index 9e5edd155..3cb565320 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -25,7 +25,7 @@ use log::debug; use pecos_core::QubitId; use pecos_core::gate_type::GateType; -use tket::hugr::ops::OpType; +use tket::hugr::ops::{OpType, Value}; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; @@ -329,12 +329,9 @@ impl HugrEngine { /// extracts the value from the Const node and returns it as a `ClassicalValue`. /// /// Supports integer constants (`ConstInt`), float constants (`ConstF64`), - /// and boolean constants (`ConstBool`). + /// boolean constants (`ConstBool`), and tuple constants of those (e.g. + /// guppy's `pi` angle constant lowers to `Const(Tuple(FloatVal))`). pub(crate) fn try_load_constant(hugr: &Hugr, node: Node) -> Option { - use tket::extension::bool::ConstBool; - use tket::hugr::std_extensions::arithmetic::float_types::ConstF64; - use tket::hugr::std_extensions::arithmetic::int_types::ConstInt; - // LoadConstant has a static edge from a Const node for pred_node in hugr.input_neighbours(node) { let pred_op = hugr.get_optype(pred_node); @@ -346,29 +343,68 @@ impl HugrEngine { value.get_type() ); - // Try to extract as ConstInt - if let Some(const_int) = value.get_custom_value::() { - // ConstInt can be signed or unsigned - let int_value = const_int.value_s(); - debug!("try_load_constant: found ConstInt with value {int_value}"); - return Some(ClassicalValue::Int(int_value)); + if let Some(classical) = Self::const_value_to_classical(value) { + return Some(classical); } - // Try to extract as ConstF64 - if let Some(const_f64) = value.get_custom_value::() { - let float_value = const_f64.value(); - debug!("try_load_constant: found ConstF64 with value {float_value}"); - return Some(ClassicalValue::Float(float_value)); - } + debug!("try_load_constant: unrecognized const type"); + } + } - // Try to extract as ConstBool - if let Some(const_bool) = value.get_custom_value::() { - let bool_value = const_bool.value(); - debug!("try_load_constant: found ConstBool with value {bool_value}"); - return Some(ClassicalValue::Bool(bool_value)); - } + None + } - debug!("try_load_constant: unrecognized const type"); + /// Convert a HUGR constant `Value` to a runtime `ClassicalValue`. + /// + /// Tuple constants convert element-wise so a downstream `UnpackTuple` + /// can unpack them at runtime exactly like a `MakeTuple`-built tuple. + fn const_value_to_classical(value: &Value) -> Option { + use tket::extension::bool::ConstBool; + use tket::hugr::std_extensions::arithmetic::float_types::ConstF64; + use tket::hugr::std_extensions::arithmetic::int_types::ConstInt; + + // ConstInt can be signed or unsigned + if let Some(const_int) = value.get_custom_value::() { + let int_value = const_int.value_s(); + debug!("const_value_to_classical: found ConstInt with value {int_value}"); + return Some(ClassicalValue::Int(int_value)); + } + + if let Some(const_f64) = value.get_custom_value::() { + let float_value = const_f64.value(); + debug!("const_value_to_classical: found ConstF64 with value {float_value}"); + return Some(ClassicalValue::Float(float_value)); + } + + if let Some(const_bool) = value.get_custom_value::() { + let bool_value = const_bool.value(); + debug!("const_value_to_classical: found ConstBool with value {bool_value}"); + return Some(ClassicalValue::Bool(bool_value)); + } + + // Tuple constants are single-variant sums with tag 0; a two-variant + // sum with no payload is a plain HUGR bool (False=0/True=1). + if let Value::Sum(sum) = value { + let num_variants = sum.sum_type.num_variants(); + if num_variants == 2 && sum.values.is_empty() { + debug!( + "const_value_to_classical: found unit-sum bool with tag {}", + sum.tag + ); + return Some(ClassicalValue::Bool(sum.tag == 1)); + } + if num_variants == 1 && sum.tag == 0 { + let elements: Option> = sum + .values + .iter() + .map(Self::const_value_to_classical) + .collect(); + let elements = elements?; + debug!( + "const_value_to_classical: found tuple const with {} elements", + elements.len() + ); + return Some(ClassicalValue::Tuple(elements)); } } diff --git a/crates/pecos/tests/test_data/hugr/rx_pi_tuple_const.hugr b/crates/pecos/tests/test_data/hugr/rx_pi_tuple_const.hugr new file mode 100644 index 0000000000000000000000000000000000000000..7d15dcf89a4e081d5c5d0ea8dcc19803e0179b3a GIT binary patch literal 3067 zcmV{OT7;)Ob?RcqJ@M)2kcHr%0Tu^@j z`W~R>1r>In=42O`D72DIie70@5q3*kn=yoq(?Nd)n-u)v{tA_-R@hJv3$DbNZ#4?{ zEL5ctzxc9uN&&k8v;n#S@!|iwM~!>$_DTlheBOnhAPxS>ze6pCU;wMP!Fssul zf5T6Z7JH17+8-=$qn)>#REHs}(?{&QX;`fLopH|xKS7#%*4_rGyYat9`@&v&)ic6R zkj81P8gVbKwQ966&hFXy3DRCGzTB3gg?+8grE`@ zL4z=Y5@B+e5D6-w5;TKKPzjr$|1b$t1fRhssDw{s&uZp%O+x z{~;8n2wuV|sDwpm28+-TEJFXG6eb5=!YZiD;1%>AQekS~Wqc~LI70uUjE$Gkgv!{0 zX0Zj8ank>onQD9%XBJVYj4wi_7%w9XD&q>3Q3jQfC1oXxGOCHbj4V_}8dT0Oxl57B zJ&QDG7H`ljx=m9geO&Y0Xo z@dwRNRK`pHqXZ`Rktd<_IUx~(0U$<*P!beOT-@W!(q}d(0wWVj5NAv70(=IsggMk5 z(U`?%9W+2q9>K2EzyHU;>LEd8vh<6V#o>pTH+hAdw7CLETM01gps6Q}@qn zp@>4=N#0$))UwDz-ACRXzSLxdq3-fSk;exROB2Kz>K?UFywv^wBw`tJs5|^mj8S)} zJ$#X=fG1#pDL`n3=nP;^?PLPbV#+u{D4+>IiwQEBCI5cGTR_1q;0bs!2M}PAj7*6# zv!5n$LKoA3I_=6-VKF~2W{T!6icJb$GxVA=XR2uKl4oZ0=XWq;WIR)PFs1e&%oGnM z5v3`AOj$GGkGxjetee6rj&olf#$5%t`@D;Lz#)EuH2fUwo547@#|b(z`$c!Z!%vXL z!Z<5@^Rmic%FEriTim@#y>!xZINZT{ zx}P9zrMV9WQyRU#o&SD0F1W+|%gZ-;_XwWFyF2hEUotY?8~Bp{yt|G6)OeD2U*PMH zyt~S~`)1t>euA{zU2QIn0W06zVVJg6#KXg5W31iVYU5x!I&39b6)S&QWvd8fQ%Tl) z-R{C*v0XW>RTY0Z7p7~5v6Tedx|N4nc1R`JA(dpSXo$@}j~$ceEe@BF3yaGL2Lq#) zAT&Jg<__~q;#($ndEJN)7qyDHEzQC~xME-tTc=lkVU5sz&d)flzd zO0rpRUWbF&Dzcodq!f!^H`^gLJzmz^iI?%#!70{b?~9pW+EjGggI_nFB=FipJ74?* zT8hmvZ~V%C$;2UJ-uRbOM$44(EgAyK8>GkX>#v*btQZ24Gs_m)2np!WZA zz83l-pv>E_^EmMEk@*q>Gkoc5PLI+Oc%1SZb+;e-oY;pz-B&*JC7AFkQ1|+wZ{b^b z8mPOQ2CC(B%Z)$f1L}5AY$_1C}I2(I_4sy{q1~inzNoer+9w z>EIc)!#5kd^sXL@1;e_Xts-x=sMfy{uzui@P_qrE7;(uP;~c zRu*n?<*nN$TS*qS?U2gC`+nRFf-6?ld{=efn_aWsJP&s;9b99uSIn!o;!bZ9=gK#= z+BmIx#k|w$bgHSTsi6S^1PBcfAV5$el#NX#Sx<=WZih3rAlA)noNl%B%*@Qp%*n-a zu}mkE$+WYx6N|-Ybab$XWlAf}aWEy^Rj^~nj??LMs;Q|#p)8H9A{Uz~LfMn8q81w( z8k(3=>3hBO%I?U-?8wBp@X+Y!T-?cM(CJR!+S1j9GuS$BooiH*#XFmqf7nX0o+;m| zcit+?>+|Z~@>lPSx>KF&+fi@b9S)LpObX-NS8Q+G{c?2V;2dnLh-a0*R;t57G-s;_ zd)#eRl8tpYZz`0nB2Eo9e>v5<{bm4XalYJudaaXCUnMnptJM3N*)2hjwCFs7VzSRE3e0BID&Ac{g5 z24Ns(3?YUPV+autLI^R)5fP(2y!#7Dg$XAZd2E=5p;7~3Sh_{y9JoMB8v}0vnY7OW zY}4R!Q2TakzhVKTbPOdrWJl#W4JM4fjqL4;2MqSISBFHD#cU|?ny*mmz_8-30aRvE z004x#oxq21Sqz3ILP-1qWnuOmx{^V4bE-nfH|n|N26(I}ntVc}0{{9H>!%|`WvB`{ z^J#^oEfp#}LQV}eKFYA?O#`eo(xa~%K}OPWMn%{N+0@>Y10_+g-$A=WMozG?)*^o} zZbxMh4hl;V1_^&7orwrq@l}K93%1^!F??`eVl9G_v|_?A35wVSMVy8|6wO1_hlnT^ z2_7aRhmkPRAp>79!be=Hqzexu*)1kYmR`^T;E=>%_0nI&XIX$v4s~`AE~`v0yaS;& z(}c@e)DYtD&}PiO1eb?xD~|{(F3LHO)>VW%QU+mY;%85)9|q>NsAd3Bn?VL)+zGI| zNhP(od0NQ2FJWrJ;F5D>mx$tJ)B@v)Ye7o&p6UUkJVgJ2M0RYf5`VLI2*CiLhMXw% z6>wJTa#kI+Ad)Ml<71XUG1gf`u~N$yvIfMOZJRQAVAVDR_~7u>Hu-6t@emy{JHaF= zK*tvgOUh`4gjP+FEvwYb6X-#VT-q|aPo!Pt0?B5C@Bpp_SJ_L1?oRy+z32N;-hBZC zxNu+sqTqy+MMr66$|_rR{LpAGFppqc7y%KO-XNPWW((sWUk-v>_wFm~U#1BR-7s`` z_)2bkI|l>H5QHcwBOIK>kTJyA3H&v=Yg7WzXHEH%-#YU?-zkkoqJTJ-P!NleiCH~J2;IDXJu14ul0gn~AoNT*S0X)00;JCzR7NT|rggsOEqPKc~@>bbQ z=$X^`j2}`Wf5XKm3&~-U#Yjx6}{g+z0Y*q8nHl z@vwiuOVX-U15+h9`!%i0o}CVqQ~!$bUVX>;8!d#D73eEOwGmv`Xu~76#qmtV85vBdaBM$#^_oVeMn8n^4_X@cD1+EO_Igl JikYy2Py-@L)ZPF9 literal 0 HcmV?d00001 From cb1eee7233ac63d2b8e9420270a01473198fb4f0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Wed, 1 Jul 2026 23:50:49 -0600 Subject: [PATCH 315/388] Add a regression test pinning the measurements rows' qubit-id ordering --- .../tests/guppy/test_real_quantum_circuits.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py b/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py index 99108710d..a3e1ec563 100644 --- a/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py +++ b/python/quantum-pecos/tests/guppy/test_real_quantum_circuits.py @@ -53,6 +53,34 @@ def prepare_bell_state() -> tuple[bool, bool]: assert 0.4 < both_one / total < 0.6, f"Should be ~50% |11⟩, got {both_one / total}" +def test_measurements_rows_are_qubit_id_ordered() -> None: + """Pin the raw-results contract: "measurements" rows are in QUBIT-ID order. + + HugrEngine::get_results assembles the rows from the per-qubit measurement + map sorted by qubit id, NOT from the guppy return-tuple order. A program + returning its measurements reversed must still yield qubit-id-ordered + rows; if tuple-order capture is ever implemented, this test documents the + intentional behavior change. + """ + + @guppy + def reversed_return() -> tuple[bool, bool]: + q1 = qubit() # qubit id 0 + q2 = qubit() # qubit id 1 + x(q1) # deterministically flip qubit 0 to |1> + m1 = measure(q1) + m2 = measure(q2) + return (m2, m1) # reversed relative to qubit-id order + + shot_vec = sim(Guppy(reversed_return)).qubits(2).quantum(state_vector()).seed(42).run(20) + shots = shot_vec.to_dict()["measurements"] + assert len(shots) == 20, "Should have one measurement row per shot" + + # Qubit-id order puts the X-flipped qubit 0 first even though the guppy + # function returns (m2, m1); tuple order would read (0, 1) instead. + assert all(tuple(row) == (1, 0) for row in shots), shots + + def test_ghz_state() -> None: """Test 3-qubit GHZ state preparation.""" From afe0d643f324065e1ed0205c097d8adf2397e71f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 00:36:33 -0600 Subject: [PATCH 316/388] Fold in Codex round-2 findings: pin the runtime tuple angle chain with wire-state assertions, replace vacuous measurements lookups with hard indexing across the guppy tests, and xfail the two array+loop programs whose pre-existing silent result truncation the de-vacuumed assertions exposed --- crates/pecos-hugr/src/engine.rs | 46 +++++++++++++++++++ .../tests/guppy/test_all_gates.py | 2 +- .../tests/guppy/test_arithmetic_support.py | 12 ++--- .../tests/guppy/test_core_quantum_ops.py | 6 +-- .../tests/guppy/test_dynamic_circuits.py | 6 +-- .../guppy/test_extended_guppy_features.py | 2 +- .../tests/guppy/test_guppy_llvm_pipeline.py | 4 +- .../tests/guppy/test_guppy_sim_builder.py | 16 +++---- .../tests/guppy/test_guppy_simple_pipeline.py | 2 +- .../tests/guppy/test_isolated_quantum_ops.py | 34 +++++++------- .../tests/guppy/test_missing_coverage.py | 14 +++++- .../tests/guppy/test_noise_models.py | 20 ++++---- .../guppy/test_qubit_allocation_limits.py | 12 ++--- .../tests/guppy/test_static_tuples.py | 10 ++-- .../tests/guppy/test_yz_gates.py | 8 ++-- 15 files changed, 126 insertions(+), 68 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 86bfc0d64..4d9ff9ace 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1220,6 +1220,17 @@ impl HugrEngine { } } + // NOTE: a node that stays deferred forever inside EXECUTED control + // flow can stall its container and silently truncate downstream + // effects. Detecting that here (queue drained, no measurement pause) + // requires the active_cases/active_cfgs/active_calls/active_tailloops + // bookkeeping to be reliably empty after healthy completion, which it + // currently is not (several passing fixtures finish with leftover + // active entries). Completion-time stall detection is deferred until + // those lifecycle invariants are fixed; the rotation-angle consumers + // fail loud regardless (resolve_rotation_angle), and the Python + // guppy tests assert result presence and shot counts. + if operation_count == 0 { debug!("No operations processed"); return Ok(None); @@ -1890,6 +1901,41 @@ mod tests { (radians - std::f64::consts::FRAC_PI_2).abs() < 1e-9, "RY command should have angle pi/2, got {radians}", ); + + // Pin the RUNTIME chain directly: the gate command above can also be + // satisfied by the STATIC extraction (op.params wins in + // resolve_rotation_angle), so assert the classical values the runtime + // tuple ops must have produced during execution. + let hugr = engine.hugr.clone().expect("hugr present"); + let (unpack, from_halfturns) = ry_runtime_chain_nodes(&hugr); + assert_eq!( + engine.wire_state.classical_values.get(&(unpack, 0)), + Some(&ClassicalValue::Float(0.5)), + "UnpackTuple should have unpacked the angle float at runtime" + ); + assert_eq!( + engine.wire_state.classical_values.get(&(from_halfturns, 0)), + Some(&ClassicalValue::Rotation(0.5)), + "from_halfturns should have produced the runtime rotation value" + ); + } + + /// Walk back from `from_halfturns_unchecked` to its feeding `UnpackTuple` + /// in the guppy tuple-wrapped-angle fixtures. + fn ry_runtime_chain_nodes(hugr: &Hugr) -> (Node, Node) { + let mut from_halfturns = None; + for node in hugr.nodes() { + if let Some(ext) = hugr.get_optype(node).as_extension_op() + && ext.unqualified_id() == "from_halfturns_unchecked" + { + from_halfturns = Some(node); + } + } + let fh = from_halfturns.expect("fixture should contain from_halfturns_unchecked"); + let (unpack, _) = hugr + .single_linked_output(fh, IncomingPort::from(0)) + .expect("from_halfturns input should be wired"); + (unpack, fh) } #[test] diff --git a/python/quantum-pecos/tests/guppy/test_all_gates.py b/python/quantum-pecos/tests/guppy/test_all_gates.py index 55c7196ba..2c9fd1a79 100644 --- a/python/quantum-pecos/tests/guppy/test_all_gates.py +++ b/python/quantum-pecos/tests/guppy/test_all_gates.py @@ -43,7 +43,7 @@ def run_circuit( def get_measurements(results: dict) -> list: """Extract measurements from results.""" - return results.get("measurements", []) + return results["measurements"] class TestSingleQubitGates: diff --git a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py index 969ecf562..332c05683 100644 --- a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py +++ b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py @@ -24,7 +24,7 @@ def quantum_add() -> bool: results = sim(Guppy(quantum_add)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For single bool return, measurements is [[1], [0], ...] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 @@ -47,7 +47,7 @@ def quantum_bool_logic() -> bool: results = sim(Guppy(quantum_bool_logic)).qubits(2).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 @@ -68,7 +68,7 @@ def quantum_compare() -> bool: results = sim(Guppy(quantum_compare)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 assert 0 in measurements @@ -93,7 +93,7 @@ def quantum_loop() -> bool: results = sim(Guppy(quantum_loop)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 assert 0 in measurements @@ -117,7 +117,7 @@ def quantum_chain() -> bool: results = sim(Guppy(quantum_chain)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 assert 0 in measurements @@ -149,7 +149,7 @@ def quantum_measure_math() -> bool: results = sim(Guppy(quantum_measure_math)).qubits(3).quantum(state_vector()).seed(42).run(20).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 20 # Should have mix unless both m1 and m2 are 0 (25% chance) diff --git a/python/quantum-pecos/tests/guppy/test_core_quantum_ops.py b/python/quantum-pecos/tests/guppy/test_core_quantum_ops.py index def35a697..f25cd9fa9 100644 --- a/python/quantum-pecos/tests/guppy/test_core_quantum_ops.py +++ b/python/quantum-pecos/tests/guppy/test_core_quantum_ops.py @@ -37,7 +37,7 @@ def decode_integer_results(results: list[int], n_bits: int) -> list[tuple[bool, def get_single_measurements(results: dict) -> list[int]: """Extract single-value measurements from results dict.""" - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] if not raw_measurements: return [] # Format is [[1], [0], ...] for single bool return @@ -50,7 +50,7 @@ def get_measurement_tuples(results: dict, n_bits: int) -> list[tuple[bool, ...]] """Extract measurement tuples from results, handling nested list format.""" # Get measurements - format is [[m0, m1, ...], [m0, m1, ...], ...] for tuple returns # or [[m], [m], ...] for single bool returns - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] if not raw_measurements: return [] @@ -390,7 +390,7 @@ def loop_test() -> int: results = sim(Guppy(loop_test)).qubits(10).quantum(state_vector()).seed(42).run(100).to_dict() # For int returns, measurements contains the return values - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # Handle nested list format [[2], [1], ...] for int returns if raw_measurements and isinstance(raw_measurements[0], list): measurements = [m[-1] if m else 0 for m in raw_measurements] diff --git a/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py b/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py index 2aecd7f97..28c94aae9 100644 --- a/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py +++ b/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py @@ -47,7 +47,7 @@ def conditional_x_from_zero() -> bool: # Extract the return value (last measurement in each shot) # Results format: [[m1, m2], [m1, m2], ...] where m2 is the return value - measurements = results.get("measurements", []) + measurements = results["measurements"] return_values = [shot[-1] for shot in measurements] # All results should be False since q1 is |0>, so X is never applied to q2 @@ -79,7 +79,7 @@ def conditional_x_from_one() -> bool: results = sim(Guppy(conditional_x_from_one)).qubits(2).quantum(state_vector()).seed(42).run(100) # Extract measurements - measurements = results.get("measurements", []) + measurements = results["measurements"] if not measurements and "measurement_0" in results: measurements = results["measurement_0"] @@ -177,7 +177,7 @@ def teleport_one() -> bool: # Extract the return value (last measurement in each shot) # Results format: [[m0, m1, m2], ...] where m2 is the return value - measurements = results.get("measurements", []) + measurements = results["measurements"] return_values = [shot[-1] for shot in measurements] # The teleported state should be |1>, so we expect all True diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index cb3baf16c..82842f1fe 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -70,7 +70,7 @@ def test_function( result_dict = builder.run(shots).to_dict() # Format results - measurements is [[m0], [m0], ...] or [[m0, m1], ...] - raw_measurements = result_dict.get("measurements", []) + raw_measurements = result_dict["measurements"] if raw_measurements and isinstance(raw_measurements[0], list): if len(raw_measurements[0]) == 1: # Single measurement - [[1], [0], ...] -> [1, 0, ...] diff --git a/python/quantum-pecos/tests/guppy/test_guppy_llvm_pipeline.py b/python/quantum-pecos/tests/guppy/test_guppy_llvm_pipeline.py index 3858eb873..353a481cc 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_llvm_pipeline.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_llvm_pipeline.py @@ -139,7 +139,7 @@ def bell_state() -> tuple[bool, bool]: assert result is not None, "Should get execution results" # Measurements format is [[m0, m1], [m0, m1], ...] - measurements = result.get("measurements", []) + measurements = result["measurements"] assert len(measurements) == 100, "Should have 100 measurements" # Check correlation (Bell state should be perfectly correlated) @@ -242,7 +242,7 @@ def superposition_test() -> tuple[bool, bool, bool]: # Calculate average number of 1s # Measurements format is [[m0], [m0], ...] for single qubit # or [[m0, m1], [m0, m1], ...] for multiple qubits - measurements = result.get("measurements", []) + measurements = result["measurements"] if n_qubits == 1: ones_count = sum(m[-1] for m in measurements) diff --git a/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py b/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py index 8ae065406..e6a90848d 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py @@ -56,8 +56,8 @@ def test_basic_build_and_run(self) -> None: assert len(results2["measurement_0"]) == 10 else: # Fallback to old format - measurements1 = results1.get("measurements", results1.get("result", [])) - measurements2 = results2.get("measurements", results2.get("result", [])) + measurements1 = results1["measurements"] + measurements2 = results2["measurements"] assert len(measurements1) == 10 assert len(measurements2) == 10 @@ -66,7 +66,7 @@ def test_direct_run(self) -> None: results = sim(self.single_qubit).qubits(10).quantum(state_vector()).run(10).to_dict() # Check that we have measurement results - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 assert all(r in [0, 1] for r in measurements) @@ -117,7 +117,7 @@ def test_config_dict(self) -> None: assert len(results["measurement_0"]) == 50 assert len(results["measurement_1"]) == 50 else: - measurements = results.get("measurements", results.get("result", [])) + measurements = results["measurements"] assert len(measurements) == 50 def test_bell_state_correlation(self) -> None: @@ -125,7 +125,7 @@ def test_bell_state_correlation(self) -> None: results = sim(self.bell_state).qubits(10).quantum(state_vector()).seed(42).run(1000).to_dict() # Measurements format is [[m0, m1], [m0, m1], ...] - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] correlated = sum(1 for m in raw_measurements if m[0] == m[1]) assert correlated == len( raw_measurements, @@ -149,7 +149,7 @@ def test_keep_intermediate_files(self) -> None: # Run simulation results = sim_obj.run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10 @@ -261,7 +261,7 @@ def measure_without_gates() -> bool: # First run - all measurements should be 0 since qubit starts in |0⟩ results1 = sim_obj.run(100) - measurements1 = results1.to_dict().get("measurements", []) + measurements1 = results1.to_dict()["measurements"] assert all( m == 0 or m == [0] or m == (0,) or m is False for m in measurements1 ), f"Expected all measurements to be 0, got: {measurements1[:5]}..." @@ -271,7 +271,7 @@ def measure_without_gates() -> bool: # After reset, qubit should be back in |0⟩, so all measurements should still be 0 results2 = sim_obj.run(100) - measurements2 = results2.to_dict().get("measurements", []) + measurements2 = results2.to_dict()["measurements"] assert all( m == 0 or m == [0] or m == (0,) or m is False for m in measurements2 ), f"After reset, expected all measurements to be 0, got: {measurements2[:5]}..." diff --git a/python/quantum-pecos/tests/guppy/test_guppy_simple_pipeline.py b/python/quantum-pecos/tests/guppy/test_guppy_simple_pipeline.py index 3a055889a..71c6656cd 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_simple_pipeline.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_simple_pipeline.py @@ -45,7 +45,7 @@ def quantum_coin() -> bool: result = sim(Guppy(quantum_coin)).qubits(1).quantum(state_vector()).seed(42).run(10).to_dict() # Should have measurement results - raw_measurements = result.get("measurements", []) + raw_measurements = result["measurements"] values = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(values) == 10 # Hadamard should give mix of 0s and 1s diff --git a/python/quantum-pecos/tests/guppy/test_isolated_quantum_ops.py b/python/quantum-pecos/tests/guppy/test_isolated_quantum_ops.py index 5b6eab831..9d8e7bcce 100644 --- a/python/quantum-pecos/tests/guppy/test_isolated_quantum_ops.py +++ b/python/quantum-pecos/tests/guppy/test_isolated_quantum_ops.py @@ -55,7 +55,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - assert len(results.get("measurements", results.get("measurement_0", []))) == 10 + assert len(results["measurements"]) == 10 def test_single_x_gate(self) -> None: """Test just X gate.""" @@ -67,7 +67,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - assert all(r for r in results.get("measurements", results.get("measurement_0", []))) + assert all(r for r in results["measurements"]) def test_single_y_gate(self) -> None: """Test just Y gate.""" @@ -79,7 +79,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - assert all(r for r in results.get("measurements", results.get("measurement_0", []))) + assert all(r for r in results["measurements"]) def test_single_z_gate(self) -> None: """Test just Z gate.""" @@ -91,7 +91,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # Z on |0> -> |0>, so all measurements should be 0 assert all(m[0] == 0 for m in measurements) @@ -107,7 +107,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # X on |0> -> |1>, S-Sdg is identity, so all measurements should be 1 assert all(m[0] == 1 for m in measurements) @@ -123,7 +123,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # X on |0> -> |1>, T-Tdg is identity, so all measurements should be 1 assert all(m[0] == 1 for m in measurements) @@ -137,7 +137,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # RX(pi) on |0> -> |1>, so all measurements should be 1 assert all(m[0] == 1 for m in measurements) @@ -151,7 +151,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # RY(pi) on |0> -> |1>, so all measurements should be 1 assert all(m[0] == 1 for m in measurements) @@ -165,7 +165,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # RZ on |0> -> |0>, so all measurements should be 0 assert all(m[0] == 0 for m in measurements) @@ -182,7 +182,7 @@ def test() -> tuple[bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # Should get [1, 1] for both qubits (X on q1, then CX flips q2) - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [1, 1] for m in measurements) def test_two_qubit_cy(self) -> None: @@ -198,7 +198,7 @@ def test() -> tuple[bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # CY with control=1 should flip target - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [1, 1] for m in measurements) def test_two_qubit_cz(self) -> None: @@ -215,7 +215,7 @@ def test() -> tuple[bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # Both qubits should be |1> (CZ only adds phase, no bit flip) - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [1, 1] for m in measurements) def test_two_qubit_ch(self) -> None: @@ -230,7 +230,7 @@ def test() -> tuple[bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # CH with control=0 does nothing, both stay |0> - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [0, 0] for m in measurements) def test_toffoli(self) -> None: @@ -248,7 +248,7 @@ def test() -> tuple[bool, bool, bool]: results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) # Both controls at |1>, target flips to |1> - measurements = results.get("measurements", []) + measurements = results["measurements"] assert all(m == [1, 1, 1] for m in measurements) def test_reset_operation(self) -> None: @@ -262,7 +262,7 @@ def test() -> bool: return measure(q) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # Reset should bring |1> back to |0> assert all(m[0] == 0 for m in measurements) @@ -279,7 +279,7 @@ def test() -> bool: return measure(q2) results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", results.get("measurement_0", [])) + measurements = results["measurements"] # After discard, X on q2 gives |1> assert all(m[0] == 1 for m in measurements) @@ -310,7 +310,7 @@ def test() -> tuple[bool, bool, bool, bool]: return result1, result2, result3, result4 results = sim(Guppy(test)).qubits(10).quantum(state_vector()).seed(42).run(10) - measurements = results.get("measurements", []) + measurements = results["measurements"] for m in measurements: # m is now a list like [r1, r2, r3, r4] diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index 752249669..d9112247a 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -67,7 +67,7 @@ def get_measurements(results: dict, _expected_count: int = 1) -> list: List of measurements (either single values or tuples) """ # Get measurements from new format - [[m0], [m1], ...] or [[m0, m1], [m0, m1], ...] - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] if not raw_measurements: return [] @@ -218,6 +218,12 @@ def measure_multiple_test() -> tuple[bool, bool, bool, bool, bool]: # b0 and b2 are probabilistic (from H gates) + @pytest.mark.xfail( + reason="engine returns no measurements for array+loop guppy programs (silent " + "result truncation, pre-existing); the test passed vacuously until result " + "presence was asserted -- see the deferred-node/stalled-control-flow follow-up", + strict=True, + ) def test_discard_array(self) -> None: """Test discarding an array of qubits.""" # First check if discard_array is available @@ -244,6 +250,12 @@ def discard_array_test() -> bool: results = sim(Guppy(discard_array_test)).qubits(10).quantum(state_vector()).seed(42).run(10).to_dict() assert all(r == 1 for r in get_measurements(results)), "Final qubit should be |1⟩" + @pytest.mark.xfail( + reason="engine returns no measurements for array+loop guppy programs (silent " + "result truncation, pre-existing); the test passed vacuously until result " + "presence was asserted -- see the deferred-node/stalled-control-flow follow-up", + strict=True, + ) def test_array_indexing_and_loops(self) -> None: """Test array indexing within loops.""" if measure_array is None: diff --git a/python/quantum-pecos/tests/guppy/test_noise_models.py b/python/quantum-pecos/tests/guppy/test_noise_models.py index cd80f877f..b79d40cf7 100644 --- a/python/quantum-pecos/tests/guppy/test_noise_models.py +++ b/python/quantum-pecos/tests/guppy/test_noise_models.py @@ -32,7 +32,7 @@ def deterministic_circuit() -> bool: results = sim(deterministic_circuit).qubits(10).quantum(state_vector()).seed(42).run(10).to_dict() # Should always measure |1⟩ - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert all(r == 1 for r in measurements) @@ -57,7 +57,7 @@ def simple_circuit() -> bool: # High depolarizing probability to see effect results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # With 0.2 depolarizing on X gate, we should see some 0s @@ -86,7 +86,7 @@ def simple_circuit() -> bool: results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # Should see some errors @@ -111,7 +111,7 @@ def simple_circuit() -> bool: sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise_builder).seed(42).run(100).to_dict() ) - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # Should see some errors but not too many @@ -146,8 +146,8 @@ def bell_circuit() -> tuple[bool, bool]: results_noisy = sim(bell_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() # Extract measurements - format is [[m0, m1], [m0, m1], ...] - clean_measurements = results_clean.get("measurements", []) - noisy_measurements = results_noisy.get("measurements", []) + clean_measurements = results_clean["measurements"] + noisy_measurements = results_noisy["measurements"] # Check correlations clean_corr = sum(1 for m in clean_measurements if m[0] == m[1]) @@ -194,8 +194,8 @@ def simple_x_circuit() -> bool: results2 = sim(simple_x_circuit).qubits(10).quantum(state_vector()).noise(noise2).seed(43).run(10).to_dict() - raw_measurements1 = results1.get("measurements", []) - raw_measurements2 = results2.get("measurements", []) + raw_measurements1 = results1["measurements"] + raw_measurements2 = results2["measurements"] measurements1 = [m[-1] if isinstance(m, list) else m for m in raw_measurements1] measurements2 = [m[-1] if isinstance(m, list) else m for m in raw_measurements2] @@ -220,7 +220,7 @@ def multi_gate_circuit() -> bool: results = sim(multi_gate_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # H followed by X should give |1⟩ without noise @@ -243,7 +243,7 @@ def simple_circuit() -> bool: results = sim(simple_circuit).qubits(10).quantum(state_vector()).noise(noise).seed(42).run(100).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] # X gate gives |1⟩, but measurement errors should flip some diff --git a/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py b/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py index e34c3aa9a..09b67d14d 100644 --- a/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py +++ b/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py @@ -25,7 +25,7 @@ def static_test() -> tuple[bool, bool, bool]: results = sim(Guppy(static_test)).qubits(5).quantum(state_vector()).run(10).to_dict() # Check we got results - format is [[m0, m1, m2], [m0, m1, m2], ...] - measurements = results.get("measurements", []) + measurements = results["measurements"] assert len(measurements) == 10, "Should have 10 measurements" for m in measurements: assert len(m) == 3, f"Each shot should have 3 measurements, got {len(m)}" @@ -51,7 +51,7 @@ def dynamic_loop_test() -> int: results = sim(Guppy(dynamic_loop_test)).qubits(10).quantum(state_vector()).seed(42).run(100) # Extract measurements - measurements = results.get("measurement_0", results.get("measurements", [])) + measurements = results.get("measurement_0", results["measurements"]) assert len(measurements) == 100, "Should have 100 measurements" # Due to Guppy limitation, only returns 0 or 1 (last measurement) @@ -175,7 +175,7 @@ def nested_loop_test() -> int: # Need sufficient qubits for nested allocation results = sim(Guppy(nested_loop_test)).qubits(10).quantum(state_vector()).seed(42).run(50) - measurements = results.get("measurement_0", results.get("measurements", [])) + measurements = results.get("measurement_0", results["measurements"]) assert len(measurements) == 50, "Should have 50 measurements" # Count should be 0-6 (depends on measurements) @@ -200,7 +200,7 @@ def measurement_reuse_test() -> int: for max_qubits in [5, 10]: results = sim(measurement_reuse_test).qubits(max_qubits).quantum(state_vector()).seed(42).run(50) - measurements = results.get("measurement_0", results.get("measurements", [])) + measurements = results.get("measurement_0", results["measurements"]) assert len(measurements) == 50, f"Should have 50 measurements with max_qubits={max_qubits}" # Due to Guppy limitation, only returns 0 or 1 (last measurement) @@ -224,7 +224,7 @@ def single_qubit_test() -> bool: for max_q in [1, 5, 10, 20]: results = sim(single_qubit_test).qubits(max_q).quantum(state_vector()).seed(42).run(10).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 10, f"Should have 10 measurements with max_qubits={max_q}" @@ -264,7 +264,7 @@ def array_test() -> array[bool, 3]: # The result should be an array of 3 booleans for each shot # Results format is [[m0, m1, m2], [m0, m1, m2], ...] - measurements = results.get("measurements", []) + measurements = results["measurements"] assert len(measurements) == 50, "Should have 50 measurement sets" # Each measurement should be an array/tuple of 3 booleans diff --git a/python/quantum-pecos/tests/guppy/test_static_tuples.py b/python/quantum-pecos/tests/guppy/test_static_tuples.py index ceb789e44..7c103cbc1 100644 --- a/python/quantum-pecos/tests/guppy/test_static_tuples.py +++ b/python/quantum-pecos/tests/guppy/test_static_tuples.py @@ -91,7 +91,7 @@ def circuit_5_tuple() -> tuple[bool, bool, bool, bool, bool]: def test_1_tuple_return() -> None: """Test that 1-tuple (bool) returns work correctly.""" results = sim(Guppy(circuit_1_tuple)).qubits(1).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For single bool return, measurements is [[1], [1], ...] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 5 @@ -101,7 +101,7 @@ def test_1_tuple_return() -> None: def test_2_tuple_return() -> None: """Test that 2-tuple returns work correctly.""" results = sim(Guppy(circuit_2_tuple)).qubits(2).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For tuple return, measurements is [[1, 0], [1, 0], ...] # First qubit has X, second doesn't assert all(raw_measurements[i][0] == 1 for i in range(5)) @@ -111,7 +111,7 @@ def test_2_tuple_return() -> None: def test_3_tuple_return() -> None: """Test that 3-tuple returns work correctly.""" results = sim(Guppy(circuit_3_tuple)).qubits(3).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For tuple return, measurements is [[1, 0, 1], [1, 0, 1], ...] # Pattern: X, no X, X assert all(raw_measurements[i][0] == 1 for i in range(5)) @@ -122,7 +122,7 @@ def test_3_tuple_return() -> None: def test_4_tuple_return() -> None: """Test that 4-tuple returns work correctly.""" results = sim(Guppy(circuit_4_tuple)).qubits(4).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For tuple return, measurements is [[1, 0, 1, 0], [1, 0, 1, 0], ...] # Pattern: X, no X, X, no X assert all(raw_measurements[i][0] == 1 for i in range(5)) @@ -134,7 +134,7 @@ def test_4_tuple_return() -> None: def test_5_tuple_return() -> None: """Test that 5-tuple returns work correctly.""" results = sim(Guppy(circuit_5_tuple)).qubits(5).quantum(state_vector()).run(5).to_dict() - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] # For tuple return, measurements is [[1, 0, 1, 0, 1], [1, 0, 1, 0, 1], ...] # Pattern: X, no X, X, no X, X assert all(raw_measurements[i][0] == 1 for i in range(5)) diff --git a/python/quantum-pecos/tests/guppy/test_yz_gates.py b/python/quantum-pecos/tests/guppy/test_yz_gates.py index 817198a80..d7ff3ae28 100644 --- a/python/quantum-pecos/tests/guppy/test_yz_gates.py +++ b/python/quantum-pecos/tests/guppy/test_yz_gates.py @@ -17,7 +17,7 @@ def y_only() -> bool: results = sim(Guppy(y_only)).qubits(1).quantum(state_vector()).run(5).to_dict() # measurements is list of lists like [[1], [1], ...], extract last value from each shot - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert all(val == 1 for val in measurements) # Y|0⟩ should give |1⟩ @@ -33,7 +33,7 @@ def z_only() -> bool: results = sim(Guppy(z_only)).qubits(1).quantum(state_vector()).run(5).to_dict() # measurements is list of lists like [[0], [0], ...], extract last value from each shot - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert all(val == 0 for val in measurements) # Z|0⟩ should give |0⟩ @@ -55,7 +55,7 @@ def yz_tuple() -> tuple[bool, bool]: results = sim(Guppy(yz_tuple)).qubits(2).quantum(state_vector()).run(5).to_dict() # measurements is list of lists like [[1, 0], [1, 0], ...] for tuple returns - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] for i in range(5): assert raw_measurements[i][0] == 1 # Y|0⟩ should give |1⟩ @@ -83,7 +83,7 @@ def xyz_tuple() -> tuple[bool, bool, bool]: results = sim(Guppy(xyz_tuple)).qubits(3).quantum(state_vector()).run(5).to_dict() # measurements is list of lists like [[1, 1, 0], [1, 1, 0], ...] for tuple returns - raw_measurements = results.get("measurements", []) + raw_measurements = results["measurements"] for i in range(5): assert raw_measurements[i][0] == 1 # X|0⟩ should give |1⟩ From f4dafc45526ea818882fb4887ea1e65dfdb3a9b8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 00:49:19 -0600 Subject: [PATCH 317/388] De-vacuum the seeded-reproducibility test that round 3 flagged (hard indexing plus shot-count assert before comparing runs) --- .../tests/guppy/test_guppy_sim_builder.py | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py b/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py index e6a90848d..0aecf18c6 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py @@ -86,10 +86,7 @@ def test_builder_methods(self) -> None: sim_obj = builder.build() results = sim_obj.run(100) - measurements = results.get( - "measurements", - results.get("measurement_0", results.get("result", [])), - ) + measurements = results["measurements"] assert measurements is not None assert len(measurements) > 0 assert len(measurements) == 100 # 100 shots, each with integer-encoded 2 qubits @@ -99,14 +96,9 @@ def test_seeded_reproducibility(self) -> None: # Run with same seed twice results1 = sim(self.single_qubit).qubits(10).quantum(state_vector()).seed(12345).run(100) results2 = sim(self.single_qubit).qubits(10).quantum(state_vector()).seed(12345).run(100) - measurements1 = results1.get( - "measurements", - results1.get("measurement_0", results1.get("result", [])), - ) - measurements2 = results2.get( - "measurements", - results2.get("measurement_0", results2.get("result", [])), - ) + measurements1 = results1["measurements"] + measurements2 = results2["measurements"] + assert len(measurements1) == 100, "seeded run should produce one row per shot" assert measurements1 == measurements2 def test_config_dict(self) -> None: From cb9533b8f34a536819db60ec3a138f2c26431fb7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 01:07:36 -0600 Subject: [PATCH 318/388] Remove dead measurement_N fallback branches from the guppy tests (the keys never exist for plain-measure programs) --- .../test_comprehensive_guppy_features.py | 93 ++++++------------- .../tests/guppy/test_dynamic_circuits.py | 15 +-- 2 files changed, 30 insertions(+), 78 deletions(-) diff --git a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py index 82d9826db..85f12ee6b 100644 --- a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py @@ -47,76 +47,35 @@ def test_function_on_both_pipelines( result_obj = builder.run(shots) result_dict = result_obj.to_dict() - # Format results to match expected structure - measurements = [] - if "measurements" in result_dict: - # measurements is a list of lists like [[1], [0, 1], ...] - # For functions returning single bool, extract the last measurement per shot - raw_measurements = result_dict["measurements"] - if raw_measurements and isinstance(raw_measurements[0], list): - # Check if function returns single bool or tuple - import inspect - - actual_func = func - if hasattr(func, "wrapped") and hasattr( - func.wrapped, - "python_func", - ): - actual_func = func.wrapped.python_func - try: - sig = inspect.signature(actual_func) - return_type = sig.return_annotation - is_tuple_return = hasattr(return_type, "__origin__") and return_type.__origin__ is tuple - except (ValueError, TypeError): - is_tuple_return = False - - if is_tuple_return: - # Return full measurement tuples - measurements = [tuple(m) for m in raw_measurements] - else: - # For single bool return, take the last measurement from each shot - measurements = [m[-1] if m else 0 for m in raw_measurements] - else: - measurements = raw_measurements - elif "measurement_0" in result_dict: - # Handle multiple measurements - num_shots = len(result_dict["measurement_0"]) - measurement_keys = sorted( - [k for k in result_dict if k.startswith("measurement_")], - ) - num_measurements = len(measurement_keys) - - for i in range(num_shots): - result_tuple = [bool(result_dict[key][i]) for key in measurement_keys] - - # Check function signature to determine if it returns a tuple - # For now, if there's more than one measurement but function returns single bool, - # take the last measurement as the return value - import inspect - - # For Guppy functions, we need to check the wrapped function - actual_func = func - if hasattr(func, "wrapped") and hasattr( - func.wrapped, - "python_func", - ): - actual_func = func.wrapped.python_func - + # Format results to match expected structure. + # "measurements" holds one row per shot like [[1], [0, 1], ...]; + # a missing key is a hard failure (reported via the except below). + raw_measurements = result_dict["measurements"] + if raw_measurements and isinstance(raw_measurements[0], list): + # Check if function returns single bool or tuple + import inspect + + actual_func = func + if hasattr(func, "wrapped") and hasattr( + func.wrapped, + "python_func", + ): + actual_func = func.wrapped.python_func + try: sig = inspect.signature(actual_func) return_type = sig.return_annotation - - # Check if return type is a tuple is_tuple_return = hasattr(return_type, "__origin__") and return_type.__origin__ is tuple - if is_tuple_return or num_measurements == 1: - # For tuple returns or single measurement, use all measurements - measurements.append( - (tuple(result_tuple) if len(result_tuple) > 1 else result_tuple[0]), - ) - else: - # For single bool return with multiple measurements, take the last one - measurements.append(result_tuple[-1]) - elif "result" in result_dict: - measurements = result_dict["result"] + except (ValueError, TypeError): + is_tuple_return = False + + if is_tuple_return: + # Return full measurement tuples + measurements = [tuple(m) for m in raw_measurements] + else: + # For single bool return, take the last measurement from each shot + measurements = [m[-1] if m else 0 for m in raw_measurements] + else: + measurements = raw_measurements func_name = getattr( func, diff --git a/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py b/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py index 28c94aae9..5ae2a51fd 100644 --- a/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py +++ b/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py @@ -80,8 +80,7 @@ def conditional_x_from_one() -> bool: # Extract measurements measurements = results["measurements"] - if not measurements and "measurement_0" in results: - measurements = results["measurement_0"] + assert len(measurements) == 100, "should have one measurement row per shot" # All results should be True since q1 is |1>, so X is always applied to q2 ones_count = sum(1 for m in measurements if m) @@ -116,15 +115,9 @@ def measurement_feedback() -> tuple[bool, bool]: # Run the circuit results = sim(Guppy(measurement_feedback)).qubits(2).quantum(state_vector()).seed(42).run(100) - # Extract measurements - should have two measurements per shot - # Need to decode the results - measurements = [] - if "measurement_0" in results and "measurement_1" in results: - m0 = results["measurement_0"] - m1 = results["measurement_1"] - measurements = list(zip(m0, m1, strict=False)) - elif "measurements" in results: - measurements = results["measurements"] + # Extract measurements - one (m0, m1) row per shot + measurements = results["measurements"] + assert len(measurements) == 100, "should have one measurement row per shot" # Both measurements should always match mismatches = sum(1 for (a, b) in measurements if a != b) From efd7597e6e030e09b09de9a03079070f7392ce52 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 19:47:14 -0600 Subject: [PATCH 319/388] Fix classical value flow through HUGR control flow: first-class Sum values with executing Tag nodes, payload propagation across block/case/exit boundaries, unified classical-op classification, extension-aware readiness gating, per-iteration re-execution resets, and defer-instead-of-fabricate tuple/conversion/load_nat handlers, with a ch-gate end-to-end regression test --- Cargo.lock | 1 + crates/pecos-hugr/Cargo.toml | 1 + crates/pecos-hugr/src/engine.rs | 112 ++++++- crates/pecos-hugr/src/engine/analysis.rs | 304 ++++++++++-------- .../src/engine/control_flow/call.rs | 64 ++-- .../pecos-hugr/src/engine/control_flow/cfg.rs | 194 +++++++---- .../src/engine/control_flow/conditional.rs | 115 ++++++- .../src/engine/handlers/arithmetic.rs | 220 ++++++++----- .../src/engine/handlers/classical.rs | 42 ++- .../pecos-hugr/src/engine/handlers/prelude.rs | 109 +++++-- crates/pecos-hugr/src/engine/propagation.rs | 26 +- crates/pecos-hugr/src/engine/types.rs | 32 +- .../pecos/tests/test_data/hugr/ch_gate.hugr | Bin 0 -> 4765 bytes 13 files changed, 853 insertions(+), 367 deletions(-) create mode 100644 crates/pecos/tests/test_data/hugr/ch_gate.hugr diff --git a/Cargo.lock b/Cargo.lock index dc41aca3f..979fe6f41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4422,6 +4422,7 @@ name = "pecos-hugr" version = "0.2.0-dev.0" dependencies = [ "anyhow", + "env_logger", "log", "pecos-core", "pecos-engines", diff --git a/crates/pecos-hugr/Cargo.toml b/crates/pecos-hugr/Cargo.toml index 2b509302c..aabc50ba4 100644 --- a/crates/pecos-hugr/Cargo.toml +++ b/crates/pecos-hugr/Cargo.toml @@ -37,6 +37,7 @@ pecos-quantum = { workspace = true, features = ["hugr"] } pecos-wasm = { workspace = true, optional = true } [dev-dependencies] +env_logger.workspace = true tempfile.workspace = true # For creating test HUGRs from DagCircuit pecos-quantum = { workspace = true, features = ["hugr"] } diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 4d9ff9ace..c31839ab3 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -855,16 +855,25 @@ impl HugrEngine { let num_conditionals = block_info.conditional_nodes.len(); let num_bool_ops = block_info.bool_ops.len(); let num_tailloops = block_info.tailloop_nodes.len(); + let num_classical = block_info.classical_ops.len(); + let num_extension = block_info.extension_ops.len(); debug!( - "CFG {current_node:?}: activated entry block {entry_block:?} with {num_ops} ops, {num_conditionals} conditionals, {num_bool_ops} bool_ops, {num_tailloops} tailloops" + "CFG {current_node:?}: activated entry block {entry_block:?} with {num_ops} ops, {num_conditionals} conditionals, {num_bool_ops} bool_ops, {num_tailloops} tailloops, {num_classical} classical, {num_extension} extension" ); - // If entry block has no operations, immediately transition to successor + // If entry block has no operations AT ALL, immediately + // transition to the successor. Classical and extension ops + // count: transitioning before they run would propagate + // missing block outputs (e.g. a loop-bounds tuple built + // from LoadConstant + MakeTuple) and starve everything + // downstream. if num_ops == 0 && num_calls == 0 && num_conditionals == 0 && num_bool_ops == 0 && num_tailloops == 0 + && num_classical == 0 + && num_extension == 0 { debug!( "[TRACE] Entry block {:?} has 0 ops and 0 calls, successors: {:?}", @@ -1027,12 +1036,23 @@ impl HugrEngine { if let Some(cfg_node) = func_info.cfg_node { debug!("Call {current_node:?}: starting FuncDefn CFG {cfg_node:?}"); + // Capture the Call's instantiation type args so type + // variables inside the body (e.g. a generic loop + // bound read by prelude.load_nat) can be resolved. + let type_args = if let OpType::Call(call_op) = hugr.get_optype(current_node) + { + call_op.type_args.clone() + } else { + Vec::new() + }; + // Register as active call self.active_calls.insert( current_node, ActiveCallInfo { call_node: current_node, func_defn_node, + type_args, }, ); @@ -1093,6 +1113,12 @@ impl HugrEngine { // Retry any pending ops that might now have their inputs ready self.retry_pending_bool_reads(); + // A Case/block may consist of just constants feeding its + // Output (e.g. a loop's continue-flag bool) -- check + // completion so outputs propagate only with values present. + self.check_case_completion(&hugr, current_node); + self.check_cfg_block_completion(&hugr, current_node); + self.queue_ready_successors(&hugr, current_node); continue; } @@ -1140,6 +1166,11 @@ impl HugrEngine { // Check if any pending conditionals can now be resolved self.try_resolve_pending_conditionals(); + // Check if this classical op completion allows a Case to + // complete (cases may contain only classical ops, e.g. sum + // construction for an iterator's continue/break value) + self.check_case_completion(&hugr, current_node); + // Check if this classical op completion allows a CFG block to complete // This is especially important for loop control (iadd for incrementing counters) self.check_cfg_block_completion(&hugr, current_node); @@ -1166,6 +1197,10 @@ impl HugrEngine { // Check if any pending conditionals can now be resolved self.try_resolve_pending_conditionals(); + // Check if this extension op completion allows a Case to + // complete (cases may contain only classical/extension ops) + self.check_case_completion(&hugr, current_node); + // Check if this extension op completion allows a CFG block to complete // This is especially important for tket.bool ops in loop control self.check_cfg_block_completion(&hugr, current_node); @@ -1938,6 +1973,79 @@ mod tests { (unpack, fh) } + #[test] + fn test_ch_gate_full_execution_completes_cleanly() { + // End-to-end guard for classical value flow through nested function + // calls: guppy's ch() decomposes via a called function whose angle + // (pi/4) is computed by further calls over a tuple constant. The + // engine must (a) not fire a Call before its argument values exist, + // (b) run every Case/block classical op before propagating outputs, + // and (c) finish with NO active control flow left (leftover active + // entries mean a stall silently truncated the program). + use pecos_engines::{ByteMessageBuilder, ControlEngine, EngineStage}; + + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/ch_gate.hugr" + ); + let mut engine = HugrEngine::from_file(path).expect("Failed to load HUGR"); + let mut stage = engine.start(()).expect("Failed to start engine"); + let mut gate_counts: BTreeMap = BTreeMap::new(); + let mut rounds = 0; + loop { + rounds += 1; + assert!(rounds <= 10, "ch execution should complete in a few rounds"); + match stage { + EngineStage::NeedsProcessing(msg) => { + let ops = msg.quantum_ops().expect("parse quantum ops"); + for g in &ops { + *gate_counts.entry(g.gate_type).or_insert(0) += 1; + } + let n_meas = ops + .iter() + .filter(|g| { + matches!( + g.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) + }) + .count(); + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_outcomes(); + builder.add_outcomes(&vec![0usize; n_meas]); + stage = engine + .continue_processing(builder.build()) + .expect("continue"); + } + EngineStage::Complete(_) => break, + } + } + + // The CH decomposition: RY(pi/4), CZ, RY(-pi/4), then 2 measurements. + assert_eq!(gate_counts.get(&GateType::RY), Some(&2), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::CZ), Some(&1), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::MZ), Some(&2), "{gate_counts:?}"); + + // No stalled control flow and no starved nodes may remain. + assert!(engine.active_cases.is_empty(), "{:?}", engine.active_cases); + assert!( + engine.active_cfgs.is_empty(), + "cfgs: {:?}", + engine.active_cfgs.keys() + ); + assert!( + engine.active_calls.is_empty(), + "calls: {:?}", + engine.active_calls.keys() + ); + assert!(engine.active_tailloops.is_empty()); + assert!( + engine.pending_bool_reads.is_empty(), + "starved nodes: {:?}", + engine.pending_bool_reads + ); + } + #[test] fn test_ry_angle_through_tuple_wrap() { // Guppy lowers `ry(q, angle(0.5))` with the angle constant wrapped in diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index f782f87ac..8448286d0 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -565,125 +565,164 @@ pub fn extract_quantum_ops(hugr: &Hugr) -> BTreeMap { // --- Classical operation extraction --- -/// Extract classical operations from the HUGR (logic, arithmetic, etc.). +/// Classification result: op type, input count, output count, and integer +/// width/signedness where applicable. +pub type ClassicalOpClassification = (ClassicalOpType, usize, usize, Option<(u8, bool)>); + +/// Classify an extension op as a tracked classical operation. +/// +/// Covers `logic`, `arithmetic.int`, `arithmetic.float`, +/// `arithmetic.conversions`, `prelude` tuples, and copyable `Tag` sums. /// -/// This identifies operations from extensions like: -/// - `logic`: And, Or, Not, Xor, Eq -/// - `arithmetic.int`: iadd, isub, imul, etc. -/// - `arithmetic.float`: fadd, fsub, fmul, etc. -/// - `arithmetic.conversions`: int/float conversions -/// - `prelude`: `MakeTuple`, `UnpackTuple` +/// This is THE single source of truth for what counts as a classical op: +/// both the global classical-op map ([`extract_classical_ops`]) and the +/// per-CFG-block classical sets (`collect_classical_ops_recursive`) use it. +/// If the two ever disagree, a node can end up tracked for block completion +/// but never queued for execution (deadlocking the block), or queued but +/// not tracked (letting the block complete before the op ran, silently +/// truncating everything downstream). #[allow(clippy::too_many_lines)] +pub fn classify_classical_op(op: &OpType) -> Option { + // HUGR `Tag` nodes build tagged sum values (variants, options, branch + // selectors). Over copyable payloads they are classical computation; + // over linear (qubit) payloads they are wire routing and stay with the + // structural machinery, like linear tuples below. + if let OpType::Tag(_) = op { + let sig = op.dataflow_signature()?; + if sig + .input() + .iter() + .chain(sig.output().iter()) + .any(|t| !t.copyable()) + { + return None; + } + return Some((ClassicalOpType::TagSum, sig.input_count(), 1, None)); + } + + let ext_op = op.as_extension_op()?; + + let ext_id = ext_op.extension_id(); + let ext_name = ext_id.as_ref() as &str; + let op_name = ext_op.unqualified_id().to_string(); + + // Map extension operations to ClassicalOpType + let classified = match ext_name { + // Logic extension + "logic" => match op_name.as_str() { + "And" => (ClassicalOpType::And, 2, 1, None), + "Or" => (ClassicalOpType::Or, 2, 1, None), + "Not" => (ClassicalOpType::Not, 1, 1, None), + "Xor" => (ClassicalOpType::Xor, 2, 1, None), + "Eq" => (ClassicalOpType::Eq, 2, 1, None), + _ => return None, + }, + // Integer arithmetic extension + "arithmetic.int" => { + // Parse operation name to extract signedness info + // Operations like "iadd", "isub" are signed; "iadd_u" are unsigned + let is_signed = !op_name.ends_with("_u"); + match op_name.trim_end_matches("_u").trim_end_matches("_s") { + "iadd" => (ClassicalOpType::Iadd, 2, 1, Some((6, is_signed))), // default 64-bit + "isub" => (ClassicalOpType::Isub, 2, 1, Some((6, is_signed))), + "imul" => (ClassicalOpType::Imul, 2, 1, Some((6, is_signed))), + "idiv" | "idiv_checked" => (ClassicalOpType::Idiv, 2, 1, Some((6, is_signed))), + "imod" => (ClassicalOpType::Imod, 2, 1, Some((6, is_signed))), + "ineg" => (ClassicalOpType::Ineg, 1, 1, Some((6, true))), + "iabs" => (ClassicalOpType::Iabs, 1, 1, Some((6, is_signed))), + "ieq" => (ClassicalOpType::Ieq, 2, 1, Some((6, is_signed))), + "ine" => (ClassicalOpType::Ine, 2, 1, Some((6, is_signed))), + "ilt" => (ClassicalOpType::Ilt, 2, 1, Some((6, is_signed))), + "ile" => (ClassicalOpType::Ile, 2, 1, Some((6, is_signed))), + "igt" => (ClassicalOpType::Igt, 2, 1, Some((6, is_signed))), + "ige" => (ClassicalOpType::Ige, 2, 1, Some((6, is_signed))), + "iand" => (ClassicalOpType::Iand, 2, 1, Some((6, is_signed))), + "ior" => (ClassicalOpType::Ior, 2, 1, Some((6, is_signed))), + "ixor" => (ClassicalOpType::Ixor, 2, 1, Some((6, is_signed))), + "inot" => (ClassicalOpType::Inot, 1, 1, Some((6, is_signed))), + "ishl" => (ClassicalOpType::Ishl, 2, 1, Some((6, is_signed))), + "ishr" => (ClassicalOpType::Ishr, 2, 1, Some((6, is_signed))), + _ => return None, + } + } + // Float arithmetic extension + "arithmetic.float" => match op_name.as_str() { + "fadd" => (ClassicalOpType::Fadd, 2, 1, None), + "fsub" => (ClassicalOpType::Fsub, 2, 1, None), + "fmul" => (ClassicalOpType::Fmul, 2, 1, None), + "fdiv" => (ClassicalOpType::Fdiv, 2, 1, None), + "fneg" => (ClassicalOpType::Fneg, 1, 1, None), + "fabs" => (ClassicalOpType::Fabs, 1, 1, None), + "ffloor" => (ClassicalOpType::Ffloor, 1, 1, None), + "fceil" => (ClassicalOpType::Fceil, 1, 1, None), + "feq" => (ClassicalOpType::Feq, 2, 1, None), + "fne" => (ClassicalOpType::Fne, 2, 1, None), + "flt" => (ClassicalOpType::Flt, 2, 1, None), + "fle" => (ClassicalOpType::Fle, 2, 1, None), + "fgt" => (ClassicalOpType::Fgt, 2, 1, None), + "fge" => (ClassicalOpType::Fge, 2, 1, None), + _ => return None, + }, + // Conversion extension + "arithmetic.conversions" => match op_name.as_str() { + "convert_s" | "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, None), + "trunc_s" | "trunc_u" => (ClassicalOpType::ConvertFloatToInt, 1, 1, None), + _ => return None, + }, + // Prelude extension (tuples, etc.) + "prelude" => { + // Use the dataflow-signature port counts, NOT hugr.num_inputs/ + // num_outputs: the portgraph counts include the order port, and + // an inflated num_inputs makes handle_classical_op wait forever + // on an order-port "value" that never arrives (starving every + // consumer downstream of the tuple). + let sig = op.dataflow_signature()?; + // Tuples that carry LINEAR values (qubits) are wire routing, + // not classical computation: the classical executor can never + // produce values for them (they would defer forever and block + // CFG-block completion). Qubit flow through them is resolved + // structurally by wire tracing instead. + if sig + .input() + .iter() + .chain(sig.output().iter()) + .any(|t| !t.copyable()) + { + return None; + } + let num_inputs = sig.input_count(); + let num_outputs = sig.output_count(); + match op_name.as_str() { + "MakeTuple" => (ClassicalOpType::MakeTuple, num_inputs, 1, None), + "UnpackTuple" => (ClassicalOpType::UnpackTuple, 1, num_outputs, None), + _ => return None, + } + } + _ => return None, + }; + + Some(classified) +} + +/// Extract classical operations from the HUGR (logic, arithmetic, tuples). pub fn extract_classical_ops(hugr: &Hugr) -> BTreeMap { let mut operations = BTreeMap::new(); for node in hugr.nodes() { let op = hugr.get_optype(node); - - // Check if this is an extension operation - let Some(ext_op) = op.as_extension_op() else { - continue; - }; - - let ext_id = ext_op.extension_id(); - let ext_name = ext_id.as_ref() as &str; - let op_name = ext_op.unqualified_id().to_string(); - - // Map extension operations to ClassicalOpType - let (op_type, num_inputs, num_outputs, int_info) = match ext_name { - // Logic extension - "logic" => match op_name.as_str() { - "And" => (ClassicalOpType::And, 2, 1, None), - "Or" => (ClassicalOpType::Or, 2, 1, None), - "Not" => (ClassicalOpType::Not, 1, 1, None), - "Xor" => (ClassicalOpType::Xor, 2, 1, None), - "Eq" => (ClassicalOpType::Eq, 2, 1, None), - _ => continue, - }, - // Integer arithmetic extension - "arithmetic.int" => { - // Parse operation name to extract signedness info - // Operations like "iadd", "isub" are signed; "iadd_u" are unsigned - let is_signed = !op_name.ends_with("_u"); - match op_name.trim_end_matches("_u").trim_end_matches("_s") { - "iadd" => (ClassicalOpType::Iadd, 2, 1, Some((6, is_signed))), // default 64-bit - "isub" => (ClassicalOpType::Isub, 2, 1, Some((6, is_signed))), - "imul" => (ClassicalOpType::Imul, 2, 1, Some((6, is_signed))), - "idiv" | "idiv_checked" => (ClassicalOpType::Idiv, 2, 1, Some((6, is_signed))), - "imod" => (ClassicalOpType::Imod, 2, 1, Some((6, is_signed))), - "ineg" => (ClassicalOpType::Ineg, 1, 1, Some((6, true))), - "iabs" => (ClassicalOpType::Iabs, 1, 1, Some((6, is_signed))), - "ieq" => (ClassicalOpType::Ieq, 2, 1, Some((6, is_signed))), - "ine" => (ClassicalOpType::Ine, 2, 1, Some((6, is_signed))), - "ilt" => (ClassicalOpType::Ilt, 2, 1, Some((6, is_signed))), - "ile" => (ClassicalOpType::Ile, 2, 1, Some((6, is_signed))), - "igt" => (ClassicalOpType::Igt, 2, 1, Some((6, is_signed))), - "ige" => (ClassicalOpType::Ige, 2, 1, Some((6, is_signed))), - "iand" => (ClassicalOpType::Iand, 2, 1, Some((6, is_signed))), - "ior" => (ClassicalOpType::Ior, 2, 1, Some((6, is_signed))), - "ixor" => (ClassicalOpType::Ixor, 2, 1, Some((6, is_signed))), - "inot" => (ClassicalOpType::Inot, 1, 1, Some((6, is_signed))), - "ishl" => (ClassicalOpType::Ishl, 2, 1, Some((6, is_signed))), - "ishr" => (ClassicalOpType::Ishr, 2, 1, Some((6, is_signed))), - _ => continue, - } - } - // Float arithmetic extension - "arithmetic.float" => match op_name.as_str() { - "fadd" => (ClassicalOpType::Fadd, 2, 1, None), - "fsub" => (ClassicalOpType::Fsub, 2, 1, None), - "fmul" => (ClassicalOpType::Fmul, 2, 1, None), - "fdiv" => (ClassicalOpType::Fdiv, 2, 1, None), - "fneg" => (ClassicalOpType::Fneg, 1, 1, None), - "fabs" => (ClassicalOpType::Fabs, 1, 1, None), - "ffloor" => (ClassicalOpType::Ffloor, 1, 1, None), - "fceil" => (ClassicalOpType::Fceil, 1, 1, None), - "feq" => (ClassicalOpType::Feq, 2, 1, None), - "fne" => (ClassicalOpType::Fne, 2, 1, None), - "flt" => (ClassicalOpType::Flt, 2, 1, None), - "fle" => (ClassicalOpType::Fle, 2, 1, None), - "fgt" => (ClassicalOpType::Fgt, 2, 1, None), - "fge" => (ClassicalOpType::Fge, 2, 1, None), - _ => continue, - }, - // Conversion extension - "arithmetic.conversions" => match op_name.as_str() { - "convert_s" | "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, None), - "trunc_s" | "trunc_u" => (ClassicalOpType::ConvertFloatToInt, 1, 1, None), - _ => continue, - }, - // Prelude extension (tuples, etc.) - "prelude" => { - // Use the dataflow-signature port counts, NOT hugr.num_inputs/ - // num_outputs: the portgraph counts include the order port, and - // an inflated num_inputs makes handle_classical_op wait forever - // on an order-port "value" that never arrives (starving every - // consumer downstream of the tuple). - let Some(sig) = op.dataflow_signature() else { - continue; - }; - let num_inputs = sig.input_count(); - let num_outputs = sig.output_count(); - match op_name.as_str() { - "MakeTuple" => (ClassicalOpType::MakeTuple, num_inputs, 1, None), - "UnpackTuple" => (ClassicalOpType::UnpackTuple, 1, num_outputs, None), - _ => continue, - } - } - _ => continue, - }; - - operations.insert( - node, - ClassicalOp { + if let Some((op_type, num_inputs, num_outputs, int_info)) = classify_classical_op(op) { + operations.insert( node, - op_type, - num_inputs, - num_outputs, - int_info, - const_value: None, - }, - ); + ClassicalOp { + node, + op_type, + num_inputs, + num_outputs, + int_info, + const_value: None, + }, + ); + } } operations @@ -817,26 +856,15 @@ pub fn find_classical_ops_in_block(hugr: &Hugr, block: Node) -> BTreeSet { } /// Recursively collect classical operation nodes in a subtree. +/// +/// Uses [`classify_classical_op`] so the per-block classical sets match the +/// global classical-op map exactly (see that function's docs for why a +/// mismatch deadlocks or silently truncates CFG-block execution). fn collect_classical_ops_recursive(hugr: &Hugr, node: Node, classical_ops: &mut BTreeSet) { for child in hugr.children(node) { let op = hugr.get_optype(child); - if let Some(ext_op) = op.as_extension_op() { - let ext_id = ext_op.extension_id(); - let ext_name = ext_id.as_ref() as &str; - // Classical ops are from these extensions - if matches!( - ext_name, - "logic" | "arithmetic.int" | "arithmetic.float" | "arithmetic.conversions" - ) { - classical_ops.insert(child); - } - // Also check prelude for MakeTuple/UnpackTuple - if ext_name == "prelude" { - let op_name = ext_op.unqualified_id().to_string(); - if op_name == "MakeTuple" || op_name == "UnpackTuple" { - classical_ops.insert(child); - } - } + if classify_classical_op(op).is_some() { + classical_ops.insert(child); } // Recurse into nested containers (but not into FuncDefns or Conditionals) if !matches!(op, OpType::FuncDefn(_) | OpType::Conditional(_)) { @@ -935,9 +963,25 @@ pub fn all_predecessors_ready( if cfgs.contains_key(&pred_node) && !processed.contains(&pred_node) { return false; } - // Check Call nodes and TailLoop nodes (they also produce qubit/array outputs) + // Check Call, TailLoop, and LoadConstant nodes (they produce + // qubit/array/classical outputs the consumer needs) let op = hugr.get_optype(pred_node); - if matches!(op, OpType::Call(_) | OpType::TailLoop(_)) && !processed.contains(&pred_node) { + if matches!( + op, + OpType::Call(_) | OpType::TailLoop(_) | OpType::LoadConstant(_) + ) && !processed.contains(&pred_node) + { + return false; + } + // Check extension-op and executable-Tag predecessors (classical ops, + // tket.* ops, copyable sum construction): they produce classical + // values, and firing a consumer before they complete copies MISSING + // inputs (e.g. a Call activated before its argument tuple exists + // silently starves the whole called body). Linear (qubit-routing) + // Tags never execute, so they must NOT gate readiness. + if (op.as_extension_op().is_some() || classify_classical_op(op).is_some()) + && !processed.contains(&pred_node) + { return false; } } diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index 25053a3ba..ba878e1d2 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -33,12 +33,45 @@ //! 6. Call's successors are added to work queue use log::debug; -use tket::hugr::{Hugr, HugrView, IncomingPort, PortIndex}; +use tket::hugr::ops::OpType; +use tket::hugr::types::TypeArg; +use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; -use crate::engine::analysis::all_predecessors_ready; impl HugrEngine { + /// Resolve a type variable used inside a called function body to the + /// concrete `BoundedNat` from the calling `Call`'s instantiation args. + /// + /// Generic function bodies reference their type parameters as variables + /// (e.g. `prelude.load_nat` of a generic loop bound); only the calling + /// `Call` op knows the concrete instantiation. + pub(crate) fn resolve_call_type_arg( + &self, + hugr: &Hugr, + node: Node, + var_idx: usize, + ) -> Option { + // Find the enclosing FuncDefn of this node. + let mut cur = hugr.get_parent(node); + let func_defn = loop { + let n = cur?; + if matches!(hugr.get_optype(n), OpType::FuncDefn(_)) { + break n; + } + cur = hugr.get_parent(n); + }; + // Find the active call executing this FuncDefn and read its arg. + self.active_calls + .values() + .find(|info| info.func_defn_node == func_defn) + .and_then(|info| info.type_args.get(var_idx)) + .and_then(|arg| match arg { + TypeArg::BoundedNat(n) => Some(*n), + _ => None, + }) + } + /// Complete a function call if the completed CFG belongs to an active Call's `FuncDefn`. /// /// This method is called when a CFG completes. It checks if that CFG belongs @@ -115,27 +148,12 @@ impl HugrEngine { // This is critical for function calls inside TailLoop bodies self.check_tailloop_body_completion(hugr, call_node); - // Add Call's successors to work queue - for succ_node in hugr.output_neighbours(call_node) { - if (self.quantum_ops.contains_key(&succ_node) - || self.call_targets.contains_key(&succ_node) - || self.conditionals.contains_key(&succ_node) - || self.cfgs.contains_key(&succ_node)) - && !self.processed.contains(&succ_node) - && !self.work_queue.contains(&succ_node) - && all_predecessors_ready( - hugr, - succ_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - debug!("Call {call_node:?}: adding successor {succ_node:?} to work queue"); - self.work_queue.push_back(succ_node); - } - } + // Add Call's successors to the work queue via the canonical + // readiness check. (A hand-rolled subset here used to omit + // classical and extension ops, so e.g. a MakeTuple consuming + // the Call's result never ran and the enclosing CFG block + // never completed.) + self.queue_ready_successors(hugr, call_node); // Check if there are pending calls to this FuncDefn if let Some(pending) = self.pending_func_calls.get_mut(&func_defn_node) diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index b0930ee2c..1d9e9e30a 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -320,71 +320,41 @@ impl HugrEngine { // Check the current block if let Some(block_info) = cfg_info.blocks.get(&active_cfg.current_block) { - // Check if this block has operations that drive completion. - // Quantum, calls, conditionals, bool, extension, and tailloops - // complete explicitly. Classical_ops complete when their inputs are ready. - let has_completion_driving_ops = !block_info.quantum_ops.is_empty() - || !block_info.call_nodes.is_empty() - || !block_info.conditional_nodes.is_empty() - || !block_info.bool_ops.is_empty() - || !block_info.extension_ops.is_empty() - || !block_info.tailloop_nodes.is_empty(); - - // Check if the processed node is in this block - let is_in_block = if has_completion_driving_ops { - block_info.quantum_ops.contains(&processed_node) - || block_info.call_nodes.contains(&processed_node) - || block_info.conditional_nodes.contains(&processed_node) - || block_info.bool_ops.contains(&processed_node) - || block_info.extension_ops.contains(&processed_node) - || block_info.tailloop_nodes.contains(&processed_node) - } else { - // Block has only classical ops - track those for completion - block_info.classical_ops.contains(&processed_node) - }; + // A block is complete only when EVERY tracked op set in it has + // been processed -- including classical ops. Transitioning + // while a classical op is still pending would propagate + // missing block outputs (e.g. a partially-built loop-state + // tuple) and silently starve everything downstream. + let is_in_block = block_info.quantum_ops.contains(&processed_node) + || block_info.call_nodes.contains(&processed_node) + || block_info.conditional_nodes.contains(&processed_node) + || block_info.bool_ops.contains(&processed_node) + || block_info.extension_ops.contains(&processed_node) + || block_info.tailloop_nodes.contains(&processed_node) + || block_info.classical_ops.contains(&processed_node); if is_in_block { - // Check completion based on block type - let block_complete = if has_completion_driving_ops { - // Block with completion-driving ops: wait for all such op types. - let all_quantum_done = block_info - .quantum_ops - .iter() - .all(|op| self.processed.contains(op)); - let all_calls_done = block_info - .call_nodes - .iter() - .all(|call| self.processed.contains(call)); - let all_conditionals_done = block_info - .conditional_nodes - .iter() - .all(|cond| self.processed.contains(cond)); - let all_bools_done = block_info - .bool_ops - .iter() - .all(|op| self.processed.contains(op)); - let all_extensions_done = block_info - .extension_ops - .iter() - .all(|op| self.processed.contains(op)); - let all_tailloops_done = block_info - .tailloop_nodes - .iter() - .all(|tl| self.processed.contains(tl)); - - all_quantum_done - && all_calls_done - && all_conditionals_done - && all_bools_done - && all_extensions_done - && all_tailloops_done - } else { - // Classical-only block: wait for classical ops - block_info - .classical_ops - .iter() - .all(|op| self.processed.contains(op)) + let all_done = |set: &std::collections::BTreeSet| { + set.iter().all(|op| self.processed.contains(op)) }; + // A conditional is only DONE once its selected Case has + // completed and propagated outputs -- expansion alone + // marks it processed, but its output values do not exist + // yet (transitioning then would copy missing values). + let conditionals_done = block_info.conditional_nodes.iter().all(|cond| { + self.processed.contains(cond) + && !self + .active_cases + .values() + .any(|case| case.conditional_node == *cond) + }); + let block_complete = all_done(&block_info.quantum_ops) + && all_done(&block_info.call_nodes) + && conditionals_done + && all_done(&block_info.bool_ops) + && all_done(&block_info.extension_ops) + && all_done(&block_info.tailloop_nodes) + && all_done(&block_info.classical_ops); if block_complete { block_completions.push(( @@ -532,6 +502,11 @@ impl HugrEngine { } } // Also activate Call nodes in this block + // Clear processed state first so they can be re-executed in loops + // (each loop iteration must call the function again) + for &call_node in &block_info.call_nodes { + self.processed.remove(&call_node); + } for &call_node in &block_info.call_nodes { self.nodes_inside_cfg_blocks.remove(&call_node); // Skip Call nodes inside TailLoops @@ -948,11 +923,29 @@ impl HugrEngine { let sum_port = IncomingPort::from(0); let mut payload_len = 0; - if let Some((sum_src_node, _)) = hugr.single_linked_output(from_output, sum_port) { + if let Some((sum_src_node, sum_src_port)) = hugr.single_linked_output(from_output, sum_port) + { let sum_src_op = hugr.get_optype(sum_src_node); + let sum_wire = (sum_src_node, sum_src_port.index()); + // If the branch Sum has an executed VALUE (e.g. built by a Tag + // over classical values, like a loop's carried state), its + // payload elements are the successor's first inputs. + if let Some(ClassicalValue::Sum { values, .. }) = + self.wire_state.classical_values.get(&sum_wire).cloned() + { + payload_len = values.len(); + for (i, value) in values.into_iter().enumerate() { + debug!( + "[TRACE] Block transition: propagated branch payload {value:?} to {to_input:?}:{i}" + ); + self.wire_state + .classical_values + .insert((to_input, i), value); + } + } // Check if it's a Conditional - extract payload from virtual output ports - if matches!(sum_src_op, OpType::Conditional(_)) { + else if matches!(sum_src_op, OpType::Conditional(_)) { // Look for payload values at virtual output ports (1, 2, ...) let mut idx = 1; while let Some(value) = self @@ -1082,17 +1075,76 @@ impl HugrEngine { /// Propagate wire mappings from final block to CFG outputs. pub(crate) fn propagate_cfg_outputs(&mut self, hugr: &Hugr, cfg_node: Node, final_block: Node) { + use tket::hugr::ops::OpTrait; + let Some(output_node) = find_output_node(hugr, final_block) else { debug!("No Output node found in final block {final_block:?}"); return; }; - // Block Output: port 0 = Sum (control), ports 1+ = data - // CFG outputs correspond to data ports (skip the Sum) + // Block Output: port 0 = Sum (control), ports 1+ = data. + // When the Sum is built by a Tag, its inputs are the PAYLOAD carried + // into the exit variant -- these become the first CFG outputs (e.g. a + // called function's return value rides in the Tag payload, not on the + // data ports). + let mut payload_len = 0; + if let Some((sum_src, sum_src_port)) = + hugr.single_linked_output(output_node, IncomingPort::from(0)) + && let OpType::Tag(_) = hugr.get_optype(sum_src) + { + // Prefer the executed Tag's Sum VALUE: its payload elements are + // the CFG outputs (a called function's return value rides here). + let sum_wire = (sum_src, sum_src_port.index()); + if let Some(ClassicalValue::Sum { values, .. }) = + self.wire_state.classical_values.get(&sum_wire).cloned() + { + payload_len = values.len(); + for (i, value) in values.into_iter().enumerate() { + debug!("CFG {cfg_node:?} output {i}: mapped payload value {value:?}"); + self.wire_state + .classical_values + .insert((cfg_node, i), value); + } + } else { + // Structural fallback (e.g. linear payloads: Tags over qubits + // do not execute as classical ops). + let num_payload = hugr + .get_optype(sum_src) + .dataflow_signature() + .map_or(0, |sig| sig.input_count()); + for i in 0..num_payload { + if let Some((vsrc, vport)) = + hugr.single_linked_output(sum_src, IncomingPort::from(i)) + { + let src_wire = (vsrc, vport.index()); + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { + self.wire_state + .wire_to_qubit + .insert((cfg_node, i), qubit_id); + debug!( + "CFG {cfg_node:?} output {i}: mapped payload qubit {qubit_id:?}" + ); + } + if let Some(value) = + self.wire_state.classical_values.get(&src_wire).cloned() + { + debug!("CFG {cfg_node:?} output {i}: mapped payload value {value:?}"); + self.wire_state + .classical_values + .insert((cfg_node, i), value); + } + } + } + payload_len = num_payload; + } + } + + // CFG outputs after the payload correspond to the data ports. let num_data_outputs = hugr.num_inputs(output_node).saturating_sub(1); for port_idx in 0..num_data_outputs { let block_port = IncomingPort::from(port_idx + 1); // Skip Sum port + let cfg_out_idx = payload_len + port_idx; if let Some((src_node, src_port)) = hugr.single_linked_output(output_node, block_port) { let src_wire = (src_node, src_port.index()); @@ -1100,8 +1152,14 @@ impl HugrEngine { if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { self.wire_state .wire_to_qubit - .insert((cfg_node, port_idx), qubit_id); - debug!("CFG {cfg_node:?} output {port_idx}: mapped qubit {qubit_id:?}"); + .insert((cfg_node, cfg_out_idx), qubit_id); + debug!("CFG {cfg_node:?} output {cfg_out_idx}: mapped qubit {qubit_id:?}"); + } + if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { + debug!("CFG {cfg_node:?} output {cfg_out_idx}: mapped value {value:?}"); + self.wire_state + .classical_values + .insert((cfg_node, cfg_out_idx), value); } } } diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index 9847b3b6f..d723ed221 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -45,7 +45,7 @@ use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; use crate::engine::analysis::find_input_node; -use crate::engine::types::{ActiveCaseInfo, QuantumOp}; +use crate::engine::types::{ActiveCaseInfo, ClassicalValue, QuantumOp}; impl HugrEngine { /// Try to resolve the control value for a Conditional node. @@ -162,6 +162,14 @@ impl HugrEngine { debug!("Case {case_node:?} complete, propagating outputs to Conditional {cond_node:?}"); self.propagate_conditional_outputs(hugr, cond_node, case_node); self.active_cases.remove(&case_node); + + // The Conditional's outputs exist only now: re-run the checks + // that treat the Conditional as complete (block completion gates + // on the case being done) and wake its consumers. + self.check_cfg_block_completion(hugr, cond_node); + self.check_tailloop_body_completion(hugr, cond_node); + self.queue_ready_successors(hugr, cond_node); + self.retry_pending_bool_reads(); } } @@ -208,19 +216,28 @@ impl HugrEngine { ); } - // Check if we have a classical value for this wire - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { + // Check if we have a classical value for this wire. When the + // source Tag executed as a classical op this is the real Sum + // value -- the structural fallback below must NOT overwrite + // it with just the tag number. + let has_value = if let Some(value) = + self.wire_state.classical_values.get(&src_wire).cloned() + { self.wire_state .classical_values .insert((cond_node, port_idx), value.clone()); debug!( "Mapped Conditional {cond_node:?} output {port_idx} to classical value {value:?} (from {src_wire:?})" ); - } + true + } else { + false + }; - // Check if the source is a Tag node - store its tag value and payload + // Structural fallback: if the source is an unexecuted Tag + // node, store its tag value and payload let src_op = hugr.get_optype(src_node); - if let OpType::Tag(tag_op) = src_op { + if !has_value && let OpType::Tag(tag_op) = src_op { let tag_value = tag_op.tag; #[allow(clippy::cast_possible_wrap)] // Tag indices are small self.wire_state @@ -297,9 +314,34 @@ impl HugrEngine { if let Some(input_node) = input_node { debug!("Case {selected_case:?} has Input node {input_node:?}"); + // The Case's Input row is [selected variant's PAYLOAD] ++ + // [other inputs]. Unpack the control Sum's payload values into + // the first Case Input ports (e.g. an iterator's + // Continue(item, state) payload); empty-payload variants (plain + // bools) contribute nothing and leave the offset at 0. + let mut payload_len = 0; + if let Some((ctrl_src, ctrl_port)) = + hugr.single_linked_output(cond_node, IncomingPort::from(0)) + && let Some(ClassicalValue::Sum { values, .. }) = self + .wire_state + .classical_values + .get(&(ctrl_src, ctrl_port.index())) + .cloned() + { + payload_len = values.len(); + for (i, value) in values.into_iter().enumerate() { + debug!( + "Propagated control payload {value:?} to Case Input ({input_node:?}, {i})" + ); + self.wire_state + .classical_values + .insert((input_node, i), value); + } + } + // Propagate ALL wires (qubit and classical) from Conditional inputs to the Case's Input node // Port 0 is the control (Sum type), ports 1+ are data inputs - // The Case's Input node outputs correspond to the Conditional's non-control inputs + // following the payload values unpacked above. let num_cond_inputs = hugr.num_inputs(cond_node); // Start from port 1 (skip control), propagate all inputs @@ -309,7 +351,7 @@ impl HugrEngine { hugr.single_linked_output(cond_node, cond_in_port) { let src_wire = (src_node, src_port.index()); - let input_output_idx = port_idx - 1; + let input_output_idx = payload_len + port_idx - 1; // Propagate qubit mappings if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { @@ -351,10 +393,65 @@ impl HugrEngine { } } + // The Case's classical, bool, and extension ops must also execute + // before its outputs propagate (propagating early copies missing + // values downstream). Unblock them from the inside-cases guard, + // queue the ready ones, and track them all for completion. + let case_classical = + crate::engine::analysis::find_classical_ops_in_block(hugr, selected_case); + let case_bools = crate::engine::analysis::find_bool_ops_in_block(hugr, selected_case); + let case_extension: Vec = + crate::engine::analysis::find_extension_ops_in_block(hugr, selected_case) + .into_iter() + .filter(|n| { + !self.quantum_ops.contains_key(n) + && !case_classical.contains(n) + && !case_bools.contains(n) + }) + .collect(); + // LoadConstants feeding the Case's Output (e.g. a loop's + // continue-flag bool) also count: queue and track them like blocks + // do at activation. + let case_load_consts: Vec = hugr + .children(selected_case) + .filter(|&child| matches!(hugr.get_optype(child), OpType::LoadConstant(_))) + .collect(); + // Clear processed state first so the case body re-executes when the + // conditional runs again (e.g. once per loop iteration), then queue. + for &op_node in case_classical + .iter() + .chain(case_bools.iter()) + .chain(case_extension.iter()) + .chain(case_load_consts.iter()) + { + self.processed.remove(&op_node); + } + for &op_node in case_classical + .iter() + .chain(case_bools.iter()) + .chain(case_extension.iter()) + .chain(case_load_consts.iter()) + { + self.nodes_inside_cases.remove(&op_node); + ops_in_case.insert(op_node); + if !self.work_queue.contains(&op_node) + && crate::engine::analysis::all_predecessors_ready( + hugr, + op_node, + &self.quantum_ops, + &self.conditionals, + &self.cfgs, + &self.processed, + ) + { + self.work_queue.push_back(op_node); + } + } + // Register this Case as active so we can propagate outputs when complete if ops_in_case.is_empty() { // No ops in this Case - propagate outputs immediately - debug!("Case {selected_case:?} has no quantum ops, propagating outputs immediately"); + debug!("Case {selected_case:?} has no ops, propagating outputs immediately"); self.propagate_conditional_outputs(hugr, cond_node, selected_case); } else { self.active_cases.insert( diff --git a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs index e9a202c09..7fbdfe963 100644 --- a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs +++ b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs @@ -320,135 +320,187 @@ impl HugrEngine { // Integer to float conversions "convert_s" | "itof_s" => { // Signed integer to float - if let Some(value) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) { - let result = value as f64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Float(result)); - debug!("convert_s: {value} -> {result}"); - } + let Some(value) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) + else { + debug!("convert_s at {node:?}: input not ready, deferring"); + return false; + }; + let result = value as f64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Float(result)); + debug!("convert_s: {value} -> {result}"); } "convert_u" | "itof_u" => { // Unsigned integer to float - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_uint()) - { - let result = value as f64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Float(result)); - debug!("convert_u: {value} -> {result}"); - } + else { + debug!("convert_u at {node:?}: input not ready, deferring"); + return false; + }; + let result = value as f64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Float(result)); + debug!("convert_u: {value} -> {result}"); + } + + // usize <-> int conversions (e.g. guppy loop bounds built from + // prelude.load_nat's usize output) + "ifromusize" => { + let Some(value) = self + .get_input_value(hugr, node, 0) + .and_then(|v| v.as_uint()) + else { + debug!("ifromusize at {node:?}: input not ready, deferring"); + return false; + }; + #[allow(clippy::cast_possible_wrap)] + let result = value as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("ifromusize: {value} -> {result}"); + } + "itousize" => { + let Some(value) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) + else { + debug!("itousize at {node:?}: input not ready, deferring"); + return false; + }; + #[allow(clippy::cast_sign_loss)] + let result = value as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("itousize: {value} -> {result}"); } // Float to integer conversions (truncate toward zero) "trunc_s" | "ftoi_s" => { // Float to signed integer (truncate) - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let result = value.trunc() as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(result)); - debug!("trunc_s: {value} -> {result}"); - } + else { + debug!("trunc_s at {node:?}: input not ready, deferring"); + return false; + }; + let result = value.trunc() as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("trunc_s: {value} -> {result}"); } "trunc_u" | "ftoi_u" => { // Float to unsigned integer (truncate) - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - // Clamp to non-negative before converting - let clamped = value.max(0.0).trunc(); - let result = clamped as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(result)); - debug!("trunc_u: {value} -> {result}"); - } + else { + debug!("trunc_u at {node:?}: input not ready, deferring"); + return false; + }; + // Clamp to non-negative before converting + let clamped = value.max(0.0).trunc(); + let result = clamped as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("trunc_u: {value} -> {result}"); } // Ceiling/floor variants "ceil_s" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let result = value.ceil() as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(result)); - debug!("ceil_s: {value} -> {result}"); - } + else { + debug!("ceil_s at {node:?}: input not ready, deferring"); + return false; + }; + let result = value.ceil() as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("ceil_s: {value} -> {result}"); } "ceil_u" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let clamped = value.max(0.0).ceil(); - let result = clamped as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(result)); - debug!("ceil_u: {value} -> {result}"); - } + else { + debug!("ceil_u at {node:?}: input not ready, deferring"); + return false; + }; + let clamped = value.max(0.0).ceil(); + let result = clamped as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("ceil_u: {value} -> {result}"); } "floor_s" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let result = value.floor() as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(result)); - debug!("floor_s: {value} -> {result}"); - } + else { + debug!("floor_s at {node:?}: input not ready, deferring"); + return false; + }; + let result = value.floor() as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("floor_s: {value} -> {result}"); } "floor_u" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let clamped = value.max(0.0).floor(); - let result = clamped as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(result)); - debug!("floor_u: {value} -> {result}"); - } + else { + debug!("floor_u at {node:?}: input not ready, deferring"); + return false; + }; + let clamped = value.max(0.0).floor(); + let result = clamped as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("floor_u: {value} -> {result}"); } // Rounding "round_s" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let result = value.round() as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(result)); - debug!("round_s: {value} -> {result}"); - } + else { + debug!("round_s at {node:?}: input not ready, deferring"); + return false; + }; + let result = value.round() as i64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(result)); + debug!("round_s: {value} -> {result}"); } "round_u" => { - if let Some(value) = self + let Some(value) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_float()) - { - let clamped = value.max(0.0).round(); - let result = clamped as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(result)); - debug!("round_u: {value} -> {result}"); - } + else { + debug!("round_u at {node:?}: input not ready, deferring"); + return false; + }; + let clamped = value.max(0.0).round(); + let result = clamped as u64; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(result)); + debug!("round_u: {value} -> {result}"); } _ => { diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 99217bacf..145e6a536 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -27,6 +27,7 @@ //! Also handles `tket.bool` extension operations. use log::debug; +use tket::hugr::ops::OpType; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; @@ -409,16 +410,41 @@ impl HugrEngine { return vec![(0, ClassicalValue::Tuple(inputs))]; } ClassicalOpType::UnpackTuple => { - // UnpackTuple takes a single tuple input and produces multiple outputs + // UnpackTuple takes a single tuple input and produces multiple + // outputs. A tuple is a 1-variant sum, so accept a Sum payload + // the same way (e.g. a tuple that crossed a CFG/Call boundary + // as a tagged value). let tuple_value = inputs.into_iter().next(); - if let Some(ClassicalValue::Tuple(elements)) = tuple_value { - // Return each element on its respective output port - return elements.into_iter().enumerate().collect(); - } else if let Some(value) = tuple_value { - // If it's a single non-tuple value, just pass it through on port 0 - return vec![(0, value)]; + match tuple_value { + Some( + ClassicalValue::Tuple(elements) + | ClassicalValue::Sum { + values: elements, .. + }, + ) => { + // Return each element on its respective output port + return elements.into_iter().enumerate().collect(); + } + Some(value) => { + // If it's a single non-tuple value, just pass it through on port 0 + return vec![(0, value)]; + } + None => return vec![], } - return vec![]; + } + ClassicalOpType::TagSum => { + // Tag wraps its inputs into the given variant of a sum. + let OpType::Tag(tag_op) = hugr.get_optype(node) else { + debug!("TagSum at {node:?}: node is not a Tag op"); + return vec![]; + }; + return vec![( + 0, + ClassicalValue::Sum { + tag: tag_op.tag, + values: inputs, + }, + )]; } }; diff --git a/crates/pecos-hugr/src/engine/handlers/prelude.rs b/crates/pecos-hugr/src/engine/handlers/prelude.rs index 5b9215f02..89416c67a 100644 --- a/crates/pecos-hugr/src/engine/handlers/prelude.rs +++ b/crates/pecos-hugr/src/engine/handlers/prelude.rs @@ -35,31 +35,46 @@ impl HugrEngine { match op_name { "load_nat" => { - // load_nat loads a bounded nat parameter into a usize runtime value. - // The value comes from the type arguments of the polymorphic instantiation. - // For now, we try to extract it from the extension op's args. + // load_nat loads a bounded nat parameter into a usize runtime + // value. In a monomorphic context the value is a concrete + // BoundedNat type arg on the op itself; inside a generic + // function body it is a type VARIABLE that must be resolved + // through the calling Call's instantiation type args. let op = hugr.get_optype(node); if let Some(ext_op) = op.as_extension_op() { - let args = ext_op.args(); - for arg in args { - // Look for BoundedNat type arg - if let tket::hugr::types::TypeArg::BoundedNat(n) = arg { - debug!("load_nat: found bounded nat value {n}"); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(*n)); - return true; + for arg in ext_op.args() { + match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => { + debug!("load_nat: found bounded nat value {n}"); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(*n)); + return true; + } + tket::hugr::types::TypeArg::Variable(var) => { + if let Some(n) = self.resolve_call_type_arg(hugr, node, var.index()) + { + debug!( + "load_nat: resolved type variable {} to {n} via active call", + var.index() + ); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::UInt(n)); + return true; + } + } + _ => {} } } - // If we can't find the value, log and return false debug!("load_nat: couldn't extract bounded nat value from args"); } - // Fallback: set a default value of 0 - debug!("load_nat: using default value 0"); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(0)); - true + // No fabricated default: a wrong nat here silently corrupts + // whatever consumes it (e.g. a loop bound). Defer so the + // engine's pending/retry mechanism (or, at completion, the + // stall accounting) surfaces the problem instead. + debug!("load_nat at {node:?}: value unresolved, deferring"); + false } "panic" => { @@ -84,13 +99,21 @@ impl HugrEngine { let op = hugr.get_optype(node); let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); + // A missing input means the value is not ready yet (or is a + // linear qubit handled structurally): defer instead of + // fabricating a default element, which would mark this node + // processed and let consumers (calls, blocks) fire with a + // garbage tuple. let mut elements = Vec::with_capacity(num_inputs); for port in 0..num_inputs { if let Some(value) = self.get_input_value(hugr, node, port) { elements.push(value); + } else if let Some(qubit_id) = self.get_input_qubit(hugr, node, port) { + // Linear payload: qubit flow is resolved structurally. + elements.push(ClassicalValue::QubitRef(qubit_id)); } else { - // Missing input - use a default - elements.push(ClassicalValue::Int(0)); + debug!("MakeTuple at {node:?}: input {port} not ready, deferring"); + return false; } } @@ -109,19 +132,43 @@ impl HugrEngine { let op = hugr.get_optype(node); let num_outputs = op.dataflow_signature().map_or(0, |sig| sig.output_count()); - if let Some(ClassicalValue::Tuple(elements)) = self.get_input_value(hugr, node, 0) { - for (port, value) in elements.into_iter().enumerate() { - if port < num_outputs { - self.wire_state.classical_values.insert((node, port), value); + // A tuple is a 1-variant sum, so accept a Sum payload the + // same way (e.g. a tuple that crossed a CFG/Call boundary as + // a tagged value). A missing input defers (marking the node + // processed without output would let consumers fire early). + match self.get_input_value(hugr, node, 0) { + Some( + ClassicalValue::Tuple(elements) + | ClassicalValue::Sum { + values: elements, .. + }, + ) => { + for (port, value) in elements.into_iter().enumerate() { + if port < num_outputs { + self.wire_state.classical_values.insert((node, port), value); + } } + debug!("UnpackTuple at {node:?}: unpacked to {num_outputs} outputs"); + true + } + Some(_) => { + // Single non-tuple value - pass through + debug!( + "UnpackTuple at {node:?}: input not a tuple, attempting pass-through" + ); + self.propagate_all_inputs(hugr, node); + true + } + None if self.get_input_qubit(hugr, node, 0).is_some() => { + // Linear (qubit) tuple: flow is resolved structurally. + self.propagate_all_inputs(hugr, node); + true + } + None => { + debug!("UnpackTuple at {node:?}: input not ready, deferring"); + false } - debug!("UnpackTuple at {node:?}: unpacked to {num_outputs} outputs"); - } else { - // Input not a tuple or not available - try pass-through as fallback - debug!("UnpackTuple at {node:?}: input not a tuple, attempting pass-through"); - self.propagate_all_inputs(hugr, node); } - true } _ => { diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index 3cb565320..05db26583 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -382,8 +382,9 @@ impl HugrEngine { return Some(ClassicalValue::Bool(bool_value)); } - // Tuple constants are single-variant sums with tag 0; a two-variant - // sum with no payload is a plain HUGR bool (False=0/True=1). + // Sum constants: a two-variant sum with no payload is a plain HUGR + // bool (False=0/True=1); a single-variant tag-0 sum is a tuple; any + // other variant shape is a general tagged sum. if let Value::Sum(sum) = value { let num_variants = sum.sum_type.num_variants(); if num_variants == 2 && sum.values.is_empty() { @@ -393,19 +394,28 @@ impl HugrEngine { ); return Some(ClassicalValue::Bool(sum.tag == 1)); } + let elements: Option> = sum + .values + .iter() + .map(Self::const_value_to_classical) + .collect(); + let elements = elements?; if num_variants == 1 && sum.tag == 0 { - let elements: Option> = sum - .values - .iter() - .map(Self::const_value_to_classical) - .collect(); - let elements = elements?; debug!( "const_value_to_classical: found tuple const with {} elements", elements.len() ); return Some(ClassicalValue::Tuple(elements)); } + debug!( + "const_value_to_classical: found sum const with tag {} and {} elements", + sum.tag, + elements.len() + ); + return Some(ClassicalValue::Sum { + tag: sum.tag, + values: elements, + }); } None diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index 38add32bd..2584c50f4 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -229,6 +229,8 @@ pub enum ClassicalOpType { // Tuple operations MakeTuple, UnpackTuple, + // Sum construction (HUGR `Tag` nodes: variants, options, branch selectors) + TagSum, } /// Classical operation extracted from HUGR. @@ -280,6 +282,16 @@ pub enum ClassicalValue { Float(f64), /// Tuple of values Tuple(Vec), + /// Tagged sum value (HUGR variants: options, loop continue/break, + /// branch selectors). A tuple is the 1-variant special case and a + /// bool the 2-variant empty-payload special case; this carries the + /// general form. + Sum { + /// The variant tag. + tag: usize, + /// The payload values of the active variant. + values: Vec, + }, /// Array of values Array(Vec), /// Future handle (for lazy measurements) @@ -300,6 +312,8 @@ impl ClassicalValue { Self::Bool(b) => Some(u32::from(*b)), Self::Int(i) => u32::try_from(*i).ok(), Self::UInt(u) => u32::try_from(*u).ok(), + // A sum's control-flow decision value is its variant tag. + Self::Sum { tag, .. } => u32::try_from(*tag).ok(), Self::Float(_) | Self::Tuple(_) | Self::Array(_) @@ -320,7 +334,10 @@ impl ClassicalValue { Self::Int(i) => Some(*i != 0), Self::UInt(u) => Some(*u != 0), Self::Float(f) => Some(*f != 0.0), - Self::Tuple(_) + // HUGR bools are 2-variant sums with empty payloads. + Self::Sum { tag, values } if values.is_empty() => Some(*tag != 0), + Self::Sum { .. } + | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::Rotation(_) @@ -340,7 +357,8 @@ impl ClassicalValue { Self::Int(i) => Some(*i), Self::UInt(u) => i64::try_from(*u).ok(), Self::Float(f) => Some(*f as i64), - Self::Tuple(_) + Self::Sum { .. } + | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::Rotation(_) @@ -360,7 +378,8 @@ impl ClassicalValue { Self::Int(i) => u64::try_from(*i).ok(), Self::UInt(u) => Some(*u), Self::Float(f) => Some(*f as u64), - Self::Tuple(_) + Self::Sum { .. } + | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::Rotation(_) @@ -381,7 +400,8 @@ impl ClassicalValue { Self::UInt(u) => Some(*u as f64), Self::Float(f) => Some(*f), Self::Rotation(r) => Some(*r), // Rotation can be interpreted as float (half-turns) - Self::Tuple(_) + Self::Sum { .. } + | Self::Tuple(_) | Self::Array(_) | Self::Future(_) | Self::RngContext(_) @@ -716,6 +736,10 @@ pub struct ActiveCallInfo { pub call_node: Node, /// The `FuncDefn` being called. pub func_defn_node: Node, + /// The Call's instantiation type arguments, used to resolve type + /// variables inside the called body (e.g. `prelude.load_nat` of a + /// generic bounded-nat parameter such as a loop bound). + pub type_args: Vec, } // --- TailLoop Control Flow Types --- diff --git a/crates/pecos/tests/test_data/hugr/ch_gate.hugr b/crates/pecos/tests/test_data/hugr/ch_gate.hugr new file mode 100644 index 0000000000000000000000000000000000000000..7b404de255a278aca9c9dd8a9753de692b1ed557 GIT binary patch literal 4765 zcmV;O5@PL0RYy{3NJ@4BK`6B^{Qy{1lmM#ns4!J<6w;;x!u9q4e|P5CSQeKvRWHtE zI!z8>iu-ULpJ%YrGzimzw;m9O?#O z93@LaX`veJ?j=9%(E{88^#XOfqW^7*cjfHXzYXg0YHPZ37N>tplM3P0a#zmkbUK{f z>EC*(4_@sKXRWo-GjMdZI-0XOx>_DNP+d6-q<`C!>hfxbbhSj9vqajnMEbV|q&j%D zKV3Piq{3^2!30~nS}x65E?sSx4ZL)6~?;s}0kf1=F4d)4y#Yb>-ED>B?CeWJo2wS}|SinC9%5t~Q4oQst}Np{pg+ zoF&uM`oKfV=+y?%)eg~|9ipo(BZLuMJ&Q#Dwun^Et2NWrD$$%(qN`n^f15MgKO6)C8~gimu#!|JI6>(z*Mtx2CzfwRgAvZ_t(3 zTho=hIQ?ImRMP9^u2-kItJC%F^nblndG79Xy*9W@qyGydRps^C=z4K9cX4#RI{Lpi zQbw;=2iN4>)zREVd6!53H%F@H^`g37Al);h>m}0l{&c-c4ESjTl(Z2hU9Xc~0@U?J zX(&T2MAY?$&E2r;75l#uqUw3(U0Hiq*8hd25IA>b zUGEZiXZ_zZCFI3YZ1*PBDzkVEU>L)RMw4?zHtqO{&JU9S;cFA`m^nIep6&P>;N4=7urN?a=j->HorzqSU)!nxmoX71RIyAVul+CrSOLHetc>;v+{$4T@6j73eOi z=m9o188MmB>TLuE3}G2LY@x7_A2u?BiQuqi;?w9e!9Rlk1A76|$AKRM{{;Tvd=9wY zNk0p}3SY%l#QqU}4?f5nEcILPP0W2uYjFNI=*qoId+*X$I!b@MOSkZ`XIyOWVh?qc z)+Xow>>E#N&U;e#bd)CftDBstXMCvZi5f>oY32I-yZXk*=03LfvFl%5yC2pw{_5Yj z4j=VAx*pZ0Z~p;wB$Asw4UL!ISMKKjsj0g%wv0|SUc;s7;5N54Oz8%%`Bw9Kjpn9P z6%slN`dOqjkHh(uMVIi8_*mgaqqy6Gs5t(ohNY{B(J3av zX6NY)OPA4!HJVQGg>;T)c&3UNS+Z8Ghoy^VhRtPoG9DwwcPjYKVYqGtfDOGy+oKt$`=X1`2V@hLZQ6A@%x{h@2h;{qzbvu)5CKg>9J;zhk z3RhwcY9)M`F4&a`Sz(1RG71Z#XeuC@vREiOG5v79>BPHWG9l4Q=;R=rZ(QLy7Ri!o z(39AbxkM9o#ysD2tkQK|KR~CK>?9w`lIkffa=S5$Q;NV@9EW2{4Y-iw+~&u**PK!x zzd5BnKi#H=X)aUB;{=>WfZtP!9lJ%ol>oHoth^rNVIa#!uh5%J?HNz zHFYylJ7uNmi1STniY>FO@fyxI-Rz>3)hUCKP%(6%pBCPNn`C$ zYF9TXx`|DSt{WB2ZB%qn{-g;vIRgyvj=yq}E0Ba0c#@5-XG?omyw?bYvn! z*9-=e9i2(w-Az7hx{ry6{FV8p*Eoji@O-a!(UqXkt=R`g2d4EooeuQX{Y0 zm!`DHJuL;z1ucvwMjNA%X-Z9hp^*tfE7O!Fa8E-*GovlxX;3t!M9yhcG^GgKtFJW` zr|vYR+P!Kcr{dtzlnVKcifT%SJf{L_N^3f<5@|{gIHg6N(7|>&)k&15)aP6!%Bf)$ zYf@8cz`Z)FGOsSxEvRaO05uGZx&v6tyr@f5C#n?nifzN$c62r#9ryi*jmV7crzx%R z)7p7k6r!dSfP0%bZb{rW<2DPpboo-Z6`E2S_ckzL#5AQd?rk}68<`oK4^63z*R4!b z`oGx9Y)EWMOZNK2qOawWl~{6oJYJ<~^nE8w zW}Y#!MDiKALZr{A{7kXvB_qFB__f5IBXTs)6!`gouoKkswZt=&xxy>rCl-BV$okF@ z>HNrYjKxF~rX+qd*lQO1!cR2IGqqxkuCnMgi>Xv9)zHw;&=e_Bq)d?_MG6rQ5fv(* z4;aHznxQF@;sP^MXmpiEMx~IDkdTn-sCCpzsZyy_mMmFjv)L3SB{3!j0kKM#>jy|Q z6{j|WBN;~@f7;K$QLWy+K(NPvK3uUE+evjic@5`+Y`G(|}% z6wM$9%+f5w&4#3yG-o+a7i^8v*yV~mUfem-CIaq$Ej962XsWiABncR;Gxhbb%j_7;}Y2pAR@QLvk!40zV!i zB;b)5w#JVKbb!j|1A-q9xB&S1fak0wnzAz_kH#4I@hoZid_Y!ge1RVic#3@`mh&S; z5z8qSeMz4De6El+9+gd6WEh$vtOP%vrKKeh2{^Jw7feYB`1ycEEmBq%fgcYMmCpx^ zy+SjD!Di>@D$AlzlqH3*@*{sv*l4n1Vhhyr`G8tJAMoSRLgjyVf(g8IFbL{|>Y)R5 z3gY9`bA?CD6_TcSk|U2C-x2uHoH5HYG{qv66pu(4fr*alfkJ&eOJL8b$EgSWd_eHy z0bStd(*ugHkOZ|*ITItxF(*cfr;@m8U5v>@2!X+sM!_@UD0>MAZg^r4@IHArFz4*oQp0S4UqD<}PxE%<76Wo(H>WahbAonoTvhzvEM=UC!d z78TF_%BmKt-b&)2n{OrY&>{F~SoPxmtE=)?wo-*{n)|I5Ke^FcEpBxX{FRF!;4lG> zfC@|SS6-cn%2j{mp}+D2P=r`0h=ZPRg4d|x@{~0Q88S06B0`ch9byU?hDBKvh^6cS z5+H$j9>f@g2qAz0Gsr1iQc~TvtRiN^ht7*=(s#8c^a%1`pz;(SCGblw8_=c`pOa!lu+ld zP@d+nqOj4c%>*;TwK|&sV8UfR7&;q5Vxc1oM|5J8jLr60TER4R7WwXvTUE}F;X~v} z^i|-G2(-#H!deX#FZY3|P||=F(ogm1fmP$vV*Q}{uwo}Y7rubW$PVs=sD0s_wUm0G z8(Xj>q1`)7I>AyPEd~+BEshz4f`#Se2?=g4o$UyJO;-&!F5to5VQv3QEs~Ou_86jm zndm_HV)Lqq<3kU%mk>qWL=@1859p0xjV099_!lVph%c3l-3AGY5RB_gZ;=MDV2Qc= zQ+`Q=^};l{lUGNepw&!ss4W=FO5B0(K4K;Pdh^65>z*(McY%#_lt&^;K@GI!=~h9e?>%KN zMi~kH&k|+cuqy2t+ci=<0H`2W7 z7lmxL93Hq|(3Sm$VVdA!Qt0<>uXcxp<9M+80N*&&+Fg<8({yu0e^& zvIUKYVS!6iwknCRx+EF~W6xTML;I$=y65?JoL%k+#Ynn1o)Ym4&*2zjW2kHQqJZGM z7y+Dxg{*rcj3$}TWG26z6tjr+@InW15pbQ%c z(GehEX?v`ETacfb{H&p88J721`wRq*E_N4vtxf`1?(t=_W;l#gN8j2{clrz$!p|0# zZ^eRuN<1>y4g?zmQvQhk$Z5FzQZPxd5N{uCEYxr|pM`J%gt6fn#RIVuz-8o8AtbN7fW3#2>Da8iVe1u|dN$dUHeB{V}J(_zvr9 zA1cDm)i!i%kJt3e&~mI9;MHf0sGzxE1IadqkU%`9U)r$*|5_TVK8}N2m7i3(nFHja zs-<<9%Lx}l!=N6Zj3yerI@gStvo>q`v%-$l#=M<56zK;vSQofdY!Xkf%?Fbq`VR!z z6JBWt?{0|$pp=i3?h;MIf2}qGR@ukiC;}*@>OER9(aF;sPxB^AQCdtRlFh!RCb|T) zc+#MRL$Z0KuW6_Gmhr*#-=zl-!%J5Ca>F~Mz>kZ__fzK)B_G2Cd*-Epq*Ft02^ z`-cl)Kp_)t@X*t>_>Nok{Ap}W*sV3+9VePZThWQ#8d)59U_(z_}N0M3^UdEMG&Uyrr24wG+6uzC~^GP!qxUBH!<2#Hq=NBCqpaD zGpcGSV^DRUd9>-PR?^zwcZLs3k3*JxqA(Da_p1@#Kt<-P rX+`&H2&il$q(my+pU0SPGeT*W3y{@|u+`-C!fkC*9STqmoswz;zGfw* literal 0 HcmV?d00001 From c0443e66fe7aa1686601030b4f3b3d5e31cc8e9c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 20:05:33 -0600 Subject: [PATCH 320/388] Give the optional-dependency test lane a post-merge CI home and drop the stale zero-test pecos-rslib line from pytest-dep --- .github/workflows/python-test.yml | 6 ++++++ Justfile | 3 +-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index af0aaa287..955b29e07 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -513,6 +513,9 @@ jobs: fi fi + # Post-merge lanes excluded from the PR fast path: the slow integration + # tests and the optional-dependency suite (guppy/selene/QIR runtime tests + # marked `optional_dependency`). python-slow-postmerge: if: github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) runs-on: ubuntu-latest @@ -594,6 +597,9 @@ jobs: - name: Build core Python packages run: just python-ci-build + - name: Run optional-dependency Python tests + run: just pytest-dep + - name: Run slow Python tests run: just pytest-slow diff --git a/Justfile b/Justfile index 4246f5214..cae5c997e 100644 --- a/Justfile +++ b/Justfile @@ -745,10 +745,9 @@ go-lint profile="release": (validate-profile "go-lint" profile) (go-build profil pytest-perf: build-release uv run --frozen --group numpy-compat pytest python/pecos-rslib/tests -m "performance" -v -# Run tests for optional dependencies +# Run tests for optional dependencies (only quantum-pecos carries the marker) [group('test')] pytest-dep: - uv run --frozen pytest python/pecos-rslib/tests -m "optional_dependency" uv run --frozen pytest python/quantum-pecos/tests -m "optional_dependency" # Run the slower integration lane (excluded from the default fast lane) From 8783c8324f9a4b058f237fa0c683c7205240eb39 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 20:17:51 -0600 Subject: [PATCH 321/388] Make result capture and tket.bool handlers defer on missing or non-bool inputs instead of fabricating values or silently dropping results --- .../src/engine/handlers/classical.rs | 78 +++++++++++++------ .../pecos-hugr/src/engine/handlers/result.rs | 61 ++++++++++++--- 2 files changed, 105 insertions(+), 34 deletions(-) diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 145e6a536..5c1512f4b 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -458,15 +458,22 @@ impl HugrEngine { debug!("Processing tket.bool operation: {op_name} at {node:?}"); match op_name { + // Binary/unary bool ops defer on missing or non-bool inputs + // instead of fabricating `false`: a fabricated operand commits a + // wrong branch value downstream (silent misexecution). "and" => { let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.bool.and at {node:?}: deferring - input not ready"); + self.pending_bool_reads.insert(node); + return false; + }; + self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a && b)); @@ -476,12 +483,16 @@ impl HugrEngine { "or" => { let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.bool.or at {node:?}: deferring - input not ready"); + self.pending_bool_reads.insert(node); + return false; + }; + self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a || b)); @@ -491,12 +502,16 @@ impl HugrEngine { "xor" => { let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.bool.xor at {node:?}: deferring - input not ready"); + self.pending_bool_reads.insert(node); + return false; + }; + self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a ^ b)); @@ -504,10 +519,15 @@ impl HugrEngine { true } "not" => { - let a = self + let Some(a) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_bool()) - .unwrap_or(false); + else { + debug!("tket.bool.not at {node:?}: deferring - input not ready"); + self.pending_bool_reads.insert(node); + return false; + }; + self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(!a)); @@ -517,12 +537,16 @@ impl HugrEngine { "eq" => { let a = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); let b = self .get_input_value(hugr, node, 1) - .and_then(|v| v.as_bool()) - .unwrap_or(false); + .and_then(|v| v.as_bool()); + let (Some(a), Some(b)) = (a, b) else { + debug!("tket.bool.eq at {node:?}: deferring - input not ready"); + self.pending_bool_reads.insert(node); + return false; + }; + self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a == b)); @@ -543,10 +567,16 @@ impl HugrEngine { return false; }; + // A present but non-bool value is the same hazard as a + // missing one: fabricating `false` commits a wrong value. + let Some(value) = input_val.as_bool() else { + debug!("tket.bool.make_opaque at {node:?}: deferring - input not a bool"); + self.pending_bool_reads.insert(node); + return false; + }; + // Successfully resolved - remove from pending if it was there self.pending_bool_reads.remove(&node); - - let value = input_val.as_bool().unwrap_or(false); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(value)); @@ -569,10 +599,14 @@ impl HugrEngine { return false; }; + let Some(value) = input_val.as_bool() else { + debug!("tket.bool.read at {node:?}: deferring - input not a bool"); + self.pending_bool_reads.insert(node); + return false; + }; + // Successfully resolved - remove from pending if it was there self.pending_bool_reads.remove(&node); - - let value = input_val.as_bool().unwrap_or(false); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(value)); diff --git a/crates/pecos-hugr/src/engine/handlers/result.rs b/crates/pecos-hugr/src/engine/handlers/result.rs index d5e76704b..a212a31d1 100644 --- a/crates/pecos-hugr/src/engine/handlers/result.rs +++ b/crates/pecos-hugr/src/engine/handlers/result.rs @@ -64,8 +64,13 @@ impl HugrEngine { value: ResultValue::Int(i), }); debug!("Captured result_int: {i}"); + true + } else { + // Marking the node processed without capturing would + // silently drop the result; defer until the value arrives. + debug!("result_int at {node:?}: deferring - input not ready"); + false } - true } "result_uint" => { if let Some(value) = self.get_input_value(hugr, node, 0) @@ -76,8 +81,11 @@ impl HugrEngine { value: ResultValue::UInt(u), }); debug!("Captured result_uint: {u}"); + true + } else { + debug!("result_uint at {node:?}: deferring - input not ready"); + false } - true } "result_f64" => { if let Some(value) = self.get_input_value(hugr, node, 0) @@ -88,57 +96,86 @@ impl HugrEngine { value: ResultValue::Float(f), }); debug!("Captured result_f64: {f}"); + true + } else { + debug!("result_f64 at {node:?}: deferring - input not ready"); + false } - true } "result_array_bool" => { + // Elements are converted with map (not filter_map): a single + // unconvertible element defers the whole capture instead of + // silently shortening the array. if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() + && let Some(bools) = arr + .iter() + .map(ClassicalValue::as_bool) + .collect::>>() { - let bools: Vec = arr.iter().filter_map(ClassicalValue::as_bool).collect(); self.captured_results.push(CapturedResult { label, value: ResultValue::ArrayBool(bools), }); + true + } else { + debug!("result_array_bool at {node:?}: deferring - input not ready"); + false } - true } "result_array_int" => { if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() + && let Some(ints) = arr + .iter() + .map(ClassicalValue::as_int) + .collect::>>() { - let ints: Vec = arr.iter().filter_map(ClassicalValue::as_int).collect(); self.captured_results.push(CapturedResult { label, value: ResultValue::ArrayInt(ints), }); + true + } else { + debug!("result_array_int at {node:?}: deferring - input not ready"); + false } - true } "result_array_uint" => { if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() + && let Some(uints) = arr + .iter() + .map(ClassicalValue::as_uint) + .collect::>>() { - let uints: Vec = arr.iter().filter_map(ClassicalValue::as_uint).collect(); self.captured_results.push(CapturedResult { label, value: ResultValue::ArrayUInt(uints), }); + true + } else { + debug!("result_array_uint at {node:?}: deferring - input not ready"); + false } - true } "result_array_f64" => { if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() + && let Some(floats) = arr + .iter() + .map(ClassicalValue::as_float) + .collect::>>() { - let floats: Vec = - arr.iter().filter_map(ClassicalValue::as_float).collect(); self.captured_results.push(CapturedResult { label, value: ResultValue::ArrayFloat(floats), }); + true + } else { + debug!("result_array_f64 at {node:?}: deferring - input not ready"); + false } - true } _ => { debug!("Unknown tket.result operation: {op_name}"); From 1ff07aaff6fc56dee360ebdb9f38b993e3515262 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 20:41:00 -0600 Subject: [PATCH 322/388] Fix the loop-iteration freeze: clear processed flags for all op categories before any readiness check during CFG block re-activation, so Calls re-fire with fresh arguments instead of re-running iteration 0 forever --- Cargo.lock | 1 - crates/pecos-hugr/Cargo.toml | 1 - crates/pecos-hugr/src/engine.rs | 63 +++++++++++++++++++ .../pecos-hugr/src/engine/control_flow/cfg.rs | 49 +++++++++------ .../tests/guppy/test_missing_coverage.py | 14 +++-- 5 files changed, 100 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 979fe6f41..dc41aca3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4422,7 +4422,6 @@ name = "pecos-hugr" version = "0.2.0-dev.0" dependencies = [ "anyhow", - "env_logger", "log", "pecos-core", "pecos-engines", diff --git a/crates/pecos-hugr/Cargo.toml b/crates/pecos-hugr/Cargo.toml index aabc50ba4..2b509302c 100644 --- a/crates/pecos-hugr/Cargo.toml +++ b/crates/pecos-hugr/Cargo.toml @@ -37,7 +37,6 @@ pecos-quantum = { workspace = true, features = ["hugr"] } pecos-wasm = { workspace = true, optional = true } [dev-dependencies] -env_logger.workspace = true tempfile.workspace = true # For creating test HUGRs from DagCircuit pecos-quantum = { workspace = true, features = ["hugr"] } diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index c31839ab3..92e52f4d6 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -2046,6 +2046,69 @@ mod tests { ); } + #[test] + fn test_forloop_executes_each_iteration_and_terminates() { + // Regression guard for the loop-iteration freeze: guppy's + // `for _ in range(3)` lowers to a CFG cycle whose body block calls + // the iterator's __next__ each pass. Block re-activation must clear + // processed flags for ALL op categories BEFORE any readiness check; + // interleaving them let the Call fire against the previous + // iteration's flags, re-propagating stale arguments so the loop + // re-ran iteration 0 forever (H emitted per wave, no termination). + use pecos_engines::{ByteMessageBuilder, ControlEngine, EngineStage}; + + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/forloop_h_test.hugr" + ); + let mut engine = HugrEngine::from_file(path).expect("Failed to load HUGR"); + let mut stage = engine.start(()).expect("Failed to start engine"); + let mut gate_counts: BTreeMap = BTreeMap::new(); + let mut rounds = 0; + loop { + rounds += 1; + assert!( + rounds <= 10, + "forloop should terminate in a few rounds; gate_counts={gate_counts:?}" + ); + match stage { + EngineStage::NeedsProcessing(msg) => { + let ops = msg.quantum_ops().expect("parse quantum ops"); + for g in &ops { + *gate_counts.entry(g.gate_type).or_insert(0) += 1; + } + let n_meas = ops + .iter() + .filter(|g| { + matches!( + g.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) + }) + .count(); + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_outcomes(); + builder.add_outcomes(&vec![0usize; n_meas]); + stage = engine + .continue_processing(builder.build()) + .expect("continue"); + } + EngineStage::Complete(_) => break, + } + } + + // range(3): exactly three H applications, then the final measure. + assert_eq!(gate_counts.get(&GateType::H), Some(&3), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::MZ), Some(&1), "{gate_counts:?}"); + + // Clean completion: no stalled control flow, no starved nodes. + assert!(engine.active_cfgs.is_empty()); + assert!(engine.active_cases.is_empty()); + assert!(engine.active_calls.is_empty()); + assert!(engine.active_tailloops.is_empty()); + assert!(engine.pending_bool_reads.is_empty()); + } + #[test] fn test_ry_angle_through_tuple_wrap() { // Guppy lowers `ry(q, angle(0.5))` with the angle constant wrapped in diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 1d9e9e30a..e3fb0b6ee 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -487,10 +487,37 @@ impl HugrEngine { } } - // Clear processed state for quantum ops first so they can be re-executed in loops - for &op_node in &block_info.quantum_ops { + // PHASE 1: clear processed state for EVERY op category in the + // block before ANY readiness check or queueing below. Readiness + // (all_predecessors_ready) consults `processed`; interleaving + // clear-and-queue per category let a Call pass its readiness + // check against the PREVIOUS iteration's flags of a + // not-yet-cleared producer, fire before that producer re-ran, + // and re-propagate stale (or cleared) argument values -- the + // loop then re-executed the same iteration forever. + let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); + for &op_node in block_info + .quantum_ops + .iter() + .chain(&block_info.call_nodes) + .chain(&block_info.conditional_nodes) + .chain(&block_info.bool_ops) + .chain(&block_info.tailloop_nodes) + .chain(&extension_ops) + { self.processed.remove(&op_node); } + for child in hugr.children(to_block) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) + || self.classical_ops.contains_key(&child) + { + self.processed.remove(&child); + } + } + + // PHASE 2: queue ops whose predecessors are ready. Ops that are + // not ready yet (their producers were just reset) are queued + // later by queue_ready_successors when the producer completes. for &op_node in &block_info.quantum_ops { self.nodes_inside_cfg_blocks.remove(&op_node); // Skip ops inside TailLoops - they'll be added when the loop expands @@ -502,11 +529,7 @@ impl HugrEngine { } } // Also activate Call nodes in this block - // Clear processed state first so they can be re-executed in loops // (each loop iteration must call the function again) - for &call_node in &block_info.call_nodes { - self.processed.remove(&call_node); - } for &call_node in &block_info.call_nodes { self.nodes_inside_cfg_blocks.remove(&call_node); // Skip Call nodes inside TailLoops @@ -529,10 +552,6 @@ impl HugrEngine { } // Also activate Conditional nodes in this block - // Clear processed state first so they can be re-executed in loops - for &cond_node in &block_info.conditional_nodes { - self.processed.remove(&cond_node); - } for &cond_node in &block_info.conditional_nodes { self.nodes_inside_cfg_blocks.remove(&cond_node); // Skip Conditional nodes inside TailLoops @@ -546,10 +565,7 @@ impl HugrEngine { // Also activate other extension ops in this block (like tket.result) // IMPORTANT: Process extension/classical ops FIRST so their results are available for bool_ops - // Find all extension ops that are children of this block - let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); for &op_node in &extension_ops { - self.processed.remove(&op_node); self.nodes_inside_cfg_blocks.remove(&op_node); // Skip extension ops inside TailLoops if self.nodes_inside_tailloops.contains(&op_node) { @@ -564,7 +580,6 @@ impl HugrEngine { for child in hugr.children(to_block) { let op = hugr.get_optype(child); if matches!(op, OpType::LoadConstant(_)) { - self.processed.remove(&child); self.nodes_inside_cfg_blocks.remove(&child); // Skip nodes inside TailLoops if self.nodes_inside_tailloops.contains(&child) { @@ -576,7 +591,6 @@ impl HugrEngine { } // Check for classical ops if self.classical_ops.contains_key(&child) { - self.processed.remove(&child); self.nodes_inside_cfg_blocks.remove(&child); // Skip nodes inside TailLoops if self.nodes_inside_tailloops.contains(&child) { @@ -599,10 +613,6 @@ impl HugrEngine { } // Now activate bool ops in this block - // Clear processed state first so they can be re-executed in loops - for &op_node in &block_info.bool_ops { - self.processed.remove(&op_node); - } for &op_node in &block_info.bool_ops { self.nodes_inside_cfg_blocks.remove(&op_node); // Skip bool ops inside TailLoops @@ -616,7 +626,6 @@ impl HugrEngine { // Also activate TailLoop nodes in this block for &tl_node in &block_info.tailloop_nodes { - self.processed.remove(&tl_node); self.nodes_inside_cfg_blocks.remove(&tl_node); if !self.work_queue.contains(&tl_node) && !self.processed.contains(&tl_node) { self.work_queue.push_back(tl_node); diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index d9112247a..356645297 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -219,9 +219,10 @@ def measure_multiple_test() -> tuple[bool, bool, bool, bool, bool]: # b0 and b2 are probabilistic (from H gates) @pytest.mark.xfail( - reason="engine returns no measurements for array+loop guppy programs (silent " - "result truncation, pre-existing); the test passed vacuously until result " - "presence was asserted -- see the deferred-node/stalled-control-flow follow-up", + reason="engine returns no measurements for guppy programs indexing arrays " + "(collections.borrow_arr ops are unsupported, so the program stalls before " + "the final measure); the loop-iteration freeze itself is fixed and covered " + "by test_forloop_executes_each_iteration_and_terminates in pecos-hugr", strict=True, ) def test_discard_array(self) -> None: @@ -251,9 +252,10 @@ def discard_array_test() -> bool: assert all(r == 1 for r in get_measurements(results)), "Final qubit should be |1⟩" @pytest.mark.xfail( - reason="engine returns no measurements for array+loop guppy programs (silent " - "result truncation, pre-existing); the test passed vacuously until result " - "presence was asserted -- see the deferred-node/stalled-control-flow follow-up", + reason="engine returns no measurements for guppy programs indexing arrays " + "(collections.borrow_arr ops are unsupported, so the program stalls before " + "the final measure); the loop-iteration freeze itself is fixed and covered " + "by test_forloop_executes_each_iteration_and_terminates in pecos-hugr", strict=True, ) def test_array_indexing_and_loops(self) -> None: From a16201bd797058eaebd0ba999052de8b4aa6c29c Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 21:08:31 -0600 Subject: [PATCH 323/388] Route empty-block CFG transitions through the canonical two-phase transition path, deleting the hand-rolled activation copy that skipped stale-value clearing and checked Call readiness against uncleared producer flags --- .../pecos-hugr/src/engine/control_flow/cfg.rs | 133 ++---------------- 1 file changed, 8 insertions(+), 125 deletions(-) diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index e3fb0b6ee..356b44d29 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -670,138 +670,21 @@ impl HugrEngine { // No successors - exit block self.complete_cfg_execution(hugr, cfg_node, to_block); } else if successors.len() == 1 { - // Single successor - transition immediately + // Single successor - transition immediately. Recurse into + // the canonical transition path (same as the resolved + // multi-successor case below) so the successor gets the + // full two-phase activation: a hand-rolled copy here used + // to skip the stale-value clearing and check Call + // readiness against uncleared producer flags, reviving + // the loop-iteration freeze through empty blocks. let next_block = successors[0]; - // Check if successor is exit block if next_block == cfg_info.exit_block { self.complete_cfg_execution(hugr, cfg_node, to_block); } else { debug!( "[TRACE] Empty block {to_block:?} transitioning to single successor {next_block:?}" ); - self.propagate_block_outputs_to_successor(hugr, to_block, next_block); - - // Update current block - if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { - active_cfg.current_block = next_block; - } - - // Recursively activate the next block - add all ops to work queue - let next_block_info = cfg_info.blocks.get(&next_block).cloned(); - if let Some(next_info) = next_block_info { - // Quantum ops - for &op_node in &next_info.quantum_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - if !self.work_queue.contains(&op_node) - && !self.processed.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - // Bool ops - for &op_node in &next_info.bool_ops { - self.processed.remove(&op_node); - self.nodes_inside_cfg_blocks.remove(&op_node); - if !self.work_queue.contains(&op_node) - && !self.processed.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - // Conditional nodes - for &cond_node in &next_info.conditional_nodes { - self.processed.remove(&cond_node); - self.nodes_inside_cfg_blocks.remove(&cond_node); - if !self.work_queue.contains(&cond_node) - && !self.processed.contains(&cond_node) - { - self.work_queue.push_back(cond_node); - } - } - // TailLoop nodes - for &tl_node in &next_info.tailloop_nodes { - self.processed.remove(&tl_node); - self.nodes_inside_cfg_blocks.remove(&tl_node); - if !self.work_queue.contains(&tl_node) - && !self.processed.contains(&tl_node) - { - self.work_queue.push_back(tl_node); - } - } - // Call nodes - for &call_node in &next_info.call_nodes { - self.processed.remove(&call_node); - self.nodes_inside_cfg_blocks.remove(&call_node); - if !self.work_queue.contains(&call_node) - && !self.processed.contains(&call_node) - && all_predecessors_ready( - hugr, - call_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(call_node); - } - } - // Also find and add classical ops and extension ops - for child in hugr.children(next_block) { - let op = hugr.get_optype(child); - if matches!(op, OpType::LoadConstant(_)) - || self.classical_ops.contains_key(&child) - || op.as_extension_op().is_some() - { - self.processed.remove(&child); - self.nodes_inside_cfg_blocks.remove(&child); - if !self.work_queue.contains(&child) - && !self.processed.contains(&child) - { - self.work_queue.push_back(child); - } - } - } - debug!( - "[TRACE] Activated next block {:?} with {} quantum ops, {} bool_ops", - next_block, - next_info.quantum_ops.len(), - next_info.bool_ops.len() - ); - - // Check if the next block is also empty - if so, we need to handle it recursively - // Find extension ops in this block - let next_extension_ops: Vec = - find_extension_ops_in_block(hugr, next_block); - let next_has_extension_ops = !next_extension_ops.is_empty(); - let next_has_classical_ops = !next_info.classical_ops.is_empty(); - - if next_info.quantum_ops.is_empty() - && next_info.call_nodes.is_empty() - && next_info.conditional_nodes.is_empty() - && next_info.bool_ops.is_empty() - && !next_has_extension_ops - && !next_has_classical_ops - && next_info.tailloop_nodes.is_empty() - { - // Next block is also empty - need to continue transitioning - let next_successors = next_info.successors.clone(); - if next_successors.len() == 1 { - let next_next_block = next_successors[0]; - if next_next_block == cfg_info.exit_block { - self.complete_cfg_execution(hugr, cfg_node, next_block); - } else { - // Recursively transition - self.transition_to_cfg_successor( - hugr, - cfg_node, - next_block, - next_next_block, - ); - } - } - } - } + self.transition_to_cfg_successor(hugr, cfg_node, to_block, next_block); } } else { // Multiple successors - need to resolve branch From 39388b057a3bc08efd992aaed4d25771993fe242 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 21:12:36 -0600 Subject: [PATCH 324/388] TailLoop parity with the CFG re-activation fix: queue body LoadConstants on expansion and re-entry, and clear stale body wire values after continue-value propagation --- .../src/engine/control_flow/tailloop.rs | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 2d9649e7a..fdc34658b 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -226,6 +226,17 @@ impl HugrEngine { entry_nodes.push(cond_node); } + // Also activate body LoadConstants: they are gated behind + // nodes_inside_tailloops (no other path queues them) while + // readiness gates their consumers on the processed flag, so + // skipping them starves the body's classical dataflow. + for child in hugr.children(tailloop_node) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + self.nodes_inside_tailloops.remove(&child); + entry_nodes.push(child); + } + } + debug!( "TailLoop {tailloop_node:?}: activated body with {} entry nodes", entry_nodes.len() @@ -306,10 +317,35 @@ impl HugrEngine { for &cond_node in &tailloop_info.conditional_nodes { self.processed.remove(&cond_node); } + // Body LoadConstants must re-run too: readiness gates consumers on + // their processed flag, and no other path queues them once the body + // is gated behind nodes_inside_tailloops. + for child in hugr.children(tailloop_node) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + self.processed.remove(&child); + } + } - // Propagate iteration values from Output to Input + // Propagate iteration values from Output to Input. This reads the + // PREVIOUS iteration's body-output wires, so it must happen before + // the stale-value clearing below. self.propagate_continue_values(hugr, tailloop_node, &tailloop_info); + // Clear stale classical values for body nodes (mirrors CFG block + // re-activation): without this, re-executed nodes read the previous + // iteration's values and e.g. a control read selects a stale branch. + // The Input node is skipped -- it holds the fresh continue values. + let input_node = tailloop_info.input_node; + for child in hugr.children(tailloop_node) { + if child == input_node { + continue; + } + let num_outputs = hugr.num_outputs(child); + for port_idx in 0..num_outputs { + self.wire_state.classical_values.remove(&(child, port_idx)); + } + } + // Update iteration counter if let Some(active_info) = self.active_tailloops.get_mut(&tailloop_node) { active_info.iteration = new_iteration; @@ -317,6 +353,14 @@ impl HugrEngine { } // Re-activate body operations + for child in hugr.children(tailloop_node) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) + && !self.work_queue.contains(&child) + && !self.processed.contains(&child) + { + self.work_queue.push_back(child); + } + } for &op_node in &tailloop_info.quantum_ops { if all_predecessors_ready( hugr, From c0ee08de28bc3566504f41da6bd3c40e8c14b8bb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 21:18:59 -0600 Subject: [PATCH 325/388] Land completion-time stall detection: queue drain with active control flow or starved deferred nodes now returns a descriptive error instead of silently truncated results, and the conditional_x test pins the fail-loud contract for the unsupported nested-TailLoop shape it exercises --- crates/pecos-hugr/src/engine.rs | 180 ++++++++++++++++++++++---------- 1 file changed, 126 insertions(+), 54 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 92e52f4d6..ff0d0677b 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1255,18 +1255,12 @@ impl HugrEngine { } } - // NOTE: a node that stays deferred forever inside EXECUTED control - // flow can stall its container and silently truncate downstream - // effects. Detecting that here (queue drained, no measurement pause) - // requires the active_cases/active_cfgs/active_calls/active_tailloops - // bookkeeping to be reliably empty after healthy completion, which it - // currently is not (several passing fixtures finish with leftover - // active entries). Completion-time stall detection is deferred until - // those lifecycle invariants are fixed; the rotation-angle consumers - // fail loud regardless (resolve_rotation_angle), and the Python - // guppy tests assert result presence and shot counts. - if operation_count == 0 { + // Queue drained with no measurement pause: this is the engine's + // completion claim. Any still-active control flow or starved + // deferred node at this point means execution stalled mid-program + // -- fail loud instead of returning silently truncated results. + self.ensure_no_stalled_execution()?; debug!("No operations processed"); return Ok(None); } @@ -1278,6 +1272,78 @@ impl HugrEngine { // === Helper Methods for process_hugr_impl === + /// Error if the work queue drained while control flow is still active or + /// deferred nodes are still starved. + /// + /// Called at the point where the engine is about to claim completion + /// (queue empty, no measurement pause, nothing emitted). Healthy programs + /// finish with every container completed and no pending reads; anything + /// left over here is a stall that would otherwise surface only as + /// silently missing results. + fn ensure_no_stalled_execution(&self) -> Result<(), PecosError> { + let mut stalled: Vec = Vec::new(); + if !self.active_cfgs.is_empty() { + stalled.push(format!( + "active CFGs: {:?}", + self.active_cfgs.keys().collect::>() + )); + } + if !self.active_cases.is_empty() { + stalled.push(format!( + "active Conditional cases: {:?}", + self.active_cases.keys().collect::>() + )); + } + if !self.active_calls.is_empty() { + stalled.push(format!( + "active Calls: {:?}", + self.active_calls.keys().collect::>() + )); + } + if !self.active_tailloops.is_empty() { + stalled.push(format!( + "active TailLoops: {:?}", + self.active_tailloops.keys().collect::>() + )); + } + if !self.pending_bool_reads.is_empty() { + stalled.push(format!( + "starved deferred nodes: {:?}", + self.pending_bool_reads + )); + } + if !self.pending_conditionals.is_empty() { + stalled.push(format!( + "unresolved Conditionals: {:?}", + self.pending_conditionals.keys().collect::>() + )); + } + if !self.pending_cfg_branches.is_empty() { + stalled.push(format!( + "unresolved CFG branches: {:?}", + self.pending_cfg_branches.keys().collect::>() + )); + } + if !self.pending_func_calls.is_empty() { + stalled.push(format!( + "parked function calls: {:?}", + self.pending_func_calls + .values() + .flatten() + .collect::>() + )); + } + if stalled.is_empty() { + Ok(()) + } else { + Err(PecosError::Generic(format!( + "HUGR execution stalled before completion; results would be silently \ + truncated ({})", + stalled.join("; ") + ))) + } + } + /// Emit a quantum gate operation to the message builder. /// /// This handles all gate types and their decompositions. @@ -2920,55 +2986,61 @@ mod tests { let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); - // Start execution - let stage = engine.start(()).expect("Failed to start engine"); - - match stage { - pecos_engines::EngineStage::NeedsProcessing(msg) => { - println!("Stage 1: NeedsProcessing"); - if let Ok(ops) = msg.quantum_ops() { - println!( - " Operations: {:?}", - ops.iter().map(|o| o.gate_type).collect::>() - ); - } - - // Simulate measurement result (0 = else branch, 1 = if branch) - // Create a mock measurement result - let mut builder = ByteMessageBuilder::new(); - let _ = builder.for_outcomes(); - builder.add_outcomes(&[0]); // Measure 0, take else branch - let measurement_msg = builder.build(); - - // Continue processing with the measurement result - let stage2 = engine - .continue_processing(measurement_msg) - .expect("Failed to continue"); - - match stage2 { - pecos_engines::EngineStage::NeedsProcessing(msg2) => { - println!("Stage 2: NeedsProcessing (more ops after conditional)"); - if let Ok(ops) = msg2.quantum_ops() { - println!( - " Operations: {:?}", - ops.iter().map(|o| o.gate_type).collect::>() + // This fixture nests TailLoops inside CFG blocks (repeat-until- + // success shape); executing that combination is not supported yet + // (case completion does not track nested TailLoops), so the program + // stalls mid-flight. The engine's completion-time stall detection + // must report that loudly instead of returning silently truncated + // results -- the historical version of this test fed one outcome, + // asserted nothing, and "passed" on the truncation. Flip this to a + // drive-to-completion test when nested-TailLoop execution lands. + let mut stage = engine.start(()).expect("Failed to start engine"); + let mut rounds = 0; + loop { + rounds += 1; + assert!( + rounds <= 20, + "conditional_x should stall or complete quickly" + ); + match stage { + pecos_engines::EngineStage::NeedsProcessing(msg) => { + let ops = msg.quantum_ops().expect("parse quantum ops"); + let n_meas = ops + .iter() + .filter(|g| { + matches!( + g.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) + }) + .count(); + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_outcomes(); + builder.add_outcomes(&vec![0usize; n_meas]); + match engine.continue_processing(builder.build()) { + Ok(next) => stage = next, + Err(e) => { + let msg = e.to_string(); + assert!( + msg.contains("stalled before completion"), + "expected a stall report, got: {msg}" ); + assert!( + msg.contains("TailLoops"), + "stall report should name the stuck TailLoops: {msg}" + ); + return; } } - pecos_engines::EngineStage::Complete(result) => { - println!("Stage 2: Complete"); - println!(" Result: {result:?}"); - } } - } - pecos_engines::EngineStage::Complete(result) => { - println!("Stage 1: Complete (no quantum ops needed)"); - println!(" Result: {result:?}"); + pecos_engines::EngineStage::Complete(_) => { + panic!( + "conditional_x unexpectedly completed: nested-TailLoop support \ + has landed -- flip this test to assert clean completion" + ); + } } } - - // The test passes if we get here without panicking - // Full correctness requires integration with a quantum simulator } // --- Integration Tests with Quantum Simulator --- From 29b2e96f0376361385b66e17615611830d5b748d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 21:40:45 -0600 Subject: [PATCH 326/388] Track nested Call/TailLoop/Conditional nodes in case completion with the two-phase clear-then-queue discipline, guard case completion on active nested containers, and run the completion hooks on the pending-conditional resolution path --- .../src/engine/control_flow/conditional.rs | 68 +++++++++++++++++-- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index d723ed221..306a96b25 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -131,6 +131,22 @@ impl HugrEngine { } } + // A zero-op case propagates outputs and marks the Conditional + // processed inside expand_conditional with no later completion + // event to observe it -- run the same completion hooks the + // direct expansion path runs, or the enclosing block/loop + // strands with no queued work. + if !self + .active_cases + .values() + .any(|c| c.conditional_node == cond_node) + { + self.check_cfg_block_completion(&hugr, cond_node); + self.check_tailloop_body_completion(&hugr, cond_node); + self.queue_ready_successors(&hugr, cond_node); + self.retry_pending_bool_reads(); + } + debug!( "Resolved pending Conditional {cond_node:?}, branch {branch_index} selected, added {num_entry_nodes} entry nodes" ); @@ -145,11 +161,20 @@ impl HugrEngine { for (case_node, case_info) in &self.active_cases { if case_info.ops_in_case.contains(&processed_node) { - // Check if all ops in this Case are now processed - let all_done = case_info - .ops_in_case - .iter() - .all(|op| self.processed.contains(op)); + // A case is complete only when every tracked op is processed + // AND no tracked nested container is still mid-flight: a + // nested Conditional is marked processed at EXPANSION (its + // outputs exist only once its own case completes), and + // active TailLoops/Calls are still producing values. + let all_done = case_info.ops_in_case.iter().all(|op| { + self.processed.contains(op) + && !self + .active_cases + .values() + .any(|c| c.conditional_node == *op) + && !self.active_tailloops.contains_key(op) + && !self.active_calls.contains_key(op) + }); if all_done { completed_cases.push((*case_node, case_info.conditional_node)); @@ -416,21 +441,41 @@ impl HugrEngine { .children(selected_case) .filter(|&child| matches!(hugr.get_optype(child), OpType::LoadConstant(_))) .collect(); - // Clear processed state first so the case body re-executes when the - // conditional runs again (e.g. once per loop iteration), then queue. + // Nested control-flow containers (Call/TailLoop/Conditional children + // of the case) must be tracked too: a case whose only content is a + // Call or loop would otherwise register with an empty/completed op + // set and propagate outputs before the nested work even starts. + let mut case_calls: Vec = Vec::new(); + let mut case_containers: Vec = Vec::new(); + for child in hugr.children(selected_case) { + match hugr.get_optype(child) { + OpType::Call(_) => case_calls.push(child), + OpType::TailLoop(_) | OpType::Conditional(_) => case_containers.push(child), + _ => {} + } + } + // PHASE 1: clear processed state for everything in the case before + // ANY readiness check, so consumers cannot pass readiness against + // the previous execution's flags of a not-yet-cleared producer + // (same discipline as CFG block re-activation). for &op_node in case_classical .iter() .chain(case_bools.iter()) .chain(case_extension.iter()) .chain(case_load_consts.iter()) + .chain(case_calls.iter()) + .chain(case_containers.iter()) { self.processed.remove(&op_node); } + // PHASE 2: queue ready ops; ops that are not ready are queued later + // by queue_ready_successors when their producers complete. for &op_node in case_classical .iter() .chain(case_bools.iter()) .chain(case_extension.iter()) .chain(case_load_consts.iter()) + .chain(case_calls.iter()) { self.nodes_inside_cases.remove(&op_node); ops_in_case.insert(op_node); @@ -447,6 +492,15 @@ impl HugrEngine { self.work_queue.push_back(op_node); } } + // TailLoops and nested Conditionals defer internally until their + // control resolves, so they queue without a readiness check. + for &op_node in &case_containers { + self.nodes_inside_cases.remove(&op_node); + ops_in_case.insert(op_node); + if !self.work_queue.contains(&op_node) { + self.work_queue.push_back(op_node); + } + } // Register this Case as active so we can propagate outputs when complete if ops_in_case.is_empty() { From 203afe331881eb4bd8ede893ef89ea25bef608ec Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 21:57:21 -0600 Subject: [PATCH 327/388] Implement proper collections.borrow_arr semantics (sized all-borrowed arrays with Borrowed hole tracking, is_borrowed support, defer-on-missing inputs), add iu_to_s/is_to_u arms, and gate first TailLoop expansion on input readiness --- crates/pecos-hugr/src/engine.rs | 18 +- .../src/engine/handlers/arithmetic.rs | 7 + .../src/engine/handlers/borrow_arr.rs | 285 +++++++++--------- crates/pecos-hugr/src/engine/types.rs | 18 +- 4 files changed, 172 insertions(+), 156 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index ff0d0677b..79645eaab 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -953,7 +953,23 @@ impl HugrEngine { self.pending_tailloop_control.insert(current_node); } } else { - // Not active - start first iteration + // Not active - start first iteration, but only once the + // loop's input producers have run: expansion propagates + // the TailLoop's input wires into the body exactly once, + // so expanding early starves the body forever. When a + // producer completes, queue_ready_successors re-queues + // this node. + if !all_predecessors_ready( + &hugr, + current_node, + &self.quantum_ops, + &self.conditionals, + &self.cfgs, + &self.processed, + ) { + debug!("TailLoop {current_node:?}: inputs not ready, deferring expansion"); + continue; + } debug!("TailLoop {current_node:?}: starting first iteration"); let entry_nodes = self.expand_tailloop(&hugr, current_node); for entry_node in entry_nodes { diff --git a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs index 7fbdfe963..ed550c476 100644 --- a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs +++ b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs @@ -228,6 +228,13 @@ impl HugrEngine { au.zip(bu).map(|(x, y)| x.max(y) as i64) } + // Signedness reinterpretation - value-preserving for the in-range + // values guppy emits (usize indices/counters) + #[allow(clippy::match_same_arms)] + "iu_to_s" => a, // as_int already converts stored UInt values + #[allow(clippy::match_same_arms)] + "is_to_u" => a, + // Sign extension / truncation - all no-ops for i64 unified storage #[allow(clippy::match_same_arms)] // Intentionally separate for clarity "iwiden_s" | "widen_s" => a, // Sign-extend (no-op for i64) diff --git a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs index 3a574bd1e..77e1ed6b6 100644 --- a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs +++ b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs @@ -14,18 +14,24 @@ //! Borrow array operations (`collections.borrow_arr`). //! -//! This module handles borrow-checked array operations emitted by Guppy -//! for array element access with ownership tracking. At simulation time, -//! borrow tracking is irrelevant -- we just need array access semantics. +//! This module handles borrow-checked array operations emitted by Guppy for +//! array element access with ownership tracking (`qs[i]`, array +//! comprehensions, `discard_array`). Arrays are represented as +//! [`ClassicalValue::Array`] whose slots hold element values (qubits as +//! [`ClassicalValue::QubitRef`]) or [`ClassicalValue::Borrowed`] holes. //! -//! Operations: -//! - `new_all_borrowed`: Create an empty borrow array -//! - `borrow`: Extract element at index from array -//! - `return`: Put element back into array -//! - `discard_all_borrowed`: Finalize/cleanup (no-op for simulation) +//! Operations (signatures per the HUGR std extension): +//! - `new_all_borrowed`: `[] -> [array]` -- n slots, all holes +//! - `borrow`: `[array, usize] -> [array, elem]` -- take slot, leave hole +//! - `return`: `[array, usize, elem] -> [array]` -- fill hole +//! - `is_borrowed`: `[array, usize] -> [array, bool]` +//! - `discard_all_borrowed`: `[array] -> []` -- consume the array +//! +//! Handlers defer (return false) on missing inputs so consumers never see +//! fabricated arrays or elements; a permanently missing input surfaces via +//! completion-time stall detection. use log::debug; -use tket::hugr::ops::OpTrait; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; @@ -39,171 +45,150 @@ impl HugrEngine { match op_name { "new_all_borrowed" => { - // new_all_borrowed: create an empty borrow array. - // The actual elements get populated via subsequent `return` operations. - // Input port 0 = the original array to borrow from. - // Output port 0 = borrow array (initially clone the input array). + // [] -> [array]: n slots, all borrowed-out (holes). The size + // comes from the op's first BoundedNat type arg. let op = hugr.get_optype(node); - let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); - - if num_inputs > 0 { - // If there's an input array, use it as the borrow array - if let Some(array_val) = self.get_input_value(hugr, node, 0) { - self.wire_state - .classical_values - .insert((node, 0), array_val); - debug!("new_all_borrowed: cloned input array as borrow array"); - } else { - // No input value found; create empty array - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(vec![])); - debug!("new_all_borrowed: created empty borrow array (no input)"); - } - } else { - // No inputs; create empty array - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(vec![])); - debug!("new_all_borrowed: created empty borrow array"); - } - - // Propagate qubit wires if present - for port in 0..num_inputs { - if let Some(qubit) = self.get_input_qubit(hugr, node, port) { - self.wire_state.wire_to_qubit.insert((node, port), qubit); - } - } - + let size = op + .as_extension_op() + .and_then(|ext| { + ext.args().iter().find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => Some(*n), + _ => None, + }) + }) + .unwrap_or(0); + #[allow(clippy::cast_possible_truncation)] // Array sizes fit in usize + let elements = vec![ClassicalValue::Borrowed; size as usize]; + debug!("new_all_borrowed: created {size}-slot all-borrowed array"); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); true } "borrow" => { - // borrow: extract element at index from array. - // Input port 0 = borrow_array, port 1 = int index - // Output port 0 = borrow_array (unchanged for simulation), port 1 = element - let array = self.get_input_value(hugr, node, 0); + // [array, usize] -> [array, elem]: take the element at the + // index, leaving a hole. + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("borrow at {node:?}: array not ready, deferring"); + return false; + }; #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize - let index = self + let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; - - if let Some(ClassicalValue::Array(elements)) = array { - // Output port 0 = the array (unchanged for simulation purposes) - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements.clone())); + .map(|v| v as usize) + else { + debug!("borrow at {node:?}: index not ready, deferring"); + return false; + }; - if let Some(element) = elements.get(index).cloned() { - // Output port 1 = the borrowed element - // If element is a QubitRef, also propagate to wire_to_qubit - if let ClassicalValue::QubitRef(qubit_id) = &element { - self.wire_state.wire_to_qubit.insert((node, 1), *qubit_id); - } - self.wire_state.classical_values.insert((node, 1), element); + let Some(slot) = elements.get_mut(index) else { + debug!( + "borrow at {node:?}: index {index} out of bounds (len={}), deferring", + elements.len() + ); + return false; + }; + let element = std::mem::replace(slot, ClassicalValue::Borrowed); + if matches!(element, ClassicalValue::Borrowed) { + // Guppy guards accesses with is_borrowed + panic, so a + // hole here means an upstream value was wrong; defer so + // stall detection names this node instead of handing a + // fabricated element downstream. + debug!("borrow at {node:?}: slot {index} already borrowed, deferring"); + return false; + } - debug!("borrow[{index}]: extracted element"); - } else { - debug!( - "borrow[{index}]: index out of bounds (len={})", - elements.len() - ); - } - } else { - debug!("borrow: no array found on input port 0"); - // Try pass-through - self.propagate_all_inputs(hugr, node); + if let ClassicalValue::QubitRef(qubit_id) = &element { + self.wire_state.wire_to_qubit.insert((node, 1), *qubit_id); } + self.wire_state.classical_values.insert((node, 1), element); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + debug!("borrow[{index}]: extracted element"); true } "return" => { - // return: put element back into array. - // Input port 0 = borrow_array, port 1 = index (usize), port 2 = element - // Output port 0 = updated array - let array = self.get_input_value(hugr, node, 0); + // [array, usize, elem] -> [array]: fill the hole at the index. + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("return at {node:?}: array not ready, deferring"); + return false; + }; #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize - let index = self + let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .map(|v| v as usize); - let element = self.get_input_value(hugr, node, 2); - - // Also check if the element is a qubit - let element_qubit = self.get_input_qubit(hugr, node, 2); - - // If the element isn't available yet (e.g., waiting for measurement result), - // defer this operation. Return false so the main loop adds us to pending. - if element.is_none() && element_qubit.is_none() { - debug!("return: element not available yet, deferring"); + .map(|v| v as usize) + else { + debug!("return at {node:?}: index not ready, deferring"); return false; - } + }; + // A qubit element arrives on the qubit wire; other elements + // as classical values. + let element = if let Some(qubit_id) = self.get_input_qubit(hugr, node, 2) { + ClassicalValue::QubitRef(qubit_id) + } else if let Some(value) = self.get_input_value(hugr, node, 2) { + value + } else { + debug!("return at {node:?}: element not ready, deferring"); + return false; + }; - if let Some(ClassicalValue::Array(mut elements)) = array { - if let Some(val) = element { - // The element might need to be wrapped as QubitRef - let val = if let Some(qubit_id) = element_qubit { - ClassicalValue::QubitRef(qubit_id) - } else { - val - }; - // Put the element at the specified index - if let Some(idx) = index { - // Extend array if needed - while elements.len() <= idx { - elements.push(ClassicalValue::Bool(false)); - } - elements[idx] = val; - debug!("return[{idx}]: element returned to borrow array"); - } else { - // No index -- append to array - elements.push(val); - debug!("return: element appended to borrow array"); - } - } else if let Some(qubit_id) = element_qubit { - // Element is a qubit (no classical value, just qubit wire) - let val = ClassicalValue::QubitRef(qubit_id); - if let Some(idx) = index { - while elements.len() <= idx { - elements.push(ClassicalValue::Bool(false)); - } - elements[idx] = val; - debug!("return[{idx}]: qubit returned to borrow array"); - } else { - elements.push(val); - debug!("return: qubit appended to borrow array"); - } - } + let Some(slot) = elements.get_mut(index) else { + debug!( + "return at {node:?}: index {index} out of bounds (len={}), deferring", + elements.len() + ); + return false; + }; + *slot = element; + debug!("return[{index}]: element returned to borrow array"); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + true + } + "is_borrowed" => { + // [array, usize] -> [array, bool] + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("is_borrowed at {node:?}: array not ready, deferring"); + return false; + }; + #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize + let Some(index) = self + .get_input_value(hugr, node, 1) + .and_then(|v| v.as_uint()) + .map(|v| v as usize) + else { + debug!("is_borrowed at {node:?}: index not ready, deferring"); + return false; + }; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - } else { - // Array not available - might need to defer - if array.is_none() { - debug!("return: array not available yet, deferring"); - return false; - } - debug!("return: no array found on input port 0, passing through"); - self.propagate_all_inputs(hugr, node); - } + let borrowed = elements + .get(index) + .is_none_or(|slot| matches!(slot, ClassicalValue::Borrowed)); + debug!("is_borrowed[{index}]: {borrowed}"); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Bool(borrowed)); true } "discard_all_borrowed" => { - // discard_all_borrowed: finalize/cleanup. - // Input port 0 = borrow_array, Output port 0 = the original array - // For simulation, we just pass through the array value. - let array = self.get_input_value(hugr, node, 0); - // eprintln!("[BORROW_ARR] discard_all_borrowed at {node:?}: array={array:?}"); - if let Some(arr) = array { - // eprintln!("[BORROW_ARR] discard_all_borrowed: propagating {:?}", arr); - debug!("discard_all_borrowed: propagating array value {arr:?}"); - self.wire_state.classical_values.insert((node, 0), arr); - } else { - // Array not available yet - defer until it's ready - // eprintln!("[BORROW_ARR] discard_all_borrowed: deferring (array not available)"); - debug!("discard_all_borrowed: array not available yet, deferring"); + // [array] -> []: consumes the (all-borrowed) array; nothing + // to produce. Defer until the array value exists so the op + // is not marked done while its producer is still pending. + if self.get_input_value(hugr, node, 0).is_none() { + debug!("discard_all_borrowed at {node:?}: array not ready, deferring"); return false; } + debug!("discard_all_borrowed: array consumed"); true } _ => { diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index 2584c50f4..99c4a9219 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -302,6 +302,9 @@ pub enum ClassicalValue { RngContext(RngContextId), /// Qubit reference (for storing qubits in arrays) QubitRef(QubitId), + /// Borrowed-out slot in a borrow array (a hole left by + /// `collections.borrow_arr.borrow`; filled by `return`) + Borrowed, } impl ClassicalValue { @@ -320,7 +323,8 @@ impl ClassicalValue { | Self::Future(_) | Self::Rotation(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::Borrowed => None, } } @@ -342,7 +346,8 @@ impl ClassicalValue { | Self::Future(_) | Self::Rotation(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::Borrowed => None, } } @@ -363,7 +368,8 @@ impl ClassicalValue { | Self::Future(_) | Self::Rotation(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::Borrowed => None, } } @@ -384,7 +390,8 @@ impl ClassicalValue { | Self::Future(_) | Self::Rotation(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::Borrowed => None, } } @@ -405,7 +412,8 @@ impl ClassicalValue { | Self::Array(_) | Self::Future(_) | Self::RngContext(_) - | Self::QubitRef(_) => None, + | Self::QubitRef(_) + | Self::Borrowed => None, } } From 7e40f37b6c46802024954f680d783508476c76ba Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 22:11:18 -0600 Subject: [PATCH 328/388] Close the second silent-completion path (stall check on the pre-drain empty-queue return, plus pending TailLoop controls in the report) and clear outer container gates for TailLoop body ops at expansion so retried nodes are not permanently starved --- Cargo.lock | 1 + crates/pecos-hugr/Cargo.toml | 1 + crates/pecos-hugr/src/engine.rs | 10 ++++++++++ .../src/engine/control_flow/tailloop.rs | 20 +++++++++++++++++++ 4 files changed, 32 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index dc41aca3f..979fe6f41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4422,6 +4422,7 @@ name = "pecos-hugr" version = "0.2.0-dev.0" dependencies = [ "anyhow", + "env_logger", "log", "pecos-core", "pecos-engines", diff --git a/crates/pecos-hugr/Cargo.toml b/crates/pecos-hugr/Cargo.toml index 2b509302c..aabc50ba4 100644 --- a/crates/pecos-hugr/Cargo.toml +++ b/crates/pecos-hugr/Cargo.toml @@ -37,6 +37,7 @@ pecos-quantum = { workspace = true, features = ["hugr"] } pecos-wasm = { workspace = true, optional = true } [dev-dependencies] +env_logger.workspace = true tempfile.workspace = true # For creating test HUGRs from DagCircuit pecos-quantum = { workspace = true, features = ["hugr"] } diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 79645eaab..800fe41e0 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -624,6 +624,10 @@ impl HugrEngine { } if self.work_queue.is_empty() { + // Same completion claim as the post-drain return below: an + // already-empty queue with active control flow or starved nodes + // is a stall, not a finished program. + self.ensure_no_stalled_execution()?; debug!("Work queue empty, processing complete"); eprintln!("[DEBUG] Work queue empty, processing complete"); return Ok(None); @@ -1340,6 +1344,12 @@ impl HugrEngine { self.pending_cfg_branches.keys().collect::>() )); } + if !self.pending_tailloop_control.is_empty() { + stalled.push(format!( + "unresolved TailLoop controls: {:?}", + self.pending_tailloop_control + )); + } if !self.pending_func_calls.is_empty() { stalled.push(format!( "parked function calls: {:?}", diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index fdc34658b..416ba2ed0 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -130,6 +130,26 @@ impl HugrEngine { debug!("Expanding TailLoop {tailloop_node:?} for iteration 0"); + // Body nodes may carry OUTER container gates too (a TailLoop inside + // a CFG block leaves its internals in nodes_inside_cfg_blocks; the + // block's activation never enumerates them). Clear every gate for + // the tracked body ops: entry pushes below bypass the gates, but the + // retry path (queue_ready_successors) checks all of them, so a + // still-gated node whose inputs become ready later is never queued + // and starves the loop. + for &op_node in tailloop_info + .quantum_ops + .iter() + .chain(&tailloop_info.call_nodes) + .chain(&tailloop_info.extension_ops) + .chain(&tailloop_info.classical_ops) + .chain(&tailloop_info.bool_ops) + .chain(&tailloop_info.conditional_nodes) + { + self.nodes_inside_cfg_blocks.remove(&op_node); + self.nodes_inside_cases.remove(&op_node); + } + // Propagate input wires from TailLoop inputs to body Input node outputs self.propagate_tailloop_inputs(hugr, tailloop_node, &tailloop_info, 0); From f1774af86005190c2916de6c75048a9dde8bb4f4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 22:25:20 -0600 Subject: [PATCH 329/388] Clear all container gates for case ops at conditional expansion so retried case-internal nodes inside loops are reachable by queue_ready_successors --- .../src/engine/control_flow/conditional.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index 306a96b25..64f2605e7 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -417,6 +417,14 @@ impl HugrEngine { ops_in_case.insert(child); } } + // Quantum ops queued as entries bypass the container gates, but any + // re-queue goes through queue_ready_successors which checks all of + // them -- clear the gates so retried case ops are reachable. + for &node in &ops_in_case { + self.nodes_inside_cases.remove(&node); + self.nodes_inside_cfg_blocks.remove(&node); + self.nodes_inside_tailloops.remove(&node); + } // The Case's classical, bool, and extension ops must also execute // before its outputs propagate (propagating early copies missing @@ -477,7 +485,14 @@ impl HugrEngine { .chain(case_load_consts.iter()) .chain(case_calls.iter()) { + // Clear ALL container gates, not just the case gate: a case + // nested in a TailLoop nested in a CFG block leaves its ops in + // the outer gate sets too, and the retry path + // (queue_ready_successors) checks every gate -- an op that is + // not ready at expansion would otherwise never be queued. self.nodes_inside_cases.remove(&op_node); + self.nodes_inside_cfg_blocks.remove(&op_node); + self.nodes_inside_tailloops.remove(&op_node); ops_in_case.insert(op_node); if !self.work_queue.contains(&op_node) && crate::engine::analysis::all_predecessors_ready( @@ -496,6 +511,8 @@ impl HugrEngine { // control resolves, so they queue without a readiness check. for &op_node in &case_containers { self.nodes_inside_cases.remove(&op_node); + self.nodes_inside_cfg_blocks.remove(&op_node); + self.nodes_inside_tailloops.remove(&op_node); ops_in_case.insert(op_node); if !self.work_queue.contains(&op_node) { self.work_queue.push_back(op_node); From 902b6b2ee679c96754b4046d30c6fdd76a5c753b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 22:37:27 -0600 Subject: [PATCH 330/388] Complete guppy array-indexing support: propagate TailLoop continue-state from executed Sum values, resolve forwarded generic type args through the active call chain, and un-xfail test_discard_array now that borrow-array programs run end-to-end --- crates/pecos-hugr/src/engine.rs | 21 ++++---- .../src/engine/control_flow/call.rs | 50 ++++++++++++------- .../src/engine/control_flow/tailloop.rs | 36 ++++++++++++- .../tests/guppy/test_missing_coverage.py | 15 ++---- 4 files changed, 82 insertions(+), 40 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 800fe41e0..eef4c4629 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1045,8 +1045,8 @@ impl HugrEngine { .classical_values .insert(func_input_wire, value.clone()); debug!( - "Call {:?}: mapped input {} classical value to FuncDefn Input {:?}", - current_node, in_port, func_info.input_node + "Call {:?}: mapped input {} classical value {:?} to FuncDefn Input {:?}", + current_node, in_port, value, func_info.input_node ); } } @@ -3012,14 +3012,15 @@ mod tests { let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); - // This fixture nests TailLoops inside CFG blocks (repeat-until- - // success shape); executing that combination is not supported yet - // (case completion does not track nested TailLoops), so the program - // stalls mid-flight. The engine's completion-time stall detection - // must report that loudly instead of returning silently truncated + // This fixture builds borrow arrays and reads them back with + // collections.borrow_arr.pop_left, which the engine does not + // implement yet (its option-Sum result never materializes, so the + // pop guard conditionals stay unresolved). The program stalls + // mid-flight and the engine's completion-time stall detection must + // report that loudly instead of returning silently truncated // results -- the historical version of this test fed one outcome, // asserted nothing, and "passed" on the truncation. Flip this to a - // drive-to-completion test when nested-TailLoop execution lands. + // drive-to-completion test when pop_left support lands. let mut stage = engine.start(()).expect("Failed to start engine"); let mut rounds = 0; loop { @@ -3052,8 +3053,8 @@ mod tests { "expected a stall report, got: {msg}" ); assert!( - msg.contains("TailLoops"), - "stall report should name the stuck TailLoops: {msg}" + msg.contains("Conditionals"), + "stall report should name the unresolved pop guards: {msg}" ); return; } diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index ba878e1d2..c80748f10 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -52,24 +52,40 @@ impl HugrEngine { node: Node, var_idx: usize, ) -> Option { - // Find the enclosing FuncDefn of this node. - let mut cur = hugr.get_parent(node); - let func_defn = loop { - let n = cur?; - if matches!(hugr.get_optype(n), OpType::FuncDefn(_)) { - break n; + let mut node = node; + let mut var_idx = var_idx; + // A generic function may be called from another generic function + // with the type arg forwarded as a variable (`f<$0>` inside `g`), + // so resolution walks the active call chain until a concrete + // BoundedNat appears. Bounded by the active-call count: each hop + // consumes one distinct call frame. + for _ in 0..=self.active_calls.len() { + // Find the enclosing FuncDefn of this node. + let mut cur = hugr.get_parent(node); + let func_defn = loop { + let n = cur?; + if matches!(hugr.get_optype(n), OpType::FuncDefn(_)) { + break n; + } + cur = hugr.get_parent(n); + }; + // Find the active call executing this FuncDefn and read its arg. + let info = self + .active_calls + .values() + .find(|info| info.func_defn_node == func_defn)?; + match info.type_args.get(var_idx)? { + TypeArg::BoundedNat(n) => return Some(*n), + TypeArg::Variable(var) => { + // Forwarded generic: continue resolution in the CALLER's + // frame, at the caller's variable index. + node = info.call_node; + var_idx = var.index(); + } + _ => return None, } - cur = hugr.get_parent(n); - }; - // Find the active call executing this FuncDefn and read its arg. - self.active_calls - .values() - .find(|info| info.func_defn_node == func_defn) - .and_then(|info| info.type_args.get(var_idx)) - .and_then(|arg| match arg { - TypeArg::BoundedNat(n) => Some(*n), - _ => None, - }) + } + None } /// Complete a function call if the completed CFG belongs to an active Call's `FuncDefn`. diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 416ba2ed0..d1f4586f2 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -496,9 +496,38 @@ impl HugrEngine { } } - // The just_inputs values come from unpacking the Sum (CONTINUE variant) - // Trace through the Tag node that created the Sum + // The just_inputs values come from unpacking the Sum (CONTINUE variant). + // If the control wire carries an executed Sum VALUE (e.g. built by a + // Tag inside a Conditional case and routed through the Conditional's + // output), its payload elements ARE the next iteration's just_inputs. let control_port = IncomingPort::from(0); + if let Some((ctrl_src, ctrl_port)) = hugr.single_linked_output(output_node, control_port) + && let Some(crate::engine::types::ClassicalValue::Sum { tag: 0, values }) = self + .wire_state + .classical_values + .get(&(ctrl_src, ctrl_port.index())) + .cloned() + { + for (port_idx, value) in values.into_iter().enumerate() { + if port_idx >= just_inputs_count { + break; + } + debug!( + "TailLoop continue: propagated just_input value {value:?} to Input:{port_idx}" + ); + if let crate::engine::types::ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((input_node, port_idx), *qubit_id); + } + self.wire_state + .classical_values + .insert((input_node, port_idx), value); + } + return; + } + + // Otherwise trace through the Tag node that created the Sum structurally if let Some((tag_node, _)) = hugr.single_linked_output(output_node, control_port) && let OpType::Tag(tag_op) = hugr.get_optype(tag_node) && tag_op.tag == 0 @@ -518,6 +547,9 @@ impl HugrEngine { ); } if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { + debug!( + "TailLoop continue: propagated just_input value {value:?} to Input:{port_idx}" + ); self.wire_state .classical_values .insert((input_node, port_idx), value); diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index 356645297..a693f0b10 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -218,13 +218,6 @@ def measure_multiple_test() -> tuple[bool, bool, bool, bool, bool]: # b0 and b2 are probabilistic (from H gates) - @pytest.mark.xfail( - reason="engine returns no measurements for guppy programs indexing arrays " - "(collections.borrow_arr ops are unsupported, so the program stalls before " - "the final measure); the loop-iteration freeze itself is fixed and covered " - "by test_forloop_executes_each_iteration_and_terminates in pecos-hugr", - strict=True, - ) def test_discard_array(self) -> None: """Test discarding an array of qubits.""" # First check if discard_array is available @@ -252,10 +245,10 @@ def discard_array_test() -> bool: assert all(r == 1 for r in get_measurements(results)), "Final qubit should be |1⟩" @pytest.mark.xfail( - reason="engine returns no measurements for guppy programs indexing arrays " - "(collections.borrow_arr ops are unsupported, so the program stalls before " - "the final measure); the loop-iteration freeze itself is fixed and covered " - "by test_forloop_executes_each_iteration_and_terminates in pecos-hugr", + reason="measure_array's internal measurement loop stalls (starved deferred " + "node inside a TailLoop nested in the measure_array helper); plain array " + "indexing with borrow ops now works (see test_discard_array) -- the engine " + "reports the stall loudly instead of truncating", strict=True, ) def test_array_indexing_and_loops(self) -> None: From 0561aa5ba019a355989efae1415eb7d928e1cb8e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Thu, 2 Jul 2026 23:09:43 -0600 Subject: [PATCH 331/388] Re-propagate active case inputs when measurement results arrive, fixing short-circuit boolean programs whose case expands before a later measurement's value exists --- crates/pecos-hugr/src/engine.rs | 4 + .../src/engine/control_flow/conditional.rs | 143 ++++++++++-------- 2 files changed, 86 insertions(+), 61 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index eef4c4629..2750be0f9 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1787,6 +1787,10 @@ impl ClassicalEngine for HugrEngine { // results are available if let Some(hugr) = self.hugr.clone() { self.repropagate_measurement_values(&hugr); + // Likewise for expanded cases: a case can expand on its + // own control before OTHER data inputs (later + // measurements) exist; refresh its Input ports now. + self.repropagate_active_case_inputs(&hugr); } // Retry any bool.read nodes that were waiting for measurement results diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index 64f2605e7..ba822834d 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -304,6 +304,87 @@ impl HugrEngine { } } + /// Copy the Conditional's control payload and data inputs into the + /// selected Case's Input node ports. + /// + /// Called at expansion and again when measurement results arrive: a case + /// can expand as soon as its own control resolves while other data + /// inputs (e.g. a later measurement) do not exist yet, and the one-shot + /// copy would leave those Case Input ports empty forever. + pub(crate) fn propagate_case_inputs(&mut self, hugr: &Hugr, cond_node: Node, input_node: Node) { + // The Case's Input row is [selected variant's PAYLOAD] ++ + // [other inputs]. Unpack the control Sum's payload values into + // the first Case Input ports (e.g. an iterator's + // Continue(item, state) payload); empty-payload variants (plain + // bools) contribute nothing and leave the offset at 0. + let mut payload_len = 0; + if let Some((ctrl_src, ctrl_port)) = + hugr.single_linked_output(cond_node, IncomingPort::from(0)) + && let Some(ClassicalValue::Sum { values, .. }) = self + .wire_state + .classical_values + .get(&(ctrl_src, ctrl_port.index())) + .cloned() + { + payload_len = values.len(); + for (i, value) in values.into_iter().enumerate() { + debug!("Propagated control payload {value:?} to Case Input ({input_node:?}, {i})"); + self.wire_state + .classical_values + .insert((input_node, i), value); + } + } + + // Propagate ALL wires (qubit and classical) from Conditional inputs to the Case's Input node + // Port 0 is the control (Sum type), ports 1+ are data inputs + // following the payload values unpacked above. + let num_cond_inputs = hugr.num_inputs(cond_node); + + // Start from port 1 (skip control), propagate all inputs + for port_idx in 1..num_cond_inputs { + let cond_in_port = IncomingPort::from(port_idx); + if let Some((src_node, src_port)) = hugr.single_linked_output(cond_node, cond_in_port) { + let src_wire = (src_node, src_port.index()); + let input_output_idx = payload_len + port_idx - 1; + + // Propagate qubit mappings + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { + self.wire_state + .wire_to_qubit + .insert((input_node, input_output_idx), qubit_id); + debug!( + "Propagated qubit {qubit_id:?} to Input node {input_node:?} port {input_output_idx}" + ); + } + + // Also propagate classical values (integers, bools, etc.) + if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { + debug!( + "Propagated classical value {value:?} from {src_wire:?} to Case Input ({input_node:?}, {input_output_idx})" + ); + self.wire_state + .classical_values + .insert((input_node, input_output_idx), value); + } + } + } + } + + /// Re-run case-input propagation for every active case (measurement + /// results may have landed after the case expanded). + pub(crate) fn repropagate_active_case_inputs(&mut self, hugr: &Hugr) { + let targets: Vec<(Node, Node)> = self + .active_cases + .iter() + .filter_map(|(&case_node, info)| { + find_input_node(hugr, case_node).map(|input| (info.conditional_node, input)) + }) + .collect(); + for (cond_node, input_node) in targets { + self.propagate_case_inputs(hugr, cond_node, input_node); + } + } + /// Expand a Conditional by selecting the appropriate Case branch. /// Returns the entry nodes of the selected Case that should be added to the work queue. pub(crate) fn expand_conditional( @@ -338,67 +419,7 @@ impl HugrEngine { if let Some(input_node) = input_node { debug!("Case {selected_case:?} has Input node {input_node:?}"); - - // The Case's Input row is [selected variant's PAYLOAD] ++ - // [other inputs]. Unpack the control Sum's payload values into - // the first Case Input ports (e.g. an iterator's - // Continue(item, state) payload); empty-payload variants (plain - // bools) contribute nothing and leave the offset at 0. - let mut payload_len = 0; - if let Some((ctrl_src, ctrl_port)) = - hugr.single_linked_output(cond_node, IncomingPort::from(0)) - && let Some(ClassicalValue::Sum { values, .. }) = self - .wire_state - .classical_values - .get(&(ctrl_src, ctrl_port.index())) - .cloned() - { - payload_len = values.len(); - for (i, value) in values.into_iter().enumerate() { - debug!( - "Propagated control payload {value:?} to Case Input ({input_node:?}, {i})" - ); - self.wire_state - .classical_values - .insert((input_node, i), value); - } - } - - // Propagate ALL wires (qubit and classical) from Conditional inputs to the Case's Input node - // Port 0 is the control (Sum type), ports 1+ are data inputs - // following the payload values unpacked above. - let num_cond_inputs = hugr.num_inputs(cond_node); - - // Start from port 1 (skip control), propagate all inputs - for port_idx in 1..num_cond_inputs { - let cond_in_port = IncomingPort::from(port_idx); - if let Some((src_node, src_port)) = - hugr.single_linked_output(cond_node, cond_in_port) - { - let src_wire = (src_node, src_port.index()); - let input_output_idx = payload_len + port_idx - 1; - - // Propagate qubit mappings - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - self.wire_state - .wire_to_qubit - .insert((input_node, input_output_idx), qubit_id); - debug!( - "Propagated qubit {qubit_id:?} to Input node {input_node:?} port {input_output_idx}" - ); - } - - // Also propagate classical values (integers, bools, etc.) - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { - debug!( - "Propagated classical value {value:?} from {src_wire:?} to Case Input ({input_node:?}, {input_output_idx})" - ); - self.wire_state - .classical_values - .insert((input_node, input_output_idx), value); - } - } - } + self.propagate_case_inputs(hugr, cond_node, input_node); } else { debug!("No Input node found in Case {selected_case:?}"); } From 0988887926e6156d57b3abc60ebd4432aad663b8 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 09:27:26 -0600 Subject: [PATCH 332/388] Add debug logging for successor queueing decisions (neighbours and skip reasons) to make defer-starvation diagnosis tractable --- crates/pecos-hugr/src/engine.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 2750be0f9..8f6d4a86f 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1603,6 +1603,10 @@ impl HugrEngine { /// Adds successor nodes to the work queue if they are relevant node types, /// not yet processed, not already queued, and have all predecessors ready. fn queue_ready_successors(&mut self, hugr: &Hugr, node: Node) { + debug!( + "queue_ready_successors({node:?}): neighbours={:?}", + hugr.output_neighbours(node).collect::>() + ); for succ_node in hugr.output_neighbours(node) { let is_relevant = self.quantum_ops.contains_key(&succ_node) || self.classical_ops.contains_key(&succ_node) @@ -1637,6 +1641,20 @@ impl HugrEngine { ) { self.work_queue.push_back(succ_node); + } else if is_relevant || is_extension { + debug!( + "queue_ready_successors({node:?}): skipped {succ_node:?} gated={inside_control_flow} processed={} queued={} ready={}", + self.processed.contains(&succ_node), + self.work_queue.contains(&succ_node), + all_predecessors_ready( + hugr, + succ_node, + &self.quantum_ops, + &self.conditionals, + &self.cfgs, + &self.processed + ) + ); } } } From e96888ea01b7ea84448140e2f07d4fe02086e6cd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 10:05:45 -0600 Subject: [PATCH 333/388] Execute Tags over linear payloads as classical ops with QubitRef fallback and qubit-wire mapping on Sum-payload propagation, resolve generic new_all_borrowed sizes through the call chain, retry pending TailLoop controls on case completion, and stop replaying superseded block transitions over fresh loop-iteration inputs --- crates/pecos-hugr/src/engine/analysis.rs | 17 ++++------ .../pecos-hugr/src/engine/control_flow/cfg.rs | 32 +++++++++++++++++-- .../src/engine/control_flow/conditional.rs | 11 +++++++ .../src/engine/handlers/borrow_arr.rs | 26 +++++++++------ .../src/engine/handlers/classical.rs | 9 ++++++ 5 files changed, 72 insertions(+), 23 deletions(-) diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 8448286d0..71cb9b723 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -584,19 +584,14 @@ pub type ClassicalOpClassification = (ClassicalOpType, usize, usize, Option<(u8, #[allow(clippy::too_many_lines)] pub fn classify_classical_op(op: &OpType) -> Option { // HUGR `Tag` nodes build tagged sum values (variants, options, branch - // selectors). Over copyable payloads they are classical computation; - // over linear (qubit) payloads they are wire routing and stay with the - // structural machinery, like linear tuples below. + // selectors). They execute as classical ops over linear payloads too: + // qubit inputs become ClassicalValue::QubitRef payload elements (the + // executor falls back to the qubit wire map for missing inputs). This + // matters because a Sum value that never materializes starves every + // value-based consumer -- e.g. an iterator's Option result built by a + // Tag over (qubit, state) would leave the caller's match unresolvable. if let OpType::Tag(_) = op { let sig = op.dataflow_signature()?; - if sig - .input() - .iter() - .chain(sig.output().iter()) - .any(|t| !t.copyable()) - { - return None; - } return Some((ClassicalOpType::TagSum, sig.input_count(), 1, None)); } diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 356b44d29..4fad813d6 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -458,7 +458,14 @@ impl HugrEngine { self.propagate_block_outputs_to_successor(hugr, from_block, to_block); // Record this propagation for re-propagation after measurement results - // are available (measurement results may not be stored yet when we transition) + // are available (measurement results may not be stored yet when we + // transition). Only the LATEST transition per CFG may stay recorded: + // replaying a superseded edge (e.g. the previous loop iteration's) + // re-reads source wires that have since been cleared or partially + // rewritten and clobbers the successor's fresh inputs with stale + // values (observed as a loop re-borrowing the same array slot). + self.pending_measurement_propagations + .retain(|(cfg, _, _)| *cfg != cfg_node); self.pending_measurement_propagations .push((cfg_node, from_block, to_block)); @@ -831,6 +838,11 @@ impl HugrEngine { debug!( "[TRACE] Block transition: propagated branch payload {value:?} to {to_input:?}:{i}" ); + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((to_input, i), *qubit_id); + } self.wire_state .classical_values .insert((to_input, i), value); @@ -960,8 +972,17 @@ impl HugrEngine { // Take ownership of the pending list to avoid borrow issues let pending: Vec<_> = std::mem::take(&mut self.pending_measurement_propagations); - for (_cfg_node, from_block, to_block) in pending { - self.propagate_block_outputs_to_successor(hugr, from_block, to_block); + for (cfg_node, from_block, to_block) in pending { + // Replay only while the successor is still the CFG's current + // block; a transition the CFG has since moved past would write + // stale values into a block that already has fresh inputs. + if self + .active_cfgs + .get(&cfg_node) + .is_some_and(|active| active.current_block == to_block) + { + self.propagate_block_outputs_to_successor(hugr, from_block, to_block); + } } } @@ -993,6 +1014,11 @@ impl HugrEngine { payload_len = values.len(); for (i, value) in values.into_iter().enumerate() { debug!("CFG {cfg_node:?} output {i}: mapped payload value {value:?}"); + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((cfg_node, i), *qubit_id); + } self.wire_state .classical_values .insert((cfg_node, i), value); diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index ba822834d..b8c5737d4 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -193,6 +193,12 @@ impl HugrEngine { // on the case being done) and wake its consumers. self.check_cfg_block_completion(hugr, cond_node); self.check_tailloop_body_completion(hugr, cond_node); + // A TailLoop whose control Sum rides through this Conditional's + // output may have parked in pending_tailloop_control before the + // value existed -- re-attempt it here (measurement rounds are + // the only other retry point, and a purely classical case + // completion may never be followed by one). + self.try_resolve_pending_tailloops(); self.queue_ready_successors(hugr, cond_node); self.retry_pending_bool_reads(); } @@ -329,6 +335,11 @@ impl HugrEngine { payload_len = values.len(); for (i, value) in values.into_iter().enumerate() { debug!("Propagated control payload {value:?} to Case Input ({input_node:?}, {i})"); + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((input_node, i), *qubit_id); + } self.wire_state .classical_values .insert((input_node, i), value); diff --git a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs index 77e1ed6b6..35fed9252 100644 --- a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs +++ b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs @@ -46,17 +46,25 @@ impl HugrEngine { match op_name { "new_all_borrowed" => { // [] -> [array]: n slots, all borrowed-out (holes). The size - // comes from the op's first BoundedNat type arg. + // comes from the op's first type arg: a concrete BoundedNat, + // or (inside a generic function) a type variable resolved + // through the active call chain. Defer if unresolvable -- a + // fabricated 0-slot array makes every later return/borrow + // out of bounds. let op = hugr.get_optype(node); - let size = op - .as_extension_op() - .and_then(|ext| { - ext.args().iter().find_map(|arg| match arg { - tket::hugr::types::TypeArg::BoundedNat(n) => Some(*n), - _ => None, - }) + let size = op.as_extension_op().and_then(|ext| { + ext.args().iter().find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => Some(*n), + tket::hugr::types::TypeArg::Variable(var) => { + self.resolve_call_type_arg(hugr, node, var.index()) + } + _ => None, }) - .unwrap_or(0); + }); + let Some(size) = size else { + debug!("new_all_borrowed at {node:?}: size unresolved, deferring"); + return false; + }; #[allow(clippy::cast_possible_truncation)] // Array sizes fit in usize let elements = vec![ClassicalValue::Borrowed; size as usize]; debug!("new_all_borrowed: created {size}-slot all-borrowed array"); diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 5c1512f4b..59aae4d0e 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -58,6 +58,15 @@ impl HugrEngine { let wire_key = (src_node, src_port.index()); if let Some(value) = self.wire_state.classical_values.get(&wire_key) { inputs.push(value.clone()); + } else if matches!(op.op_type, ClassicalOpType::TagSum) + && let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&wire_key) + { + // A Tag may carry linear payload elements (e.g. an + // iterator's Option over (qubit, state)): represent the + // qubit as a QubitRef so the Sum value materializes. + // Scoped to TagSum only -- a qubit input to an + // arithmetic op is a semantic error, not a value. + inputs.push(ClassicalValue::QubitRef(qubit_id)); } else { debug!( "Classical op {node:?}: missing input value for port {port_idx} from {wire_key:?}" From b840bf2e5f816f9874670a2db9d53622804745f9 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 10:24:35 -0600 Subject: [PATCH 334/388] Scope quantum-op collection to immediate containers (no premature unselected-case gate execution), clear descendant wire values on function re-call, reset case quantum ops at re-expansion, guard TailLoop body completion on active cases, and implement borrow_arr.get --- crates/pecos-hugr/src/engine.rs | 18 +++++++++ crates/pecos-hugr/src/engine/analysis.rs | 21 +++++++++- .../src/engine/control_flow/conditional.rs | 7 +++- .../src/engine/control_flow/tailloop.rs | 14 +++++-- .../src/engine/handlers/borrow_arr.rs | 39 +++++++++++++++++++ 5 files changed, 92 insertions(+), 7 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 8f6d4a86f..a2769d8bb 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1091,6 +1091,24 @@ impl HugrEngine { } self.processed.remove(&cfg_node); + // Also clear the descendants' stale wire VALUES: with + // only the processed flags cleared, a Conditional + // inside the body can resolve from the PREVIOUS + // call's control wire and expand with stale case + // inputs before its producers re-run (observed as an + // array iterator re-yielding index 0 forever). The + // FuncDefn Input node is skipped -- fresh call + // arguments were just copied onto it above. + for node in &descendants { + if *node == func_info.input_node { + continue; + } + let num_outputs = hugr.num_outputs(*node); + for port in 0..num_outputs { + self.wire_state.classical_values.remove(&(*node, port)); + } + } + // Add the CFG to the work queue to be processed if !self.work_queue.contains(&cfg_node) { self.work_queue.push_front(cfg_node); diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 71cb9b723..aaefb3d9d 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -732,11 +732,27 @@ pub fn find_quantum_ops_in_block(hugr: &Hugr, block: Node) -> BTreeSet { ops } -/// Recursively collect quantum operations in a subtree. +/// Recursively collect quantum operations in a subtree, stopping at nested +/// control-flow containers. +/// +/// Ops inside a nested Conditional/TailLoop/CFG/FuncDefn belong to THAT +/// container's activation: collecting them here queues them when the outer +/// block activates -- before the case is selected or the loop iterates -- +/// which executes unselected-branch gates (observed as phantom extra +/// allocations and measurements). Completion tracking covers the nested +/// work through the container node itself (conditionals gate on +/// no-active-case; Calls/TailLoops are marked processed only at completion). fn collect_quantum_ops_recursive(hugr: &Hugr, node: Node, ops: &mut BTreeSet) { for child in hugr.children(node) { let op = hugr.get_optype(child); + if matches!( + op, + OpType::Conditional(_) | OpType::TailLoop(_) | OpType::CFG(_) | OpType::FuncDefn(_) + ) { + continue; + } + // Check if this is a quantum extension operation if let Some(ext_op) = op.as_extension_op() { let ext_id = ext_op.extension_id(); @@ -747,7 +763,8 @@ fn collect_quantum_ops_recursive(hugr: &Hugr, node: Node, ops: &mut BTreeSet { + // [array, usize] -> [Sum([[], [T]]), array]: copy the element + // at the index (copyable element types only -- no hole is + // left). Out-of-bounds or borrowed slots yield the None + // variant, matching the std extension's option result. + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("get at {node:?}: array not ready, deferring"); + return false; + }; + #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize + let Some(index) = self + .get_input_value(hugr, node, 1) + .and_then(|v| v.as_uint()) + .map(|v| v as usize) + else { + debug!("get at {node:?}: index not ready, deferring"); + return false; + }; + + let result = match elements.get(index) { + Some(slot) if !matches!(slot, ClassicalValue::Borrowed) => { + ClassicalValue::Sum { + tag: 1, + values: vec![slot.clone()], + } + } + _ => ClassicalValue::Sum { + tag: 0, + values: vec![], + }, + }; + debug!("get[{index}]: {result:?}"); + self.wire_state.classical_values.insert((node, 0), result); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Array(elements)); + true + } "discard_all_borrowed" => { // [array] -> []: consumes the (all-borrowed) array; nothing // to produce. Defer until the array value exists so the op From 06d1d6442ce41f356c0504c7d5867abad02baeac Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 11:29:55 -0600 Subject: [PATCH 335/388] Fix double conditional expansion across the queue and pending paths, defer gates whose qubit producer is still pending (implicit qubits only for processed or Input sources, keyed to the source wire), and clear qubit-wire mappings alongside classical values at every re-activation site --- crates/pecos-hugr/src/engine.rs | 11 ++++-- .../pecos-hugr/src/engine/control_flow/cfg.rs | 1 + .../src/engine/control_flow/conditional.rs | 31 +++++++++++++++-- .../src/engine/control_flow/tailloop.rs | 1 + crates/pecos-hugr/src/engine/propagation.rs | 34 ++++++++++++------- 5 files changed, 62 insertions(+), 16 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index a2769d8bb..119437e8f 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1106,6 +1106,7 @@ impl HugrEngine { let num_outputs = hugr.num_outputs(*node); for port in 0..num_outputs { self.wire_state.classical_values.remove(&(*node, port)); + self.wire_state.wire_to_qubit.remove(&(*node, port)); } } @@ -1264,8 +1265,14 @@ impl HugrEngine { continue; }; - // Resolve qubit IDs for this operation - let qubits = self.resolve_qubits(&hugr, current_node, &op); + // Resolve qubit IDs for this operation; defer the gate if a + // qubit wire has no mapping yet (its producer has not run -- + // completion of that producer re-queues this node). + let Some(qubits) = self.resolve_qubits(&hugr, current_node, &op) else { + self.pending_bool_reads.insert(current_node); + continue; + }; + self.pending_bool_reads.remove(¤t_node); // Emit the gate operation if self.emit_quantum_gate(&hugr, current_node, &op, &qubits)? { diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 4fad813d6..5858e810b 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -491,6 +491,7 @@ impl HugrEngine { let num_outputs = hugr.num_outputs(child); for port_idx in 0..num_outputs { self.wire_state.classical_values.remove(&(child, port_idx)); + self.wire_state.wire_to_qubit.remove(&(child, port_idx)); } } diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index d732009cd..31350bb0b 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -111,13 +111,23 @@ impl HugrEngine { None => return, }; - // Collect conditionals that can now be resolved + // Collect conditionals that can now be resolved. A pending entry + // whose node is already processed was expanded through the queue + // path in the meantime -- expanding it again would re-run its case. let mut to_resolve = Vec::new(); + let mut already_expanded = Vec::new(); for &cond_node in self.pending_conditionals.keys() { - if let Some(branch_index) = self.try_resolve_conditional_control(&hugr, cond_node) { + if self.processed.contains(&cond_node) { + already_expanded.push(cond_node); + } else if let Some(branch_index) = + self.try_resolve_conditional_control(&hugr, cond_node) + { to_resolve.push((cond_node, branch_index)); } } + for cond_node in already_expanded { + self.pending_conditionals.remove(&cond_node); + } // Resolve them for (cond_node, branch_index) in to_resolve { @@ -424,6 +434,14 @@ impl HugrEngine { "Expanding Conditional {cond_node:?} branch {branch_index} -> Case {selected_case:?}" ); + // This conditional may ALSO be parked in pending_conditionals (it + // deferred once, then a loop iteration re-queued it and the queue + // path expanded it). Drop the pending entry, or a later retry wave + // expands it a second time -- with case quantum ops reset per + // expansion that re-allocates and re-measures qubits (observed as + // gates and measurements landing on disjoint qubit generations). + self.pending_conditionals.remove(&cond_node); + // Find the Input node inside the selected Case // Operations inside the Case connect to this Input node, not to the Case node itself let input_node = find_input_node(hugr, selected_case); @@ -461,6 +479,15 @@ impl HugrEngine { self.nodes_inside_cases.remove(&node); self.nodes_inside_cfg_blocks.remove(&node); self.nodes_inside_tailloops.remove(&node); + // Clear stale OUTPUT wires from the previous expansion: a + // consumer popping before this op re-runs would otherwise read + // last iteration's value/qubit (e.g. a gate resolving the + // previous iteration's borrowed qubit). + let num_outputs = hugr.num_outputs(node); + for port_idx in 0..num_outputs { + self.wire_state.classical_values.remove(&(node, port_idx)); + self.wire_state.wire_to_qubit.remove(&(node, port_idx)); + } } // The Case's classical, bool, and extension ops must also execute diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index eb9f09a6d..78a3997e0 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -363,6 +363,7 @@ impl HugrEngine { let num_outputs = hugr.num_outputs(child); for port_idx in 0..num_outputs { self.wire_state.classical_values.remove(&(child, port_idx)); + self.wire_state.wire_to_qubit.remove(&(child, port_idx)); } } diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index 05db26583..efc00b8c0 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -242,13 +242,13 @@ impl HugrEngine { hugr: &Hugr, node: Node, op: &QuantumOp, - ) -> Vec { + ) -> Option> { if op.gate_type == GateType::QAlloc { // QAlloc creates a new qubit let qubit_id = QubitId::from(self.wire_state.next_qubit_id); self.wire_state.next_qubit_id += 1; self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - return vec![qubit_id]; + return Some(vec![qubit_id]); } let mut qubits = Vec::with_capacity(op.num_qubit_inputs); @@ -295,32 +295,42 @@ impl HugrEngine { .wire_to_qubit .insert((node, port_idx), qubit_id); } + } else if !self.processed.contains(&wire_key.0) + && !matches!(hugr.get_optype(wire_key.0), OpType::Input(_)) + { + // No mapping and the producer has NOT run yet (e.g. a + // borrow op or case payload still pending): defer the + // gate -- fabricating here applies it to a phantom qubit + // (observed as gates and measurements landing on + // disjoint qubit sets). The producer's completion + // re-queues this node. + debug!("resolve_qubits at {node:?}: producer {wire_key:?} pending, deferring"); + return None; } else { - // Fallback: create a new qubit ID + // The source already ran (or is a structural Input wire) + // and no mapping will ever appear: this is an implicit + // top-level qubit. Allocate it ONCE, keyed to the source + // wire so every consumer of this wire sees the same id. let fallback = QubitId::from(self.wire_state.next_qubit_id); self.wire_state.next_qubit_id += 1; + self.wire_state.wire_to_qubit.insert(wire_key, fallback); qubits.push(fallback); if port_idx < op.num_qubit_outputs { self.wire_state .wire_to_qubit .insert((node, port_idx), fallback); } - debug!( - "Warning: No wire mapping for {wire_key:?}, using fallback {fallback:?}" - ); + debug!("resolve_qubits: implicit qubit {fallback:?} for wire {wire_key:?}"); } } else { - // No linked output - create fallback - let fallback = QubitId::from(self.wire_state.next_qubit_id); - self.wire_state.next_qubit_id += 1; - qubits.push(fallback); debug!( - "Warning: No linked output for node {node:?} port {port_idx}, using fallback {fallback:?}" + "resolve_qubits at {node:?}: no linked output for port {port_idx}, deferring" ); + return None; } } - qubits + Some(qubits) } /// Try to load a constant value from a `LoadConstant` node. From 9f9ce081234603c88a3a668969956fed61e204ba Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 12:14:48 -0600 Subject: [PATCH 336/388] Prefer a qubit mapping on the Input port itself before tracing through it, so Sum-payload QubitRefs on case Inputs resolve instead of falling through to outer unmapped wires --- crates/pecos-hugr/src/engine/propagation.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index efc00b8c0..bcbc59c3d 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -260,8 +260,14 @@ impl HugrEngine { let mut wire_key = (src_node, src_port.index()); let src_op = hugr.get_optype(src_node); - // Check if the source is an Input node - if so, trace through it - if matches!(src_op, OpType::Input(_)) { + // Check if the source is an Input node - if so, trace through + // it -- UNLESS the Input port itself already carries a qubit + // mapping (e.g. a case Input holding a Sum-payload QubitRef): + // tracing past it would land on an outer wire with no + // mapping and misresolve the gate. + if matches!(src_op, OpType::Input(_)) + && !self.wire_state.wire_to_qubit.contains_key(&wire_key) + { debug!( "Input node detected: {:?}:{}, attempting trace", src_node, From 7b0bac041ad3d955eba6c973b272e1b22d7ffe23 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 12:24:13 -0600 Subject: [PATCH 337/388] Mirror QubitRef outputs of classical and prelude tuple ops into the qubit-wire map, and clear a Call node's own output wires at re-activation --- crates/pecos-hugr/src/engine.rs | 20 ++++++++++++++++++- .../pecos-hugr/src/engine/handlers/prelude.rs | 5 +++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 119437e8f..ff2015f68 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1076,6 +1076,19 @@ impl HugrEngine { }, ); + // Clear the Call node's OWN stale output wires (the + // descendant clearing below does not cover them): a + // consumer resolving against the previous + // invocation's outputs mid-call reads one-iteration- + // stale data (observed as a measure loop measuring + // each qubit one iteration late). + for port in 0..func_info.num_outputs { + self.wire_state + .classical_values + .remove(&(current_node, port)); + self.wire_state.wire_to_qubit.remove(&(current_node, port)); + } + // Remove FuncDefn descendants from nodes_inside_func_defns // so they can be processed now that the function is being called let mut descendants = BTreeSet::new(); @@ -1190,9 +1203,14 @@ impl HugrEngine { // Successfully resolved - remove from pending if it was there self.pending_bool_reads.remove(¤t_node); - // Store output values + // Store output values. A QubitRef output (e.g. an + // UnpackTuple or Tag over a linear payload) is mirrored into + // the qubit-wire map so downstream gates can resolve it. for (port, value) in outputs { let wire_key = (current_node, port); + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state.wire_to_qubit.insert(wire_key, *qubit_id); + } self.wire_state.classical_values.insert(wire_key, value); } diff --git a/crates/pecos-hugr/src/engine/handlers/prelude.rs b/crates/pecos-hugr/src/engine/handlers/prelude.rs index 89416c67a..25be9c73d 100644 --- a/crates/pecos-hugr/src/engine/handlers/prelude.rs +++ b/crates/pecos-hugr/src/engine/handlers/prelude.rs @@ -145,6 +145,11 @@ impl HugrEngine { ) => { for (port, value) in elements.into_iter().enumerate() { if port < num_outputs { + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((node, port), *qubit_id); + } self.wire_state.classical_values.insert((node, port), value); } } From 23a32a33c3d0a7c445f22778ec2a6349a977311b Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 13:23:38 -0600 Subject: [PATCH 338/388] Clear stale output wires for every case op category at expansion (a measurement queued in the same expansion resolved the previous iteration's unpacked qubit), completing guppy measure_array support and un-xfailing the last array test --- .../pecos-hugr/src/engine/control_flow/conditional.rs | 11 +++++++++++ .../tests/guppy/test_missing_coverage.py | 7 ------- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index 31350bb0b..91124864e 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -539,6 +539,17 @@ impl HugrEngine { .chain(case_containers.iter()) { self.processed.remove(&op_node); + // Clear stale OUTPUT wires too: a consumer queued in this same + // expansion (e.g. a measurement whose qubit rides an + // UnpackTuple output) would otherwise resolve against the + // previous iteration's value before this op re-runs. + let num_outputs = hugr.num_outputs(op_node); + for port_idx in 0..num_outputs { + self.wire_state + .classical_values + .remove(&(op_node, port_idx)); + self.wire_state.wire_to_qubit.remove(&(op_node, port_idx)); + } } // PHASE 2: queue ready ops; ops that are not ready are queued later // by queue_ready_successors when their producers complete. diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index a693f0b10..93d57511e 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -244,13 +244,6 @@ def discard_array_test() -> bool: results = sim(Guppy(discard_array_test)).qubits(10).quantum(state_vector()).seed(42).run(10).to_dict() assert all(r == 1 for r in get_measurements(results)), "Final qubit should be |1⟩" - @pytest.mark.xfail( - reason="measure_array's internal measurement loop stalls (starved deferred " - "node inside a TailLoop nested in the measure_array helper); plain array " - "indexing with borrow ops now works (see test_discard_array) -- the engine " - "reports the stall loudly instead of truncating", - strict=True, - ) def test_array_indexing_and_loops(self) -> None: """Test array indexing within loops.""" if measure_array is None: From 5e5ab80f29871204d68ac5e49f1e1228ebd48172 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 14:32:11 -0600 Subject: [PATCH 339/388] Fold in Codex round-3 findings: stop call/bool collection at case boundaries, clear case Input ports before propagation, propagate executed Sum payloads on TailLoop break and CFG exit regardless of source shape, and defer instead of fabricate in collections.array get/set/new_array and futures.Read --- crates/pecos-hugr/src/engine/analysis.rs | 14 ++- .../pecos-hugr/src/engine/control_flow/cfg.rs | 9 +- .../src/engine/control_flow/conditional.rs | 15 +++ .../src/engine/control_flow/tailloop.rs | 32 ++++++- .../pecos-hugr/src/engine/handlers/array.rs | 92 +++++++++++++------ .../pecos-hugr/src/engine/handlers/futures.rs | 11 ++- 6 files changed, 129 insertions(+), 44 deletions(-) diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index aaefb3d9d..399c6786b 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -783,8 +783,12 @@ fn collect_call_nodes_recursive(hugr: &Hugr, node: Node, calls: &mut BTreeSet= just_outputs_count { + break; + } + debug!( + "TailLoop {tailloop_node:?} output {port_idx}: mapped just_output payload {value:?}" + ); + if let crate::engine::types::ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((tailloop_node, port_idx), *qubit_id); + } + self.wire_state + .classical_values + .insert((tailloop_node, port_idx), value); + } + return; + } + + // Otherwise trace the BREAK Tag structurally if let Some((tag_node, _)) = hugr.single_linked_output(output_node, control_port) && let OpType::Tag(tag_op) = hugr.get_optype(tag_node) && tag_op.tag == 1 diff --git a/crates/pecos-hugr/src/engine/handlers/array.rs b/crates/pecos-hugr/src/engine/handlers/array.rs index ca04c1c6b..4237a4f33 100644 --- a/crates/pecos-hugr/src/engine/handlers/array.rs +++ b/crates/pecos-hugr/src/engine/handlers/array.rs @@ -42,61 +42,93 @@ impl HugrEngine { let op = hugr.get_optype(node); let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); + // A missing element defers the whole construction: silently + // skipping it would shorten the array and shift every index. let mut elements = Vec::with_capacity(num_inputs); for port in 0..num_inputs { if let Some(value) = self.get_input_value(hugr, node, port) { elements.push(value); + } else if let Some(qubit_id) = self.get_input_qubit(hugr, node, port) { + elements.push(ClassicalValue::QubitRef(qubit_id)); + } else { + debug!("new_array at {node:?}: element {port} not ready, deferring"); + return false; } } + debug!("new_array: created array with {} elements", elements.len()); self.wire_state .classical_values - .insert((node, 0), ClassicalValue::Array(elements.clone())); - - debug!("new_array: created array with {} elements", elements.len()); + .insert((node, 0), ClassicalValue::Array(elements)); true } "get" | "Get" | "index" | "Index" => { // get: (Array, int) -> T // Get element at index - let array = self.get_input_value(hugr, node, 0); - let index = self + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.get at {node:?}: array not ready, deferring"); + return false; + }; + #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize + let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; + .map(|v| v as usize) + else { + debug!("array.get at {node:?}: index not ready, deferring"); + return false; + }; - if let Some(ClassicalValue::Array(elements)) = array { - if let Some(element) = elements.get(index) { - self.wire_state - .classical_values - .insert((node, 0), element.clone()); - debug!("array.get[{index}]: retrieved element"); - } else { - debug!("array.get[{index}]: index out of bounds"); - } + let Some(element) = elements.get(index) else { + debug!( + "array.get at {node:?}: index {index} out of bounds (len={}), deferring", + elements.len() + ); + return false; + }; + if let ClassicalValue::QubitRef(qubit_id) = element { + self.wire_state.wire_to_qubit.insert((node, 0), *qubit_id); } + self.wire_state + .classical_values + .insert((node, 0), element.clone()); + debug!("array.get[{index}]: retrieved element"); true } "set" | "Set" => { // set: (Array, int, T) -> Array // Set element at index - let array = self.get_input_value(hugr, node, 0); - let index = self + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.set at {node:?}: array not ready, deferring"); + return false; + }; + #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize + let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; - let value = self.get_input_value(hugr, node, 2); - - if let (Some(ClassicalValue::Array(mut elements)), Some(new_value)) = (array, value) - { - if index < elements.len() { - elements[index] = new_value; - } - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.set[{index}]: updated element"); - } + .map(|v| v as usize) + else { + debug!("array.set at {node:?}: index not ready, deferring"); + return false; + }; + let Some(new_value) = self.get_input_value(hugr, node, 2) else { + debug!("array.set at {node:?}: value not ready, deferring"); + return false; + }; + let Some(slot) = elements.get_mut(index) else { + debug!( + "array.set at {node:?}: index {index} out of bounds (len={}), deferring", + elements.len() + ); + return false; + }; + *slot = new_value; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + debug!("array.set[{index}]: updated element"); true } "len" | "Len" | "length" | "Length" => { diff --git a/crates/pecos-hugr/src/engine/handlers/futures.rs b/crates/pecos-hugr/src/engine/handlers/futures.rs index 14224a4c1..3630d0ae5 100644 --- a/crates/pecos-hugr/src/engine/handlers/futures.rs +++ b/crates/pecos-hugr/src/engine/handlers/futures.rs @@ -60,11 +60,12 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Bool(result != 0)); debug!("Read future {future_id} from measurement -> {result}"); } else { - // Result not yet available - use default - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Bool(false)); - debug!("Read future {future_id} pending, using default"); + // Result not yet available: defer -- a + // fabricated `false` commits a wrong + // measurement value downstream. Retried + // when measurement results arrive. + debug!("Read future {future_id} pending, deferring"); + return false; } } } From 68b6edf0239c899224a5102a75a8ce200ed222d5 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 15:15:00 -0600 Subject: [PATCH 340/388] Reject direct recursion with a clear error, make the structural Tag branch fallback return the tag (consistent with the value path), and replace the silent payload-offset reset with a loud warning --- crates/pecos-hugr/src/engine.rs | 17 +++++++++ .../pecos-hugr/src/engine/control_flow/cfg.rs | 37 +++++++++++-------- 2 files changed, 38 insertions(+), 16 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index ff2015f68..d5013f07f 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1005,6 +1005,23 @@ impl HugrEngine { .any(|info| info.func_defn_node == func_defn_node); if func_defn_in_use { + // Direct recursion (a call to F from inside F's own + // body) can never make progress: the outer invocation + // waits on this Call node while this Call waits for the + // FuncDefn to free up. Reject it immediately with a + // clear error instead of parking it (indirect recursion + // deadlocks the same way and is caught by the + // completion-time stall detection, which lists the + // parked calls). + let mut cur = hugr.get_parent(current_node); + while let Some(n) = cur { + if n == func_defn_node { + return Err(PecosError::Generic(format!( + "recursive call to FuncDefn {func_defn_node:?} at {current_node:?}: recursion is not supported by the HUGR engine (no call stack; each function has a single execution frame)" + ))); + } + cur = hugr.get_parent(n); + } debug!( "Call {current_node:?}: FuncDefn {func_defn_node:?} is in use, queueing" ); diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index e815f304f..9e15c6243 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -84,15 +84,16 @@ impl HugrEngine { hugr.single_linked_output(src_node, tag_input_port) { let tag_src_wire = (tag_src_node, tag_src_port.index()); - if let Some(input_value) = self.wire_state.classical_values.get(&tag_src_wire) - && let Some(v) = input_value.to_u32() - { + if self.wire_state.classical_values.contains_key(&tag_src_wire) { + // A Tag node ALWAYS produces its own variant: the + // branch is the tag, not the wrapped value. (The + // executed-value path above returns the Sum's tag + // for exactly the same reason; this structural + // fallback must agree with it.) debug!( - "CFG block {block_node:?} resolved via Tag: tag={tag_value}, input={v}" + "CFG block {block_node:?} resolved via Tag with known input: tag={tag_value}" ); - // For booleans converted to Sum: input_value determines the branch - // The Tag wraps the value - we use the input value as the branch - return Some(v as usize); + return Some(tag_value); } } @@ -867,17 +868,21 @@ impl HugrEngine { } } - // Now map other_outputs (port 1+) to successor Input ports - // Check if the target Input node has enough outputs to accommodate the payload offset + // Now map other_outputs (port 1+) to successor Input ports, offset + // by the payload length. In valid HUGR the successor's inputs are + // exactly [selected variant's row] ++ [other_outputs], so the offset + // always fits; if it does not, something upstream extracted the + // wrong variant's payload -- warn loudly rather than silently + // dropping the offset (which would overwrite the payload ports just + // written above with misaligned data). let target_num_outputs = hugr.num_outputs(to_input); let num_data_outputs = num_output_ports.saturating_sub(1); - // Only apply payload offset if the target has enough outputs - // This handles exit blocks which don't expect payloads - let effective_payload_len = if payload_len + num_data_outputs <= target_num_outputs { - payload_len - } else { - 0 // Target doesn't have room for payloads, don't offset - }; + if payload_len + num_data_outputs > target_num_outputs { + debug!( + "WARNING: block transition {from_block:?} -> {to_block:?}: payload ({payload_len}) + data ({num_data_outputs}) exceeds target inputs ({target_num_outputs}); upstream variant extraction is suspect" + ); + } + let effective_payload_len = payload_len; debug!("[TRACE] num_data_outputs={num_data_outputs}"); debug!( "[TRACE] propagate_block_outputs: from_block={from_block:?}, to_block={to_block:?}, num_data_outputs={num_data_outputs}" From 5e9562ed7d38bbe8c6a428eef353b4f3fbc9fe63 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 15:22:53 -0600 Subject: [PATCH 341/388] Rewrite the classical executor with typed extraction (present-but-unconvertible inputs defer instead of computing on fabricated defaults) and honor classified signedness for division, modulo, ordering comparisons, and right shift --- .../src/engine/handlers/classical.rs | 487 ++++++------------ 1 file changed, 155 insertions(+), 332 deletions(-) diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 59aae4d0e..b12a58617 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -79,343 +79,20 @@ impl HugrEngine { } } - // Execute the operation - let result = match op.op_type { - // Logic operations - ClassicalOpType::And => { - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(a && b) - } - ClassicalOpType::Or => { - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(a || b) - } - ClassicalOpType::Not => { - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(!a) - } - ClassicalOpType::Xor => { - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(a ^ b) - } - ClassicalOpType::Eq => { - // Eq can work on bools - let a = inputs - .first() - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_bool) - .unwrap_or(false); - ClassicalValue::Bool(a == b) - } - - // Integer arithmetic - ClassicalOpType::Iadd => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_add(b)) - } - ClassicalOpType::Isub => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_sub(b)) - } - ClassicalOpType::Imul => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_mul(b)) - } - ClassicalOpType::Idiv => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(1); - if b == 0 { - ClassicalValue::Int(0) // Avoid division by zero - } else { - ClassicalValue::Int(a.wrapping_div(b)) - } - } - ClassicalOpType::Imod => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(1); - if b == 0 { - ClassicalValue::Int(0) - } else { - ClassicalValue::Int(a.wrapping_rem(b)) - } - } - ClassicalOpType::Ineg => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_neg()) - } - ClassicalOpType::Iabs => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a.wrapping_abs()) - } - - // Integer comparisons - ClassicalOpType::Ieq => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a == b) - } - ClassicalOpType::Ine => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a != b) - } - ClassicalOpType::Ilt => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a < b) - } - ClassicalOpType::Ile => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a <= b) - } - ClassicalOpType::Igt => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a > b) - } - ClassicalOpType::Ige => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Bool(a >= b) - } - - // Integer bitwise operations - ClassicalOpType::Iand => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a & b) - } - ClassicalOpType::Ior => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a | b) - } - ClassicalOpType::Ixor => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(a ^ b) - } - ClassicalOpType::Inot => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Int(!a) - } - ClassicalOpType::Ishl => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - // Clamp shift amount to valid range (0-63 for i64) - let shift = b.clamp(0, 63) as u32; - ClassicalValue::Int(a.wrapping_shl(shift)) - } - ClassicalOpType::Ishr => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - let b = inputs.get(1).and_then(ClassicalValue::as_int).unwrap_or(0); - // Clamp shift amount to valid range (0-63 for i64) - let shift = b.clamp(0, 63) as u32; - ClassicalValue::Int(a.wrapping_shr(shift)) - } - - // Float arithmetic - ClassicalOpType::Fadd => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a + b) - } - ClassicalOpType::Fsub => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a - b) - } - ClassicalOpType::Fmul => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a * b) - } - ClassicalOpType::Fdiv => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(1.0); - ClassicalValue::Float(a / b) - } - ClassicalOpType::Fneg => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(-a) - } - ClassicalOpType::Fabs => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a.abs()) - } - ClassicalOpType::Ffloor => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a.floor()) - } - ClassicalOpType::Fceil => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Float(a.ceil()) - } - - // Float comparisons - ClassicalOpType::Feq => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a == b) - } - ClassicalOpType::Fne => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a != b) - } - ClassicalOpType::Flt => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a < b) - } - ClassicalOpType::Fle => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a <= b) - } - ClassicalOpType::Fgt => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a > b) - } - ClassicalOpType::Fge => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - let b = inputs - .get(1) - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - ClassicalValue::Bool(a >= b) - } - - // Conversions - ClassicalOpType::ConvertIntToFloat => { - let a = inputs.first().and_then(ClassicalValue::as_int).unwrap_or(0); - ClassicalValue::Float(a as f64) - } - ClassicalOpType::ConvertFloatToInt => { - let a = inputs - .first() - .and_then(ClassicalValue::as_float) - .unwrap_or(0.0); - // Truncate toward zero, matching standard float-to-int semantics - ClassicalValue::Int(a.trunc() as i64) - } - + // Multi-output / special arms first (they return whole port lists). + match op.op_type { // Constants (shouldn't be processed as operations, but handle anyway) ClassicalOpType::ConstInt | ClassicalOpType::ConstFloat | ClassicalOpType::ConstBool => { - if let Some(value) = &op.const_value { - value.clone() - } else { - return vec![]; - } + return op + .const_value + .as_ref() + .map(|value| vec![(0, value.clone())]) + .unwrap_or_default(); } - - // Tuple operations - these have special return handling ClassicalOpType::MakeTuple => { // MakeTuple combines all inputs into a single tuple - // inputs already collected above return vec![(0, ClassicalValue::Tuple(inputs))]; } ClassicalOpType::UnpackTuple => { @@ -455,10 +132,156 @@ impl HugrEngine { }, )]; } + _ => {} + } + + // Typed extraction for the scalar arms below: a PRESENT but + // unconvertible input is the same hazard as a missing one -- + // defaulting (the old unwrap_or(0/false/0.0)) silently computes on + // fabricated operands, so extraction failure defers the whole op. + let int = |i: usize| inputs.get(i).and_then(ClassicalValue::as_int); + // Unsigned ops reinterpret the stored i64 bit pattern: wrapping + // arithmetic stores results through Int, so as_uint (which rejects + // negatives) would spuriously defer on e.g. u64::MAX. + #[allow(clippy::cast_sign_loss)] + let uint = |i: usize| { + inputs + .get(i) + .and_then(ClassicalValue::as_int) + .map(|v| v as u64) }; + let boolean = |i: usize| inputs.get(i).and_then(ClassicalValue::as_bool); + let float = |i: usize| inputs.get(i).and_then(ClassicalValue::as_float); + // Classified arithmetic.int ops carry their signedness; ops without + // int_info (logic/float/etc.) never consult it. + let signed = op.int_info.is_none_or(|(_, is_signed)| is_signed); + + // Execute the operation + #[allow(clippy::cast_possible_wrap)] + let result: Option = (|| { + Some(match op.op_type { + // Logic operations + ClassicalOpType::And => ClassicalValue::Bool(boolean(0)? && boolean(1)?), + ClassicalOpType::Or => ClassicalValue::Bool(boolean(0)? || boolean(1)?), + ClassicalOpType::Not => ClassicalValue::Bool(!boolean(0)?), + ClassicalOpType::Xor => ClassicalValue::Bool(boolean(0)? ^ boolean(1)?), + ClassicalOpType::Eq => ClassicalValue::Bool(boolean(0)? == boolean(1)?), - // Return output on port 0 - vec![(0, result)] + // Integer arithmetic (add/sub/mul are sign-agnostic modulo 2^64) + ClassicalOpType::Iadd => ClassicalValue::Int(int(0)?.wrapping_add(int(1)?)), + ClassicalOpType::Isub => ClassicalValue::Int(int(0)?.wrapping_sub(int(1)?)), + ClassicalOpType::Imul => ClassicalValue::Int(int(0)?.wrapping_mul(int(1)?)), + ClassicalOpType::Idiv => { + // Division by zero yields 0 (the unchecked op's legacy + // behavior; proper error-Sum modeling for the _checked + // variants is tracked separately). + if signed { + let (a, b) = (int(0)?, int(1)?); + ClassicalValue::Int(if b == 0 { 0 } else { a.wrapping_div(b) }) + } else { + let (a, b) = (uint(0)?, uint(1)?); + ClassicalValue::Int(a.checked_div(b).unwrap_or(0) as i64) + } + } + ClassicalOpType::Imod => { + if signed { + let (a, b) = (int(0)?, int(1)?); + ClassicalValue::Int(if b == 0 { 0 } else { a.wrapping_rem(b) }) + } else { + let (a, b) = (uint(0)?, uint(1)?); + ClassicalValue::Int(a.checked_rem(b).unwrap_or(0) as i64) + } + } + ClassicalOpType::Ineg => ClassicalValue::Int(int(0)?.wrapping_neg()), + ClassicalOpType::Iabs => ClassicalValue::Int(int(0)?.wrapping_abs()), + + // Integer comparisons (ordering is signedness-sensitive) + ClassicalOpType::Ieq => ClassicalValue::Bool(int(0)? == int(1)?), + ClassicalOpType::Ine => ClassicalValue::Bool(int(0)? != int(1)?), + ClassicalOpType::Ilt => ClassicalValue::Bool(if signed { + int(0)? < int(1)? + } else { + uint(0)? < uint(1)? + }), + ClassicalOpType::Ile => ClassicalValue::Bool(if signed { + int(0)? <= int(1)? + } else { + uint(0)? <= uint(1)? + }), + ClassicalOpType::Igt => ClassicalValue::Bool(if signed { + int(0)? > int(1)? + } else { + uint(0)? > uint(1)? + }), + ClassicalOpType::Ige => ClassicalValue::Bool(if signed { + int(0)? >= int(1)? + } else { + uint(0)? >= uint(1)? + }), + + // Integer bitwise operations (sign-agnostic) + ClassicalOpType::Iand => ClassicalValue::Int(int(0)? & int(1)?), + ClassicalOpType::Ior => ClassicalValue::Int(int(0)? | int(1)?), + ClassicalOpType::Ixor => ClassicalValue::Int(int(0)? ^ int(1)?), + ClassicalOpType::Inot => ClassicalValue::Int(!int(0)?), + #[allow(clippy::cast_sign_loss)] + ClassicalOpType::Ishl => { + let shift = int(1)?.clamp(0, 63) as u32; + ClassicalValue::Int(int(0)?.wrapping_shl(shift)) + } + #[allow(clippy::cast_sign_loss)] + ClassicalOpType::Ishr => { + // Arithmetic shift for signed, logical for unsigned + let shift = int(1)?.clamp(0, 63) as u32; + if signed { + ClassicalValue::Int(int(0)?.wrapping_shr(shift)) + } else { + ClassicalValue::Int((uint(0)? >> shift) as i64) + } + } + + // Float arithmetic + ClassicalOpType::Fadd => ClassicalValue::Float(float(0)? + float(1)?), + ClassicalOpType::Fsub => ClassicalValue::Float(float(0)? - float(1)?), + ClassicalOpType::Fmul => ClassicalValue::Float(float(0)? * float(1)?), + ClassicalOpType::Fdiv => ClassicalValue::Float(float(0)? / float(1)?), + ClassicalOpType::Fneg => ClassicalValue::Float(-float(0)?), + ClassicalOpType::Fabs => ClassicalValue::Float(float(0)?.abs()), + ClassicalOpType::Ffloor => ClassicalValue::Float(float(0)?.floor()), + ClassicalOpType::Fceil => ClassicalValue::Float(float(0)?.ceil()), + + // Float comparisons (exact comparison is intentional) + ClassicalOpType::Feq => ClassicalValue::Bool(float(0)? == float(1)?), + ClassicalOpType::Fne => ClassicalValue::Bool(float(0)? != float(1)?), + ClassicalOpType::Flt => ClassicalValue::Bool(float(0)? < float(1)?), + ClassicalOpType::Fle => ClassicalValue::Bool(float(0)? <= float(1)?), + ClassicalOpType::Fgt => ClassicalValue::Bool(float(0)? > float(1)?), + ClassicalOpType::Fge => ClassicalValue::Bool(float(0)? >= float(1)?), + + #[allow(clippy::cast_precision_loss)] + ClassicalOpType::ConvertIntToFloat => ClassicalValue::Float(int(0)? as f64), + #[allow(clippy::cast_possible_truncation)] + ClassicalOpType::ConvertFloatToInt => { + // Truncate toward zero, matching standard float-to-int semantics + ClassicalValue::Int(float(0)?.trunc() as i64) + } + + // Handled by the early match above + ClassicalOpType::ConstInt + | ClassicalOpType::ConstFloat + | ClassicalOpType::ConstBool + | ClassicalOpType::MakeTuple + | ClassicalOpType::UnpackTuple + | ClassicalOpType::TagSum => return None, + }) + })(); + + if let Some(value) = result { + vec![(0, value)] + } else { + debug!("Classical op {node:?}: input type mismatch, deferring"); + vec![] + } } /// Handle `tket.bool` operations. From 41512a5f818a99d486d987a0e5d6161a51efd100 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 15:31:14 -0600 Subject: [PATCH 342/388] Model sum_with_error outputs for checked division, checked modulo, and float truncation instead of collapsing them to raw scalars that consumers misread as tags --- crates/pecos-hugr/src/engine/analysis.rs | 18 ++++- .../src/engine/handlers/classical.rs | 68 +++++++++++++++++++ crates/pecos-hugr/src/engine/types.rs | 8 +++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 399c6786b..84ff374bb 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -621,8 +621,10 @@ pub fn classify_classical_op(op: &OpType) -> Option { "iadd" => (ClassicalOpType::Iadd, 2, 1, Some((6, is_signed))), // default 64-bit "isub" => (ClassicalOpType::Isub, 2, 1, Some((6, is_signed))), "imul" => (ClassicalOpType::Imul, 2, 1, Some((6, is_signed))), - "idiv" | "idiv_checked" => (ClassicalOpType::Idiv, 2, 1, Some((6, is_signed))), + "idiv" => (ClassicalOpType::Idiv, 2, 1, Some((6, is_signed))), + "idiv_checked" => (ClassicalOpType::IdivChecked, 2, 1, Some((6, is_signed))), "imod" => (ClassicalOpType::Imod, 2, 1, Some((6, is_signed))), + "imod_checked" => (ClassicalOpType::ImodChecked, 2, 1, Some((6, is_signed))), "ineg" => (ClassicalOpType::Ineg, 1, 1, Some((6, true))), "iabs" => (ClassicalOpType::Iabs, 1, 1, Some((6, is_signed))), "ieq" => (ClassicalOpType::Ieq, 2, 1, Some((6, is_signed))), @@ -661,7 +663,19 @@ pub fn classify_classical_op(op: &OpType) -> Option { // Conversion extension "arithmetic.conversions" => match op_name.as_str() { "convert_s" | "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, None), - "trunc_s" | "trunc_u" => (ClassicalOpType::ConvertFloatToInt, 1, 1, None), + // trunc_s/trunc_u return sum_with_error(int), not a raw int + "trunc_s" => ( + ClassicalOpType::ConvertFloatToIntChecked, + 1, + 1, + Some((6, true)), + ), + "trunc_u" => ( + ClassicalOpType::ConvertFloatToIntChecked, + 1, + 1, + Some((6, false)), + ), _ => return None, }, // Prelude extension (tuples, etc.) diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index b12a58617..a705b8793 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -192,6 +192,50 @@ impl HugrEngine { ClassicalValue::Int(a.checked_rem(b).unwrap_or(0) as i64) } } + // Checked variants return sum_with_error(int): tag 1 wraps + // the value, tag 0 is the error variant. The error payload + // (a prelude error value) is not modeled -- correct programs + // never take that branch, and one that does stalls loudly on + // the missing payload instead of computing on a fabricated + // value. + ClassicalOpType::IdivChecked => { + let ok = if signed { + let (a, b) = (int(0)?, int(1)?); + (b != 0).then(|| a.wrapping_div(b)) + } else { + let (a, b) = (uint(0)?, uint(1)?); + a.checked_div(b).map(|q| q as i64) + }; + match ok { + Some(q) => ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(q)], + }, + None => ClassicalValue::Sum { + tag: 0, + values: vec![], + }, + } + } + ClassicalOpType::ImodChecked => { + let ok = if signed { + let (a, b) = (int(0)?, int(1)?); + (b != 0).then(|| a.wrapping_rem(b)) + } else { + let (a, b) = (uint(0)?, uint(1)?); + a.checked_rem(b).map(|r| r as i64) + }; + match ok { + Some(r) => ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(r)], + }, + None => ClassicalValue::Sum { + tag: 0, + values: vec![], + }, + } + } ClassicalOpType::Ineg => ClassicalValue::Int(int(0)?.wrapping_neg()), ClassicalOpType::Iabs => ClassicalValue::Int(int(0)?.wrapping_abs()), @@ -265,6 +309,30 @@ impl HugrEngine { // Truncate toward zero, matching standard float-to-int semantics ClassicalValue::Int(float(0)?.trunc() as i64) } + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + ClassicalOpType::ConvertFloatToIntChecked => { + // trunc_s/trunc_u: sum_with_error(int) -- error (tag 0) + // for NaN/infinite/out-of-range, value (tag 1) otherwise. + let f = float(0)?; + let t = f.trunc(); + let in_range = if signed { + t.is_finite() && t >= i64::MIN as f64 && t <= i64::MAX as f64 + } else { + t.is_finite() && t >= 0.0 && t <= u64::MAX as f64 + }; + if in_range { + let bits = if signed { t as i64 } else { (t as u64) as i64 }; + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(bits)], + } + } else { + ClassicalValue::Sum { + tag: 0, + values: vec![], + } + } + } // Handled by the early match above ClassicalOpType::ConstInt diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index 99c4a9219..a4a222bac 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -187,6 +187,11 @@ pub enum ClassicalOpType { Imul, Idiv, Imod, + /// Checked division: `sum_with_error(int)` -- error variant (tag 0) on + /// division by zero, value variant (tag 1) otherwise + IdivChecked, + /// Checked modulo: `sum_with_error(int)` like [`Self::IdivChecked`] + ImodChecked, Ineg, Iabs, // Integer comparisons @@ -231,6 +236,9 @@ pub enum ClassicalOpType { UnpackTuple, // Sum construction (HUGR `Tag` nodes: variants, options, branch selectors) TagSum, + /// Checked float->int truncation: `sum_with_error(int)` -- error variant + /// (tag 0) for NaN/infinite/out-of-range inputs + ConvertFloatToIntChecked, } /// Classical operation extracted from HUGR. From 2ee6ffb4a7d9b418a6d7b854cb74b8238e85db4a Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 15:36:01 -0600 Subject: [PATCH 343/388] Implement borrow_arr pop_left, discard_empty, and new_array, flipping the conditional_x fixture test from a fail-loud stall pin to a clean end-to-end completion test --- crates/pecos-hugr/src/engine.rs | 52 +++++---------- .../src/engine/handlers/borrow_arr.rs | 66 +++++++++++++++++++ 2 files changed, 84 insertions(+), 34 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index d5013f07f..bd3bf42ab 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -3094,23 +3094,17 @@ mod tests { let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); - // This fixture builds borrow arrays and reads them back with - // collections.borrow_arr.pop_left, which the engine does not - // implement yet (its option-Sum result never materializes, so the - // pop guard conditionals stay unresolved). The program stalls - // mid-flight and the engine's completion-time stall detection must - // report that loudly instead of returning silently truncated - // results -- the historical version of this test fed one outcome, - // asserted nothing, and "passed" on the truncation. Flip this to a - // drive-to-completion test when pop_left support lands. + // Drive to completion, feeding one outcome per measurement each + // round (all zeros). This fixture builds borrow arrays in nested + // TailLoops, reads them back with pop_left (option-Sum results), + // and branches on a measurement -- for most of its life it stalled + // mid-flight while the test "passed" on silently truncated output. + // It now pins clean end-to-end execution of the whole combination. let mut stage = engine.start(()).expect("Failed to start engine"); let mut rounds = 0; loop { rounds += 1; - assert!( - rounds <= 20, - "conditional_x should stall or complete quickly" - ); + assert!(rounds <= 20, "conditional_x should complete quickly"); match stage { pecos_engines::EngineStage::NeedsProcessing(msg) => { let ops = msg.quantum_ops().expect("parse quantum ops"); @@ -3126,30 +3120,20 @@ mod tests { let mut builder = ByteMessageBuilder::new(); let _ = builder.for_outcomes(); builder.add_outcomes(&vec![0usize; n_meas]); - match engine.continue_processing(builder.build()) { - Ok(next) => stage = next, - Err(e) => { - let msg = e.to_string(); - assert!( - msg.contains("stalled before completion"), - "expected a stall report, got: {msg}" - ); - assert!( - msg.contains("Conditionals"), - "stall report should name the unresolved pop guards: {msg}" - ); - return; - } - } - } - pecos_engines::EngineStage::Complete(_) => { - panic!( - "conditional_x unexpectedly completed: nested-TailLoop support \ - has landed -- flip this test to assert clean completion" - ); + stage = engine + .continue_processing(builder.build()) + .expect("Failed to continue"); } + pecos_engines::EngineStage::Complete(_) => break, } } + + // Clean completion: no stalled control flow, no starved nodes. + assert!(engine.active_cfgs.is_empty()); + assert!(engine.active_cases.is_empty()); + assert!(engine.active_calls.is_empty()); + assert!(engine.active_tailloops.is_empty()); + assert!(engine.pending_bool_reads.is_empty()); } // --- Integration Tests with Quantum Simulator --- diff --git a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs index cd719fce0..51ff0d11d 100644 --- a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs +++ b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs @@ -227,6 +227,72 @@ impl HugrEngine { .insert((node, 1), ClassicalValue::Array(elements)); true } + "new_array" => { + // [T; n] -> [array]: construct from elements. A missing + // element defers (skipping would shorten the array and shift + // every index); qubit elements ride as QubitRef. + use tket::hugr::ops::OpTrait; + let op = hugr.get_optype(node); + let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); + let mut elements = Vec::with_capacity(num_inputs); + for port in 0..num_inputs { + if let Some(qubit_id) = self.get_input_qubit(hugr, node, port) { + elements.push(ClassicalValue::QubitRef(qubit_id)); + } else if let Some(value) = self.get_input_value(hugr, node, port) { + elements.push(value); + } else { + debug!( + "borrow_arr.new_array at {node:?}: element {port} not ready, deferring" + ); + return false; + } + } + debug!("borrow_arr.new_array: created {} elements", elements.len()); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Array(elements)); + true + } + "pop_left" => { + // [array] -> [Sum([[], [T, array]])]: take the + // first element; the empty variant (tag 0) when nothing is + // left, else (element, rest) in the value variant (tag 1). + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("pop_left at {node:?}: array not ready, deferring"); + return false; + }; + let result = if elements.is_empty() { + debug!("pop_left at {node:?}: empty array -> None variant"); + ClassicalValue::Sum { + tag: 0, + values: vec![], + } + } else { + let element = elements.remove(0); + debug!( + "pop_left at {node:?}: popped element, {} remain", + elements.len() + ); + ClassicalValue::Sum { + tag: 1, + values: vec![element, ClassicalValue::Array(elements)], + } + }; + self.wire_state.classical_values.insert((node, 0), result); + true + } + "discard_empty" => { + // [array<0,T>] -> []: consume an empty array. Defer until + // the value exists so the op is not marked done while its + // producer is pending. + if self.get_input_value(hugr, node, 0).is_none() { + debug!("discard_empty at {node:?}: array not ready, deferring"); + return false; + } + debug!("discard_empty: array consumed"); + true + } "discard_all_borrowed" => { // [array] -> []: consumes the (all-borrowed) array; nothing // to produce. Defer until the array value exists so the op From 62600a2e29cbd4f976630044c76b038b6d163bdd Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 16:14:08 -0600 Subject: [PATCH 344/388] Fold in Codex round-4 findings: Euclidean signed division/modulo per the HUGR spec, strict checked-truncation bounds, UInt-aware unsigned extraction and convert_u signedness, pop_left defers on borrowed holes, delete the Conditional control-value branch shortcut, and pin conditional_x gate counts --- crates/pecos-hugr/src/engine.rs | 12 +++ crates/pecos-hugr/src/engine/analysis.rs | 3 +- .../pecos-hugr/src/engine/control_flow/cfg.rs | 78 ++----------------- .../src/engine/handlers/borrow_arr.rs | 7 ++ .../src/engine/handlers/classical.rs | 53 ++++++++----- 5 files changed, 61 insertions(+), 92 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index bd3bf42ab..e8d95767c 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -3101,6 +3101,7 @@ mod tests { // mid-flight while the test "passed" on silently truncated output. // It now pins clean end-to-end execution of the whole combination. let mut stage = engine.start(()).expect("Failed to start engine"); + let mut gate_counts: BTreeMap = BTreeMap::new(); let mut rounds = 0; loop { rounds += 1; @@ -3108,6 +3109,9 @@ mod tests { match stage { pecos_engines::EngineStage::NeedsProcessing(msg) => { let ops = msg.quantum_ops().expect("parse quantum ops"); + for g in &ops { + *gate_counts.entry(g.gate_type).or_insert(0) += 1; + } let n_meas = ops .iter() .filter(|g| { @@ -3128,6 +3132,14 @@ mod tests { } } + // Non-vacuous: the fixture's gates must actually have been emitted + // (a zero-op run can also reach Complete). With all-zero outcomes + // the measurement selects the else branch: H on the control, two + // measurements, and NO conditional X. + assert_eq!(gate_counts.get(&GateType::H), Some(&1), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::MZ), Some(&2), "{gate_counts:?}"); + assert_eq!(gate_counts.get(&GateType::X), None, "{gate_counts:?}"); + // Clean completion: no stalled control flow, no starved nodes. assert!(engine.active_cfgs.is_empty()); assert!(engine.active_cases.is_empty()); diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 84ff374bb..92f4e495d 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -662,7 +662,8 @@ pub fn classify_classical_op(op: &OpType) -> Option { }, // Conversion extension "arithmetic.conversions" => match op_name.as_str() { - "convert_s" | "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, None), + "convert_s" => (ClassicalOpType::ConvertIntToFloat, 1, 1, Some((6, true))), + "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, Some((6, false))), // trunc_s/trunc_u return sum_with_error(int), not a raw int "trunc_s" => ( ClassicalOpType::ConvertFloatToIntChecked, diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 9e15c6243..3037266ee 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -136,78 +136,12 @@ impl HugrEngine { } } - // Check if the source is a Conditional node (inside the block) - // The Conditional's output is a Sum type - we need to trace its control input - if matches!(src_op, OpType::Conditional(_)) { - debug!( - "[TRACE] Block {block_node:?} output from Conditional {src_node:?}, tracing control input" - ); - // Conditional's control input is port 0 - let control_port = IncomingPort::from(0); - if let Some((ctrl_src_node, ctrl_src_port)) = - hugr.single_linked_output(src_node, control_port) - { - // The control input might be from tket.bool.read - let ctrl_op = hugr.get_optype(ctrl_src_node); - if let Some(ext_op) = ctrl_op.as_extension_op() { - let ext_id = ext_op.extension_id(); - let op_name = ext_op.unqualified_id(); - if ext_id.as_ref() as &str == "tket.bool" && op_name == "read" { - // Trace the bool input to tket.bool.read - let bool_input_port = IncomingPort::from(0); - if let Some((bool_src_node, bool_src_port)) = - hugr.single_linked_output(ctrl_src_node, bool_input_port) - { - let bool_wire = (bool_src_node, bool_src_port.index()); - debug!( - "[TRACE] tket.bool.read input comes from {bool_wire:?}, checking classical_values" - ); - - // First check if we have a classical value for this wire - if let Some(bool_value) = - self.wire_state.classical_values.get(&bool_wire) - && let Some(v) = bool_value.to_u32() - { - debug!( - "[TRACE] Found classical value {v} for Conditional control" - ); - // The bool value (0 or 1) determines which Case - // Case 0 = false, Case 1 = true - // Each Case outputs a Tag that determines the successor - // For while loop: false -> Case 0 -> Tag 0 -> continue - // true -> Case 1 -> Tag 1 -> exit - return Some(v as usize); - } - - // Try to resolve constant bool - if let Some(const_value) = - Self::try_resolve_const_bool(hugr, bool_src_node) - { - debug!( - "CFG block {block_node:?} Conditional control resolved from const: {const_value}" - ); - return Some(usize::from(const_value)); - } - - debug!( - "[TRACE] Could not resolve bool value for wire {bool_wire:?}" - ); - } - } - } - - // Check classical_values for the control wire - let ctrl_wire = (ctrl_src_node, ctrl_src_port.index()); - if let Some(ctrl_value) = self.wire_state.classical_values.get(&ctrl_wire) - && let Some(v) = ctrl_value.to_u32() - { - debug!( - "CFG block {block_node:?} Conditional control from classical value: {v}" - ); - return Some(v as usize); - } - } - } + // A branch Sum produced by a Conditional resolves through the + // VALUE path above once the selected case completes and + // propagates its outputs -- the case's branch Tag need not equal + // the condition, so shortcutting to the Conditional's CONTROL + // value here routed to the wrong successor. Wait for the real + // Sum instead. } None diff --git a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs index 51ff0d11d..fb1824898 100644 --- a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs +++ b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs @@ -269,6 +269,13 @@ impl HugrEngine { values: vec![], } } else { + if matches!(elements[0], ClassicalValue::Borrowed) { + // The front slot is borrowed out: popping the hole + // would silently drop the element when it returns. + // Defer, matching `borrow` on a borrowed slot. + debug!("pop_left at {node:?}: front slot borrowed, deferring"); + return false; + } let element = elements.remove(0); debug!( "pop_left at {node:?}: popped element, {} remain", diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index a705b8793..9f46a4103 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -144,11 +144,9 @@ impl HugrEngine { // arithmetic stores results through Int, so as_uint (which rejects // negatives) would spuriously defer on e.g. u64::MAX. #[allow(clippy::cast_sign_loss)] - let uint = |i: usize| { - inputs - .get(i) - .and_then(ClassicalValue::as_int) - .map(|v| v as u64) + let uint = |i: usize| match inputs.get(i) { + Some(ClassicalValue::UInt(u)) => Some(*u), + other => other.and_then(ClassicalValue::as_int).map(|v| v as u64), }; let boolean = |i: usize| inputs.get(i).and_then(ClassicalValue::as_bool); let float = |i: usize| inputs.get(i).and_then(ClassicalValue::as_float); @@ -172,21 +170,25 @@ impl HugrEngine { ClassicalOpType::Isub => ClassicalValue::Int(int(0)?.wrapping_sub(int(1)?)), ClassicalOpType::Imul => ClassicalValue::Int(int(0)?.wrapping_mul(int(1)?)), ClassicalOpType::Idiv => { - // Division by zero yields 0 (the unchecked op's legacy - // behavior; proper error-Sum modeling for the _checked - // variants is tracked separately). + // Division by zero yields 0 (the spec says the unchecked + // op panics; the engine's panic handling is log-and- + // continue, so 0 is the documented legacy stand-in). + // Signed division is EUCLIDEAN per the HUGR spec + // (idivmod_s: q*m+r=n with 0<=r { + // Euclidean remainder for signed (0 <= r < m), see Idiv. if signed { - let (a, b) = (int(0)?, int(1)?); - ClassicalValue::Int(if b == 0 { 0 } else { a.wrapping_rem(b) }) + let (n, m) = (i128::from(int(0)?), i128::from(uint(1)?)); + ClassicalValue::Int(if m == 0 { 0 } else { n.rem_euclid(m) as i64 }) } else { let (a, b) = (uint(0)?, uint(1)?); ClassicalValue::Int(a.checked_rem(b).unwrap_or(0) as i64) @@ -199,9 +201,10 @@ impl HugrEngine { // the missing payload instead of computing on a fabricated // value. ClassicalOpType::IdivChecked => { + // Signed checked division is Euclidean, like Idiv. let ok = if signed { - let (a, b) = (int(0)?, int(1)?); - (b != 0).then(|| a.wrapping_div(b)) + let (n, m) = (i128::from(int(0)?), i128::from(uint(1)?)); + (m != 0).then(|| n.div_euclid(m) as i64) } else { let (a, b) = (uint(0)?, uint(1)?); a.checked_div(b).map(|q| q as i64) @@ -218,9 +221,10 @@ impl HugrEngine { } } ClassicalOpType::ImodChecked => { + // Euclidean remainder (0 <= r < m), like Imod. let ok = if signed { - let (a, b) = (int(0)?, int(1)?); - (b != 0).then(|| a.wrapping_rem(b)) + let (n, m) = (i128::from(int(0)?), i128::from(uint(1)?)); + (m != 0).then(|| n.rem_euclid(m) as i64) } else { let (a, b) = (uint(0)?, uint(1)?); a.checked_rem(b).map(|r| r as i64) @@ -303,7 +307,11 @@ impl HugrEngine { ClassicalOpType::Fge => ClassicalValue::Bool(float(0)? >= float(1)?), #[allow(clippy::cast_precision_loss)] - ClassicalOpType::ConvertIntToFloat => ClassicalValue::Float(int(0)? as f64), + ClassicalOpType::ConvertIntToFloat => ClassicalValue::Float(if signed { + int(0)? as f64 + } else { + uint(0)? as f64 + }), #[allow(clippy::cast_possible_truncation)] ClassicalOpType::ConvertFloatToInt => { // Truncate toward zero, matching standard float-to-int semantics @@ -315,10 +323,17 @@ impl HugrEngine { // for NaN/infinite/out-of-range, value (tag 1) otherwise. let f = float(0)?; let t = f.trunc(); + // Strict upper bounds: i64::MAX as f64 rounds UP to + // 2^63 (and u64::MAX to 2^64), so `<= MAX as f64` would + // accept one out-of-range value and saturate instead of + // taking the error branch. i64::MIN (-2^63) is exactly + // representable, so >= is correct there. let in_range = if signed { - t.is_finite() && t >= i64::MIN as f64 && t <= i64::MAX as f64 + t.is_finite() + && (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0) + .contains(&t) } else { - t.is_finite() && t >= 0.0 && t <= u64::MAX as f64 + t.is_finite() && (0.0..18_446_744_073_709_551_616.0).contains(&t) }; if in_range { let bits = if signed { t as i64 } else { (t as u64) as i64 }; From d92b68130796033e21c825d94a1f89e536022d4f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 16:33:13 -0600 Subject: [PATCH 345/388] Convert unconditional eprintln debug spam to log-gated debug and pin Euclidean division semantics with an end-to-end guppy test --- crates/pecos-hugr/src/engine.rs | 18 +++------- .../tests/guppy/test_arithmetic_support.py | 34 ++++++++++++++++++- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index e8d95767c..81eab17e5 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -311,7 +311,6 @@ impl HugrEngine { // Extract TailLoop control flow structures self.tailloops = extract_tailloops(&hugr); debug!("Extracted {} TailLoop nodes", self.tailloops.len()); - eprintln!("[DEBUG] Extracted {} TailLoop nodes", self.tailloops.len()); // Track nodes inside TailLoop bodies (should not be processed until loop is active) self.nodes_inside_tailloops = find_nodes_inside_tailloops(&hugr, &self.tailloops); @@ -323,8 +322,8 @@ impl HugrEngine { // Extract quantum operations (but we'll skip case/CFG-internal ones in work queue) self.quantum_ops = extract_quantum_ops(&hugr); debug!("Extracted {} quantum operations", self.quantum_ops.len()); - eprintln!( - "[DEBUG] Extracted {} quantum ops, {} cfgs, {} func_defns, {} call_targets", + debug!( + "Extracted {} quantum ops, {} cfgs, {} func_defns, {} call_targets", self.quantum_ops.len(), self.cfgs.len(), self.func_defns.len(), @@ -619,7 +618,6 @@ impl HugrEngine { if self.work_queue.is_empty() && self.quantum_ops.is_empty() { debug!("Empty HUGR, no commands to generate"); - eprintln!("[DEBUG] Empty HUGR, no commands to generate"); return Ok(None); } @@ -629,10 +627,9 @@ impl HugrEngine { // is a stall, not a finished program. self.ensure_no_stalled_execution()?; debug!("Work queue empty, processing complete"); - eprintln!("[DEBUG] Work queue empty, processing complete"); return Ok(None); } - eprintln!("[DEBUG] Work queue has {} items", self.work_queue.len()); + debug!("Work queue has {} items", self.work_queue.len()); let mut operation_count = 0; let mut hit_measurement = false; @@ -642,7 +639,7 @@ impl HugrEngine { continue; } let node_op = hugr.get_optype(current_node); - eprintln!("[DEBUG] Processing node {current_node:?}: {node_op:?}"); + debug!("Processing node {current_node:?}: {node_op:?}"); // Check batch size if operation_count >= Self::MAX_BATCH_SIZE { @@ -684,10 +681,6 @@ impl HugrEngine { if let Some(cfg_info) = self.cfgs.get(¤t_node).cloned() { debug!("Starting CFG {current_node:?} execution"); debug!("[TRACE] Starting CFG {current_node:?}"); - eprintln!( - "[DEBUG] Starting CFG {current_node:?}, entry_block={:?}", - cfg_info.entry_block - ); // Start CFG execution by activating the entry block's operations let entry_block = cfg_info.entry_block; @@ -993,9 +986,6 @@ impl HugrEngine { } debug!("Processing Call {current_node:?} to FuncDefn {func_defn_node:?}"); - eprintln!( - "[DEBUG] Processing Call {current_node:?} to FuncDefn {func_defn_node:?}" - ); // Check if there's already an active call to this FuncDefn // If so, queue this call to wait diff --git a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py index 332c05683..2409b4aea 100644 --- a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py +++ b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py @@ -2,7 +2,7 @@ import pytest from guppylang import guppy -from guppylang.std.quantum import h, measure, qubit +from guppylang.std.quantum import h, measure, qubit, x from pecos import Guppy, sim from pecos_rslib import state_vector @@ -153,3 +153,35 @@ def quantum_measure_math() -> bool: measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert len(measurements) == 20 # Should have mix unless both m1 and m2 are 0 (25% chance) + + +def test_euclidean_division_semantics() -> None: + """Negative-operand division follows the HUGR spec (Euclidean). + + idivmod_s is defined as q*m+r=n with 0<=r bool: + q = qubit() + a = -3 + if a % 2 == 1: + x(q) + return measure(q) + + @guppy + def euclid_div() -> bool: + q = qubit() + a = -3 + if a // 2 == -2: + x(q) + return measure(q) + + for prog in (euclid_mod, euclid_div): + results = sim(Guppy(prog)).qubits(1).quantum(state_vector()).seed(1).run(3).to_dict() + raw_measurements = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert measurements == [1, 1, 1], f"Euclidean semantics violated: {measurements}" From 7140867245280761c6283e094a3b758d3f2f3389 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 17:35:24 -0600 Subject: [PATCH 346/388] Deep-review handler contract sweep: defer-not-fabricate across arithmetic/array/qsystem/futures/random handlers, spec-shape array ops (option and either Sums), delete shadowed divergent arms, rotate-mod-width, inarrow error-Sums, borrow_arr clone and to_array, unknown ops defer loudly, and re-pin conditional_x at the newly-exposed higher-order scan gap --- crates/pecos-hugr/src/engine.rs | 55 ++-- .../src/engine/handlers/arithmetic.rs | 228 +++++++------- .../pecos-hugr/src/engine/handlers/array.rs | 281 ++++++++++------- .../src/engine/handlers/borrow_arr.rs | 34 +- .../pecos-hugr/src/engine/handlers/futures.rs | 78 ++--- .../pecos-hugr/src/engine/handlers/prelude.rs | 12 +- .../pecos-hugr/src/engine/handlers/qsystem.rs | 295 +++++++++--------- 7 files changed, 538 insertions(+), 445 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 81eab17e5..535295a6c 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -3084,18 +3084,24 @@ mod tests { let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); - // Drive to completion, feeding one outcome per measurement each - // round (all zeros). This fixture builds borrow arrays in nested - // TailLoops, reads them back with pop_left (option-Sum results), - // and branches on a measurement -- for most of its life it stalled - // mid-flight while the test "passed" on silently truncated output. - // It now pins clean end-to-end execution of the whole combination. + // Drive with one outcome per measurement (all zeros). The quantum + // part of this fixture executes correctly (asserted below), but its + // result-reporting tail maps a function over the measured array via + // collections.borrow_arr.scan -- a higher-order op the engine does + // not execute (function-valued arguments). The engine must report + // that as a loud stall rather than pass the scan through as an + // identity wire and hand result_array_bool garbage, which is + // exactly how this test "passed" for most of its life. Flip the + // stall expectation to clean completion when scan support lands. let mut stage = engine.start(()).expect("Failed to start engine"); let mut gate_counts: BTreeMap = BTreeMap::new(); let mut rounds = 0; loop { rounds += 1; - assert!(rounds <= 20, "conditional_x should complete quickly"); + assert!( + rounds <= 20, + "conditional_x should stall or complete quickly" + ); match stage { pecos_engines::EngineStage::NeedsProcessing(msg) => { let ops = msg.quantum_ops().expect("parse quantum ops"); @@ -3114,28 +3120,33 @@ mod tests { let mut builder = ByteMessageBuilder::new(); let _ = builder.for_outcomes(); builder.add_outcomes(&vec![0usize; n_meas]); - stage = engine - .continue_processing(builder.build()) - .expect("Failed to continue"); + match engine.continue_processing(builder.build()) { + Ok(next) => stage = next, + Err(e) => { + let msg = e.to_string(); + assert!( + msg.contains("stalled before completion"), + "expected the unsupported-scan stall, got: {msg}" + ); + break; + } + } + } + pecos_engines::EngineStage::Complete(_) => { + panic!( + "conditional_x unexpectedly completed: borrow_arr.scan support has landed -- flip this test to assert clean completion" + ); } - pecos_engines::EngineStage::Complete(_) => break, } } - // Non-vacuous: the fixture's gates must actually have been emitted - // (a zero-op run can also reach Complete). With all-zero outcomes - // the measurement selects the else branch: H on the control, two - // measurements, and NO conditional X. + // Non-vacuous: the quantum part must have executed before the + // reporting tail stalled. With all-zero outcomes the measurement + // selects the else branch: H on the control, two measurements, and + // NO conditional X. assert_eq!(gate_counts.get(&GateType::H), Some(&1), "{gate_counts:?}"); assert_eq!(gate_counts.get(&GateType::MZ), Some(&2), "{gate_counts:?}"); assert_eq!(gate_counts.get(&GateType::X), None, "{gate_counts:?}"); - - // Clean completion: no stalled control flow, no starved nodes. - assert!(engine.active_cfgs.is_empty()); - assert!(engine.active_cases.is_empty()); - assert!(engine.active_calls.is_empty()); - assert!(engine.active_tailloops.is_empty()); - assert!(engine.pending_bool_reads.is_empty()); } // --- Integration Tests with Quantum Simulator --- diff --git a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs index ed550c476..6d72e9c45 100644 --- a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs +++ b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs @@ -20,7 +20,7 @@ //! - Conversions: int<->float type conversions use log::debug; -use tket::hugr::{Hugr, Node}; +use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; use crate::engine::types::ClassicalValue; @@ -124,13 +124,16 @@ impl HugrEngine { } }; - if let Some(value) = result { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Float(value)); - debug!("arithmetic.float.{op_name}: result = {value}"); - } - + // A missing or unconvertible input defers the op: marking it + // processed with no output would strand every consumer. + let Some(value) = result else { + debug!("arithmetic.float.{op_name} at {node:?}: input not ready, deferring"); + return false; + }; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Float(value)); + debug!("arithmetic.float.{op_name}: result = {value}"); true } @@ -143,61 +146,27 @@ impl HugrEngine { pub(crate) fn handle_int_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { debug!("Processing arithmetic.int operation: {op_name} at {node:?}"); + // Ops with a classify_classical_op entry (add/sub/mul/div/mod/ + // comparisons/bitwise/shifts and their checked variants) never reach + // this handler -- they execute through the classical-op path. Only + // the UNCLASSIFIED arithmetic.int ops live here; keeping shadowed + // duplicate arms around invited semantic drift (they had truncating + // division while the classical path is Euclidean), so they were + // removed. + // Get input values let a = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()); let b = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()); + #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] let result: Option = match op_name { - // Basic arithmetic (may also be handled elsewhere) - "iadd" => a.zip(b).map(|(x, y)| x.wrapping_add(y)), - "isub" => a.zip(b).map(|(x, y)| x.wrapping_sub(y)), - "imul" => a.zip(b).map(|(x, y)| x.wrapping_mul(y)), - "idiv_s" | "idiv" => a.zip(b).map(|(x, y)| if y != 0 { x / y } else { 0 }), - // Cast u64 result to i64 for unified storage - wrap is acceptable for large values - #[allow(clippy::cast_possible_wrap)] - "idiv_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu) - .map(|(x, y)| x.checked_div(y).map_or(0, |q| q as i64)) - } - "imod_s" | "imod" => a.zip(b).map(|(x, y)| if y != 0 { x % y } else { 0 }), - #[allow(clippy::cast_possible_wrap)] - "imod_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu) - .map(|(x, y)| if y != 0 { (x % y) as i64 } else { 0 }) - } - "ineg" => a.map(i64::wrapping_neg), - "iabs" => a.map(i64::abs), - - // Bitwise operations - "iand" => a.zip(b).map(|(x, y)| x & y), - "ior" => a.zip(b).map(|(x, y)| x | y), - "ixor" => a.zip(b).map(|(x, y)| x ^ y), - "inot" => a.map(|x| !x), - - // Shift operations - clamp shift amount to valid range (0-63 for i64) - "ishl" => a.zip(b).map(|(x, y)| x.wrapping_shl(y.clamp(0, 63) as u32)), - "ishr_s" | "ishr" => a.zip(b).map(|(x, y)| x.wrapping_shr(y.clamp(0, 63) as u32)), - #[allow(clippy::cast_possible_wrap)] - "ishr_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - au.zip(b).map(|(x, y)| (x >> y.clamp(0, 63) as u32) as i64) - } - "irotl" | "rotl" => a.zip(b).map(|(x, y)| x.rotate_left(y.clamp(0, 63) as u32)), - "irotr" | "rotr" => a.zip(b).map(|(x, y)| x.rotate_right(y.clamp(0, 63) as u32)), + // Rotations: the amount is reduced modulo the width (64), per + // the spec's const-fold (`k = n1 % w`); clamping mapped + // rotate-by-64 to rotate-by-63 instead of the identity. + "irotl" | "rotl" => a.zip(b).map(|(x, y)| x.rotate_left((y as u64 % 64) as u32)), + "irotr" | "rotr" => a + .zip(b) + .map(|(x, y)| x.rotate_right((y as u64 % 64) as u32)), // Bit counting "ipopcnt" | "popcnt" | "popcount" => a.map(|x| i64::from(x.count_ones())), @@ -228,69 +197,29 @@ impl HugrEngine { au.zip(bu).map(|(x, y)| x.max(y) as i64) } - // Signedness reinterpretation - value-preserving for the in-range - // values guppy emits (usize indices/counters) + // Width widening: no-op for the 64-bit unified storage #[allow(clippy::match_same_arms)] - "iu_to_s" => a, // as_int already converts stored UInt values - #[allow(clippy::match_same_arms)] - "is_to_u" => a, - - // Sign extension / truncation - all no-ops for i64 unified storage - #[allow(clippy::match_same_arms)] // Intentionally separate for clarity - "iwiden_s" | "widen_s" => a, // Sign-extend (no-op for i64) + "iwiden_s" | "widen_s" => a, #[allow(clippy::cast_possible_wrap)] "iwiden_u" | "widen_u" => self .get_input_value(hugr, node, 0) .and_then(|v| v.as_uint()) .map(|x| x as i64), + + // Width narrowing returns sum_with_error(int): handled below + // (multi-variant output, not a plain i64). + "inarrow_s" | "narrow_s" | "inarrow_u" | "narrow_u" => { + return self.handle_inarrow(hugr, node, op_name); + } + + // Signedness reinterpretation - value-preserving for the + // in-range values guppy emits (usize indices/counters). The spec + // panics out of range; the engine passes the bit pattern through + // (documented divergence, tracked as a follow-up). #[allow(clippy::match_same_arms)] - "inarrow_s" | "narrow_s" => a, // Truncate (no-op for now) + "iu_to_s" => a, #[allow(clippy::match_same_arms)] - "inarrow_u" | "narrow_u" => a, // Truncate (no-op for now) - - // Comparisons (return 0 or 1) - "ieq" => a.zip(b).map(|(x, y)| i64::from(x == y)), - "ine" => a.zip(b).map(|(x, y)| i64::from(x != y)), - "ilt_s" | "ilt" => a.zip(b).map(|(x, y)| i64::from(x < y)), - "ile_s" | "ile" => a.zip(b).map(|(x, y)| i64::from(x <= y)), - "igt_s" | "igt" => a.zip(b).map(|(x, y)| i64::from(x > y)), - "ige_s" | "ige" => a.zip(b).map(|(x, y)| i64::from(x >= y)), - "ilt_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| i64::from(x < y)) - } - "ile_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| i64::from(x <= y)) - } - "igt_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| i64::from(x > y)) - } - "ige_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| i64::from(x >= y)) - } + "is_to_u" => a, _ => { debug!("Unknown arithmetic.int operation: {op_name}"); @@ -298,13 +227,70 @@ impl HugrEngine { } }; - if let Some(value) = result { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(value)); - debug!("arithmetic.int.{op_name}: result = {value}"); - } + // A missing or unconvertible input defers the op: marking it + // processed with no output would strand every consumer (the node is + // never retried once processed). + let Some(value) = result else { + debug!("arithmetic.int.{op_name} at {node:?}: input not ready, deferring"); + return false; + }; + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Int(value)); + debug!("arithmetic.int.{op_name}: result = {value}"); + true + } + /// `inarrow_s`/`inarrow_u`: narrow to width `N`, returning + /// `sum_with_error(int)` -- error variant (tag 0) when the value does + /// not fit, value variant (tag 1) otherwise. + fn handle_inarrow(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + let signed = !op_name.ends_with('u'); + // Target log-width is the SECOND type arg (source is the first). + let target_log_width = hugr + .get_optype(node) + .as_extension_op() + .and_then(|ext| { + ext.args() + .iter() + .filter_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => Some(*n), + _ => None, + }) + .nth(1) + }) + .unwrap_or(6); + let bits = 1u32 << target_log_width.min(6); + let Some(v) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) else { + debug!("arithmetic.int.{op_name} at {node:?}: input not ready, deferring"); + return false; + }; + let fits = if signed { + if bits >= 64 { + true + } else { + let min = -(1i64 << (bits - 1)); + let max = (1i64 << (bits - 1)) - 1; + v >= min && v <= max + } + } else if bits >= 64 { + v >= 0 + } else { + v >= 0 && v < (1i64 << bits) + }; + let result = if fits { + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(v)], + } + } else { + ClassicalValue::Sum { + tag: 0, + values: vec![], + } + }; + debug!("arithmetic.int.{op_name}: {result:?}"); + self.wire_state.classical_values.insert((node, 0), result); true } @@ -386,7 +372,7 @@ impl HugrEngine { } // Float to integer conversions (truncate toward zero) - "trunc_s" | "ftoi_s" => { + "ftoi_s" => { // Float to signed integer (truncate) let Some(value) = self .get_input_value(hugr, node, 0) @@ -401,7 +387,7 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Int(result)); debug!("trunc_s: {value} -> {result}"); } - "trunc_u" | "ftoi_u" => { + "ftoi_u" => { // Float to unsigned integer (truncate) let Some(value) = self .get_input_value(hugr, node, 0) diff --git a/crates/pecos-hugr/src/engine/handlers/array.rs b/crates/pecos-hugr/src/engine/handlers/array.rs index 4237a4f33..d1ba4be85 100644 --- a/crates/pecos-hugr/src/engine/handlers/array.rs +++ b/crates/pecos-hugr/src/engine/handlers/array.rs @@ -14,10 +14,21 @@ //! Array operations (`collections.array`). //! -//! This module handles array collection operations: -//! - Creation: `new_array`, `repeat` -//! - Access: `get`, `set`, `len` -//! - Modification: `push`, `pop`, `swap` +//! Implements the HUGR std array ops with their spec signatures +//! (`hugr-core` `std_extensions/collections/array/array_op.rs`): +//! - `new_array`: `[T; n] -> [array]` +//! - `unpack`: `[array] -> [T; n]` +//! - `get`: `[array, usize] -> [option, array]` +//! - `set`: `[array, usize, T] -> [either([T, array], [T, array])]` +//! (tag 1 = success carrying the displaced element; tag 0 = out-of-bounds +//! carrying the given element back so linear values are not lost) +//! - `swap`: `[array, usize, usize] -> [either([array], [array])]` +//! - `pop_left`/`pop_right`: `[array] -> [option<(T, array)>]` +//! - `discard_empty`: `[array<0, T>] -> []` +//! +//! Handlers defer (return false) on missing inputs so consumers never see +//! fabricated arrays or elements; unknown ops also defer so they surface in +//! the completion-time stall report instead of silently passing through. use log::debug; use tket::hugr::ops::OpTrait; @@ -37,13 +48,11 @@ impl HugrEngine { match op_name { "new_array" | "NewArray" => { - // new_array: (T, ...) -> Array - // Collect all inputs into an array + // [T; n] -> [array]. A missing element defers the whole + // construction: silently skipping it would shorten the array + // and shift every index. Qubit elements ride as QubitRef. let op = hugr.get_optype(node); let num_inputs = op.dataflow_signature().map_or(0, |sig| sig.input_count()); - - // A missing element defers the whole construction: silently - // skipping it would shorten the array and shift every index. let mut elements = Vec::with_capacity(num_inputs); for port in 0..num_inputs { if let Some(value) = self.get_input_value(hugr, node, port) { @@ -62,15 +71,32 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Array(elements)); true } + "unpack" => { + // [array] -> [T; n]: each element on its own output port. + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.unpack at {node:?}: array not ready, deferring"); + return false; + }; + for (port, value) in elements.into_iter().enumerate() { + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state + .wire_to_qubit + .insert((node, port), *qubit_id); + } + self.wire_state.classical_values.insert((node, port), value); + } + debug!("array.unpack at {node:?}: unpacked"); + true + } "get" | "Get" | "index" | "Index" => { - // get: (Array, int) -> T - // Get element at index + // [array, usize] -> [option, array]: option on port 0 + // (None = tag 0 for out-of-bounds), the array back on port 1. let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) else { debug!("array.get at {node:?}: array not ready, deferring"); return false; }; - #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) @@ -80,31 +106,33 @@ impl HugrEngine { return false; }; - let Some(element) = elements.get(index) else { - debug!( - "array.get at {node:?}: index {index} out of bounds (len={}), deferring", - elements.len() - ); - return false; + let result = match elements.get(index) { + Some(element) => ClassicalValue::Sum { + tag: 1, + values: vec![element.clone()], + }, + None => ClassicalValue::Sum { + tag: 0, + values: vec![], + }, }; - if let ClassicalValue::QubitRef(qubit_id) = element { - self.wire_state.wire_to_qubit.insert((node, 0), *qubit_id); - } + debug!("array.get[{index}]: {result:?}"); + self.wire_state.classical_values.insert((node, 0), result); self.wire_state .classical_values - .insert((node, 0), element.clone()); - debug!("array.get[{index}]: retrieved element"); + .insert((node, 1), ClassicalValue::Array(elements)); true } "set" | "Set" => { - // set: (Array, int, T) -> Array - // Set element at index + // [array, usize, T] -> [either([T, array], [T, array])]: + // tag 1 = success carrying the DISPLACED element and the + // updated array; tag 0 = out-of-bounds carrying the given + // element and the unchanged array (linear values survive). let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) else { debug!("array.set at {node:?}: array not ready, deferring"); return false; }; - #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize let Some(index) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) @@ -113,117 +141,130 @@ impl HugrEngine { debug!("array.set at {node:?}: index not ready, deferring"); return false; }; - let Some(new_value) = self.get_input_value(hugr, node, 2) else { + let new_value = if let Some(qubit_id) = self.get_input_qubit(hugr, node, 2) { + ClassicalValue::QubitRef(qubit_id) + } else if let Some(value) = self.get_input_value(hugr, node, 2) { + value + } else { debug!("array.set at {node:?}: value not ready, deferring"); return false; }; - let Some(slot) = elements.get_mut(index) else { - debug!( - "array.set at {node:?}: index {index} out of bounds (len={}), deferring", - elements.len() - ); - return false; - }; - *slot = new_value; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.set[{index}]: updated element"); - true - } - "len" | "Len" | "length" | "Length" => { - // len: Array -> int - // Get array length - let array = self.get_input_value(hugr, node, 0); - - if let Some(ClassicalValue::Array(elements)) = array { - let len = elements.len() as u64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(len)); - debug!("array.len: {len}"); - } - true - } - "pop" | "Pop" => { - // pop: Array -> (Array, T) - // Remove and return the last element - let array = self.get_input_value(hugr, node, 0); - - if let Some(ClassicalValue::Array(mut elements)) = array - && let Some(last) = elements.pop() - { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - self.wire_state.classical_values.insert((node, 1), last); - debug!("array.pop: removed last element"); - } - true - } - "push" | "Push" => { - // push: (Array, T) -> Array - // Append element to array - let array = self.get_input_value(hugr, node, 0); - let value = self.get_input_value(hugr, node, 1); - - if let (Some(ClassicalValue::Array(mut elements)), Some(new_value)) = (array, value) - { - elements.push(new_value); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.push: appended element"); - } - true - } - "repeat" | "Repeat" => { - // repeat: (T, int) -> Array - // Create array with n copies of value - let value = self.get_input_value(hugr, node, 0); - let count = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; - if let Some(val) = value { - let elements = vec![val; count]; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.repeat: created array with {count} copies"); - } + let result = if let Some(slot) = elements.get_mut(index) { + let displaced = std::mem::replace(slot, new_value); + debug!("array.set[{index}]: element replaced"); + ClassicalValue::Sum { + tag: 1, + values: vec![displaced, ClassicalValue::Array(elements)], + } + } else { + debug!("array.set[{index}]: out of bounds (len={})", elements.len()); + ClassicalValue::Sum { + tag: 0, + values: vec![new_value, ClassicalValue::Array(elements)], + } + }; + self.wire_state.classical_values.insert((node, 0), result); true } "swap" | "Swap" => { - // swap: (Array, int, int) -> Array - // Swap elements at two indices - let array = self.get_input_value(hugr, node, 0); - let i = self + // [array, usize, usize] -> [either([array], [array])]: + // tag 1 = success with the swapped array; tag 0 = + // out-of-bounds with the unchanged array. + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.swap at {node:?}: array not ready, deferring"); + return false; + }; + let Some(i) = self .get_input_value(hugr, node, 1) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; - let j = self + .map(|v| v as usize) + else { + debug!("array.swap at {node:?}: first index not ready, deferring"); + return false; + }; + let Some(j) = self .get_input_value(hugr, node, 2) .and_then(|v| v.as_uint()) - .unwrap_or(0) as usize; + .map(|v| v as usize) + else { + debug!("array.swap at {node:?}: second index not ready, deferring"); + return false; + }; - if let Some(ClassicalValue::Array(mut elements)) = array { - if i < elements.len() && j < elements.len() { - elements.swap(i, j); + let result = if i < elements.len() && j < elements.len() { + elements.swap(i, j); + debug!("array.swap[{i},{j}]: swapped"); + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Array(elements)], + } + } else { + debug!( + "array.swap[{i},{j}]: out of bounds (len={})", + elements.len() + ); + ClassicalValue::Sum { + tag: 0, + values: vec![ClassicalValue::Array(elements)], + } + }; + self.wire_state.classical_values.insert((node, 0), result); + true + } + "pop_left" | "pop_right" => { + // [array] -> [option<(T, array)>]: the empty + // variant (tag 0) when nothing is left, else (element, rest) + // in the value variant (tag 1). + let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) + else { + debug!("array.{op_name} at {node:?}: array not ready, deferring"); + return false; + }; + let result = if elements.is_empty() { + debug!("array.{op_name} at {node:?}: empty array -> None variant"); + ClassicalValue::Sum { + tag: 0, + values: vec![], } - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Array(elements)); - debug!("array.swap[{i}, {j}]"); + } else { + let element = if op_name == "pop_left" { + elements.remove(0) + } else { + elements.pop().expect("non-empty checked above") + }; + debug!( + "array.{op_name} at {node:?}: popped element, {} remain", + elements.len() + ); + ClassicalValue::Sum { + tag: 1, + values: vec![element, ClassicalValue::Array(elements)], + } + }; + self.wire_state.classical_values.insert((node, 0), result); + true + } + "discard_empty" => { + // [array<0, T>] -> []: consume an empty array. Defer until + // the value exists so the op is not marked done while its + // producer is pending. + if self.get_input_value(hugr, node, 0).is_none() { + debug!("array.discard_empty at {node:?}: array not ready, deferring"); + return false; } + debug!("array.discard_empty: array consumed"); true } _ => { - // For unknown array operations, try pass-through - debug!("Unknown collections.array operation: {op_name} - attempting pass-through"); - self.propagate_all_inputs(hugr, node); - true + // Unknown/unimplemented array op (e.g. `repeat`, whose real + // signature takes a function value the engine cannot + // execute): defer so it surfaces in the completion-time + // stall report instead of silently passing values through + // as if the op were an identity wire. + debug!("Unknown collections.array operation: {op_name} at {node:?}, deferring"); + false } } } diff --git a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs index fb1824898..d6dcd79e9 100644 --- a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs +++ b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs @@ -300,6 +300,32 @@ impl HugrEngine { debug!("discard_empty: array consumed"); true } + "clone" => { + // [array] -> [array, array] (copyable elements): duplicate. + let Some(value @ ClassicalValue::Array(_)) = self.get_input_value(hugr, node, 0) + else { + debug!("borrow_arr.clone at {node:?}: array not ready, deferring"); + return false; + }; + self.wire_state + .classical_values + .insert((node, 0), value.clone()); + self.wire_state.classical_values.insert((node, 1), value); + debug!("borrow_arr.clone at {node:?}: duplicated"); + true + } + "to_array" | "from_array" => { + // borrow_array <-> array conversions: identity on the + // engine's shared Array representation. + let Some(value @ ClassicalValue::Array(_)) = self.get_input_value(hugr, node, 0) + else { + debug!("borrow_arr.{op_name} at {node:?}: array not ready, deferring"); + return false; + }; + self.wire_state.classical_values.insert((node, 0), value); + debug!("borrow_arr.{op_name} at {node:?}: converted"); + true + } "discard_all_borrowed" => { // [array] -> []: consumes the (all-borrowed) array; nothing // to produce. Defer until the array value exists so the op @@ -312,12 +338,12 @@ impl HugrEngine { true } _ => { - // For unknown borrow_arr operations, try pass-through + // Unknown op: defer so it surfaces in the stall report + // instead of silently passing values through. debug!( - "Unknown collections.borrow_arr operation: {op_name} - attempting pass-through" + "Unknown collections.borrow_arr operation: {op_name} at {node:?}, deferring" ); - self.propagate_all_inputs(hugr, node); - true + false } } } diff --git a/crates/pecos-hugr/src/engine/handlers/futures.rs b/crates/pecos-hugr/src/engine/handlers/futures.rs index 3630d0ae5..ea9686db8 100644 --- a/crates/pecos-hugr/src/engine/handlers/futures.rs +++ b/crates/pecos-hugr/src/engine/handlers/futures.rs @@ -33,51 +33,57 @@ impl HugrEngine { match op_name { "Read" => { - // Read: Future -> T - // Resolve the Future to its value - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::Future(future_id) = value - && let Some(state) = self.extension_state.futures.get(&future_id) - { - match state { - FutureState::Resolved(outcome) => { - // Future is resolved, output the value + // Read: Future -> T. Every unresolved shape defers: a + // missing input, a non-Future value, an unknown future id, + // or a pending measurement all mean the value does not exist + // yet -- returning handled would mark the node processed + // with no output and strand every consumer. + let Some(ClassicalValue::Future(future_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("futures.Read at {node:?}: future not ready, deferring"); + return false; + }; + let Some(state) = self.extension_state.futures.get(&future_id) else { + debug!("futures.Read at {node:?}: unknown future {future_id}, deferring"); + return false; + }; + match state { + FutureState::Resolved(outcome) => { + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Bool(*outcome != 0)); + debug!("Read future {future_id} -> {outcome}"); + true + } + FutureState::Pending { + measurement_index, .. + } => { + if let Some((_, qubit)) = + self.measurement_state.mappings.get(*measurement_index) + && let Some(&result) = self.measurement_state.results.get(qubit) + { self.wire_state .classical_values - .insert((node, 0), ClassicalValue::Bool(*outcome != 0)); - debug!("Read future {future_id} -> {outcome}"); - } - FutureState::Pending { - measurement_index, .. - } => { - // Check if measurement result is available - if let Some((_, qubit)) = - self.measurement_state.mappings.get(*measurement_index) - { - if let Some(&result) = self.measurement_state.results.get(qubit) { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Bool(result != 0)); - debug!("Read future {future_id} from measurement -> {result}"); - } else { - // Result not yet available: defer -- a - // fabricated `false` commits a wrong - // measurement value downstream. Retried - // when measurement results arrive. - debug!("Read future {future_id} pending, deferring"); - return false; - } - } + .insert((node, 0), ClassicalValue::Bool(result != 0)); + debug!("Read future {future_id} from measurement -> {result}"); + true + } else { + // Result not yet available: defer -- retried + // when measurement results arrive. + debug!("Read future {future_id} pending, deferring"); + false } } } - true } "Dup" => { // Dup: Future -> (Future, Future) // Create two new Futures pointing to the same result - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::Future(original_id) = value + let Some(ClassicalValue::Future(original_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("futures.Dup at {node:?}: future not ready, deferring"); + return false; + }; { // Create two new Future IDs that share the same state let new_id1 = self.extension_state.next_future_id; diff --git a/crates/pecos-hugr/src/engine/handlers/prelude.rs b/crates/pecos-hugr/src/engine/handlers/prelude.rs index 25be9c73d..db606bd90 100644 --- a/crates/pecos-hugr/src/engine/handlers/prelude.rs +++ b/crates/pecos-hugr/src/engine/handlers/prelude.rs @@ -176,12 +176,18 @@ impl HugrEngine { } } - _ => { - debug!("Unknown prelude operation: {op_name}"); - // For unknown ops, try to propagate inputs as a pass-through + "Noop" | "Lift" | "Barrier" => { + // Genuine identity/annotation ops: pass values through. self.propagate_all_inputs(hugr, node); true } + _ => { + // Unknown op: defer so it surfaces in the completion-time + // stall report -- treating an op the engine knows nothing + // about as an identity wire fabricates semantics. + debug!("Unknown prelude operation: {op_name} at {node:?}, deferring"); + false + } } } } diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index 7d6fe50c8..ee4de44cf 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -39,111 +39,119 @@ impl HugrEngine { "LazyMeasure" => { // LazyMeasure: Qubit -> Future // Queue the measurement and create a Future handle - if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { - // Queue measurement - self.message_builder.mz(&[qubit_id.0]); - let measurement_index = self.measurement_state.mappings.len(); - self.measurement_state.mappings.push((node, qubit_id)); - - // Create a Future - let future_id = self.extension_state.next_future_id; - self.extension_state.next_future_id += 1; - self.extension_state.futures.insert( - future_id, - FutureState::Pending { - measurement_node: node, - qubit: qubit_id, - measurement_index, - }, - ); - - // Store Future value on output port 0 - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Future(future_id)); + let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { + debug!("LazyMeasure at {node:?}: qubit not resolved, deferring"); + return false; + }; + // Queue measurement + self.message_builder.mz(&[qubit_id.0]); + let measurement_index = self.measurement_state.mappings.len(); + self.measurement_state.mappings.push((node, qubit_id)); + + // Create a Future + let future_id = self.extension_state.next_future_id; + self.extension_state.next_future_id += 1; + self.extension_state.futures.insert( + future_id, + FutureState::Pending { + measurement_node: node, + qubit: qubit_id, + measurement_index, + }, + ); + + // Store Future value on output port 0 + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Future(future_id)); - debug!("LazyMeasure on qubit {qubit_id:?}, created future {future_id}"); - } + debug!("LazyMeasure on qubit {qubit_id:?}, created future {future_id}"); true } "LazyMeasureReset" => { // LazyMeasureReset: Qubit -> (Qubit, Future) - if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { - // Queue measurement - self.message_builder.mz(&[qubit_id.0]); - let measurement_index = self.measurement_state.mappings.len(); - self.measurement_state.mappings.push((node, qubit_id)); - - // Queue reset - self.message_builder.pz(&[qubit_id.0]); - - // Create a Future - let future_id = self.extension_state.next_future_id; - self.extension_state.next_future_id += 1; - self.extension_state.futures.insert( - future_id, - FutureState::Pending { - measurement_node: node, - qubit: qubit_id, - measurement_index, - }, - ); - - // Output port 0: qubit, Output port 1: Future - self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - self.wire_state - .classical_values - .insert((node, 1), ClassicalValue::Future(future_id)); + let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { + debug!("LazyMeasureReset at {node:?}: qubit not resolved, deferring"); + return false; + }; + // Queue measurement + self.message_builder.mz(&[qubit_id.0]); + let measurement_index = self.measurement_state.mappings.len(); + self.measurement_state.mappings.push((node, qubit_id)); + + // Queue reset + self.message_builder.pz(&[qubit_id.0]); + + // Create a Future + let future_id = self.extension_state.next_future_id; + self.extension_state.next_future_id += 1; + self.extension_state.futures.insert( + future_id, + FutureState::Pending { + measurement_node: node, + qubit: qubit_id, + measurement_index, + }, + ); + + // Output port 0: qubit, Output port 1: Future + self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Future(future_id)); - debug!("LazyMeasureReset on qubit {qubit_id:?}, created future {future_id}"); - } + debug!("LazyMeasureReset on qubit {qubit_id:?}, created future {future_id}"); true } "LazyMeasureLeaked" => { // LazyMeasureLeaked: Qubit -> Future // Same as LazyMeasure but result can be 0, 1, or 2 (leaked) - if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { - self.message_builder.mz(&[qubit_id.0]); - let measurement_index = self.measurement_state.mappings.len(); - self.measurement_state.mappings.push((node, qubit_id)); - - let future_id = self.extension_state.next_future_id; - self.extension_state.next_future_id += 1; - self.extension_state.futures.insert( - future_id, - FutureState::Pending { - measurement_node: node, - qubit: qubit_id, - measurement_index, - }, - ); + let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { + debug!("LazyMeasureLeaked at {node:?}: qubit not resolved, deferring"); + return false; + }; + self.message_builder.mz(&[qubit_id.0]); + let measurement_index = self.measurement_state.mappings.len(); + self.measurement_state.mappings.push((node, qubit_id)); + + let future_id = self.extension_state.next_future_id; + self.extension_state.next_future_id += 1; + self.extension_state.futures.insert( + future_id, + FutureState::Pending { + measurement_node: node, + qubit: qubit_id, + measurement_index, + }, + ); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Future(future_id)); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Future(future_id)); - debug!("LazyMeasureLeaked on qubit {qubit_id:?}, created future {future_id}"); - } + debug!("LazyMeasureLeaked on qubit {qubit_id:?}, created future {future_id}"); true } "MeasureReset" => { // MeasureReset: Qubit -> (Qubit, bool) // Atomic measure + reset (not lazy) - if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { - self.message_builder.mz(&[qubit_id.0]); - self.measurement_state.mappings.push((node, qubit_id)); + let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { + debug!("MeasureReset at {node:?}: qubit not resolved, deferring"); + return false; + }; + self.message_builder.mz(&[qubit_id.0]); + self.measurement_state.mappings.push((node, qubit_id)); - // Queue reset - self.message_builder.pz(&[qubit_id.0]); + // Queue reset + self.message_builder.pz(&[qubit_id.0]); - // Track measurement output wire - self.measurement_state.output_wires.insert(node, (node, 1)); + // Track measurement output wire + self.measurement_state.output_wires.insert(node, (node, 1)); - // Output port 0: qubit - self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); + // Output port 0: qubit + self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - debug!("MeasureReset on qubit {qubit_id:?}"); - } + debug!("MeasureReset on qubit {qubit_id:?}"); true } "RuntimeBarrier" | "StateResult" => { @@ -191,10 +199,13 @@ impl HugrEngine { "NewRNGContext" => { // NewRNGContext: int<64> -> RNGContext // Create a new RNG context with the given seed - let seed = self + let Some(seed) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_uint()) - .unwrap_or(0); + else { + debug!("NewRNGContext at {node:?}: seed not ready, deferring"); + return false; + }; let ctx_id = self.extension_state.next_rng_context_id; self.extension_state.next_rng_context_id += 1; @@ -213,86 +224,92 @@ impl HugrEngine { "DeleteRNGContext" => { // DeleteRNGContext: RNGContext -> () // Clean up an RNG context - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::RngContext(ctx_id) = value - { - self.extension_state.rng_contexts.remove(&ctx_id); - debug!("DeleteRNGContext: removed context {ctx_id}"); - } + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("DeleteRNGContext at {node:?}: context not ready, deferring"); + return false; + }; + self.extension_state.rng_contexts.remove(&ctx_id); + debug!("DeleteRNGContext: removed context {ctx_id}"); true } "RandomFloat" => { // RandomFloat: RNGContext -> (RNGContext, float64) // Generate a random float in [0, 1) - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::RngContext(ctx_id) = value - { - let random_float = self.generate_random_float(ctx_id); - - // Output port 0: RNGContext (pass through) - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); - // Output port 1: random float - self.wire_state - .classical_values - .insert((node, 1), ClassicalValue::Float(random_float)); + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("RandomFloat at {node:?}: context not ready, deferring"); + return false; + }; + let random_float = self.generate_random_float(ctx_id); + + // Output port 0: RNGContext (pass through) + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::RngContext(ctx_id)); + // Output port 1: random float + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Float(random_float)); - debug!("RandomFloat: generated {random_float}"); - } + debug!("RandomFloat: generated {random_float}"); true } "RandomInt" => { // RandomInt: RNGContext -> (RNGContext, int<32>) // Generate a random 32-bit integer - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::RngContext(ctx_id) = value - { - let random_int = self.generate_random_u64(ctx_id) as i64; + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("RandomInt at {node:?}: context not ready, deferring"); + return false; + }; + let random_int = self.generate_random_u64(ctx_id) as i64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); - self.wire_state - .classical_values - .insert((node, 1), ClassicalValue::Int(random_int)); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::RngContext(ctx_id)); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Int(random_int)); - debug!("RandomInt: generated {random_int}"); - } + debug!("RandomInt: generated {random_int}"); true } "RandomIntBounded" => { // RandomIntBounded: (RNGContext, int<32>) -> (RNGContext, int<32>) // Generate a random integer in [0, bound) - let ctx_value = self.get_input_value(hugr, node, 0); - let bound = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_int()) - .unwrap_or(1) - .max(1) as u64; - - if let Some(ClassicalValue::RngContext(ctx_id)) = ctx_value { - let random_val = self.generate_random_u64(ctx_id) % bound; + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("RandomIntBounded at {node:?}: context not ready, deferring"); + return false; + }; + let Some(bound) = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()) + else { + debug!("RandomIntBounded at {node:?}: bound not ready, deferring"); + return false; + }; + let bound = bound.max(1) as u64; + let random_val = self.generate_random_u64(ctx_id) % bound; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); - self.wire_state - .classical_values - .insert((node, 1), ClassicalValue::Int(random_val as i64)); + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::RngContext(ctx_id)); + self.wire_state + .classical_values + .insert((node, 1), ClassicalValue::Int(random_val as i64)); - debug!("RandomIntBounded({bound}): generated {random_val}"); - } + debug!("RandomIntBounded({bound}): generated {random_val}"); true } "RandomAdvance" => { // RandomAdvance: (RNGContext, int<64>) -> RNGContext // Advance the RNG state by delta steps (can be negative for backtracking) let ctx_value = self.get_input_value(hugr, node, 0); - let delta = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_int()) - .unwrap_or(0); + let Some(delta) = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()) + else { + debug!("RandomAdvance at {node:?}: delta not ready, deferring"); + return false; + }; if let Some(ClassicalValue::RngContext(ctx_id)) = ctx_value { // Advance the RNG state by |delta| steps From 5ef6d95bc22127056513803117018cb2192c2f19 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 18:26:17 -0600 Subject: [PATCH 347/388] Complete no-CFG passthrough calls properly and error on non-passthrough plain bodies, defer unparseable LoadConstants (rotation constants now parse), and run case-completion hooks after Call and TailLoop completion --- crates/pecos-hugr/src/engine.rs | 69 ++++++++++++++----- .../src/engine/control_flow/call.rs | 5 ++ .../src/engine/control_flow/tailloop.rs | 3 + crates/pecos-hugr/src/engine/propagation.rs | 7 ++ 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 535295a6c..5073d6770 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1134,25 +1134,55 @@ impl HugrEngine { if !self.work_queue.contains(&cfg_node) { self.work_queue.push_front(cfg_node); } - } else { - debug!("Call {current_node:?}: FuncDefn has no CFG, passing through"); - // No CFG - just pass through qubits (identity function) - for port in 0..func_info.num_outputs { - let func_input_wire = (func_info.input_node, port); - if let Some(&qubit_id) = - self.wire_state.wire_to_qubit.get(&func_input_wire) - { - let call_output_wire = (current_node, port); - self.wire_state - .wire_to_qubit - .insert(call_output_wire, qubit_id); - } + // Don't mark Call as processed yet - wait for the + // FuncDefn's CFG to complete; the Call is completed + // in complete_func_call_if_needed. + continue; + } + + // No CFG: the body is a plain dataflow region. The + // engine only executes CFG-bodied functions, so anything + // beyond a pure Input->Output passthrough is + // unsupported -- fail loud rather than leaving the Call + // stranded outside every completion and stall check + // (which silently truncates downstream results). + debug!("Call {current_node:?}: FuncDefn has no CFG, treating as passthrough"); + for port in 0..func_info.num_outputs { + let out_port = IncomingPort::from(port); + let Some((src_node, src_port)) = + hugr.single_linked_output(func_info.output_node, out_port) + else { + continue; + }; + if src_node != func_info.input_node { + return Err(PecosError::Generic(format!( + "Call {current_node:?} targets FuncDefn {func_defn_node:?} with no CFG and a non-passthrough body (output {port} fed by {src_node:?}); plain dataflow function bodies are not supported by the HUGR engine" + ))); + } + let func_input_wire = (func_info.input_node, src_port.index()); + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&func_input_wire) + { + self.wire_state + .wire_to_qubit + .insert((current_node, port), qubit_id); + } + if let Some(value) = self + .wire_state + .classical_values + .get(&func_input_wire) + .cloned() + { + self.wire_state + .classical_values + .insert((current_node, port), value); } } + self.processed.insert(current_node); + self.check_case_completion(&hugr, current_node); + self.check_cfg_block_completion(&hugr, current_node); + self.check_tailloop_body_completion(&hugr, current_node); + self.queue_ready_successors(&hugr, current_node); } - - // Don't mark Call as processed yet - wait for FuncDefn to complete - // The Call will be marked as processed in complete_func_call_if_needed continue; } @@ -1165,7 +1195,12 @@ impl HugrEngine { .insert((current_node, 0), value); debug!("LoadConstant {current_node:?}: loaded value"); } else { - debug!("LoadConstant {current_node:?}: failed to load value"); + // An unparseable constant will never parse: defer so the + // stall report names this node instead of letting its + // block complete around a missing constant-derived value. + debug!("LoadConstant {current_node:?}: failed to load value, deferring"); + self.pending_bool_reads.insert(current_node); + continue; } self.processed.insert(current_node); diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index c80748f10..9dc706062 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -156,6 +156,11 @@ impl HugrEngine { self.processed.insert(call_node); self.active_calls.remove(&call_node); + // Check if this Call completion allows a parent Case to + // complete (a case whose FINAL completion event is the Call + // itself would otherwise stay active forever). + self.check_case_completion(hugr, call_node); + // Check if this Call completion allows a parent CFG block to complete // This is critical for nested function calls self.check_cfg_block_completion(hugr, call_node); diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 4e3825308..86d035ec0 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -580,6 +580,9 @@ impl HugrEngine { self.queue_ready_successors(hugr, tailloop_node); // Check if this TailLoop completion allows a CFG block to complete + // A parent Case whose final completion event is this loop must + // observe it too. + self.check_case_completion(hugr, tailloop_node); self.check_cfg_block_completion(hugr, tailloop_node); } diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index bcbc59c3d..fdf673319 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -376,9 +376,16 @@ impl HugrEngine { /// can unpack them at runtime exactly like a `MakeTuple`-built tuple. fn const_value_to_classical(value: &Value) -> Option { use tket::extension::bool::ConstBool; + use tket::extension::rotation::ConstRotation; use tket::hugr::std_extensions::arithmetic::float_types::ConstF64; use tket::hugr::std_extensions::arithmetic::int_types::ConstInt; + if let Some(const_rot) = value.get_custom_value::() { + let half_turns = const_rot.half_turns(); + debug!("const_value_to_classical: found ConstRotation with {half_turns} half-turns"); + return Some(ClassicalValue::Rotation(half_turns)); + } + // ConstInt can be signed or unsigned if let Some(const_int) = value.get_custom_value::() { let int_value = const_int.value_s(); From 9def592dbe07fcd10255047098c9d7be451443f3 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 20:18:33 -0600 Subject: [PATCH 348/388] Raise fatal execution faults instead of silent fallbacks: out-of-range branch tags error via a poison field, executed prelude.panic is a hard error, ishr is logical per spec, TailLoops get an iteration ceiling, sparse successor lists truncate loudly instead of shifting tags, pending resolvers early-return before cloning the HUGR, and first TailLoop expansion gets the two-phase state-clearing discipline --- crates/pecos-hugr/src/engine.rs | 26 ++++++++++ crates/pecos-hugr/src/engine/analysis.rs | 12 ++++- .../pecos-hugr/src/engine/control_flow/cfg.rs | 33 ++++++------- .../src/engine/control_flow/conditional.rs | 3 ++ .../src/engine/control_flow/tailloop.rs | 47 +++++++++++++++++++ .../src/engine/handlers/classical.rs | 13 +++-- .../pecos-hugr/src/engine/handlers/prelude.rs | 12 +++-- 7 files changed, 115 insertions(+), 31 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 5073d6770..f9737384c 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -169,6 +169,12 @@ pub struct HugrEngine { /// Pending `TailLoops` waiting for Sum value (measurement result) to determine continue/break. pub(crate) pending_tailloop_control: BTreeSet, + /// A fatal execution fault raised from deep (non-Result) code paths -- + /// e.g. an executed `prelude.panic`, an out-of-range branch tag, or a + /// loop-iteration ceiling. Checked by the main processing loop, which + /// converts it into an error instead of continuing on corrupt control + /// flow. + pub(crate) execution_error: Option, // === Result Capture === /// Captured results from tket.result operations. @@ -372,6 +378,7 @@ impl HugrEngine { // Clear TailLoop control flow state self.active_tailloops.clear(); self.pending_tailloop_control.clear(); + self.execution_error = None; // Clear result capture state self.captured_results.clear(); @@ -533,6 +540,9 @@ impl HugrEngine { /// Try to resolve pending `TailLoop` control values after measurement results are available. fn try_resolve_pending_tailloops(&mut self) { + if self.pending_tailloop_control.is_empty() { + return; + } let hugr = match &self.hugr { Some(h) => h.clone(), None => return, @@ -635,6 +645,9 @@ impl HugrEngine { let mut hit_measurement = false; while let Some(current_node) = self.work_queue.pop_front() { + if let Some(fault) = self.execution_error.take() { + return Err(PecosError::Generic(fault)); + } if self.processed.contains(¤t_node) { continue; } @@ -916,6 +929,15 @@ impl HugrEngine { entry_block, successors[branch_idx], ); + } else { + // An out-of-range tag means a Sum/tag + // propagation bug upstream -- taking an + // arbitrary branch would mask it as a + // plausible control-flow path. + self.execution_error = Some(format!( + "CFG {current_node:?} block {entry_block:?}: branch tag {branch_idx} out of range ({} successors)", + successors.len() + )); } } else { debug!("[TRACE] Branch NOT resolved, adding to pending"); @@ -1360,6 +1382,9 @@ impl HugrEngine { } } + if let Some(fault) = self.execution_error.take() { + return Err(PecosError::Generic(fault)); + } if operation_count == 0 { // Queue drained with no measurement pause: this is the engine's // completion claim. Any still-active control flow or starved @@ -1781,6 +1806,7 @@ impl Default for HugrEngine { nodes_inside_tailloops: BTreeSet::new(), active_tailloops: BTreeMap::new(), pending_tailloop_control: BTreeSet::new(), + execution_error: None, // Result capture captured_results: Vec::new(), // WASM support diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 92f4e495d..404442fc5 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -274,8 +274,16 @@ pub fn find_block_successors(hugr: &Hugr, block: Node, num_successors: usize) -> } } - // Convert Option to Node, filtering out None entries - successors.into_iter().flatten().collect() + // Positions are tag indices: compacting out a None would shift every + // later successor onto the wrong tag. Truncate at the first gap instead + // (the downstream out-of-range check then fails loud for tags past it). + let valid = successors.iter().take_while(|s| s.is_some()).count(); + if valid != num_successors { + debug!( + "find_block_successors: block {block:?} has unconnected successor port {valid} of {num_successors}; truncating (tags past it will error loudly)" + ); + } + successors.into_iter().take(valid).flatten().collect() } /// Find all nodes inside CFG blocks (should be deferred until block is active). diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 3037266ee..4ec68a8e0 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -198,6 +198,9 @@ impl HugrEngine { /// Try to resolve pending CFG blocks that were waiting for measurement results. pub(crate) fn try_resolve_pending_cfg_branches(&mut self) { + if self.pending_cfg_branches.is_empty() { + return; + } let hugr = match &self.hugr { Some(h) => h.clone(), None => return, @@ -231,12 +234,12 @@ impl HugrEngine { ); self.transition_to_cfg_successor(&hugr, cfg_node, block_node, next_block); } else { - debug!( - "[TRACE] Resolving pending: {block_node:?} branch {branch_idx} out of range, using first" - ); - if !successors.is_empty() { - self.transition_to_cfg_successor(&hugr, cfg_node, block_node, successors[0]); - } + // Out-of-range tag = upstream Sum/tag propagation bug; + // taking an arbitrary successor would mask it. + self.execution_error = Some(format!( + "CFG {cfg_node:?} block {block_node:?}: pending branch tag {branch_idx} out of range ({} successors)", + successors.len() + )); } } } @@ -338,19 +341,13 @@ impl HugrEngine { next_block, ); } else { - debug!( - "CFG {:?} block {:?}: branch {} out of range ({}), defaulting to first", - cfg_node, - completed_block, - branch_idx, + // An out-of-range tag means a Sum/tag propagation + // bug upstream; routing to an arbitrary successor + // would mask it as plausible control flow. + self.execution_error = Some(format!( + "CFG {cfg_node:?} block {completed_block:?}: branch tag {branch_idx} out of range ({} successors)", successors.len() - ); - self.transition_to_cfg_successor( - hugr, - cfg_node, - completed_block, - successors[0], - ); + )); } } else { // Branch value not yet known - store as pending diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index ec0140a37..ff7d579d3 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -106,6 +106,9 @@ impl HugrEngine { /// Try to resolve any pending conditionals that were waiting for measurement results. pub(crate) fn try_resolve_pending_conditionals(&mut self) { + if self.pending_conditionals.is_empty() { + return; + } let hugr = match &self.hugr { Some(h) => h.clone(), None => return, diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 86d035ec0..d7daee941 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -43,6 +43,10 @@ use crate::engine::analysis::all_predecessors_ready; use crate::engine::types::{ActiveTailLoopInfo, TailLoopInfo}; impl HugrEngine { + /// Iteration ceiling for `TailLoop`s: a loop that never breaks would + /// otherwise spin the processing loop forever with no yield. + const MAX_LOOP_ITERATIONS: usize = 10_000_000; + /// Try to resolve the control value for a `TailLoop`'s current iteration. /// Returns `Some(0)` for `CONTINUE_TAG` (continue looping) or `Some(1)` for `BREAK_TAG` (exit loop). pub(crate) fn try_resolve_tailloop_control( @@ -150,6 +154,36 @@ impl HugrEngine { self.nodes_inside_cases.remove(&op_node); } + // Two-phase discipline (parity with continue_tailloop_iteration and + // CFG/case re-activation): clear processed flags AND stale output + // wires for every tracked body op BEFORE any readiness check below. + // A TailLoop nested in a re-expanded Case/CFG otherwise inherits the + // previous execution's state and its consumers pass readiness + // against stale flags. + for &op_node in tailloop_info + .quantum_ops + .iter() + .chain(&tailloop_info.call_nodes) + .chain(&tailloop_info.extension_ops) + .chain(&tailloop_info.classical_ops) + .chain(&tailloop_info.bool_ops) + .chain(&tailloop_info.conditional_nodes) + { + self.processed.remove(&op_node); + let num_outputs = hugr.num_outputs(op_node); + for port_idx in 0..num_outputs { + self.wire_state + .classical_values + .remove(&(op_node, port_idx)); + self.wire_state.wire_to_qubit.remove(&(op_node, port_idx)); + } + } + for child in hugr.children(tailloop_node) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + self.processed.remove(&child); + } + } + // Propagate input wires from TailLoop inputs to body Input node outputs self.propagate_tailloop_inputs(hugr, tailloop_node, &tailloop_info, 0); @@ -318,6 +352,19 @@ impl HugrEngine { debug!("TailLoop {tailloop_node:?}: continuing to iteration {new_iteration}"); + // Safety ceiling: a loop whose control never breaks (a program bug, + // or an engine bug feeding a stale control value) would otherwise + // spin forever inside the processing loop with no yield -- the batch + // cap counts only quantum ops, so a purely classical loop hangs the + // host process. + if new_iteration > Self::MAX_LOOP_ITERATIONS { + self.execution_error = Some(format!( + "TailLoop {tailloop_node:?} exceeded {} iterations without breaking", + Self::MAX_LOOP_ITERATIONS + )); + return; + } + // Clear processed state for body nodes so they can be re-executed for &op_node in &tailloop_info.quantum_ops { self.processed.remove(&op_node); diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 9f46a4103..daece4104 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -277,15 +277,14 @@ impl HugrEngine { let shift = int(1)?.clamp(0, 63) as u32; ClassicalValue::Int(int(0)?.wrapping_shl(shift)) } - #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)] ClassicalOpType::Ishr => { - // Arithmetic shift for signed, logical for unsigned + // LOGICAL shift per the spec ("rightmost bits dropped, + // leftmost bits set to zero") -- ishr has no signed + // variant; the value's signedness does not change the + // shift semantics. let shift = int(1)?.clamp(0, 63) as u32; - if signed { - ClassicalValue::Int(int(0)?.wrapping_shr(shift)) - } else { - ClassicalValue::Int((uint(0)? >> shift) as i64) - } + ClassicalValue::Int((uint(0)? >> shift) as i64) } // Float arithmetic diff --git a/crates/pecos-hugr/src/engine/handlers/prelude.rs b/crates/pecos-hugr/src/engine/handlers/prelude.rs index db606bd90..13336a8e8 100644 --- a/crates/pecos-hugr/src/engine/handlers/prelude.rs +++ b/crates/pecos-hugr/src/engine/handlers/prelude.rs @@ -78,10 +78,14 @@ impl HugrEngine { } "panic" => { - // Panic operation - for simulation, we log it and continue - // In a real execution, this would halt the program - debug!("prelude::panic encountered at {node:?}"); - // Mark as handled but don't crash the simulation + // An EXECUTED panic is a real runtime error on the taken + // path (guppy routes bounds/borrow/arithmetic failures + // here): raise a fatal fault instead of continuing with the + // panic's outputs unproduced, which either stalls with a + // misleading message or completes with corrupt results. + self.execution_error = Some(format!( + "program panicked (prelude.panic executed at {node:?})" + )); true } From ed986d1c024019aad4525b521f47f66d0833a521 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 20:44:15 -0600 Subject: [PATCH 349/388] De-vacuum the test corpus: value-anchor the truthy-list and len-only assertions, require zero shot failures in the statevec tests, fail tests on pipeline errors instead of success-gating (exposing ten dead tests now strict-xfailed with precise reasons including seven never-compiling programs), add a real for-loop test, enable the years-disabled X-branch assertion, and add deterministic shift/zero-iteration/div-zero/recursion tests --- crates/pecos-hugr/src/engine.rs | 50 ++++++------ .../tests/guppy/test_arithmetic_support.py | 80 +++++++++++++++++++ .../test_comprehensive_guppy_features.py | 18 +++-- .../tests/guppy/test_dynamic_circuits.py | 12 ++- .../guppy/test_extended_guppy_features.py | 41 ++++++++-- .../tests/guppy/test_for_loop.py | 13 +++ .../tests/guppy/test_missing_coverage.py | 13 ++- 7 files changed, 181 insertions(+), 46 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index f9737384c..4a169ec22 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1029,7 +1029,9 @@ impl HugrEngine { while let Some(n) = cur { if n == func_defn_node { return Err(PecosError::Generic(format!( - "recursive call to FuncDefn {func_defn_node:?} at {current_node:?}: recursion is not supported by the HUGR engine (no call stack; each function has a single execution frame)" + "recursive call to FuncDefn {func_defn_node:?} at {current_node:?}: \ + recursion is not supported by the HUGR engine (no call \ + stack; each function has a single execution frame)" ))); } cur = hugr.get_parent(n); @@ -4065,12 +4067,16 @@ mod tests { println!("While loop results: {successes} successes, {failures} failures"); - // For now, just check that we can load and attempt to run - // Full while loop support may require additional work for CFG back edges - assert!( - successes > 0 || failures > 0, - "Should have attempted at least some shots" + // Every shot must succeed: while-loop execution over CFG back edges + // is supported (the old `successes > 0 || failures > 0` form was a + // tautology that passed with 10/10 failed shots). + assert_eq!( + failures, + 0, + "while-loop shots failed: {failures}/{}", + successes + failures ); + assert!(successes > 0, "no shots ran"); } #[test] @@ -4171,11 +4177,8 @@ mod tests { // With H gate, should be roughly 50/50 // Allow for statistical variance - assert!( - failures < num_shots, - "All shots failed - function call not working" - ); - if failures == 0 { + assert_eq!(failures, 0, "shots failed: {failures}/{num_shots}"); + { // Check distribution only if all shots succeeded let total = count_0 + count_1; assert!(total > 0, "No measurements recorded"); @@ -4312,11 +4315,8 @@ mod tests { ); // With two independent H gates, should see roughly 25% each - assert!( - failures < num_shots, - "All shots failed - multiple function calls not working" - ); - if failures == 0 { + assert_eq!(failures, 0, "shots failed: {failures}/{num_shots}"); + { let total = count_00 + count_01 + count_10 + count_11; assert!(total > 0, "No measurements recorded"); // Each outcome should be roughly 25% (allow 10-40%) @@ -4424,11 +4424,8 @@ mod tests { ); // With H gate (through nested calls), should be roughly 50/50 - assert!( - failures < num_shots, - "All shots failed - nested function calls not working" - ); - if failures == 0 { + assert_eq!(failures, 0, "shots failed: {failures}/{num_shots}"); + { let total = count_0 + count_1; assert!(total > 0, "No measurements recorded"); let ratio = f64::from(count_0) / f64::from(total); @@ -4540,11 +4537,8 @@ mod tests { ); // Bell state: should only see 00 or 11 (correlated measurements) - assert!( - failures < num_shots, - "All shots failed - multi-qubit function not working" - ); - if failures == 0 { + assert_eq!(failures, 0, "shots failed: {failures}/{num_shots}"); + { let total = count_00 + count_01 + count_10 + count_11; assert!(total > 0, "No measurements recorded"); @@ -4555,6 +4549,10 @@ mod tests { correlated > uncorrelated * 4, "Expected Bell state correlation: {correlated} correlated vs {uncorrelated} uncorrelated" ); + // A zero-gate engine also satisfies the ratio (all shots 00): + // a real Bell state must produce BOTH correlated outcomes. + assert!(count_00 > 0, "expected some 00 outcomes"); + assert!(count_11 > 0, "expected some 11 outcomes"); } } } diff --git a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py index 2409b4aea..cbfb233b5 100644 --- a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py +++ b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py @@ -185,3 +185,83 @@ def euclid_div() -> bool: raw_measurements = results["measurements"] measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] assert measurements == [1, 1, 1], f"Euclidean semantics violated: {measurements}" + + +def test_shift_semantics() -> None: + """Left/right shifts on positive values, X-anchored.""" + + @guppy + def shifts() -> bool: + q = qubit() + a = 1 + b = 16 + if (a << 3) == 8 and (b >> 2) == 4: + x(q) + return measure(q) + + results = sim(Guppy(shifts)).qubits(1).quantum(state_vector()).seed(1).run(3).to_dict() + raw_measurements = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert measurements == [1, 1, 1], f"shift semantics violated: {measurements}" + + +def test_zero_iteration_loop() -> None: + """A range(0) loop must run zero iterations and fall through cleanly.""" + + @guppy + def zero_iters() -> bool: + q = qubit() + count = 0 + for _i in range(0): + count = count + 1 + if count == 0: + x(q) + return measure(q) + + results = sim(Guppy(zero_iters)).qubits(1).quantum(state_vector()).seed(1).run(3).to_dict() + raw_measurements = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert measurements == [1, 1, 1], f"zero-iteration loop misbehaved: {measurements}" + + +def test_division_by_zero_yields_zero() -> None: + """Division by zero currently yields 0 (documented engine legacy). + + The HUGR spec says the unchecked op panics; the engine's documented + stand-in is 0. This pin makes any change to that behavior visible. + """ + + @guppy + def div_zero() -> bool: + q = qubit() + a = 5 + b = 0 + if a // b == 0: + x(q) + return measure(q) + + results = sim(Guppy(div_zero)).qubits(1).quantum(state_vector()).seed(1).run(3).to_dict() + raw_measurements = results["measurements"] + measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert measurements == [1, 1, 1], f"div-by-zero legacy behavior changed: {measurements}" + + +def test_recursion_rejected_loudly() -> None: + """Recursive guppy functions must produce a clear engine error, not a + hang or silent truncation.""" + + @guppy + def recurse(n: int) -> int: + if n <= 0: + return 0 + return recurse(n - 1) + + @guppy + def recursive_main() -> bool: + q = qubit() + if recurse(3) == 0: + x(q) + return measure(q) + + with pytest.raises(RuntimeError, match="recursion is not supported"): + sim(Guppy(recursive_main)).qubits(1).quantum(state_vector()).seed(1).run(1).to_dict() diff --git a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py index 85f12ee6b..57fdbd187 100644 --- a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py @@ -93,11 +93,11 @@ def test_function_on_both_pipelines( "error": None, } except Exception as e: - results["hugr_llvm"] = { - "success": False, - "result": None, - "error": str(e), - } + # A pipeline failure must FAIL the test: every semantic + # assertion in this file is gated behind success, so converting + # exceptions into success=False used to make any engine + # regression pass everything. + pytest.fail(f"guppy pipeline failed for {func}: {e}") return results @@ -276,6 +276,10 @@ def boolean_or_test() -> bool: shots=10, ) + @pytest.mark.xfail( + reason="pure-classical return values are not captured in results (program has no measurements; return-value capture is a known gap)", + strict=True, + ) def test_classical_arithmetic(self, pipeline_tester: GuppyPipelineTest) -> None: """Test basic arithmetic operations.""" @@ -417,6 +421,10 @@ def qft_2qubit() -> tuple[bool, bool]: # The test passes if we get results without errors assert len(measurements) == 100 + @pytest.mark.xfail( + reason="test program does not compile under guppylang 0.21 (PlaceNotUsedError: linearity violation); needs a rewrite -- it never actually ran, the old success-gate silently passed the compile failure", + strict=True, + ) def test_deutsch_josza_algorithm(self, pipeline_tester: GuppyPipelineTest) -> None: """Test Deutsch-Josza algorithm for 2-bit function.""" from guppylang.std.quantum import cx, h, measure, qubit, x diff --git a/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py b/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py index 5ae2a51fd..aaa784083 100644 --- a/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py +++ b/python/quantum-pecos/tests/guppy/test_dynamic_circuits.py @@ -78,12 +78,14 @@ def conditional_x_from_one() -> bool: # Run the circuit results = sim(Guppy(conditional_x_from_one)).qubits(2).quantum(state_vector()).seed(42).run(100) - # Extract measurements + # Extract the return value (last measurement in each shot) -- each + # row is a per-shot LIST, which is always truthy: counting rows + # instead of values made this assertion unable to fail. measurements = results["measurements"] - assert len(measurements) == 100, "should have one measurement row per shot" + return_values = [shot[-1] for shot in measurements] # All results should be True since q1 is |1>, so X is always applied to q2 - ones_count = sum(1 for m in measurements if m) + ones_count = sum(1 for m in return_values if m) assert ones_count == 100, f"Conditional X from |1> should always trigger, but got {ones_count}/100 ones" def test_measurement_feedback_entanglement(self) -> None: @@ -121,6 +123,10 @@ def measurement_feedback() -> tuple[bool, bool]: # Both measurements should always match mismatches = sum(1 for (a, b) in measurements if a != b) + # A zero-gate engine also produces zero mismatches (all (0,0)): + # require BOTH outcomes to actually occur across 100 shots. + firsts = {a for (a, _b) in measurements} + assert firsts == {0, 1}, f"expected both outcomes over 100 shots, got {firsts}" assert ( mismatches == 0 ), f"Measurement feedback should create perfect correlation, but got {mismatches}/100 mismatches" diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index 82842f1fe..1ac90c85b 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -87,11 +87,10 @@ def test_function( "error": None, } except Exception as e: - return { - "success": False, - "result": None, - "error": str(e), - } + # A pipeline failure must FAIL the test: the semantic assertions + # in this file are gated behind success, so returning + # success=False used to make any engine regression pass. + pytest.fail(f"guppy pipeline failed for {func}: {e}") @pytest.fixture @@ -195,6 +194,10 @@ def rotation_test() -> tuple[bool, bool]: class TestMultiQubitGates: """Test multi-qubit gate operations.""" + @pytest.mark.xfail( + reason="test program does not compile under guppylang 0.21 (PlaceNotUsedError); needs a rewrite -- never ran behind the old success-gate", + strict=True, + ) def test_controlled_y_and_z(self, tester: ExtendedGuppyTester) -> None: """Test CY and CZ gates.""" # Note: state_vector() engine supports non-Clifford operations like CY @@ -235,6 +238,10 @@ def cy_cz_test() -> tuple[bool, bool, bool]: class TestQubitArrays: """Test qubit array operations and indexing.""" + @pytest.mark.xfail( + reason="test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- never ran behind the old success-gate", + strict=True, + ) def test_qubit_array_creation_and_access(self, tester: ExtendedGuppyTester) -> None: """Test creating and accessing qubit arrays.""" @@ -262,6 +269,10 @@ def array_test() -> tuple[bool, bool, bool, bool]: expected = sum(1 for m in measurements if m == (False, True, False, True)) assert expected > 95, f"Array indexing failed, got {expected}/100 correct" + @pytest.mark.xfail( + reason="test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- never ran behind the old success-gate", + strict=True, + ) def test_qubit_array_loops(self, tester: ExtendedGuppyTester) -> None: """Test looping over qubit arrays.""" @@ -324,6 +335,10 @@ def tuple_test() -> tuple[bool, bool]: correlated = sum(1 for (a, b) in measurements if a == b) assert correlated > 80, f"Tuple ops failed, correlation={correlated}/100" + @pytest.mark.xfail( + reason="pure-classical return values are not captured in results (program has no measurements; return-value capture is a known gap)", + strict=True, + ) def test_boolean_expressions(self, tester: ExtendedGuppyTester) -> None: """Test complex boolean expressions.""" @@ -351,6 +366,10 @@ def boolean_expr_test() -> bool: class TestControlFlow: """Test advanced control flow patterns.""" + @pytest.mark.xfail( + reason="engine stalls on this nested-loop shape (starved deferred node; nested-container tracking is a known follow-up) -- fails loud instead of silently truncating", + strict=True, + ) def test_nested_loops(self, tester: ExtendedGuppyTester) -> None: """Test nested loop structures.""" @@ -454,6 +473,10 @@ def early_return_test() -> int: class TestQuantumAlgorithms: """Test quantum algorithms and protocols.""" + @pytest.mark.xfail( + reason="test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- never ran behind the old success-gate", + strict=True, + ) def test_ghz_state_creation(self, tester: ExtendedGuppyTester) -> None: """Test GHZ state creation for multiple qubits.""" @@ -477,6 +500,10 @@ def create_ghz3() -> tuple[bool, bool, bool]: total_valid = all_zeros + all_ones assert total_valid > 95, f"GHZ state invalid, got {total_valid}/100 valid states" + @pytest.mark.xfail( + reason="test program does not compile under guppylang 0.21 (PlaceNotUsedError); needs a rewrite -- never ran behind the old success-gate", + strict=True, + ) def test_quantum_phase_kickback(self, tester: ExtendedGuppyTester) -> None: """Test phase kickback principle.""" @@ -689,6 +716,10 @@ def empty_circuit() -> bool: class TestPerformance: """Test performance with larger circuits.""" + @pytest.mark.xfail( + reason="test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- never ran behind the old success-gate", + strict=True, + ) def test_many_qubits(self, tester: ExtendedGuppyTester) -> None: """Test handling many qubits.""" diff --git a/python/quantum-pecos/tests/guppy/test_for_loop.py b/python/quantum-pecos/tests/guppy/test_for_loop.py index 0f2adc6d7..fe22bcf5a 100755 --- a/python/quantum-pecos/tests/guppy/test_for_loop.py +++ b/python/quantum-pecos/tests/guppy/test_for_loop.py @@ -21,6 +21,19 @@ def loop_with_measure() -> int: return count +def test_for_loop_with_measurements() -> None: + """The loop must run exactly 3 iterations (3 measurements per shot), and + with H per iteration both outcomes must occur across 20 seeded shots -- + a zero-iteration or frozen loop cannot satisfy either.""" + results = sim(Guppy(loop_with_measure)).qubits(10).quantum(state_vector()).seed(42).run(20).to_dict() + measurements = results["measurements"] + assert len(measurements) == 20 + for shot in measurements: + assert len(shot) == 3, f"expected 3 loop measurements, got {shot}" + outcomes = {m for shot in measurements for m in shot} + assert outcomes == {0, 1}, f"H per iteration must yield both outcomes, got {outcomes}" + + if __name__ == "__main__": os.environ["RUST_LOG"] = "pecos_hugr::engine=debug" print("Testing for-loop with measurements...") diff --git a/python/quantum-pecos/tests/guppy/test_missing_coverage.py b/python/quantum-pecos/tests/guppy/test_missing_coverage.py index 93d57511e..7ff726727 100644 --- a/python/quantum-pecos/tests/guppy/test_missing_coverage.py +++ b/python/quantum-pecos/tests/guppy/test_missing_coverage.py @@ -585,15 +585,14 @@ def error_handling_test() -> tuple[bool, bool]: assert len(success_zeros) > 150, f"H gate should produce ~50% 0s, got {len(success_zeros)}/{len(success_cases)}" assert len(success_ones) > 150, f"H gate should produce ~50% 1s, got {len(success_ones)}/{len(success_cases)}" - # Check error cases (X gate should give all 1s) - # Note: Guppy conditional branching has a known issue where gates in - # conditional branches may not execute correctly. This test verifies - # measurements are valid; full X gate verification may fail until fixed. - error_zeros = [m for m in error_cases if m[1] == 0] + # Check error cases: the error branch applies X, so m2 must be 1 + # in EVERY error-case shot. (This assertion spent years disabled + # behind a "known conditional-branch issue" note while a tautology + # kept the test green; branch execution is fixed.) error_ones = [m for m in error_cases if m[1] == 1] - assert len(error_ones) + len(error_zeros) == len( + assert len(error_ones) == len( error_cases, - ), "All error cases should have valid m2 measurements" + ), f"X branch must force m2=1 in all {len(error_cases)} error cases, got {len(error_ones)}" def test_projective_measurement(self) -> None: """Test measurement collapse behavior.""" From 9b3df1d549d7200f56851daa4b17d38ac6598881 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 20:48:17 -0600 Subject: [PATCH 350/388] Wrap the strict-xfail reason strings to the line-length limit --- .../test_comprehensive_guppy_features.py | 11 ++++- .../guppy/test_extended_guppy_features.py | 40 +++++++++++++++---- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py index 57fdbd187..6bfc580fb 100644 --- a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py @@ -277,7 +277,10 @@ def boolean_or_test() -> bool: ) @pytest.mark.xfail( - reason="pure-classical return values are not captured in results (program has no measurements; return-value capture is a known gap)", + reason=( + "pure-classical return values are not captured in results (program has no measurements; return- " + "value capture is a known gap)" + ), strict=True, ) def test_classical_arithmetic(self, pipeline_tester: GuppyPipelineTest) -> None: @@ -422,7 +425,11 @@ def qft_2qubit() -> tuple[bool, bool]: assert len(measurements) == 100 @pytest.mark.xfail( - reason="test program does not compile under guppylang 0.21 (PlaceNotUsedError: linearity violation); needs a rewrite -- it never actually ran, the old success-gate silently passed the compile failure", + reason=( + "test program does not compile under guppylang 0.21 (PlaceNotUsedError: linearity violation); " + "needs a rewrite -- it never actually ran, the old success-gate silently passed the compile " + "failure" + ), strict=True, ) def test_deutsch_josza_algorithm(self, pipeline_tester: GuppyPipelineTest) -> None: diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index 1ac90c85b..5d8bb8455 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -195,7 +195,10 @@ class TestMultiQubitGates: """Test multi-qubit gate operations.""" @pytest.mark.xfail( - reason="test program does not compile under guppylang 0.21 (PlaceNotUsedError); needs a rewrite -- never ran behind the old success-gate", + reason=( + "test program does not compile under guppylang 0.21 (PlaceNotUsedError); needs a rewrite -- " + "never ran behind the old success-gate" + ), strict=True, ) def test_controlled_y_and_z(self, tester: ExtendedGuppyTester) -> None: @@ -239,7 +242,10 @@ class TestQubitArrays: """Test qubit array operations and indexing.""" @pytest.mark.xfail( - reason="test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- never ran behind the old success-gate", + reason=( + "test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- " + "never ran behind the old success-gate" + ), strict=True, ) def test_qubit_array_creation_and_access(self, tester: ExtendedGuppyTester) -> None: @@ -270,7 +276,10 @@ def array_test() -> tuple[bool, bool, bool, bool]: assert expected > 95, f"Array indexing failed, got {expected}/100 correct" @pytest.mark.xfail( - reason="test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- never ran behind the old success-gate", + reason=( + "test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- " + "never ran behind the old success-gate" + ), strict=True, ) def test_qubit_array_loops(self, tester: ExtendedGuppyTester) -> None: @@ -336,7 +345,10 @@ def tuple_test() -> tuple[bool, bool]: assert correlated > 80, f"Tuple ops failed, correlation={correlated}/100" @pytest.mark.xfail( - reason="pure-classical return values are not captured in results (program has no measurements; return-value capture is a known gap)", + reason=( + "pure-classical return values are not captured in results (program has no measurements; return- " + "value capture is a known gap)" + ), strict=True, ) def test_boolean_expressions(self, tester: ExtendedGuppyTester) -> None: @@ -367,7 +379,10 @@ class TestControlFlow: """Test advanced control flow patterns.""" @pytest.mark.xfail( - reason="engine stalls on this nested-loop shape (starved deferred node; nested-container tracking is a known follow-up) -- fails loud instead of silently truncating", + reason=( + "engine stalls on this nested-loop shape (starved deferred node; nested-container tracking is a " + "known follow-up) -- fails loud instead of silently truncating" + ), strict=True, ) def test_nested_loops(self, tester: ExtendedGuppyTester) -> None: @@ -474,7 +489,10 @@ class TestQuantumAlgorithms: """Test quantum algorithms and protocols.""" @pytest.mark.xfail( - reason="test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- never ran behind the old success-gate", + reason=( + "test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- " + "never ran behind the old success-gate" + ), strict=True, ) def test_ghz_state_creation(self, tester: ExtendedGuppyTester) -> None: @@ -501,7 +519,10 @@ def create_ghz3() -> tuple[bool, bool, bool]: assert total_valid > 95, f"GHZ state invalid, got {total_valid}/100 valid states" @pytest.mark.xfail( - reason="test program does not compile under guppylang 0.21 (PlaceNotUsedError); needs a rewrite -- never ran behind the old success-gate", + reason=( + "test program does not compile under guppylang 0.21 (PlaceNotUsedError); needs a rewrite -- " + "never ran behind the old success-gate" + ), strict=True, ) def test_quantum_phase_kickback(self, tester: ExtendedGuppyTester) -> None: @@ -717,7 +738,10 @@ class TestPerformance: """Test performance with larger circuits.""" @pytest.mark.xfail( - reason="test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- never ran behind the old success-gate", + reason=( + "test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- " + "never ran behind the old success-gate" + ), strict=True, ) def test_many_qubits(self, tester: ExtendedGuppyTester) -> None: From c3f200908997c2c268786b09caa8aba338e9de5e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Fri, 3 Jul 2026 21:16:02 -0600 Subject: [PATCH 351/388] Fold in round-6 re-review: RNG ops emit value-first with NewRNGContext returning an option per the tket-qsystem signatures, RandomAdvance defers on missing context, the empty-block multi-successor path errors on out-of-range tags, unwired no-CFG passthrough outputs error, CFG cycles get a transition ceiling, and division by zero panics per the spec (test flipped from the legacy pin) --- crates/pecos-hugr/src/engine.rs | 7 ++- .../pecos-hugr/src/engine/control_flow/cfg.rs | 24 +++++++++- .../src/engine/handlers/classical.rs | 47 ++++++++++++++----- .../pecos-hugr/src/engine/handlers/qsystem.rs | 8 +++- crates/pecos-hugr/src/engine/types.rs | 4 ++ .../tests/guppy/test_arithmetic_support.py | 14 ++---- 6 files changed, 78 insertions(+), 26 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 4a169ec22..199f939d9 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -705,6 +705,7 @@ impl HugrEngine { cfg_node: current_node, current_block: entry_block, completed_blocks: BTreeSet::new(), + transitions: 0, }, ); @@ -1176,7 +1177,11 @@ impl HugrEngine { let Some((src_node, src_port)) = hugr.single_linked_output(func_info.output_node, out_port) else { - continue; + return Err(PecosError::Generic(format!( + "Call {current_node:?} targets FuncDefn {func_defn_node:?} \ + whose Output port {port} is unwired; the caller's output \ + would silently go missing" + ))); }; if src_node != func_info.input_node { return Err(PecosError::Generic(format!( diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 4ec68a8e0..624805e1b 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -45,6 +45,10 @@ use crate::engine::analysis::{ use crate::engine::types::ClassicalValue; impl HugrEngine { + /// Block-transition ceiling for CFGs (loop-iteration bound: guppy loops + /// are CFG cycles; a loop that never breaks must error, not hang). + const MAX_CFG_TRANSITIONS: u64 = 10_000_000; + /// Try to resolve the branch value for a CFG `DataflowBlock`. /// Returns `Some(branch_index)` if the Sum tag value is known, None otherwise. #[allow(clippy::too_many_lines)] @@ -401,10 +405,21 @@ impl HugrEngine { self.pending_measurement_propagations .push((cfg_node, from_block, to_block)); - // Update active CFG state + // Update active CFG state. Guppy loops lower to CFG cycles, so the + // transition count doubles as the loop-iteration ceiling: a + // never-breaking classical loop would otherwise spin the processing + // loop forever with no yield. if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { active_cfg.completed_blocks.insert(from_block); active_cfg.current_block = to_block; + active_cfg.transitions += 1; + if active_cfg.transitions > Self::MAX_CFG_TRANSITIONS { + self.execution_error = Some(format!( + "CFG {cfg_node:?} exceeded {} block transitions without exiting", + Self::MAX_CFG_TRANSITIONS + )); + return; + } } // Activate successor block's quantum ops and Call nodes @@ -642,6 +657,13 @@ impl HugrEngine { ); // Recursively transition self.transition_to_cfg_successor(hugr, cfg_node, to_block, next_block); + } else { + // Out-of-range tag = upstream Sum/tag bug. + self.execution_error = Some(format!( + "CFG {cfg_node:?} empty block {to_block:?}: branch tag \ + {branch_idx} out of range ({} successors)", + successors.len() + )); } } else { debug!( diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index daece4104..2b16e847b 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -45,7 +45,7 @@ impl HugrEngine { clippy::cast_sign_loss // shift amounts are clamped to 0-63 before cast to u32 )] pub(crate) fn handle_classical_op( - &self, + &mut self, hugr: &Hugr, node: Node, op: &ClassicalOp, @@ -155,6 +155,7 @@ impl HugrEngine { let signed = op.int_info.is_none_or(|(_, is_signed)| is_signed); // Execute the operation + let mut div_by_zero = false; #[allow(clippy::cast_possible_wrap)] let result: Option = (|| { Some(match op.op_type { @@ -170,28 +171,44 @@ impl HugrEngine { ClassicalOpType::Isub => ClassicalValue::Int(int(0)?.wrapping_sub(int(1)?)), ClassicalOpType::Imul => ClassicalValue::Int(int(0)?.wrapping_mul(int(1)?)), ClassicalOpType::Idiv => { - // Division by zero yields 0 (the spec says the unchecked - // op panics; the engine's panic handling is log-and- - // continue, so 0 is the documented legacy stand-in). - // Signed division is EUCLIDEAN per the HUGR spec - // (idivmod_s: q*m+r=n with 0<=r { - // Euclidean remainder for signed (0 <= r < m), see Idiv. + // Euclidean remainder for signed (0 <= r < m), see Idiv; + // modulo by zero panics per the spec. if signed { let (n, m) = (i128::from(int(0)?), i128::from(uint(1)?)); - ClassicalValue::Int(if m == 0 { 0 } else { n.rem_euclid(m) as i64 }) + if m == 0 { + div_by_zero = true; + return None; + } + ClassicalValue::Int(n.rem_euclid(m) as i64) } else { let (a, b) = (uint(0)?, uint(1)?); - ClassicalValue::Int(a.checked_rem(b).unwrap_or(0) as i64) + let Some(r) = a.checked_rem(b) else { + div_by_zero = true; + return None; + }; + ClassicalValue::Int(r as i64) } } // Checked variants return sum_with_error(int): tag 1 wraps @@ -360,6 +377,12 @@ impl HugrEngine { if let Some(value) = result { vec![(0, value)] + } else if div_by_zero { + // The spec says unchecked division/modulo by zero panics. + self.execution_error = Some(format!( + "division by zero at {node:?} (the HUGR spec defines m=0 as a panic)" + )); + vec![] } else { debug!("Classical op {node:?}: input type mismatch, deferring"); vec![] diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index ee4de44cf..8a55eedd3 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -304,14 +304,18 @@ impl HugrEngine { "RandomAdvance" => { // RandomAdvance: (RNGContext, int<64>) -> RNGContext // Advance the RNG state by delta steps (can be negative for backtracking) - let ctx_value = self.get_input_value(hugr, node, 0); + let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("RandomAdvance at {node:?}: context not ready, deferring"); + return false; + }; let Some(delta) = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()) else { debug!("RandomAdvance at {node:?}: delta not ready, deferring"); return false; }; - if let Some(ClassicalValue::RngContext(ctx_id)) = ctx_value { + { // Advance the RNG state by |delta| steps // Note: For simplicity, we only support forward advancement // Negative delta would require storing history which we don't do diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index a4a222bac..c3175cbb7 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -719,6 +719,10 @@ pub struct ActiveCfgInfo { pub current_block: Node, /// Blocks that have been fully processed. pub completed_blocks: BTreeSet, + /// Number of block transitions taken (loop-iteration ceiling: guppy + /// loops lower to CFG cycles, so a never-breaking classical loop would + /// otherwise spin forever). + pub transitions: u64, } // --- Function Call Types --- diff --git a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py index cbfb233b5..048f24791 100644 --- a/python/quantum-pecos/tests/guppy/test_arithmetic_support.py +++ b/python/quantum-pecos/tests/guppy/test_arithmetic_support.py @@ -224,12 +224,8 @@ def zero_iters() -> bool: assert measurements == [1, 1, 1], f"zero-iteration loop misbehaved: {measurements}" -def test_division_by_zero_yields_zero() -> None: - """Division by zero currently yields 0 (documented engine legacy). - - The HUGR spec says the unchecked op panics; the engine's documented - stand-in is 0. This pin makes any change to that behavior visible. - """ +def test_division_by_zero_panics() -> None: + """Division by zero is a runtime error per the HUGR spec (m=0 panics).""" @guppy def div_zero() -> bool: @@ -240,10 +236,8 @@ def div_zero() -> bool: x(q) return measure(q) - results = sim(Guppy(div_zero)).qubits(1).quantum(state_vector()).seed(1).run(3).to_dict() - raw_measurements = results["measurements"] - measurements = [m[-1] if isinstance(m, list) else m for m in raw_measurements] - assert measurements == [1, 1, 1], f"div-by-zero legacy behavior changed: {measurements}" + with pytest.raises(RuntimeError, match="division by zero"): + sim(Guppy(div_zero)).qubits(1).quantum(state_vector()).seed(1).run(1).to_dict() def test_recursion_rejected_loudly() -> None: From bc91d1280299b95e0f943cf3555a4d4773feaff0 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 09:24:17 -0600 Subject: [PATCH 352/388] Fix nested-loop starvation (zero-op conditional cases now run completion hooks that wake order-edge consumers), add a 14-program dynamic semantic sweep, and fold in Codex round-7: RNG ops emit value-first tuples and NewRNGContext a real option (pinned by a builder-chain unit test), RandomInt masks to 32 bits, RandomIntBounded poisons on non-positive bounds, futures.Free defers instead of silently succeeding, and zero-output no-CFG function bodies are shape-checked instead of vacuously passing --- crates/pecos-hugr/src/engine.rs | 179 ++++++++++- .../src/engine/control_flow/conditional.rs | 48 +-- .../pecos-hugr/src/engine/handlers/futures.rs | 17 +- .../pecos-hugr/src/engine/handlers/qsystem.rs | 55 ++-- .../guppy/test_extended_guppy_features.py | 7 - .../tests/guppy/test_semantic_sweep.py | 296 ++++++++++++++++++ 6 files changed, 545 insertions(+), 57 deletions(-) create mode 100644 python/quantum-pecos/tests/guppy/test_semantic_sweep.py diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 199f939d9..c84068fc5 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -675,9 +675,9 @@ impl HugrEngine { } } debug!("Conditional {current_node:?} expanded, branch {branch_index} selected"); - - // Check if this Conditional completion allows a CFG block to complete - self.check_cfg_block_completion(&hugr, current_node); + // Completion hooks for a zero-op case (block completion, + // consumer wake-up) run inside expand_conditional; a + // non-empty case completes later via check_case_completion. } else { // Can't resolve yet - likely waiting for measurement result // Add to pending conditionals and continue @@ -1172,6 +1172,22 @@ impl HugrEngine { // stranded outside every completion and stall check // (which silently truncates downstream results). debug!("Call {current_node:?}: FuncDefn has no CFG, treating as passthrough"); + // The per-port wiring checks below are vacuous for a + // zero-output function, so check the body shape first: + // any child beyond Input/Output (and inert Const + // statics) is real work this path would silently skip. + if let Some(extra) = hugr.children(func_defn_node).find(|c| { + *c != func_info.input_node + && *c != func_info.output_node + && !matches!(hugr.get_optype(*c), OpType::Const(_)) + }) { + return Err(PecosError::Generic(format!( + "Call {current_node:?} targets FuncDefn {func_defn_node:?} \ + with no CFG and a non-passthrough body (contains \ + {extra:?}); plain dataflow function bodies are not \ + supported by the HUGR engine" + ))); + } for port in 0..func_info.num_outputs { let out_port = IncomingPort::from(port); let Some((src_node, src_port)) = @@ -1185,7 +1201,10 @@ impl HugrEngine { }; if src_node != func_info.input_node { return Err(PecosError::Generic(format!( - "Call {current_node:?} targets FuncDefn {func_defn_node:?} with no CFG and a non-passthrough body (output {port} fed by {src_node:?}); plain dataflow function bodies are not supported by the HUGR engine" + "Call {current_node:?} targets FuncDefn {func_defn_node:?} \ + with no CFG and a non-passthrough body (output {port} fed \ + by {src_node:?}); plain dataflow function bodies are not \ + supported by the HUGR engine" ))); } let func_input_wire = (func_info.input_node, src_port.index()); @@ -2137,6 +2156,158 @@ mod tests { assert!(engine.quantum_ops.is_empty()); } + /// Build the RNG chain from tket-qsystem's own builder test and drive + /// the qsystem handlers over it, pinning the OUTPUT SHAPES to the + /// extension signatures: `NewRNGContext -> Option` (a Sum + /// with tag 1) and value-FIRST tuples for the Random* ops. The chain + /// seeds only + /// the constants and the unwrap Conditional's output; every later op + /// resolves its context from the previous op's stored port-1 output, so + /// a swapped port order fails the chain, not just one assert. + #[test] + #[allow(clippy::too_many_lines)] + fn test_rng_ops_emit_value_first_spec_shapes() { + use crate::engine::types::RngContextState; + use tket::hugr::builder::{Dataflow, DataflowHugr, FunctionBuilder}; + use tket::hugr::extension::prelude::{UnwrapBuilder, option_type}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::{Signature, Type}; + use tket_qsystem::extension::random::{CONTEXT_TYPE_NAME, EXTENSION, RandomOpBuilder}; + + let hugr = { + let mut fb = + FunctionBuilder::new("rng_chain", Signature::new(vec![], vec![int_type(5)])) + .unwrap(); + let seed = fb.add_load_const(Value::from(ConstInt::new_u(6, 123_456).unwrap())); + let maybe_ctx = fb.add_new_rng_context(seed).unwrap(); + let context_type = Type::from( + EXTENSION + .get_type(&CONTEXT_TYPE_NAME) + .unwrap() + .instantiate([]) + .unwrap(), + ); + let [ctx] = fb + .build_unwrap_sum(1, option_type(vec![context_type]), maybe_ctx) + .unwrap(); + let bound = fb.add_load_const(Value::from(ConstInt::new_u(5, 100).unwrap())); + let delta = fb.add_load_const(Value::from(ConstInt::new_s(6, -1).unwrap())); + let [_, ctx] = fb.add_random_int_bounded(ctx, bound).unwrap(); + let [_, ctx] = fb.add_random_float(ctx).unwrap(); + let ctx = fb.add_random_advance(ctx, delta).unwrap(); + let [rnd, ctx] = fb.add_random_int(ctx).unwrap(); + fb.add_delete_rng_context(ctx).unwrap(); + fb.finish_hugr_with_outputs([rnd]).unwrap() + }; + + let find = |name: &str| -> Node { + hugr.nodes() + .find(|n| { + hugr.get_optype(*n) + .as_extension_op() + .is_some_and(|op| op.unqualified_id() == name) + }) + .unwrap_or_else(|| panic!("no {name} node in the built chain")) + }; + let new_ctx = find("NewRNGContext"); + let bounded = find("RandomIntBounded"); + let float = find("RandomFloat"); + let advance = find("RandomAdvance"); + let int = find("RandomInt"); + let delete = find("DeleteRNGContext"); + + let seed_input = |engine: &mut HugrEngine, node: Node, port: usize, value| { + let (src, sp) = hugr + .single_linked_output(node, IncomingPort::from(port)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), value); + }; + + let mut engine = HugrEngine::default(); + + // NewRNGContext: u64 seed -> Some(context) + seed_input(&mut engine, new_ctx, 0, ClassicalValue::Int(123_456)); + assert!(engine.handle_random_op(&hugr, new_ctx, "NewRNGContext")); + let Some(ClassicalValue::Sum { tag: 1, values }) = engine + .wire_state + .classical_values + .get(&(new_ctx, 0)) + .cloned() + else { + panic!("NewRNGContext must produce Sum tag 1 (Some), got {:?}", { + engine.wire_state.classical_values.get(&(new_ctx, 0)) + }); + }; + let [ClassicalValue::RngContext(ctx_id)] = values.as_slice() else { + panic!("NewRNGContext Some payload must be an RNG context, got {values:?}"); + }; + + // The unwrap Conditional's output is engine-propagated in real runs; + // seed it directly here so the chain below starts from the context. + seed_input(&mut engine, bounded, 0, ClassicalValue::RngContext(*ctx_id)); + seed_input(&mut engine, bounded, 1, ClassicalValue::Int(100)); + assert!(engine.handle_random_op(&hugr, bounded, "RandomIntBounded")); + assert!(engine.execution_error.is_none()); + match engine.wire_state.classical_values.get(&(bounded, 0)) { + Some(ClassicalValue::Int(v)) => assert!((0..100).contains(v)), + other => panic!("RandomIntBounded port 0 must be the value, got {other:?}"), + } + assert!(matches!( + engine.wire_state.classical_values.get(&(bounded, 1)), + Some(ClassicalValue::RngContext(_)) + )); + + // RandomFloat/RandomAdvance/RandomInt/Delete each read the context + // from the PREVIOUS op's stored output -- no more seeding. + assert!(engine.handle_random_op(&hugr, float, "RandomFloat")); + match engine.wire_state.classical_values.get(&(float, 0)) { + Some(ClassicalValue::Float(f)) => assert!((0.0..1.0).contains(f)), + other => panic!("RandomFloat port 0 must be the value, got {other:?}"), + } + assert!(matches!( + engine.wire_state.classical_values.get(&(float, 1)), + Some(ClassicalValue::RngContext(_)) + )); + + seed_input(&mut engine, advance, 1, ClassicalValue::Int(-1)); + assert!(engine.handle_random_op(&hugr, advance, "RandomAdvance")); + + assert!(engine.handle_random_op(&hugr, int, "RandomInt")); + match engine.wire_state.classical_values.get(&(int, 0)) { + Some(ClassicalValue::Int(v)) => { + assert!( + (0..=i64::from(u32::MAX)).contains(v), + "int<32> value, got {v}" + ); + } + other => panic!("RandomInt port 0 must be the value, got {other:?}"), + } + assert!(matches!( + engine.wire_state.classical_values.get(&(int, 1)), + Some(ClassicalValue::RngContext(_)) + )); + + assert!(engine.handle_random_op(&hugr, delete, "DeleteRNGContext")); + assert!(engine.extension_state.rng_contexts.is_empty()); + + // An empty range has no value to produce: bound 0 must poison the + // execution, not clamp. + let mut poisoned = HugrEngine::default(); + seed_input(&mut poisoned, bounded, 0, ClassicalValue::RngContext(7)); + poisoned + .extension_state + .rng_contexts + .insert(7, RngContextState::new(1)); + seed_input(&mut poisoned, bounded, 1, ClassicalValue::Int(0)); + assert!(poisoned.handle_random_op(&hugr, bounded, "RandomIntBounded")); + let fault = poisoned.execution_error.expect("bound 0 must poison"); + assert!(fault.contains("not positive"), "unexpected fault: {fault}"); + } + #[test] fn test_ry_angle_tuple_runtime_execution() { // End-to-end guard for the RUNTIME classical value chain of guppy's diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index ff7d579d3..ada1fb62a 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -144,21 +144,8 @@ impl HugrEngine { } } - // A zero-op case propagates outputs and marks the Conditional - // processed inside expand_conditional with no later completion - // event to observe it -- run the same completion hooks the - // direct expansion path runs, or the enclosing block/loop - // strands with no queued work. - if !self - .active_cases - .values() - .any(|c| c.conditional_node == cond_node) - { - self.check_cfg_block_completion(&hugr, cond_node); - self.check_tailloop_body_completion(&hugr, cond_node); - self.queue_ready_successors(&hugr, cond_node); - self.retry_pending_bool_reads(); - } + // expand_conditional runs the zero-op-case completion hooks + // itself, so nothing more is needed here. debug!( "Resolved pending Conditional {cond_node:?}, branch {branch_index} selected, added {num_entry_nodes} entry nodes" @@ -195,15 +182,22 @@ impl HugrEngine { } } - // Propagate outputs for completed cases + // Propagate outputs for completed cases. Removing the case UP FRONT + // doubles as a re-entrancy guard: the check_case_completion recursion + // below can complete a case this loop also collected. for (case_node, cond_node) in completed_cases { + if self.active_cases.remove(&case_node).is_none() { + continue; + } debug!("Case {case_node:?} complete, propagating outputs to Conditional {cond_node:?}"); self.propagate_conditional_outputs(hugr, cond_node, case_node); - self.active_cases.remove(&case_node); // The Conditional's outputs exist only now: re-run the checks // that treat the Conditional as complete (block completion gates - // on the case being done) and wake its consumers. + // on the case being done) and wake its consumers. The Conditional + // may itself be the last op of an ENCLOSING case, so check case + // completion too (recursing one nesting level per call). + self.check_case_completion(hugr, cond_node); self.check_cfg_block_completion(hugr, cond_node); self.check_tailloop_body_completion(hugr, cond_node); // A TailLoop whose control Sum rides through this Conditional's @@ -613,7 +607,8 @@ impl HugrEngine { } // Register this Case as active so we can propagate outputs when complete - if ops_in_case.is_empty() { + let case_completed_inline = ops_in_case.is_empty(); + if case_completed_inline { // No ops in this Case - propagate outputs immediately debug!("Case {selected_case:?} has no ops, propagating outputs immediately"); self.propagate_conditional_outputs(hugr, cond_node, selected_case); @@ -637,6 +632,21 @@ impl HugrEngine { // Mark the Conditional as processed self.processed.insert(cond_node); + // A zero-op case completed inline above with no later completion + // event to observe it: check_case_completion never fires for it, so + // run the same wake-up sequence here (after the processed mark, or + // the readiness checks still see the Conditional as pending). A + // consumer gated solely on this Conditional -- e.g. a zero-argument + // Call sequenced behind it by an order edge -- otherwise starves. + if case_completed_inline { + self.check_case_completion(hugr, cond_node); + self.check_cfg_block_completion(hugr, cond_node); + self.check_tailloop_body_completion(hugr, cond_node); + self.try_resolve_pending_tailloops(); + self.queue_ready_successors(hugr, cond_node); + self.retry_pending_bool_reads(); + } + entry_nodes } diff --git a/crates/pecos-hugr/src/engine/handlers/futures.rs b/crates/pecos-hugr/src/engine/handlers/futures.rs index ea9686db8..f89154cd0 100644 --- a/crates/pecos-hugr/src/engine/handlers/futures.rs +++ b/crates/pecos-hugr/src/engine/handlers/futures.rs @@ -111,13 +111,16 @@ impl HugrEngine { } "Free" => { // Free: Future -> () - // Discard the Future without reading - if let Some(value) = self.get_input_value(hugr, node, 0) - && let ClassicalValue::Future(future_id) = value - { - self.extension_state.futures.remove(&future_id); - debug!("Free future {future_id}"); - } + // Discard the Future without reading. Defer until the input + // resolves to an actual Future: succeeding without one marks + // the node processed while its producer never ran. + let Some(ClassicalValue::Future(future_id)) = self.get_input_value(hugr, node, 0) + else { + debug!("Free at {node:?}: future not ready, deferring"); + return false; + }; + self.extension_state.futures.remove(&future_id); + debug!("Free future {future_id}"); true } _ => { diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index 8a55eedd3..a4022eea3 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -197,8 +197,11 @@ impl HugrEngine { match op_name { "NewRNGContext" => { - // NewRNGContext: int<64> -> RNGContext - // Create a new RNG context with the given seed + // NewRNGContext: int<64> -> Option + // Create a new RNG context with the given seed. The + // signature returns an option (None on a second call); this + // engine has no global-context restriction, so it always + // produces Some. let Some(seed) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_uint()) @@ -214,11 +217,15 @@ impl HugrEngine { .rng_contexts .insert(ctx_id, RngContextState::new(seed)); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); + self.wire_state.classical_values.insert( + (node, 0), + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::RngContext(ctx_id)], + }, + ); - debug!("NewRNGContext with seed {seed} -> context {ctx_id}"); + debug!("NewRNGContext with seed {seed} -> Some(context {ctx_id})"); true } "DeleteRNGContext" => { @@ -234,7 +241,7 @@ impl HugrEngine { true } "RandomFloat" => { - // RandomFloat: RNGContext -> (RNGContext, float64) + // RandomFloat: RNGContext -> (float64, RNGContext) // Generate a random float in [0, 1) let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) else { @@ -243,40 +250,41 @@ impl HugrEngine { }; let random_float = self.generate_random_float(ctx_id); - // Output port 0: RNGContext (pass through) + // Value first, context second, per the extension signature self.wire_state .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); - // Output port 1: random float + .insert((node, 0), ClassicalValue::Float(random_float)); self.wire_state .classical_values - .insert((node, 1), ClassicalValue::Float(random_float)); + .insert((node, 1), ClassicalValue::RngContext(ctx_id)); debug!("RandomFloat: generated {random_float}"); true } "RandomInt" => { - // RandomInt: RNGContext -> (RNGContext, int<32>) + // RandomInt: RNGContext -> (int<32>, RNGContext) // Generate a random 32-bit integer let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) else { debug!("RandomInt at {node:?}: context not ready, deferring"); return false; }; - let random_int = self.generate_random_u64(ctx_id) as i64; + // The output is int<32>: keep only the low 32 bits + #[allow(clippy::cast_possible_truncation)] // intentional 32-bit mask + let random_int = i64::from(self.generate_random_u64(ctx_id) as u32); self.wire_state .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); + .insert((node, 0), ClassicalValue::Int(random_int)); self.wire_state .classical_values - .insert((node, 1), ClassicalValue::Int(random_int)); + .insert((node, 1), ClassicalValue::RngContext(ctx_id)); debug!("RandomInt: generated {random_int}"); true } "RandomIntBounded" => { - // RandomIntBounded: (RNGContext, int<32>) -> (RNGContext, int<32>) + // RandomIntBounded: (RNGContext, int<32>) -> (int<32>, RNGContext) // Generate a random integer in [0, bound) let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) else { @@ -288,15 +296,22 @@ impl HugrEngine { debug!("RandomIntBounded at {node:?}: bound not ready, deferring"); return false; }; - let bound = bound.max(1) as u64; - let random_val = self.generate_random_u64(ctx_id) % bound; + if bound <= 0 { + // [0, bound) is empty: there is no value this op could + // produce, so clamping would fabricate a result. + self.execution_error = Some(format!( + "RandomIntBounded at {node:?}: bound {bound} is not positive" + )); + return true; + } + let random_val = self.generate_random_u64(ctx_id) % bound as u64; self.wire_state .classical_values - .insert((node, 0), ClassicalValue::RngContext(ctx_id)); + .insert((node, 0), ClassicalValue::Int(random_val as i64)); self.wire_state .classical_values - .insert((node, 1), ClassicalValue::Int(random_val as i64)); + .insert((node, 1), ClassicalValue::RngContext(ctx_id)); debug!("RandomIntBounded({bound}): generated {random_val}"); true diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index 5d8bb8455..24ea63b18 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -378,13 +378,6 @@ def boolean_expr_test() -> bool: class TestControlFlow: """Test advanced control flow patterns.""" - @pytest.mark.xfail( - reason=( - "engine stalls on this nested-loop shape (starved deferred node; nested-container tracking is a " - "known follow-up) -- fails loud instead of silently truncating" - ), - strict=True, - ) def test_nested_loops(self, tester: ExtendedGuppyTester) -> None: """Test nested loop structures.""" diff --git a/python/quantum-pecos/tests/guppy/test_semantic_sweep.py b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py new file mode 100644 index 000000000..c30141f83 --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py @@ -0,0 +1,296 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Dynamic semantic sweep: execute diverse guppy programs end-to-end. + +Every static review of the HUGR engine shared one blind spot: nobody RAN +adversarial programs against it. Each test here is a deterministic program +whose classical computation gates an X on a fresh qubit -- the measurement +is 1 iff the engine computed the exact expected value, so a wrong result, +a stalled loop, or a mis-propagated wire fails loudly. Programs are chosen +to cross-cut the semantics the engine implements: Euclidean division, +logical shifts, comparison chains, nested and zero-iteration loops, while +loops, measurement-conditioned branches, function calls, tuples, and +sequential loops sharing state. +""" + +from guppylang import guppy +from guppylang.std.quantum import h, measure, qubit, x +from pecos import Guppy, sim +from pecos_rslib import state_vector + + +def _expect_all_ones(prog, shots: int = 3) -> None: + results = sim(Guppy(prog)).qubits(4).quantum(state_vector()).seed(7).run(shots).to_dict() + raw_measurements = results["measurements"] + values = [m[-1] if isinstance(m, list) else m for m in raw_measurements] + assert values == [1] * shots, f"semantic anchor failed: {values}" + + +def test_euclidean_matrix() -> None: + """Several signed division/modulo identities in one predicate.""" + + @guppy + def euclid_matrix() -> bool: + q = qubit() + ok = (-7) % 3 == 2 + ok = ok and (-7) // 3 == -3 + ok = ok and 7 % 3 == 1 + ok = ok and 7 // 3 == 2 + ok = ok and (-1) % 2 == 1 + ok = ok and (-9) // 2 == -5 + if ok: + x(q) + return measure(q) + + _expect_all_ones(euclid_matrix) + + +def test_shift_chain() -> None: + """Shift identities on positive values.""" + + @guppy + def shift_chain() -> bool: + q = qubit() + a = 1 + ok = (a << 10) == 1024 + ok = ok and (1024 >> 3) == 128 + ok = ok and (0 << 5) == 0 + ok = ok and (7 >> 3) == 0 + if ok: + x(q) + return measure(q) + + _expect_all_ones(shift_chain) + + +def test_comparison_chain_negatives() -> None: + """Signed ordering comparisons across zero.""" + + @guppy + def cmp_chain() -> bool: + q = qubit() + a = -5 + b = 3 + ok = a < b + ok = ok and a <= -5 + ok = ok and b > a + ok = ok and b >= 3 + ok = ok and a != b + ok = ok and (a + 8) == b + if ok: + x(q) + return measure(q) + + _expect_all_ones(cmp_chain) + + +def test_nested_loop_accumulation() -> None: + """A 3x4 nested loop must accumulate exactly 12.""" + + @guppy + def nested_accumulate() -> bool: + q = qubit() + count = 0 + for _i in range(3): + for _j in range(4): + count = count + 1 + if count == 12: + x(q) + return measure(q) + + _expect_all_ones(nested_accumulate) + + +def test_zero_iteration_inner_loop() -> None: + """A zero-range inner loop must not perturb the outer accumulation.""" + + @guppy + def zero_inner() -> bool: + q = qubit() + count = 0 + for _i in range(3): + count = count + 1 + for _j in range(0): + count = count + 100 + if count == 3: + x(q) + return measure(q) + + _expect_all_ones(zero_inner) + + +def test_while_countdown() -> None: + """A while loop must run its exact number of iterations.""" + + @guppy + def countdown() -> bool: + q = qubit() + n = 5 + steps = 0 + while n > 0: + n = n - 1 + steps = steps + 1 + if n == 0 and steps == 5: + x(q) + return measure(q) + + _expect_all_ones(countdown) + + +def test_measurement_correlated_branch() -> None: + """A measured bit routed through a branch must correlate exactly.""" + + @guppy + def correlated() -> bool: + q1 = qubit() + q2 = qubit() + h(q1) + m1 = measure(q1) + if m1: + x(q2) + m2 = measure(q2) + q3 = qubit() + if m1 == m2: + x(q3) + return measure(q3) + + _expect_all_ones(correlated, shots=10) + + +def test_function_call_arithmetic() -> None: + """A called function's return value must flow back exactly.""" + + @guppy + def double(n: int) -> int: + return n * 2 + + @guppy + def call_arith() -> bool: + q = qubit() + if double(21) == 42 and double(-3) == -6: + x(q) + return measure(q) + + _expect_all_ones(call_arith) + + +def test_tuple_roundtrip() -> None: + """Tuple construction and unpacking must preserve both values.""" + + @guppy + def tuple_roundtrip() -> bool: + q = qubit() + pair = (3, 4) + a, b = pair + if a + b == 7 and a * b == 12: + x(q) + return measure(q) + + _expect_all_ones(tuple_roundtrip) + + +def test_sequential_loops_shared_state() -> None: + """Two sequential loops over the same accumulator must both run.""" + + @guppy + def sequential_loops() -> bool: + q = qubit() + total = 0 + for i in range(4): + total = total + i + for _j in range(2): + total = total + 10 + if total == 26: + x(q) + return measure(q) + + _expect_all_ones(sequential_loops) + + +def test_branch_chain_on_loop_counter() -> None: + """An if/elif chain evaluated inside a loop must pick each arm.""" + + @guppy + def branch_chain() -> bool: + q = qubit() + low = 0 + mid = 0 + high = 0 + for i in range(6): + if i < 2: + low = low + 1 + elif i < 4: + mid = mid + 1 + else: + high = high + 1 + if low == 2 and mid == 2 and high == 2: + x(q) + return measure(q) + + _expect_all_ones(branch_chain) + + +def test_mixed_arithmetic_expression() -> None: + """Composite expressions with precedence and negatives.""" + + @guppy + def mixed_expr() -> bool: + q = qubit() + ok = (5 * 7 - 3) // 4 == 8 + ok = ok and (-13) % 5 == 2 + ok = ok and (2 + 3) * (7 - 4) == 15 + if ok: + x(q) + return measure(q) + + _expect_all_ones(mixed_expr) + + +def test_measured_bits_in_arithmetic() -> None: + """Measured booleans used as integers must arithmetic correctly.""" + + @guppy + def bits_arith() -> bool: + q1 = qubit() + q2 = qubit() + x(q1) # deterministic 1 + m1 = measure(q1) # 1 + m2 = measure(q2) # 0 + total = int(m1) + int(m1) + int(m2) + q3 = qubit() + if total == 2: + x(q3) + return measure(q3) + + _expect_all_ones(bits_arith) + + +def test_loop_carrying_measured_state() -> None: + """A loop accumulating deterministic measurement outcomes.""" + + @guppy + def loop_measures() -> bool: + count = 0 + for _i in range(3): + q = qubit() + x(q) + if measure(q): + count = count + 1 + q_out = qubit() + if count == 3: + x(q_out) + return measure(q_out) + + _expect_all_ones(loop_measures) From ac7132d884e2f066443dd1f37bd197c7f416f283 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 10:39:07 -0600 Subject: [PATCH 353/388] Replace the boolean handler contract with a typed HandlerOutcome/ClassicalOutcome enum (Processed/Defer/Fault), making poison faults first-class handler return values --- crates/pecos-hugr/src/engine.rs | 84 ++++++++++++----- crates/pecos-hugr/src/engine/handlers.rs | 43 +++++++-- .../src/engine/handlers/arithmetic.rs | 68 ++++++++------ .../pecos-hugr/src/engine/handlers/array.rs | 48 +++++----- .../src/engine/handlers/borrow_arr.rs | 72 ++++++++------- .../src/engine/handlers/classical.rs | 90 +++++++++++-------- .../pecos-hugr/src/engine/handlers/debug.rs | 12 ++- .../pecos-hugr/src/engine/handlers/futures.rs | 28 +++--- .../pecos-hugr/src/engine/handlers/guppy.rs | 25 ++++-- .../pecos-hugr/src/engine/handlers/prelude.rs | 37 ++++---- .../pecos-hugr/src/engine/handlers/qsystem.rs | 83 ++++++++++------- .../pecos-hugr/src/engine/handlers/quantum.rs | 51 ++++++----- .../pecos-hugr/src/engine/handlers/result.rs | 42 +++++---- crates/pecos-hugr/src/engine/handlers/wasm.rs | 22 +++-- 14 files changed, 440 insertions(+), 265 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index c84068fc5..3743427e8 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -25,6 +25,7 @@ pub(crate) mod analysis; mod control_flow; mod handlers; +use handlers::{ClassicalOutcome, HandlerOutcome}; mod propagation; pub(crate) mod types; @@ -1273,22 +1274,31 @@ impl HugrEngine { ); // Execute the classical operation - let outputs = self.handle_classical_op(&hugr, current_node, &classical_op); - - // If outputs are empty, inputs weren't ready - defer this operation - if outputs.is_empty() && classical_op.num_outputs > 0 { - debug!("Classical op {current_node:?}: deferring - inputs not ready"); - // Clear stale output values so dependent ops see None and also defer - // This is critical for loops where old iteration values could be misread - for port in 0..classical_op.num_outputs { - self.wire_state - .classical_values - .remove(&(current_node, port)); + let outputs = match self.handle_classical_op(&hugr, current_node, &classical_op) { + ClassicalOutcome::Outputs(outputs) => outputs, + ClassicalOutcome::Defer if classical_op.num_outputs > 0 => { + debug!("Classical op {current_node:?}: deferring - inputs not ready"); + // Clear stale output values so dependent ops see None and also defer + // This is critical for loops where old iteration values could be misread + for port in 0..classical_op.num_outputs { + self.wire_state + .classical_values + .remove(&(current_node, port)); + } + // Add to pending bool reads set for retry (reusing the same mechanism) + self.pending_bool_reads.insert(current_node); + continue; } - // Add to pending bool reads set for retry (reusing the same mechanism) - self.pending_bool_reads.insert(current_node); - continue; - } + // A zero-output op with nothing to store completes. + ClassicalOutcome::Defer => Vec::new(), + ClassicalOutcome::Fault(msg) => { + // Poison and mark processed; the loop-top check + // raises it before the next node fires. + self.execution_error = Some(msg); + self.processed.insert(current_node); + continue; + } + }; // Successfully resolved - remove from pending if it was there self.pending_bool_reads.remove(¤t_node); @@ -1335,7 +1345,12 @@ impl HugrEngine { let op = hugr.get_optype(current_node); let is_extension_op = op.as_extension_op().is_some(); let ext_result = self.handle_extension_op(&hugr, current_node); - if ext_result { + if let HandlerOutcome::Fault(msg) = &ext_result { + // Poison first; the processed path below still runs so the + // node does not re-fire before the loop-top check raises it. + self.execution_error = Some(msg.clone()); + } + if !matches!(ext_result, HandlerOutcome::Defer) { self.processed.insert(current_node); // Retry any pending ops that might now have their inputs ready @@ -2231,7 +2246,10 @@ mod tests { // NewRNGContext: u64 seed -> Some(context) seed_input(&mut engine, new_ctx, 0, ClassicalValue::Int(123_456)); - assert!(engine.handle_random_op(&hugr, new_ctx, "NewRNGContext")); + assert_eq!( + engine.handle_random_op(&hugr, new_ctx, "NewRNGContext"), + HandlerOutcome::Processed + ); let Some(ClassicalValue::Sum { tag: 1, values }) = engine .wire_state .classical_values @@ -2250,7 +2268,10 @@ mod tests { // seed it directly here so the chain below starts from the context. seed_input(&mut engine, bounded, 0, ClassicalValue::RngContext(*ctx_id)); seed_input(&mut engine, bounded, 1, ClassicalValue::Int(100)); - assert!(engine.handle_random_op(&hugr, bounded, "RandomIntBounded")); + assert_eq!( + engine.handle_random_op(&hugr, bounded, "RandomIntBounded"), + HandlerOutcome::Processed + ); assert!(engine.execution_error.is_none()); match engine.wire_state.classical_values.get(&(bounded, 0)) { Some(ClassicalValue::Int(v)) => assert!((0..100).contains(v)), @@ -2263,7 +2284,10 @@ mod tests { // RandomFloat/RandomAdvance/RandomInt/Delete each read the context // from the PREVIOUS op's stored output -- no more seeding. - assert!(engine.handle_random_op(&hugr, float, "RandomFloat")); + assert_eq!( + engine.handle_random_op(&hugr, float, "RandomFloat"), + HandlerOutcome::Processed + ); match engine.wire_state.classical_values.get(&(float, 0)) { Some(ClassicalValue::Float(f)) => assert!((0.0..1.0).contains(f)), other => panic!("RandomFloat port 0 must be the value, got {other:?}"), @@ -2274,9 +2298,15 @@ mod tests { )); seed_input(&mut engine, advance, 1, ClassicalValue::Int(-1)); - assert!(engine.handle_random_op(&hugr, advance, "RandomAdvance")); + assert_eq!( + engine.handle_random_op(&hugr, advance, "RandomAdvance"), + HandlerOutcome::Processed + ); - assert!(engine.handle_random_op(&hugr, int, "RandomInt")); + assert_eq!( + engine.handle_random_op(&hugr, int, "RandomInt"), + HandlerOutcome::Processed + ); match engine.wire_state.classical_values.get(&(int, 0)) { Some(ClassicalValue::Int(v)) => { assert!( @@ -2291,7 +2321,10 @@ mod tests { Some(ClassicalValue::RngContext(_)) )); - assert!(engine.handle_random_op(&hugr, delete, "DeleteRNGContext")); + assert_eq!( + engine.handle_random_op(&hugr, delete, "DeleteRNGContext"), + HandlerOutcome::Processed + ); assert!(engine.extension_state.rng_contexts.is_empty()); // An empty range has no value to produce: bound 0 must poison the @@ -2303,8 +2336,11 @@ mod tests { .rng_contexts .insert(7, RngContextState::new(1)); seed_input(&mut poisoned, bounded, 1, ClassicalValue::Int(0)); - assert!(poisoned.handle_random_op(&hugr, bounded, "RandomIntBounded")); - let fault = poisoned.execution_error.expect("bound 0 must poison"); + let HandlerOutcome::Fault(fault) = + poisoned.handle_random_op(&hugr, bounded, "RandomIntBounded") + else { + panic!("bound 0 must fault"); + }; assert!(fault.contains("not positive"), "unexpected fault: {fault}"); } diff --git a/crates/pecos-hugr/src/engine/handlers.rs b/crates/pecos-hugr/src/engine/handlers.rs index 3667cde75..aec37cbfd 100644 --- a/crates/pecos-hugr/src/engine/handlers.rs +++ b/crates/pecos-hugr/src/engine/handlers.rs @@ -63,6 +63,37 @@ use tket::hugr::{Hugr, HugrView, Node}; use super::HugrEngine; +/// Outcome of an extension-op handler. +/// +/// This replaces the old boolean contract (`true` = handled, `false` = +/// defer) so that fatal faults are a first-class result instead of a side +/// channel: a handler that cannot ever produce a value must not choose +/// between fabricating one and silently deferring forever. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum HandlerOutcome { + /// The op executed; its outputs (if any) are stored on its wires. + Processed, + /// Inputs not ready, or the op is unknown to the engine: park the node + /// for retry. A node that defers forever surfaces in the stall report. + Defer, + /// Fatal fault: poison the execution with this message. + Fault(String), +} + +/// Outcome of the classical-op executor ([`HugrEngine::handle_classical_op`]). +/// +/// Same three-way contract as [`HandlerOutcome`], but `Processed` carries +/// the computed `(output_port, value)` pairs for the caller to store. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum ClassicalOutcome { + /// The op executed; store these `(port, value)` pairs on its wires. + Outputs(Vec<(usize, crate::engine::types::ClassicalValue)>), + /// Inputs not ready or unconvertible: park the node for retry. + Defer, + /// Fatal fault: poison the execution with this message. + Fault(String), +} + impl HugrEngine { /// Handle extension operations from various tket extensions. /// @@ -94,13 +125,13 @@ impl HugrEngine { /// /// # Returns /// - /// Returns `true` if the operation was handled, `false` otherwise - /// (including when a handler's inputs are not ready yet -- the caller - /// defers such nodes for retry). - pub(crate) fn handle_extension_op(&mut self, hugr: &Hugr, node: Node) -> bool { + /// Returns the [`HandlerOutcome`]: `Processed` when the op executed, + /// `Defer` when its inputs are not ready yet or the op is unknown (the + /// caller parks such nodes for retry), `Fault` for a fatal fault. + pub(crate) fn handle_extension_op(&mut self, hugr: &Hugr, node: Node) -> HandlerOutcome { let op = hugr.get_optype(node); let Some(ext_op) = op.as_extension_op() else { - return false; + return HandlerOutcome::Defer; }; let ext_id = ext_op.extension_id(); @@ -128,7 +159,7 @@ impl HugrEngine { "arithmetic.float" => self.handle_float_op(hugr, node, &op_name), "arithmetic.int" => self.handle_int_op(hugr, node, &op_name), "arithmetic.conversions" => self.handle_conversions_op(hugr, node, &op_name), - _ => false, + _ => HandlerOutcome::Defer, } } } diff --git a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs index 6d72e9c45..6f64e10bd 100644 --- a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs +++ b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs @@ -23,12 +23,18 @@ use log::debug; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { /// Handle `arithmetic.float` operations (transcendental functions, etc.). #[allow(clippy::too_many_lines)] - pub(crate) fn handle_float_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_float_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing arithmetic.float operation: {op_name} at {node:?}"); // Get input values @@ -120,7 +126,7 @@ impl HugrEngine { _ => { debug!("Unknown arithmetic.float operation: {op_name}"); - return false; + return HandlerOutcome::Defer; } }; @@ -128,13 +134,13 @@ impl HugrEngine { // processed with no output would strand every consumer. let Some(value) = result else { debug!("arithmetic.float.{op_name} at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; self.wire_state .classical_values .insert((node, 0), ClassicalValue::Float(value)); debug!("arithmetic.float.{op_name}: result = {value}"); - true + HandlerOutcome::Processed } /// Handle `arithmetic.int` operations (extended integer operations). @@ -143,7 +149,12 @@ impl HugrEngine { clippy::cast_sign_loss, // shift amounts are clamped to 0-63 before cast to u32 clippy::cast_possible_truncation // shift amounts are clamped before cast )] - pub(crate) fn handle_int_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_int_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing arithmetic.int operation: {op_name} at {node:?}"); // Ops with a classify_classical_op entry (add/sub/mul/div/mod/ @@ -223,7 +234,7 @@ impl HugrEngine { _ => { debug!("Unknown arithmetic.int operation: {op_name}"); - return false; + return HandlerOutcome::Defer; } }; @@ -232,19 +243,19 @@ impl HugrEngine { // never retried once processed). let Some(value) = result else { debug!("arithmetic.int.{op_name} at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; self.wire_state .classical_values .insert((node, 0), ClassicalValue::Int(value)); debug!("arithmetic.int.{op_name}: result = {value}"); - true + HandlerOutcome::Processed } /// `inarrow_s`/`inarrow_u`: narrow to width `N`, returning /// `sum_with_error(int)` -- error variant (tag 0) when the value does /// not fit, value variant (tag 1) otherwise. - fn handle_inarrow(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + fn handle_inarrow(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> HandlerOutcome { let signed = !op_name.ends_with('u'); // Target log-width is the SECOND type arg (source is the first). let target_log_width = hugr @@ -263,7 +274,7 @@ impl HugrEngine { let bits = 1u32 << target_log_width.min(6); let Some(v) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) else { debug!("arithmetic.int.{op_name} at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let fits = if signed { if bits >= 64 { @@ -291,7 +302,7 @@ impl HugrEngine { }; debug!("arithmetic.int.{op_name}: {result:?}"); self.wire_state.classical_values.insert((node, 0), result); - true + HandlerOutcome::Processed } /// Handle `arithmetic.conversions` operations (int/float conversions). @@ -306,7 +317,12 @@ impl HugrEngine { clippy::cast_possible_truncation, clippy::cast_sign_loss )] - pub(crate) fn handle_conversions_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_conversions_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing arithmetic.conversions operation: {op_name} at {node:?}"); match op_name { @@ -316,7 +332,7 @@ impl HugrEngine { let Some(value) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) else { debug!("convert_s at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = value as f64; self.wire_state @@ -331,7 +347,7 @@ impl HugrEngine { .and_then(|v| v.as_uint()) else { debug!("convert_u at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = value as f64; self.wire_state @@ -348,7 +364,7 @@ impl HugrEngine { .and_then(|v| v.as_uint()) else { debug!("ifromusize at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; #[allow(clippy::cast_possible_wrap)] let result = value as i64; @@ -361,7 +377,7 @@ impl HugrEngine { let Some(value) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) else { debug!("itousize at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; #[allow(clippy::cast_sign_loss)] let result = value as u64; @@ -379,7 +395,7 @@ impl HugrEngine { .and_then(|v| v.as_float()) else { debug!("trunc_s at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = value.trunc() as i64; self.wire_state @@ -394,7 +410,7 @@ impl HugrEngine { .and_then(|v| v.as_float()) else { debug!("trunc_u at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; // Clamp to non-negative before converting let clamped = value.max(0.0).trunc(); @@ -412,7 +428,7 @@ impl HugrEngine { .and_then(|v| v.as_float()) else { debug!("ceil_s at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = value.ceil() as i64; self.wire_state @@ -426,7 +442,7 @@ impl HugrEngine { .and_then(|v| v.as_float()) else { debug!("ceil_u at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let clamped = value.max(0.0).ceil(); let result = clamped as u64; @@ -441,7 +457,7 @@ impl HugrEngine { .and_then(|v| v.as_float()) else { debug!("floor_s at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = value.floor() as i64; self.wire_state @@ -455,7 +471,7 @@ impl HugrEngine { .and_then(|v| v.as_float()) else { debug!("floor_u at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let clamped = value.max(0.0).floor(); let result = clamped as u64; @@ -472,7 +488,7 @@ impl HugrEngine { .and_then(|v| v.as_float()) else { debug!("round_s at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = value.round() as i64; self.wire_state @@ -486,7 +502,7 @@ impl HugrEngine { .and_then(|v| v.as_float()) else { debug!("round_u at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let clamped = value.max(0.0).round(); let result = clamped as u64; @@ -498,10 +514,10 @@ impl HugrEngine { _ => { debug!("Unknown arithmetic.conversions operation: {op_name}"); - return false; + return HandlerOutcome::Defer; } } - true + HandlerOutcome::Processed } } diff --git a/crates/pecos-hugr/src/engine/handlers/array.rs b/crates/pecos-hugr/src/engine/handlers/array.rs index d1ba4be85..83d12f300 100644 --- a/crates/pecos-hugr/src/engine/handlers/array.rs +++ b/crates/pecos-hugr/src/engine/handlers/array.rs @@ -35,6 +35,7 @@ use tket::hugr::ops::OpTrait; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { @@ -43,7 +44,12 @@ impl HugrEngine { clippy::too_many_lines, clippy::cast_possible_truncation // Array indices in simulation context won't exceed usize )] - pub(crate) fn handle_array_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_array_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing collections.array operation: {op_name} at {node:?}"); match op_name { @@ -61,7 +67,7 @@ impl HugrEngine { elements.push(ClassicalValue::QubitRef(qubit_id)); } else { debug!("new_array at {node:?}: element {port} not ready, deferring"); - return false; + return HandlerOutcome::Defer; } } @@ -69,14 +75,14 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 0), ClassicalValue::Array(elements)); - true + HandlerOutcome::Processed } "unpack" => { // [array] -> [T; n]: each element on its own output port. let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) else { debug!("array.unpack at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; for (port, value) in elements.into_iter().enumerate() { if let ClassicalValue::QubitRef(qubit_id) = &value { @@ -87,7 +93,7 @@ impl HugrEngine { self.wire_state.classical_values.insert((node, port), value); } debug!("array.unpack at {node:?}: unpacked"); - true + HandlerOutcome::Processed } "get" | "Get" | "index" | "Index" => { // [array, usize] -> [option, array]: option on port 0 @@ -95,7 +101,7 @@ impl HugrEngine { let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) else { debug!("array.get at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(index) = self .get_input_value(hugr, node, 1) @@ -103,7 +109,7 @@ impl HugrEngine { .map(|v| v as usize) else { debug!("array.get at {node:?}: index not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = match elements.get(index) { @@ -121,7 +127,7 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 1), ClassicalValue::Array(elements)); - true + HandlerOutcome::Processed } "set" | "Set" => { // [array, usize, T] -> [either([T, array], [T, array])]: @@ -131,7 +137,7 @@ impl HugrEngine { let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) else { debug!("array.set at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(index) = self .get_input_value(hugr, node, 1) @@ -139,7 +145,7 @@ impl HugrEngine { .map(|v| v as usize) else { debug!("array.set at {node:?}: index not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let new_value = if let Some(qubit_id) = self.get_input_qubit(hugr, node, 2) { ClassicalValue::QubitRef(qubit_id) @@ -147,7 +153,7 @@ impl HugrEngine { value } else { debug!("array.set at {node:?}: value not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = if let Some(slot) = elements.get_mut(index) { @@ -165,7 +171,7 @@ impl HugrEngine { } }; self.wire_state.classical_values.insert((node, 0), result); - true + HandlerOutcome::Processed } "swap" | "Swap" => { // [array, usize, usize] -> [either([array], [array])]: @@ -174,7 +180,7 @@ impl HugrEngine { let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) else { debug!("array.swap at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(i) = self .get_input_value(hugr, node, 1) @@ -182,7 +188,7 @@ impl HugrEngine { .map(|v| v as usize) else { debug!("array.swap at {node:?}: first index not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(j) = self .get_input_value(hugr, node, 2) @@ -190,7 +196,7 @@ impl HugrEngine { .map(|v| v as usize) else { debug!("array.swap at {node:?}: second index not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = if i < elements.len() && j < elements.len() { @@ -211,7 +217,7 @@ impl HugrEngine { } }; self.wire_state.classical_values.insert((node, 0), result); - true + HandlerOutcome::Processed } "pop_left" | "pop_right" => { // [array] -> [option<(T, array)>]: the empty @@ -220,7 +226,7 @@ impl HugrEngine { let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) else { debug!("array.{op_name} at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = if elements.is_empty() { debug!("array.{op_name} at {node:?}: empty array -> None variant"); @@ -244,7 +250,7 @@ impl HugrEngine { } }; self.wire_state.classical_values.insert((node, 0), result); - true + HandlerOutcome::Processed } "discard_empty" => { // [array<0, T>] -> []: consume an empty array. Defer until @@ -252,10 +258,10 @@ impl HugrEngine { // producer is pending. if self.get_input_value(hugr, node, 0).is_none() { debug!("array.discard_empty at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; } debug!("array.discard_empty: array consumed"); - true + HandlerOutcome::Processed } _ => { // Unknown/unimplemented array op (e.g. `repeat`, whose real @@ -264,7 +270,7 @@ impl HugrEngine { // stall report instead of silently passing values through // as if the op were an identity wire. debug!("Unknown collections.array operation: {op_name} at {node:?}, deferring"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs index d6dcd79e9..dd1b083dc 100644 --- a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs +++ b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs @@ -35,12 +35,18 @@ use log::debug; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { /// Handle `collections.borrow_arr` operations. #[allow(clippy::too_many_lines)] - pub(crate) fn handle_borrow_arr_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_borrow_arr_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing collections.borrow_arr operation: {op_name} at {node:?}"); match op_name { @@ -63,7 +69,7 @@ impl HugrEngine { }); let Some(size) = size else { debug!("new_all_borrowed at {node:?}: size unresolved, deferring"); - return false; + return HandlerOutcome::Defer; }; #[allow(clippy::cast_possible_truncation)] // Array sizes fit in usize let elements = vec![ClassicalValue::Borrowed; size as usize]; @@ -71,7 +77,7 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 0), ClassicalValue::Array(elements)); - true + HandlerOutcome::Processed } "borrow" => { // [array, usize] -> [array, elem]: take the element at the @@ -79,7 +85,7 @@ impl HugrEngine { let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) else { debug!("borrow at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize let Some(index) = self @@ -88,7 +94,7 @@ impl HugrEngine { .map(|v| v as usize) else { debug!("borrow at {node:?}: index not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(slot) = elements.get_mut(index) else { @@ -96,7 +102,7 @@ impl HugrEngine { "borrow at {node:?}: index {index} out of bounds (len={}), deferring", elements.len() ); - return false; + return HandlerOutcome::Defer; }; let element = std::mem::replace(slot, ClassicalValue::Borrowed); if matches!(element, ClassicalValue::Borrowed) { @@ -105,7 +111,7 @@ impl HugrEngine { // stall detection names this node instead of handing a // fabricated element downstream. debug!("borrow at {node:?}: slot {index} already borrowed, deferring"); - return false; + return HandlerOutcome::Defer; } if let ClassicalValue::QubitRef(qubit_id) = &element { @@ -116,14 +122,14 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::Array(elements)); debug!("borrow[{index}]: extracted element"); - true + HandlerOutcome::Processed } "return" => { // [array, usize, elem] -> [array]: fill the hole at the index. let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) else { debug!("return at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize let Some(index) = self @@ -132,7 +138,7 @@ impl HugrEngine { .map(|v| v as usize) else { debug!("return at {node:?}: index not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; // A qubit element arrives on the qubit wire; other elements // as classical values. @@ -142,7 +148,7 @@ impl HugrEngine { value } else { debug!("return at {node:?}: element not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(slot) = elements.get_mut(index) else { @@ -150,21 +156,21 @@ impl HugrEngine { "return at {node:?}: index {index} out of bounds (len={}), deferring", elements.len() ); - return false; + return HandlerOutcome::Defer; }; *slot = element; debug!("return[{index}]: element returned to borrow array"); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Array(elements)); - true + HandlerOutcome::Processed } "is_borrowed" => { // [array, usize] -> [array, bool] let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) else { debug!("is_borrowed at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize let Some(index) = self @@ -173,7 +179,7 @@ impl HugrEngine { .map(|v| v as usize) else { debug!("is_borrowed at {node:?}: index not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let borrowed = elements @@ -186,7 +192,7 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 1), ClassicalValue::Bool(borrowed)); - true + HandlerOutcome::Processed } "get" => { // [array, usize] -> [Sum([[], [T]]), array]: copy the element @@ -196,7 +202,7 @@ impl HugrEngine { let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) else { debug!("get at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; #[allow(clippy::cast_possible_truncation)] // Array indices fit in usize let Some(index) = self @@ -205,7 +211,7 @@ impl HugrEngine { .map(|v| v as usize) else { debug!("get at {node:?}: index not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = match elements.get(index) { @@ -225,7 +231,7 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 1), ClassicalValue::Array(elements)); - true + HandlerOutcome::Processed } "new_array" => { // [T; n] -> [array]: construct from elements. A missing @@ -244,14 +250,14 @@ impl HugrEngine { debug!( "borrow_arr.new_array at {node:?}: element {port} not ready, deferring" ); - return false; + return HandlerOutcome::Defer; } } debug!("borrow_arr.new_array: created {} elements", elements.len()); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Array(elements)); - true + HandlerOutcome::Processed } "pop_left" => { // [array] -> [Sum([[], [T, array]])]: take the @@ -260,7 +266,7 @@ impl HugrEngine { let Some(ClassicalValue::Array(mut elements)) = self.get_input_value(hugr, node, 0) else { debug!("pop_left at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let result = if elements.is_empty() { debug!("pop_left at {node:?}: empty array -> None variant"); @@ -274,7 +280,7 @@ impl HugrEngine { // would silently drop the element when it returns. // Defer, matching `borrow` on a borrowed slot. debug!("pop_left at {node:?}: front slot borrowed, deferring"); - return false; + return HandlerOutcome::Defer; } let element = elements.remove(0); debug!( @@ -287,7 +293,7 @@ impl HugrEngine { } }; self.wire_state.classical_values.insert((node, 0), result); - true + HandlerOutcome::Processed } "discard_empty" => { // [array<0,T>] -> []: consume an empty array. Defer until @@ -295,24 +301,24 @@ impl HugrEngine { // producer is pending. if self.get_input_value(hugr, node, 0).is_none() { debug!("discard_empty at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; } debug!("discard_empty: array consumed"); - true + HandlerOutcome::Processed } "clone" => { // [array] -> [array, array] (copyable elements): duplicate. let Some(value @ ClassicalValue::Array(_)) = self.get_input_value(hugr, node, 0) else { debug!("borrow_arr.clone at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; self.wire_state .classical_values .insert((node, 0), value.clone()); self.wire_state.classical_values.insert((node, 1), value); debug!("borrow_arr.clone at {node:?}: duplicated"); - true + HandlerOutcome::Processed } "to_array" | "from_array" => { // borrow_array <-> array conversions: identity on the @@ -320,11 +326,11 @@ impl HugrEngine { let Some(value @ ClassicalValue::Array(_)) = self.get_input_value(hugr, node, 0) else { debug!("borrow_arr.{op_name} at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; self.wire_state.classical_values.insert((node, 0), value); debug!("borrow_arr.{op_name} at {node:?}: converted"); - true + HandlerOutcome::Processed } "discard_all_borrowed" => { // [array] -> []: consumes the (all-borrowed) array; nothing @@ -332,10 +338,10 @@ impl HugrEngine { // is not marked done while its producer is still pending. if self.get_input_value(hugr, node, 0).is_none() { debug!("discard_all_borrowed at {node:?}: array not ready, deferring"); - return false; + return HandlerOutcome::Defer; } debug!("discard_all_borrowed: array consumed"); - true + HandlerOutcome::Processed } _ => { // Unknown op: defer so it surfaces in the stall report @@ -343,7 +349,7 @@ impl HugrEngine { debug!( "Unknown collections.borrow_arr operation: {op_name} at {node:?}, deferring" ); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 2b16e847b..c1e389a30 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -31,12 +31,16 @@ use tket::hugr::ops::OpType; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; +use crate::engine::handlers::{ClassicalOutcome, HandlerOutcome}; use crate::engine::types::{ClassicalOp, ClassicalOpType, ClassicalValue}; impl HugrEngine { - /// Execute a classical operation and return the output values. + /// Execute a classical operation. /// - /// Returns a vector of (`port_index`, value) pairs for output ports. + /// Returns [`ClassicalOutcome::Outputs`] with (`port_index`, value) + /// pairs on success, `Defer` when an input is missing or + /// unconvertible, and `Fault` for unrecoverable spec-defined errors + /// (e.g. unchecked division by zero). #[allow( clippy::too_many_lines, clippy::float_cmp, // Exact float comparison is intentional for feq/fne operations @@ -49,7 +53,7 @@ impl HugrEngine { hugr: &Hugr, node: Node, op: &ClassicalOp, - ) -> Vec<(usize, ClassicalValue)> { + ) -> ClassicalOutcome { // Collect input values let mut inputs = Vec::with_capacity(op.num_inputs); for port_idx in 0..op.num_inputs { @@ -71,11 +75,11 @@ impl HugrEngine { debug!( "Classical op {node:?}: missing input value for port {port_idx} from {wire_key:?}" ); - return vec![]; + return ClassicalOutcome::Defer; } } else { debug!("Classical op {node:?}: no source for input port {port_idx}"); - return vec![]; + return ClassicalOutcome::Defer; } } @@ -88,12 +92,13 @@ impl HugrEngine { return op .const_value .as_ref() - .map(|value| vec![(0, value.clone())]) - .unwrap_or_default(); + .map_or(ClassicalOutcome::Defer, |value| { + ClassicalOutcome::Outputs(vec![(0, value.clone())]) + }); } ClassicalOpType::MakeTuple => { // MakeTuple combines all inputs into a single tuple - return vec![(0, ClassicalValue::Tuple(inputs))]; + return ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Tuple(inputs))]); } ClassicalOpType::UnpackTuple => { // UnpackTuple takes a single tuple input and produces multiple @@ -109,28 +114,33 @@ impl HugrEngine { }, ) => { // Return each element on its respective output port - return elements.into_iter().enumerate().collect(); + return ClassicalOutcome::Outputs( + elements.into_iter().enumerate().collect(), + ); } Some(value) => { // If it's a single non-tuple value, just pass it through on port 0 - return vec![(0, value)]; + return ClassicalOutcome::Outputs(vec![(0, value)]); } - None => return vec![], + None => return ClassicalOutcome::Defer, } } ClassicalOpType::TagSum => { // Tag wraps its inputs into the given variant of a sum. let OpType::Tag(tag_op) = hugr.get_optype(node) else { - debug!("TagSum at {node:?}: node is not a Tag op"); - return vec![]; + // Classified as TagSum but not a Tag op: an engine + // invariant violation that no retry can repair. + return ClassicalOutcome::Fault(format!( + "node {node:?} classified as TagSum is not a Tag op" + )); }; - return vec![( + return ClassicalOutcome::Outputs(vec![( 0, ClassicalValue::Sum { tag: tag_op.tag, values: inputs, }, - )]; + )]); } _ => {} } @@ -376,22 +386,26 @@ impl HugrEngine { })(); if let Some(value) = result { - vec![(0, value)] + ClassicalOutcome::Outputs(vec![(0, value)]) } else if div_by_zero { // The spec says unchecked division/modulo by zero panics. - self.execution_error = Some(format!( + ClassicalOutcome::Fault(format!( "division by zero at {node:?} (the HUGR spec defines m=0 as a panic)" - )); - vec![] + )) } else { debug!("Classical op {node:?}: input type mismatch, deferring"); - vec![] + ClassicalOutcome::Defer } } /// Handle `tket.bool` operations. #[allow(clippy::too_many_lines)] // Boolean operation dispatch is inherently large - pub(crate) fn handle_bool_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_bool_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.bool operation: {op_name} at {node:?}"); match op_name { @@ -408,14 +422,14 @@ impl HugrEngine { let (Some(a), Some(b)) = (a, b) else { debug!("tket.bool.and at {node:?}: deferring - input not ready"); self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a && b)); debug!("tket.bool.and: {a} && {b} = {}", a && b); - true + HandlerOutcome::Processed } "or" => { let a = self @@ -427,14 +441,14 @@ impl HugrEngine { let (Some(a), Some(b)) = (a, b) else { debug!("tket.bool.or at {node:?}: deferring - input not ready"); self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a || b)); debug!("tket.bool.or: {a} || {b} = {}", a || b); - true + HandlerOutcome::Processed } "xor" => { let a = self @@ -446,14 +460,14 @@ impl HugrEngine { let (Some(a), Some(b)) = (a, b) else { debug!("tket.bool.xor at {node:?}: deferring - input not ready"); self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a ^ b)); debug!("tket.bool.xor: {a} ^ {b} = {}", a ^ b); - true + HandlerOutcome::Processed } "not" => { let Some(a) = self @@ -462,14 +476,14 @@ impl HugrEngine { else { debug!("tket.bool.not at {node:?}: deferring - input not ready"); self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(!a)); debug!("tket.bool.not: !{a} = {}", !a); - true + HandlerOutcome::Processed } "eq" => { let a = self @@ -481,14 +495,14 @@ impl HugrEngine { let (Some(a), Some(b)) = (a, b) else { debug!("tket.bool.eq at {node:?}: deferring - input not ready"); self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; self.pending_bool_reads.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a == b)); debug!("tket.bool.eq: {a} == {b} = {}", a == b); - true + HandlerOutcome::Processed } "make_opaque" => { // make_opaque: Sum -> tket.bool @@ -501,7 +515,7 @@ impl HugrEngine { debug!("tket.bool.make_opaque at {node:?}: deferring - input not ready"); // Track this node so it can be retried when input becomes available self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; // A present but non-bool value is the same hazard as a @@ -509,7 +523,7 @@ impl HugrEngine { let Some(value) = input_val.as_bool() else { debug!("tket.bool.make_opaque at {node:?}: deferring - input not a bool"); self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; // Successfully resolved - remove from pending if it was there @@ -518,7 +532,7 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::Bool(value)); debug!("tket.bool.make_opaque: {value}"); - true + HandlerOutcome::Processed } "read" => { // read: tket.bool -> Sum @@ -533,13 +547,13 @@ impl HugrEngine { debug!("tket.bool.read at {node:?}: deferring - input not ready"); // Track this node so it can be retried when measurement results arrive self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; let Some(value) = input_val.as_bool() else { debug!("tket.bool.read at {node:?}: deferring - input not a bool"); self.pending_bool_reads.insert(node); - return false; + return HandlerOutcome::Defer; }; // Successfully resolved - remove from pending if it was there @@ -548,11 +562,11 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::Bool(value)); debug!("tket.bool.read: {value}"); - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.bool operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/debug.rs b/crates/pecos-hugr/src/engine/handlers/debug.rs index 981a1bfae..05c71b860 100644 --- a/crates/pecos-hugr/src/engine/handlers/debug.rs +++ b/crates/pecos-hugr/src/engine/handlers/debug.rs @@ -21,10 +21,16 @@ use log::debug; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; impl HugrEngine { /// Handle tket.debug operations. - pub(crate) fn handle_debug_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_debug_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.debug operation: {op_name} at {node:?}"); if op_name == "StateResult" { @@ -32,10 +38,10 @@ impl HugrEngine { // Pass-through for simulation; optionally log state info self.propagate_qubit_array(hugr, node); debug!("StateResult at {node:?} (no-op for simulation)"); - true + HandlerOutcome::Processed } else { debug!("Unknown tket.debug operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/futures.rs b/crates/pecos-hugr/src/engine/handlers/futures.rs index f89154cd0..f958f1449 100644 --- a/crates/pecos-hugr/src/engine/handlers/futures.rs +++ b/crates/pecos-hugr/src/engine/handlers/futures.rs @@ -24,11 +24,17 @@ use log::debug; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::{ClassicalValue, FutureState}; impl HugrEngine { /// Handle tket.futures operations. - pub(crate) fn handle_futures_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_futures_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.futures operation: {op_name} at {node:?}"); match op_name { @@ -41,11 +47,11 @@ impl HugrEngine { let Some(ClassicalValue::Future(future_id)) = self.get_input_value(hugr, node, 0) else { debug!("futures.Read at {node:?}: future not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(state) = self.extension_state.futures.get(&future_id) else { debug!("futures.Read at {node:?}: unknown future {future_id}, deferring"); - return false; + return HandlerOutcome::Defer; }; match state { FutureState::Resolved(outcome) => { @@ -53,7 +59,7 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::Bool(*outcome != 0)); debug!("Read future {future_id} -> {outcome}"); - true + HandlerOutcome::Processed } FutureState::Pending { measurement_index, .. @@ -66,12 +72,12 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::Bool(result != 0)); debug!("Read future {future_id} from measurement -> {result}"); - true + HandlerOutcome::Processed } else { // Result not yet available: defer -- retried // when measurement results arrive. debug!("Read future {future_id} pending, deferring"); - false + HandlerOutcome::Defer } } } @@ -82,7 +88,7 @@ impl HugrEngine { let Some(ClassicalValue::Future(original_id)) = self.get_input_value(hugr, node, 0) else { debug!("futures.Dup at {node:?}: future not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; { // Create two new Future IDs that share the same state @@ -107,7 +113,7 @@ impl HugrEngine { debug!("Dup future {original_id} -> {new_id1}, {new_id2}"); } - true + HandlerOutcome::Processed } "Free" => { // Free: Future -> () @@ -117,15 +123,15 @@ impl HugrEngine { let Some(ClassicalValue::Future(future_id)) = self.get_input_value(hugr, node, 0) else { debug!("Free at {node:?}: future not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; self.extension_state.futures.remove(&future_id); debug!("Free future {future_id}"); - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.futures operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/guppy.rs b/crates/pecos-hugr/src/engine/handlers/guppy.rs index 6459b3a8c..de6914e11 100644 --- a/crates/pecos-hugr/src/engine/handlers/guppy.rs +++ b/crates/pecos-hugr/src/engine/handlers/guppy.rs @@ -21,11 +21,17 @@ use log::debug; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; impl HugrEngine { /// Handle `tket.guppy` operations. #[allow(clippy::unused_self)] // Consistent with other handler methods; may use self in future - pub(crate) fn handle_guppy_op(&mut self, _hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_guppy_op( + &mut self, + _hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.guppy operation: {op_name} at {node:?}"); if op_name == "drop" { @@ -33,15 +39,20 @@ impl HugrEngine { // Drop an affine type value (opposite of move semantics) // No-op for simulation - just consumes the value debug!("tket.guppy.drop at {node:?} (value consumed)"); - true + HandlerOutcome::Processed } else { debug!("Unknown tket.guppy operation: {op_name}"); - false + HandlerOutcome::Defer } } /// Handle `guppylang` extension operations. - pub(crate) fn handle_guppylang_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_guppylang_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing guppylang operation: {op_name} at {node:?}"); match op_name { @@ -51,18 +62,18 @@ impl HugrEngine { debug!("guppylang.unsupported at {node:?} - operation not supported"); // Pass through any inputs to outputs self.propagate_all_inputs(hugr, node); - true + HandlerOutcome::Processed } "partial" => { // partial: partial function application // For simulation, treat as identity/pass-through debug!("guppylang.partial at {node:?} - pass-through"); self.propagate_all_inputs(hugr, node); - true + HandlerOutcome::Processed } _ => { debug!("Unknown guppylang operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/prelude.rs b/crates/pecos-hugr/src/engine/handlers/prelude.rs index 13336a8e8..4fed11f3b 100644 --- a/crates/pecos-hugr/src/engine/handlers/prelude.rs +++ b/crates/pecos-hugr/src/engine/handlers/prelude.rs @@ -24,13 +24,19 @@ use log::debug; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { /// Handle prelude extension operations. /// /// The prelude extension provides fundamental operations used across all HUGR programs. - pub(crate) fn handle_prelude_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_prelude_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing prelude operation: {op_name} at {node:?}"); match op_name { @@ -49,7 +55,7 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 0), ClassicalValue::UInt(*n)); - return true; + return HandlerOutcome::Processed; } tket::hugr::types::TypeArg::Variable(var) => { if let Some(n) = self.resolve_call_type_arg(hugr, node, var.index()) @@ -61,7 +67,7 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 0), ClassicalValue::UInt(n)); - return true; + return HandlerOutcome::Processed; } } _ => {} @@ -74,7 +80,7 @@ impl HugrEngine { // engine's pending/retry mechanism (or, at completion, the // stall accounting) surfaces the problem instead. debug!("load_nat at {node:?}: value unresolved, deferring"); - false + HandlerOutcome::Defer } "panic" => { @@ -83,17 +89,16 @@ impl HugrEngine { // here): raise a fatal fault instead of continuing with the // panic's outputs unproduced, which either stalls with a // misleading message or completes with corrupt results. - self.execution_error = Some(format!( + HandlerOutcome::Fault(format!( "program panicked (prelude.panic executed at {node:?})" - )); - true + )) } "print" => { // Print operation - for simulation, we just pass through debug!("prelude::print at {node:?}"); self.propagate_all_inputs(hugr, node); - true + HandlerOutcome::Processed } "MakeTuple" => { @@ -117,7 +122,7 @@ impl HugrEngine { elements.push(ClassicalValue::QubitRef(qubit_id)); } else { debug!("MakeTuple at {node:?}: input {port} not ready, deferring"); - return false; + return HandlerOutcome::Defer; } } @@ -128,7 +133,7 @@ impl HugrEngine { self.wire_state .classical_values .insert((node, 0), ClassicalValue::Tuple(elements)); - true + HandlerOutcome::Processed } "UnpackTuple" => { // UnpackTuple: 1 input (a tuple) -> N outputs (the elements) @@ -158,7 +163,7 @@ impl HugrEngine { } } debug!("UnpackTuple at {node:?}: unpacked to {num_outputs} outputs"); - true + HandlerOutcome::Processed } Some(_) => { // Single non-tuple value - pass through @@ -166,16 +171,16 @@ impl HugrEngine { "UnpackTuple at {node:?}: input not a tuple, attempting pass-through" ); self.propagate_all_inputs(hugr, node); - true + HandlerOutcome::Processed } None if self.get_input_qubit(hugr, node, 0).is_some() => { // Linear (qubit) tuple: flow is resolved structurally. self.propagate_all_inputs(hugr, node); - true + HandlerOutcome::Processed } None => { debug!("UnpackTuple at {node:?}: input not ready, deferring"); - false + HandlerOutcome::Defer } } } @@ -183,14 +188,14 @@ impl HugrEngine { "Noop" | "Lift" | "Barrier" => { // Genuine identity/annotation ops: pass values through. self.propagate_all_inputs(hugr, node); - true + HandlerOutcome::Processed } _ => { // Unknown op: defer so it surfaces in the completion-time // stall report -- treating an op the engine knows nothing // about as an identity wire fabricates semantics. debug!("Unknown prelude operation: {op_name} at {node:?}, deferring"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index a4022eea3..7609c1a36 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -27,12 +27,18 @@ use pecos_core::QubitId; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::{ClassicalValue, FutureState, RngContextId, RngContextState}; impl HugrEngine { /// Handle tket.qsystem operations (lazy measurements, barriers, etc.). #[allow(clippy::too_many_lines)] // Operation dispatch is inherently large - pub(crate) fn handle_qsystem_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_qsystem_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.qsystem operation: {op_name} at {node:?}"); match op_name { @@ -41,7 +47,7 @@ impl HugrEngine { // Queue the measurement and create a Future handle let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { debug!("LazyMeasure at {node:?}: qubit not resolved, deferring"); - return false; + return HandlerOutcome::Defer; }; // Queue measurement self.message_builder.mz(&[qubit_id.0]); @@ -66,13 +72,13 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Future(future_id)); debug!("LazyMeasure on qubit {qubit_id:?}, created future {future_id}"); - true + HandlerOutcome::Processed } "LazyMeasureReset" => { // LazyMeasureReset: Qubit -> (Qubit, Future) let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { debug!("LazyMeasureReset at {node:?}: qubit not resolved, deferring"); - return false; + return HandlerOutcome::Defer; }; // Queue measurement self.message_builder.mz(&[qubit_id.0]); @@ -101,14 +107,14 @@ impl HugrEngine { .insert((node, 1), ClassicalValue::Future(future_id)); debug!("LazyMeasureReset on qubit {qubit_id:?}, created future {future_id}"); - true + HandlerOutcome::Processed } "LazyMeasureLeaked" => { // LazyMeasureLeaked: Qubit -> Future // Same as LazyMeasure but result can be 0, 1, or 2 (leaked) let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { debug!("LazyMeasureLeaked at {node:?}: qubit not resolved, deferring"); - return false; + return HandlerOutcome::Defer; }; self.message_builder.mz(&[qubit_id.0]); let measurement_index = self.measurement_state.mappings.len(); @@ -130,14 +136,14 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Future(future_id)); debug!("LazyMeasureLeaked on qubit {qubit_id:?}, created future {future_id}"); - true + HandlerOutcome::Processed } "MeasureReset" => { // MeasureReset: Qubit -> (Qubit, bool) // Atomic measure + reset (not lazy) let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) else { debug!("MeasureReset at {node:?}: qubit not resolved, deferring"); - return false; + return HandlerOutcome::Defer; }; self.message_builder.mz(&[qubit_id.0]); self.measurement_state.mappings.push((node, qubit_id)); @@ -152,7 +158,7 @@ impl HugrEngine { self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); debug!("MeasureReset on qubit {qubit_id:?}"); - true + HandlerOutcome::Processed } "RuntimeBarrier" | "StateResult" => { // Pass-through operations: input array = output array @@ -160,7 +166,7 @@ impl HugrEngine { // Propagate qubit arrays if present self.propagate_qubit_array(hugr, node); debug!("{op_name} at {node:?} (no-op for simulation)"); - true + HandlerOutcome::Processed } "TryQAlloc" => { // TryQAlloc: () -> Sum<(), Qubit> @@ -176,23 +182,28 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::UInt(1)); debug!("TryQAlloc created qubit {qubit_id:?}"); - true + HandlerOutcome::Processed } "Reset" | "Rz" | "PhasedX" | "ZZPhase" | "Measure" | "QFree" => { // These are handled as quantum ops (via hugr_op_to_gate_type) // Return false to let the quantum op handler process them - false + HandlerOutcome::Defer } _ => { debug!("Unknown tket.qsystem operation: {op_name}"); - false + HandlerOutcome::Defer } } } /// Handle `tket.qsystem.random` operations for random number generation. #[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] - pub(crate) fn handle_random_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_random_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.qsystem.random operation: {op_name} at {node:?}"); match op_name { @@ -207,7 +218,7 @@ impl HugrEngine { .and_then(|v| v.as_uint()) else { debug!("NewRNGContext at {node:?}: seed not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let ctx_id = self.extension_state.next_rng_context_id; @@ -226,7 +237,7 @@ impl HugrEngine { ); debug!("NewRNGContext with seed {seed} -> Some(context {ctx_id})"); - true + HandlerOutcome::Processed } "DeleteRNGContext" => { // DeleteRNGContext: RNGContext -> () @@ -234,11 +245,11 @@ impl HugrEngine { let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) else { debug!("DeleteRNGContext at {node:?}: context not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; self.extension_state.rng_contexts.remove(&ctx_id); debug!("DeleteRNGContext: removed context {ctx_id}"); - true + HandlerOutcome::Processed } "RandomFloat" => { // RandomFloat: RNGContext -> (float64, RNGContext) @@ -246,7 +257,7 @@ impl HugrEngine { let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) else { debug!("RandomFloat at {node:?}: context not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let random_float = self.generate_random_float(ctx_id); @@ -259,7 +270,7 @@ impl HugrEngine { .insert((node, 1), ClassicalValue::RngContext(ctx_id)); debug!("RandomFloat: generated {random_float}"); - true + HandlerOutcome::Processed } "RandomInt" => { // RandomInt: RNGContext -> (int<32>, RNGContext) @@ -267,7 +278,7 @@ impl HugrEngine { let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) else { debug!("RandomInt at {node:?}: context not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; // The output is int<32>: keep only the low 32 bits #[allow(clippy::cast_possible_truncation)] // intentional 32-bit mask @@ -281,7 +292,7 @@ impl HugrEngine { .insert((node, 1), ClassicalValue::RngContext(ctx_id)); debug!("RandomInt: generated {random_int}"); - true + HandlerOutcome::Processed } "RandomIntBounded" => { // RandomIntBounded: (RNGContext, int<32>) -> (int<32>, RNGContext) @@ -289,20 +300,19 @@ impl HugrEngine { let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) else { debug!("RandomIntBounded at {node:?}: context not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(bound) = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()) else { debug!("RandomIntBounded at {node:?}: bound not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; if bound <= 0 { // [0, bound) is empty: there is no value this op could // produce, so clamping would fabricate a result. - self.execution_error = Some(format!( + return HandlerOutcome::Fault(format!( "RandomIntBounded at {node:?}: bound {bound} is not positive" )); - return true; } let random_val = self.generate_random_u64(ctx_id) % bound as u64; @@ -314,7 +324,7 @@ impl HugrEngine { .insert((node, 1), ClassicalValue::RngContext(ctx_id)); debug!("RandomIntBounded({bound}): generated {random_val}"); - true + HandlerOutcome::Processed } "RandomAdvance" => { // RandomAdvance: (RNGContext, int<64>) -> RNGContext @@ -322,12 +332,12 @@ impl HugrEngine { let Some(ClassicalValue::RngContext(ctx_id)) = self.get_input_value(hugr, node, 0) else { debug!("RandomAdvance at {node:?}: context not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; let Some(delta) = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()) else { debug!("RandomAdvance at {node:?}: delta not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; { @@ -345,11 +355,11 @@ impl HugrEngine { debug!("RandomAdvance: advanced by {delta} steps"); } - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.qsystem.random operation: {op_name}"); - false + HandlerOutcome::Defer } } } @@ -373,7 +383,12 @@ impl HugrEngine { } /// Handle `tket.qsystem.utils` operations. - pub(crate) fn handle_utils_op(&mut self, _hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_utils_op( + &mut self, + _hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.qsystem.utils operation: {op_name} at {node:?}"); if op_name == "GetCurrentShot" { @@ -385,10 +400,10 @@ impl HugrEngine { ); debug!("GetCurrentShot: {}", self.extension_state.current_shot); - true + HandlerOutcome::Processed } else { debug!("Unknown tket.qsystem.utils operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/quantum.rs b/crates/pecos-hugr/src/engine/handlers/quantum.rs index 111f48e1a..f7db38f65 100644 --- a/crates/pecos-hugr/src/engine/handlers/quantum.rs +++ b/crates/pecos-hugr/src/engine/handlers/quantum.rs @@ -28,6 +28,7 @@ use pecos_quantum::hugr_convert::try_extract_rotation_angle; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { @@ -41,7 +42,7 @@ impl HugrEngine { hugr: &Hugr, node: Node, op_name: &str, - ) -> bool { + ) -> HandlerOutcome { debug!("Processing tket.quantum non-gate operation: {op_name} at {node:?}"); match op_name { @@ -65,11 +66,11 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Rotation(0.0)); debug!("symbolic_angle: defaulting to 0"); } - true + HandlerOutcome::Processed } // Quantum gates are handled via the quantum ops path, not here - // Return false to let them fall through to the gate handling - _ => false, + // -- defer so they fall through to the gate handling + _ => HandlerOutcome::Defer, } } @@ -144,7 +145,12 @@ impl HugrEngine { /// produce the value, the node stays deferred and the gate consumer /// (`resolve_rotation_angle`) fails loud if the statically extracted /// gate angle is also missing. - pub(crate) fn handle_rotation_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_rotation_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.rotation operation: {op_name} at {node:?}"); match op_name { @@ -159,7 +165,7 @@ impl HugrEngine { .or_else(|| try_extract_rotation_angle(hugr, node, 0).map(|turns| turns * 2.0)) else { debug!("tket.rotation.{op_name} at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; self.wire_state @@ -167,7 +173,7 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Rotation(halfturns)); debug!("tket.rotation.from_halfturns: {halfturns}"); - true + HandlerOutcome::Processed } "to_halfturns" => { // to_halfturns: Rotation -> float64 @@ -177,7 +183,7 @@ impl HugrEngine { .and_then(|v| v.as_rotation()) else { debug!("tket.rotation.to_halfturns at {node:?}: input not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; self.wire_state @@ -185,7 +191,7 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Float(halfturns)); debug!("tket.rotation.to_halfturns: {halfturns}"); - true + HandlerOutcome::Processed } "radd" => { // radd: (Rotation, Rotation) -> Rotation @@ -198,7 +204,7 @@ impl HugrEngine { .and_then(|v| v.as_rotation()); let (Some(a), Some(b)) = (a, b) else { debug!("tket.rotation.radd at {node:?}: inputs not ready, deferring"); - return false; + return HandlerOutcome::Defer; }; // Rotation addition, normalized to [0, 2) half-turns @@ -209,17 +215,22 @@ impl HugrEngine { .insert((node, 0), ClassicalValue::Rotation(sum)); debug!("tket.rotation.radd: {a} + {b} = {sum}"); - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.rotation operation: {op_name}"); - false + HandlerOutcome::Defer } } } /// Handle `tket.modifier` operations for gate modifiers. - pub(crate) fn handle_modifier_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_modifier_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.modifier operation: {op_name} at {node:?}"); // Gate modifiers change how gates are applied. @@ -232,25 +243,25 @@ impl HugrEngine { // For simulation, this is handled by the quantum backend self.propagate_qubit_array(hugr, node); debug!("ControlModifier at {node:?} (handled by quantum backend)"); - true + HandlerOutcome::Processed } "DaggerModifier" => { // DaggerModifier applies the inverse/adjoint of an operation // For simulation, this is handled by the quantum backend self.propagate_qubit_array(hugr, node); debug!("DaggerModifier at {node:?} (handled by quantum backend)"); - true + HandlerOutcome::Processed } "PowerModifier" => { // PowerModifier raises an operation to a power // For simulation, this is handled by the quantum backend self.propagate_qubit_array(hugr, node); debug!("PowerModifier at {node:?} (handled by quantum backend)"); - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.modifier operation: {op_name}"); - false + HandlerOutcome::Defer } } } @@ -261,7 +272,7 @@ impl HugrEngine { hugr: &Hugr, node: Node, op_name: &str, - ) -> bool { + ) -> HandlerOutcome { debug!("Processing tket.global_phase operation: {op_name} at {node:?}"); if op_name == "global_phase" { @@ -280,10 +291,10 @@ impl HugrEngine { "tket.global_phase: added {phase}, total = {}", self.extension_state.global_phase ); - true + HandlerOutcome::Processed } else { debug!("Unknown tket.global_phase operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/result.rs b/crates/pecos-hugr/src/engine/handlers/result.rs index a212a31d1..678b44056 100644 --- a/crates/pecos-hugr/src/engine/handlers/result.rs +++ b/crates/pecos-hugr/src/engine/handlers/result.rs @@ -25,12 +25,18 @@ use log::debug; use tket::hugr::{Hugr, HugrView, Node, NodeIndex}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::{CapturedResult, ClassicalValue, ResultValue}; impl HugrEngine { /// Handle tket.result operations for capturing output values. #[allow(clippy::too_many_lines)] - pub(crate) fn handle_result_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_result_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.result operation: {op_name} at {node:?}"); // Get the label from the first input port (typically the operation has a label parameter) @@ -48,11 +54,11 @@ impl HugrEngine { label, value: ResultValue::Bool(b), }); - true + HandlerOutcome::Processed } else { // Input not ready - defer processing debug!("result_bool at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } "result_int" => { @@ -64,12 +70,12 @@ impl HugrEngine { value: ResultValue::Int(i), }); debug!("Captured result_int: {i}"); - true + HandlerOutcome::Processed } else { // Marking the node processed without capturing would // silently drop the result; defer until the value arrives. debug!("result_int at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } "result_uint" => { @@ -81,10 +87,10 @@ impl HugrEngine { value: ResultValue::UInt(u), }); debug!("Captured result_uint: {u}"); - true + HandlerOutcome::Processed } else { debug!("result_uint at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } "result_f64" => { @@ -96,10 +102,10 @@ impl HugrEngine { value: ResultValue::Float(f), }); debug!("Captured result_f64: {f}"); - true + HandlerOutcome::Processed } else { debug!("result_f64 at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } "result_array_bool" => { @@ -117,10 +123,10 @@ impl HugrEngine { label, value: ResultValue::ArrayBool(bools), }); - true + HandlerOutcome::Processed } else { debug!("result_array_bool at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } "result_array_int" => { @@ -135,10 +141,10 @@ impl HugrEngine { label, value: ResultValue::ArrayInt(ints), }); - true + HandlerOutcome::Processed } else { debug!("result_array_int at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } "result_array_uint" => { @@ -153,10 +159,10 @@ impl HugrEngine { label, value: ResultValue::ArrayUInt(uints), }); - true + HandlerOutcome::Processed } else { debug!("result_array_uint at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } "result_array_f64" => { @@ -171,15 +177,15 @@ impl HugrEngine { label, value: ResultValue::ArrayFloat(floats), }); - true + HandlerOutcome::Processed } else { debug!("result_array_f64 at {node:?}: deferring - input not ready"); - false + HandlerOutcome::Defer } } _ => { debug!("Unknown tket.result operation: {op_name}"); - false + HandlerOutcome::Defer } } } diff --git a/crates/pecos-hugr/src/engine/handlers/wasm.rs b/crates/pecos-hugr/src/engine/handlers/wasm.rs index ded62ade2..396200c98 100644 --- a/crates/pecos-hugr/src/engine/handlers/wasm.rs +++ b/crates/pecos-hugr/src/engine/handlers/wasm.rs @@ -21,11 +21,17 @@ use log::debug; use tket::hugr::{Hugr, Node}; use crate::engine::HugrEngine; +use crate::engine::handlers::HandlerOutcome; use crate::engine::types::ClassicalValue; impl HugrEngine { /// Handle `tket.wasm` operations for WebAssembly integration. - pub(crate) fn handle_wasm_op(&mut self, hugr: &Hugr, node: Node, op_name: &str) -> bool { + pub(crate) fn handle_wasm_op( + &mut self, + hugr: &Hugr, + node: Node, + op_name: &str, + ) -> HandlerOutcome { debug!("Processing tket.wasm operation: {op_name} at {node:?}"); // WASM operations are for hybrid classical-quantum computation. @@ -40,13 +46,13 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::UInt(0)); debug!("tket.wasm.get_context: stub (no WASM support)"); - true + HandlerOutcome::Processed } "dispose_context" | "DisposeContext" => { // dispose_context: WasmContext -> () // Clean up WASM context (no-op for stub) debug!("tket.wasm.dispose_context: stub (no WASM support)"); - true + HandlerOutcome::Processed } "call" | "Call" => { // call: (WasmContext, ...) -> (WasmContext, ...) @@ -54,7 +60,7 @@ impl HugrEngine { // Stub: pass through inputs to outputs self.propagate_all_inputs(hugr, node); debug!("tket.wasm.call: stub (no WASM support)"); - true + HandlerOutcome::Processed } "lookup_by_id" | "LookupById" => { // lookup_by_id: (WasmContext, int) -> (WasmContext, WasmFunc) @@ -66,7 +72,7 @@ impl HugrEngine { .classical_values .insert((node, 1), ClassicalValue::UInt(0)); debug!("tket.wasm.lookup_by_id: stub (no WASM support)"); - true + HandlerOutcome::Processed } "lookup_by_name" | "LookupByName" => { // lookup_by_name: (WasmContext, String) -> (WasmContext, WasmFunc) @@ -78,7 +84,7 @@ impl HugrEngine { .classical_values .insert((node, 1), ClassicalValue::UInt(0)); debug!("tket.wasm.lookup_by_name: stub (no WASM support)"); - true + HandlerOutcome::Processed } "read_result" | "ReadResult" => { // read_result: WasmResult -> value @@ -87,11 +93,11 @@ impl HugrEngine { .classical_values .insert((node, 0), ClassicalValue::Int(0)); debug!("tket.wasm.read_result: stub (no WASM support)"); - true + HandlerOutcome::Processed } _ => { debug!("Unknown tket.wasm operation: {op_name}"); - false + HandlerOutcome::Defer } } } From c680ce67103c0d950e83a8c2399db7158675d550 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 11:35:08 -0600 Subject: [PATCH 354/388] Consolidate the six container-activation sites (CFG entry/transition, case expansion, TailLoop expand/continue, Call frames) onto one two-phase ContainerActivation mechanism that structurally enforces reset-before-readiness --- crates/pecos-hugr/src/engine.rs | 225 +++--------- crates/pecos-hugr/src/engine/activation.rs | 178 ++++++++++ .../pecos-hugr/src/engine/control_flow/cfg.rs | 174 +++------- .../src/engine/control_flow/conditional.rs | 90 +---- .../src/engine/control_flow/tailloop.rs | 319 ++++-------------- 5 files changed, 351 insertions(+), 635 deletions(-) create mode 100644 crates/pecos-hugr/src/engine/activation.rs diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 3743427e8..523bb19df 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -22,6 +22,7 @@ //! - [`analysis`]: HUGR static analysis and extraction functions //! - [`control_flow`]: Control flow handling (`TailLoop`, Conditional, CFG, Call) +mod activation; pub(crate) mod analysis; mod control_flow; mod handlers; @@ -713,154 +714,52 @@ impl HugrEngine { // Propagate CFG inputs to entry block's Input node self.propagate_cfg_inputs_to_entry_block(&hugr, current_node, entry_block); - // Remove entry block's quantum ops from nodes_inside_cfg_blocks - // and add ops whose predecessors are ready to the work queue + // First activation of the entry block via the shared + // mechanism (no resets -- nothing has executed yet). + // Ops inside TailLoops leave the block gate but queue + // only when their loop expands; TailLoop nodes + // themselves queue unconditionally (they handle input + // propagation during expansion). + let mut act = activation::ContainerActivation::new(); + let submit = + |act: &mut activation::ContainerActivation, + node: Node, + policy: activation::QueuePolicy| { + if self.nodes_inside_tailloops.contains(&node) { + act.ungate_block_only(node); + } else { + act.queue(node, policy); + } + }; for &op_node in &block_info.quantum_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip ops inside TailLoops - they'll be added when the loop expands - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - let preds_ready = all_predecessors_ready( - &hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ); - if !self.work_queue.contains(&op_node) - && !self.processed.contains(&op_node) - && preds_ready - { - self.work_queue.push_back(op_node); - } + submit(&mut act, op_node, activation::QueuePolicy::IfReady); } - - // Also activate Call nodes in the entry block for child in hugr.children(entry_block) { - let op = hugr.get_optype(child); - if matches!(op, OpType::Call(_)) { - self.nodes_inside_cfg_blocks.remove(&child); - // Skip Call nodes inside TailLoops - they'll be added when the loop expands - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - if !self.work_queue.contains(&child) - && !self.processed.contains(&child) - && all_predecessors_ready( - &hugr, - child, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(child); - } + if matches!(hugr.get_optype(child), OpType::Call(_)) { + submit(&mut act, child, activation::QueuePolicy::IfReady); } } - - // Also activate Conditional nodes in the entry block for &cond_node in &block_info.conditional_nodes { - self.nodes_inside_cfg_blocks.remove(&cond_node); - // Skip Conditional nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&cond_node) { - continue; - } - if !self.work_queue.contains(&cond_node) - && !self.processed.contains(&cond_node) - { - self.work_queue.push_back(cond_node); - } + submit(&mut act, cond_node, activation::QueuePolicy::Always); } - - // Also activate bool ops in the entry block for &op_node in &block_info.bool_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip bool ops inside TailLoops - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - if !self.work_queue.contains(&op_node) && !self.processed.contains(&op_node) - { - self.work_queue.push_back(op_node); - } + submit(&mut act, op_node, activation::QueuePolicy::Always); } - - // Also activate LoadConstant and classical ops in the entry block for child in hugr.children(entry_block) { - let op = hugr.get_optype(child); - if matches!(op, OpType::LoadConstant(_)) { - self.nodes_inside_cfg_blocks.remove(&child); - // Skip nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - if !self.work_queue.contains(&child) && !self.processed.contains(&child) - { - self.work_queue.push_back(child); - } + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + submit(&mut act, child, activation::QueuePolicy::Always); } - // Check for classical ops (extension ops in arithmetic.int, etc.) if self.classical_ops.contains_key(&child) { - self.nodes_inside_cfg_blocks.remove(&child); - // Skip nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - // Classical ops need their inputs ready - if !self.work_queue.contains(&child) - && !self.processed.contains(&child) - && all_predecessors_ready( - &hugr, - child, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(child); - } + submit(&mut act, child, activation::QueuePolicy::IfReady); } } - - // Also activate extension ops (tket.rotation, tket.result, etc.) - // Use block_info.extension_ops which is already filtered to exclude - // quantum_ops, bool_ops, and classical_ops (those are handled above). for &op_node in &block_info.extension_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip extension ops inside TailLoops - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - if !self.work_queue.contains(&op_node) - && !self.processed.contains(&op_node) - && all_predecessors_ready( - &hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(op_node); - } + submit(&mut act, op_node, activation::QueuePolicy::IfReady); } - - // Also activate TailLoop nodes in the entry block - // NOTE: Don't check preds_ready for TailLoops - they handle input - // propagation separately during expansion. for &tl_node in &block_info.tailloop_nodes { - self.nodes_inside_cfg_blocks.remove(&tl_node); - if !self.work_queue.contains(&tl_node) && !self.processed.contains(&tl_node) - { - self.work_queue.push_back(tl_node); - } + act.queue(tl_node, activation::QueuePolicy::Always); } + self.run_activation(&hugr, &act); let num_ops = block_info.quantum_ops.len(); let num_calls = block_info.call_nodes.len(); @@ -992,12 +891,7 @@ impl HugrEngine { continue; } debug!("TailLoop {current_node:?}: starting first iteration"); - let entry_nodes = self.expand_tailloop(&hugr, current_node); - for entry_node in entry_nodes { - if !self.work_queue.contains(&entry_node) { - self.work_queue.push_back(entry_node); - } - } + self.expand_tailloop(&hugr, current_node); } continue; } @@ -1109,52 +1003,29 @@ impl HugrEngine { }, ); - // Clear the Call node's OWN stale output wires (the - // descendant clearing below does not cover them): a - // consumer resolving against the previous - // invocation's outputs mid-call reads one-iteration- - // stale data (observed as a measure loop measuring - // each qubit one iteration late). - for port in 0..func_info.num_outputs { - self.wire_state - .classical_values - .remove(&(current_node, port)); - self.wire_state.wire_to_qubit.remove(&(current_node, port)); - } - - // Remove FuncDefn descendants from nodes_inside_func_defns - // so they can be processed now that the function is being called + // Reset the call frame via the shared mechanism: + // every descendant's processed flag AND stale wire + // values clear (critical for multiple calls to the + // same function -- with only the flags cleared, a + // Conditional inside the body can resolve from the + // PREVIOUS call's control wire and expand with stale + // case inputs before its producers re-run). The + // FuncDefn Input node keeps its wires: fresh call + // arguments were just copied onto it above. The Call + // node's OWN outputs reset too -- a consumer + // resolving against the previous invocation's + // outputs mid-call reads one-iteration-stale data. let mut descendants = BTreeSet::new(); collect_descendants(&hugr, func_defn_node, &mut descendants); + let mut act = activation::ContainerActivation::new(); for node in &descendants { self.nodes_inside_func_defns.remove(node); + act.reset(*node); } - - // Mark ALL FuncDefn descendants as unprocessed so they can be re-executed - // This is critical for supporting multiple calls to the same function - for node in &descendants { - self.processed.remove(node); - } - self.processed.remove(&cfg_node); - - // Also clear the descendants' stale wire VALUES: with - // only the processed flags cleared, a Conditional - // inside the body can resolve from the PREVIOUS - // call's control wire and expand with stale case - // inputs before its producers re-run (observed as an - // array iterator re-yielding index 0 forever). The - // FuncDefn Input node is skipped -- fresh call - // arguments were just copied onto it above. - for node in &descendants { - if *node == func_info.input_node { - continue; - } - let num_outputs = hugr.num_outputs(*node); - for port in 0..num_outputs { - self.wire_state.classical_values.remove(&(*node, port)); - self.wire_state.wire_to_qubit.remove(&(*node, port)); - } - } + act.keep_wires(func_info.input_node); + act.reset_processed(cfg_node); + act.reset_wires(current_node); + self.run_activation(&hugr, &act); // Add the CFG to the work queue to be processed if !self.work_queue.contains(&cfg_node) { diff --git a/crates/pecos-hugr/src/engine/activation.rs b/crates/pecos-hugr/src/engine/activation.rs new file mode 100644 index 000000000..a4cfc2bf7 --- /dev/null +++ b/crates/pecos-hugr/src/engine/activation.rs @@ -0,0 +1,178 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Container activation as a mechanism: the two-phase discipline. +//! +//! Every container (re-)activation -- CFG entry blocks and transitions, +//! Conditional case expansion, `TailLoop` expansion and continuation, and +//! Call frame re-activation -- must clear ALL stale state (processed flags, +//! output wires) BEFORE any readiness check queues work. Readiness +//! (`all_predecessors_ready`) consults the processed set, so interleaving +//! clear-and-queue per op category lets a consumer pass its readiness check +//! against a not-yet-cleared producer's previous-iteration flags, fire +//! early, and copy stale or missing values (the historical loop-freeze +//! class of bugs). +//! +//! Sites build a [`ContainerActivation`] batch with their own selection +//! logic (which nodes, in which order, under which queue policy); +//! [`HugrEngine::run_activation`] enforces the phase ordering so no site +//! can get it wrong again. + +use std::collections::BTreeSet; + +use tket::hugr::{Hugr, HugrView, Node}; + +use crate::engine::HugrEngine; +use crate::engine::analysis::all_predecessors_ready; + +/// Whether a node queues unconditionally or only once its predecessors are +/// processed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum QueuePolicy { + /// Queue only when `all_predecessors_ready` passes. For nodes that copy + /// their inputs at fire time (Calls, classical/extension ops): firing + /// early copies missing values and starves everything downstream. + IfReady, + /// Queue unconditionally. For nodes that defer internally until their + /// inputs resolve (Conditionals, `TailLoops`, bool reads) or that have no + /// dataflow inputs at all (`LoadConstants`). + Always, +} + +/// A batched container activation. Build with the site's selection logic, +/// then apply with [`HugrEngine::run_activation`]. +#[derive(Debug, Default)] +pub(crate) struct ContainerActivation { + /// Nodes whose processed flag clears in phase 1. + reset_processed: Vec, + /// Nodes whose stale output wires clear in phase 1. + reset_wires: Vec, + /// Wire-clear exemptions (nodes holding freshly propagated values, + /// e.g. a block or loop-body Input node). + keep_wires: BTreeSet, + /// Nodes released from the CFG-block gate only, without queueing + /// (e.g. block-level tracking of ops that an inner `TailLoop` still + /// owns and will queue at its own expansion). + ungate_block_only: Vec, + /// Nodes released from every container gate without queueing. + ungate_all: Vec, + /// Nodes to queue in phase 2, in submission order. + queue: Vec<(Node, QueuePolicy)>, +} + +impl ContainerActivation { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Phase 1: clear this node's processed flag AND stale output wires. + pub(crate) fn reset(&mut self, node: Node) { + self.reset_processed.push(node); + self.reset_wires.push(node); + } + + /// Phase 1: clear only this node's processed flag. + pub(crate) fn reset_processed(&mut self, node: Node) { + self.reset_processed.push(node); + } + + /// Phase 1: clear only this node's stale output wires. + pub(crate) fn reset_wires(&mut self, node: Node) { + self.reset_wires.push(node); + } + + /// Exempt a node from wire clearing (it holds freshly propagated + /// values). + pub(crate) fn keep_wires(&mut self, node: Node) { + self.keep_wires.insert(node); + } + + /// Release a node from the CFG-block gate without queueing it: an + /// inner container still owns it and queues it at its own expansion. + pub(crate) fn ungate_block_only(&mut self, node: Node) { + self.ungate_block_only.push(node); + } + + /// Release a node from EVERY container gate without queueing it (e.g. + /// case quantum ops queued separately as entry nodes, or ops that only + /// the retry path may queue). + pub(crate) fn ungate(&mut self, node: Node) { + self.ungate_all.push(node); + } + + /// Phase 2: release this node from every container gate and queue it + /// under the given policy. + pub(crate) fn queue(&mut self, node: Node, policy: QueuePolicy) { + self.queue.push((node, policy)); + } +} + +impl HugrEngine { + /// Apply a batched container activation with the two-phase discipline: + /// every reset happens before any readiness check. + pub(crate) fn run_activation(&mut self, hugr: &Hugr, act: &ContainerActivation) { + // PHASE 1: clear processed flags first, then stale output wires -- + // readiness checks and value resolution must not see either. + for node in &act.reset_processed { + self.processed.remove(node); + } + for node in &act.reset_wires { + if act.keep_wires.contains(node) { + continue; + } + for port in 0..hugr.num_outputs(*node) { + self.wire_state.classical_values.remove(&(*node, port)); + self.wire_state.wire_to_qubit.remove(&(*node, port)); + } + } + + // Gates: a queued node leaves every container gate (it is being + // activated now); an ungate-only node leaves just the block gate. + for node in &act.ungate_block_only { + self.nodes_inside_cfg_blocks.remove(node); + } + for node in &act.ungate_all { + self.nodes_inside_cfg_blocks.remove(node); + self.nodes_inside_cases.remove(node); + self.nodes_inside_tailloops.remove(node); + } + for (node, _) in &act.queue { + self.nodes_inside_cfg_blocks.remove(node); + self.nodes_inside_cases.remove(node); + self.nodes_inside_tailloops.remove(node); + } + + // PHASE 2: queue, respecting each node's policy. Nodes that fail + // their readiness check here are queued later by + // queue_ready_successors when their producers complete. + for &(node, policy) in &act.queue { + if self.work_queue.contains(&node) || self.processed.contains(&node) { + continue; + } + if policy == QueuePolicy::IfReady + && !all_predecessors_ready( + hugr, + node, + &self.quantum_ops, + &self.conditionals, + &self.cfgs, + &self.processed, + ) + { + continue; + } + self.work_queue.push_back(node); + } + } +} diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 624805e1b..de5c03304 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -39,6 +39,7 @@ use tket::hugr::ops::OpType; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; +use crate::engine::activation::{ContainerActivation, QueuePolicy}; use crate::engine::analysis::{ all_predecessors_ready, find_extension_ops_in_block, find_input_node, find_output_node, }; @@ -424,33 +425,27 @@ impl HugrEngine { // Activate successor block's quantum ops and Call nodes if let Some(block_info) = cfg_info.blocks.get(&to_block) { - // Clear stale classical values for all operations in this block. - // This is critical for loops: without this, nodes like tket.bool.read - // retain values from the previous iteration. Since Conditionals are - // added to the work queue before bool_ops, the Conditional would read - // the stale value and select the wrong branch. + // Re-activate the block through the shared two-phase mechanism. + // Stale wires clear for every child except the Input node (it + // holds fresh values from propagation): without this, nodes + // like tket.bool.read retain values from the previous loop + // iteration and a Conditional queued before the bool op reads + // the stale value and selects the wrong branch. Processed flags + // clear for EVERY op category before ANY readiness check: + // interleaving clear-and-queue per category let a Call pass + // readiness against the PREVIOUS iteration's flags of a + // not-yet-cleared producer and re-execute the same iteration + // forever. Ops inside TailLoops leave the block gate but queue + // only when their loop expands. let block_input_node = find_input_node(hugr, to_block); + let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); + let mut act = ContainerActivation::new(); for child in hugr.children(to_block) { - // Don't clear the Input node - it has fresh values from propagation - if Some(child) == block_input_node { - continue; - } - let num_outputs = hugr.num_outputs(child); - for port_idx in 0..num_outputs { - self.wire_state.classical_values.remove(&(child, port_idx)); - self.wire_state.wire_to_qubit.remove(&(child, port_idx)); - } + act.reset_wires(child); + } + if let Some(input) = block_input_node { + act.keep_wires(input); } - - // PHASE 1: clear processed state for EVERY op category in the - // block before ANY readiness check or queueing below. Readiness - // (all_predecessors_ready) consults `processed`; interleaving - // clear-and-queue per category let a Call pass its readiness - // check against the PREVIOUS iteration's flags of a - // not-yet-cleared producer, fire before that producer re-ran, - // and re-propagate stale (or cleared) argument values -- the - // loop then re-executed the same iteration forever. - let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); for &op_node in block_info .quantum_ops .iter() @@ -460,132 +455,59 @@ impl HugrEngine { .chain(&block_info.tailloop_nodes) .chain(&extension_ops) { - self.processed.remove(&op_node); + act.reset_processed(op_node); } for child in hugr.children(to_block) { if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) || self.classical_ops.contains_key(&child) { - self.processed.remove(&child); + act.reset_processed(child); } } - - // PHASE 2: queue ops whose predecessors are ready. Ops that are - // not ready yet (their producers were just reset) are queued - // later by queue_ready_successors when the producer completes. - for &op_node in &block_info.quantum_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip ops inside TailLoops - they'll be added when the loop expands - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - if !self.work_queue.contains(&op_node) && !self.processed.contains(&op_node) { - self.work_queue.push_back(op_node); + // Queue order preserves the historical activation order + // (extension/classical before bool ops so their results are + // available; each loop iteration must re-run Calls). Policies: + // Calls and classical ops copy inputs at fire time, so they + // wait for readiness; the rest defer internally or are static. + let submit = |act: &mut ContainerActivation, node: Node, policy: QueuePolicy| { + if self.nodes_inside_tailloops.contains(&node) { + // Skip ops inside TailLoops - they'll be added when the + // loop expands + act.ungate_block_only(node); + } else { + act.queue(node, policy); } + }; + for &op_node in &block_info.quantum_ops { + submit(&mut act, op_node, QueuePolicy::Always); } - // Also activate Call nodes in this block - // (each loop iteration must call the function again) for &call_node in &block_info.call_nodes { - self.nodes_inside_cfg_blocks.remove(&call_node); - // Skip Call nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&call_node) { - continue; - } - if !self.work_queue.contains(&call_node) - && !self.processed.contains(&call_node) - && all_predecessors_ready( - hugr, - call_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(call_node); - } + submit(&mut act, call_node, QueuePolicy::IfReady); } - - // Also activate Conditional nodes in this block for &cond_node in &block_info.conditional_nodes { - self.nodes_inside_cfg_blocks.remove(&cond_node); - // Skip Conditional nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&cond_node) { - continue; - } - if !self.work_queue.contains(&cond_node) && !self.processed.contains(&cond_node) { - self.work_queue.push_back(cond_node); - } + submit(&mut act, cond_node, QueuePolicy::Always); } - - // Also activate other extension ops in this block (like tket.result) - // IMPORTANT: Process extension/classical ops FIRST so their results are available for bool_ops for &op_node in &extension_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip extension ops inside TailLoops - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - if !self.work_queue.contains(&op_node) && !self.processed.contains(&op_node) { - self.work_queue.push_back(op_node); - } + submit(&mut act, op_node, QueuePolicy::Always); } - - // Also activate LoadConstant and classical ops in this block for child in hugr.children(to_block) { - let op = hugr.get_optype(child); - if matches!(op, OpType::LoadConstant(_)) { - self.nodes_inside_cfg_blocks.remove(&child); - // Skip nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - if !self.work_queue.contains(&child) && !self.processed.contains(&child) { - self.work_queue.push_back(child); - } + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + submit(&mut act, child, QueuePolicy::Always); } - // Check for classical ops if self.classical_ops.contains_key(&child) { - self.nodes_inside_cfg_blocks.remove(&child); - // Skip nodes inside TailLoops - if self.nodes_inside_tailloops.contains(&child) { - continue; - } - if !self.work_queue.contains(&child) - && !self.processed.contains(&child) - && all_predecessors_ready( - hugr, - child, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(child); - } + submit(&mut act, child, QueuePolicy::IfReady); } } - - // Now activate bool ops in this block for &op_node in &block_info.bool_ops { - self.nodes_inside_cfg_blocks.remove(&op_node); - // Skip bool ops inside TailLoops - if self.nodes_inside_tailloops.contains(&op_node) { - continue; - } - if !self.work_queue.contains(&op_node) && !self.processed.contains(&op_node) { - self.work_queue.push_back(op_node); - } + submit(&mut act, op_node, QueuePolicy::Always); } - - // Also activate TailLoop nodes in this block + // TailLoop nodes queue even when nested inside another loop's + // body tracking: they defer internally until their inputs are + // ready. for &tl_node in &block_info.tailloop_nodes { - self.nodes_inside_cfg_blocks.remove(&tl_node); - if !self.work_queue.contains(&tl_node) && !self.processed.contains(&tl_node) { - self.work_queue.push_back(tl_node); - } + act.queue(tl_node, QueuePolicy::Always); } + self.run_activation(hugr, &act); let num_ops = block_info.quantum_ops.len(); let num_calls = block_info.call_nodes.len(); diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index ada1fb62a..ea7e4ab8c 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -44,6 +44,7 @@ use tket::hugr::ops::OpType; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; +use crate::engine::activation::{ContainerActivation, QueuePolicy}; use crate::engine::analysis::find_input_node; use crate::engine::types::{ActiveCaseInfo, ClassicalValue, QuantumOp}; @@ -479,27 +480,18 @@ impl HugrEngine { ops_in_case.insert(child); } } - // Quantum ops queued as entries bypass the container gates, but any - // re-queue goes through queue_ready_successors which checks all of - // them -- clear the gates so retried case ops are reachable. Also - // clear their processed flags: a case re-expanded on a later loop - // iteration must re-emit its gates (nothing else resets - // case-internal quantum ops now that container op-collection stops - // at case boundaries). + // Build the case activation batch through the shared two-phase + // mechanism: every processed flag and stale output wire in the case + // clears BEFORE any readiness check, so consumers cannot pass + // readiness against the previous execution's flags of a + // not-yet-cleared producer (same discipline as CFG/TailLoop + // re-activation). Quantum ops are queued separately as entry nodes + // by the caller, so they only reset and ungate here; a case + // re-expanded on a later loop iteration must re-emit its gates. + let mut act = ContainerActivation::new(); for &node in &ops_in_case { - self.processed.remove(&node); - self.nodes_inside_cases.remove(&node); - self.nodes_inside_cfg_blocks.remove(&node); - self.nodes_inside_tailloops.remove(&node); - // Clear stale OUTPUT wires from the previous expansion: a - // consumer popping before this op re-runs would otherwise read - // last iteration's value/qubit (e.g. a gate resolving the - // previous iteration's borrowed qubit). - let num_outputs = hugr.num_outputs(node); - for port_idx in 0..num_outputs { - self.wire_state.classical_values.remove(&(node, port_idx)); - self.wire_state.wire_to_qubit.remove(&(node, port_idx)); - } + act.reset(node); + act.ungate(node); } // The Case's classical, bool, and extension ops must also execute @@ -538,73 +530,25 @@ impl HugrEngine { _ => {} } } - // PHASE 1: clear processed state for everything in the case before - // ANY readiness check, so consumers cannot pass readiness against - // the previous execution's flags of a not-yet-cleared producer - // (same discipline as CFG block re-activation). for &op_node in case_classical .iter() .chain(case_bools.iter()) .chain(case_extension.iter()) .chain(case_load_consts.iter()) .chain(case_calls.iter()) - .chain(case_containers.iter()) { - self.processed.remove(&op_node); - // Clear stale OUTPUT wires too: a consumer queued in this same - // expansion (e.g. a measurement whose qubit rides an - // UnpackTuple output) would otherwise resolve against the - // previous iteration's value before this op re-runs. - let num_outputs = hugr.num_outputs(op_node); - for port_idx in 0..num_outputs { - self.wire_state - .classical_values - .remove(&(op_node, port_idx)); - self.wire_state.wire_to_qubit.remove(&(op_node, port_idx)); - } - } - // PHASE 2: queue ready ops; ops that are not ready are queued later - // by queue_ready_successors when their producers complete. - for &op_node in case_classical - .iter() - .chain(case_bools.iter()) - .chain(case_extension.iter()) - .chain(case_load_consts.iter()) - .chain(case_calls.iter()) - { - // Clear ALL container gates, not just the case gate: a case - // nested in a TailLoop nested in a CFG block leaves its ops in - // the outer gate sets too, and the retry path - // (queue_ready_successors) checks every gate -- an op that is - // not ready at expansion would otherwise never be queued. - self.nodes_inside_cases.remove(&op_node); - self.nodes_inside_cfg_blocks.remove(&op_node); - self.nodes_inside_tailloops.remove(&op_node); + act.reset(op_node); + act.queue(op_node, QueuePolicy::IfReady); ops_in_case.insert(op_node); - if !self.work_queue.contains(&op_node) - && crate::engine::analysis::all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) - { - self.work_queue.push_back(op_node); - } } // TailLoops and nested Conditionals defer internally until their // control resolves, so they queue without a readiness check. for &op_node in &case_containers { - self.nodes_inside_cases.remove(&op_node); - self.nodes_inside_cfg_blocks.remove(&op_node); - self.nodes_inside_tailloops.remove(&op_node); + act.reset(op_node); + act.queue(op_node, QueuePolicy::Always); ops_in_case.insert(op_node); - if !self.work_queue.contains(&op_node) { - self.work_queue.push_back(op_node); - } } + self.run_activation(hugr, &act); // Register this Case as active so we can propagate outputs when complete let case_completed_inline = ops_in_case.is_empty(); diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index d7daee941..8a9cc363a 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -39,7 +39,7 @@ use tket::hugr::ops::OpType; use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; -use crate::engine::analysis::all_predecessors_ready; +use crate::engine::activation::{ContainerActivation, QueuePolicy}; use crate::engine::types::{ActiveTailLoopInfo, TailLoopInfo}; impl HugrEngine { @@ -126,21 +126,25 @@ impl HugrEngine { /// Expand a `TailLoop` by activating its body for the first iteration. /// Returns the entry nodes that should be added to the work queue. - pub(crate) fn expand_tailloop(&mut self, hugr: &Hugr, tailloop_node: Node) -> Vec { + pub(crate) fn expand_tailloop(&mut self, hugr: &Hugr, tailloop_node: Node) { let Some(tailloop_info) = self.tailloops.get(&tailloop_node).cloned() else { debug!("TailLoop {tailloop_node:?} not found in tailloops map"); - return Vec::new(); + return; }; debug!("Expanding TailLoop {tailloop_node:?} for iteration 0"); - // Body nodes may carry OUTER container gates too (a TailLoop inside - // a CFG block leaves its internals in nodes_inside_cfg_blocks; the - // block's activation never enumerates them). Clear every gate for - // the tracked body ops: entry pushes below bypass the gates, but the - // retry path (queue_ready_successors) checks all of them, so a - // still-gated node whose inputs become ready later is never queued - // and starves the loop. + // Two-phase discipline via the shared activation mechanism (parity + // with continue_tailloop_iteration and CFG/case re-activation): all + // processed flags and stale output wires clear BEFORE any readiness + // check. A TailLoop nested in a re-expanded Case/CFG otherwise + // inherits the previous execution's state and its consumers pass + // readiness against stale flags. Queueing also releases every + // container gate (a body node may carry OUTER gates too -- a + // TailLoop inside a CFG block leaves its internals in + // nodes_inside_cfg_blocks): entry pushes bypass the gates, but the + // retry path (queue_ready_successors) checks all of them. + let mut act = ContainerActivation::new(); for &op_node in tailloop_info .quantum_ops .iter() @@ -148,44 +152,30 @@ impl HugrEngine { .chain(&tailloop_info.extension_ops) .chain(&tailloop_info.classical_ops) .chain(&tailloop_info.bool_ops) - .chain(&tailloop_info.conditional_nodes) { - self.nodes_inside_cfg_blocks.remove(&op_node); - self.nodes_inside_cases.remove(&op_node); + act.reset(op_node); + act.queue(op_node, QueuePolicy::IfReady); } - - // Two-phase discipline (parity with continue_tailloop_iteration and - // CFG/case re-activation): clear processed flags AND stale output - // wires for every tracked body op BEFORE any readiness check below. - // A TailLoop nested in a re-expanded Case/CFG otherwise inherits the - // previous execution's state and its consumers pass readiness - // against stale flags. - for &op_node in tailloop_info - .quantum_ops - .iter() - .chain(&tailloop_info.call_nodes) - .chain(&tailloop_info.extension_ops) - .chain(&tailloop_info.classical_ops) - .chain(&tailloop_info.bool_ops) - .chain(&tailloop_info.conditional_nodes) - { - self.processed.remove(&op_node); - let num_outputs = hugr.num_outputs(op_node); - for port_idx in 0..num_outputs { - self.wire_state - .classical_values - .remove(&(op_node, port_idx)); - self.wire_state.wire_to_qubit.remove(&(op_node, port_idx)); - } + // Conditionals defer internally until their control resolves. + for &cond_node in &tailloop_info.conditional_nodes { + act.reset(cond_node); + act.queue(cond_node, QueuePolicy::Always); } + // Body LoadConstants are gated behind nodes_inside_tailloops (no + // other path queues them) while readiness gates their consumers on + // the processed flag, so skipping them starves the body's classical + // dataflow. for child in hugr.children(tailloop_node) { if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { - self.processed.remove(&child); + act.reset_processed(child); + act.queue(child, QueuePolicy::Always); } } - // Propagate input wires from TailLoop inputs to body Input node outputs + // Propagate input wires from TailLoop inputs to body Input node + // outputs, then apply the batch (reset -> ungate -> queue). self.propagate_tailloop_inputs(hugr, tailloop_node, &tailloop_info, 0); + self.run_activation(hugr, &act); // Register as active TailLoop self.active_tailloops.insert( @@ -197,106 +187,7 @@ impl HugrEngine { }, ); - // Activate quantum ops in the body - let mut entry_nodes = Vec::new(); - for &op_node in &tailloop_info.quantum_ops { - self.nodes_inside_tailloops.remove(&op_node); - let preds_ready = all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ); - if preds_ready { - entry_nodes.push(op_node); - } - } - - // Also activate Call nodes - for &call_node in &tailloop_info.call_nodes { - self.nodes_inside_tailloops.remove(&call_node); - if all_predecessors_ready( - hugr, - call_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) { - entry_nodes.push(call_node); - } - } - - // Also activate extension ops - for &op_node in &tailloop_info.extension_ops { - self.nodes_inside_tailloops.remove(&op_node); - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) { - entry_nodes.push(op_node); - } - } - - // Also activate classical ops - for &op_node in &tailloop_info.classical_ops { - self.nodes_inside_tailloops.remove(&op_node); - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) { - entry_nodes.push(op_node); - } - } - - // Also activate bool ops - for &op_node in &tailloop_info.bool_ops { - self.nodes_inside_tailloops.remove(&op_node); - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) { - entry_nodes.push(op_node); - } - } - - // Also activate Conditional nodes - for &cond_node in &tailloop_info.conditional_nodes { - self.nodes_inside_tailloops.remove(&cond_node); - entry_nodes.push(cond_node); - } - - // Also activate body LoadConstants: they are gated behind - // nodes_inside_tailloops (no other path queues them) while - // readiness gates their consumers on the processed flag, so - // skipping them starves the body's classical dataflow. - for child in hugr.children(tailloop_node) { - if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { - self.nodes_inside_tailloops.remove(&child); - entry_nodes.push(child); - } - } - - debug!( - "TailLoop {tailloop_node:?}: activated body with {} entry nodes", - entry_nodes.len() - ); - - entry_nodes + debug!("TailLoop {tailloop_node:?}: activated body"); } /// Propagate wire mappings from `TailLoop` inputs to body Input node. @@ -365,54 +256,42 @@ impl HugrEngine { return; } - // Clear processed state for body nodes so they can be re-executed - for &op_node in &tailloop_info.quantum_ops { - self.processed.remove(&op_node); - } - for &call_node in &tailloop_info.call_nodes { - self.processed.remove(&call_node); - } - for &op_node in &tailloop_info.extension_ops { - self.processed.remove(&op_node); - } - for &op_node in &tailloop_info.classical_ops { - self.processed.remove(&op_node); - } - for &op_node in &tailloop_info.bool_ops { - self.processed.remove(&op_node); - } - for &cond_node in &tailloop_info.conditional_nodes { - self.processed.remove(&cond_node); - } - // Body LoadConstants must re-run too: readiness gates consumers on - // their processed flag, and no other path queues them once the body - // is gated behind nodes_inside_tailloops. - for child in hugr.children(tailloop_node) { - if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { - self.processed.remove(&child); - } - } - // Propagate iteration values from Output to Input. This reads the // PREVIOUS iteration's body-output wires, so it must happen before - // the stale-value clearing below. + // the batch below clears them. (It touches only wire maps, never + // the processed set, so running it first is order-equivalent.) self.propagate_continue_values(hugr, tailloop_node, &tailloop_info); - // Clear stale classical values for body nodes (mirrors CFG block - // re-activation): without this, re-executed nodes read the previous - // iteration's values and e.g. a control read selects a stale branch. - // The Input node is skipped -- it holds the fresh continue values. - let input_node = tailloop_info.input_node; + // Re-activate the body through the shared two-phase mechanism: + // clear processed flags for every tracked body op (and body + // LoadConstants -- readiness gates consumers on their processed + // flag and no other path queues them once the body is gated) and + // stale wires for every child except the Input node, which holds + // the fresh continue values; then queue. + let mut act = ContainerActivation::new(); + for &op_node in tailloop_info + .quantum_ops + .iter() + .chain(&tailloop_info.call_nodes) + .chain(&tailloop_info.extension_ops) + .chain(&tailloop_info.classical_ops) + .chain(&tailloop_info.bool_ops) + { + act.reset_processed(op_node); + act.queue(op_node, QueuePolicy::IfReady); + } + for &cond_node in &tailloop_info.conditional_nodes { + act.reset_processed(cond_node); + act.queue(cond_node, QueuePolicy::Always); + } for child in hugr.children(tailloop_node) { - if child == input_node { - continue; - } - let num_outputs = hugr.num_outputs(child); - for port_idx in 0..num_outputs { - self.wire_state.classical_values.remove(&(child, port_idx)); - self.wire_state.wire_to_qubit.remove(&(child, port_idx)); + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + act.reset_processed(child); + act.queue(child, QueuePolicy::Always); } + act.reset_wires(child); } + act.keep_wires(tailloop_info.input_node); // Update iteration counter if let Some(active_info) = self.active_tailloops.get_mut(&tailloop_node) { @@ -420,85 +299,7 @@ impl HugrEngine { active_info.body_active = true; } - // Re-activate body operations - for child in hugr.children(tailloop_node) { - if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) - && !self.work_queue.contains(&child) - && !self.processed.contains(&child) - { - self.work_queue.push_back(child); - } - } - for &op_node in &tailloop_info.quantum_ops { - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - for &call_node in &tailloop_info.call_nodes { - if all_predecessors_ready( - hugr, - call_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&call_node) - { - self.work_queue.push_back(call_node); - } - } - for &op_node in &tailloop_info.extension_ops { - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - for &op_node in &tailloop_info.classical_ops { - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - for &op_node in &tailloop_info.bool_ops { - if all_predecessors_ready( - hugr, - op_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.processed, - ) && !self.work_queue.contains(&op_node) - { - self.work_queue.push_back(op_node); - } - } - for &cond_node in &tailloop_info.conditional_nodes { - if !self.work_queue.contains(&cond_node) { - self.work_queue.push_back(cond_node); - } - } + self.run_activation(hugr, &act); } /// Propagate values from CONTINUE tag to next iteration's inputs. From 87b4088ca0da08e8ad7ab2e32843448ad700b139 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 11:42:04 -0600 Subject: [PATCH 355/388] Guard against re-activating a CFG that is still executing (re-registration reset current_block and transitions mid-flight) --- crates/pecos-hugr/src/engine.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 523bb19df..ffcc5edea 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -694,6 +694,16 @@ impl HugrEngine { // --- Control Flow: CFG --- if let Some(cfg_info) = self.cfgs.get(¤t_node).cloned() { + // A CFG re-queued while it is still executing must NOT + // restart: re-registering resets current_block/transitions + // mid-flight and silently corrupts the walk. (Legitimate + // re-execution -- a second Call to the same function -- + // only happens after complete_cfg_execution removed the + // active entry.) + if self.active_cfgs.contains_key(¤t_node) { + debug!("CFG {current_node:?} re-queued while active, ignoring"); + continue; + } debug!("Starting CFG {current_node:?} execution"); debug!("[TRACE] Starting CFG {current_node:?}"); From f46a65483519adf62834ffbf48c3848529872204 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 11:51:31 -0600 Subject: [PATCH 356/388] Track nested containers: TailLoop/CFG children of loop bodies and cases queue at expansion (CFGs gated on readiness like Calls), body/case completion waits for them, and completed loops/CFGs run their parent-container hooks --- crates/pecos-hugr/src/engine/analysis.rs | 18 +++++++++ .../pecos-hugr/src/engine/control_flow/cfg.rs | 7 ++++ .../src/engine/control_flow/conditional.rs | 6 +++ .../src/engine/control_flow/tailloop.rs | 38 ++++++++++++++++++- crates/pecos-hugr/src/engine/types.rs | 4 ++ 5 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 404442fc5..9458b415b 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -344,6 +344,22 @@ pub fn extract_tailloops(hugr: &Hugr) -> BTreeMap { let classical_ops = find_classical_ops_in_block(hugr, node); let bool_ops = find_bool_ops_in_block(hugr, node); let conditional_nodes = find_conditional_nodes_in_block(hugr, node); + // Nested containers: direct TailLoop/CFG children of the body. + // Nothing else queues them once the body is gated, and body + // completion must wait for them. + let mut tailloop_nodes = BTreeSet::new(); + let mut cfg_nodes = BTreeSet::new(); + for child in hugr.children(node) { + match hugr.get_optype(child) { + OpType::TailLoop(_) => { + tailloop_nodes.insert(child); + } + OpType::CFG(_) => { + cfg_nodes.insert(child); + } + _ => {} + } + } debug!( "Found TailLoop node {:?} with {} inputs, {} outputs, {} quantum ops, {} calls, {} extension ops, {} classical ops, {} bool ops, {} conditionals", @@ -373,6 +389,8 @@ pub fn extract_tailloops(hugr: &Hugr) -> BTreeMap { classical_ops, bool_ops, conditional_nodes, + tailloop_nodes, + cfg_nodes, num_inputs, num_outputs, }, diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index de5c03304..f9d55f286 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -665,6 +665,13 @@ impl HugrEngine { self.work_queue.push_back(succ_node); } } + + // A nested CFG may be the last op of an enclosing case or loop + // body -- run the container completion hooks (mirrors + // complete_tailloop). Block completion needs no hook: blocks do + // not track nested CFG children. + self.check_case_completion(hugr, cfg_node); + self.check_tailloop_body_completion(hugr, cfg_node); } /// Propagate wire mappings from a completed block to a successor block. diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index ea7e4ab8c..bc0f347fe 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -175,6 +175,7 @@ impl HugrEngine { .any(|c| c.conditional_node == *op) && !self.active_tailloops.contains_key(op) && !self.active_calls.contains_key(op) + && !self.active_cfgs.contains_key(op) }); if all_done { @@ -523,10 +524,14 @@ impl HugrEngine { // set and propagate outputs before the nested work even starts. let mut case_calls: Vec = Vec::new(); let mut case_containers: Vec = Vec::new(); + let mut case_cfgs: Vec = Vec::new(); for child in hugr.children(selected_case) { match hugr.get_optype(child) { OpType::Call(_) => case_calls.push(child), OpType::TailLoop(_) | OpType::Conditional(_) => case_containers.push(child), + // CFGs copy their inputs one-shot at activation, so they + // queue like Calls: only once their producers ran. + OpType::CFG(_) => case_cfgs.push(child), _ => {} } } @@ -536,6 +541,7 @@ impl HugrEngine { .chain(case_extension.iter()) .chain(case_load_consts.iter()) .chain(case_calls.iter()) + .chain(case_cfgs.iter()) { act.reset(op_node); act.queue(op_node, QueuePolicy::IfReady); diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 8a9cc363a..ce173dd54 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -161,6 +161,18 @@ impl HugrEngine { act.reset(cond_node); act.queue(cond_node, QueuePolicy::Always); } + // Nested containers (TailLoop-in-TailLoop, CFG-in-body): nothing + // else queues them once the body is gated. Loops defer internally + // until their inputs resolve; CFGs copy their inputs one-shot at + // activation, so they must wait for readiness like Calls do. + for &nested in &tailloop_info.tailloop_nodes { + act.reset(nested); + act.queue(nested, QueuePolicy::Always); + } + for &nested in &tailloop_info.cfg_nodes { + act.reset(nested); + act.queue(nested, QueuePolicy::IfReady); + } // Body LoadConstants are gated behind nodes_inside_tailloops (no // other path queues them) while readiness gates their consumers on // the processed flag, so skipping them starves the body's classical @@ -284,6 +296,14 @@ impl HugrEngine { act.reset_processed(cond_node); act.queue(cond_node, QueuePolicy::Always); } + for &nested in &tailloop_info.tailloop_nodes { + act.reset_processed(nested); + act.queue(nested, QueuePolicy::Always); + } + for &nested in &tailloop_info.cfg_nodes { + act.reset_processed(nested); + act.queue(nested, QueuePolicy::IfReady); + } for child in hugr.children(tailloop_node) { if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { act.reset_processed(child); @@ -432,6 +452,9 @@ impl HugrEngine { // observe it too. self.check_case_completion(hugr, tailloop_node); self.check_cfg_block_completion(hugr, tailloop_node); + // A completed loop may be the last op of an ENCLOSING loop's body + // (TailLoop-in-TailLoop). + self.check_tailloop_body_completion(hugr, tailloop_node); } /// Propagate outputs from `TailLoop` body to `TailLoop` node outputs. @@ -562,7 +585,9 @@ impl HugrEngine { || tailloop_info.extension_ops.contains(&processed_node) || tailloop_info.classical_ops.contains(&processed_node) || tailloop_info.bool_ops.contains(&processed_node) - || tailloop_info.conditional_nodes.contains(&processed_node); + || tailloop_info.conditional_nodes.contains(&processed_node) + || tailloop_info.tailloop_nodes.contains(&processed_node) + || tailloop_info.cfg_nodes.contains(&processed_node); if is_in_loop { // Check if all ops are processed @@ -597,12 +622,23 @@ impl HugrEngine { .any(|case| case.conditional_node == *cond) }); + // Nested containers count as done only once processed AND + // no longer active (a nested loop marks processed at its + // own completion; a nested CFG at complete_cfg_execution). + let all_nested_done = tailloop_info.tailloop_nodes.iter().all(|tl| { + self.processed.contains(tl) && !self.active_tailloops.contains_key(tl) + }) && tailloop_info + .cfg_nodes + .iter() + .all(|c| self.processed.contains(c) && !self.active_cfgs.contains_key(c)); + if all_quantum_done && all_calls_done && all_extension_done && all_classical_done && all_bool_done && all_conditionals_done + && all_nested_done { completions.push(*tailloop_node); } diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index c3175cbb7..db0ed0538 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -802,6 +802,10 @@ pub struct TailLoopInfo { pub bool_ops: BTreeSet, /// All Conditional nodes inside this `TailLoop` body. pub conditional_nodes: BTreeSet, + /// Nested `TailLoop` nodes directly inside this `TailLoop` body. + pub tailloop_nodes: BTreeSet, + /// Nested CFG nodes directly inside this `TailLoop` body. + pub cfg_nodes: BTreeSet, /// Total number of `TailLoop` input ports. pub num_inputs: usize, /// Total number of `TailLoop` output ports. From 1cf8c8521b2908a29ae98c2b0b23dcd7341fb867 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 12:12:58 -0600 Subject: [PATCH 357/388] Add a completion-time reachability audit: every child of every executed container region must be processed or provably inert, catching starvation invisible to queue bookkeeping; Call-frame resets invalidate the previous invocation's records --- crates/pecos-hugr/src/engine.rs | 97 +++++++++++++++++++ .../pecos-hugr/src/engine/control_flow/cfg.rs | 1 + .../src/engine/control_flow/conditional.rs | 1 + .../src/engine/control_flow/tailloop.rs | 3 + 4 files changed, 102 insertions(+) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index ffcc5edea..9fbac7957 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -90,6 +90,12 @@ pub struct HugrEngine { /// Set of processed nodes. pub(crate) processed: BTreeSet, + /// Container regions the engine actually activated this shot + /// (`DataflowBlocks`, selected Cases, `TailLoop` bodies), with a label for + /// diagnostics. Persistent across the shot (unlike the active_* maps), + /// so completion can audit that every child of an executed region ran. + pub(crate) executed_containers: BTreeMap, + /// Reusable message builder for generating commands. pub(crate) message_builder: ByteMessageBuilder, @@ -381,6 +387,7 @@ impl HugrEngine { self.active_tailloops.clear(); self.pending_tailloop_control.clear(); self.execution_error = None; + self.executed_containers.clear(); // Clear result capture state self.captured_results.clear(); @@ -724,6 +731,8 @@ impl HugrEngine { // Propagate CFG inputs to entry block's Input node self.propagate_cfg_inputs_to_entry_block(&hugr, current_node, entry_block); + self.executed_containers + .insert(entry_block, "DataflowBlock"); // First activation of the entry block via the shared // mechanism (no resets -- nothing has executed yet). // Ops inside TailLoops leave the block gate but queue @@ -1031,6 +1040,13 @@ impl HugrEngine { for node in &descendants { self.nodes_inside_func_defns.remove(node); act.reset(*node); + // The frame reset invalidates the PREVIOUS + // invocation's executed-container records (their + // processed flags are being cleared); this + // invocation re-records whatever it executes, so + // the completion audit covers exactly the final + // invocation of each frame. + self.executed_containers.remove(node); } act.keep_wires(func_info.input_node); act.reset_processed(cfg_node); @@ -1391,6 +1407,19 @@ impl HugrEngine { .collect::>() )); } + // Reachability audit: the bookkeeping above only sees nodes that + // entered a queue or pending set. A node that was never QUEUED at + // all -- an op the category tracking missed inside a container the + // engine executed -- is invisible to it. Check every direct child + // of every executed region instead. + let unexecuted = self.audit_executed_containers(); + if !unexecuted.is_empty() { + let shown: Vec<&String> = unexecuted.iter().take(10).collect(); + stalled.push(format!( + "unexecuted ops in executed containers ({} total): {shown:?}", + unexecuted.len() + )); + } if stalled.is_empty() { Ok(()) } else { @@ -1402,6 +1431,33 @@ impl HugrEngine { } } + /// Audit that every direct child of every executed container region + /// (activated `DataflowBlock`, selected Case, expanded `TailLoop` body) + /// was processed. Exempt kinds never execute: Input/Output boundaries, + /// static constants, and linear qubit-routing Tags (only Tags + /// classified as classical `TagSum` ops execute). + /// + /// Only the FINAL activation of a re-activated container is audited + /// (earlier iterations cleared and re-set the same flags), which is + /// exactly the activation whose flags are still live. + fn audit_executed_containers(&self) -> Vec { + let Some(hugr) = &self.hugr else { + return Vec::new(); + }; + let mut misses = Vec::new(); + for (&container, kind) in &self.executed_containers { + for child in hugr.children(container) { + let op = hugr.get_optype(child); + let exempt = matches!(op, OpType::Input(_) | OpType::Output(_) | OpType::Const(_)) + || (matches!(op, OpType::Tag(_)) && !self.classical_ops.contains_key(&child)); + if !exempt && !self.processed.contains(&child) { + misses.push(format!("{child:?} ({op}) in {kind} {container:?}")); + } + } + } + misses + } + /// Emit a quantum gate operation to the message builder. /// /// This handles all gate types and their decompositions. @@ -1700,6 +1756,7 @@ impl Default for HugrEngine { classical_ops: BTreeMap::new(), work_queue: VecDeque::new(), processed: BTreeSet::new(), + executed_containers: BTreeMap::new(), message_builder: ByteMessageBuilder::new(), // Grouped state wire_state: WireState::default(), @@ -2366,6 +2423,45 @@ mod tests { ); } + #[test] + fn test_completion_audit_reports_unexecuted_container_ops() { + // The reachability audit must catch a container the engine claims + // to have executed whose ops never ran -- the class of bug that is + // invisible to queue/pending bookkeeping (a node that was never + // queued at all). Simulate one by recording a real block as + // executed without running anything. + use tket::hugr::ops::OpType; + + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/forloop_h_test.hugr" + ); + let mut engine = HugrEngine::from_file(path).expect("Failed to load HUGR"); + let hugr = engine.hugr.clone().expect("hugr loaded"); + let block = hugr + .nodes() + .find(|n| { + matches!(hugr.get_optype(*n), OpType::DataflowBlock(_)) + && hugr.children(*n).any(|c| { + !matches!( + hugr.get_optype(c), + OpType::Input(_) | OpType::Output(_) | OpType::Const(_) + ) + }) + }) + .expect("fixture has a non-trivial block"); + engine.executed_containers.insert(block, "DataflowBlock"); + + let err = engine + .ensure_no_stalled_execution() + .expect_err("audit must fail with unexecuted ops"); + let msg = format!("{err}"); + assert!( + msg.contains("unexecuted ops in executed containers"), + "unexpected error: {msg}" + ); + } + #[test] fn test_forloop_executes_each_iteration_and_terminates() { // Regression guard for the loop-iteration freeze: guppy's @@ -2377,6 +2473,7 @@ mod tests { // re-ran iteration 0 forever (H emitted per wave, no termination). use pecos_engines::{ByteMessageBuilder, ControlEngine, EngineStage}; + let _ = env_logger::builder().is_test(true).try_init(); let path = concat!( env!("CARGO_MANIFEST_DIR"), "/../pecos/tests/test_data/hugr/forloop_h_test.hugr" diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index f9d55f286..1b9e838f8 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -425,6 +425,7 @@ impl HugrEngine { // Activate successor block's quantum ops and Call nodes if let Some(block_info) = cfg_info.blocks.get(&to_block) { + self.executed_containers.insert(to_block, "DataflowBlock"); // Re-activate the block through the shared two-phase mechanism. // Stale wires clear for every child except the Input node (it // holds fresh values from propagation): without this, nodes diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index bc0f347fe..051a3c5ee 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -481,6 +481,7 @@ impl HugrEngine { ops_in_case.insert(child); } } + self.executed_containers.insert(selected_case, "Case"); // Build the case activation batch through the shared two-phase // mechanism: every processed flag and stale output wire in the case // clears BEFORE any readiness check, so consumers cannot pass diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index ce173dd54..a6d3fde13 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -134,6 +134,9 @@ impl HugrEngine { debug!("Expanding TailLoop {tailloop_node:?} for iteration 0"); + self.executed_containers + .insert(tailloop_node, "TailLoop body"); + // Two-phase discipline via the shared activation mechanism (parity // with continue_tailloop_iteration and CFG/case re-activation): all // processed flags and stale output wires clear BEFORE any readiness From d7ef7f6845139aad4db9e0062fd47496e3e6c8b6 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 12:49:16 -0600 Subject: [PATCH 358/388] Implement ipow (wrapping square-and-multiply), idivmod_s/u with checked variants (Euclidean q,r pair; m=0 faults unchecked and error-Sums checked), and itobool/ifrombool, pinned by a builder-driven executor test --- crates/pecos-hugr/src/engine.rs | 174 ++++++++++++++++++ crates/pecos-hugr/src/engine/analysis.rs | 5 + .../src/engine/handlers/classical.rs | 108 ++++++++++- crates/pecos-hugr/src/engine/types.rs | 10 + 4 files changed, 296 insertions(+), 1 deletion(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 9fbac7957..2ad0bab64 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -2423,6 +2423,180 @@ mod tests { ); } + /// Build ipow / idivmod / itobool / ifrombool via the hugr std-arith + /// builders and drive the classical executor over them, pinning the + /// spec shapes: Euclidean (q, r) pairs on two ports, fail-loud m=0 for + /// the unchecked ops, error Sums for the checked ones, and wrapping + /// square-and-multiply exponentiation. + #[test] + #[allow(clippy::too_many_lines)] + fn test_missing_int_ops_execute_per_spec() { + use crate::engine::analysis::classify_classical_op; + use crate::engine::handlers::ClassicalOutcome; + use crate::engine::types::ClassicalOp; + use tket::hugr::builder::{Dataflow, DataflowHugr, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::conversions::ConvertOpDef; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let hugr = { + let mut fb = + FunctionBuilder::new("int_ops", Signature::new(vec![], vec![int_type(6)])).unwrap(); + let n = fb.add_load_const(Value::from(ConstInt::new_s(6, -7).unwrap())); + let m = fb.add_load_const(Value::from(ConstInt::new_u(6, 3).unwrap())); + let base = fb.add_load_const(Value::from(ConstInt::new_s(6, 3).unwrap())); + let exp = fb.add_load_const(Value::from(ConstInt::new_u(6, 4).unwrap())); + let bit = fb.add_load_const(Value::from(ConstInt::new_u(0, 1).unwrap())); + let flag = fb.add_load_const(Value::true_val()); + let [pow] = fb + .add_dataflow_op(IntOpDef::ipow.with_log_width(6), [base, exp]) + .unwrap() + .outputs_arr(); + let [_q, _r] = fb + .add_dataflow_op(IntOpDef::idivmod_s.with_log_width(6), [n, m]) + .unwrap() + .outputs_arr(); + let [_qr_sum] = fb + .add_dataflow_op(IntOpDef::idivmod_checked_s.with_log_width(6), [n, m]) + .unwrap() + .outputs_arr(); + let [_b] = fb + .add_dataflow_op(ConvertOpDef::itobool.without_log_width(), [bit]) + .unwrap() + .outputs_arr(); + let [_i] = fb + .add_dataflow_op(ConvertOpDef::ifrombool.without_log_width(), [flag]) + .unwrap() + .outputs_arr(); + fb.finish_hugr_with_outputs([pow]).unwrap() + }; + + let find = |name: &str| -> Node { + hugr.nodes() + .find(|n| { + hugr.get_optype(*n) + .as_extension_op() + .is_some_and(|op| op.unqualified_id() == name) + }) + .unwrap_or_else(|| panic!("no {name} node")) + }; + let mut engine = HugrEngine::default(); + let run = |engine: &mut HugrEngine, + name: &str, + seeds: &[(usize, ClassicalValue)]| + -> ClassicalOutcome { + let node = find(name); + for (port, value) in seeds { + let (src, sp) = hugr + .single_linked_output(node, IncomingPort::from(*port)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), value.clone()); + } + let (op_type, num_inputs, num_outputs, int_info) = + classify_classical_op(hugr.get_optype(node)) + .unwrap_or_else(|| panic!("{name} must classify")); + let op = ClassicalOp { + node, + op_type, + num_inputs, + num_outputs, + int_info, + const_value: None, + }; + engine.handle_classical_op(&hugr, node, &op) + }; + + // ipow: 3^4 = 81 + let got = run( + &mut engine, + "ipow", + &[(0, ClassicalValue::Int(3)), (1, ClassicalValue::Int(4))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(81))]) + ); + + // idivmod_s: Euclidean -7 divmod 3 -> (q, r) = (-3, 2) on two ports + let got = run( + &mut engine, + "idivmod_s", + &[(0, ClassicalValue::Int(-7)), (1, ClassicalValue::Int(3))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![ + (0, ClassicalValue::Int(-3)), + (1, ClassicalValue::Int(2)) + ]) + ); + + // idivmod_s by zero: fatal fault per the spec + let got = run( + &mut engine, + "idivmod_s", + &[(0, ClassicalValue::Int(-7)), (1, ClassicalValue::Int(0))], + ); + assert!( + matches!(got, ClassicalOutcome::Fault(ref msg) if msg.contains("division by zero")), + "expected div-by-zero fault, got {got:?}" + ); + + // idivmod_checked_s: value = Sum tag 1 with a (q, r) tuple payload + let got = run( + &mut engine, + "idivmod_checked_s", + &[(0, ClassicalValue::Int(-7)), (1, ClassicalValue::Int(3))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![( + 0, + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Tuple(vec![ + ClassicalValue::Int(-3), + ClassicalValue::Int(2) + ])], + } + )]) + ); + + // idivmod_checked_s by zero: error Sum (tag 0), not a fault + let got = run( + &mut engine, + "idivmod_checked_s", + &[(0, ClassicalValue::Int(-7)), (1, ClassicalValue::Int(0))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![( + 0, + ClassicalValue::Sum { + tag: 0, + values: vec![] + } + )]) + ); + + // itobool / ifrombool + let got = run(&mut engine, "itobool", &[(0, ClassicalValue::Int(1))]); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Bool(true))]) + ); + let got = run(&mut engine, "ifrombool", &[(0, ClassicalValue::Bool(true))]); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(1))]) + ); + } + #[test] fn test_completion_audit_reports_unexecuted_container_ops() { // The reachability audit must catch a container the engine claims diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 9458b415b..95f97e48f 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -651,6 +651,9 @@ pub fn classify_classical_op(op: &OpType) -> Option { "idiv_checked" => (ClassicalOpType::IdivChecked, 2, 1, Some((6, is_signed))), "imod" => (ClassicalOpType::Imod, 2, 1, Some((6, is_signed))), "imod_checked" => (ClassicalOpType::ImodChecked, 2, 1, Some((6, is_signed))), + "idivmod" => (ClassicalOpType::Idivmod, 2, 2, Some((6, is_signed))), + "idivmod_checked" => (ClassicalOpType::IdivmodChecked, 2, 1, Some((6, is_signed))), + "ipow" => (ClassicalOpType::Ipow, 2, 1, Some((6, is_signed))), "ineg" => (ClassicalOpType::Ineg, 1, 1, Some((6, true))), "iabs" => (ClassicalOpType::Iabs, 1, 1, Some((6, is_signed))), "ieq" => (ClassicalOpType::Ieq, 2, 1, Some((6, is_signed))), @@ -703,6 +706,8 @@ pub fn classify_classical_op(op: &OpType) -> Option { 1, Some((6, false)), ), + "itobool" => (ClassicalOpType::ItoBool, 1, 1, Some((0, false))), + "ifrombool" => (ClassicalOpType::IfromBool, 1, 1, Some((0, false))), _ => return None, }, // Prelude extension (tuples, etc.) diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index c1e389a30..5b7b1c4c9 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -142,6 +142,86 @@ impl HugrEngine { }, )]); } + ClassicalOpType::Idivmod => { + // Combined Euclidean division+remainder, TWO outputs (q, r); + // m=0 panics per the spec, like Idiv/Imod. + let int_at = |i: usize| inputs.get(i).and_then(ClassicalValue::as_int); + let uint_at = |i: usize| match inputs.get(i) { + Some(ClassicalValue::UInt(u)) => Some(*u), + #[allow(clippy::cast_sign_loss)] + other => other.and_then(ClassicalValue::as_int).map(|v| v as u64), + }; + let signed = op.int_info.is_none_or(|(_, is_signed)| is_signed); + let qr = if signed { + let (Some(n), Some(m)) = (int_at(0), uint_at(1)) else { + debug!("idivmod at {node:?}: inputs not ready, deferring"); + return ClassicalOutcome::Defer; + }; + let (n, m) = (i128::from(n), i128::from(m)); + if m == 0 { + None + } else { + #[allow(clippy::cast_possible_truncation)] + Some((n.div_euclid(m) as i64, n.rem_euclid(m) as i64)) + } + } else { + let (Some(a), Some(b)) = (uint_at(0), uint_at(1)) else { + debug!("idivmod at {node:?}: inputs not ready, deferring"); + return ClassicalOutcome::Defer; + }; + #[allow(clippy::cast_possible_wrap)] + (b != 0).then(|| ((a / b) as i64, (a % b) as i64)) + }; + let Some((q, r)) = qr else { + return ClassicalOutcome::Fault(format!( + "division by zero at {node:?} (the HUGR spec defines m=0 as a panic)" + )); + }; + return ClassicalOutcome::Outputs(vec![ + (0, ClassicalValue::Int(q)), + (1, ClassicalValue::Int(r)), + ]); + } + ClassicalOpType::IdivmodChecked => { + // sum_with_error(tuple(q, r)): error = tag 0, value = tag 1. + let int_at = |i: usize| inputs.get(i).and_then(ClassicalValue::as_int); + let uint_at = |i: usize| match inputs.get(i) { + Some(ClassicalValue::UInt(u)) => Some(*u), + #[allow(clippy::cast_sign_loss)] + other => other.and_then(ClassicalValue::as_int).map(|v| v as u64), + }; + let signed = op.int_info.is_none_or(|(_, is_signed)| is_signed); + let qr = if signed { + let (Some(n), Some(m)) = (int_at(0), uint_at(1)) else { + debug!("idivmod_checked at {node:?}: inputs not ready, deferring"); + return ClassicalOutcome::Defer; + }; + let (n, m) = (i128::from(n), i128::from(m)); + #[allow(clippy::cast_possible_truncation)] + (m != 0).then(|| (n.div_euclid(m) as i64, n.rem_euclid(m) as i64)) + } else { + let (Some(a), Some(b)) = (uint_at(0), uint_at(1)) else { + debug!("idivmod_checked at {node:?}: inputs not ready, deferring"); + return ClassicalOutcome::Defer; + }; + #[allow(clippy::cast_possible_wrap)] + (b != 0).then(|| ((a / b) as i64, (a % b) as i64)) + }; + let value = match qr { + Some((q, r)) => ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Tuple(vec![ + ClassicalValue::Int(q), + ClassicalValue::Int(r), + ])], + }, + None => ClassicalValue::Sum { + tag: 0, + values: vec![], + }, + }; + return ClassicalOutcome::Outputs(vec![(0, value)]); + } _ => {} } @@ -313,6 +393,30 @@ impl HugrEngine { let shift = int(1)?.clamp(0, 63) as u32; ClassicalValue::Int((uint(0)? >> shift) as i64) } + ClassicalOpType::Ipow => { + // "raise first input to the power of second input, the + // exponent is treated as an unsigned integer"; wrapping + // square-and-multiply so huge exponents stay exact under + // two's-complement wrap. + let (mut base, mut exp) = (int(0)?, uint(1)?); + let mut result: i64 = 1; + while exp > 0 { + if exp & 1 == 1 { + result = result.wrapping_mul(base); + } + base = base.wrapping_mul(base); + exp >>= 1; + } + ClassicalValue::Int(result) + } + ClassicalOpType::ItoBool => { + // itobool: int<1> -> bool (1 is true, 0 is false) + ClassicalValue::Bool(int(0)? != 0) + } + ClassicalOpType::IfromBool => { + // ifrombool: bool -> int<1> + ClassicalValue::Int(i64::from(boolean(0)?)) + } // Float arithmetic ClassicalOpType::Fadd => ClassicalValue::Float(float(0)? + float(1)?), @@ -381,7 +485,9 @@ impl HugrEngine { | ClassicalOpType::ConstBool | ClassicalOpType::MakeTuple | ClassicalOpType::UnpackTuple - | ClassicalOpType::TagSum => return None, + | ClassicalOpType::TagSum + | ClassicalOpType::Idivmod + | ClassicalOpType::IdivmodChecked => return None, }) })(); diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index db0ed0538..963f0aade 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -187,6 +187,16 @@ pub enum ClassicalOpType { Imul, Idiv, Imod, + /// Combined Euclidean division+remainder: two outputs (q, r). + Idivmod, + /// Checked combined div+mod: `sum_with_error(tuple(q, r))`. + IdivmodChecked, + /// Exponentiation; the exponent is treated as unsigned per the spec. + Ipow, + /// Convert a 1-bit integer to bool. + ItoBool, + /// Convert a bool to a 1-bit integer. + IfromBool, /// Checked division: `sum_with_error(int)` -- error variant (tag 0) on /// division by zero, value variant (tag 1) otherwise IdivChecked, From d56664d617bb6261bb613640f9c4a79a2e8eb325 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 13:15:53 -0600 Subject: [PATCH 359/388] Model int widths: classification reads the op's BoundedNat log_width, values store canonically sign-extended per width, unsigned reads mask, shifts drop past-width bits, rotates/popcnt/clz/ctz work within N bits, and widen_u zero-extends from the source width --- crates/pecos-hugr/src/engine.rs | 121 +++++++++++++++++- crates/pecos-hugr/src/engine/analysis.rs | 109 +++++++++------- .../src/engine/handlers/arithmetic.rs | 79 +++++++++--- .../src/engine/handlers/classical.rs | 77 +++++++++-- 4 files changed, 315 insertions(+), 71 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 2ad0bab64..87a350e57 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -2590,10 +2590,129 @@ mod tests { got, ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Bool(true))]) ); + // ifrombool produces int<1>; the canonical (sign-extended) storage + // of a 1-bit "1" is -1, matching ConstInt::value_s parsing. A + // round-trip through itobool still reads it as true. let got = run(&mut engine, "ifrombool", &[(0, ClassicalValue::Bool(true))]); assert_eq!( got, - ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(1))]) + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(-1))]) + ); + let got = run(&mut engine, "itobool", &[(0, ClassicalValue::Int(-1))]); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Bool(true))]) + ); + } + + /// int<5> (32-bit) semantics: wrapping addition, shift-out-to-zero, + /// rotation within 32 bits, and leading-zero counts relative to the + /// width -- all derived from the op's `BoundedNat` type arg rather than + /// the engine's 64-bit storage. + #[test] + fn test_int_width_modeling_32bit() { + use crate::engine::analysis::classify_classical_op; + use crate::engine::handlers::{ClassicalOutcome, HandlerOutcome}; + use crate::engine::types::ClassicalOp; + use tket::hugr::builder::{Dataflow, DataflowHugr, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let hugr = { + let mut fb = + FunctionBuilder::new("w32", Signature::new(vec![], vec![int_type(5)])).unwrap(); + let a = fb.add_load_const(Value::from(ConstInt::new_u(5, 0x7FFF_FFFF).unwrap())); + let b = fb.add_load_const(Value::from(ConstInt::new_u(5, 1).unwrap())); + let [sum] = fb + .add_dataflow_op(IntOpDef::iadd.with_log_width(5), [a, b]) + .unwrap() + .outputs_arr(); + let [_shifted] = fb + .add_dataflow_op(IntOpDef::ishl.with_log_width(5), [a, b]) + .unwrap() + .outputs_arr(); + let [_rot] = fb + .add_dataflow_op(IntOpDef::irotl.with_log_width(5), [a, b]) + .unwrap() + .outputs_arr(); + fb.finish_hugr_with_outputs([sum]).unwrap() + }; + + let find = |name: &str| -> Node { + hugr.nodes() + .find(|n| { + hugr.get_optype(*n) + .as_extension_op() + .is_some_and(|op| op.unqualified_id() == name) + }) + .unwrap_or_else(|| panic!("no {name} node")) + }; + let mut engine = HugrEngine::default(); + let seed = |engine: &mut HugrEngine, node: Node, port: usize, value: ClassicalValue| { + let (src, sp) = hugr + .single_linked_output(node, IncomingPort::from(port)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), value); + }; + let classical = |node: Node| -> ClassicalOp { + let (op_type, num_inputs, num_outputs, int_info) = + classify_classical_op(hugr.get_optype(node)).expect("classifies"); + ClassicalOp { + node, + op_type, + num_inputs, + num_outputs, + int_info, + const_value: None, + } + }; + + // iadd at 32 bits: i32::MAX + 1 wraps to i32::MIN (canonical + // sign-extended storage) + let node = find("iadd"); + seed(&mut engine, node, 0, ClassicalValue::Int(0x7FFF_FFFF)); + seed(&mut engine, node, 1, ClassicalValue::Int(1)); + assert_eq!( + engine.handle_classical_op(&hugr, node, &classical(node)), + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(i64::from(i32::MIN)))]) + ); + + // ishl at 32 bits: 1 << 31 = i32::MIN; 1 << 32 drops every bit + let node = find("ishl"); + seed(&mut engine, node, 0, ClassicalValue::Int(1)); + seed(&mut engine, node, 1, ClassicalValue::Int(31)); + assert_eq!( + engine.handle_classical_op(&hugr, node, &classical(node)), + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(i64::from(i32::MIN)))]) + ); + seed(&mut engine, node, 1, ClassicalValue::Int(32)); + assert_eq!( + engine.handle_classical_op(&hugr, node, &classical(node)), + ClassicalOutcome::Outputs(vec![(0, ClassicalValue::Int(0))]) + ); + + // irotl at 32 bits: rotating 0x8000_0001 left by 1 gives + // 0x0000_0003 (the high bit wraps into bit 0 within 32 bits) + let node = find("irotl"); + seed( + &mut engine, + node, + 0, + ClassicalValue::Int(i64::from(u32::from_le_bytes([1, 0, 0, 0x80]).cast_signed())), + ); + seed(&mut engine, node, 1, ClassicalValue::Int(1)); + assert_eq!( + engine.handle_int_op(&hugr, node, "irotl"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.wire_state.classical_values.get(&(node, 0)), + Some(&ClassicalValue::Int(3)) ); } diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 95f97e48f..51761fe19 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -643,31 +643,42 @@ pub fn classify_classical_op(op: &OpType) -> Option { // Parse operation name to extract signedness info // Operations like "iadd", "isub" are signed; "iadd_u" are unsigned let is_signed = !op_name.ends_with("_u"); + // The op's width comes from its BoundedNat type arg + // (log_width: int has 2^N bits); absent args mean the + // generic default of 64-bit. + let lw = ext_op + .args() + .iter() + .find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => u8::try_from(*n).ok(), + _ => None, + }) + .unwrap_or(6); match op_name.trim_end_matches("_u").trim_end_matches("_s") { - "iadd" => (ClassicalOpType::Iadd, 2, 1, Some((6, is_signed))), // default 64-bit - "isub" => (ClassicalOpType::Isub, 2, 1, Some((6, is_signed))), - "imul" => (ClassicalOpType::Imul, 2, 1, Some((6, is_signed))), - "idiv" => (ClassicalOpType::Idiv, 2, 1, Some((6, is_signed))), - "idiv_checked" => (ClassicalOpType::IdivChecked, 2, 1, Some((6, is_signed))), - "imod" => (ClassicalOpType::Imod, 2, 1, Some((6, is_signed))), - "imod_checked" => (ClassicalOpType::ImodChecked, 2, 1, Some((6, is_signed))), - "idivmod" => (ClassicalOpType::Idivmod, 2, 2, Some((6, is_signed))), - "idivmod_checked" => (ClassicalOpType::IdivmodChecked, 2, 1, Some((6, is_signed))), - "ipow" => (ClassicalOpType::Ipow, 2, 1, Some((6, is_signed))), - "ineg" => (ClassicalOpType::Ineg, 1, 1, Some((6, true))), - "iabs" => (ClassicalOpType::Iabs, 1, 1, Some((6, is_signed))), - "ieq" => (ClassicalOpType::Ieq, 2, 1, Some((6, is_signed))), - "ine" => (ClassicalOpType::Ine, 2, 1, Some((6, is_signed))), - "ilt" => (ClassicalOpType::Ilt, 2, 1, Some((6, is_signed))), - "ile" => (ClassicalOpType::Ile, 2, 1, Some((6, is_signed))), - "igt" => (ClassicalOpType::Igt, 2, 1, Some((6, is_signed))), - "ige" => (ClassicalOpType::Ige, 2, 1, Some((6, is_signed))), - "iand" => (ClassicalOpType::Iand, 2, 1, Some((6, is_signed))), - "ior" => (ClassicalOpType::Ior, 2, 1, Some((6, is_signed))), - "ixor" => (ClassicalOpType::Ixor, 2, 1, Some((6, is_signed))), - "inot" => (ClassicalOpType::Inot, 1, 1, Some((6, is_signed))), - "ishl" => (ClassicalOpType::Ishl, 2, 1, Some((6, is_signed))), - "ishr" => (ClassicalOpType::Ishr, 2, 1, Some((6, is_signed))), + "iadd" => (ClassicalOpType::Iadd, 2, 1, Some((lw, is_signed))), // default 64-bit + "isub" => (ClassicalOpType::Isub, 2, 1, Some((lw, is_signed))), + "imul" => (ClassicalOpType::Imul, 2, 1, Some((lw, is_signed))), + "idiv" => (ClassicalOpType::Idiv, 2, 1, Some((lw, is_signed))), + "idiv_checked" => (ClassicalOpType::IdivChecked, 2, 1, Some((lw, is_signed))), + "imod" => (ClassicalOpType::Imod, 2, 1, Some((lw, is_signed))), + "imod_checked" => (ClassicalOpType::ImodChecked, 2, 1, Some((lw, is_signed))), + "idivmod" => (ClassicalOpType::Idivmod, 2, 2, Some((lw, is_signed))), + "idivmod_checked" => (ClassicalOpType::IdivmodChecked, 2, 1, Some((lw, is_signed))), + "ipow" => (ClassicalOpType::Ipow, 2, 1, Some((lw, is_signed))), + "ineg" => (ClassicalOpType::Ineg, 1, 1, Some((lw, true))), + "iabs" => (ClassicalOpType::Iabs, 1, 1, Some((lw, is_signed))), + "ieq" => (ClassicalOpType::Ieq, 2, 1, Some((lw, is_signed))), + "ine" => (ClassicalOpType::Ine, 2, 1, Some((lw, is_signed))), + "ilt" => (ClassicalOpType::Ilt, 2, 1, Some((lw, is_signed))), + "ile" => (ClassicalOpType::Ile, 2, 1, Some((lw, is_signed))), + "igt" => (ClassicalOpType::Igt, 2, 1, Some((lw, is_signed))), + "ige" => (ClassicalOpType::Ige, 2, 1, Some((lw, is_signed))), + "iand" => (ClassicalOpType::Iand, 2, 1, Some((lw, is_signed))), + "ior" => (ClassicalOpType::Ior, 2, 1, Some((lw, is_signed))), + "ixor" => (ClassicalOpType::Ixor, 2, 1, Some((lw, is_signed))), + "inot" => (ClassicalOpType::Inot, 1, 1, Some((lw, is_signed))), + "ishl" => (ClassicalOpType::Ishl, 2, 1, Some((lw, is_signed))), + "ishr" => (ClassicalOpType::Ishr, 2, 1, Some((lw, is_signed))), _ => return None, } } @@ -690,26 +701,36 @@ pub fn classify_classical_op(op: &OpType) -> Option { _ => return None, }, // Conversion extension - "arithmetic.conversions" => match op_name.as_str() { - "convert_s" => (ClassicalOpType::ConvertIntToFloat, 1, 1, Some((6, true))), - "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, Some((6, false))), - // trunc_s/trunc_u return sum_with_error(int), not a raw int - "trunc_s" => ( - ClassicalOpType::ConvertFloatToIntChecked, - 1, - 1, - Some((6, true)), - ), - "trunc_u" => ( - ClassicalOpType::ConvertFloatToIntChecked, - 1, - 1, - Some((6, false)), - ), - "itobool" => (ClassicalOpType::ItoBool, 1, 1, Some((0, false))), - "ifrombool" => (ClassicalOpType::IfromBool, 1, 1, Some((0, false))), - _ => return None, - }, + "arithmetic.conversions" => { + let lw = ext_op + .args() + .iter() + .find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => u8::try_from(*n).ok(), + _ => None, + }) + .unwrap_or(6); + match op_name.as_str() { + "convert_s" => (ClassicalOpType::ConvertIntToFloat, 1, 1, Some((lw, true))), + "convert_u" => (ClassicalOpType::ConvertIntToFloat, 1, 1, Some((lw, false))), + // trunc_s/trunc_u return sum_with_error(int), not a raw int + "trunc_s" => ( + ClassicalOpType::ConvertFloatToIntChecked, + 1, + 1, + Some((lw, true)), + ), + "trunc_u" => ( + ClassicalOpType::ConvertFloatToIntChecked, + 1, + 1, + Some((lw, false)), + ), + "itobool" => (ClassicalOpType::ItoBool, 1, 1, Some((0, false))), + "ifrombool" => (ClassicalOpType::IfromBool, 1, 1, Some((0, false))), + _ => return None, + } + } // Prelude extension (tuples, etc.) "prelude" => { // Use the dataflow-signature port counts, NOT hugr.num_inputs/ diff --git a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs index 6f64e10bd..05893bed4 100644 --- a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs +++ b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs @@ -165,24 +165,70 @@ impl HugrEngine { // division while the classical path is Euclidean), so they were // removed. - // Get input values + // Get input values. The op's width (BoundedNat type arg) makes the + // bit-level ops exact for narrow ints; absent args default to 64. + let log_width = hugr + .get_optype(node) + .as_extension_op() + .and_then(|ext| { + ext.args().iter().find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => u8::try_from(*n).ok(), + _ => None, + }) + }) + .unwrap_or(6); + let bits = u64::from(1u32 << log_width); + let mask = crate::engine::handlers::classical::width_mask(log_width); + let canon = |v: i64| crate::engine::handlers::classical::canonicalize_width(v, log_width); let a = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()); let b = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()); #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] let result: Option = match op_name { - // Rotations: the amount is reduced modulo the width (64), per - // the spec's const-fold (`k = n1 % w`); clamping mapped - // rotate-by-64 to rotate-by-63 instead of the identity. - "irotl" | "rotl" => a.zip(b).map(|(x, y)| x.rotate_left((y as u64 % 64) as u32)), - "irotr" | "rotr" => a - .zip(b) - .map(|(x, y)| x.rotate_right((y as u64 % 64) as u32)), - - // Bit counting - "ipopcnt" | "popcnt" | "popcount" => a.map(|x| i64::from(x.count_ones())), - "iclz" | "clz" => a.map(|x| i64::from(x.leading_zeros())), - "ictz" | "ctz" => a.map(|x| i64::from(x.trailing_zeros())), + // Rotations: the amount is reduced modulo the width, per the + // spec's const-fold (`k = n1 % w`); the rotation happens within + // the op's N bits. + "irotl" | "rotl" => a.zip(b).map(|(x, y)| { + let k = (y as u64) % bits; + let x = (x as u64) & mask; + let rotated = if k == 0 { + x + } else { + ((x << k) | (x >> (bits - k))) & mask + }; + canon(rotated.cast_signed()) + }), + "irotr" | "rotr" => a.zip(b).map(|(x, y)| { + let k = (y as u64) % bits; + let x = (x as u64) & mask; + let rotated = if k == 0 { + x + } else { + ((x >> k) | (x << (bits - k))) & mask + }; + canon(rotated.cast_signed()) + }), + + // Bit counting, within the op's N bits + "ipopcnt" | "popcnt" | "popcount" => { + a.map(|x| i64::from(((x as u64) & mask).count_ones())) + } + "iclz" | "clz" => a.map(|x| { + let masked = (x as u64) & mask; + i64::from(if masked == 0 { + bits as u32 + } else { + masked.leading_zeros() - (64 - bits as u32) + }) + }), + "ictz" | "ctz" => a.map(|x| { + let masked = (x as u64) & mask; + i64::from(if masked == 0 { + bits as u32 + } else { + masked.trailing_zeros() + }) + }), // Min/max "imin_s" | "imin" => a.zip(b).map(|(x, y)| x.min(y)), @@ -208,14 +254,17 @@ impl HugrEngine { au.zip(bu).map(|(x, y)| x.max(y) as i64) } - // Width widening: no-op for the 64-bit unified storage + // Width widening. Signed values are stored canonically + // sign-extended, so widen_s is the identity; widen_u must + // ZERO-extend from the source width (the first BoundedNat arg), + // e.g. a canonical 1-bit "1" is stored as -1 and widens to 1. #[allow(clippy::match_same_arms)] "iwiden_s" | "widen_s" => a, #[allow(clippy::cast_possible_wrap)] "iwiden_u" | "widen_u" => self .get_input_value(hugr, node, 0) .and_then(|v| v.as_uint()) - .map(|x| x as i64), + .map(|x| (x & mask) as i64), // Width narrowing returns sum_with_error(int): handled below // (multi-variant output, not a plain i64). diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 5b7b1c4c9..c9b086236 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -34,6 +34,29 @@ use crate::engine::HugrEngine; use crate::engine::handlers::{ClassicalOutcome, HandlerOutcome}; use crate::engine::types::{ClassicalOp, ClassicalOpType, ClassicalValue}; +/// Mask for the low `2^log_width` bits (`log_width` >= 6 means full `i64`). +pub(crate) fn width_mask(log_width: u8) -> u64 { + if log_width >= 6 { + u64::MAX + } else { + (1u64 << (1u32 << log_width)) - 1 + } +} + +/// Canonicalize a value to the op's width: mask to `2^log_width` bits and +/// sign-extend, so the stored i64 is the two's-complement value the width +/// implies (e.g. `int<5>` `0xFFFF_FFFF` stores as -1). Matches `ConstInt` +/// parsing, which stores `value_s()` (sign-extended) for every width. +#[allow(clippy::cast_possible_wrap, clippy::cast_sign_loss)] +pub(crate) fn canonicalize_width(value: i64, log_width: u8) -> i64 { + if log_width >= 6 { + return value; + } + let bits = 1u32 << log_width; + let masked = (value as u64) & width_mask(log_width); + ((masked << (64 - bits)) as i64) >> (64 - bits) +} + impl HugrEngine { /// Execute a classical operation. /// @@ -229,14 +252,26 @@ impl HugrEngine { // unconvertible input is the same hazard as a missing one -- // defaulting (the old unwrap_or(0/false/0.0)) silently computes on // fabricated operands, so extraction failure defers the whole op. - let int = |i: usize| inputs.get(i).and_then(ClassicalValue::as_int); + // Extraction is width-aware: the op's declared width (int_info) + // masks unsigned reads and canonicalizes signed ones, so narrow + // ints (int<5> = 32-bit etc.) compute exactly. + let log_width = op.int_info.map_or(6, |(lw, _)| lw); + let int = |i: usize| { + inputs + .get(i) + .and_then(ClassicalValue::as_int) + .map(|v| canonicalize_width(v, log_width)) + }; // Unsigned ops reinterpret the stored i64 bit pattern: wrapping // arithmetic stores results through Int, so as_uint (which rejects // negatives) would spuriously defer on e.g. u64::MAX. #[allow(clippy::cast_sign_loss)] - let uint = |i: usize| match inputs.get(i) { - Some(ClassicalValue::UInt(u)) => Some(*u), - other => other.and_then(ClassicalValue::as_int).map(|v| v as u64), + let uint = |i: usize| { + match inputs.get(i) { + Some(ClassicalValue::UInt(u)) => Some(*u), + other => other.and_then(ClassicalValue::as_int).map(|v| v as u64), + } + .map(|v| v & width_mask(log_width)) }; let boolean = |i: usize| inputs.get(i).and_then(ClassicalValue::as_bool); let float = |i: usize| inputs.get(i).and_then(ClassicalValue::as_float); @@ -379,19 +414,30 @@ impl HugrEngine { ClassicalOpType::Ior => ClassicalValue::Int(int(0)? | int(1)?), ClassicalOpType::Ixor => ClassicalValue::Int(int(0)? ^ int(1)?), ClassicalOpType::Inot => ClassicalValue::Int(!int(0)?), - #[allow(clippy::cast_sign_loss)] + #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)] ClassicalOpType::Ishl => { - let shift = int(1)?.clamp(0, 63) as u32; - ClassicalValue::Int(int(0)?.wrapping_shl(shift)) + // "leftmost bits dropped, rightmost bits set to zero": + // shifting by k >= N drops every bit. + let k = uint(1)?; + let bits = u64::from(1u32 << log_width); + if k >= bits { + return Some(ClassicalValue::Int(0)); + } + #[allow(clippy::cast_possible_truncation)] + ClassicalValue::Int((uint(0)? << (k as u32)) as i64) } #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)] ClassicalOpType::Ishr => { // LOGICAL shift per the spec ("rightmost bits dropped, // leftmost bits set to zero") -- ishr has no signed - // variant; the value's signedness does not change the - // shift semantics. - let shift = int(1)?.clamp(0, 63) as u32; - ClassicalValue::Int((uint(0)? >> shift) as i64) + // variant, and shifting by k >= N drops every bit. + let k = uint(1)?; + let bits = u64::from(1u32 << log_width); + if k >= bits { + return Some(ClassicalValue::Int(0)); + } + #[allow(clippy::cast_possible_truncation)] + ClassicalValue::Int((uint(0)? >> (k as u32)) as i64) } ClassicalOpType::Ipow => { // "raise first input to the power of second input, the @@ -492,6 +538,15 @@ impl HugrEngine { })(); if let Some(value) = result { + // Results of width-carrying ops store CANONICAL: masked to the + // op's width and sign-extended (so wrapping at int<5> etc. is + // exact, and every consumer sees the value the width implies). + let value = match value { + ClassicalValue::Int(v) if op.int_info.is_some() => { + ClassicalValue::Int(canonicalize_width(v, log_width)) + } + other => other, + }; ClassicalOutcome::Outputs(vec![(0, value)]) } else if div_by_zero { // The spec says unchecked division/modulo by zero panics. From 8bba6c3f04dd039b9eb52ec60fb54f323eb8523e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 13:57:50 -0600 Subject: [PATCH 360/388] Support higher-order array scan (per-element execution through Call-frame machinery for both CFG and plain-dataflow function bodies, with LoadFunction producing first-class FuncRef values), parse result labels from typed String args instead of Debug scraping, and flip conditional_x to assert clean completion with its captured result array --- crates/pecos-hugr/src/engine.rs | 75 ++-- crates/pecos-hugr/src/engine/analysis.rs | 6 + crates/pecos-hugr/src/engine/control_flow.rs | 1 + .../src/engine/control_flow/call.rs | 6 + .../pecos-hugr/src/engine/control_flow/cfg.rs | 1 + .../src/engine/control_flow/conditional.rs | 2 + .../src/engine/control_flow/scan.rs | 346 ++++++++++++++++++ .../src/engine/control_flow/tailloop.rs | 1 + .../pecos-hugr/src/engine/handlers/array.rs | 1 + .../src/engine/handlers/borrow_arr.rs | 1 + .../src/engine/handlers/classical.rs | 12 +- .../pecos-hugr/src/engine/handlers/result.rs | 23 +- crates/pecos-hugr/src/engine/types.rs | 33 ++ .../tests/guppy/test_semantic_sweep.py | 37 ++ 14 files changed, 498 insertions(+), 47 deletions(-) create mode 100644 crates/pecos-hugr/src/engine/control_flow/scan.rs diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 87a350e57..b58a20a54 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -50,9 +50,9 @@ pub use types::{CapturedResult, ClassicalValue, FutureId, ResultValue, RngContex // Use internal types from submodules use types::{ - ActiveCallInfo, ActiveCaseInfo, ActiveCfgInfo, ActiveTailLoopInfo, CfgInfo, ClassicalOp, - ConditionalInfo, ExtensionState, FuncDefnInfo, MeasurementState, QuantumOp, TailLoopInfo, - WireState, + ActiveCallInfo, ActiveCaseInfo, ActiveCfgInfo, ActiveScanInfo, ActiveTailLoopInfo, CfgInfo, + ClassicalOp, ConditionalInfo, ExtensionState, FuncDefnInfo, MeasurementState, QuantumOp, + TailLoopInfo, WireState, }; // Use analysis functions from submodule @@ -90,6 +90,9 @@ pub struct HugrEngine { /// Set of processed nodes. pub(crate) processed: BTreeSet, + /// In-flight higher-order array scans, keyed by scan node. + pub(crate) active_scans: BTreeMap, + /// Container regions the engine actually activated this shot /// (`DataflowBlocks`, selected Cases, `TailLoop` bodies), with a label for /// diagnostics. Persistent across the shot (unlike the active_* maps), @@ -387,6 +390,7 @@ impl HugrEngine { self.active_tailloops.clear(); self.pending_tailloop_control.clear(); self.execution_error = None; + self.active_scans.clear(); self.executed_containers.clear(); // Clear result capture state @@ -1124,6 +1128,7 @@ impl HugrEngine { } } self.processed.insert(current_node); + self.check_scan_frame_completion(&hugr, current_node); self.check_case_completion(&hugr, current_node); self.check_cfg_block_completion(&hugr, current_node); self.check_tailloop_body_completion(&hugr, current_node); @@ -1156,6 +1161,7 @@ impl HugrEngine { // A Case/block may consist of just constants feeding its // Output (e.g. a loop's continue-flag bool) -- check // completion so outputs propagate only with values present. + self.check_scan_frame_completion(&hugr, current_node); self.check_case_completion(&hugr, current_node); self.check_cfg_block_completion(&hugr, current_node); @@ -1223,6 +1229,7 @@ impl HugrEngine { // Check if this classical op completion allows a Case to // complete (cases may contain only classical ops, e.g. sum // construction for an iterator's continue/break value) + self.check_scan_frame_completion(&hugr, current_node); self.check_case_completion(&hugr, current_node); // Check if this classical op completion allows a CFG block to complete @@ -1258,6 +1265,7 @@ impl HugrEngine { // Check if this extension op completion allows a Case to // complete (cases may contain only classical/extension ops) + self.check_scan_frame_completion(&hugr, current_node); self.check_case_completion(&hugr, current_node); // Check if this extension op completion allows a CFG block to complete @@ -1303,6 +1311,7 @@ impl HugrEngine { operation_count += 1; // Check if this operation completes any active Case + self.check_scan_frame_completion(&hugr, current_node); self.check_case_completion(&hugr, current_node); // Check if this operation completes any active CFG block @@ -1374,6 +1383,12 @@ impl HugrEngine { self.active_tailloops.keys().collect::>() )); } + if !self.active_scans.is_empty() { + stalled.push(format!( + "active scans: {:?}", + self.active_scans.keys().collect::>() + )); + } if !self.pending_bool_reads.is_empty() { stalled.push(format!( "starved deferred nodes: {:?}", @@ -1756,6 +1771,7 @@ impl Default for HugrEngine { classical_ops: BTreeMap::new(), work_queue: VecDeque::new(), processed: BTreeSet::new(), + active_scans: BTreeMap::new(), executed_containers: BTreeMap::new(), message_builder: ByteMessageBuilder::new(), // Grouped state @@ -3630,24 +3646,16 @@ mod tests { let mut engine = HugrEngine::from_file(hugr_path).expect("Failed to load HUGR"); - // Drive with one outcome per measurement (all zeros). The quantum - // part of this fixture executes correctly (asserted below), but its + // Drive with one outcome per measurement (all zeros). The // result-reporting tail maps a function over the measured array via - // collections.borrow_arr.scan -- a higher-order op the engine does - // not execute (function-valued arguments). The engine must report - // that as a loud stall rather than pass the scan through as an - // identity wire and hand result_array_bool garbage, which is - // exactly how this test "passed" for most of its life. Flip the - // stall expectation to clean completion when scan support lands. + // collections.borrow_arr.scan; with scan support the program runs + // to clean completion and captures the reported result array. let mut stage = engine.start(()).expect("Failed to start engine"); let mut gate_counts: BTreeMap = BTreeMap::new(); let mut rounds = 0; loop { rounds += 1; - assert!( - rounds <= 20, - "conditional_x should stall or complete quickly" - ); + assert!(rounds <= 20, "conditional_x should complete quickly"); match stage { pecos_engines::EngineStage::NeedsProcessing(msg) => { let ops = msg.quantum_ops().expect("parse quantum ops"); @@ -3666,33 +3674,30 @@ mod tests { let mut builder = ByteMessageBuilder::new(); let _ = builder.for_outcomes(); builder.add_outcomes(&vec![0usize; n_meas]); - match engine.continue_processing(builder.build()) { - Ok(next) => stage = next, - Err(e) => { - let msg = e.to_string(); - assert!( - msg.contains("stalled before completion"), - "expected the unsupported-scan stall, got: {msg}" - ); - break; - } - } - } - pecos_engines::EngineStage::Complete(_) => { - panic!( - "conditional_x unexpectedly completed: borrow_arr.scan support has landed -- flip this test to assert clean completion" - ); + stage = engine + .continue_processing(builder.build()) + .expect("conditional_x must complete cleanly under scan support"); } + pecos_engines::EngineStage::Complete(_) => break, } } - // Non-vacuous: the quantum part must have executed before the - // reporting tail stalled. With all-zero outcomes the measurement - // selects the else branch: H on the control, two measurements, and - // NO conditional X. + // With all-zero outcomes the measurement selects the else branch: + // H on the control, two measurements, and NO conditional X. assert_eq!(gate_counts.get(&GateType::H), Some(&1), "{gate_counts:?}"); assert_eq!(gate_counts.get(&GateType::MZ), Some(&2), "{gate_counts:?}"); assert_eq!(gate_counts.get(&GateType::X), None, "{gate_counts:?}"); + + // The scan-driven reporting tail must produce the result array: + // both measured bits are 0. + assert!(engine.active_scans.is_empty()); + let captured = engine.get_captured_results(); + assert!( + captured.iter().any( + |r| matches!(&r.value, ResultValue::ArrayBool(bits) if bits == &vec![false, false]) + ), + "expected a [false, false] result array, got {captured:?}" + ); } // --- Integration Tests with Quantum Simulator --- diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 51761fe19..21386f9b5 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -621,6 +621,12 @@ pub fn classify_classical_op(op: &OpType) -> Option { return Some((ClassicalOpType::TagSum, sig.input_count(), 1, None)); } + // LoadFunction produces a first-class function value for its static + // FuncDefn target (consumed by higher-order ops like scan). + if let OpType::LoadFunction(_) = op { + return Some((ClassicalOpType::LoadFunc, 0, 1, None)); + } + let ext_op = op.as_extension_op()?; let ext_id = ext_op.extension_id(); diff --git a/crates/pecos-hugr/src/engine/control_flow.rs b/crates/pecos-hugr/src/engine/control_flow.rs index da0ededbc..253377452 100644 --- a/crates/pecos-hugr/src/engine/control_flow.rs +++ b/crates/pecos-hugr/src/engine/control_flow.rs @@ -27,4 +27,5 @@ pub mod call; pub mod cfg; pub mod conditional; +pub mod scan; pub mod tailloop; diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index 9dc706062..88577b612 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -97,6 +97,11 @@ impl HugrEngine { /// 3. Adds Call successors to the work queue /// 4. Starts any pending calls to the same `FuncDefn` pub(crate) fn complete_func_call_if_needed(&mut self, hugr: &Hugr, cfg_node: tket::hugr::Node) { + // A scan folding through this FuncDefn owns the frame: route the + // completion to it (next element, or scan completion). + if self.continue_scan_after_frame(hugr, cfg_node) { + return; + } // Find which active Call (if any) has a FuncDefn with this CFG let call_to_complete: Option<(tket::hugr::Node, tket::hugr::Node)> = self .active_calls @@ -159,6 +164,7 @@ impl HugrEngine { // Check if this Call completion allows a parent Case to // complete (a case whose FINAL completion event is the Call // itself would otherwise stay active forever). + self.check_scan_frame_completion(hugr, call_node); self.check_case_completion(hugr, call_node); // Check if this Call completion allows a parent CFG block to complete diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 1b9e838f8..3ae16c96b 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -671,6 +671,7 @@ impl HugrEngine { // body -- run the container completion hooks (mirrors // complete_tailloop). Block completion needs no hook: blocks do // not track nested CFG children. + self.check_scan_frame_completion(hugr, cfg_node); self.check_case_completion(hugr, cfg_node); self.check_tailloop_body_completion(hugr, cfg_node); } diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index 051a3c5ee..617c504a8 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -199,6 +199,7 @@ impl HugrEngine { // on the case being done) and wake its consumers. The Conditional // may itself be the last op of an ENCLOSING case, so check case // completion too (recursing one nesting level per call). + self.check_scan_frame_completion(hugr, cond_node); self.check_case_completion(hugr, cond_node); self.check_cfg_block_completion(hugr, cond_node); self.check_tailloop_body_completion(hugr, cond_node); @@ -590,6 +591,7 @@ impl HugrEngine { // consumer gated solely on this Conditional -- e.g. a zero-argument // Call sequenced behind it by an order edge -- otherwise starves. if case_completed_inline { + self.check_scan_frame_completion(hugr, cond_node); self.check_case_completion(hugr, cond_node); self.check_cfg_block_completion(hugr, cond_node); self.check_tailloop_body_completion(hugr, cond_node); diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs new file mode 100644 index 000000000..2bc84ef18 --- /dev/null +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -0,0 +1,346 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Higher-order array `scan` execution. +//! +//! `collections.array.scan` / `collections.borrow_arr.scan` fold a function +//! value over an array: `array, (T1, *A -> T2, *A), *A -> +//! array, *A`. The engine runs the scanned function once per element +//! through the same frame machinery as Calls, so quantum ops inside the +//! function (e.g. `measure_array`'s per-qubit measure) go through real +//! measurement rounds. Elements fold left to right, matching the order the +//! measurements were declared in. + +use log::debug; +use tket::hugr::ops::OpTrait; +use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; + +use crate::engine::HugrEngine; +use crate::engine::activation::{ContainerActivation, QueuePolicy}; +use crate::engine::analysis::collect_descendants; +use crate::engine::handlers::HandlerOutcome; +use crate::engine::types::{ActiveScanInfo, ClassicalValue}; + +impl HugrEngine { + /// Handle a `scan` op: start folding, or defer until every input + /// (array, function value, accumulators) resolves and the scanned + /// function's frame is free. + pub(crate) fn handle_scan_op(&mut self, hugr: &Hugr, node: Node) -> HandlerOutcome { + if self.active_scans.contains_key(&node) { + // Already folding: frame completions drive progress; retries of + // the scan node itself have nothing to do. + return HandlerOutcome::Defer; + } + + let Some(ClassicalValue::Array(elements)) = self.get_input_value(hugr, node, 0) else { + debug!("scan at {node:?}: array not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(ClassicalValue::FuncRef(func_defn_node)) = self.get_input_value(hugr, node, 1) + else { + debug!("scan at {node:?}: function value not ready, deferring"); + return HandlerOutcome::Defer; + }; + let Some(sig) = hugr.get_optype(node).dataflow_signature() else { + return HandlerOutcome::Fault(format!("scan at {node:?} has no dataflow signature")); + }; + let mut accs = Vec::new(); + for port in 2..sig.input_count() { + let Some(value) = self.get_input_value(hugr, node, port) else { + debug!("scan at {node:?}: accumulator {port} not ready, deferring"); + return HandlerOutcome::Defer; + }; + accs.push(value); + } + + if !self.func_defns.contains_key(&func_defn_node) { + return HandlerOutcome::Fault(format!( + "scan at {node:?} references unknown FuncDefn {func_defn_node:?}" + )); + } + // The scanned function's single execution frame must be free. + let frame_in_use = self + .active_calls + .values() + .any(|info| info.func_defn_node == func_defn_node) + || self + .active_scans + .values() + .any(|scan| scan.func_defn_node == func_defn_node); + if frame_in_use { + debug!("scan at {node:?}: FuncDefn {func_defn_node:?} frame in use, deferring"); + return HandlerOutcome::Defer; + } + + let mut state = ActiveScanInfo { + scan_node: node, + func_defn_node, + remaining: elements.into(), + results: Vec::new(), + accs, + frame_ops: std::collections::BTreeSet::new(), + }; + if state.remaining.is_empty() { + // Zero-length array: complete immediately. + self.store_scan_outputs(&state); + debug!("scan at {node:?}: empty array, completed immediately"); + return HandlerOutcome::Processed; + } + debug!( + "scan at {node:?}: folding {} elements through FuncDefn {func_defn_node:?}", + state.remaining.len() + ); + let element = state + .remaining + .pop_front() + .expect("non-empty checked above"); + self.active_scans.insert(node, state); + self.launch_scan_iteration(hugr, node, element); + // Like a Call, the scan node stays unprocessed while its frame + // runs; completion marks it. Defer parks it harmlessly. + HandlerOutcome::Defer + } + + /// Run one element through the scanned function's frame. + fn launch_scan_iteration(&mut self, hugr: &Hugr, scan_node: Node, element: ClassicalValue) { + let Some(state) = self.active_scans.get(&scan_node) else { + return; + }; + let func_defn_node = state.func_defn_node; + let accs = state.accs.clone(); + let Some(func_info) = self.func_defns.get(&func_defn_node).cloned() else { + return; + }; + + // Reset the frame exactly like a Call re-activation: descendants' + // processed flags AND stale wires clear (previous element's values + // must not leak into this one), and the previous iteration's + // executed-container records are invalidated. + let mut descendants = std::collections::BTreeSet::new(); + collect_descendants(hugr, func_defn_node, &mut descendants); + let mut act = ContainerActivation::new(); + for node in &descendants { + self.nodes_inside_func_defns.remove(node); + act.reset(*node); + self.executed_containers.remove(node); + } + act.keep_wires(func_info.input_node); + if let Some(cfg_node) = func_info.cfg_node { + act.reset_processed(cfg_node); + } + // A plain dataflow body (no CFG) executes as a tracked op set: queue + // every non-boundary child; their collective completion finishes + // the element (check_scan_frame_completion). + let mut frame_ops = std::collections::BTreeSet::new(); + if func_info.cfg_node.is_none() { + for child in hugr.children(func_defn_node) { + let op = hugr.get_optype(child); + if matches!( + op, + tket::hugr::ops::OpType::Input(_) + | tket::hugr::ops::OpType::Output(_) + | tket::hugr::ops::OpType::Const(_) + ) { + continue; + } + let policy = match op { + tket::hugr::ops::OpType::Conditional(_) + | tket::hugr::ops::OpType::TailLoop(_) + | tket::hugr::ops::OpType::LoadConstant(_) => QueuePolicy::Always, + _ => QueuePolicy::IfReady, + }; + act.queue(child, policy); + frame_ops.insert(child); + } + } + if let Some(state) = self.active_scans.get_mut(&scan_node) { + state.frame_ops = frame_ops; + } + self.run_activation(hugr, &act); + + // Element -> Input port 0; accumulators -> Input ports 1.. + let input_wire = (func_info.input_node, 0); + if let ClassicalValue::QubitRef(qubit_id) = &element { + self.wire_state.wire_to_qubit.insert(input_wire, *qubit_id); + } else { + self.wire_state.wire_to_qubit.remove(&input_wire); + } + self.wire_state.classical_values.insert(input_wire, element); + for (i, acc) in accs.into_iter().enumerate() { + self.wire_state + .classical_values + .insert((func_info.input_node, 1 + i), acc); + } + + if let Some(cfg_node) = func_info.cfg_node { + debug!("scan {scan_node:?}: launching frame CFG {cfg_node:?}"); + if !self.work_queue.contains(&cfg_node) { + self.work_queue.push_front(cfg_node); + } + } else { + debug!("scan {scan_node:?}: launched dataflow frame"); + // An EMPTY dataflow body (pure passthrough) completes at once. + self.check_scan_frame_completion(hugr, scan_node); + } + } + + /// Check whether an active scan's DATAFLOW frame finished: every + /// tracked body op processed and no nested container still active. + /// Advances the scan when it did. `processed_node` scopes the check to + /// scans whose frame contains it (or the scan node itself for the + /// empty-frame case). + pub(crate) fn check_scan_frame_completion(&mut self, hugr: &Hugr, processed_node: Node) { + let candidates: Vec = self + .active_scans + .iter() + .filter(|(scan_node, state)| { + **scan_node == processed_node || state.frame_ops.contains(&processed_node) + }) + .filter(|(_, state)| { + // CFG-bodied frames complete via complete_func_call_if_needed. + self.func_defns + .get(&state.func_defn_node) + .is_some_and(|info| info.cfg_node.is_none()) + }) + .map(|(&scan_node, _)| scan_node) + .collect(); + for scan_node in candidates { + let Some(state) = self.active_scans.get(&scan_node) else { + continue; + }; + let all_done = state.frame_ops.iter().all(|op| { + self.processed.contains(op) + && !self + .active_cases + .values() + .any(|case| case.conditional_node == *op) + && !self.active_tailloops.contains_key(op) + && !self.active_calls.contains_key(op) + && !self.active_cfgs.contains_key(op) + }); + if all_done { + debug!("scan {scan_node:?}: dataflow frame complete"); + self.advance_scan(hugr, scan_node); + } + } + } + + /// Route a completed `FuncDefn` CFG to its scan, if one is folding + /// through it. Returns true when the completion belonged to a scan. + pub(crate) fn continue_scan_after_frame(&mut self, hugr: &Hugr, cfg_node: Node) -> bool { + let scan_node = self.active_scans.iter().find_map(|(&scan_node, state)| { + self.func_defns + .get(&state.func_defn_node) + .filter(|info| info.cfg_node == Some(cfg_node)) + .map(|_| scan_node) + }); + let Some(scan_node) = scan_node else { + return false; + }; + self.advance_scan(hugr, scan_node); + true + } + + /// One element's frame finished: collect its outputs, then fold the + /// next element or complete the scan. + fn advance_scan(&mut self, hugr: &Hugr, scan_node: Node) { + let Some(state) = self.active_scans.get(&scan_node) else { + return; + }; + let Some(func_info) = self.func_defns.get(&state.func_defn_node).cloned() else { + return; + }; + + // Collect the frame's outputs: port 0 = mapped element, 1.. = accs. + let read_output = |engine: &Self, port: usize| -> Option { + let (src, sp) = + hugr.single_linked_output(func_info.output_node, IncomingPort::from(port))?; + let wire = (src, sp.index()); + if let Some(value) = engine.wire_state.classical_values.get(&wire) { + return Some(value.clone()); + } + engine + .wire_state + .wire_to_qubit + .get(&wire) + .map(|q| ClassicalValue::QubitRef(*q)) + }; + let Some(mapped) = read_output(self, 0) else { + // The frame completed without producing the mapped value: a + // wiring bug this must not paper over. + self.execution_error = Some(format!( + "scan {scan_node:?}: frame completed without an output value" + )); + self.active_scans.remove(&scan_node); + return; + }; + let num_accs = self.active_scans[&scan_node].accs.len(); + let mut new_accs = Vec::with_capacity(num_accs); + for i in 0..num_accs { + if let Some(value) = read_output(self, 1 + i) { + new_accs.push(value); + } else { + self.execution_error = Some(format!( + "scan {scan_node:?}: frame completed without accumulator {i}" + )); + self.active_scans.remove(&scan_node); + return; + } + } + + let state = self + .active_scans + .get_mut(&scan_node) + .expect("checked above"); + state.results.push(mapped); + state.accs = new_accs; + + if let Some(element) = state.remaining.pop_front() { + self.launch_scan_iteration(hugr, scan_node, element); + return; + } + + // All elements folded: store outputs and complete the scan node. + let state = self.active_scans.remove(&scan_node).expect("present above"); + self.store_scan_outputs(&state); + self.processed.insert(scan_node); + self.pending_bool_reads.remove(&scan_node); + debug!( + "scan {scan_node:?}: complete with {} results", + state.results.len() + ); + + // Same completion cascade as a Call. + self.check_case_completion(hugr, scan_node); + self.check_cfg_block_completion(hugr, scan_node); + self.check_tailloop_body_completion(hugr, scan_node); + self.try_resolve_pending_tailloops(); + self.queue_ready_successors(hugr, scan_node); + self.retry_pending_bool_reads(); + } + + /// Store the scan node's outputs: the mapped array on port 0 and the + /// final accumulators on ports 1.. . + fn store_scan_outputs(&mut self, state: &ActiveScanInfo) { + let scan_node = state.scan_node; + self.wire_state + .classical_values + .insert((scan_node, 0), ClassicalValue::Array(state.results.clone())); + for (i, acc) in state.accs.iter().enumerate() { + self.wire_state + .classical_values + .insert((scan_node, 1 + i), acc.clone()); + } + } +} diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index a6d3fde13..ac062018d 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -453,6 +453,7 @@ impl HugrEngine { // Check if this TailLoop completion allows a CFG block to complete // A parent Case whose final completion event is this loop must // observe it too. + self.check_scan_frame_completion(hugr, tailloop_node); self.check_case_completion(hugr, tailloop_node); self.check_cfg_block_completion(hugr, tailloop_node); // A completed loop may be the last op of an ENCLOSING loop's body diff --git a/crates/pecos-hugr/src/engine/handlers/array.rs b/crates/pecos-hugr/src/engine/handlers/array.rs index 83d12f300..f7f478108 100644 --- a/crates/pecos-hugr/src/engine/handlers/array.rs +++ b/crates/pecos-hugr/src/engine/handlers/array.rs @@ -263,6 +263,7 @@ impl HugrEngine { debug!("array.discard_empty: array consumed"); HandlerOutcome::Processed } + "scan" => self.handle_scan_op(hugr, node), _ => { // Unknown/unimplemented array op (e.g. `repeat`, whose real // signature takes a function value the engine cannot diff --git a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs index dd1b083dc..106c1f957 100644 --- a/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs +++ b/crates/pecos-hugr/src/engine/handlers/borrow_arr.rs @@ -343,6 +343,7 @@ impl HugrEngine { debug!("discard_all_borrowed: array consumed"); HandlerOutcome::Processed } + "scan" => self.handle_scan_op(hugr, node), _ => { // Unknown op: defer so it surfaces in the stall report // instead of silently passing values through. diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index c9b086236..cffb8b2df 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -148,6 +148,15 @@ impl HugrEngine { None => return ClassicalOutcome::Defer, } } + ClassicalOpType::LoadFunc => { + // Resolve the static FuncDefn target into a function value. + let Some(func_defn) = hugr.static_source(node) else { + return ClassicalOutcome::Fault(format!( + "LoadFunction at {node:?} has no static source" + )); + }; + return ClassicalOutcome::Outputs(vec![(0, ClassicalValue::FuncRef(func_defn))]); + } ClassicalOpType::TagSum => { // Tag wraps its inputs into the given variant of a sum. let OpType::Tag(tag_op) = hugr.get_optype(node) else { @@ -533,7 +542,8 @@ impl HugrEngine { | ClassicalOpType::UnpackTuple | ClassicalOpType::TagSum | ClassicalOpType::Idivmod - | ClassicalOpType::IdivmodChecked => return None, + | ClassicalOpType::IdivmodChecked + | ClassicalOpType::LoadFunc => return None, }) })(); diff --git a/crates/pecos-hugr/src/engine/handlers/result.rs b/crates/pecos-hugr/src/engine/handlers/result.rs index 678b44056..52fc8fd6a 100644 --- a/crates/pecos-hugr/src/engine/handlers/result.rs +++ b/crates/pecos-hugr/src/engine/handlers/result.rs @@ -190,21 +190,22 @@ impl HugrEngine { } } - /// Extract result label from operation parameters. + /// Extract the result label from the op's TYPED String arg. + /// + /// tket.result ops carry the user label as a String type arg; reading + /// it directly replaces the old Debug-output scrape, whose quote + /// heuristics also rejected legitimate labels containing "result", + /// "Op", or "Report". #[allow(clippy::unused_self)] // Consistent with other handler methods; may use self in future pub(crate) fn extract_result_label(&self, hugr: &Hugr, node: Node, op_name: &str) -> String { - // Try to extract label from the ExtensionOp's debug representation - // The debug format typically includes the label as a string parameter let op = hugr.get_optype(node); if let Some(ext_op) = op.as_extension_op() { - let debug_str = format!("{ext_op:?}"); - // Look for quoted string patterns that might be labels - // Common patterns: "label", label="value", or ("label", ...) - if let Some(label) = Self::extract_string_from_debug(&debug_str) - && !label.is_empty() - && label != op_name - { - return label; + for arg in ext_op.args() { + if let tket::hugr::types::TypeArg::String(label) = arg + && !label.is_empty() + { + return label.clone(); + } } } // Fallback: use node ID as label diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index 963f0aade..ad57a14a9 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -193,6 +193,8 @@ pub enum ClassicalOpType { IdivmodChecked, /// Exponentiation; the exponent is treated as unsigned per the spec. Ipow, + /// `LoadFunction`: produce a `FuncRef` value for the static target. + LoadFunc, /// Convert a 1-bit integer to bool. ItoBool, /// Convert a bool to a 1-bit integer. @@ -316,6 +318,9 @@ pub enum ClassicalValue { Future(FutureId), /// Rotation angle (in half-turns, i.e., multiples of pi) Rotation(f64), + /// A first-class function value: the `FuncDefn` it references + /// (produced by `LoadFunction`, consumed by higher-order ops like scan). + FuncRef(Node), /// RNG context handle RngContext(RngContextId), /// Qubit reference (for storing qubits in arrays) @@ -342,6 +347,7 @@ impl ClassicalValue { | Self::Rotation(_) | Self::RngContext(_) | Self::QubitRef(_) + | Self::FuncRef(_) | Self::Borrowed => None, } } @@ -365,6 +371,7 @@ impl ClassicalValue { | Self::Rotation(_) | Self::RngContext(_) | Self::QubitRef(_) + | Self::FuncRef(_) | Self::Borrowed => None, } } @@ -387,6 +394,7 @@ impl ClassicalValue { | Self::Rotation(_) | Self::RngContext(_) | Self::QubitRef(_) + | Self::FuncRef(_) | Self::Borrowed => None, } } @@ -409,6 +417,7 @@ impl ClassicalValue { | Self::Rotation(_) | Self::RngContext(_) | Self::QubitRef(_) + | Self::FuncRef(_) | Self::Borrowed => None, } } @@ -431,6 +440,7 @@ impl ClassicalValue { | Self::Future(_) | Self::RngContext(_) | Self::QubitRef(_) + | Self::FuncRef(_) | Self::Borrowed => None, } } @@ -774,6 +784,29 @@ pub struct ActiveCallInfo { // --- TailLoop Control Flow Types --- +/// State of an in-flight higher-order array `scan`: the engine runs the +/// scanned function once per element through the normal Call-frame +/// machinery, so quantum ops inside the function (e.g. `measure_array`'s +/// per-qubit measure) go through real measurement rounds. +#[derive(Debug, Clone)] +pub struct ActiveScanInfo { + /// The scan node itself. + pub scan_node: Node, + /// The scanned function. + pub func_defn_node: Node, + /// Elements not yet folded (front = next). + pub remaining: std::collections::VecDeque, + /// Mapped outputs collected so far. + pub results: Vec, + /// Current accumulator values (scan signature `*A`). + pub accs: Vec, + /// For a scanned function with a plain DATAFLOW body (no CFG): the + /// body ops whose completion finishes one element. Empty for + /// CFG-bodied functions (their frame completes through + /// `complete_func_call_if_needed`). + pub frame_ops: std::collections::BTreeSet, +} + /// Information about a `TailLoop` node. /// /// `TailLoop` executes its body repeatedly until the body outputs `BREAK_TAG` (1). diff --git a/python/quantum-pecos/tests/guppy/test_semantic_sweep.py b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py index c30141f83..569fea924 100644 --- a/python/quantum-pecos/tests/guppy/test_semantic_sweep.py +++ b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py @@ -26,6 +26,7 @@ """ from guppylang import guppy +from guppylang.std.builtins import result from guppylang.std.quantum import h, measure, qubit, x from pecos import Guppy, sim from pecos_rslib import state_vector @@ -294,3 +295,39 @@ def loop_measures() -> bool: return measure(q_out) _expect_all_ones(loop_measures) + + +def test_result_label_containing_reserved_words() -> None: + """Labels are read from the op's typed String arg, so user labels + containing "result", "Op", or "Report" (which the old Debug-scrape + heuristics rejected) must survive verbatim as result keys. The array + variant matters most: its extra BoundedNat arg broke the old primary + pattern, falling through to the rejecting heuristics.""" + + @guppy + def labeled() -> None: + q = qubit() + x(q) + result("my_result_Report_Op", measure(q)) + + results = sim(Guppy(labeled)).qubits(2).quantum(state_vector()).seed(7).run(3).to_dict() + assert results["my_result_Report_Op"] == [1, 1, 1], f"keys: {sorted(results)}" + + +def test_array_result_label_containing_reserved_words() -> None: + """Array results carry [String, BoundedNat] type args; the label must + still come from the typed String arg.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array + + @guppy + def labeled_array() -> None: + qs = array(qubit() for _ in range(2)) + x(qs[0]) + x(qs[1]) + result("my_result_Report", measure_array(qs)) + + results = ( + sim(Guppy(labeled_array)).qubits(3).quantum(state_vector()).seed(7).run(2).to_dict() + ) + assert results["my_result_Report"] == [[1, 1], [1, 1]], f"keys: {sorted(results)}" From 10c75c8f60011148f5db524a339af888701b6f7d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 14:05:03 -0600 Subject: [PATCH 361/388] Raise on measurement outcomes with no queued measurement to bind to (driver/engine batch disagreement) instead of silently dropping them --- crates/pecos-hugr/src/engine.rs | 44 ++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index b58a20a54..edc02c157 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1891,7 +1891,15 @@ impl ClassicalEngine for HugrEngine { .insert(wire_key, ClassicalValue::Bool(value != 0)); } } else { - debug!("No mapping for measurement index {global_idx}"); + // An outcome with no queued measurement to bind to + // means the driver and engine disagree about how + // many measurements this batch contained -- binding + // nothing silently corrupts every later index. + return Err(PecosError::Input(format!( + "measurement outcome {global_idx} has no queued measurement \ + (engine queued {}, driver sent {num_outcomes} in this batch)", + self.measurement_state.mappings.len() + ))); } } @@ -2732,6 +2740,40 @@ mod tests { ); } + /// Excess measurement outcomes (driver/engine batch-count disagreement) + /// must raise instead of silently dropping -- an unbound outcome shifts + /// every later measurement index. + #[test] + fn test_excess_measurement_outcomes_error() { + use pecos_engines::{ByteMessageBuilder, ControlEngine, EngineStage}; + + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../pecos/tests/test_data/hugr/single_hadamard.hugr" + ); + let mut engine = HugrEngine::from_file(path).expect("Failed to load HUGR"); + let stage = engine.start(()).expect("start"); + let EngineStage::NeedsProcessing(msg) = stage else { + panic!("expected a processing stage"); + }; + let n_meas = msg + .quantum_ops() + .expect("parse ops") + .iter() + .filter(|g| matches!(g.gate_type, GateType::MZ)) + .count(); + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_outcomes(); + builder.add_outcomes(&vec![0usize; n_meas + 3]); + let Err(err) = engine.continue_processing(builder.build()) else { + panic!("excess outcomes must error"); + }; + assert!( + err.to_string().contains("has no queued measurement"), + "unexpected error: {err}" + ); + } + #[test] fn test_completion_audit_reports_unexecuted_container_ops() { // The reachability audit must catch a container the engine claims From 78befd9975250dff7f66b4173bab3a2627241ee3 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 14:20:56 -0600 Subject: [PATCH 362/388] Capture the entrypoint CFG's classical return values and surface them for pure-classical programs (results key 'return' / 'return_N'), un-xfailing the two return-capture tests --- crates/pecos-hugr/src/engine.rs | 34 +++++++++++++++++++ .../pecos-hugr/src/engine/control_flow/cfg.rs | 24 +++++++++++++ .../test_comprehensive_guppy_features.py | 24 ++++--------- .../guppy/test_extended_guppy_features.py | 21 +++++------- 4 files changed, 72 insertions(+), 31 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index edc02c157..915e2945b 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -93,6 +93,11 @@ pub struct HugrEngine { /// In-flight higher-order array scans, keyed by scan node. pub(crate) active_scans: BTreeMap, + /// The entrypoint's classical return values, captured when its CFG + /// completes. Pure-classical programs (no measurements, no `result()` + /// calls) surface these as their shot results. + pub(crate) return_values: Vec, + /// Container regions the engine actually activated this shot /// (`DataflowBlocks`, selected Cases, `TailLoop` bodies), with a label for /// diagnostics. Persistent across the shot (unlike the active_* maps), @@ -391,6 +396,7 @@ impl HugrEngine { self.pending_tailloop_control.clear(); self.execution_error = None; self.active_scans.clear(); + self.return_values.clear(); self.executed_containers.clear(); // Clear result capture state @@ -1772,6 +1778,7 @@ impl Default for HugrEngine { work_queue: VecDeque::new(), processed: BTreeSet::new(), active_scans: BTreeMap::new(), + return_values: Vec::new(), executed_containers: BTreeMap::new(), message_builder: ByteMessageBuilder::new(), // Grouped state @@ -1960,6 +1967,33 @@ impl ClassicalEngine for HugrEngine { .data .insert("measurements".to_string(), Data::from_u32_vec(values)); } + + // Pure-classical programs (no measurements either): surface the + // entrypoint's return values -- "return" for a single value, + // "return_{port}" for multiple. + if self.measurement_state.results.is_empty() && !self.return_values.is_empty() { + let scalars: Vec = self + .return_values + .iter() + .filter_map(|value| match value { + ClassicalValue::Bool(b) => Some(Data::Bool(*b)), + ClassicalValue::Int(i) => Some(Data::I64(*i)), + ClassicalValue::UInt(u) => Some(Data::U64(*u)), + ClassicalValue::Float(f) => Some(Data::F64(*f)), + _ => None, + }) + .collect(); + if scalars.len() == 1 { + let mut scalars = scalars; + result + .data + .insert("return".to_string(), scalars.pop().expect("len 1")); + } else { + for (i, data) in scalars.into_iter().enumerate() { + result.data.insert(format!("return_{i}"), data); + } + } + } } // Add captured results from result() calls diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 3ae16c96b..42a12eecd 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -617,6 +617,30 @@ impl HugrEngine { self.processed.insert(cfg_node); self.active_cfgs.remove(&cfg_node); + // The ENTRYPOINT's CFG (module-level FuncDefn that nothing calls or + // scans through) completing means main returned: capture its + // classical return values so pure-classical programs surface them. + let parent_is_entry_func = hugr.get_parent(cfg_node).is_some_and(|fd| { + matches!(hugr.get_optype(fd), OpType::FuncDefn(_)) + && hugr.get_parent(fd) == Some(hugr.module_root()) + && !self.call_targets.values().any(|&target| target == fd) + && !self + .active_scans + .values() + .any(|scan| scan.func_defn_node == fd) + }); + if parent_is_entry_func { + let mut values = Vec::new(); + for port in 0..hugr.num_outputs(cfg_node) { + if let Some(value) = self.wire_state.classical_values.get(&(cfg_node, port)) { + values.push(value.clone()); + } + } + if !values.is_empty() { + self.return_values = values; + } + } + // Check if this CFG is inside a FuncDefn that's being called self.complete_func_call_if_needed(hugr, cfg_node); diff --git a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py index 6bfc580fb..bf5f65e57 100644 --- a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py @@ -276,17 +276,10 @@ def boolean_or_test() -> bool: shots=10, ) - @pytest.mark.xfail( - reason=( - "pure-classical return values are not captured in results (program has no measurements; return- " - "value capture is a known gap)" - ), - strict=True, - ) def test_classical_arithmetic(self, pipeline_tester: GuppyPipelineTest) -> None: - """Test basic arithmetic operations.""" + """Pure-classical programs surface the entrypoint's return value + under the "return" key (no measurements, no result() calls).""" - # NOTE: This may fail on current pipelines due to limited classical support @guppy def arithmetic_test() -> int: # Simple arithmetic that doesn't depend on quantum measurements @@ -294,16 +287,11 @@ def arithmetic_test() -> int: b = 3 return a + b - results = pipeline_tester.test_function_on_both_pipelines( - arithmetic_test, - shots=5, - ) + from pecos import Guppy, sim + from pecos_rslib import state_vector - # Document current limitations - if not results.get("hugr_llvm", {}).get("success"): - pass - if not results.get("phir", {}).get("success"): - pass + results = sim(Guppy(arithmetic_test)).qubits(1).quantum(state_vector()).seed(1).run(5).to_dict() + assert list(results["return"]) == [8] * 5, f"keys: {sorted(results)}" # ============================================================================ diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index 24ea63b18..e99d9ff7e 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -344,15 +344,8 @@ def tuple_test() -> tuple[bool, bool]: correlated = sum(1 for (a, b) in measurements if a == b) assert correlated > 80, f"Tuple ops failed, correlation={correlated}/100" - @pytest.mark.xfail( - reason=( - "pure-classical return values are not captured in results (program has no measurements; return- " - "value capture is a known gap)" - ), - strict=True, - ) def test_boolean_expressions(self, tester: ExtendedGuppyTester) -> None: - """Test complex boolean expressions.""" + """Pure-classical boolean returns surface under the "return" key.""" @guppy def boolean_expr_test() -> bool: @@ -363,11 +356,13 @@ def boolean_expr_test() -> bool: # Complex boolean expression return (a and b) or (not b and c) or (a and not c) - result = tester.test_function(boolean_expr_test, shots=10) - if result["success"]: - results = result["result"]["results"] - # (True and False) or (True and True) or (True and False) = True - assert all(r for r in results), f"Boolean expression failed: {results}" + from pecos import Guppy, sim + from pecos_rslib import state_vector + + results = sim(Guppy(boolean_expr_test)).qubits(1).quantum(state_vector()).seed(1).run(10).to_dict() + # (True and False) or (True and True) or (True and False) = True + values = [bool(v) for v in results["return"]] + assert values == [True] * 10, f"Boolean expression failed: {values}" # ============================================================================ From b3e6c0536a42aed22c4965df1a9c691144107c31 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 14:39:13 -0600 Subject: [PATCH 363/388] Rewrite the seven guppylang-0.21-dead test programs (discard for linearity, array comprehensions with literal sizes, measure_array instead of move-out-of-subscript) -- the guppy tree now has zero xfails --- .../test_comprehensive_guppy_features.py | 34 +++--- .../guppy/test_extended_guppy_features.py | 106 ++++++------------ .../tests/guppy/test_semantic_sweep.py | 4 +- 3 files changed, 50 insertions(+), 94 deletions(-) diff --git a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py index bf5f65e57..b21aab056 100644 --- a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py @@ -412,17 +412,9 @@ def qft_2qubit() -> tuple[bool, bool]: # The test passes if we get results without errors assert len(measurements) == 100 - @pytest.mark.xfail( - reason=( - "test program does not compile under guppylang 0.21 (PlaceNotUsedError: linearity violation); " - "needs a rewrite -- it never actually ran, the old success-gate silently passed the compile " - "failure" - ), - strict=True, - ) def test_deutsch_josza_algorithm(self, pipeline_tester: GuppyPipelineTest) -> None: """Test Deutsch-Josza algorithm for 2-bit function.""" - from guppylang.std.quantum import cx, h, measure, qubit, x + from guppylang.std.quantum import cx, discard, h, measure, qubit, x @guppy def deutsch_josza_constant() -> tuple[bool, bool]: @@ -447,8 +439,10 @@ def deutsch_josza_constant() -> tuple[bool, bool]: h(q0) h(q1) - # Measure input qubits (ancilla can be discarded) - return measure(q0), measure(q1) + # Measure input qubits; the ancilla is discarded (linearity) + r = measure(q0), measure(q1) + discard(anc) + return r @guppy def deutsch_josza_balanced() -> tuple[bool, bool]: @@ -475,8 +469,10 @@ def deutsch_josza_balanced() -> tuple[bool, bool]: h(q0) h(q1) - # Measure input qubits - return measure(q0), measure(q1) + # Measure input qubits; the ancilla is discarded (linearity) + r = measure(q0), measure(q1) + discard(anc) + return r # Test constant function results_const = pipeline_tester.test_function_on_both_pipelines( @@ -485,10 +481,8 @@ def deutsch_josza_balanced() -> tuple[bool, bool]: ) if results_const.get("hugr_llvm", {}).get("success"): measurements = results_const["hugr_llvm"]["result"]["results"] - # Decode integer-encoded results - decoded_measurements = decode_integer_results(measurements, 2) - # For constant function, should measure |00⟩ with high probability - zeros = sum(1 for (a, b) in decoded_measurements if not a and not b) + # Rows are already per-shot (q0, q1) bit tuples + zeros = sum(1 for (a, b) in measurements if not a and not b) assert zeros > 95, f"Constant oracle should give |00⟩, got {zeros}/100" # Test balanced function @@ -498,10 +492,8 @@ def deutsch_josza_balanced() -> tuple[bool, bool]: ) if results_bal.get("hugr_llvm", {}).get("success"): measurements = results_bal["hugr_llvm"]["result"]["results"] - # Decode integer-encoded results - decoded_measurements = decode_integer_results(measurements, 2) - # For balanced function, should never measure |00⟩ - zeros = sum(1 for (a, b) in decoded_measurements if not a and not b) + # Rows are already per-shot (q0, q1) bit tuples + zeros = sum(1 for (a, b) in measurements if not a and not b) assert zeros < 5, f"Balanced oracle should not give |00⟩, got {zeros}/100" def test_grover_search(self, pipeline_tester: GuppyPipelineTest) -> None: diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index e99d9ff7e..d4099721d 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -194,13 +194,6 @@ def rotation_test() -> tuple[bool, bool]: class TestMultiQubitGates: """Test multi-qubit gate operations.""" - @pytest.mark.xfail( - reason=( - "test program does not compile under guppylang 0.21 (PlaceNotUsedError); needs a rewrite -- " - "never ran behind the old success-gate" - ), - strict=True, - ) def test_controlled_y_and_z(self, tester: ExtendedGuppyTester) -> None: """Test CY and CZ gates.""" # Note: state_vector() engine supports non-Clifford operations like CY @@ -213,6 +206,7 @@ def cy_cz_test() -> tuple[bool, bool, bool]: x(q1) # Set control to |1⟩ cy(q1, q2) # Apply Y to q2 since control is |1⟩ r1 = measure(q2) # Should be |1⟩ + discard(q1) # Control no longer needed (linearity) # Test CZ gate q3 = qubit() @@ -241,32 +235,24 @@ def cy_cz_test() -> tuple[bool, bool, bool]: class TestQubitArrays: """Test qubit array operations and indexing.""" - @pytest.mark.xfail( - reason=( - "test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- " - "never ran behind the old success-gate" - ), - strict=True, - ) def test_qubit_array_creation_and_access(self, tester: ExtendedGuppyTester) -> None: """Test creating and accessing qubit arrays.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array @guppy def array_test() -> tuple[bool, bool, bool, bool]: # Create array of 4 qubits - qubits = qubit_array(4) + qubits = array(qubit() for _ in range(4)) # Apply different gates to different qubits x(qubits[1]) # Flip second qubit x(qubits[3]) # Flip fourth qubit - # Measure all - return ( - measure(qubits[0]), - measure(qubits[1]), - measure(qubits[2]), - measure(qubits[3]), - ) + # Measure all (elements cannot move out of a subscript; + # measure the array and index the copyable bits) + bits = measure_array(qubits) + return bits[0], bits[1], bits[2], bits[3] result = tester.test_function(array_test, shots=100) if result["success"]: @@ -275,38 +261,33 @@ def array_test() -> tuple[bool, bool, bool, bool]: expected = sum(1 for m in measurements if m == (False, True, False, True)) assert expected > 95, f"Array indexing failed, got {expected}/100 correct" - @pytest.mark.xfail( - reason=( - "test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- " - "never ran behind the old success-gate" - ), - strict=True, - ) def test_qubit_array_loops(self, tester: ExtendedGuppyTester) -> None: """Test looping over qubit arrays.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array @guppy def array_loop_test() -> int: - n = 5 - qubits = qubit_array(n) + qubits = array(qubit() for _ in range(5)) # Apply H to all qubits - for i in range(n): + for i in range(5): h(qubits[i]) # Count how many measure to |1⟩ + bits = measure_array(qubits) count = 0 - for i in range(n): - if measure(qubits[i]): + for i in range(5): + if bits[i]: count += 1 - return count result = tester.test_function(array_loop_test, shots=100) if result["success"]: - # With 5 qubits in superposition, expect average ~2.5 - counts = result["result"]["results"] - avg = sum(counts) / len(counts) + # Each shot yields the 5 measured bits; with 5 qubits in + # superposition the mean ones-per-shot is ~2.5 + rows = result["result"]["results"] + avg = sum(sum(row) for row in rows) / len(rows) assert 1.5 < avg < 3.5, f"Superposition statistics off, avg={avg}" @@ -476,26 +457,22 @@ def early_return_test() -> int: class TestQuantumAlgorithms: """Test quantum algorithms and protocols.""" - @pytest.mark.xfail( - reason=( - "test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- " - "never ran behind the old success-gate" - ), - strict=True, - ) def test_ghz_state_creation(self, tester: ExtendedGuppyTester) -> None: """Test GHZ state creation for multiple qubits.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array @guppy def create_ghz3() -> tuple[bool, bool, bool]: # Create 3-qubit GHZ state: (|000⟩ + |111⟩)/√2 - qubits = qubit_array(3) + qubits = array(qubit() for _ in range(3)) h(qubits[0]) cx(qubits[0], qubits[1]) cx(qubits[1], qubits[2]) - return measure(qubits[0]), measure(qubits[1]), measure(qubits[2]) + bits = measure_array(qubits) + return bits[0], bits[1], bits[2] result = tester.test_function(create_ghz3, shots=100) if result["success"]: @@ -506,13 +483,6 @@ def create_ghz3() -> tuple[bool, bool, bool]: total_valid = all_zeros + all_ones assert total_valid > 95, f"GHZ state invalid, got {total_valid}/100 valid states" - @pytest.mark.xfail( - reason=( - "test program does not compile under guppylang 0.21 (PlaceNotUsedError); needs a rewrite -- " - "never ran behind the old success-gate" - ), - strict=True, - ) def test_quantum_phase_kickback(self, tester: ExtendedGuppyTester) -> None: """Test phase kickback principle.""" @@ -532,7 +502,9 @@ def phase_kickback_test() -> bool: # Measure in X basis (apply H before measuring) h(control) - return measure(control) + r = measure(control) + discard(target) # linearity: target is no longer needed + return r result = tester.test_function(phase_kickback_test, shots=100) if result["success"]: @@ -725,38 +697,32 @@ def empty_circuit() -> bool: class TestPerformance: """Test performance with larger circuits.""" - @pytest.mark.xfail( - reason=( - "test program does not compile under guppylang 0.21 (VarNotDefinedError); needs a rewrite -- " - "never ran behind the old success-gate" - ), - strict=True, - ) def test_many_qubits(self, tester: ExtendedGuppyTester) -> None: """Test handling many qubits.""" + from guppylang.std.builtins import array + from guppylang.std.quantum import measure_array @guppy def many_qubits_test() -> int: # Create 10 qubits - n = 10 - qubits = qubit_array(n) + qubits = array(qubit() for _ in range(10)) # Apply H to all - for i in range(n): + for i in range(10): h(qubits[i]) # Count ones + bits = measure_array(qubits) count = 0 - for i in range(n): - if measure(qubits[i]): + for i in range(10): + if bits[i]: count += 1 - return count result = tester.test_function(many_qubits_test, shots=50) if result["success"]: - counts = result["result"]["results"] - avg = sum(counts) / len(counts) + rows = result["result"]["results"] + avg = sum(sum(row) for row in rows) / len(rows) assert 3 < avg < 7, f"Many qubit statistics off, avg={avg}" @pytest.mark.skip( diff --git a/python/quantum-pecos/tests/guppy/test_semantic_sweep.py b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py index 569fea924..0957de8a8 100644 --- a/python/quantum-pecos/tests/guppy/test_semantic_sweep.py +++ b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py @@ -327,7 +327,5 @@ def labeled_array() -> None: x(qs[1]) result("my_result_Report", measure_array(qs)) - results = ( - sim(Guppy(labeled_array)).qubits(3).quantum(state_vector()).seed(7).run(2).to_dict() - ) + results = sim(Guppy(labeled_array)).qubits(3).quantum(state_vector()).seed(7).run(2).to_dict() assert results["my_result_Report"] == [[1, 1], [1, 1]], f"keys: {sorted(results)}" From 77ee53ef660ea0a6116d337edd6782e2a0e397d4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 14:57:04 -0600 Subject: [PATCH 364/388] Add generative program fuzzing: 20 seeded random classical guppy programs checked against a plain-Python reference evaluator via the X-anchor trick --- .../tests/guppy/test_program_fuzz.py | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 python/quantum-pecos/tests/guppy/test_program_fuzz.py diff --git a/python/quantum-pecos/tests/guppy/test_program_fuzz.py b/python/quantum-pecos/tests/guppy/test_program_fuzz.py new file mode 100644 index 000000000..503bb0206 --- /dev/null +++ b/python/quantum-pecos/tests/guppy/test_program_fuzz.py @@ -0,0 +1,175 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Generative program fuzzing for the HUGR engine. + +Each seed generates a random classical program that is simultaneously valid +Python and valid guppy: plain Python `exec` computes the reference value, +and the guppy version gates an X on a fresh qubit iff the engine computes +the same value. Every divergence -- a wrong arithmetic result, a +mis-executed loop, a dropped branch -- fails loudly as a 0 measurement. + +Generated programs stay in Euclidean-safe territory (every reduction uses a +positive literal modulus, divisions use positive literal divisors) so +Python's floor semantics coincide exactly with the HUGR spec's Euclidean +semantics, and magnitudes stay far below 64-bit wrap. +""" + +import importlib.util +import random +import sys +from pathlib import Path + +import pytest +from pecos import Guppy, sim +from pecos_rslib import state_vector + +MOD = 9973 +SEEDS = range(20) + + +class _BodyGenerator: + """Generate a random classical statement body over int variables.""" + + def __init__(self, rng: random.Random) -> None: + self.rng = rng + self.lines: list[str] = [] + self.vars = ["v0", "v1", "v2"] + self.loop_depth = 0 + + def operand(self) -> str: + if self.rng.random() < 0.3: + return str(self.rng.randint(0, 99)) + return self.rng.choice(self.vars) + + def emit(self, line: str, indent: int) -> None: + self.lines.append(" " * indent + line) + + def assign(self, indent: int) -> None: + target = self.rng.choice(self.vars) + kind = self.rng.random() + a, b = self.operand(), self.operand() + if kind < 0.45: + op = self.rng.choice(["+", "-", "*"]) + expr = f"({a} {op} {b}) % {MOD}" + elif kind < 0.7: + op = self.rng.choice(["//", "%"]) + divisor = self.rng.randint(2, 9) + expr = f"({a} {op} {divisor}) % {MOD}" + else: + op = self.rng.choice(["<<", ">>"]) + shift = self.rng.randint(0, 6) + expr = f"(({a} % 64) {op} {shift}) % {MOD}" + self.emit(f"{target} = {expr}", indent) + + def branch(self, indent: int) -> None: + a, b = self.rng.choice(self.vars), self.rng.choice(self.vars) + cmp_op = self.rng.choice(["<", "<=", ">", ">=", "==", "!="]) + self.emit(f"if {a} {cmp_op} {b}:", indent) + self.assign(indent + 1) + if self.rng.random() < 0.5: + self.emit("else:", indent) + self.assign(indent + 1) + + def loop(self, indent: int) -> None: + self.loop_depth += 1 + var = f"i{self.loop_depth}" + bound = self.rng.randint(0, 4) + self.emit(f"for {var} in range({bound}):", indent) + target = self.rng.choice(self.vars) + self.emit(f"{target} = ({target} + {var} + 1) % {MOD}", indent + 1) + if self.loop_depth < 2 and self.rng.random() < 0.4: + self.loop(indent + 1) + elif self.rng.random() < 0.4: + self.assign(indent + 1) + + def while_loop(self, indent: int) -> None: + count = self.rng.randint(1, 5) + target = self.rng.choice(self.vars) + self.emit(f"w = {count}", indent) + self.emit("while w > 0:", indent) + self.emit(f"{target} = ({target} * 3 + w) % {MOD}", indent + 1) + self.emit("w = w - 1", indent + 1) + + def generate(self) -> str: + for i, name in enumerate(self.vars): + self.emit(f"{name} = {self.rng.randint(0, MOD - 1)}", 1) + del i + for _ in range(self.rng.randint(4, 8)): + pick = self.rng.random() + if pick < 0.45: + self.assign(1) + elif pick < 0.65: + self.branch(1) + elif pick < 0.85: + self.loop(1) + else: + self.while_loop(1) + self.emit(f"acc = (v0 + 31 * v1 + 977 * v2) % {MOD}", 1) + return "\n".join(self.lines) + + +def _reference_value(body: str) -> int: + """Execute the generated body as plain Python and return acc.""" + source = "def _ref():\n" + body + "\n return acc\n" + namespace: dict = {} + exec(source, namespace) # noqa: S102 -- fuzz reference evaluation of generated code + return namespace["_ref"]() + + +def _load_guppy_module(tmp_path: Path, seed: int, source: str): + path = tmp_path / f"fuzz_prog_{seed}.py" + path.write_text(source) + spec = importlib.util.spec_from_file_location(f"fuzz_prog_{seed}", path) + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + try: + spec.loader.exec_module(module) + finally: + sys.modules.pop(spec.name, None) + return module + + +@pytest.mark.parametrize("seed", SEEDS) +def test_fuzzed_program_matches_python_reference(seed: int, tmp_path: Path) -> None: + rng = random.Random(seed) + body = _BodyGenerator(rng).generate() + expected = _reference_value(body) + + source = ( + "from guppylang import guppy\n" + "from guppylang.std.quantum import measure, qubit, x\n" + "\n" + "\n" + "@guppy\n" + "def fuzz_prog() -> bool:\n" + " q = qubit()\n" + f"{body}\n" + f" if acc == {expected}:\n" + " x(q)\n" + " return measure(q)\n" + ) + module = _load_guppy_module(tmp_path, seed, source) + + results = ( + sim(Guppy(module.fuzz_prog)) + .qubits(2) + .quantum(state_vector()) + .seed(7) + .run(2) + .to_dict() + ) + raw = results["measurements"] + values = [m[-1] if isinstance(m, list) else m for m in raw] + assert values == [1, 1], f"seed {seed}: engine diverged from reference\n{source}" From 89cda39c8a8c88b95403ce683312bee06181b046 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 16:06:58 -0600 Subject: [PATCH 365/388] Fold in Codex round-8: entrypoint-based FuncDefn gating (dead functions no longer execute or clobber returns), symmetric Call/scan frame ownership with cross wake-ups, unsigned width conversions reinterpret canonical storage at the source width (iwiden_u/inarrow_u/imin_u/imax_u), and error Sums carry the spec's error payload --- crates/pecos-hugr/src/engine.rs | 155 +++++++++++++++++- crates/pecos-hugr/src/engine/analysis.rs | 20 ++- .../src/engine/control_flow/call.rs | 4 + .../pecos-hugr/src/engine/control_flow/cfg.rs | 22 ++- .../src/engine/control_flow/scan.rs | 14 ++ .../src/engine/handlers/arithmetic.rs | 80 +++++---- .../src/engine/handlers/classical.rs | 14 +- 7 files changed, 261 insertions(+), 48 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 915e2945b..4da444bd4 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -934,12 +934,18 @@ impl HugrEngine { debug!("Processing Call {current_node:?} to FuncDefn {func_defn_node:?}"); - // Check if there's already an active call to this FuncDefn - // If so, queue this call to wait + // Check if there's already an active call OR an in-flight + // scan folding through this FuncDefn -- both own the single + // execution frame, and activating over a scan would reset + // the scanned function's state mid-element. let func_defn_in_use = self .active_calls .values() - .any(|info| info.func_defn_node == func_defn_node); + .any(|info| info.func_defn_node == func_defn_node) + || self + .active_scans + .values() + .any(|scan| scan.func_defn_node == func_defn_node); if func_defn_in_use { // Direct recursion (a call to F from inside F's own @@ -2625,7 +2631,8 @@ mod tests { )]) ); - // idivmod_checked_s by zero: error Sum (tag 0), not a fault + // idivmod_checked_s by zero: error Sum (tag 0) with the opaque + // error payload sum_with_error's error variant carries let got = run( &mut engine, "idivmod_checked_s", @@ -2637,7 +2644,7 @@ mod tests { 0, ClassicalValue::Sum { tag: 0, - values: vec![] + values: vec![ClassicalValue::Tuple(vec![])] } )]) ); @@ -2808,6 +2815,144 @@ mod tests { ); } + /// Round-8 regressions: unsigned width conversions must reinterpret + /// the canonical (sign-extended) storage as a bit pattern at the + /// SOURCE width -- as_uint-style negative rejection deferred a + /// canonical `int<1>` "1" (stored -1) forever, and `inarrow_u`'s signed + /// range test rejected legitimate high-bit unsigned values. + #[test] + fn test_unsigned_width_conversions_handle_canonical_storage() { + use crate::engine::handlers::HandlerOutcome; + use tket::hugr::builder::{Dataflow, DataflowHugr, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let hugr = { + let mut fb = + FunctionBuilder::new("widen", Signature::new(vec![], vec![int_type(6)])).unwrap(); + let bit = fb.add_load_const(Value::from(ConstInt::new_u(0, 1).unwrap())); + let wide = fb.add_load_const(Value::from(ConstInt::new_u(6, 1).unwrap())); + let [widened] = fb + .add_dataflow_op(IntOpDef::iwiden_u.with_two_log_widths(0, 6), [bit]) + .unwrap() + .outputs_arr(); + let [_narrowed] = fb + .add_dataflow_op(IntOpDef::inarrow_u.with_two_log_widths(6, 5), [wide]) + .unwrap() + .outputs_arr(); + let _ = widened; + fb.finish_hugr_with_outputs([widened]).unwrap() + }; + let find = |name: &str| -> Node { + hugr.nodes() + .find(|n| { + hugr.get_optype(*n) + .as_extension_op() + .is_some_and(|op| op.unqualified_id() == name) + }) + .unwrap_or_else(|| panic!("no {name} node")) + }; + let seed = |engine: &mut HugrEngine, node: Node, value: ClassicalValue| { + let (src, sp) = hugr + .single_linked_output(node, IncomingPort::from(0)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), value); + }; + + let mut engine = HugrEngine::default(); + + // iwiden_u int<1> -> int<64>: canonical 1-bit "1" stores as -1 and + // must widen (zero-extend) to 1, not defer or become u64::MAX. + let widen = find("iwiden_u"); + seed(&mut engine, widen, ClassicalValue::Int(-1)); + assert_eq!( + engine.handle_int_op(&hugr, widen, "iwiden_u"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.wire_state.classical_values.get(&(widen, 0)), + Some(&ClassicalValue::Int(1)) + ); + + // inarrow_u int<64> -> int<32> of 0xFFFF_FFFF: fits as unsigned, + // and the narrowed value stores canonically (sign-extended) as -1. + let narrow = find("inarrow_u"); + seed(&mut engine, narrow, ClassicalValue::Int(0xFFFF_FFFF)); + assert_eq!( + engine.handle_int_op(&hugr, narrow, "inarrow_u"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.wire_state.classical_values.get(&(narrow, 0)), + Some(&ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::Int(-1)] + }) + ); + + // inarrow_u of a value ABOVE 2^32 must produce the error variant + // (with its opaque payload), not a fault and not a fit. + seed(&mut engine, narrow, ClassicalValue::Int(0x1_0000_0000)); + assert_eq!( + engine.handle_int_op(&hugr, narrow, "inarrow_u"), + HandlerOutcome::Processed + ); + assert_eq!( + engine.wire_state.classical_values.get(&(narrow, 0)), + Some(&ClassicalValue::Sum { + tag: 0, + values: vec![ClassicalValue::Tuple(vec![])] + }) + ); + } + + /// A DEAD (uncalled, non-entrypoint) module-level function must not + /// execute: its body used to be ungated (only CALLED `FuncDefns` were + /// gated), so it raced the real program and could clobber the + /// entrypoint's captured return values. + #[test] + fn test_dead_function_body_does_not_execute() { + use tket::hugr::builder::{Container, Dataflow, DataflowSubContainer, ModuleBuilder}; + use tket::hugr::hugr::hugrmut::HugrMut; + use tket::hugr::ops::Value; + use tket::hugr::ops::handle::NodeHandle; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let (hugr, dead_load) = { + let mut module = ModuleBuilder::new(); + let mut main_fb = module + .define_function("main", Signature::new(vec![], vec![int_type(6)])) + .unwrap(); + let seven = main_fb.add_load_const(Value::from(ConstInt::new_u(6, 7).unwrap())); + let main_id = main_fb.finish_with_outputs([seven]).unwrap(); + let mut dead_fb = module + .define_function("dead", Signature::new(vec![], vec![int_type(6)])) + .unwrap(); + let nine = dead_fb.add_load_const(Value::from(ConstInt::new_u(6, 9).unwrap())); + let dead_load = nine.node(); + dead_fb.finish_with_outputs([nine]).unwrap(); + let mut hugr = module.hugr().clone(); + hugr.set_entrypoint(main_id.node()); + (hugr, dead_load) + }; + + let engine = HugrEngine::from_hugr(hugr); + assert!( + engine.nodes_inside_func_defns.contains(&dead_load), + "dead function body must be gated" + ); + assert!( + !engine.work_queue.contains(&dead_load), + "dead function body must not be queued" + ); + } + #[test] fn test_completion_audit_reports_unexecuted_container_ops() { // The reachability audit must catch a container the engine claims diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index 21386f9b5..cfb37fa3a 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -504,11 +504,25 @@ pub fn find_nodes_inside_func_defns( ) -> BTreeSet { let mut inside_func_defns = BTreeSet::new(); - // Find which FuncDefns are called (not the entrypoint) - let called_func_defns: BTreeSet = call_targets.values().copied().collect(); + // Gate EVERY FuncDefn body except the entrypoint's: called functions + // activate through Call/scan frames, and a DEAD (uncalled) function + // must not execute at top level at all -- it would race the real + // program and clobber its return values. Guppy packages carry the + // entrypoint on the HUGR; fall back to the old called-only gating for + // HUGRs whose entrypoint is not a FuncDefn (e.g. module-rooted test + // graphs). + let entrypoint = hugr.entrypoint(); + if func_defns.contains_key(&entrypoint) { + for &func_defn_node in func_defns.keys() { + if func_defn_node != entrypoint { + collect_descendants(hugr, func_defn_node, &mut inside_func_defns); + } + } + return inside_func_defns; + } + let called_func_defns: BTreeSet = call_targets.values().copied().collect(); for &func_defn_node in func_defns.keys() { - // Only defer nodes inside FuncDefns that are called (not the entrypoint) if called_func_defns.contains(&func_defn_node) { collect_descendants(hugr, func_defn_node, &mut inside_func_defns); } diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index 88577b612..c0f5aa224 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -182,6 +182,10 @@ impl HugrEngine { // never completed.) self.queue_ready_successors(hugr, call_node); + // A scan parked on this frame retries through the pending + // mechanism -- wake the parked set now that the frame freed. + self.retry_pending_bool_reads(); + // Check if there are pending calls to this FuncDefn if let Some(pending) = self.pending_func_calls.get_mut(&func_defn_node) && let Some(next_call) = pending.pop() diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 42a12eecd..58927e31e 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -617,17 +617,23 @@ impl HugrEngine { self.processed.insert(cfg_node); self.active_cfgs.remove(&cfg_node); - // The ENTRYPOINT's CFG (module-level FuncDefn that nothing calls or - // scans through) completing means main returned: capture its + // The ENTRYPOINT's CFG completing means main returned: capture its // classical return values so pure-classical programs surface them. + // The entrypoint is taken from the HUGR itself when it names a + // FuncDefn; the not-called-not-scanned fallback covers graphs + // whose entrypoint is the module root. let parent_is_entry_func = hugr.get_parent(cfg_node).is_some_and(|fd| { matches!(hugr.get_optype(fd), OpType::FuncDefn(_)) - && hugr.get_parent(fd) == Some(hugr.module_root()) - && !self.call_targets.values().any(|&target| target == fd) - && !self - .active_scans - .values() - .any(|scan| scan.func_defn_node == fd) + && if matches!(hugr.get_optype(hugr.entrypoint()), OpType::FuncDefn(_)) { + fd == hugr.entrypoint() + } else { + hugr.get_parent(fd) == Some(hugr.module_root()) + && !self.call_targets.values().any(|&target| target == fd) + && !self + .active_scans + .values() + .any(|scan| scan.func_defn_node == fd) + } }); if parent_is_entry_func { let mut values = Vec::new(); diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs index 2bc84ef18..5374d8762 100644 --- a/crates/pecos-hugr/src/engine/control_flow/scan.rs +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -328,6 +328,20 @@ impl HugrEngine { self.try_resolve_pending_tailloops(); self.queue_ready_successors(hugr, scan_node); self.retry_pending_bool_reads(); + + // The frame is free now: wake any Call that parked waiting for it + // (mirrors complete_func_call_if_needed). + if let Some(pending) = self.pending_func_calls.get_mut(&state.func_defn_node) + && let Some(next_call) = pending.pop() + { + debug!( + "FuncDefn {:?} free after scan: starting pending Call {next_call:?}", + state.func_defn_node + ); + if !self.work_queue.contains(&next_call) { + self.work_queue.push_front(next_call); + } + } } /// Store the scan node's outputs: the mapped array on port 0 and the diff --git a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs index 05893bed4..62aa869b0 100644 --- a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs +++ b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs @@ -182,6 +182,16 @@ impl HugrEngine { let canon = |v: i64| crate::engine::handlers::classical::canonicalize_width(v, log_width); let a = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()); let b = self.get_input_value(hugr, node, 1).and_then(|v| v.as_int()); + // Unsigned reads must reinterpret the canonical (sign-extended) + // bit pattern at the op's width -- as_uint rejects negatives, so a + // canonical int<1> "1" (stored -1) would defer forever. + #[allow(clippy::cast_sign_loss)] + let read_unsigned = |engine: &Self, port: usize| -> Option { + engine + .get_input_value(hugr, node, port) + .and_then(|v| v.as_int()) + .map(|v| (v as u64) & mask) + }; #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] let result: Option = match op_name { @@ -235,36 +245,27 @@ impl HugrEngine { "imax_s" | "imax" => a.zip(b).map(|(x, y)| x.max(y)), #[allow(clippy::cast_possible_wrap)] "imin_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| x.min(y) as i64) + let au = read_unsigned(self, 0); + let bu = read_unsigned(self, 1); + au.zip(bu).map(|(x, y)| canon(x.min(y) as i64)) } #[allow(clippy::cast_possible_wrap)] "imax_u" => { - let au = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()); - let bu = self - .get_input_value(hugr, node, 1) - .and_then(|v| v.as_uint()); - au.zip(bu).map(|(x, y)| x.max(y) as i64) + let au = read_unsigned(self, 0); + let bu = read_unsigned(self, 1); + au.zip(bu).map(|(x, y)| canon(x.max(y) as i64)) } // Width widening. Signed values are stored canonically // sign-extended, so widen_s is the identity; widen_u must - // ZERO-extend from the source width (the first BoundedNat arg), - // e.g. a canonical 1-bit "1" is stored as -1 and widens to 1. + // ZERO-extend from the source width (the first BoundedNat arg, + // which is what `mask` derives from), e.g. a canonical 1-bit + // "1" is stored as -1 and widens to 1. The zero-extended value + // is already canonical at the (wider) destination. #[allow(clippy::match_same_arms)] "iwiden_s" | "widen_s" => a, #[allow(clippy::cast_possible_wrap)] - "iwiden_u" | "widen_u" => self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()) - .map(|x| (x & mask) as i64), + "iwiden_u" | "widen_u" => read_unsigned(self, 0).map(|x| x as i64), // Width narrowing returns sum_with_error(int): handled below // (multi-variant output, not a plain i64). @@ -320,33 +321,56 @@ impl HugrEngine { .nth(1) }) .unwrap_or(6); + // Source log-width is the FIRST type arg: the unsigned range test + // must reinterpret the canonical (sign-extended) storage as the + // SOURCE-width bit pattern, or a high-bit unsigned value (stored + // negative) would spuriously fail to fit. + let source_log_width = hugr + .get_optype(node) + .as_extension_op() + .and_then(|ext| { + ext.args().iter().find_map(|arg| match arg { + tket::hugr::types::TypeArg::BoundedNat(n) => u8::try_from(*n).ok(), + _ => None, + }) + }) + .unwrap_or(6); let bits = 1u32 << target_log_width.min(6); let Some(v) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) else { debug!("arithmetic.int.{op_name} at {node:?}: input not ready, deferring"); return HandlerOutcome::Defer; }; - let fits = if signed { - if bits >= 64 { + #[allow(clippy::cast_sign_loss, clippy::cast_possible_wrap)] + let (fits, narrowed) = if signed { + let fits = if bits >= 64 { true } else { let min = -(1i64 << (bits - 1)); let max = (1i64 << (bits - 1)) - 1; v >= min && v <= max - } - } else if bits >= 64 { - v >= 0 + }; + (fits, v) } else { - v >= 0 && v < (1i64 << bits) + let pattern = + (v as u64) & crate::engine::handlers::classical::width_mask(source_log_width); + let fits = bits >= 64 || pattern < (1u64 << bits); + // Store canonically at the TARGET width (sign-extended). + #[allow(clippy::cast_possible_truncation)] + let narrowed = crate::engine::handlers::classical::canonicalize_width( + pattern as i64, + target_log_width.min(6) as u8, + ); + (fits, narrowed) }; let result = if fits { ClassicalValue::Sum { tag: 1, - values: vec![ClassicalValue::Int(v)], + values: vec![ClassicalValue::Int(narrowed)], } } else { ClassicalValue::Sum { tag: 0, - values: vec![], + values: vec![ClassicalValue::Tuple(vec![])], } }; debug!("arithmetic.int.{op_name}: {result:?}"); diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index cffb8b2df..eea978804 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -248,8 +248,11 @@ impl HugrEngine { ])], }, None => ClassicalValue::Sum { + // The error variant of sum_with_error carries an + // error payload; an opaque token keeps the case + // Input port arity correct for propagation. tag: 0, - values: vec![], + values: vec![ClassicalValue::Tuple(vec![])], }, }; return ClassicalOutcome::Outputs(vec![(0, value)]); @@ -366,8 +369,9 @@ impl HugrEngine { values: vec![ClassicalValue::Int(q)], }, None => ClassicalValue::Sum { + // sum_with_error error variants carry a payload tag: 0, - values: vec![], + values: vec![ClassicalValue::Tuple(vec![])], }, } } @@ -386,8 +390,9 @@ impl HugrEngine { values: vec![ClassicalValue::Int(r)], }, None => ClassicalValue::Sum { + // sum_with_error error variants carry a payload tag: 0, - values: vec![], + values: vec![ClassicalValue::Tuple(vec![])], }, } } @@ -528,8 +533,9 @@ impl HugrEngine { } } else { ClassicalValue::Sum { + // sum_with_error error variants carry a payload tag: 0, - values: vec![], + values: vec![ClassicalValue::Tuple(vec![])], } } } From c1cf383f4b84ac722793c4a7b651e3ea4fddfa43 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 19:59:17 -0600 Subject: [PATCH 366/388] Panel-2 batch 1: out-of-range Conditional/TailLoop tags poison, TailLoop control resolution gates on body completion, nodes_inside_cases rebuilds on reset, poison surfaces at loop entry, bare DFG containers fault loudly, pending_conditionals is a set, pending_func_calls is a deduped FIFO, dead Tag audit exemption removed --- crates/pecos-hugr/src/engine.rs | 84 +++++++++++++++---- .../src/engine/control_flow/call.rs | 2 +- .../src/engine/control_flow/conditional.rs | 15 ++-- .../src/engine/control_flow/scan.rs | 2 +- .../src/engine/control_flow/tailloop.rs | 9 +- 5 files changed, 85 insertions(+), 27 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 4da444bd4..9be817773 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -123,7 +123,7 @@ pub struct HugrEngine { /// Pending conditionals waiting for measurement results. /// Maps the Conditional node to the qubit ID whose measurement determines the branch. - pub(crate) pending_conditionals: BTreeMap, + pub(crate) pending_conditionals: BTreeSet, /// Pending bool.read nodes waiting for measurement results. /// These are re-added to the work queue when measurement results arrive. @@ -171,7 +171,7 @@ pub struct HugrEngine { /// Pending Calls waiting for a `FuncDefn` to be free. /// Maps `FuncDefn` node -> queue of Call nodes waiting. - pub(crate) pending_func_calls: BTreeMap>, + pub(crate) pending_func_calls: BTreeMap>, // === TailLoop Support === /// `TailLoop` nodes extracted from the HUGR. @@ -405,6 +405,7 @@ impl HugrEngine { // Re-initialize nodes_inside_* from their respective control structures // (in case we need to re-process after a reset) if let Some(hugr) = &self.hugr { + self.nodes_inside_cases = find_nodes_inside_cases(hugr, &self.conditionals); self.nodes_inside_cfg_blocks = find_nodes_inside_cfg_blocks(hugr, &self.cfgs); self.nodes_inside_func_defns = find_nodes_inside_func_defns(hugr, &self.func_defns, &self.call_targets); @@ -572,9 +573,20 @@ impl HugrEngine { self.pending_tailloop_control.len() ); - // Collect TailLoops that can now be resolved + // Collect TailLoops that can now be resolved. A loop whose body is + // still mid-iteration must NOT resolve here: a stale or early + // control value would re-activate (or complete) the loop while + // in-flight body ops still run, silently corrupting the iteration. + // Only body completion legitimately re-arms resolution. let mut to_resolve = Vec::new(); for &tailloop_node in &self.pending_tailloop_control { + if self + .active_tailloops + .get(&tailloop_node) + .is_some_and(|info| info.body_active) + { + continue; + } if let Some(tag) = self.try_resolve_tailloop_control(&hugr, tailloop_node) { to_resolve.push((tailloop_node, tag)); } @@ -584,7 +596,12 @@ impl HugrEngine { for (tailloop_node, tag) in to_resolve { self.pending_tailloop_control.remove(&tailloop_node); - if tag == 0 { + if tag > 1 { + // Two variants only (0=continue, 1=break); see the loop arm. + self.execution_error = Some(format!( + "TailLoop {tailloop_node:?}: control tag {tag} out of range" + )); + } else if tag == 0 { // CONTINUE_TAG - start next iteration debug!("Pending TailLoop {tailloop_node:?}: CONTINUE, starting next iteration"); self.continue_tailloop_iteration(&hugr, tailloop_node); @@ -637,6 +654,13 @@ impl HugrEngine { /// - `Ok(None)` - No operations to process (empty or complete) #[allow(clippy::too_many_lines, clippy::unnecessary_wraps)] fn process_hugr_impl(&mut self) -> Result, PecosError> { + // A fault raised by a completion cascade (e.g. during measurement + // handling) must surface even when the queue is empty -- check + // BEFORE the early returns below, or the message is discarded and + // at best re-reported as a generic stall. + if let Some(fault) = self.execution_error.take() { + return Err(PecosError::Generic(fault)); + } self.message_builder.reset(); let _ = self.message_builder.for_quantum_operations(); @@ -703,8 +727,7 @@ impl HugrEngine { debug!("Conditional {current_node:?} cannot be resolved yet, deferring"); // We'll re-add this after measurement results come in // For now, mark as pending and don't add back to queue - self.pending_conditionals - .insert(current_node, QubitId::from(0)); // placeholder + self.pending_conditionals.insert(current_node); } continue; } @@ -884,10 +907,21 @@ impl HugrEngine { // --- Control Flow: TailLoop --- if self.tailloops.contains_key(¤t_node) { // Check if already active - if self.active_tailloops.contains_key(¤t_node) { - // Active TailLoop - check if we can resolve control - if let Some(tag) = self.try_resolve_tailloop_control(&hugr, current_node) { - if tag == 0 { + if let Some(active_info) = self.active_tailloops.get(¤t_node) { + // A loop whose body is still mid-iteration must not + // resolve control: a stale/early value would re-activate + // or complete it over in-flight body ops. Body + // completion re-arms resolution. + if active_info.body_active { + debug!("TailLoop {current_node:?}: body mid-iteration, not resolving"); + } else if let Some(tag) = self.try_resolve_tailloop_control(&hugr, current_node) + { + if tag > 1 { + // Two variants only (0=continue, 1=break). + self.execution_error = Some(format!( + "TailLoop {current_node:?}: control tag {tag} out of range" + )); + } else if tag == 0 { // CONTINUE_TAG - start next iteration debug!("TailLoop {current_node:?}: CONTINUE, starting next iteration"); self.continue_tailloop_iteration(&hugr, current_node); @@ -970,10 +1004,12 @@ impl HugrEngine { debug!( "Call {current_node:?}: FuncDefn {func_defn_node:?} is in use, queueing" ); - self.pending_func_calls - .entry(func_defn_node) - .or_default() - .push(current_node); + let queue = self.pending_func_calls.entry(func_defn_node).or_default(); + // A parked Call re-queued by a retry wave would + // otherwise park twice. + if !queue.contains(¤t_node) { + queue.push_back(current_node); + } continue; } @@ -1300,6 +1336,17 @@ impl HugrEngine { } // Fall through to quantum op handling + // A container optype the engine cannot drive must fail loud: + // silently dropping it starves its consumers with a stall report + // naming everything except the cause. + if matches!(hugr.get_optype(current_node), OpType::DFG(_)) { + self.execution_error = Some(format!( + "unsupported container op DFG at {current_node:?}: the engine \ + executes CFG/Conditional/TailLoop/Call containers only" + )); + continue; + } + // --- Quantum Operations (gates, measurements) --- let Some(op) = self.quantum_ops.get(¤t_node).cloned() else { continue; @@ -1410,7 +1457,7 @@ impl HugrEngine { if !self.pending_conditionals.is_empty() { stalled.push(format!( "unresolved Conditionals: {:?}", - self.pending_conditionals.keys().collect::>() + self.pending_conditionals )); } if !self.pending_cfg_branches.is_empty() { @@ -1475,8 +1522,9 @@ impl HugrEngine { for (&container, kind) in &self.executed_containers { for child in hugr.children(container) { let op = hugr.get_optype(child); - let exempt = matches!(op, OpType::Input(_) | OpType::Output(_) | OpType::Const(_)) - || (matches!(op, OpType::Tag(_)) && !self.classical_ops.contains_key(&child)); + // Every Tag classifies as an executable TagSum (linear + // payloads become QubitRef values), so Tags are NOT exempt. + let exempt = matches!(op, OpType::Input(_) | OpType::Output(_) | OpType::Const(_)); if !exempt && !self.processed.contains(&child) { misses.push(format!("{child:?} ({op}) in {kind} {container:?}")); } @@ -1793,7 +1841,7 @@ impl Default for HugrEngine { extension_state: ExtensionState::default(), // Control flow fields (Conditional) conditionals: BTreeMap::new(), - pending_conditionals: BTreeMap::new(), + pending_conditionals: BTreeSet::new(), pending_bool_reads: BTreeSet::new(), nodes_inside_cases: BTreeSet::new(), active_cases: BTreeMap::new(), diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index c0f5aa224..66a75ebb4 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -188,7 +188,7 @@ impl HugrEngine { // Check if there are pending calls to this FuncDefn if let Some(pending) = self.pending_func_calls.get_mut(&func_defn_node) - && let Some(next_call) = pending.pop() + && let Some(next_call) = pending.pop_front() { debug!( "FuncDefn {func_defn_node:?} free: starting next pending Call {next_call:?}" diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index 617c504a8..b7a6bb1c6 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -120,7 +120,7 @@ impl HugrEngine { // path in the meantime -- expanding it again would re-run its case. let mut to_resolve = Vec::new(); let mut already_expanded = Vec::new(); - for &cond_node in self.pending_conditionals.keys() { + for &cond_node in &self.pending_conditionals { if self.processed.contains(&cond_node) { already_expanded.push(cond_node); } else if let Some(branch_index) = @@ -420,12 +420,15 @@ impl HugrEngine { }; if branch_index >= cond_info.cases.len() { - debug!( - "Branch index {} out of range for Conditional {:?} with {} cases", - branch_index, - cond_node, + // An out-of-range tag means an upstream Sum-propagation bug; + // swallowing it here left the Conditional neither processed nor + // pending -- an eventual stall naming innocent starved + // consumers. Poison instead, like the CFG branch sites. + self.execution_error = Some(format!( + "Conditional {cond_node:?}: branch tag {branch_index} out of range \ + ({} cases)", cond_info.cases.len() - ); + )); return Vec::new(); } diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs index 5374d8762..37f6b9971 100644 --- a/crates/pecos-hugr/src/engine/control_flow/scan.rs +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -332,7 +332,7 @@ impl HugrEngine { // The frame is free now: wake any Call that parked waiting for it // (mirrors complete_func_call_if_needed). if let Some(pending) = self.pending_func_calls.get_mut(&state.func_defn_node) - && let Some(next_call) = pending.pop() + && let Some(next_call) = pending.pop_front() { debug!( "FuncDefn {:?} free after scan: starting pending Call {next_call:?}", diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index ac062018d..9f7d58325 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -659,7 +659,14 @@ impl HugrEngine { // Try to resolve control immediately if let Some(tag) = self.try_resolve_tailloop_control(hugr, tailloop_node) { - if tag == 0 { + if tag > 1 { + // The control Sum has exactly two variants (0=continue, + // 1=break); anything else is an upstream propagation + // bug that must not complete cleanly as a break. + self.execution_error = Some(format!( + "TailLoop {tailloop_node:?}: control tag {tag} out of range" + )); + } else if tag == 0 { // CONTINUE self.continue_tailloop_iteration(hugr, tailloop_node); } else { From a8acf5dca3305a1b664e123d298b8bf9322de148 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 20:09:59 -0600 Subject: [PATCH 367/388] Panel-2 batch 2: TryQAlloc emits a real Option Sum carrying the qubit, RandomInt/RandomIntBounded store canonically at int<5> with unsigned bound reads, RandomAdvance faults on backtracking and unboundedly large deltas, result_uint/result_array_uint bit-reinterpret canonical storage, and unresolved symbolic angles fault instead of defaulting to identity --- crates/pecos-hugr/src/engine.rs | 21 ++++-- .../pecos-hugr/src/engine/handlers/qsystem.rs | 68 +++++++++++++------ .../pecos-hugr/src/engine/handlers/quantum.rs | 61 +++++++++-------- .../pecos-hugr/src/engine/handlers/result.rs | 11 ++- 4 files changed, 108 insertions(+), 53 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 9be817773..2b4ada0ea 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -2347,7 +2347,15 @@ mod tests { Some(ClassicalValue::RngContext(_)) )); + // Backtracking is unsupported by the step-based implementation and + // must FAULT (the spec allows negative delta; silently advancing + // forward instead fabricates a different stream). seed_input(&mut engine, advance, 1, ClassicalValue::Int(-1)); + assert!(matches!( + engine.handle_random_op(&hugr, advance, "RandomAdvance"), + HandlerOutcome::Fault(_) + )); + seed_input(&mut engine, advance, 1, ClassicalValue::Int(2)); assert_eq!( engine.handle_random_op(&hugr, advance, "RandomAdvance"), HandlerOutcome::Processed @@ -2359,9 +2367,11 @@ mod tests { ); match engine.wire_state.classical_values.get(&(int, 0)) { Some(ClassicalValue::Int(v)) => { + // Canonical int<5> storage: the 32-bit value sign-extended, + // i.e. exactly the i32 range (NOT zero-extended [0, 2^32)). assert!( - (0..=i64::from(u32::MAX)).contains(v), - "int<32> value, got {v}" + (i64::from(i32::MIN)..=i64::from(i32::MAX)).contains(v), + "canonical int<32> value, got {v}" ); } other => panic!("RandomInt port 0 must be the value, got {other:?}"), @@ -2377,8 +2387,9 @@ mod tests { ); assert!(engine.extension_state.rng_contexts.is_empty()); - // An empty range has no value to produce: bound 0 must poison the - // execution, not clamp. + // An empty range has no value to produce: bound 0 must fault, not + // clamp. (Negative-looking bounds are canonical high-bit unsigned + // values and are VALID.) let mut poisoned = HugrEngine::default(); seed_input(&mut poisoned, bounded, 0, ClassicalValue::RngContext(7)); poisoned @@ -2391,7 +2402,7 @@ mod tests { else { panic!("bound 0 must fault"); }; - assert!(fault.contains("not positive"), "unexpected fault: {fault}"); + assert!(fault.contains("empty range"), "unexpected fault: {fault}"); } #[test] diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index 7609c1a36..f769dac2b 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -169,17 +169,23 @@ impl HugrEngine { HandlerOutcome::Processed } "TryQAlloc" => { - // TryQAlloc: () -> Sum<(), Qubit> - // For simulation, always succeed and allocate a qubit + // TryQAlloc: () -> Option + // For simulation, always succeed and allocate a qubit. The + // value must be a REAL Sum carrying the qubit payload: + // case-input propagation unpacks payloads from Sum values, + // and a bare scalar loses the allocated qubit (falling into + // implicit re-allocation downstream). let qubit_id = QubitId::from(self.wire_state.next_qubit_id); self.wire_state.next_qubit_id += 1; - // Output on port 0 (Sum type, tag 1 = success with qubit) self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - // Store Sum tag = 1 (success) for control flow - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::UInt(1)); + self.wire_state.classical_values.insert( + (node, 0), + ClassicalValue::Sum { + tag: 1, + values: vec![ClassicalValue::QubitRef(qubit_id)], + }, + ); debug!("TryQAlloc created qubit {qubit_id:?}"); HandlerOutcome::Processed @@ -280,9 +286,12 @@ impl HugrEngine { debug!("RandomInt at {node:?}: context not ready, deferring"); return HandlerOutcome::Defer; }; - // The output is int<32>: keep only the low 32 bits + // The output is int<5> (32-bit): canonical storage is the + // sign-extended low 32 bits, per the engine-wide width + // convention (a raw zero-extended u32 would misread in every + // signed consumer). #[allow(clippy::cast_possible_truncation)] // intentional 32-bit mask - let random_int = i64::from(self.generate_random_u64(ctx_id) as u32); + let random_int = i64::from((self.generate_random_u64(ctx_id) as u32).cast_signed()); self.wire_state .classical_values @@ -307,18 +316,25 @@ impl HugrEngine { debug!("RandomIntBounded at {node:?}: bound not ready, deferring"); return HandlerOutcome::Defer; }; - if bound <= 0 { - // [0, bound) is empty: there is no value this op could + // The bound is UNSIGNED int<5>: reinterpret the canonical + // (sign-extended) storage as its 32-bit pattern -- a bound + // >= 2^31 stores negative but names a valid nonempty range. + #[allow(clippy::cast_sign_loss)] + let bound = (bound as u64) & 0xFFFF_FFFF; + if bound == 0 { + // [0, 0) is empty: there is no value this op could // produce, so clamping would fabricate a result. return HandlerOutcome::Fault(format!( - "RandomIntBounded at {node:?}: bound {bound} is not positive" + "RandomIntBounded at {node:?}: bound 0 names an empty range" )); } - let random_val = self.generate_random_u64(ctx_id) % bound as u64; + let random_val = self.generate_random_u64(ctx_id) % bound; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Int(random_val as i64)); + #[allow(clippy::cast_possible_truncation)] + self.wire_state.classical_values.insert( + (node, 0), + ClassicalValue::Int(i64::from((random_val as u32).cast_signed())), + ); self.wire_state .classical_values .insert((node, 1), ClassicalValue::RngContext(ctx_id)); @@ -341,10 +357,24 @@ impl HugrEngine { }; { - // Advance the RNG state by |delta| steps - // Note: For simplicity, we only support forward advancement - // Negative delta would require storing history which we don't do + const MAX_ADVANCE_STEPS: u64 = 1 << 24; + // The spec advances OR BACKTRACKS by delta; this + // step-based implementation can only go forward, and a + // huge forward delta would hang the host. Fail loud on + // both instead of silently advancing the wrong way. + if delta < 0 { + return HandlerOutcome::Fault(format!( + "RandomAdvance at {node:?}: backtracking (delta {delta}) is \ + not supported by the step-based RNG implementation" + )); + } let steps = delta.unsigned_abs(); + if steps > MAX_ADVANCE_STEPS { + return HandlerOutcome::Fault(format!( + "RandomAdvance at {node:?}: delta {steps} exceeds the \ + step-based implementation's ceiling ({MAX_ADVANCE_STEPS})" + )); + } for _ in 0..steps { self.generate_random_u64(ctx_id); } diff --git a/crates/pecos-hugr/src/engine/handlers/quantum.rs b/crates/pecos-hugr/src/engine/handlers/quantum.rs index f7db38f65..4d77750cd 100644 --- a/crates/pecos-hugr/src/engine/handlers/quantum.rs +++ b/crates/pecos-hugr/src/engine/handlers/quantum.rs @@ -48,25 +48,29 @@ impl HugrEngine { match op_name { "symbolic_angle" => { // symbolic_angle: () -> rotation - // Creates a rotation from a symbolic expression (sympy string parameter) - // For simulation, we try to parse simple numeric expressions + // Creates a rotation from a symbolic expression (sympy + // string parameter). Simple numeric expressions parse; an + // UNRESOLVED symbolic angle must fault -- defaulting to 0.0 + // silently simulates the identity instead of the program. let op = hugr.get_optype(node); - if let Some(ext_op) = op.as_extension_op() { + let parsed = op.as_extension_op().and_then(|ext_op| { let debug_str = format!("{ext_op:?}"); - // Try to extract the symbolic expression from parameters - let angle = Self::parse_symbolic_angle(&debug_str); - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Rotation(angle)); - debug!("symbolic_angle: parsed angle = {angle} half-turns"); - } else { - // Default to 0 if we can't parse - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Rotation(0.0)); - debug!("symbolic_angle: defaulting to 0"); + Self::parse_symbolic_angle(&debug_str) + }); + match parsed { + Some(angle) => { + self.wire_state + .classical_values + .insert((node, 0), ClassicalValue::Rotation(angle)); + debug!("symbolic_angle: parsed angle = {angle} half-turns"); + HandlerOutcome::Processed + } + None => HandlerOutcome::Fault(format!( + "symbolic_angle at {node:?}: unresolved symbolic expression \ + (cannot be simulated; substituting 0 would silently run the \ + wrong circuit)" + )), } - HandlerOutcome::Processed } // Quantum gates are handled via the quantum ops path, not here // -- defer so they fall through to the gate handling @@ -80,44 +84,47 @@ impl HugrEngine { /// - Numeric literals: "0.5", "1.0", "-0.25" /// - Pi expressions: "pi", "pi/2", "pi/4", "2*pi" /// - Fractions: "1/2", "1/4" - pub(crate) fn parse_symbolic_angle(debug_str: &str) -> f64 { + /// + /// Returns None for expressions it cannot evaluate -- the caller must + /// fail loud, never substitute a default angle. + pub(crate) fn parse_symbolic_angle(debug_str: &str) -> Option { // Look for quoted string content that might contain the expression if let Some(expr) = Self::extract_string_from_debug(debug_str) { let expr = expr.trim().to_lowercase(); // Try parsing as a simple float if let Ok(val) = expr.parse::() { - return val; + return Some(val); } // Handle pi expressions (angles in half-turns, so pi = 1.0 half-turn) if expr == "pi" { - return 1.0; + return Some(1.0); } if expr == "-pi" { - return -1.0; + return Some(-1.0); } if expr == "2*pi" || expr == "2pi" { - return 2.0; + return Some(2.0); } // Handle pi/n expressions if let Some(rest) = expr.strip_prefix("pi/") && let Ok(divisor) = rest.parse::() { - return 1.0 / divisor; + return Some(1.0 / divisor); } if let Some(rest) = expr.strip_prefix("-pi/") && let Ok(divisor) = rest.parse::() { - return -1.0 / divisor; + return Some(-1.0 / divisor); } // Handle n*pi expressions if let Some(rest) = expr.strip_suffix("*pi") && let Ok(multiplier) = rest.parse::() { - return multiplier; + return Some(multiplier); } // Handle simple fractions like 1/2, 1/4 @@ -125,13 +132,13 @@ impl HugrEngine { && let (Ok(num), Ok(denom)) = (num_str.parse::(), denom_str.parse::()) && denom != 0.0 { - return num / denom; + return Some(num / denom); } - debug!("Could not parse symbolic angle expression: '{expr}', defaulting to 0"); + debug!("Could not parse symbolic angle expression: '{expr}'"); } - 0.0 + None } /// Handle `tket.rotation` operations. diff --git a/crates/pecos-hugr/src/engine/handlers/result.rs b/crates/pecos-hugr/src/engine/handlers/result.rs index 52fc8fd6a..1cb403393 100644 --- a/crates/pecos-hugr/src/engine/handlers/result.rs +++ b/crates/pecos-hugr/src/engine/handlers/result.rs @@ -79,8 +79,13 @@ impl HugrEngine { } } "result_uint" => { + // Reinterpret the canonical (sign-extended) bit pattern: + // as_uint REJECTS negative storage, but a canonical u64 + // >= 2^63 stores negative -- rejecting it defers the result + // op forever and kills the shot in the stall report. + #[allow(clippy::cast_sign_loss)] if let Some(value) = self.get_input_value(hugr, node, 0) - && let Some(u) = value.as_uint() + && let Some(u) = value.as_int().map(|v| v as u64) { self.captured_results.push(CapturedResult { label, @@ -148,11 +153,13 @@ impl HugrEngine { } } "result_array_uint" => { + // Bit-reinterpret canonical storage; see result_uint. + #[allow(clippy::cast_sign_loss)] if let Some(value) = self.get_input_value(hugr, node, 0) && let Some(arr) = value.as_array() && let Some(uints) = arr .iter() - .map(ClassicalValue::as_uint) + .map(|v| v.as_int().map(|i| i as u64)) .collect::>>() { self.captured_results.push(CapturedResult { From 4e0cbb0886b0f8d9e477a7c933c815e5772c941d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 20:54:18 -0600 Subject: [PATCH 368/388] Panel-2 batch 3: readiness treats a Conditional with an active case as not-ready (one-shot input copiers no longer fire early), case payload offsets derive from the type row, loop-continue clears stale just-input wires, LoadConstant children count in block emptiness/completion with extra pending-branch wake-ups, and empty-block/scan-passthrough chains iterate instead of recursing --- crates/pecos-hugr/src/engine.rs | 10 + crates/pecos-hugr/src/engine/activation.rs | 1 + crates/pecos-hugr/src/engine/analysis.rs | 27 +- .../pecos-hugr/src/engine/control_flow/cfg.rs | 404 +++++++++--------- .../src/engine/control_flow/conditional.rs | 37 +- .../src/engine/control_flow/scan.rs | 192 +++++---- .../src/engine/control_flow/tailloop.rs | 15 + crates/pecos-hugr/src/engine/types.rs | 4 + 8 files changed, 405 insertions(+), 285 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 2b4ada0ea..fa7fae7d1 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -442,6 +442,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { @@ -460,6 +461,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { @@ -478,6 +480,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { @@ -496,6 +499,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { @@ -514,6 +518,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { @@ -544,6 +549,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { @@ -837,6 +843,7 @@ impl HugrEngine { && num_tailloops == 0 && num_classical == 0 && num_extension == 0 + && block_info.load_constants.is_empty() { debug!( "[TRACE] Entry block {:?} has 0 ops and 0 calls, successors: {:?}", @@ -948,6 +955,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { debug!("TailLoop {current_node:?}: inputs not ready, deferring expansion"); @@ -1800,6 +1808,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { @@ -1815,6 +1824,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed ) ); diff --git a/crates/pecos-hugr/src/engine/activation.rs b/crates/pecos-hugr/src/engine/activation.rs index a4cfc2bf7..d15d50a05 100644 --- a/crates/pecos-hugr/src/engine/activation.rs +++ b/crates/pecos-hugr/src/engine/activation.rs @@ -167,6 +167,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index cfb37fa3a..eff902b84 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -39,8 +39,8 @@ use tket::hugr::ops::{OpTrait, OpType}; use tket::hugr::{Hugr, HugrView, Node}; use super::types::{ - CfgInfo, ClassicalOp, ClassicalOpType, ConditionalInfo, ContainerType, DataflowBlockInfo, - FuncDefnInfo, QuantumOp, TailLoopInfo, + ActiveCaseInfo, CfgInfo, ClassicalOp, ClassicalOpType, ConditionalInfo, ContainerType, + DataflowBlockInfo, FuncDefnInfo, QuantumOp, TailLoopInfo, }; // --- Conditional extraction --- @@ -216,6 +216,13 @@ pub fn extract_dataflow_block_info( // Find TailLoop nodes inside this block let tailloop_nodes = find_tailloop_nodes_in_block(hugr, node); + // LoadConstant children execute like every other op: block emptiness + // and completion must count them. + let load_constants: BTreeSet = hugr + .children(node) + .filter(|&child| matches!(hugr.get_optype(child), OpType::LoadConstant(_))) + .collect(); + debug!( "DataflowBlock {:?}: {} inputs, {} successors, {} quantum ops, {} calls, {} conditionals, {} bool_ops, {} classical_ops, {} extension_ops, {} tailloops", node, @@ -242,6 +249,7 @@ pub fn extract_dataflow_block_info( classical_ops, extension_ops, tailloop_nodes, + load_constants, input_node, output_node, } @@ -1053,6 +1061,7 @@ pub fn all_predecessors_ready( quantum_ops: &BTreeMap, conditionals: &BTreeMap, cfgs: &BTreeMap, + active_cases: &BTreeMap, processed: &BTreeSet, ) -> bool { for pred_node in hugr.input_neighbours(node) { @@ -1060,8 +1069,18 @@ pub fn all_predecessors_ready( if quantum_ops.contains_key(&pred_node) && !processed.contains(&pred_node) { return false; } - // Check conditionals (they also produce qubit outputs) - if conditionals.contains_key(&pred_node) && !processed.contains(&pred_node) { + // Check conditionals (they also produce qubit outputs). A + // Conditional is marked processed at EXPANSION, but its outputs + // exist only once its selected case completes -- treating it as + // ready while a case is active lets one-shot input copiers (Calls, + // TailLoop expansion, CFG activation) fire early, copy missing + // values, and starve with no repair path. + if conditionals.contains_key(&pred_node) + && (!processed.contains(&pred_node) + || active_cases + .values() + .any(|case| case.conditional_node == pred_node)) + { return false; } // Check CFG nodes (they also produce qubit outputs) diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 58927e31e..7db857358 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -274,7 +274,8 @@ impl HugrEngine { || block_info.bool_ops.contains(&processed_node) || block_info.extension_ops.contains(&processed_node) || block_info.tailloop_nodes.contains(&processed_node) - || block_info.classical_ops.contains(&processed_node); + || block_info.classical_ops.contains(&processed_node) + || block_info.load_constants.contains(&processed_node); if is_in_block { let all_done = |set: &std::collections::BTreeSet| { @@ -297,7 +298,8 @@ impl HugrEngine { && all_done(&block_info.bool_ops) && all_done(&block_info.extension_ops) && all_done(&block_info.tailloop_nodes) - && all_done(&block_info.classical_ops); + && all_done(&block_info.classical_ops) + && all_done(&block_info.load_constants); if block_complete { block_completions.push(( @@ -380,223 +382,242 @@ impl HugrEngine { return; }; - // If to_block is the ExitBlock, complete the CFG. - // ExitBlock has no operations - it's just a marker node. The from_block (a DataflowBlock) - // should have already executed any result operations before this transition. - if to_block == cfg_info.exit_block { - debug!("CFG {cfg_node:?}: transitioning to exit block {to_block:?}"); - self.complete_cfg_execution(hugr, cfg_node, from_block); - return; - } - - debug!("CFG {cfg_node:?}: transitioning from block {from_block:?} to {to_block:?}"); - - // Propagate wire mappings from completed block to successor block - self.propagate_block_outputs_to_successor(hugr, from_block, to_block); - - // Record this propagation for re-propagation after measurement results - // are available (measurement results may not be stored yet when we - // transition). Only the LATEST transition per CFG may stay recorded: - // replaying a superseded edge (e.g. the previous loop iteration's) - // re-reads source wires that have since been cleared or partially - // rewritten and clobbers the successor's fresh inputs with stale - // values (observed as a loop re-borrowing the same array slot). - self.pending_measurement_propagations - .retain(|(cfg, _, _)| *cfg != cfg_node); - self.pending_measurement_propagations - .push((cfg_node, from_block, to_block)); - - // Update active CFG state. Guppy loops lower to CFG cycles, so the - // transition count doubles as the loop-iteration ceiling: a - // never-breaking classical loop would otherwise spin the processing - // loop forever with no yield. - if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { - active_cfg.completed_blocks.insert(from_block); - active_cfg.current_block = to_block; - active_cfg.transitions += 1; - if active_cfg.transitions > Self::MAX_CFG_TRANSITIONS { - self.execution_error = Some(format!( - "CFG {cfg_node:?} exceeded {} block transitions without exiting", - Self::MAX_CFG_TRANSITIONS - )); + // ITERATIVE transition: a chain of empty blocks re-enters this + // logic once per hop. Recursing here overflowed the stack around + // ~10^5 hops -- an abort, not the clean MAX_CFG_TRANSITIONS error. + let mut from_block = from_block; + let mut to_block = to_block; + 'transition: loop { + let _ = &from_block; + + // If to_block is the ExitBlock, complete the CFG. + // ExitBlock has no operations - it's just a marker node. The from_block (a DataflowBlock) + // should have already executed any result operations before this transition. + if to_block == cfg_info.exit_block { + debug!("CFG {cfg_node:?}: transitioning to exit block {to_block:?}"); + self.complete_cfg_execution(hugr, cfg_node, from_block); return; } - } - // Activate successor block's quantum ops and Call nodes - if let Some(block_info) = cfg_info.blocks.get(&to_block) { - self.executed_containers.insert(to_block, "DataflowBlock"); - // Re-activate the block through the shared two-phase mechanism. - // Stale wires clear for every child except the Input node (it - // holds fresh values from propagation): without this, nodes - // like tket.bool.read retain values from the previous loop - // iteration and a Conditional queued before the bool op reads - // the stale value and selects the wrong branch. Processed flags - // clear for EVERY op category before ANY readiness check: - // interleaving clear-and-queue per category let a Call pass - // readiness against the PREVIOUS iteration's flags of a - // not-yet-cleared producer and re-execute the same iteration - // forever. Ops inside TailLoops leave the block gate but queue - // only when their loop expands. - let block_input_node = find_input_node(hugr, to_block); - let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); - let mut act = ContainerActivation::new(); - for child in hugr.children(to_block) { - act.reset_wires(child); - } - if let Some(input) = block_input_node { - act.keep_wires(input); - } - for &op_node in block_info - .quantum_ops - .iter() - .chain(&block_info.call_nodes) - .chain(&block_info.conditional_nodes) - .chain(&block_info.bool_ops) - .chain(&block_info.tailloop_nodes) - .chain(&extension_ops) - { - act.reset_processed(op_node); + debug!("CFG {cfg_node:?}: transitioning from block {from_block:?} to {to_block:?}"); + + // Propagate wire mappings from completed block to successor block + self.propagate_block_outputs_to_successor(hugr, from_block, to_block); + + // Record this propagation for re-propagation after measurement results + // are available (measurement results may not be stored yet when we + // transition). Only the LATEST transition per CFG may stay recorded: + // replaying a superseded edge (e.g. the previous loop iteration's) + // re-reads source wires that have since been cleared or partially + // rewritten and clobbers the successor's fresh inputs with stale + // values (observed as a loop re-borrowing the same array slot). + self.pending_measurement_propagations + .retain(|(cfg, _, _)| *cfg != cfg_node); + self.pending_measurement_propagations + .push((cfg_node, from_block, to_block)); + + // Update active CFG state. Guppy loops lower to CFG cycles, so the + // transition count doubles as the loop-iteration ceiling: a + // never-breaking classical loop would otherwise spin the processing + // loop forever with no yield. + if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { + active_cfg.completed_blocks.insert(from_block); + active_cfg.current_block = to_block; + active_cfg.transitions += 1; + if active_cfg.transitions > Self::MAX_CFG_TRANSITIONS { + self.execution_error = Some(format!( + "CFG {cfg_node:?} exceeded {} block transitions without exiting", + Self::MAX_CFG_TRANSITIONS + )); + return; + } } - for child in hugr.children(to_block) { - if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) - || self.classical_ops.contains_key(&child) + + // Activate successor block's quantum ops and Call nodes + if let Some(block_info) = cfg_info.blocks.get(&to_block) { + self.executed_containers.insert(to_block, "DataflowBlock"); + // Re-activate the block through the shared two-phase mechanism. + // Stale wires clear for every child except the Input node (it + // holds fresh values from propagation): without this, nodes + // like tket.bool.read retain values from the previous loop + // iteration and a Conditional queued before the bool op reads + // the stale value and selects the wrong branch. Processed flags + // clear for EVERY op category before ANY readiness check: + // interleaving clear-and-queue per category let a Call pass + // readiness against the PREVIOUS iteration's flags of a + // not-yet-cleared producer and re-execute the same iteration + // forever. Ops inside TailLoops leave the block gate but queue + // only when their loop expands. + let block_input_node = find_input_node(hugr, to_block); + let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); + let mut act = ContainerActivation::new(); + for child in hugr.children(to_block) { + act.reset_wires(child); + } + if let Some(input) = block_input_node { + act.keep_wires(input); + } + for &op_node in block_info + .quantum_ops + .iter() + .chain(&block_info.call_nodes) + .chain(&block_info.conditional_nodes) + .chain(&block_info.bool_ops) + .chain(&block_info.tailloop_nodes) + .chain(&extension_ops) { - act.reset_processed(child); + act.reset_processed(op_node); } - } - // Queue order preserves the historical activation order - // (extension/classical before bool ops so their results are - // available; each loop iteration must re-run Calls). Policies: - // Calls and classical ops copy inputs at fire time, so they - // wait for readiness; the rest defer internally or are static. - let submit = |act: &mut ContainerActivation, node: Node, policy: QueuePolicy| { - if self.nodes_inside_tailloops.contains(&node) { - // Skip ops inside TailLoops - they'll be added when the - // loop expands - act.ungate_block_only(node); - } else { - act.queue(node, policy); + for child in hugr.children(to_block) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) + || self.classical_ops.contains_key(&child) + { + act.reset_processed(child); + } } - }; - for &op_node in &block_info.quantum_ops { - submit(&mut act, op_node, QueuePolicy::Always); - } - for &call_node in &block_info.call_nodes { - submit(&mut act, call_node, QueuePolicy::IfReady); - } - for &cond_node in &block_info.conditional_nodes { - submit(&mut act, cond_node, QueuePolicy::Always); - } - for &op_node in &extension_ops { - submit(&mut act, op_node, QueuePolicy::Always); - } - for child in hugr.children(to_block) { - if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { - submit(&mut act, child, QueuePolicy::Always); + // Queue order preserves the historical activation order + // (extension/classical before bool ops so their results are + // available; each loop iteration must re-run Calls). Policies: + // Calls and classical ops copy inputs at fire time, so they + // wait for readiness; the rest defer internally or are static. + let submit = |act: &mut ContainerActivation, node: Node, policy: QueuePolicy| { + if self.nodes_inside_tailloops.contains(&node) { + // Skip ops inside TailLoops - they'll be added when the + // loop expands + act.ungate_block_only(node); + } else { + act.queue(node, policy); + } + }; + for &op_node in &block_info.quantum_ops { + submit(&mut act, op_node, QueuePolicy::Always); } - if self.classical_ops.contains_key(&child) { - submit(&mut act, child, QueuePolicy::IfReady); + for &call_node in &block_info.call_nodes { + submit(&mut act, call_node, QueuePolicy::IfReady); } - } - for &op_node in &block_info.bool_ops { - submit(&mut act, op_node, QueuePolicy::Always); - } - // TailLoop nodes queue even when nested inside another loop's - // body tracking: they defer internally until their inputs are - // ready. - for &tl_node in &block_info.tailloop_nodes { - act.queue(tl_node, QueuePolicy::Always); - } - self.run_activation(hugr, &act); - - let num_ops = block_info.quantum_ops.len(); - let num_calls = block_info.call_nodes.len(); - let num_conditionals = block_info.conditional_nodes.len(); - let num_bool_ops = block_info.bool_ops.len(); - let num_tailloops = block_info.tailloop_nodes.len(); - debug!( - "[TRACE] Activated block {to_block:?} with {num_ops} ops, {num_calls} calls, {num_conditionals} conditionals, {num_bool_ops} bool_ops, {num_tailloops} tailloops" - ); + for &cond_node in &block_info.conditional_nodes { + submit(&mut act, cond_node, QueuePolicy::Always); + } + for &op_node in &extension_ops { + submit(&mut act, op_node, QueuePolicy::Always); + } + for child in hugr.children(to_block) { + if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { + submit(&mut act, child, QueuePolicy::Always); + } + if self.classical_ops.contains_key(&child) { + submit(&mut act, child, QueuePolicy::IfReady); + } + } + for &op_node in &block_info.bool_ops { + submit(&mut act, op_node, QueuePolicy::Always); + } + // TailLoop nodes queue even when nested inside another loop's + // body tracking: they defer internally until their inputs are + // ready. + for &tl_node in &block_info.tailloop_nodes { + act.queue(tl_node, QueuePolicy::Always); + } + self.run_activation(hugr, &act); - // Handle blocks with no operations - immediately complete and transition - // IMPORTANT: Also check for extension_ops and classical_ops, not just quantum/bool/conditional - let has_extension_ops = !extension_ops.is_empty(); - let has_classical_ops = !block_info.classical_ops.is_empty(); - let has_tailloops = !block_info.tailloop_nodes.is_empty(); - - if num_ops == 0 - && num_calls == 0 - && num_conditionals == 0 - && num_bool_ops == 0 - && !has_extension_ops - && !has_classical_ops - && !has_tailloops - { + let num_ops = block_info.quantum_ops.len(); + let num_calls = block_info.call_nodes.len(); + let num_conditionals = block_info.conditional_nodes.len(); + let num_bool_ops = block_info.bool_ops.len(); + let num_tailloops = block_info.tailloop_nodes.len(); debug!( - "[TRACE] Block {to_block:?} has 0 ops and 0 calls, trying to resolve branch" + "[TRACE] Activated block {to_block:?} with {num_ops} ops, {num_calls} calls, {num_conditionals} conditionals, {num_bool_ops} bool_ops, {num_tailloops} tailloops" ); - debug!("[TRACE] Block {to_block:?} has no quantum ops, checking for successors"); - // Mark this block as complete in the active CFG - if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { - active_cfg.completed_blocks.insert(to_block); - } - // Get successors for this block - let successors = block_info.successors.clone(); - if successors.is_empty() { - // No successors - exit block - self.complete_cfg_execution(hugr, cfg_node, to_block); - } else if successors.len() == 1 { - // Single successor - transition immediately. Recurse into - // the canonical transition path (same as the resolved - // multi-successor case below) so the successor gets the - // full two-phase activation: a hand-rolled copy here used - // to skip the stale-value clearing and check Call - // readiness against uncleared producer flags, reviving - // the loop-iteration freeze through empty blocks. - let next_block = successors[0]; - if next_block == cfg_info.exit_block { + // Handle blocks with no operations - immediately complete and transition + // IMPORTANT: Also check for extension_ops and classical_ops, not just quantum/bool/conditional + let has_extension_ops = !extension_ops.is_empty(); + let has_classical_ops = !block_info.classical_ops.is_empty(); + let has_tailloops = !block_info.tailloop_nodes.is_empty(); + let has_load_consts = !block_info.load_constants.is_empty(); + + if num_ops == 0 + && num_calls == 0 + && num_conditionals == 0 + && num_bool_ops == 0 + && !has_extension_ops + && !has_classical_ops + && !has_tailloops + && !has_load_consts + { + debug!( + "[TRACE] Block {to_block:?} has 0 ops and 0 calls, trying to resolve branch" + ); + debug!( + "[TRACE] Block {to_block:?} has no quantum ops, checking for successors" + ); + // Mark this block as complete in the active CFG + if let Some(active_cfg) = self.active_cfgs.get_mut(&cfg_node) { + active_cfg.completed_blocks.insert(to_block); + } + + // Get successors for this block + let successors = block_info.successors.clone(); + if successors.is_empty() { + // No successors - exit block self.complete_cfg_execution(hugr, cfg_node, to_block); + } else if successors.len() == 1 { + // Single successor - transition immediately. Recurse into + // the canonical transition path (same as the resolved + // multi-successor case below) so the successor gets the + // full two-phase activation: a hand-rolled copy here used + // to skip the stale-value clearing and check Call + // readiness against uncleared producer flags, reviving + // the loop-iteration freeze through empty blocks. + let next_block = successors[0]; + if next_block == cfg_info.exit_block { + self.complete_cfg_execution(hugr, cfg_node, to_block); + } else { + debug!( + "[TRACE] Empty block {to_block:?} transitioning to single successor {next_block:?}" + ); + from_block = to_block; + to_block = next_block; + continue 'transition; + } } else { + // Multiple successors - need to resolve branch debug!( - "[TRACE] Empty block {to_block:?} transitioning to single successor {next_block:?}" + "[TRACE] Block {:?} has {} successors, resolving branch", + to_block, + successors.len() ); - self.transition_to_cfg_successor(hugr, cfg_node, to_block, next_block); - } - } else { - // Multiple successors - need to resolve branch - debug!( - "[TRACE] Block {:?} has {} successors, resolving branch", - to_block, - successors.len() - ); - if let Some(branch_idx) = self.try_resolve_cfg_block_branch(hugr, to_block) { - debug!("[TRACE] Branch resolved to {branch_idx} for block {to_block:?}"); - if branch_idx < successors.len() { - let next_block = successors[branch_idx]; + if let Some(branch_idx) = self.try_resolve_cfg_block_branch(hugr, to_block) + { debug!( - "[TRACE] Empty block {to_block:?} resolved branch {branch_idx} to {next_block:?}" + "[TRACE] Branch resolved to {branch_idx} for block {to_block:?}" ); - // Recursively transition - self.transition_to_cfg_successor(hugr, cfg_node, to_block, next_block); - } else { + if branch_idx < successors.len() { + let next_block = successors[branch_idx]; + debug!( + "[TRACE] Empty block {to_block:?} resolved branch {branch_idx} to {next_block:?}" + ); + from_block = to_block; + to_block = next_block; + continue 'transition; + } // Out-of-range tag = upstream Sum/tag bug. self.execution_error = Some(format!( "CFG {cfg_node:?} empty block {to_block:?}: branch tag \ {branch_idx} out of range ({} successors)", successors.len() )); + } else { + debug!( + "[TRACE] Branch NOT resolved for block {to_block:?}, adding to pending" + ); + // Branch not resolved - add to pending + let block_key = (cfg_node, to_block); + self.pending_cfg_branches.insert(block_key, successors); } - } else { - debug!( - "[TRACE] Branch NOT resolved for block {to_block:?}, adding to pending" - ); - // Branch not resolved - add to pending - let block_key = (cfg_node, to_block); - self.pending_cfg_branches.insert(block_key, successors); } } + return; } } } @@ -689,6 +710,7 @@ impl HugrEngine { &self.quantum_ops, &self.conditionals, &self.cfgs, + &self.active_cases, &self.processed, ) { diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index b7a6bb1c6..e95c091ee 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -209,6 +209,7 @@ impl HugrEngine { // the only other retry point, and a purely classical case // completion may never be followed by one). self.try_resolve_pending_tailloops(); + self.try_resolve_pending_cfg_branches(); self.queue_ready_successors(hugr, cond_node); self.retry_pending_bool_reads(); } @@ -329,11 +330,26 @@ impl HugrEngine { /// copy would leave those Case Input ports empty forever. pub(crate) fn propagate_case_inputs(&mut self, hugr: &Hugr, cond_node: Node, input_node: Node) { // The Case's Input row is [selected variant's PAYLOAD] ++ - // [other inputs]. Unpack the control Sum's payload values into - // the first Case Input ports (e.g. an iterator's - // Continue(item, state) payload); empty-payload variants (plain - // bools) contribute nothing and leave the offset at 0. - let mut payload_len = 0; + // [other inputs]. The payload ARITY comes from the Conditional's + // TYPE (sum_rows of the selected variant), never from the control + // value: when the branch resolves through the structural Tag + // fallback the value is a bare tag with no payload, and deriving + // the offset from it would land the data inputs on the payload's + // ports. + let payload_len = hugr + .get_parent(input_node) + .and_then(|case_node| { + let branch = hugr.children(cond_node).position(|c| c == case_node)?; + if let OpType::Conditional(cond_op) = hugr.get_optype(cond_node) { + cond_op + .sum_rows + .get(branch) + .map(tket::hugr::types::TypeRow::len) + } else { + None + } + }) + .unwrap_or(0); if let Some((ctrl_src, ctrl_port)) = hugr.single_linked_output(cond_node, IncomingPort::from(0)) && let Some(ClassicalValue::Sum { values, .. }) = self @@ -342,8 +358,14 @@ impl HugrEngine { .get(&(ctrl_src, ctrl_port.index())) .cloned() { - payload_len = values.len(); - for (i, value) in values.into_iter().enumerate() { + if values.len() != payload_len { + debug!( + "Conditional {cond_node:?}: control payload arity {} != type arity \ + {payload_len}; propagating what exists at type offsets", + values.len() + ); + } + for (i, value) in values.into_iter().enumerate().take(payload_len) { debug!("Propagated control payload {value:?} to Case Input ({input_node:?}, {i})"); if let ClassicalValue::QubitRef(qubit_id) = &value { self.wire_state @@ -599,6 +621,7 @@ impl HugrEngine { self.check_cfg_block_completion(hugr, cond_node); self.check_tailloop_body_completion(hugr, cond_node); self.try_resolve_pending_tailloops(); + self.try_resolve_pending_cfg_branches(); self.queue_ready_successors(hugr, cond_node); self.retry_pending_bool_reads(); } diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs index 37f6b9971..67b1cabf6 100644 --- a/crates/pecos-hugr/src/engine/control_flow/scan.rs +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -106,21 +106,33 @@ impl HugrEngine { .pop_front() .expect("non-empty checked above"); self.active_scans.insert(node, state); - self.launch_scan_iteration(hugr, node, element); + if self.launch_scan_iteration(hugr, node, element) { + // Pure-passthrough frame: fold the remaining elements now. + self.advance_scan(hugr, node); + } // Like a Call, the scan node stays unprocessed while its frame - // runs; completion marks it. Defer parks it harmlessly. + // runs; completion marks it (advance_scan may already have, in + // which case the processed check absorbs this Defer harmlessly). HandlerOutcome::Defer } - /// Run one element through the scanned function's frame. - fn launch_scan_iteration(&mut self, hugr: &Hugr, scan_node: Node, element: ClassicalValue) { + /// Run one element through the scanned function's frame. Returns true + /// when the frame completed INSTANTLY (an empty dataflow passthrough + /// body): the caller must then advance in ITS loop -- calling back into + /// completion here would recurse once per element. + fn launch_scan_iteration( + &mut self, + hugr: &Hugr, + scan_node: Node, + element: ClassicalValue, + ) -> bool { let Some(state) = self.active_scans.get(&scan_node) else { - return; + return false; }; let func_defn_node = state.func_defn_node; let accs = state.accs.clone(); let Some(func_info) = self.func_defns.get(&func_defn_node).cloned() else { - return; + return false; }; // Reset the frame exactly like a Call re-activation: descendants' @@ -188,10 +200,14 @@ impl HugrEngine { if !self.work_queue.contains(&cfg_node) { self.work_queue.push_front(cfg_node); } + false } else { debug!("scan {scan_node:?}: launched dataflow frame"); - // An EMPTY dataflow body (pure passthrough) completes at once. - self.check_scan_frame_completion(hugr, scan_node); + // An EMPTY dataflow body (pure passthrough) completes at once; + // the caller advances iteratively. + self.active_scans + .get(&scan_node) + .is_some_and(|state| state.frame_ops.is_empty()) } } @@ -253,94 +269,104 @@ impl HugrEngine { } /// One element's frame finished: collect its outputs, then fold the - /// next element or complete the scan. + /// next element or complete the scan. Iterative: a pure-passthrough + /// frame completes each launch instantly, and recursing per element + /// would grow the stack with the array length. fn advance_scan(&mut self, hugr: &Hugr, scan_node: Node) { - let Some(state) = self.active_scans.get(&scan_node) else { - return; - }; - let Some(func_info) = self.func_defns.get(&state.func_defn_node).cloned() else { - return; - }; + loop { + let Some(state) = self.active_scans.get(&scan_node) else { + return; + }; + let Some(func_info) = self.func_defns.get(&state.func_defn_node).cloned() else { + return; + }; - // Collect the frame's outputs: port 0 = mapped element, 1.. = accs. - let read_output = |engine: &Self, port: usize| -> Option { - let (src, sp) = - hugr.single_linked_output(func_info.output_node, IncomingPort::from(port))?; - let wire = (src, sp.index()); - if let Some(value) = engine.wire_state.classical_values.get(&wire) { - return Some(value.clone()); - } - engine - .wire_state - .wire_to_qubit - .get(&wire) - .map(|q| ClassicalValue::QubitRef(*q)) - }; - let Some(mapped) = read_output(self, 0) else { - // The frame completed without producing the mapped value: a - // wiring bug this must not paper over. - self.execution_error = Some(format!( - "scan {scan_node:?}: frame completed without an output value" - )); - self.active_scans.remove(&scan_node); - return; - }; - let num_accs = self.active_scans[&scan_node].accs.len(); - let mut new_accs = Vec::with_capacity(num_accs); - for i in 0..num_accs { - if let Some(value) = read_output(self, 1 + i) { - new_accs.push(value); - } else { + // Collect the frame's outputs: port 0 = mapped element, 1.. = accs. + let read_output = |engine: &Self, port: usize| -> Option { + let (src, sp) = + hugr.single_linked_output(func_info.output_node, IncomingPort::from(port))?; + let wire = (src, sp.index()); + if let Some(value) = engine.wire_state.classical_values.get(&wire) { + return Some(value.clone()); + } + engine + .wire_state + .wire_to_qubit + .get(&wire) + .map(|q| ClassicalValue::QubitRef(*q)) + }; + let Some(mapped) = read_output(self, 0) else { + // The frame completed without producing the mapped value: a + // wiring bug this must not paper over. self.execution_error = Some(format!( - "scan {scan_node:?}: frame completed without accumulator {i}" + "scan {scan_node:?}: frame completed without an output value" )); self.active_scans.remove(&scan_node); return; + }; + let num_accs = self.active_scans[&scan_node].accs.len(); + let mut new_accs = Vec::with_capacity(num_accs); + for i in 0..num_accs { + if let Some(value) = read_output(self, 1 + i) { + new_accs.push(value); + } else { + self.execution_error = Some(format!( + "scan {scan_node:?}: frame completed without accumulator {i}" + )); + self.active_scans.remove(&scan_node); + return; + } } - } - - let state = self - .active_scans - .get_mut(&scan_node) - .expect("checked above"); - state.results.push(mapped); - state.accs = new_accs; - if let Some(element) = state.remaining.pop_front() { - self.launch_scan_iteration(hugr, scan_node, element); - return; - } - - // All elements folded: store outputs and complete the scan node. - let state = self.active_scans.remove(&scan_node).expect("present above"); - self.store_scan_outputs(&state); - self.processed.insert(scan_node); - self.pending_bool_reads.remove(&scan_node); - debug!( - "scan {scan_node:?}: complete with {} results", - state.results.len() - ); + let state = self + .active_scans + .get_mut(&scan_node) + .expect("checked above"); + state.results.push(mapped); + state.accs = new_accs; - // Same completion cascade as a Call. - self.check_case_completion(hugr, scan_node); - self.check_cfg_block_completion(hugr, scan_node); - self.check_tailloop_body_completion(hugr, scan_node); - self.try_resolve_pending_tailloops(); - self.queue_ready_successors(hugr, scan_node); - self.retry_pending_bool_reads(); + if let Some(element) = state.remaining.pop_front() { + if self.launch_scan_iteration(hugr, scan_node, element) { + // Instant (passthrough) completion: fold the next element + // in this same loop. + continue; + } + return; + } - // The frame is free now: wake any Call that parked waiting for it - // (mirrors complete_func_call_if_needed). - if let Some(pending) = self.pending_func_calls.get_mut(&state.func_defn_node) - && let Some(next_call) = pending.pop_front() - { + // All elements folded: store outputs and complete the scan node. + let state = self.active_scans.remove(&scan_node).expect("present above"); + self.store_scan_outputs(&state); + self.processed.insert(scan_node); + self.pending_bool_reads.remove(&scan_node); debug!( - "FuncDefn {:?} free after scan: starting pending Call {next_call:?}", - state.func_defn_node + "scan {scan_node:?}: complete with {} results", + state.results.len() ); - if !self.work_queue.contains(&next_call) { - self.work_queue.push_front(next_call); + + // Same completion cascade as a Call. + self.check_case_completion(hugr, scan_node); + self.check_cfg_block_completion(hugr, scan_node); + self.check_tailloop_body_completion(hugr, scan_node); + self.try_resolve_pending_tailloops(); + self.try_resolve_pending_cfg_branches(); + self.queue_ready_successors(hugr, scan_node); + self.retry_pending_bool_reads(); + + // The frame is free now: wake any Call that parked waiting for it + // (mirrors complete_func_call_if_needed). + if let Some(pending) = self.pending_func_calls.get_mut(&state.func_defn_node) + && let Some(next_call) = pending.pop_front() + { + debug!( + "FuncDefn {:?} free after scan: starting pending Call {next_call:?}", + state.func_defn_node + ); + if !self.work_queue.contains(&next_call) { + self.work_queue.push_front(next_call); + } } + return; } } diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 9f7d58325..76d4716be 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -341,6 +341,21 @@ impl HugrEngine { let just_inputs_count = tailloop_info.just_inputs_count; + // Clear EVERY Input port before writing fresh values: the + // activation batch exempts the Input node from wire clearing + // (keep_wires) so the fresh values survive, which means any port + // this propagation cannot source would otherwise silently keep the + // PREVIOUS iteration's value. Missing sources must starve loudly + // instead (same discipline as case-input propagation). + for port_idx in 0..(just_inputs_count + tailloop_info.rest_count) { + self.wire_state + .classical_values + .remove(&(input_node, port_idx)); + self.wire_state + .wire_to_qubit + .remove(&(input_node, port_idx)); + } + // Propagate the "rest" values from Output ports 1.. to Input ports (after just_inputs) for rest_idx in 0..tailloop_info.rest_count { let output_port_idx = rest_idx + 1; // Skip Sum port diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index ad57a14a9..6c076955e 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -721,6 +721,10 @@ pub struct DataflowBlockInfo { pub extension_ops: BTreeSet, /// All `TailLoop` nodes inside this block. pub tailloop_nodes: BTreeSet, + /// `LoadConstant` nodes inside this block: they execute like every + /// other op, so block emptiness and completion must count them (a + /// constants-only block used to transition before they ran). + pub load_constants: BTreeSet, /// Input node inside this block (kept for future wire tracing). #[allow(dead_code)] pub input_node: Option, From 595a82e61934ccae17f95cb58a18179600beda94 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 21:32:10 -0600 Subject: [PATCH 369/388] Panel-2 batch 4: scan frames carry LoadFunction type args (generic scanned functions resolve load_nat), trunc_s/u range-check and canonicalize at the op's width, iu_to_s/is_to_u fault out of range per spec, shadowed divergent arms deleted (float comparisons, convert aliases, nonexistent bit-count ops) with fmax/fmin/fpow confirmed live, prelude.exit faults explicitly, and ConstUsize/ConstError constants parse --- .../src/engine/control_flow/call.rs | 23 +++- .../src/engine/control_flow/scan.rs | 4 +- .../src/engine/handlers/arithmetic.rs | 114 +++++++----------- .../src/engine/handlers/classical.rs | 44 ++++--- .../pecos-hugr/src/engine/handlers/prelude.rs | 11 ++ crates/pecos-hugr/src/engine/propagation.rs | 19 +++ crates/pecos-hugr/src/engine/types.rs | 21 ++-- 7 files changed, 133 insertions(+), 103 deletions(-) diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index 66a75ebb4..0411abd00 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -69,17 +69,28 @@ impl HugrEngine { } cur = hugr.get_parent(n); }; - // Find the active call executing this FuncDefn and read its arg. - let info = self + // Find the active call OR scan executing this FuncDefn and + // read its arg (a scanned function's frame carries the + // LoadFunction's instantiation args). + let (owner_node, arg) = if let Some(info) = self .active_calls .values() - .find(|info| info.func_defn_node == func_defn)?; - match info.type_args.get(var_idx)? { - TypeArg::BoundedNat(n) => return Some(*n), + .find(|info| info.func_defn_node == func_defn) + { + (info.call_node, info.type_args.get(var_idx)?.clone()) + } else { + let scan = self + .active_scans + .values() + .find(|scan| scan.func_defn_node == func_defn)?; + (scan.scan_node, scan.type_args.get(var_idx)?.clone()) + }; + match arg { + TypeArg::BoundedNat(n) => return Some(n), TypeArg::Variable(var) => { // Forwarded generic: continue resolution in the CALLER's // frame, at the caller's variable index. - node = info.call_node; + node = owner_node; var_idx = var.index(); } _ => return None, diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs index 67b1cabf6..6e45e9109 100644 --- a/crates/pecos-hugr/src/engine/control_flow/scan.rs +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -47,7 +47,8 @@ impl HugrEngine { debug!("scan at {node:?}: array not ready, deferring"); return HandlerOutcome::Defer; }; - let Some(ClassicalValue::FuncRef(func_defn_node)) = self.get_input_value(hugr, node, 1) + let Some(ClassicalValue::FuncRef(func_defn_node, func_type_args)) = + self.get_input_value(hugr, node, 1) else { debug!("scan at {node:?}: function value not ready, deferring"); return HandlerOutcome::Defer; @@ -89,6 +90,7 @@ impl HugrEngine { remaining: elements.into(), results: Vec::new(), accs, + type_args: func_type_args, frame_ops: std::collections::BTreeSet::new(), }; if state.remaining.is_empty() { diff --git a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs index 62aa869b0..872a63adf 100644 --- a/crates/pecos-hugr/src/engine/handlers/arithmetic.rs +++ b/crates/pecos-hugr/src/engine/handlers/arithmetic.rs @@ -109,21 +109,10 @@ impl HugrEngine { a.zip(b).zip(c).map(|((x, y), z)| x.mul_add(y, z)) } - // Float comparisons - exact comparison is intentional per HUGR semantics - #[allow(clippy::float_cmp)] - "feq" => a.zip(b).map(|(x, y)| if x == y { 1.0 } else { 0.0 }), - #[allow(clippy::float_cmp)] - "fne" => a.zip(b).map(|(x, y)| if x == y { 0.0 } else { 1.0 }), - "flt" => a.zip(b).map(|(x, y)| if x < y { 1.0 } else { 0.0 }), - "fle" => a.zip(b).map(|(x, y)| if x <= y { 1.0 } else { 0.0 }), - "fgt" => a.zip(b).map(|(x, y)| if x > y { 1.0 } else { 0.0 }), - "fge" => a.zip(b).map(|(x, y)| if x >= y { 1.0 } else { 0.0 }), - - // Check for special values - "fis_nan" | "is_nan" => a.map(|x| if x.is_nan() { 1.0 } else { 0.0 }), - "fis_inf" | "is_inf" => a.map(|x| if x.is_infinite() { 1.0 } else { 0.0 }), - "fis_finite" | "is_finite" => a.map(|x| if x.is_finite() { 1.0 } else { 0.0 }), - + // Float comparisons (feq/fne/flt/fle/fgt/fge) are CLASSIFIED + // ops handled by the classical path with proper Bool outputs; + // duplicate arms here used to return Float(1.0/0.0) -- deleted + // so classification drift cannot resurrect the wrong type. _ => { debug!("Unknown arithmetic.float operation: {op_name}"); return HandlerOutcome::Defer; @@ -219,27 +208,6 @@ impl HugrEngine { canon(rotated.cast_signed()) }), - // Bit counting, within the op's N bits - "ipopcnt" | "popcnt" | "popcount" => { - a.map(|x| i64::from(((x as u64) & mask).count_ones())) - } - "iclz" | "clz" => a.map(|x| { - let masked = (x as u64) & mask; - i64::from(if masked == 0 { - bits as u32 - } else { - masked.leading_zeros() - (64 - bits as u32) - }) - }), - "ictz" | "ctz" => a.map(|x| { - let masked = (x as u64) & mask; - i64::from(if masked == 0 { - bits as u32 - } else { - masked.trailing_zeros() - }) - }), - // Min/max "imin_s" | "imin" => a.zip(b).map(|(x, y)| x.min(y)), "imax_s" | "imax" => a.zip(b).map(|(x, y)| x.max(y)), @@ -273,14 +241,39 @@ impl HugrEngine { return self.handle_inarrow(hugr, node, op_name); } - // Signedness reinterpretation - value-preserving for the - // in-range values guppy emits (usize indices/counters). The spec - // panics out of range; the engine passes the bit pattern through - // (documented divergence, tracked as a follow-up). - #[allow(clippy::match_same_arms)] - "iu_to_s" => a, - #[allow(clippy::match_same_arms)] - "is_to_u" => a, + // Signedness reinterpretation. The spec PANICS out of range + // (iu_to_s of an unsigned value >= 2^(N-1); is_to_u of a + // negative) -- silently passing the bit pattern through would + // hand consumers a sign-flipped value. + "iu_to_s" => match read_unsigned(self, 0) { + None => None, + Some(value) => { + let out_of_range = if log_width < 6 { + value >= (1u64 << ((1u32 << log_width) - 1)) + } else { + value > i64::MAX as u64 + }; + if out_of_range { + return HandlerOutcome::Fault(format!( + "iu_to_s at {node:?}: unsigned value {value} does not fit \ + the signed range at width 2^{log_width} (the spec panics)" + )); + } + Some(canon(value.cast_signed())) + } + }, + "is_to_u" => match a { + None => None, + Some(value) => { + if canon(value) < 0 { + return HandlerOutcome::Fault(format!( + "is_to_u at {node:?}: negative value {value} has no \ + unsigned interpretation (the spec panics)" + )); + } + Some(canon(value)) + } + }, _ => { debug!("Unknown arithmetic.int operation: {op_name}"); @@ -399,35 +392,10 @@ impl HugrEngine { debug!("Processing arithmetic.conversions operation: {op_name} at {node:?}"); match op_name { - // Integer to float conversions - "convert_s" | "itof_s" => { - // Signed integer to float - let Some(value) = self.get_input_value(hugr, node, 0).and_then(|v| v.as_int()) - else { - debug!("convert_s at {node:?}: input not ready, deferring"); - return HandlerOutcome::Defer; - }; - let result = value as f64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Float(result)); - debug!("convert_s: {value} -> {result}"); - } - "convert_u" | "itof_u" => { - // Unsigned integer to float - let Some(value) = self - .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()) - else { - debug!("convert_u at {node:?}: input not ready, deferring"); - return HandlerOutcome::Defer; - }; - let result = value as f64; - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Float(result)); - debug!("convert_u: {value} -> {result}"); - } + // convert_s / convert_u are CLASSIFIED ops handled by the + // width-aware classical path; the duplicate arms here read + // via as_uint (which rejects canonical negative storage) and + // were deleted so classification drift cannot resurrect them. // usize <-> int conversions (e.g. guppy loop bounds built from // prelude.load_nat's usize output) diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index eea978804..4dc77f93b 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -149,13 +149,23 @@ impl HugrEngine { } } ClassicalOpType::LoadFunc => { - // Resolve the static FuncDefn target into a function value. + // Resolve the static FuncDefn target into a function value, + // carrying the instantiation's type args (generic functions + // executed through scan resolve type-level naturals from + // them, symmetric with Call frames). let Some(func_defn) = hugr.static_source(node) else { return ClassicalOutcome::Fault(format!( "LoadFunction at {node:?} has no static source" )); }; - return ClassicalOutcome::Outputs(vec![(0, ClassicalValue::FuncRef(func_defn))]); + let type_args = match hugr.get_optype(node) { + OpType::LoadFunction(lf) => lf.type_args.clone(), + _ => Vec::new(), + }; + return ClassicalOutcome::Outputs(vec![( + 0, + ClassicalValue::FuncRef(func_defn, type_args), + )]); } ClassicalOpType::TagSum => { // Tag wraps its inputs into the given variant of a sum. @@ -509,27 +519,31 @@ impl HugrEngine { } #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] ClassicalOpType::ConvertFloatToIntChecked => { - // trunc_s/trunc_u: sum_with_error(int) -- error (tag 0) - // for NaN/infinite/out-of-range, value (tag 1) otherwise. + // trunc_s/trunc_u: sum_with_error(int) -- error (tag + // 0) for NaN/infinite/out-of-range AT THE OP'S WIDTH, + // value (tag 1) otherwise. The spec folds through + // ConstInt::new_s/new_u(log_width, ..), which rejects + // values that do not fit the declared width. let f = float(0)?; let t = f.trunc(); - // Strict upper bounds: i64::MAX as f64 rounds UP to - // 2^63 (and u64::MAX to 2^64), so `<= MAX as f64` would - // accept one out-of-range value and saturate instead of - // taking the error branch. i64::MIN (-2^63) is exactly - // representable, so >= is correct there. - let in_range = if signed { - t.is_finite() - && (-9_223_372_036_854_775_808.0..9_223_372_036_854_775_808.0) - .contains(&t) + // Strict upper bounds: the width's MAX as f64 can round + // UP to 2^bits (e.g. i64::MAX -> 2^63), so `<= MAX` + // would accept one out-of-range value and saturate + // instead of taking the error branch. The signed MIN + // (-2^(bits-1)) is exactly representable, so >= holds. + let bits_width = 1u32 << log_width.min(6); + let (lo, hi) = if signed { + let half = 2f64.powi(bits_width as i32 - 1); + (-half, half) } else { - t.is_finite() && (0.0..18_446_744_073_709_551_616.0).contains(&t) + (0.0, 2f64.powi(bits_width as i32)) }; + let in_range = t.is_finite() && (lo..hi).contains(&t); if in_range { let bits = if signed { t as i64 } else { (t as u64) as i64 }; ClassicalValue::Sum { tag: 1, - values: vec![ClassicalValue::Int(bits)], + values: vec![ClassicalValue::Int(canonicalize_width(bits, log_width))], } } else { ClassicalValue::Sum { diff --git a/crates/pecos-hugr/src/engine/handlers/prelude.rs b/crates/pecos-hugr/src/engine/handlers/prelude.rs index 4fed11f3b..1ba9b4688 100644 --- a/crates/pecos-hugr/src/engine/handlers/prelude.rs +++ b/crates/pecos-hugr/src/engine/handlers/prelude.rs @@ -83,6 +83,17 @@ impl HugrEngine { HandlerOutcome::Defer } + "exit" => { + // prelude.exit "immediately halts a single shot's + // execution" -- a NORMAL termination the engine's + // drain-to-completion model cannot express mid-shot yet. + // Fault with a clear message instead of stalling with a + // starved-node report. + HandlerOutcome::Fault(format!( + "prelude.exit executed at {node:?}: mid-shot halt is not \ + supported by the engine yet" + )) + } "panic" => { // An EXECUTED panic is a real runtime error on the taken // path (guppy routes bounds/borrow/arithmetic failures diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index fdf673319..8df886645 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -393,6 +393,25 @@ impl HugrEngine { return Some(ClassicalValue::Int(int_value)); } + if let Some(const_usize) = + value.get_custom_value::() + { + let usize_value = const_usize.value(); + debug!("const_value_to_classical: found ConstUsize with value {usize_value}"); + return Some(ClassicalValue::UInt(usize_value)); + } + + if value + .get_custom_value::() + .is_some() + { + // Error constants feed prelude.panic (which faults before + // reading its input) -- an opaque token keeps the LoadConstant + // from deferring forever and stalling instead of panicking. + debug!("const_value_to_classical: found ConstError"); + return Some(ClassicalValue::Tuple(vec![])); + } + if let Some(const_f64) = value.get_custom_value::() { let float_value = const_f64.value(); debug!("const_value_to_classical: found ConstF64 with value {float_value}"); diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index 6c076955e..d0b914652 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -318,9 +318,11 @@ pub enum ClassicalValue { Future(FutureId), /// Rotation angle (in half-turns, i.e., multiples of pi) Rotation(f64), - /// A first-class function value: the `FuncDefn` it references - /// (produced by `LoadFunction`, consumed by higher-order ops like scan). - FuncRef(Node), + /// A first-class function value: the `FuncDefn` it references and the + /// type args instantiating it (produced by `LoadFunction`, consumed by + /// higher-order ops like scan -- generic scanned functions resolve + /// type-level naturals through these, symmetric with Call frames). + FuncRef(Node, Vec), /// RNG context handle RngContext(RngContextId), /// Qubit reference (for storing qubits in arrays) @@ -347,7 +349,7 @@ impl ClassicalValue { | Self::Rotation(_) | Self::RngContext(_) | Self::QubitRef(_) - | Self::FuncRef(_) + | Self::FuncRef(..) | Self::Borrowed => None, } } @@ -371,7 +373,7 @@ impl ClassicalValue { | Self::Rotation(_) | Self::RngContext(_) | Self::QubitRef(_) - | Self::FuncRef(_) + | Self::FuncRef(..) | Self::Borrowed => None, } } @@ -394,7 +396,7 @@ impl ClassicalValue { | Self::Rotation(_) | Self::RngContext(_) | Self::QubitRef(_) - | Self::FuncRef(_) + | Self::FuncRef(..) | Self::Borrowed => None, } } @@ -417,7 +419,7 @@ impl ClassicalValue { | Self::Rotation(_) | Self::RngContext(_) | Self::QubitRef(_) - | Self::FuncRef(_) + | Self::FuncRef(..) | Self::Borrowed => None, } } @@ -440,7 +442,7 @@ impl ClassicalValue { | Self::Future(_) | Self::RngContext(_) | Self::QubitRef(_) - | Self::FuncRef(_) + | Self::FuncRef(..) | Self::Borrowed => None, } } @@ -804,6 +806,9 @@ pub struct ActiveScanInfo { pub results: Vec, /// Current accumulator values (scan signature `*A`). pub accs: Vec, + /// Type args instantiating the scanned function (from its + /// `LoadFunction`), for resolving type-level variables in the body. + pub type_args: Vec, /// For a scanned function with a plain DATAFLOW body (no CFG): the /// body ops whose completion finishes one element. Empty for /// CFG-bodied functions (their frame completes through From 77ca46efb989eadad9f827922618f83c7c3a0991 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 22:24:15 -0600 Subject: [PATCH 370/388] Panel-2 batch 5: de-vacuum round 2 (phase/rotation/boolean/feedback/conditional/QFT/Hadamard tests assert real distributions, backend-unavailable skips honestly, Bell/Grover pins exact), the two skip-marker constructs un-skipped (RUS while loop reports via result(), deep circuit passes), interference test pins the correct 50/50 physics, and the Python-vs-HUGR divergent regimes (negative divisors, logical shifts, past-width shifts) pinned with spec-derived expectations --- .../test_comprehensive_guppy_features.py | 61 +++++++----- .../guppy/test_extended_guppy_features.py | 92 +++++++++---------- .../tests/guppy/test_program_fuzz.py | 19 ++-- .../tests/guppy/test_semantic_sweep.py | 46 ++++++++++ 4 files changed, 138 insertions(+), 80 deletions(-) diff --git a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py index b21aab056..368366472 100644 --- a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py @@ -140,14 +140,12 @@ def hadamard_test() -> bool: hadamard_test, shots=50, ) - assert results.get("hugr_llvm", {}).get( - "success", - False, - ), f"HUGR-LLVM failed: {results.get('hugr_llvm', {}).get('error')}" - # PHIR might not be available on all systems - if "phir" in results: - # print(f"PHIR result: {results['phir']}") - pass + rows = results["hugr_llvm"]["result"]["results"] + assert len(rows) == 50 + # H|0> is an exact 50/50 superposition: both outcomes must appear + # with a near-even split. + ones = sum(int(r) for r in rows) + assert 12 <= ones <= 38, f"H distribution off: {ones}/50 ones" def test_pauli_gates(self, pipeline_tester: GuppyPipelineTest) -> None: """Test all Pauli gates (X, Y, Z).""" @@ -227,7 +225,9 @@ def bell_state() -> tuple[bool, bool]: decoded_measurements = decode_integer_results(measurements, 2) correlated = sum(1 for (a, b) in decoded_measurements if a == b) correlation_rate = correlated / len(decoded_measurements) - assert correlation_rate > 0.8, f"Bell state should be highly correlated, got {correlation_rate:.2%}" + assert ( + correlation_rate == 1.0 + ), f"Bell correlation is EXACT on a noiseless statevector, got {correlation_rate:.2%}" # Verify PHIR pipeline results if available if results.get("phir", {}).get("success"): @@ -236,7 +236,9 @@ def bell_state() -> tuple[bool, bool]: decoded_measurements = decode_integer_results(measurements, 2) correlated = sum(1 for (a, b) in decoded_measurements if a == b) correlation_rate = correlated / len(decoded_measurements) - assert correlation_rate > 0.8, f"PHIR Bell state should be highly correlated, got {correlation_rate:.2%}" + assert ( + correlation_rate == 1.0 + ), f"PHIR Bell correlation is EXACT on a noiseless statevector, got {correlation_rate:.2%}" # ============================================================================ @@ -264,17 +266,21 @@ def boolean_or_test() -> bool: result = measure(q) # Will be True return result or False - # Test AND operation - pipeline_tester.test_function_on_both_pipelines( + # AND: measure(|0>) is deterministically 0 + results_and = pipeline_tester.test_function_on_both_pipelines( boolean_and_test, shots=10, ) + rows = results_and["hugr_llvm"]["result"]["results"] + assert [int(r) for r in rows] == [0] * 10, f"AND path measurements: {rows}" - # Test OR operation - pipeline_tester.test_function_on_both_pipelines( + # OR: measure(X|0>) is deterministically 1 + results_or = pipeline_tester.test_function_on_both_pipelines( boolean_or_test, shots=10, ) + rows = results_or["hugr_llvm"]["result"]["results"] + assert [int(r) for r in rows] == [1] * 10, f"OR path measurements: {rows}" def test_classical_arithmetic(self, pipeline_tester: GuppyPipelineTest) -> None: """Pure-classical programs surface the entrypoint's return value @@ -352,10 +358,17 @@ def feedback_circuit() -> tuple[bool, bool]: return result1, measure(q2) - pipeline_tester.test_function_on_both_pipelines( + results = pipeline_tester.test_function_on_both_pipelines( feedback_circuit, shots=50, ) + rows = results["hugr_llvm"]["result"]["results"] + # The correction makes q2 EQUAL q1 on every shot -- this is the + # engine's core measurement-feedback path. + assert all(int(a) == int(b) for (a, b) in rows), f"feedback broke: {rows[:10]}" + # And H gives both branches: both outcomes must appear over 50 shots. + ones = sum(int(a) for (a, _) in rows) + assert 10 <= ones <= 40, f"H distribution off: {ones}/50 ones" # ============================================================================ @@ -405,12 +418,16 @@ def qft_2qubit() -> tuple[bool, bool]: results = pipeline_tester.test_function_on_both_pipelines(qft_2qubit, shots=100) - if results.get("hugr_llvm", {}).get("success"): - # QFT of |01⟩ should give a specific pattern - measurements = results["hugr_llvm"]["result"]["results"] - # print(f"QFT results distribution: {set(measurements)}") - # The test passes if we get results without errors - assert len(measurements) == 100 + # QFT of a computational basis state measured in the computational + # basis is UNIFORM over all four outcomes (phases are invisible + # here, so this pins the H layers and plumbing, not the CRZ angle). + measurements = results["hugr_llvm"]["result"]["results"] + assert len(measurements) == 100 + from collections import Counter + + counts = Counter(tuple(int(v) for v in row) for row in measurements) + assert set(counts) == {(0, 0), (0, 1), (1, 0), (1, 1)}, f"missing outcomes: {counts}" + assert all(10 <= c <= 45 for c in counts.values()), f"non-uniform: {counts}" def test_deutsch_josza_algorithm(self, pipeline_tester: GuppyPipelineTest) -> None: """Test Deutsch-Josza algorithm for 2-bit function.""" @@ -554,4 +571,4 @@ def grover_2qubit() -> tuple[bool, bool]: decoded_measurements = decode_integer_results(measurements, 2) # Should find |11⟩ with high probability after 1 Grover iteration found = sum(1 for (a, b) in decoded_measurements if a and b) - assert found > 70, f"Grover should amplify |11⟩, got {found}/100" + assert found == 100, f"2-qubit Grover with one iteration is deterministic |11>, got {found}/100" diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index d4099721d..a21996342 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -55,11 +55,9 @@ def test_function( ) -> dict[str, Any]: """Test a Guppy function and return results.""" if not self.backends.get("rust_backend", False): - return { - "success": False, - "error": "Rust backend not available", - "result": None, - } + # Skipping is honest; returning success=False used to + # green-pass every assertion gated behind `if success`. + pytest.skip("Rust backend not available") try: # Use sim() API @@ -130,9 +128,14 @@ def phase_gate_test() -> tuple[bool, bool]: return r1, r2 result = tester.test_function(phase_gate_test, shots=100) - if result["success"]: - pass - # print(f"Phase gate test results: {result['result']['results'][:10]}...") + rows = result["result"]["results"] + assert len(rows) == 100 + # H;S;H and H;T;T;H both give an exact 50/50 distribution; a broken + # S or T (e.g. silently applied as identity) gives all-zeros. + r1_ones = sum(r[0] for r in rows) + r2_ones = sum(r[1] for r in rows) + assert 30 <= r1_ones <= 70, f"S-gate distribution off: {r1_ones}/100 ones" + assert 30 <= r2_ones <= 70, f"T^2-gate distribution off: {r2_ones}/100 ones" def test_phase_gate_inverses(self, tester: ExtendedGuppyTester) -> None: """Test S† and T† (inverse phase gates).""" @@ -179,11 +182,16 @@ def rotation_test() -> tuple[bool, bool]: return r1, r2 result = tester.test_function(rotation_test, shots=100) - if result["success"]: - # RY(pi/2) on |0⟩ creates equal superposition, so roughly 50/50 distribution - # RZ just adds phase, results will vary - result["result"]["results"] - # print(f"Rotation gate test results (first 10): {results[:10]}") + rows = result["result"]["results"] + assert len(rows) == 100 + # RY(pi/2)|0> is an equal superposition: both outcomes must appear + # with a near-even split (a dropped rotation gives all-zeros). + r1_ones = sum(r[0] for r in rows) + assert 30 <= r1_ones <= 70, f"RY(pi/2) distribution off: {r1_ones}/100 ones" + # H;RZ(pi/4);H gives P(1) = sin^2(pi/8) ~ 0.146: assert the skewed + # but non-degenerate distribution. + r2_ones = sum(r[1] for r in rows) + assert 3 <= r2_ones <= 35, f"RZ(pi/4) distribution off: {r2_ones}/100 ones" # ============================================================================ @@ -386,40 +394,38 @@ def nested_loop_test() -> int: expected_pattern, ), f"Pattern mismatch: {shot_result}" - @pytest.mark.skip( - reason="While loops with compound conditions need more work in HUGR interpreter", - ) - def test_while_with_quantum(self, tester: ExtendedGuppyTester) -> None: - """Test while loops with quantum operations.""" + def test_while_with_quantum(self) -> None: + """A repeat-until-success while loop with a compound condition. + + The measurement count varies per shot, so raw measurements cannot + aggregate into rectangular results -- report through result(), + which is exactly what the engine's captured-result path is for. + """ + from guppylang.std.builtins import result @guppy - def while_quantum_test() -> int: + def while_quantum_test() -> None: count = 0 tries = 0 - # Keep trying until we get a |1⟩ measurement + # Keep trying until we get a |1> measurement (max 10 tries) while count == 0 and tries < 10: q = qubit() - h(q) # 50% chance of |1⟩ + h(q) # 50% chance of |1> if measure(q): count = 1 tries += 1 - return tries + result("tries", tries) - result = tester.test_function(while_quantum_test, shots=100) - if result["success"]: - # Function returns measurements, not the tries count - # Results are tuples of measurements (number varies per shot based on loop iterations) - # We can count the number of measurements to approximate tries, but can't directly verify the int return - # Just verify that we got measurement results - measurements = result["result"]["results"] - assert len(measurements) == 100, f"Expected 100 shots, got {len(measurements)}" - # Each shot should have at least one measurement (at least 1 try) - for shot_measurements in measurements: - if isinstance(shot_measurements, tuple): - assert len(shot_measurements) >= 1, "Should have at least 1 measurement per shot" - # Can't verify avg_tries since we don't get the integer return value + results = sim(Guppy(while_quantum_test)).qubits(2).quantum(state_vector()).seed(11).run(100).to_dict() + tries = [int(t) for t in results["tries"]] + assert len(tries) == 100 + # Geometric with p=1/2 capped at 10: every value in [1, 10], and + # the mean is ~2 (assert a generous but non-vacuous band). + assert all(1 <= t <= 10 for t in tries), f"out-of-range tries: {sorted(set(tries))}" + mean = sum(tries) / len(tries) + assert 1.5 < mean < 3.0, f"RUS try distribution off: mean={mean}" def test_early_return(self, tester: ExtendedGuppyTester) -> None: """Test early return from functions.""" @@ -621,15 +627,10 @@ def quantum_interference_test() -> bool: ones = sum(measurements_interference) prob_one = ones / len(measurements_interference) - # The S gate behavior might vary by implementation - # If S gate is not working as expected, we might get 50/50 - # For now, just verify we get measurements - assert 0 <= prob_one <= 1, f"Probability should be between 0 and 1, got {prob_one:.3f}" - - # Note: In ideal case, H-S-H on |0⟩ should give |0⟩ with high probability - # But current implementation seems to give 50/50, which suggests - # either S gate implementation differs or there's a phase issue - # This would need deeper investigation into the simulator's S gate + # H;S;H on |0> is EXACTLY 50/50: S maps |+> to (|0> + i|1>)/sqrt(2), + # and the final H yields P(1) = |1 - i|^2 / 4 = 1/2. (An S applied + # as identity would give all-zeros; as Z, all-ones.) + assert 0.4 <= prob_one <= 0.6, f"H;S;H must be 50/50, got {prob_one:.3f}" # ============================================================================ @@ -725,9 +726,6 @@ def many_qubits_test() -> int: avg = sum(sum(row) for row in rows) / len(rows) assert 3 < avg < 7, f"Many qubit statistics off, avg={avg}" - @pytest.mark.skip( - reason="For-loop in function body returns empty results in HUGR interpreter", - ) def test_deep_circuit(self, tester: ExtendedGuppyTester) -> None: """Test deep circuit with many gates.""" diff --git a/python/quantum-pecos/tests/guppy/test_program_fuzz.py b/python/quantum-pecos/tests/guppy/test_program_fuzz.py index 503bb0206..a3439a109 100644 --- a/python/quantum-pecos/tests/guppy/test_program_fuzz.py +++ b/python/quantum-pecos/tests/guppy/test_program_fuzz.py @@ -21,9 +21,13 @@ mis-executed loop, a dropped branch -- fails loudly as a 0 measurement. Generated programs stay in Euclidean-safe territory (every reduction uses a -positive literal modulus, divisions use positive literal divisors) so -Python's floor semantics coincide exactly with the HUGR spec's Euclidean -semantics, and magnitudes stay far below 64-bit wrap. +positive literal modulus, divisions use positive literal divisors, shift +operands are pre-masked non-negative) BY DESIGN: plain Python is only a +valid reference where its semantics coincide with HUGR's. The regimes +where they diverge -- negative divisors (floor vs Euclidean with an +UNSIGNED divisor bit pattern), arithmetic-vs-logical right shift on +negative operands, and shifts past the width -- are pinned separately with +hand-derived spec expectations in test_semantic_sweep.py. """ import importlib.util @@ -162,14 +166,7 @@ def test_fuzzed_program_matches_python_reference(seed: int, tmp_path: Path) -> N ) module = _load_guppy_module(tmp_path, seed, source) - results = ( - sim(Guppy(module.fuzz_prog)) - .qubits(2) - .quantum(state_vector()) - .seed(7) - .run(2) - .to_dict() - ) + results = sim(Guppy(module.fuzz_prog)).qubits(2).quantum(state_vector()).seed(7).run(2).to_dict() raw = results["measurements"] values = [m[-1] if isinstance(m, list) else m for m in raw] assert values == [1, 1], f"seed {seed}: engine diverged from reference\n{source}" diff --git a/python/quantum-pecos/tests/guppy/test_semantic_sweep.py b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py index 0957de8a8..61923c68e 100644 --- a/python/quantum-pecos/tests/guppy/test_semantic_sweep.py +++ b/python/quantum-pecos/tests/guppy/test_semantic_sweep.py @@ -329,3 +329,49 @@ def labeled_array() -> None: results = sim(Guppy(labeled_array)).qubits(3).quantum(state_vector()).seed(7).run(2).to_dict() assert results["my_result_Report"] == [[1, 1], [1, 1]], f"keys: {sorted(results)}" + + +def test_negative_divisor_euclidean_semantics() -> None: + """Division/modulo with NEGATIVE divisors -- the regime where Python's + floor semantics and HUGR's Euclidean semantics diverge. HUGR idiv_s + takes an UNSIGNED divisor, so a negative divisor contributes its + two's-complement bit pattern (2^64 - 3 here): Euclidean q*m+r=n with + 0 <= r < m gives q=0, r=7. (Python would say 7 // -3 == -3.)""" + + @guppy + def neg_div() -> int: + a = 7 + b = -3 + return a // b + + @guppy + def neg_mod() -> int: + a = 7 + b = -3 + return a % b + + r = sim(Guppy(neg_div)).qubits(1).quantum(state_vector()).seed(1).run(2).to_dict() + assert list(r["return"]) == [0, 0], f"7 // -3 per HUGR spec: {r['return']}" + r = sim(Guppy(neg_mod)).qubits(1).quantum(state_vector()).seed(1).run(2).to_dict() + assert list(r["return"]) == [7, 7], f"7 %% -3 per HUGR spec: {r['return']}" + + +def test_logical_shift_on_negative_and_past_width() -> None: + """ishr is LOGICAL per the spec ("leftmost bits set to zero"), unlike + Python's arithmetic >>; and shifting by k >= width drops every bit.""" + + @guppy + def shift_negative() -> int: + a = -8 + return a >> 1 + + @guppy + def shift_past_width() -> int: + a = 5 + return a >> 70 + + r = sim(Guppy(shift_negative)).qubits(1).quantum(state_vector()).seed(1).run(2).to_dict() + # (-8 as u64) >> 1 = 0x7FFF_FFFF_FFFF_FFFC + assert list(r["return"]) == [0x7FFF_FFFF_FFFF_FFFC] * 2, f"logical shift: {r['return']}" + r = sim(Guppy(shift_past_width)).qubits(1).quantum(state_vector()).seed(1).run(2).to_dict() + assert list(r["return"]) == [0, 0], f"past-width shift: {r['return']}" From 864b036b04510eeb2df08dabe000f38b3fe4dea7 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 22:32:30 -0600 Subject: [PATCH 371/388] Rename the pending_bool_reads parking lot to deferred_nodes (it holds every deferred op kind, not just bool reads) and document pending_conditionals accurately --- crates/pecos-hugr/src/engine.rs | 53 ++++++++++--------- .../src/engine/control_flow/call.rs | 2 +- .../src/engine/control_flow/conditional.rs | 4 +- .../src/engine/control_flow/scan.rs | 4 +- .../src/engine/handlers/classical.rs | 32 +++++------ 5 files changed, 48 insertions(+), 47 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index fa7fae7d1..5f1d4d8bb 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -122,12 +122,16 @@ pub struct HugrEngine { pub(crate) conditionals: BTreeMap, /// Pending conditionals waiting for measurement results. - /// Maps the Conditional node to the qubit ID whose measurement determines the branch. + /// Conditionals whose control value is not yet resolvable (waiting on + /// a measurement); retried when results arrive. pub(crate) pending_conditionals: BTreeSet, - /// Pending bool.read nodes waiting for measurement results. - /// These are re-added to the work queue when measurement results arrive. - pub(crate) pending_bool_reads: BTreeSet, + /// The starved-node parking lot: every op that DEFERRED (missing or + /// unconvertible inputs) -- classical ops, bool reads, extension ops, + /// `LoadConstants`, parked scans. Re-queued by `retry_deferred_nodes` on + /// completions and measurement rounds; anything still here at + /// completion time surfaces in the stall report. + pub(crate) deferred_nodes: BTreeSet, /// Set of nodes that are inside Case nodes (children of Conditionals). /// These should not be processed until their parent Conditional is expanded. @@ -379,7 +383,7 @@ impl HugrEngine { // Clear Conditional control flow state self.pending_conditionals.clear(); - self.pending_bool_reads.clear(); + self.deferred_nodes.clear(); self.active_cases.clear(); // Clear CFG control flow state @@ -622,9 +626,9 @@ impl HugrEngine { /// Re-queue pending bool.read nodes that were waiting for measurement results. /// When a measurement result arrives, the classical value is stored and we need to /// retry any bool.read nodes that were deferred because their input wasn't ready. - fn retry_pending_bool_reads(&mut self) { + fn retry_deferred_nodes(&mut self) { // Move pending bool.reads to work queue so they can be retried - let pending: Vec<_> = std::mem::take(&mut self.pending_bool_reads) + let pending: Vec<_> = std::mem::take(&mut self.deferred_nodes) .into_iter() .collect(); @@ -1206,13 +1210,13 @@ impl HugrEngine { // stall report names this node instead of letting its // block complete around a missing constant-derived value. debug!("LoadConstant {current_node:?}: failed to load value, deferring"); - self.pending_bool_reads.insert(current_node); + self.deferred_nodes.insert(current_node); continue; } self.processed.insert(current_node); // Retry any pending ops that might now have their inputs ready - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); // A Case/block may consist of just constants feeding its // Output (e.g. a loop's continue-flag bool) -- check @@ -1245,7 +1249,7 @@ impl HugrEngine { .remove(&(current_node, port)); } // Add to pending bool reads set for retry (reusing the same mechanism) - self.pending_bool_reads.insert(current_node); + self.deferred_nodes.insert(current_node); continue; } // A zero-output op with nothing to store completes. @@ -1260,7 +1264,7 @@ impl HugrEngine { }; // Successfully resolved - remove from pending if it was there - self.pending_bool_reads.remove(¤t_node); + self.deferred_nodes.remove(¤t_node); // Store output values. A QubitRef output (e.g. an // UnpackTuple or Tag over a linear payload) is mirrored into @@ -1277,7 +1281,7 @@ impl HugrEngine { self.processed.insert(current_node); // Retry any pending ops that might now have their inputs ready - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); // Check if any pending conditionals can now be resolved self.try_resolve_pending_conditionals(); @@ -1314,7 +1318,7 @@ impl HugrEngine { self.processed.insert(current_node); // Retry any pending ops that might now have their inputs ready - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); // Check if any pending conditionals can now be resolved self.try_resolve_pending_conditionals(); @@ -1339,7 +1343,7 @@ impl HugrEngine { // Extension op couldn't be processed (input not ready) - defer it // But don't defer if it's also a quantum op (e.g., MeasureFree from tket.quantum) // - those should fall through to the quantum op handling below - self.pending_bool_reads.insert(current_node); + self.deferred_nodes.insert(current_node); continue; } // Fall through to quantum op handling @@ -1364,10 +1368,10 @@ impl HugrEngine { // qubit wire has no mapping yet (its producer has not run -- // completion of that producer re-queues this node). let Some(qubits) = self.resolve_qubits(&hugr, current_node, &op) else { - self.pending_bool_reads.insert(current_node); + self.deferred_nodes.insert(current_node); continue; }; - self.pending_bool_reads.remove(¤t_node); + self.deferred_nodes.remove(¤t_node); // Emit the gate operation if self.emit_quantum_gate(&hugr, current_node, &op, &qubits)? { @@ -1456,11 +1460,8 @@ impl HugrEngine { self.active_scans.keys().collect::>() )); } - if !self.pending_bool_reads.is_empty() { - stalled.push(format!( - "starved deferred nodes: {:?}", - self.pending_bool_reads - )); + if !self.deferred_nodes.is_empty() { + stalled.push(format!("starved deferred nodes: {:?}", self.deferred_nodes)); } if !self.pending_conditionals.is_empty() { stalled.push(format!( @@ -1852,7 +1853,7 @@ impl Default for HugrEngine { // Control flow fields (Conditional) conditionals: BTreeMap::new(), pending_conditionals: BTreeSet::new(), - pending_bool_reads: BTreeSet::new(), + deferred_nodes: BTreeSet::new(), nodes_inside_cases: BTreeSet::new(), active_cases: BTreeMap::new(), // Control flow fields (CFG) @@ -1997,7 +1998,7 @@ impl ClassicalEngine for HugrEngine { } // Retry any bool.read nodes that were waiting for measurement results - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); Ok(()) } @@ -2550,9 +2551,9 @@ mod tests { ); assert!(engine.active_tailloops.is_empty()); assert!( - engine.pending_bool_reads.is_empty(), + engine.deferred_nodes.is_empty(), "starved nodes: {:?}", - engine.pending_bool_reads + engine.deferred_nodes ); } @@ -3122,7 +3123,7 @@ mod tests { assert!(engine.active_cases.is_empty()); assert!(engine.active_calls.is_empty()); assert!(engine.active_tailloops.is_empty()); - assert!(engine.pending_bool_reads.is_empty()); + assert!(engine.deferred_nodes.is_empty()); } #[test] diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index 0411abd00..891af483f 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -195,7 +195,7 @@ impl HugrEngine { // A scan parked on this frame retries through the pending // mechanism -- wake the parked set now that the frame freed. - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); // Check if there are pending calls to this FuncDefn if let Some(pending) = self.pending_func_calls.get_mut(&func_defn_node) diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index e95c091ee..d9e1bdc56 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -211,7 +211,7 @@ impl HugrEngine { self.try_resolve_pending_tailloops(); self.try_resolve_pending_cfg_branches(); self.queue_ready_successors(hugr, cond_node); - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); } } @@ -623,7 +623,7 @@ impl HugrEngine { self.try_resolve_pending_tailloops(); self.try_resolve_pending_cfg_branches(); self.queue_ready_successors(hugr, cond_node); - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); } entry_nodes diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs index 6e45e9109..0fffe8791 100644 --- a/crates/pecos-hugr/src/engine/control_flow/scan.rs +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -340,7 +340,7 @@ impl HugrEngine { let state = self.active_scans.remove(&scan_node).expect("present above"); self.store_scan_outputs(&state); self.processed.insert(scan_node); - self.pending_bool_reads.remove(&scan_node); + self.deferred_nodes.remove(&scan_node); debug!( "scan {scan_node:?}: complete with {} results", state.results.len() @@ -353,7 +353,7 @@ impl HugrEngine { self.try_resolve_pending_tailloops(); self.try_resolve_pending_cfg_branches(); self.queue_ready_successors(hugr, scan_node); - self.retry_pending_bool_reads(); + self.retry_deferred_nodes(); // The frame is free now: wake any Call that parked waiting for it // (mirrors complete_func_call_if_needed). diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index 4dc77f93b..e43a92968 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -612,10 +612,10 @@ impl HugrEngine { .and_then(|v| v.as_bool()); let (Some(a), Some(b)) = (a, b) else { debug!("tket.bool.and at {node:?}: deferring - input not ready"); - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; - self.pending_bool_reads.remove(&node); + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a && b)); @@ -631,10 +631,10 @@ impl HugrEngine { .and_then(|v| v.as_bool()); let (Some(a), Some(b)) = (a, b) else { debug!("tket.bool.or at {node:?}: deferring - input not ready"); - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; - self.pending_bool_reads.remove(&node); + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a || b)); @@ -650,10 +650,10 @@ impl HugrEngine { .and_then(|v| v.as_bool()); let (Some(a), Some(b)) = (a, b) else { debug!("tket.bool.xor at {node:?}: deferring - input not ready"); - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; - self.pending_bool_reads.remove(&node); + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a ^ b)); @@ -666,10 +666,10 @@ impl HugrEngine { .and_then(|v| v.as_bool()) else { debug!("tket.bool.not at {node:?}: deferring - input not ready"); - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; - self.pending_bool_reads.remove(&node); + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(!a)); @@ -685,10 +685,10 @@ impl HugrEngine { .and_then(|v| v.as_bool()); let (Some(a), Some(b)) = (a, b) else { debug!("tket.bool.eq at {node:?}: deferring - input not ready"); - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; - self.pending_bool_reads.remove(&node); + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(a == b)); @@ -705,7 +705,7 @@ impl HugrEngine { let Some(input_val) = input_value else { debug!("tket.bool.make_opaque at {node:?}: deferring - input not ready"); // Track this node so it can be retried when input becomes available - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; @@ -713,12 +713,12 @@ impl HugrEngine { // missing one: fabricating `false` commits a wrong value. let Some(value) = input_val.as_bool() else { debug!("tket.bool.make_opaque at {node:?}: deferring - input not a bool"); - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; // Successfully resolved - remove from pending if it was there - self.pending_bool_reads.remove(&node); + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(value)); @@ -737,18 +737,18 @@ impl HugrEngine { let Some(input_val) = input_value else { debug!("tket.bool.read at {node:?}: deferring - input not ready"); // Track this node so it can be retried when measurement results arrive - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; let Some(value) = input_val.as_bool() else { debug!("tket.bool.read at {node:?}: deferring - input not a bool"); - self.pending_bool_reads.insert(node); + self.deferred_nodes.insert(node); return HandlerOutcome::Defer; }; // Successfully resolved - remove from pending if it was there - self.pending_bool_reads.remove(&node); + self.deferred_nodes.remove(&node); self.wire_state .classical_values .insert((node, 0), ClassicalValue::Bool(value)); From b16f9f0ab2de903147b96fdec0762e3715084146 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 23:17:16 -0600 Subject: [PATCH 372/388] Performance: the work queue gains a membership mirror (O(log n) contains, dedup on push) and the loaded Hugr lives behind an Arc so per-round and per-wave handles stop deep-cloning the graph -- guppy tree wall time drops ~60% --- crates/pecos-hugr/src/engine.rs | 38 +++--- crates/pecos-hugr/src/engine/activation.rs | 2 +- .../src/engine/control_flow/call.rs | 2 +- .../pecos-hugr/src/engine/control_flow/cfg.rs | 4 +- .../src/engine/control_flow/conditional.rs | 2 +- .../src/engine/control_flow/scan.rs | 4 +- .../src/engine/control_flow/tailloop.rs | 2 +- crates/pecos-hugr/src/engine/work_queue.rs | 116 ++++++++++++++++++ 8 files changed, 145 insertions(+), 25 deletions(-) create mode 100644 crates/pecos-hugr/src/engine/work_queue.rs diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 5f1d4d8bb..904fad12a 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -29,6 +29,7 @@ mod handlers; use handlers::{ClassicalOutcome, HandlerOutcome}; mod propagation; pub(crate) mod types; +mod work_queue; use std::any::Any; use std::collections::{BTreeMap, BTreeSet, VecDeque}; @@ -76,7 +77,10 @@ use analysis::{ /// 3. Operations from the selected branch are processed pub struct HugrEngine { /// The HUGR program being executed. - pub(crate) hugr: Option, + /// The loaded program, shared behind an Arc: the main loop and every + /// resolution wave take a handle per round, and a deep graph clone + /// there cost O(program) per measurement round. + pub(crate) hugr: Option>, /// Extracted quantum operations indexed by node. pub(crate) quantum_ops: BTreeMap, @@ -85,7 +89,7 @@ pub struct HugrEngine { pub(crate) classical_ops: BTreeMap, /// Work queue for topological traversal. - pub(crate) work_queue: VecDeque, + pub(crate) work_queue: work_queue::WorkQueue, /// Set of processed nodes. pub(crate) processed: BTreeSet, @@ -363,7 +367,7 @@ impl HugrEngine { self.classical_ops.len() ); - self.hugr = Some(hugr); + self.hugr = Some(std::sync::Arc::new(hugr)); self.reset_state(); } @@ -439,7 +443,7 @@ impl HugrEngine { // (but skip nodes inside cases or CFG blocks) for node in self.quantum_ops.keys() { if !should_skip(node) - && !self.work_queue.contains(node) + && !self.work_queue.contains(*node) && all_predecessors_ready( hugr, *node, @@ -458,7 +462,7 @@ impl HugrEngine { // (but skip classical ops inside cases, CFG blocks, etc.) for node in self.classical_ops.keys() { if !should_skip(node) - && !self.work_queue.contains(node) + && !self.work_queue.contains(*node) && all_predecessors_ready( hugr, *node, @@ -477,7 +481,7 @@ impl HugrEngine { // (but skip Conditionals inside FuncDefn bodies or CFG blocks) for node in self.conditionals.keys() { if !should_skip(node) - && !self.work_queue.contains(node) + && !self.work_queue.contains(*node) && all_predecessors_ready( hugr, *node, @@ -496,7 +500,7 @@ impl HugrEngine { // (but skip CFGs inside FuncDefn bodies - they should only be activated when called) for node in self.cfgs.keys() { if !should_skip(node) - && !self.work_queue.contains(node) + && !self.work_queue.contains(*node) && all_predecessors_ready( hugr, *node, @@ -515,7 +519,7 @@ impl HugrEngine { // (but skip Calls inside FuncDefn bodies or CFG blocks) for node in self.call_targets.keys() { if !should_skip(node) - && !self.work_queue.contains(node) + && !self.work_queue.contains(*node) && all_predecessors_ready( hugr, *node, @@ -536,7 +540,7 @@ impl HugrEngine { let op = hugr.get_optype(node); if matches!(op, OpType::LoadConstant(_)) && !should_skip(&node) - && !self.work_queue.contains(&node) + && !self.work_queue.contains(node) { self.work_queue.push_back(node); } @@ -546,7 +550,7 @@ impl HugrEngine { // (but skip TailLoops inside FuncDefn bodies, CFG blocks, etc.) for node in self.tailloops.keys() { if !should_skip(node) - && !self.work_queue.contains(node) + && !self.work_queue.contains(*node) && all_predecessors_ready( hugr, *node, @@ -633,7 +637,7 @@ impl HugrEngine { .collect(); for node in pending { - if !self.processed.contains(&node) && !self.work_queue.contains(&node) { + if !self.processed.contains(&node) && !self.work_queue.contains(node) { self.work_queue.push_back(node); } } @@ -723,7 +727,7 @@ impl HugrEngine { // Expand the selected branch and add its entry nodes to the queue let entry_nodes = self.expand_conditional(&hugr, current_node, branch_index); for entry_node in entry_nodes { - if !self.work_queue.contains(&entry_node) { + if !self.work_queue.contains(entry_node) { self.work_queue.push_back(entry_node); } } @@ -1118,7 +1122,7 @@ impl HugrEngine { self.run_activation(&hugr, &act); // Add the CFG to the work queue to be processed - if !self.work_queue.contains(&cfg_node) { + if !self.work_queue.contains(cfg_node) { self.work_queue.push_front(cfg_node); } // Don't mark Call as processed yet - wait for the @@ -1802,7 +1806,7 @@ impl HugrEngine { if (is_relevant || is_extension) && !inside_control_flow && !self.processed.contains(&succ_node) - && !self.work_queue.contains(&succ_node) + && !self.work_queue.contains(succ_node) && all_predecessors_ready( hugr, succ_node, @@ -1818,7 +1822,7 @@ impl HugrEngine { debug!( "queue_ready_successors({node:?}): skipped {succ_node:?} gated={inside_control_flow} processed={} queued={} ready={}", self.processed.contains(&succ_node), - self.work_queue.contains(&succ_node), + self.work_queue.contains(succ_node), all_predecessors_ready( hugr, succ_node, @@ -1840,7 +1844,7 @@ impl Default for HugrEngine { hugr: None, quantum_ops: BTreeMap::new(), classical_ops: BTreeMap::new(), - work_queue: VecDeque::new(), + work_queue: work_queue::WorkQueue::new(), processed: BTreeSet::new(), active_scans: BTreeMap::new(), return_values: Vec::new(), @@ -3018,7 +3022,7 @@ mod tests { "dead function body must be gated" ); assert!( - !engine.work_queue.contains(&dead_load), + !engine.work_queue.contains(dead_load), "dead function body must not be queued" ); } diff --git a/crates/pecos-hugr/src/engine/activation.rs b/crates/pecos-hugr/src/engine/activation.rs index d15d50a05..1be788e4a 100644 --- a/crates/pecos-hugr/src/engine/activation.rs +++ b/crates/pecos-hugr/src/engine/activation.rs @@ -157,7 +157,7 @@ impl HugrEngine { // their readiness check here are queued later by // queue_ready_successors when their producers complete. for &(node, policy) in &act.queue { - if self.work_queue.contains(&node) || self.processed.contains(&node) { + if self.work_queue.contains(node) || self.processed.contains(&node) { continue; } if policy == QueuePolicy::IfReady diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index 891af483f..70dc22ca2 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -206,7 +206,7 @@ impl HugrEngine { ); // Add the pending call to the front of the work queue // so it gets processed next - if !self.work_queue.contains(&next_call) { + if !self.work_queue.contains(next_call) { self.work_queue.push_front(next_call); } } diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 7db857358..d92b3d207 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -698,12 +698,12 @@ impl HugrEngine { is_relevant, is_extension, self.processed.contains(&succ_node), - self.work_queue.contains(&succ_node) + self.work_queue.contains(succ_node) ); if (is_relevant || is_extension) && !self.processed.contains(&succ_node) - && !self.work_queue.contains(&succ_node) + && !self.work_queue.contains(succ_node) && all_predecessors_ready( hugr, succ_node, diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index d9e1bdc56..da765540a 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -140,7 +140,7 @@ impl HugrEngine { let entry_nodes = self.expand_conditional(&hugr, cond_node, branch_index); let num_entry_nodes = entry_nodes.len(); for entry_node in entry_nodes { - if !self.work_queue.contains(&entry_node) && !self.processed.contains(&entry_node) { + if !self.work_queue.contains(entry_node) && !self.processed.contains(&entry_node) { self.work_queue.push_back(entry_node); } } diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs index 0fffe8791..181b41c9b 100644 --- a/crates/pecos-hugr/src/engine/control_flow/scan.rs +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -199,7 +199,7 @@ impl HugrEngine { if let Some(cfg_node) = func_info.cfg_node { debug!("scan {scan_node:?}: launching frame CFG {cfg_node:?}"); - if !self.work_queue.contains(&cfg_node) { + if !self.work_queue.contains(cfg_node) { self.work_queue.push_front(cfg_node); } false @@ -364,7 +364,7 @@ impl HugrEngine { "FuncDefn {:?} free after scan: starting pending Call {next_call:?}", state.func_defn_node ); - if !self.work_queue.contains(&next_call) { + if !self.work_queue.contains(next_call) { self.work_queue.push_front(next_call); } } diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 76d4716be..73da001fc 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -692,7 +692,7 @@ impl HugrEngine { // Add to pending self.pending_tailloop_control.insert(tailloop_node); // Re-add to work queue for resolution after measurements - if !self.work_queue.contains(&tailloop_node) { + if !self.work_queue.contains(tailloop_node) { self.work_queue.push_back(tailloop_node); } } diff --git a/crates/pecos-hugr/src/engine/work_queue.rs b/crates/pecos-hugr/src/engine/work_queue.rs new file mode 100644 index 000000000..eacb5dabd --- /dev/null +++ b/crates/pecos-hugr/src/engine/work_queue.rs @@ -0,0 +1,116 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! The engine's work queue: FIFO with a membership mirror. +//! +//! The queue is consulted with `contains` on every activation and retry +//! wave (dozens of sites); a raw `VecDeque` made each of those an O(n) +//! scan, quadratic-shaped across a retry storm. The membership set makes +//! them O(log n) and gives dedup on push for free: a node is never queued +//! twice, matching the `if !contains { push }` guards every call site +//! already carried. + +use std::collections::{BTreeSet, VecDeque}; + +use tket::hugr::Node; + +/// FIFO node queue with O(log n) membership and dedup on push. +#[derive(Debug, Default, Clone)] +pub(crate) struct WorkQueue { + queue: VecDeque, + members: BTreeSet, +} + +impl WorkQueue { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Enqueue at the back; a node already queued stays where it is. + pub(crate) fn push_back(&mut self, node: Node) { + if self.members.insert(node) { + self.queue.push_back(node); + } + } + + /// Enqueue at the front (priority); a node already queued stays where + /// it is. + pub(crate) fn push_front(&mut self, node: Node) { + if self.members.insert(node) { + self.queue.push_front(node); + } + } + + pub(crate) fn pop_front(&mut self) -> Option { + let node = self.queue.pop_front(); + if let Some(node) = node { + self.members.remove(&node); + } + node + } + + pub(crate) fn contains(&self, node: Node) -> bool { + self.members.contains(&node) + } + + pub(crate) fn len(&self) -> usize { + self.queue.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.queue.is_empty() + } + + pub(crate) fn clear(&mut self) { + self.queue.clear(); + self.members.clear(); + } +} + +impl<'a> IntoIterator for &'a WorkQueue { + type Item = &'a Node; + type IntoIter = std::collections::vec_deque::Iter<'a, Node>; + + fn into_iter(self) -> Self::IntoIter { + self.queue.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tket::hugr::HugrView; + + #[test] + fn dedup_and_order() { + // Real node ids from a trivial hugr (Node is not directly + // constructible). + let hugr = tket::hugr::Hugr::default(); + let nodes: Vec = hugr.nodes().take(1).collect(); + let n1 = nodes[0]; + + let mut q = WorkQueue::new(); + q.push_back(n1); + q.push_back(n1); // dedup: stays queued once + assert_eq!(q.len(), 1); + assert!(q.contains(n1)); + assert_eq!(q.pop_front(), Some(n1)); + assert!(!q.contains(n1)); + assert!(q.is_empty()); + q.push_front(n1); + assert_eq!(q.len(), 1); + q.clear(); + assert!(q.is_empty()); + } +} From 0508621265f72040ca59f0e2148a0188ab27556e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 23:23:03 -0600 Subject: [PATCH 373/388] futures.Read produces the future's declared type: LazyMeasureLeaked futures resolve to Int (preserving leak values) instead of collapsing to Bool --- .../pecos-hugr/src/engine/handlers/futures.rs | 29 ++++++++++++++----- .../pecos-hugr/src/engine/handlers/qsystem.rs | 3 ++ crates/pecos-hugr/src/engine/types.rs | 12 ++++++-- 3 files changed, 34 insertions(+), 10 deletions(-) diff --git a/crates/pecos-hugr/src/engine/handlers/futures.rs b/crates/pecos-hugr/src/engine/handlers/futures.rs index f958f1449..767d37573 100644 --- a/crates/pecos-hugr/src/engine/handlers/futures.rs +++ b/crates/pecos-hugr/src/engine/handlers/futures.rs @@ -53,24 +53,37 @@ impl HugrEngine { debug!("futures.Read at {node:?}: unknown future {future_id}, deferring"); return HandlerOutcome::Defer; }; + // Produce the future's DECLARED type: Future reads a + // Bool; Future (LazyMeasureLeaked, 0/1/2-leaked) reads + // an Int -- a Bool there loses the leak value. + let to_value = |outcome: u32, int_valued: bool| { + if int_valued { + ClassicalValue::Int(i64::from(outcome)) + } else { + ClassicalValue::Bool(outcome != 0) + } + }; match state { - FutureState::Resolved(outcome) => { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Bool(*outcome != 0)); + FutureState::Resolved { + outcome, + int_valued, + } => { + let value = to_value(*outcome, *int_valued); + self.wire_state.classical_values.insert((node, 0), value); debug!("Read future {future_id} -> {outcome}"); HandlerOutcome::Processed } FutureState::Pending { - measurement_index, .. + measurement_index, + int_valued, + .. } => { if let Some((_, qubit)) = self.measurement_state.mappings.get(*measurement_index) && let Some(&result) = self.measurement_state.results.get(qubit) { - self.wire_state - .classical_values - .insert((node, 0), ClassicalValue::Bool(result != 0)); + let value = to_value(result, *int_valued); + self.wire_state.classical_values.insert((node, 0), value); debug!("Read future {future_id} from measurement -> {result}"); HandlerOutcome::Processed } else { diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index f769dac2b..687aa58be 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -63,6 +63,7 @@ impl HugrEngine { measurement_node: node, qubit: qubit_id, measurement_index, + int_valued: false, }, ); @@ -97,6 +98,7 @@ impl HugrEngine { measurement_node: node, qubit: qubit_id, measurement_index, + int_valued: false, }, ); @@ -128,6 +130,7 @@ impl HugrEngine { measurement_node: node, qubit: qubit_id, measurement_index, + int_valued: true, }, ); diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index d0b914652..12944f53f 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -557,9 +557,17 @@ pub enum FutureState { qubit: QubitId, /// Index in `measurement_mappings` for result retrieval. measurement_index: usize, + /// True for `Future` (`LazyMeasureLeaked`: 0/1/2-leaked); + /// false for `Future`. Read must produce the declared type. + int_valued: bool, + }, + /// The measurement result is available (same `int_valued` semantics). + Resolved { + /// The measurement outcome. + outcome: u32, + /// See [`FutureState::Pending::int_valued`]. + int_valued: bool, }, - /// The measurement result is available. - Resolved(u32), } // --- Container Type Classification --- From 768e8fd3decda0dd51b596a280bec466171e5cde Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 23:24:48 -0600 Subject: [PATCH 374/388] RandomAdvance jumps in O(log delta) both directions: xorshift64 is linear over GF(2), so advancement is a 64x64 bit-matrix power and backtracking uses the 2^64-1 period -- the step ceiling and backtracking fault are gone, pinned by jump-vs-stepping and round-trip tests --- crates/pecos-hugr/src/engine.rs | 52 +++++++++++++++--- .../pecos-hugr/src/engine/handlers/qsystem.rs | 29 +++------- crates/pecos-hugr/src/engine/types.rs | 55 +++++++++++++++++++ 3 files changed, 108 insertions(+), 28 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 904fad12a..2be4ca4ca 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -2362,19 +2362,31 @@ mod tests { Some(ClassicalValue::RngContext(_)) )); - // Backtracking is unsupported by the step-based implementation and - // must FAULT (the spec allows negative delta; silently advancing - // forward instead fabricates a different stream). - seed_input(&mut engine, advance, 1, ClassicalValue::Int(-1)); - assert!(matches!( + // Advance then backtrack by the same delta must round-trip the + // stream exactly (xorshift64 jumps are exact in both directions). + let ctx_id_now = match engine.wire_state.classical_values.get(&(float, 1)) { + Some(ClassicalValue::RngContext(id)) => *id, + other => panic!("expected context after RandomFloat, got {other:?}"), + }; + let state_before = engine.extension_state.rng_contexts[&ctx_id_now].state; + seed_input(&mut engine, advance, 1, ClassicalValue::Int(1000)); + assert_eq!( engine.handle_random_op(&hugr, advance, "RandomAdvance"), - HandlerOutcome::Fault(_) - )); - seed_input(&mut engine, advance, 1, ClassicalValue::Int(2)); + HandlerOutcome::Processed + ); + assert_ne!( + engine.extension_state.rng_contexts[&ctx_id_now].state, + state_before + ); + seed_input(&mut engine, advance, 1, ClassicalValue::Int(-1000)); assert_eq!( engine.handle_random_op(&hugr, advance, "RandomAdvance"), HandlerOutcome::Processed ); + assert_eq!( + engine.extension_state.rng_contexts[&ctx_id_now].state, state_before, + "advance(+1000) then advance(-1000) must round-trip" + ); assert_eq!( engine.handle_random_op(&hugr, int, "RandomInt"), @@ -3027,6 +3039,30 @@ mod tests { ); } + /// xorshift64 jump-ahead must be EXACT: M^k over GF(2) equals k + /// sequential steps, and backward jumps invert them via the 2^64-1 + /// period. + #[test] + fn test_rng_jump_matches_stepping() { + use crate::engine::types::RngContextState; + + let mut stepped = RngContextState::new(0xDEAD_BEEF); + let mut jumped = RngContextState::new(0xDEAD_BEEF); + for _ in 0..137 { + stepped.next_u64(); + } + jumped.jump(137); + assert_eq!(stepped.state, jumped.state); + + jumped.jump_back(137); + assert_eq!(jumped.state, RngContextState::new(0xDEAD_BEEF).state); + + // Identity jump. + let before = stepped.state; + stepped.jump(0); + assert_eq!(stepped.state, before); + } + #[test] fn test_completion_audit_reports_unexecuted_container_ops() { // The reachability audit must catch a container the engine claims diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index 687aa58be..1f9f6677f 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -360,26 +360,15 @@ impl HugrEngine { }; { - const MAX_ADVANCE_STEPS: u64 = 1 << 24; - // The spec advances OR BACKTRACKS by delta; this - // step-based implementation can only go forward, and a - // huge forward delta would hang the host. Fail loud on - // both instead of silently advancing the wrong way. - if delta < 0 { - return HandlerOutcome::Fault(format!( - "RandomAdvance at {node:?}: backtracking (delta {delta}) is \ - not supported by the step-based RNG implementation" - )); - } - let steps = delta.unsigned_abs(); - if steps > MAX_ADVANCE_STEPS { - return HandlerOutcome::Fault(format!( - "RandomAdvance at {node:?}: delta {steps} exceeds the \ - step-based implementation's ceiling ({MAX_ADVANCE_STEPS})" - )); - } - for _ in 0..steps { - self.generate_random_u64(ctx_id); + // The spec advances OR BACKTRACKS by delta. xorshift64 + // is linear over GF(2), so both directions jump in + // O(log delta) -- no step loop, no ceiling. + if let Some(ctx) = self.extension_state.rng_contexts.get_mut(&ctx_id) { + if delta >= 0 { + ctx.jump(delta.unsigned_abs().into()); + } else { + ctx.jump_back(delta.unsigned_abs()); + } } self.wire_state diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index 12944f53f..fda1d5ccc 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -646,6 +646,61 @@ impl RngContextState { let result = bits as f64 / (1u64 << 53) as f64; result } + + /// Advance the generator by `steps` in O(log steps), exactly as if + /// `next_u64` had been called `steps` times. + /// + /// xorshift64 is a LINEAR map over GF(2): one step is multiplication + /// by a fixed 64x64 bit matrix, so stepping k times is the matrix + /// power M^k. Backtracking uses the generator's period 2^64 - 1 + /// (all nonzero states form one cycle): k steps back == period - k + /// steps forward. + pub fn jump(&mut self, steps: u128) { + const PERIOD: u128 = (1u128 << 64) - 1; + let steps = steps % PERIOD; + if steps == 0 { + return; + } + // Row i of the one-step matrix = transform applied to basis 1<> 7; + x ^= x << 17; + x + }); + let mat_vec = |m: &[u64; 64], v: u64| -> u64 { + let mut out = 0u64; + for (i, row) in m.iter().enumerate() { + if (v >> i) & 1 == 1 { + out ^= row; + } + } + out + }; + let mat_mul = |a: &[u64; 64], b: &[u64; 64]| -> [u64; 64] { + std::array::from_fn(|i| mat_vec(a, b[i])) + }; + // result = M^steps via binary exponentiation. + let identity: [u64; 64] = std::array::from_fn(|i| 1u64 << i); + let mut result = identity; + let mut base = step_matrix; + let mut e = steps; + while e > 0 { + if e & 1 == 1 { + result = mat_mul(&result, &base); + } + base = mat_mul(&base, &base); + e >>= 1; + } + self.state = mat_vec(&result, self.state); + } + + /// Step `steps` BACKWARD, exactly undoing that many `next_u64` calls. + pub fn jump_back(&mut self, steps: u64) { + const PERIOD: u128 = (1u128 << 64) - 1; + self.jump(PERIOD - (u128::from(steps) % PERIOD)); + } } // --- Conditional Control Flow Types --- From 8b0a00e696c1d01952b5b191072225140332987e Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sat, 4 Jul 2026 23:34:06 -0600 Subject: [PATCH 375/388] Guard quantum-op qubit ids against the simulator's capacity at batch dispatch, naming the op and capacity instead of failing opaquely inside the simulator --- crates/pecos-engines/src/quantum.rs | 32 +++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index 9039080cb..f77ae036e 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -92,6 +92,22 @@ fn process_clifford_message= sim.num_qubits() { + return Err(PecosError::Generic(format!( + "quantum op {:?} targets qubit {} but the simulator holds {} qubits \ + (dynamic allocation exceeded the configured capacity; raise the \ + builder's qubit count)", + cmd.gate_type, + q.0, + sim.num_qubits() + ))); + } + } match cmd.gate_type { // Single-qubit Clifford gates GateType::X => { @@ -309,6 +325,22 @@ fn process_general_message< let mut cmd_idx = 0; while cmd_idx < batch.len() { let cmd = &batch[cmd_idx]; + // Capacity guard: an emitted qubit id past the simulator's size + // (e.g. a program allocating per loop iteration beyond the + // configured .qubits(n)) must fail HERE with the op and the + // capacity named, not deep inside the simulator. + for q in &cmd.qubits { + if q.0 >= sim.num_qubits() { + return Err(PecosError::Generic(format!( + "quantum op {:?} targets qubit {} but the simulator holds {} qubits \ + (dynamic allocation exceeded the configured capacity; raise the \ + builder's qubit count)", + cmd.gate_type, + q.0, + sim.num_qubits() + ))); + } + } match cmd.gate_type { // Single-qubit Clifford gates GateType::X => { From a7dbca3df28635f048a93dd1b41bd2d098c3eceb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 00:21:16 -0600 Subject: [PATCH 376/388] Close the capacity-guard hole Codex round-9 found: MZ-batching lookahead now validates each consumed command's qubits, via a shared check_qubit_capacity helper on both dispatchers --- crates/pecos-engines/src/quantum.rs | 69 ++++++++++++++++------------- 1 file changed, 37 insertions(+), 32 deletions(-) diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index f77ae036e..b71315550 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -77,6 +77,29 @@ impl ChannelDispatch for DensityMatrix { /// Process a `ByteMessage` against any Clifford-capable simulator. /// +/// Capacity guard: an emitted qubit id past the simulator's size (e.g. a +/// program allocating per loop iteration beyond the configured qubit +/// count) must fail with the op and the capacity named, not deep inside +/// the simulator. Called for every dispatched command, INCLUDING commands +/// consumed by MZ-batching lookahead. +fn check_qubit_capacity( + gate_type: pecos_core::gate_type::GateType, + qubits: &[QubitId], + capacity: usize, +) -> Result<(), PecosError> { + for q in qubits { + if q.0 >= capacity { + return Err(PecosError::Generic(format!( + "quantum op {gate_type:?} targets qubit {} but the simulator holds \ + {capacity} qubits (dynamic allocation exceeded the configured \ + capacity; raise the builder's qubit count)", + q.0 + ))); + } + } + Ok(()) +} + /// Shared gate dispatch for `SparseStabEngine`, `StabilizerEngine`, etc. /// Supports Clifford gates, preparations, measurements, and Clifford rotations /// (non-Clifford angles produce an error via `CliffordRotation::try_*`). @@ -92,22 +115,7 @@ fn process_clifford_message= sim.num_qubits() { - return Err(PecosError::Generic(format!( - "quantum op {:?} targets qubit {} but the simulator holds {} qubits \ - (dynamic allocation exceeded the configured capacity; raise the \ - builder's qubit count)", - cmd.gate_type, - q.0, - sim.num_qubits() - ))); - } - } + check_qubit_capacity(cmd.gate_type, &cmd.qubits, sim.num_qubits())?; match cmd.gate_type { // Single-qubit Clifford gates GateType::X => { @@ -204,6 +212,12 @@ fn process_clifford_message= sim.num_qubits() { - return Err(PecosError::Generic(format!( - "quantum op {:?} targets qubit {} but the simulator holds {} qubits \ - (dynamic allocation exceeded the configured capacity; raise the \ - builder's qubit count)", - cmd.gate_type, - q.0, - sim.num_qubits() - ))); - } - } + check_qubit_capacity(cmd.gate_type, &cmd.qubits, sim.num_qubits())?; match cmd.gate_type { // Single-qubit Clifford gates GateType::X => { @@ -572,6 +571,12 @@ fn process_general_message< ) { cmd_idx += 1; + // Lookahead-consumed commands need the guard too. + check_qubit_capacity( + batch[cmd_idx].gate_type, + &batch[cmd_idx].qubits, + sim.num_qubits(), + )?; mz_qubits.extend_from_slice(&batch[cmd_idx].qubits); } let meas_ids = sim.mz(&mz_qubits); From 21e4e7a52c2bee773d9b2fca5b9dfa26bf323b2f Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 11:08:43 -0600 Subject: [PATCH 377/388] Fold in round-10 two-arm verification: successor Input wires clear before block-output propagation (stale-iteration values starve loudly), block/CFG payload offsets derive from type-level arity, unknown transition successors poison instead of spinning past the ceiling, extension Faults skip the completion cascades, synchronously-completed scans no longer re-park as false stalls, NewRNGContext reads the seed's canonical bit pattern, pass-through ops defer on missing inputs, DFG containers flatten explicitly, and impossible scan-launch states fault loudly --- crates/pecos-hugr/src/engine.rs | 39 +++++--- .../pecos-hugr/src/engine/control_flow/cfg.rs | 94 +++++++++++++------ .../src/engine/control_flow/scan.rs | 14 ++- .../pecos-hugr/src/engine/handlers/guppy.rs | 8 +- .../pecos-hugr/src/engine/handlers/prelude.rs | 20 +++- .../pecos-hugr/src/engine/handlers/qsystem.rs | 7 +- crates/pecos-hugr/src/engine/handlers/wasm.rs | 4 +- crates/pecos-hugr/src/engine/propagation.rs | 14 ++- 8 files changed, 146 insertions(+), 54 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 2be4ca4ca..51583008f 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -1313,10 +1313,14 @@ impl HugrEngine { let op = hugr.get_optype(current_node); let is_extension_op = op.as_extension_op().is_some(); let ext_result = self.handle_extension_op(&hugr, current_node); - if let HandlerOutcome::Fault(msg) = &ext_result { - // Poison first; the processed path below still runs so the - // node does not re-fire before the loop-top check raises it. - self.execution_error = Some(msg.clone()); + if let HandlerOutcome::Fault(msg) = ext_result { + // Poison and stop this node cold: running the completion + // cascades (retries, block/case checks, successor queueing) + // on a poisoned engine does arbitrary work that can only + // mask the fault. The loop-top check raises it next. + self.execution_error = Some(msg); + self.processed.insert(current_node); + continue; } if !matches!(ext_result, HandlerOutcome::Defer) { self.processed.insert(current_node); @@ -1343,23 +1347,32 @@ impl HugrEngine { self.queue_ready_successors(&hugr, current_node); continue; - } else if is_extension_op && !self.quantum_ops.contains_key(¤t_node) { + } else if is_extension_op + && !self.quantum_ops.contains_key(¤t_node) + && !self.processed.contains(¤t_node) + { // Extension op couldn't be processed (input not ready) - defer it // But don't defer if it's also a quantum op (e.g., MeasureFree from tket.quantum) - // - those should fall through to the quantum op handling below + // - those should fall through to the quantum op handling below. + // The processed guard matters for complete-then-Defer handlers + // (a scan whose whole fold ran synchronously): re-parking a + // completed node would read as a stall at completion time. self.deferred_nodes.insert(current_node); continue; } // Fall through to quantum op handling - // A container optype the engine cannot drive must fail loud: - // silently dropping it starves its consumers with a stall report - // naming everything except the cause. + // DFG containers execute by FLATTENING: their children are + // extracted into the global op maps at load (nodes_inside_* does + // not gate DFG interiors) and wire tracing crosses the boundary + // structurally, so the container node itself is a no-op -- + // marked processed explicitly rather than silently dropped. + // Classical values are NOT propagated onto a DFG's Input node + // (tracked follow-up); consumers of such values defer and the + // stall machinery keeps the gap loud. if matches!(hugr.get_optype(current_node), OpType::DFG(_)) { - self.execution_error = Some(format!( - "unsupported container op DFG at {current_node:?}: the engine \ - executes CFG/Conditional/TailLoop/Call containers only" - )); + debug!("DFG container {current_node:?}: flattened, marking processed"); + self.processed.insert(current_node); continue; } diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index d92b3d207..32654281b 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -242,7 +242,8 @@ impl HugrEngine { // Out-of-range tag = upstream Sum/tag propagation bug; // taking an arbitrary successor would mask it. self.execution_error = Some(format!( - "CFG {cfg_node:?} block {block_node:?}: pending branch tag {branch_idx} out of range ({} successors)", + "CFG {cfg_node:?} block {block_node:?}: pending branch tag {branch_idx} \ + out of range ({} successors)", successors.len() )); } @@ -352,7 +353,8 @@ impl HugrEngine { // bug upstream; routing to an arbitrary successor // would mask it as plausible control flow. self.execution_error = Some(format!( - "CFG {cfg_node:?} block {completed_block:?}: branch tag {branch_idx} out of range ({} successors)", + "CFG {cfg_node:?} block {completed_block:?}: branch tag {branch_idx} \ + out of range ({} successors)", successors.len() )); } @@ -388,8 +390,6 @@ impl HugrEngine { let mut from_block = from_block; let mut to_block = to_block; 'transition: loop { - let _ = &from_block; - // If to_block is the ExitBlock, complete the CFG. // ExitBlock has no operations - it's just a marker node. The from_block (a DataflowBlock) // should have already executed any result operations before this transition. @@ -434,7 +434,18 @@ impl HugrEngine { } // Activate successor block's quantum ops and Call nodes - if let Some(block_info) = cfg_info.blocks.get(&to_block) { + let Some(block_info) = cfg_info.blocks.get(&to_block) else { + // A successor that is not a known DataflowBlock (invalid + // HUGR, or a stale pending branch racing CFG completion) + // must fault: falling through here used to re-run the loop + // with unchanged state -- a 10M-hop spin at best, unbounded + // if the active entry was already gone. + self.execution_error = Some(format!( + "CFG {cfg_node:?}: successor {to_block:?} is not a known DataflowBlock" + )); + return; + }; + { self.executed_containers.insert(to_block, "DataflowBlock"); // Re-activate the block through the shared two-phase mechanism. // Stale wires clear for every child except the Input node (it @@ -749,12 +760,28 @@ impl HugrEngine { // Block Output ports: [Sum (port 0), data1, data2, ...] // For CFG blocks with branching, the successor Input ports are: // [payload from Sum (if any), other_outputs...] - // So we need to: - // 1. Check if port 0's source is a Conditional/Tag with payload - // 2. Extract payload values and map them to successor Input ports 0..payload_len-1 - // 3. Map other_outputs (port 1+) to successor Input ports payload_len+ - + // + // The payload ARITY is a TYPE-level fact: the successor's input row + // is fixed, so every branch into it carries exactly + // (successor inputs - other outputs) payload values. Deriving the + // offset from the runtime value's length mis-laid the data ports + // whenever the Sum resolved structurally (bare tag, no payload). let num_output_ports = hugr.num_inputs(from_output); + let target_num_outputs = hugr.num_outputs(to_input); + let num_data_outputs = num_output_ports.saturating_sub(1); + let expected_payload_len = target_num_outputs.saturating_sub(num_data_outputs); + + // Clear EVERY successor Input port before writing: the transition + // batch exempts the Input node from wire clearing (keep_wires) so + // fresh values survive, which means any port this propagation + // cannot source would otherwise keep the PREVIOUS iteration's + // value. Missing sources must starve loudly instead. + for port_idx in 0..target_num_outputs { + self.wire_state + .classical_values + .remove(&(to_input, port_idx)); + self.wire_state.wire_to_qubit.remove(&(to_input, port_idx)); + } // First, check if port 0 (Sum) has payload values from a Conditional let sum_port = IncomingPort::from(0); @@ -772,7 +799,7 @@ impl HugrEngine { self.wire_state.classical_values.get(&sum_wire).cloned() { payload_len = values.len(); - for (i, value) in values.into_iter().enumerate() { + for (i, value) in values.into_iter().enumerate().take(expected_payload_len) { debug!( "[TRACE] Block transition: propagated branch payload {value:?} to {to_input:?}:{i}" ); @@ -804,21 +831,17 @@ impl HugrEngine { } } - // Now map other_outputs (port 1+) to successor Input ports, offset - // by the payload length. In valid HUGR the successor's inputs are - // exactly [selected variant's row] ++ [other_outputs], so the offset - // always fits; if it does not, something upstream extracted the - // wrong variant's payload -- warn loudly rather than silently - // dropping the offset (which would overwrite the payload ports just - // written above with misaligned data). - let target_num_outputs = hugr.num_outputs(to_input); - let num_data_outputs = num_output_ports.saturating_sub(1); - if payload_len + num_data_outputs > target_num_outputs { + // Now map other_outputs (port 1+) to successor Input ports at the + // TYPE-derived offset. A runtime payload arity that disagrees with + // the type row means upstream variant extraction is suspect -- warn, + // but the offset stays type-correct either way. + if payload_len != expected_payload_len { debug!( - "WARNING: block transition {from_block:?} -> {to_block:?}: payload ({payload_len}) + data ({num_data_outputs}) exceeds target inputs ({target_num_outputs}); upstream variant extraction is suspect" + "WARNING: block transition {from_block:?} -> {to_block:?}: runtime payload \ + arity ({payload_len}) != type arity ({expected_payload_len})" ); } - let effective_payload_len = payload_len; + let effective_payload_len = expected_payload_len; debug!("[TRACE] num_data_outputs={num_data_outputs}"); debug!( "[TRACE] propagate_block_outputs: from_block={from_block:?}, to_block={to_block:?}, num_data_outputs={num_data_outputs}" @@ -941,7 +964,15 @@ impl HugrEngine { // When the Sum is built by a Tag, its inputs are the PAYLOAD carried // into the exit variant -- these become the first CFG outputs (e.g. a // called function's return value rides in the Tag payload, not on the - // data ports). + // data ports). The payload ARITY is type-level: CFG outputs minus + // the final block's data ports (a bare-tag structural resolution + // must not collapse the offset to zero). + let num_data_outputs = hugr.num_inputs(output_node).saturating_sub(1); + let expected_payload_len = hugr + .get_optype(cfg_node) + .dataflow_signature() + .map_or(0, |sig| sig.output_count()) + .saturating_sub(num_data_outputs); let mut payload_len = 0; if let Some((sum_src, sum_src_port)) = hugr.single_linked_output(output_node, IncomingPort::from(0)) @@ -955,7 +986,7 @@ impl HugrEngine { self.wire_state.classical_values.get(&sum_wire).cloned() { payload_len = values.len(); - for (i, value) in values.into_iter().enumerate() { + for (i, value) in values.into_iter().enumerate().take(expected_payload_len) { debug!("CFG {cfg_node:?} output {i}: mapped payload value {value:?}"); if let ClassicalValue::QubitRef(qubit_id) = &value { self.wire_state @@ -1000,12 +1031,17 @@ impl HugrEngine { } } - // CFG outputs after the payload correspond to the data ports. - let num_data_outputs = hugr.num_inputs(output_node).saturating_sub(1); - + // CFG outputs after the payload correspond to the data ports, at + // the TYPE-derived offset. + if payload_len != expected_payload_len { + debug!( + "WARNING: CFG {cfg_node:?} exit: runtime payload arity ({payload_len}) != \ + type arity ({expected_payload_len})" + ); + } for port_idx in 0..num_data_outputs { let block_port = IncomingPort::from(port_idx + 1); // Skip Sum port - let cfg_out_idx = payload_len + port_idx; + let cfg_out_idx = expected_payload_len + port_idx; if let Some((src_node, src_port)) = hugr.single_linked_output(output_node, block_port) { let src_wire = (src_node, src_port.index()); diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs index 181b41c9b..303f9bd8b 100644 --- a/crates/pecos-hugr/src/engine/control_flow/scan.rs +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -113,8 +113,9 @@ impl HugrEngine { self.advance_scan(hugr, node); } // Like a Call, the scan node stays unprocessed while its frame - // runs; completion marks it (advance_scan may already have, in - // which case the processed check absorbs this Defer harmlessly). + // runs; completion marks it. When advance_scan already completed + // the whole fold synchronously, the dispatcher's processed guard + // prevents this Defer from re-parking a finished node. HandlerOutcome::Defer } @@ -129,11 +130,20 @@ impl HugrEngine { element: ClassicalValue, ) -> bool { let Some(state) = self.active_scans.get(&scan_node) else { + // Unreachable from current call sites; if ever reached, a + // silent false would impersonate a launched frame whose + // completion never comes. + self.execution_error = Some(format!( + "scan {scan_node:?}: launch without an active scan state" + )); return false; }; let func_defn_node = state.func_defn_node; let accs = state.accs.clone(); let Some(func_info) = self.func_defns.get(&func_defn_node).cloned() else { + self.execution_error = Some(format!( + "scan {scan_node:?}: FuncDefn {func_defn_node:?} vanished mid-fold" + )); return false; }; diff --git a/crates/pecos-hugr/src/engine/handlers/guppy.rs b/crates/pecos-hugr/src/engine/handlers/guppy.rs index de6914e11..79119236d 100644 --- a/crates/pecos-hugr/src/engine/handlers/guppy.rs +++ b/crates/pecos-hugr/src/engine/handlers/guppy.rs @@ -61,14 +61,18 @@ impl HugrEngine { // Log a warning but allow execution to continue debug!("guppylang.unsupported at {node:?} - operation not supported"); // Pass through any inputs to outputs - self.propagate_all_inputs(hugr, node); + if !self.propagate_all_inputs(hugr, node) { + return HandlerOutcome::Defer; + } HandlerOutcome::Processed } "partial" => { // partial: partial function application // For simulation, treat as identity/pass-through debug!("guppylang.partial at {node:?} - pass-through"); - self.propagate_all_inputs(hugr, node); + if !self.propagate_all_inputs(hugr, node) { + return HandlerOutcome::Defer; + } HandlerOutcome::Processed } _ => { diff --git a/crates/pecos-hugr/src/engine/handlers/prelude.rs b/crates/pecos-hugr/src/engine/handlers/prelude.rs index 1ba9b4688..b77e3923b 100644 --- a/crates/pecos-hugr/src/engine/handlers/prelude.rs +++ b/crates/pecos-hugr/src/engine/handlers/prelude.rs @@ -108,7 +108,10 @@ impl HugrEngine { "print" => { // Print operation - for simulation, we just pass through debug!("prelude::print at {node:?}"); - self.propagate_all_inputs(hugr, node); + if !self.propagate_all_inputs(hugr, node) { + debug!("prelude::print at {node:?}: inputs not ready, deferring"); + return HandlerOutcome::Defer; + } HandlerOutcome::Processed } @@ -181,12 +184,14 @@ impl HugrEngine { debug!( "UnpackTuple at {node:?}: input not a tuple, attempting pass-through" ); - self.propagate_all_inputs(hugr, node); + if !self.propagate_all_inputs(hugr, node) { + return HandlerOutcome::Defer; + } HandlerOutcome::Processed } None if self.get_input_qubit(hugr, node, 0).is_some() => { // Linear (qubit) tuple: flow is resolved structurally. - self.propagate_all_inputs(hugr, node); + let _ = self.propagate_all_inputs(hugr, node); HandlerOutcome::Processed } None => { @@ -197,8 +202,13 @@ impl HugrEngine { } "Noop" | "Lift" | "Barrier" => { - // Genuine identity/annotation ops: pass values through. - self.propagate_all_inputs(hugr, node); + // Genuine identity/annotation ops: pass values through; + // a missing input defers (marking processed would drop a + // late value permanently). + if !self.propagate_all_inputs(hugr, node) { + debug!("prelude::{op_name} at {node:?}: inputs not ready, deferring"); + return HandlerOutcome::Defer; + } HandlerOutcome::Processed } _ => { diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index 1f9f6677f..cef299467 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -222,9 +222,14 @@ impl HugrEngine { // signature returns an option (None on a second call); this // engine has no global-context restriction, so it always // produces Some. + // The u64 seed must be read as the canonical bit pattern: + // as_uint rejects negative storage, so a valid seed >= 2^63 + // (stored sign-extended) would defer forever. + #[allow(clippy::cast_sign_loss)] let Some(seed) = self .get_input_value(hugr, node, 0) - .and_then(|v| v.as_uint()) + .and_then(|v| v.as_int()) + .map(|v| v as u64) else { debug!("NewRNGContext at {node:?}: seed not ready, deferring"); return HandlerOutcome::Defer; diff --git a/crates/pecos-hugr/src/engine/handlers/wasm.rs b/crates/pecos-hugr/src/engine/handlers/wasm.rs index 396200c98..5b3df1b8e 100644 --- a/crates/pecos-hugr/src/engine/handlers/wasm.rs +++ b/crates/pecos-hugr/src/engine/handlers/wasm.rs @@ -58,7 +58,9 @@ impl HugrEngine { // call: (WasmContext, ...) -> (WasmContext, ...) // Call a WASM function // Stub: pass through inputs to outputs - self.propagate_all_inputs(hugr, node); + if !self.propagate_all_inputs(hugr, node) { + return HandlerOutcome::Defer; + } debug!("tket.wasm.call: stub (no WASM support)"); HandlerOutcome::Processed } diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index 8df886645..18a8faf27 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -152,19 +152,31 @@ impl HugrEngine { /// Propagate all input values to corresponding output ports. /// /// This is used for pass-through operations that don't modify values. - pub(crate) fn propagate_all_inputs(&mut self, hugr: &Hugr, node: Node) { + /// Copy every input wire to the corresponding output wire (pass-through + /// ops). Returns false when some port had NEITHER a classical value nor + /// a qubit mapping: the caller must DEFER -- marking the op processed + /// would permanently drop the late value (pass-throughs have no retry + /// of their own). + #[must_use] + pub(crate) fn propagate_all_inputs(&mut self, hugr: &Hugr, node: Node) -> bool { use tket::hugr::ops::OpTrait; let op = hugr.get_optype(node); let num_outputs = op.dataflow_signature().map_or(0, |sig| sig.output_count()); + let mut complete = true; for port in 0..num_outputs { + let mut found = false; if let Some(value) = self.get_input_value(hugr, node, port) { self.wire_state.classical_values.insert((node, port), value); + found = true; } if let Some(qubit) = self.get_input_qubit(hugr, node, port) { self.wire_state.wire_to_qubit.insert((node, port), qubit); + found = true; } + complete &= found; } + complete } /// Get a classical value from an input port. From 2e2615f632782df02cce83c5166c9f902b90f201 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 12:22:37 -0600 Subject: [PATCH 378/388] Pin the divergent-regime classical semantics against the Selene reference (bool-encoded: int result reporting is unlinked locally, and hugr-core's ishr const-folder breaks on constant past-width shifts -- both noted) --- .../tests/pecos/test_selene_sim_parity.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py index cffda1e08..894549469 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -610,3 +610,39 @@ def test_surface_memory_noiseless_complementary_family_repeats_after_projection( ) for row in results[comp_key]: assert _round_blocks_repeat(row, num_rounds) + + +def test_divergent_classical_semantics_match_selene_reference() -> None: + """The HUGR engine's spec-derived pins for the Python-divergent regimes + (negative divisors under Euclidean division with an unsigned divisor + bit pattern; logical right shift on negative operands) must agree with + the Selene reference implementation. + + Values are compared as booleans because the local Selene runtime does + not link int-result reporting (print_int). Shift-past-width is pinned + engine-side only: hugr-core's ishr CONST-FOLDER panics (debug) or + mis-folds (release) on constant shifts >= width, so that program cannot + compile through the QIS path until the upstream fix lands. + """ + import pecos + from guppylang import guppy + from guppylang.std.builtins import result + + _require_selene_runtime() + _configure_selene_caches() + + @guppy + def divergent_agrees() -> None: + a = 7 + b = -3 + result("div", (a // b) == 0) + result("mod", (a % b) == 7) + c = -8 + result("shr", (c >> 1) == 9223372036854775804) + + r = pecos.sim(pecos.Guppy(divergent_agrees)).qubits(1).classical(pecos.selene_engine()).seed(1).run(1).to_dict() + assert [int(r["div"][0]), int(r["mod"][0]), int(r["shr"][0])] == [ + 1, + 1, + 1, + ], f"Selene disagrees with the engine's spec pins: {r}" From 2346defa2e847ef6f50859a568ba18ed8d7dabe6 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 12:37:35 -0600 Subject: [PATCH 379/388] Consolidate the settled-node predicate: one HugrEngine::node_settled (processed and no active container owns it) backs readiness and all four completion checks, and all_predecessors_ready becomes a method, ending the map-threading at eleven call sites --- crates/pecos-hugr/src/engine.rs | 146 ++++++++---------- crates/pecos-hugr/src/engine/activation.rs | 13 +- crates/pecos-hugr/src/engine/analysis.rs | 63 +------- .../pecos-hugr/src/engine/control_flow/cfg.rs | 25 +-- .../src/engine/control_flow/conditional.rs | 14 +- .../src/engine/control_flow/scan.rs | 11 +- .../src/engine/control_flow/tailloop.rs | 20 +-- 7 files changed, 83 insertions(+), 209 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 51583008f..88ed210d0 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -58,10 +58,10 @@ use types::{ // Use analysis functions from submodule use analysis::{ - all_predecessors_ready, collect_descendants, extract_call_targets, extract_cfgs, - extract_classical_ops, extract_conditionals, extract_func_defns, extract_quantum_ops, - extract_tailloops, find_nodes_inside_cases, find_nodes_inside_cfg_blocks, - find_nodes_inside_func_defns, find_nodes_inside_tailloops, + collect_descendants, extract_call_targets, extract_cfgs, extract_classical_ops, + extract_conditionals, extract_func_defns, extract_quantum_ops, extract_tailloops, + find_nodes_inside_cases, find_nodes_inside_cfg_blocks, find_nodes_inside_func_defns, + find_nodes_inside_tailloops, }; /// A HUGR interpreter engine that directly executes HUGR programs. /// @@ -444,15 +444,7 @@ impl HugrEngine { for node in self.quantum_ops.keys() { if !should_skip(node) && !self.work_queue.contains(*node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -463,15 +455,7 @@ impl HugrEngine { for node in self.classical_ops.keys() { if !should_skip(node) && !self.work_queue.contains(*node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -482,15 +466,7 @@ impl HugrEngine { for node in self.conditionals.keys() { if !should_skip(node) && !self.work_queue.contains(*node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -501,15 +477,7 @@ impl HugrEngine { for node in self.cfgs.keys() { if !should_skip(node) && !self.work_queue.contains(*node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -520,15 +488,7 @@ impl HugrEngine { for node in self.call_targets.keys() { if !should_skip(node) && !self.work_queue.contains(*node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -551,15 +511,7 @@ impl HugrEngine { for node in self.tailloops.keys() { if !should_skip(node) && !self.work_queue.contains(*node) - && all_predecessors_ready( - hugr, - *node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) + && self.all_predecessors_ready(hugr, *node) { self.work_queue.push_back(*node); } @@ -957,15 +909,7 @@ impl HugrEngine { // so expanding early starves the body forever. When a // producer completes, queue_ready_successors re-queues // this node. - if !all_predecessors_ready( - &hugr, - current_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) { + if !self.all_predecessors_ready(&hugr, current_node) { debug!("TailLoop {current_node:?}: inputs not ready, deferring expansion"); continue; } @@ -1531,6 +1475,54 @@ impl HugrEngine { } } + /// A node is SETTLED as a dependency: processed AND no active container + /// state machine still owns it. A Conditional is marked processed at + /// EXPANSION but its outputs exist only once its selected case + /// completes; Calls/TailLoops/CFGs/scans mark processed at completion, + /// where the active check is redundant but keeps this predicate the + /// single source of truth (five sites used to hand-roll subsets of it, + /// and a sixth would have forgotten one). + pub(crate) fn node_settled(&self, node: Node) -> bool { + self.processed.contains(&node) + && !self + .active_cases + .values() + .any(|case| case.conditional_node == node) + && !self.active_tailloops.contains_key(&node) + && !self.active_calls.contains_key(&node) + && !self.active_cfgs.contains_key(&node) + && !self.active_scans.contains_key(&node) + } + + /// Whether every producer feeding `node` is settled: the gate for + /// one-shot input copiers (Calls, TailLoop/CFG activation, classical + /// and extension ops) -- firing before a producer settles copies + /// missing or stale values with no repair path. + pub(crate) fn all_predecessors_ready(&self, hugr: &Hugr, node: Node) -> bool { + for pred_node in hugr.input_neighbours(node) { + let op = hugr.get_optype(pred_node); + let gates = self.quantum_ops.contains_key(&pred_node) + || self.conditionals.contains_key(&pred_node) + || self.cfgs.contains_key(&pred_node) + || matches!( + op, + OpType::Call(_) | OpType::TailLoop(_) | OpType::LoadConstant(_) + ) + // Extension-op and executable-Tag predecessors (classical + // ops, tket.* ops, copyable sum construction) produce + // classical values; firing a consumer before they complete + // copies MISSING inputs. Linear (qubit-routing) Tags never + // execute... but every Tag classifies as executable now, so + // the classical-op check covers them. + || op.as_extension_op().is_some() + || crate::engine::analysis::classify_classical_op(op).is_some(); + if gates && !self.node_settled(pred_node) { + return false; + } + } + true + } + /// Audit that every direct child of every executed container region /// (activated `DataflowBlock`, selected Case, expanded `TailLoop` body) /// was processed. Exempt kinds never execute: Input/Output boundaries, @@ -1820,15 +1812,7 @@ impl HugrEngine { && !inside_control_flow && !self.processed.contains(&succ_node) && !self.work_queue.contains(succ_node) - && all_predecessors_ready( - hugr, - succ_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) + && self.all_predecessors_ready(hugr, succ_node) { self.work_queue.push_back(succ_node); } else if is_relevant || is_extension { @@ -1836,15 +1820,7 @@ impl HugrEngine { "queue_ready_successors({node:?}): skipped {succ_node:?} gated={inside_control_flow} processed={} queued={} ready={}", self.processed.contains(&succ_node), self.work_queue.contains(succ_node), - all_predecessors_ready( - hugr, - succ_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed - ) + self.all_predecessors_ready(hugr, succ_node) ); } } diff --git a/crates/pecos-hugr/src/engine/activation.rs b/crates/pecos-hugr/src/engine/activation.rs index 1be788e4a..e1d5870c8 100644 --- a/crates/pecos-hugr/src/engine/activation.rs +++ b/crates/pecos-hugr/src/engine/activation.rs @@ -34,7 +34,6 @@ use std::collections::BTreeSet; use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; -use crate::engine::analysis::all_predecessors_ready; /// Whether a node queues unconditionally or only once its predecessors are /// processed. @@ -160,17 +159,7 @@ impl HugrEngine { if self.work_queue.contains(node) || self.processed.contains(&node) { continue; } - if policy == QueuePolicy::IfReady - && !all_predecessors_ready( - hugr, - node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) - { + if policy == QueuePolicy::IfReady && !self.all_predecessors_ready(hugr, node) { continue; } self.work_queue.push_back(node); diff --git a/crates/pecos-hugr/src/engine/analysis.rs b/crates/pecos-hugr/src/engine/analysis.rs index eff902b84..003e682f8 100644 --- a/crates/pecos-hugr/src/engine/analysis.rs +++ b/crates/pecos-hugr/src/engine/analysis.rs @@ -39,8 +39,8 @@ use tket::hugr::ops::{OpTrait, OpType}; use tket::hugr::{Hugr, HugrView, Node}; use super::types::{ - ActiveCaseInfo, CfgInfo, ClassicalOp, ClassicalOpType, ConditionalInfo, ContainerType, - DataflowBlockInfo, FuncDefnInfo, QuantumOp, TailLoopInfo, + CfgInfo, ClassicalOp, ClassicalOpType, ConditionalInfo, ContainerType, DataflowBlockInfo, + FuncDefnInfo, QuantumOp, TailLoopInfo, }; // --- Conditional extraction --- @@ -1053,65 +1053,6 @@ pub fn get_container_type(hugr: &Hugr, node: Node) -> ContainerType { } } -/// Check if all quantum predecessors of a node have been processed. -/// This includes quantum operations, Conditionals, CFGs, `TailLoops`, and Call nodes. -pub fn all_predecessors_ready( - hugr: &Hugr, - node: Node, - quantum_ops: &BTreeMap, - conditionals: &BTreeMap, - cfgs: &BTreeMap, - active_cases: &BTreeMap, - processed: &BTreeSet, -) -> bool { - for pred_node in hugr.input_neighbours(node) { - // Check quantum ops - if quantum_ops.contains_key(&pred_node) && !processed.contains(&pred_node) { - return false; - } - // Check conditionals (they also produce qubit outputs). A - // Conditional is marked processed at EXPANSION, but its outputs - // exist only once its selected case completes -- treating it as - // ready while a case is active lets one-shot input copiers (Calls, - // TailLoop expansion, CFG activation) fire early, copy missing - // values, and starve with no repair path. - if conditionals.contains_key(&pred_node) - && (!processed.contains(&pred_node) - || active_cases - .values() - .any(|case| case.conditional_node == pred_node)) - { - return false; - } - // Check CFG nodes (they also produce qubit outputs) - if cfgs.contains_key(&pred_node) && !processed.contains(&pred_node) { - return false; - } - // Check Call, TailLoop, and LoadConstant nodes (they produce - // qubit/array/classical outputs the consumer needs) - let op = hugr.get_optype(pred_node); - if matches!( - op, - OpType::Call(_) | OpType::TailLoop(_) | OpType::LoadConstant(_) - ) && !processed.contains(&pred_node) - { - return false; - } - // Check extension-op and executable-Tag predecessors (classical ops, - // tket.* ops, copyable sum construction): they produce classical - // values, and firing a consumer before they complete copies MISSING - // inputs (e.g. a Call activated before its argument tuple exists - // silently starves the whole called body). Linear (qubit-routing) - // Tags never execute, so they must NOT gate readiness. - if (op.as_extension_op().is_some() || classify_classical_op(op).is_some()) - && !processed.contains(&pred_node) - { - return false; - } - } - true -} - #[cfg(test)] mod tests { diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 32654281b..cdec2896f 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -40,9 +40,7 @@ use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; use crate::engine::HugrEngine; use crate::engine::activation::{ContainerActivation, QueuePolicy}; -use crate::engine::analysis::{ - all_predecessors_ready, find_extension_ops_in_block, find_input_node, find_output_node, -}; +use crate::engine::analysis::{find_extension_ops_in_block, find_input_node, find_output_node}; use crate::engine::types::ClassicalValue; impl HugrEngine { @@ -286,13 +284,10 @@ impl HugrEngine { // completed and propagated outputs -- expansion alone // marks it processed, but its output values do not exist // yet (transitioning then would copy missing values). - let conditionals_done = block_info.conditional_nodes.iter().all(|cond| { - self.processed.contains(cond) - && !self - .active_cases - .values() - .any(|case| case.conditional_node == *cond) - }); + let conditionals_done = block_info + .conditional_nodes + .iter() + .all(|cond| self.node_settled(*cond)); let block_complete = all_done(&block_info.quantum_ops) && all_done(&block_info.call_nodes) && conditionals_done @@ -715,15 +710,7 @@ impl HugrEngine { if (is_relevant || is_extension) && !self.processed.contains(&succ_node) && !self.work_queue.contains(succ_node) - && all_predecessors_ready( - hugr, - succ_node, - &self.quantum_ops, - &self.conditionals, - &self.cfgs, - &self.active_cases, - &self.processed, - ) + && self.all_predecessors_ready(hugr, succ_node) { debug!("CFG complete: adding successor {succ_node:?} to work queue"); self.work_queue.push_back(succ_node); diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index da765540a..bbe093baa 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -167,16 +167,10 @@ impl HugrEngine { // nested Conditional is marked processed at EXPANSION (its // outputs exist only once its own case completes), and // active TailLoops/Calls are still producing values. - let all_done = case_info.ops_in_case.iter().all(|op| { - self.processed.contains(op) - && !self - .active_cases - .values() - .any(|c| c.conditional_node == *op) - && !self.active_tailloops.contains_key(op) - && !self.active_calls.contains_key(op) - && !self.active_cfgs.contains_key(op) - }); + let all_done = case_info + .ops_in_case + .iter() + .all(|op| self.node_settled(*op)); if all_done { completed_cases.push((*case_node, case_info.conditional_node)); diff --git a/crates/pecos-hugr/src/engine/control_flow/scan.rs b/crates/pecos-hugr/src/engine/control_flow/scan.rs index 303f9bd8b..f28da92f5 100644 --- a/crates/pecos-hugr/src/engine/control_flow/scan.rs +++ b/crates/pecos-hugr/src/engine/control_flow/scan.rs @@ -247,16 +247,7 @@ impl HugrEngine { let Some(state) = self.active_scans.get(&scan_node) else { continue; }; - let all_done = state.frame_ops.iter().all(|op| { - self.processed.contains(op) - && !self - .active_cases - .values() - .any(|case| case.conditional_node == *op) - && !self.active_tailloops.contains_key(op) - && !self.active_calls.contains_key(op) - && !self.active_cfgs.contains_key(op) - }); + let all_done = state.frame_ops.iter().all(|op| self.node_settled(*op)); if all_done { debug!("scan {scan_node:?}: dataflow frame complete"); self.advance_scan(hugr, scan_node); diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 73da001fc..0fe802499 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -633,23 +633,19 @@ impl HugrEngine { // A Conditional is marked processed at EXPANSION; its // outputs exist only once its selected case completes, so // an active case must also block body completion. - let all_conditionals_done = tailloop_info.conditional_nodes.iter().all(|cond| { - self.processed.contains(cond) - && !self - .active_cases - .values() - .any(|case| case.conditional_node == *cond) - }); + let all_conditionals_done = tailloop_info + .conditional_nodes + .iter() + .all(|cond| self.node_settled(*cond)); // Nested containers count as done only once processed AND // no longer active (a nested loop marks processed at its // own completion; a nested CFG at complete_cfg_execution). - let all_nested_done = tailloop_info.tailloop_nodes.iter().all(|tl| { - self.processed.contains(tl) && !self.active_tailloops.contains_key(tl) - }) && tailloop_info - .cfg_nodes + let all_nested_done = tailloop_info + .tailloop_nodes .iter() - .all(|c| self.processed.contains(c) && !self.active_cfgs.contains_key(c)); + .chain(&tailloop_info.cfg_nodes) + .all(|nested| self.node_settled(*nested)); if all_quantum_done && all_calls_done From a40215ae59814082986c79f18b4f01856f6c281d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 12:56:52 -0600 Subject: [PATCH 380/388] Classical values trace through DFG boundaries (flattening parity with qubit tracing: a DFG node's outputs resolve at its Output child, its Input node at the outer wires), closing the documented classical-DFG residual with a nested-DFG pinning test --- crates/pecos-hugr/src/engine.rs | 78 +++++++++++++++++++++ crates/pecos-hugr/src/engine/propagation.rs | 25 ++++++- 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 88ed210d0..445e3bc03 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -3052,6 +3052,84 @@ mod tests { assert_eq!(stepped.state, before); } + /// Classical values must cross DFG boundaries structurally (the + /// flattening semantics qubit tracing already had): a consumer inside + /// a nested DFG reads through the Input boundary to the outer + /// producer, and a consumer of the DFG node reads through its Output + /// child. + #[test] + fn test_classical_values_trace_through_dfg_boundaries() { + use tket::hugr::builder::{Dataflow, DataflowHugr, DataflowSubContainer, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::ops::handle::NodeHandle; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let (hugr, iadd_node, dfg_node) = { + let mut fb = + FunctionBuilder::new("dfg_flow", Signature::new(vec![], vec![int_type(6)])) + .unwrap(); + let a = fb.add_load_const(Value::from(ConstInt::new_u(6, 30).unwrap())); + let b = fb.add_load_const(Value::from(ConstInt::new_u(6, 12).unwrap())); + let mut dfg = fb + .dfg_builder( + Signature::new(vec![int_type(6); 2], vec![int_type(6)]), + [a, b], + ) + .unwrap(); + let [ia, ib] = dfg.input_wires_arr(); + let [sum] = dfg + .add_dataflow_op(IntOpDef::iadd.with_log_width(6), [ia, ib]) + .unwrap() + .outputs_arr(); + let iadd_node = sum.node(); + let dfg_handle = dfg.finish_with_outputs([sum]).unwrap(); + let dfg_node = dfg_handle.node(); + let [out] = dfg_handle.outputs_arr(); + let hugr = fb.finish_hugr_with_outputs([out]).unwrap(); + (hugr, iadd_node, dfg_node) + }; + + let mut engine = HugrEngine::default(); + // Seed the OUTER producers (the LoadConstant wires feeding the DFG). + for (port, value) in [(0, 30i64), (1, 12i64)] { + let (src, sp) = hugr + .single_linked_output(dfg_node, IncomingPort::from(port)) + .unwrap(); + engine + .wire_state + .classical_values + .insert((src, sp.index()), ClassicalValue::Int(value)); + } + + // Inside: the iadd's inputs read THROUGH the DFG Input boundary. + assert_eq!( + engine.get_input_value(&hugr, iadd_node, 0), + Some(ClassicalValue::Int(30)) + ); + assert_eq!( + engine.get_input_value(&hugr, iadd_node, 1), + Some(ClassicalValue::Int(12)) + ); + + // Outside: once the interior op stores its result, a consumer of + // the DFG node reads THROUGH its Output child. The function's + // Output node consumes the DFG's port 0. + engine + .wire_state + .classical_values + .insert((iadd_node, 0), ClassicalValue::Int(42)); + let func_output = hugr + .get_io(hugr.get_parent(dfg_node).unwrap()) + .map(|[_, o]| o) + .unwrap(); + assert_eq!( + engine.get_input_value(&hugr, func_output, 0), + Some(ClassicalValue::Int(42)) + ); + } + #[test] fn test_completion_audit_reports_unexecuted_container_ops() { // The reachability audit must catch a container the engine claims diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index 18a8faf27..ce63a3170 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -202,7 +202,30 @@ impl HugrEngine { wire_key, value ); - value + if value.is_some() { + return value; + } + // FLATTENING: classical values cross DFG boundaries + // structurally, exactly like qubit tracing does. A DFG node's + // outputs live at its Output child's sources; a DFG's Input + // node ports live at the DFG node's own input wires. Bounded + // by nesting depth. + match hugr.get_optype(src_node) { + OpType::DFG(_) => { + let output_node = hugr.get_io(src_node).map(|[_, o]| o)?; + return self.get_input_value(hugr, output_node, src_port.index()); + } + OpType::Input(_) + if hugr + .get_parent(src_node) + .is_some_and(|p| matches!(hugr.get_optype(p), OpType::DFG(_))) => + { + let dfg_node = hugr.get_parent(src_node)?; + return self.get_input_value(hugr, dfg_node, src_port.index()); + } + _ => {} + } + None } else { debug!("get_input_value({node:?}, {port}): no linked output"); None From c826f31478258e2f13f730fabfb4539027bb43ac Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 17:23:55 -0600 Subject: [PATCH 381/388] Fold in third-panel findings: measurement replay becomes fill-only with purge-on-reactivation retention and post-replay resolver re-runs, the classical executor resolves inputs through the tracing layer (pinned end-to-end: a nested-DFG op previously deferred forever), qubit tracing crosses DFG boundaries with depth-capped recursion, Call arguments and conditional virtual payloads clear stale state before rewrite, and the fail-loud edges close (Engine::process with pending quantum work, gate modifiers, the gate-emitter catch-all, the empty-queue stall check, port-keyed return capture, global_phase deferral) with capacity-guard dispatcher tests replacing the vacuous allocation test --- .../tests/qubit_capacity_test.rs | 111 +++++++++++++ crates/pecos-hugr/src/engine.rs | 157 ++++++++++++++++-- .../pecos-hugr/src/engine/control_flow/cfg.rs | 136 ++++++++++----- .../src/engine/control_flow/conditional.rs | 24 +++ crates/pecos-hugr/src/engine/handlers.rs | 2 +- .../src/engine/handlers/classical.rs | 43 +++-- .../pecos-hugr/src/engine/handlers/debug.rs | 5 +- .../pecos-hugr/src/engine/handlers/qsystem.rs | 6 +- .../pecos-hugr/src/engine/handlers/quantum.rs | 52 +++--- crates/pecos-hugr/src/engine/propagation.rs | 100 +++++++++-- .../test_comprehensive_guppy_features.py | 2 +- .../guppy/test_extended_guppy_features.py | 2 +- .../guppy/test_qubit_allocation_limits.py | 103 +++++------- 13 files changed, 547 insertions(+), 196 deletions(-) create mode 100644 crates/pecos-engines/tests/qubit_capacity_test.rs diff --git a/crates/pecos-engines/tests/qubit_capacity_test.rs b/crates/pecos-engines/tests/qubit_capacity_test.rs new file mode 100644 index 000000000..069ac5e15 --- /dev/null +++ b/crates/pecos-engines/tests/qubit_capacity_test.rs @@ -0,0 +1,111 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except +// in compliance with the License.You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software distributed under the License +// is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express +// or implied. See the License for the specific language governing permissions and limitations under +// the License. + +//! Integration tests for the per-command qubit-capacity guard. +//! +//! A command targeting a qubit index at or beyond the simulator's capacity +//! (e.g. dynamic allocation past the configured qubit count) must fail with +//! the op and the capacity named -- through BOTH shared dispatchers +//! (`SparseStabEngine` -> Clifford dispatch, `StabVecEngine` -> general +//! dispatch), and INCLUDING commands consumed by the MZ-batching lookahead, +//! which bypasses the top-of-loop check. +//! +//! (`StateVecEngine` is NOT covered: it has its own dispatch loop that +//! auto-grows the simulator instead of rejecting -- pre-existing behavior +//! for static-circuit flows whose qubit count is inferred as 0.) + +use pecos_engines::Engine; +use pecos_engines::byte_message::ByteMessageBuilder; +use pecos_engines::quantum::{SparseStabEngine, StabVecEngine}; + +fn build_message(build: impl FnOnce(&mut ByteMessageBuilder)) -> pecos_engines::ByteMessage { + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_quantum_operations(); + build(&mut builder); + builder.build() +} + +fn assert_capacity_error(result: Result) { + match result { + Err(e) => { + let msg = e.to_string(); + assert!( + msg.contains("2 qubits"), + "error should name the capacity: {msg}" + ); + assert!( + msg.contains("qubit 2"), + "error should name the offending qubit: {msg}" + ); + } + Ok(_) => panic!("expected out-of-capacity command to fail"), + } +} + +#[test] +fn clifford_dispatch_rejects_gate_beyond_capacity() { + let mut engine = SparseStabEngine::new(2); + let msg = build_message(|b| { + b.h(&[2]); + }); + assert_capacity_error(engine.process(msg)); +} + +#[test] +fn general_dispatch_rejects_gate_beyond_capacity() { + let mut engine = StabVecEngine::new(2); + let msg = build_message(|b| { + b.h(&[2]); + }); + assert_capacity_error(engine.process(msg)); +} + +#[test] +fn clifford_dispatch_rejects_mz_lookahead_beyond_capacity() { + // Two consecutive MZ commands batch via lookahead; the SECOND one is + // consumed inside the batching loop, not at the top of the dispatch + // loop, and must still be guarded. + let mut engine = SparseStabEngine::new(2); + let msg = build_message(|b| { + b.mz(&[0]); + b.mz(&[2]); + }); + assert_capacity_error(engine.process(msg)); +} + +#[test] +fn general_dispatch_rejects_mz_lookahead_beyond_capacity() { + let mut engine = StabVecEngine::new(2); + let msg = build_message(|b| { + b.mz(&[0]); + b.mz(&[2]); + }); + assert_capacity_error(engine.process(msg)); +} + +#[test] +fn in_range_commands_still_process() { + let mut engine = SparseStabEngine::new(2); + let msg = build_message(|b| { + b.h(&[0]); + b.cx(&[(0, 1)]); + b.mz(&[0]); + b.mz(&[1]); + }); + let outcomes = engine + .process(msg) + .expect("in-range circuit should process") + .outcomes() + .expect("outcomes should parse"); + assert_eq!(outcomes.len(), 2); + assert_eq!(outcomes[0], outcomes[1], "Bell pair outcomes must agree"); +} diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 445e3bc03..7f7bf6529 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -636,6 +636,10 @@ impl HugrEngine { }; if self.work_queue.is_empty() && self.quantum_ops.is_empty() { + // "Nothing to do" is only a completion claim if nothing is + // stranded -- a purely classical program that starved mid-run + // also lands here, and it must report as a stall. + self.ensure_no_stalled_execution()?; debug!("Empty HUGR, no commands to generate"); return Ok(None); } @@ -713,6 +717,12 @@ impl HugrEngine { debug!("Starting CFG {current_node:?} execution"); debug!("[TRACE] Starting CFG {current_node:?}"); + // A fresh walk must not replay the previous invocation's + // measurement-propagation edges (completion purges them; + // this covers a walk that never completed). + self.pending_measurement_propagations + .retain(|(cfg, _, _)| *cfg != current_node); + // Start CFG execution by activating the entry block's operations let entry_block = cfg_info.entry_block; if let Some(block_info) = cfg_info.blocks.get(&entry_block) { @@ -974,6 +984,18 @@ impl HugrEngine { } if let Some(func_info) = self.func_defns.get(&func_defn_node).cloned() { + // Clear every FuncDefn Input port before copying: the + // frame reset below exempts the Input node (keep_wires) + // so the fresh arguments survive, which means a port + // whose source is missing would otherwise keep the + // PREVIOUS call's argument. Missing sources must starve + // loudly instead. + for in_port in 0..func_info.num_inputs { + let func_input_wire = (func_info.input_node, in_port); + self.wire_state.classical_values.remove(&func_input_wire); + self.wire_state.wire_to_qubit.remove(&func_input_wire); + } + // Map Call inputs to FuncDefn Input node outputs // Call inputs come from upstream nodes for in_port in 0..func_info.num_inputs { @@ -1739,7 +1761,15 @@ impl HugrEngine { } _ => { - debug!("Unsupported gate type: {:?}", op.gate_type); + // A recognized quantum op the emitter cannot lower: skipping + // it silently executes a DIFFERENT circuit (the node was + // already marked processed by the dispatcher, so nothing + // downstream would notice). + return Err(PecosError::Generic(format!( + "quantum gate {:?} at {node:?} is not supported by the \ + HUGR engine's gate emitter", + op.gate_type + ))); } } @@ -1988,6 +2018,13 @@ impl ClassicalEngine for HugrEngine { // own control before OTHER data inputs (later // measurements) exist; refresh its Input ports now. self.repropagate_active_case_inputs(&hugr); + + // Replay can fill the very port a pending control was + // starving on (the resolver pass above ran before the + // fill), so give the resolvers a second look now. + self.try_resolve_pending_conditionals(); + self.try_resolve_pending_cfg_branches(); + self.try_resolve_pending_tailloops(); } // Retry any bool.read nodes that were waiting for measurement results @@ -2030,25 +2067,37 @@ impl ClassicalEngine for HugrEngine { // entrypoint's return values -- "return" for a single value, // "return_{port}" for multiple. if self.measurement_state.results.is_empty() && !self.return_values.is_empty() { - let scalars: Vec = self + // Keys carry the ACTUAL port index: compacting around an + // unconvertible value (a Tuple/Sum return) would silently + // relabel every later port, and "return" only applies when + // the function genuinely returns one value. + let scalars: Vec<(usize, Data)> = self .return_values .iter() - .filter_map(|value| match value { - ClassicalValue::Bool(b) => Some(Data::Bool(*b)), - ClassicalValue::Int(i) => Some(Data::I64(*i)), - ClassicalValue::UInt(u) => Some(Data::U64(*u)), - ClassicalValue::Float(f) => Some(Data::F64(*f)), - _ => None, + .enumerate() + .filter_map(|(port, value)| { + let data = match value { + ClassicalValue::Bool(b) => Some(Data::Bool(*b)), + ClassicalValue::Int(i) => Some(Data::I64(*i)), + ClassicalValue::UInt(u) => Some(Data::U64(*u)), + ClassicalValue::Float(f) => Some(Data::F64(*f)), + _ => { + debug!( + "return port {port}: non-scalar value {value:?} not surfaced" + ); + None + } + }; + data.map(|d| (port, d)) }) .collect(); - if scalars.len() == 1 { - let mut scalars = scalars; - result - .data - .insert("return".to_string(), scalars.pop().expect("len 1")); + if self.return_values.len() == 1 { + if let Some((_, data)) = scalars.into_iter().next() { + result.data.insert("return".to_string(), data); + } } else { - for (i, data) in scalars.into_iter().enumerate() { - result.data.insert(format!("return_{i}"), data); + for (port, data) in scalars { + result.data.insert(format!("return_{port}"), data); } } } @@ -2154,8 +2203,16 @@ impl Engine for HugrEngine { match stage { EngineStage::Complete(result) => Ok(result), EngineStage::NeedsProcessing(_) => { - debug!("HugrEngine cannot process quantum operations directly"); - Ok(self.get_results()?) + // The program emitted quantum commands, but this entry point + // has no quantum backend to run them: any results would be + // the pre-measurement partial state, silently wrong. The + // caller must drive the engine through `start`/`step` with a + // quantum engine attached instead. + Err(PecosError::Generic( + "HUGR program requires quantum processing; \ + Engine::process has no quantum backend to run it" + .to_string(), + )) } } } @@ -2675,6 +2732,23 @@ mod tests { ]) ); + // idivmod_s with a "negative" divisor: the divisor port is UNSIGNED + // per the spec, so canonical -3 reads as its bit pattern (2^64 - 3) + // and the huge divisor makes q = 0, r = the dividend. This is the + // Selene-validated guppy semantics (7 // -3 == 0, 7 % -3 == 7). + let got = run( + &mut engine, + "idivmod_s", + &[(0, ClassicalValue::Int(7)), (1, ClassicalValue::Int(-3))], + ); + assert_eq!( + got, + ClassicalOutcome::Outputs(vec![ + (0, ClassicalValue::Int(0)), + (1, ClassicalValue::Int(7)) + ]) + ); + // idivmod_s by zero: fatal fault per the spec let got = run( &mut engine, @@ -3130,6 +3204,55 @@ mod tests { ); } + /// End-to-end companion to the tracing test above: the EXECUTOR + /// (`handle_classical_op` via the work-queue dispatch, not the + /// `get_input_value` helper in isolation) must resolve a classical op's + /// inputs across a flattened-DFG boundary. Before the executor used + /// the tracing layer it raw-read the source wire, so the iadd inside + /// the DFG deferred forever and this run failed as a stall. + #[test] + fn test_classical_op_inside_dfg_executes_end_to_end() { + use tket::hugr::builder::{Dataflow, DataflowHugr, DataflowSubContainer, FunctionBuilder}; + use tket::hugr::ops::Value; + use tket::hugr::std_extensions::arithmetic::int_ops::IntOpDef; + use tket::hugr::std_extensions::arithmetic::int_types::{ConstInt, int_type}; + use tket::hugr::types::Signature; + + let (hugr, iadd_node) = { + let mut fb = + FunctionBuilder::new("dfg_exec", Signature::new(vec![], vec![int_type(6)])) + .unwrap(); + let a = fb.add_load_const(Value::from(ConstInt::new_u(6, 30).unwrap())); + let b = fb.add_load_const(Value::from(ConstInt::new_u(6, 12).unwrap())); + let mut dfg = fb + .dfg_builder( + Signature::new(vec![int_type(6); 2], vec![int_type(6)]), + [a, b], + ) + .unwrap(); + let [ia, ib] = dfg.input_wires_arr(); + let [sum] = dfg + .add_dataflow_op(IntOpDef::iadd.with_log_width(6), [ia, ib]) + .unwrap() + .outputs_arr(); + let iadd_node = sum.node(); + let dfg_handle = dfg.finish_with_outputs([sum]).unwrap(); + let [out] = dfg_handle.outputs_arr(); + let hugr = fb.finish_hugr_with_outputs([out]).unwrap(); + (hugr, iadd_node) + }; + + let mut engine = HugrEngine::from_hugr(hugr); + engine + .generate_commands() + .expect("pure-classical DFG program must complete without stalling"); + assert_eq!( + engine.wire_state.classical_values.get(&(iadd_node, 0)), + Some(&ClassicalValue::Int(42)), + "iadd inside the DFG must execute with values traced across the boundary" + ); + } + #[test] fn test_completion_audit_reports_unexecuted_container_ops() { // The reachability audit must catch a container the engine claims diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index cdec2896f..14fca9301 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -277,20 +277,17 @@ impl HugrEngine { || block_info.load_constants.contains(&processed_node); if is_in_block { + // node_settled, not bare `processed`: several container + // ops are marked processed while their results are still + // in flight (a Conditional at EXPANSION, a Call/TailLoop + // while its frame is active) -- transitioning then would + // copy missing values. let all_done = |set: &std::collections::BTreeSet| { - set.iter().all(|op| self.processed.contains(op)) + set.iter().all(|op| self.node_settled(*op)) }; - // A conditional is only DONE once its selected Case has - // completed and propagated outputs -- expansion alone - // marks it processed, but its output values do not exist - // yet (transitioning then would copy missing values). - let conditionals_done = block_info - .conditional_nodes - .iter() - .all(|cond| self.node_settled(*cond)); let block_complete = all_done(&block_info.quantum_ops) && all_done(&block_info.call_nodes) - && conditionals_done + && all_done(&block_info.conditional_nodes) && all_done(&block_info.bool_ops) && all_done(&block_info.extension_ops) && all_done(&block_info.tailloop_nodes) @@ -401,13 +398,16 @@ impl HugrEngine { // Record this propagation for re-propagation after measurement results // are available (measurement results may not be stored yet when we - // transition). Only the LATEST transition per CFG may stay recorded: - // replaying a superseded edge (e.g. the previous loop iteration's) - // re-reads source wires that have since been cleared or partially - // rewritten and clobbers the successor's fresh inputs with stale - // values (observed as a loop re-borrowing the same array slot). + // transition). Retention is PURGE-ON-REACTIVATION: entering + // to_block supersedes any previously recorded edge INTO it (the + // previous loop iteration's), while edges into other blocks of + // the same chain survive -- a multi-hop transition through an + // empty block records EVERY hop, so replay can walk the chain + // and carry a late measurement value across it. (The old + // latest-edge-only policy amputated chains: the A->empty hop + // was dropped and the value never reached the consumer.) self.pending_measurement_propagations - .retain(|(cfg, _, _)| *cfg != cfg_node); + .retain(|(cfg, _, to)| *cfg != cfg_node || *to != to_block); self.pending_measurement_propagations .push((cfg_node, from_block, to_block)); @@ -643,6 +643,11 @@ impl HugrEngine { // Mark CFG as processed self.processed.insert(cfg_node); self.active_cfgs.remove(&cfg_node); + // Recorded edges must not outlive their CFG walk: a later + // re-invocation (next Call / scan element) starts a fresh chain + // against reset frame state. + self.pending_measurement_propagations + .retain(|(cfg, _, _)| *cfg != cfg_node); // The ENTRYPOINT's CFG completing means main returned: capture its // classical return values so pure-classical programs surface them. @@ -733,6 +738,24 @@ impl HugrEngine { hugr: &Hugr, from_block: Node, to_block: Node, + ) { + self.propagate_block_outputs(hugr, from_block, to_block, false); + } + + /// Copy a completed block's outputs onto the successor's Input node. + /// + /// Transition mode (`fill_only == false`) clears every successor Input + /// port first so a port this propagation cannot source starves loudly + /// instead of keeping the previous iteration's value. Measurement + /// replay (`fill_only == true`) runs against LIVE state whose sources + /// a later activation may have legitimately consumed: it never clears + /// and writes only ports that are currently missing. + pub(crate) fn propagate_block_outputs( + &mut self, + hugr: &Hugr, + from_block: Node, + to_block: Node, + fill_only: bool, ) { debug!("[TRACE] propagate_block_outputs_to_successor: from {from_block:?} to {to_block:?}"); let from_output = find_output_node(hugr, from_block); @@ -762,12 +785,15 @@ impl HugrEngine { // batch exempts the Input node from wire clearing (keep_wires) so // fresh values survive, which means any port this propagation // cannot source would otherwise keep the PREVIOUS iteration's - // value. Missing sources must starve loudly instead. - for port_idx in 0..target_num_outputs { - self.wire_state - .classical_values - .remove(&(to_input, port_idx)); - self.wire_state.wire_to_qubit.remove(&(to_input, port_idx)); + // value. Missing sources must starve loudly instead. Replay + // (fill_only) must NOT clear -- it runs against live state. + if !fill_only { + for port_idx in 0..target_num_outputs { + self.wire_state + .classical_values + .remove(&(to_input, port_idx)); + self.wire_state.wire_to_qubit.remove(&(to_input, port_idx)); + } } // First, check if port 0 (Sum) has payload values from a Conditional @@ -787,6 +813,14 @@ impl HugrEngine { { payload_len = values.len(); for (i, value) in values.into_iter().enumerate().take(expected_payload_len) { + if fill_only + && self + .wire_state + .classical_values + .contains_key(&(to_input, i)) + { + continue; + } debug!( "[TRACE] Block transition: propagated branch payload {value:?} to {to_input:?}:{i}" ); @@ -811,7 +845,9 @@ impl HugrEngine { .cloned() { let to_wire = (to_input, idx - 1); - self.wire_state.classical_values.insert(to_wire, value); + if !(fill_only && self.wire_state.classical_values.contains_key(&to_wire)) { + self.wire_state.classical_values.insert(to_wire, value); + } payload_len += 1; idx += 1; } @@ -849,7 +885,13 @@ impl HugrEngine { ); let src_wire = (src_node, src_port.index()); - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) + && !(fill_only + && self + .wire_state + .wire_to_qubit + .contains_key(&(to_input, to_port_idx))) + { self.wire_state .wire_to_qubit .insert((to_input, to_port_idx), qubit_id); @@ -863,8 +905,17 @@ impl HugrEngine { ); } + let target_filled = fill_only + && self + .wire_state + .classical_values + .contains_key(&(to_input, to_port_idx)); + // Also propagate classical values - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { + if target_filled { + // Fill-only replay never overwrites a live value. + } else if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() + { let to_wire = (to_input, to_port_idx); debug!( "[TRACE] Block transition: propagated classical value {value:?} from {src_wire:?} to {to_wire:?}" @@ -905,10 +956,12 @@ impl HugrEngine { if let Some(value) = self.wire_state.classical_values.get(&input_wire).cloned() { let to_wire = (to_input, to_port_idx); - debug!( - "[TRACE] Fallback: propagating {value:?} from input {input_wire:?} to {to_wire:?}" - ); - self.wire_state.classical_values.insert(to_wire, value); + if !(fill_only && self.wire_state.classical_values.contains_key(&to_wire)) { + debug!( + "[TRACE] Fallback: propagating {value:?} from input {input_wire:?} to {to_wire:?}" + ); + self.wire_state.classical_values.insert(to_wire, value); + } } } } @@ -921,19 +974,20 @@ impl HugrEngine { /// happens before measurement results are available. This function re-propagates /// values after measurement results are stored. pub(crate) fn repropagate_measurement_values(&mut self, hugr: &Hugr) { - // Take ownership of the pending list to avoid borrow issues - let pending: Vec<_> = std::mem::take(&mut self.pending_measurement_propagations); - + // Replay every recorded edge IN ORDER, FILL-ONLY: writes land only + // on ports that are currently missing, and nothing is cleared. + // Fill-only is what makes replay safe against live state -- a full + // re-propagation cleared the successor's inputs and re-read source + // wires that a later activation may have legitimately destroyed + // (self-loop blocks corrupted an in-flight iteration this way). + // Ordered chain replay is what carries a late measurement value + // across empty-block hops. Edges stay recorded (fill-only is + // idempotent); they are purged when their target re-activates or + // their CFG completes. + let pending = self.pending_measurement_propagations.clone(); for (cfg_node, from_block, to_block) in pending { - // Replay only while the successor is still the CFG's current - // block; a transition the CFG has since moved past would write - // stale values into a block that already has fresh inputs. - if self - .active_cfgs - .get(&cfg_node) - .is_some_and(|active| active.current_block == to_block) - { - self.propagate_block_outputs_to_successor(hugr, from_block, to_block); + if self.active_cfgs.contains_key(&cfg_node) { + self.propagate_block_outputs(hugr, from_block, to_block, true); } } } diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index bbe093baa..ad1829429 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -284,6 +284,23 @@ impl HugrEngine { // Store them at "virtual" output ports (1, 2, ...) on the Conditional // These will be used during CFG block transitions let num_tag_inputs = hugr.num_inputs(src_node); + + // Clear the previous execution's virtual run first: the + // consumer walks virtual ports upward until the first + // gap, so a survivor from a LONGER previous payload + // would extend this one with stale data (and a payload + // element that is not ready yet must read as a gap, not + // as the old value). + let mut stale_idx = port_idx + 1; + while self + .wire_state + .classical_values + .remove(&(cond_node, stale_idx)) + .is_some() + { + stale_idx += 1; + } + for payload_idx in 0..num_tag_inputs { let tag_in_port = IncomingPort::from(payload_idx); if let Some((payload_src_node, payload_src_port)) = @@ -410,6 +427,13 @@ impl HugrEngine { /// Re-run case-input propagation for every active case (measurement /// results may have landed after the case expanded). pub(crate) fn repropagate_active_case_inputs(&mut self, hugr: &Hugr) { + // Unlike the CFG replay path, this writes WITHOUT clearing first: + // propagate_case_inputs copies whatever sources exist right now + // onto the Case's Input ports, overwriting like-for-like. That is + // safe here because a case expands exactly once per conditional + // resolution (its Input ports have a single producer -- this copy), + // whereas CFG block Inputs are rewritten every iteration and so + // need fill-only replay to avoid clobbering live state. let targets: Vec<(Node, Node)> = self .active_cases .iter() diff --git a/crates/pecos-hugr/src/engine/handlers.rs b/crates/pecos-hugr/src/engine/handlers.rs index aec37cbfd..1c113444c 100644 --- a/crates/pecos-hugr/src/engine/handlers.rs +++ b/crates/pecos-hugr/src/engine/handlers.rs @@ -147,7 +147,7 @@ impl HugrEngine { "tket.debug" => self.handle_debug_op(hugr, node, &op_name), "tket.bool" => self.handle_bool_op(hugr, node, &op_name), "tket.rotation" => self.handle_rotation_op(hugr, node, &op_name), - "tket.modifier" => self.handle_modifier_op(hugr, node, &op_name), + "tket.modifier" => Self::handle_modifier_op(node, &op_name), "tket.wasm" => self.handle_wasm_op(hugr, node, &op_name), "tket.guppy" => self.handle_guppy_op(hugr, node, &op_name), "tket.global_phase" => self.handle_global_phase_op(hugr, node, &op_name), diff --git a/crates/pecos-hugr/src/engine/handlers/classical.rs b/crates/pecos-hugr/src/engine/handlers/classical.rs index e43a92968..332af3b98 100644 --- a/crates/pecos-hugr/src/engine/handlers/classical.rs +++ b/crates/pecos-hugr/src/engine/handlers/classical.rs @@ -28,7 +28,7 @@ use log::debug; use tket::hugr::ops::OpType; -use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; +use tket::hugr::{Hugr, HugrView, IncomingPort, Node}; use crate::engine::HugrEngine; use crate::engine::handlers::{ClassicalOutcome, HandlerOutcome}; @@ -77,33 +77,32 @@ impl HugrEngine { node: Node, op: &ClassicalOp, ) -> ClassicalOutcome { - // Collect input values + // Collect input values. get_input_value (not a raw wire read) so + // values are found across flattened-DFG boundaries -- the executor + // must see exactly what the tracing layer sees, or a classical op + // fed by a nested DFG defers forever. let mut inputs = Vec::with_capacity(op.num_inputs); for port_idx in 0..op.num_inputs { let in_port = IncomingPort::from(port_idx); - if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { - let wire_key = (src_node, src_port.index()); - if let Some(value) = self.wire_state.classical_values.get(&wire_key) { - inputs.push(value.clone()); - } else if matches!(op.op_type, ClassicalOpType::TagSum) - && let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&wire_key) - { - // A Tag may carry linear payload elements (e.g. an - // iterator's Option over (qubit, state)): represent the - // qubit as a QubitRef so the Sum value materializes. - // Scoped to TagSum only -- a qubit input to an - // arithmetic op is a semantic error, not a value. - inputs.push(ClassicalValue::QubitRef(qubit_id)); - } else { - debug!( - "Classical op {node:?}: missing input value for port {port_idx} from {wire_key:?}" - ); - return ClassicalOutcome::Defer; - } - } else { + if hugr.single_linked_output(node, in_port).is_none() { debug!("Classical op {node:?}: no source for input port {port_idx}"); return ClassicalOutcome::Defer; } + if let Some(value) = self.get_input_value(hugr, node, port_idx) { + inputs.push(value); + } else if matches!(op.op_type, ClassicalOpType::TagSum) + && let Some(qubit_id) = self.get_input_qubit(hugr, node, port_idx) + { + // A Tag may carry linear payload elements (e.g. an + // iterator's Option over (qubit, state)): represent the + // qubit as a QubitRef so the Sum value materializes. + // Scoped to TagSum only -- a qubit input to an + // arithmetic op is a semantic error, not a value. + inputs.push(ClassicalValue::QubitRef(qubit_id)); + } else { + debug!("Classical op {node:?}: missing input value for port {port_idx}"); + return ClassicalOutcome::Defer; + } } // Multi-output / special arms first (they return whole port lists). diff --git a/crates/pecos-hugr/src/engine/handlers/debug.rs b/crates/pecos-hugr/src/engine/handlers/debug.rs index 05c71b860..f72891e89 100644 --- a/crates/pecos-hugr/src/engine/handlers/debug.rs +++ b/crates/pecos-hugr/src/engine/handlers/debug.rs @@ -36,7 +36,10 @@ impl HugrEngine { if op_name == "StateResult" { // StateResult: array -> array // Pass-through for simulation; optionally log state info - self.propagate_qubit_array(hugr, node); + if !self.propagate_qubit_array(hugr, node) { + debug!("StateResult at {node:?}: input not resolved, deferring"); + return HandlerOutcome::Defer; + } debug!("StateResult at {node:?} (no-op for simulation)"); HandlerOutcome::Processed } else { diff --git a/crates/pecos-hugr/src/engine/handlers/qsystem.rs b/crates/pecos-hugr/src/engine/handlers/qsystem.rs index cef299467..76b8c6eff 100644 --- a/crates/pecos-hugr/src/engine/handlers/qsystem.rs +++ b/crates/pecos-hugr/src/engine/handlers/qsystem.rs @@ -166,8 +166,10 @@ impl HugrEngine { "RuntimeBarrier" | "StateResult" => { // Pass-through operations: input array = output array // For simulation, these are no-ops - // Propagate qubit arrays if present - self.propagate_qubit_array(hugr, node); + if !self.propagate_qubit_array(hugr, node) { + debug!("{op_name} at {node:?}: input not resolved, deferring"); + return HandlerOutcome::Defer; + } debug!("{op_name} at {node:?} (no-op for simulation)"); HandlerOutcome::Processed } diff --git a/crates/pecos-hugr/src/engine/handlers/quantum.rs b/crates/pecos-hugr/src/engine/handlers/quantum.rs index 4d77750cd..89625b3fe 100644 --- a/crates/pecos-hugr/src/engine/handlers/quantum.rs +++ b/crates/pecos-hugr/src/engine/handlers/quantum.rs @@ -232,39 +232,20 @@ impl HugrEngine { } /// Handle `tket.modifier` operations for gate modifiers. - pub(crate) fn handle_modifier_op( - &mut self, - hugr: &Hugr, - node: Node, - op_name: &str, - ) -> HandlerOutcome { + pub(crate) fn handle_modifier_op(node: Node, op_name: &str) -> HandlerOutcome { debug!("Processing tket.modifier operation: {op_name} at {node:?}"); - // Gate modifiers change how gates are applied. - // For simulation, we track these as metadata but the actual gate - // application happens in the quantum backend. + // Gate modifiers (control/dagger/power) CHANGE the semantics of the + // operation they wrap. The engine has no machinery to apply that + // change: passing the qubits through and reporting Processed would + // silently execute the UNMODIFIED gate -- wrong answers, no error. + // Fail loud until modifier semantics are actually implemented. match op_name { - "ControlModifier" => { - // ControlModifier adds quantum control to an operation - // Input: control qubit(s) + operation - // For simulation, this is handled by the quantum backend - self.propagate_qubit_array(hugr, node); - debug!("ControlModifier at {node:?} (handled by quantum backend)"); - HandlerOutcome::Processed - } - "DaggerModifier" => { - // DaggerModifier applies the inverse/adjoint of an operation - // For simulation, this is handled by the quantum backend - self.propagate_qubit_array(hugr, node); - debug!("DaggerModifier at {node:?} (handled by quantum backend)"); - HandlerOutcome::Processed - } - "PowerModifier" => { - // PowerModifier raises an operation to a power - // For simulation, this is handled by the quantum backend - self.propagate_qubit_array(hugr, node); - debug!("PowerModifier at {node:?} (handled by quantum backend)"); - HandlerOutcome::Processed + "ControlModifier" | "DaggerModifier" | "PowerModifier" => { + HandlerOutcome::Fault(format!( + "tket.modifier {op_name} at {node:?} is not supported by the HUGR \ + engine; executing the wrapped operation unmodified would be wrong" + )) } _ => { debug!("Unknown tket.modifier operation: {op_name}"); @@ -284,11 +265,16 @@ impl HugrEngine { if op_name == "global_phase" { // global_phase: Rotation -> () - // Add global phase to the circuit - let phase = self + // Add global phase to the circuit. No silent zero default: the + // rotation may simply not have resolved YET, and folding it to + // 0 would drop the phase without any visible failure. + let Some(phase) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_rotation()) - .unwrap_or(0.0); + else { + debug!("tket.global_phase at {node:?}: rotation not resolved, deferring"); + return HandlerOutcome::Defer; + }; // Accumulate global phase (normalized to [0, 2)) self.extension_state.global_phase = diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index ce63a3170..b8aab2463 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -33,6 +33,12 @@ use crate::engine::analysis::get_container_type; use crate::engine::types::{ClassicalValue, ContainerType, QuantumOp, WireKey}; impl HugrEngine { + /// Ceiling on flattened-DFG boundary hops when tracing a value or + /// qubit backwards. Valid HUGRs bound this by nesting depth (the + /// hierarchy is a tree and each region a DAG); the cap keeps a + /// malformed graph from recursing without limit. + const MAX_DFG_TRACE_DEPTH: usize = 64; + /// Trace through an Input node to find the actual source wire. /// /// When processing nodes inside containers (DFG, Case, `FuncDefn`, etc.), @@ -66,15 +72,15 @@ impl HugrEngine { output_port } ContainerType::Conditional => { - // Conditional: Port 0 of Input unpacks Sum fields; subsequent ports are data - // This is complex - the Input node outputs come from unpacking the Sum - // For now, skip port 0 (Sum unpacking) and map other ports - if output_port == 0 { - debug!("Skipping Conditional Sum unpacking (port 0)"); - return None; - } - // Data ports start at container input port 1 (after control) - output_port // Actually maps to same port since control is separate + // Conditional: the Input row is [sum payload..., other data...] + // while the container's port row is [sum, other data...]. A + // direct port-N -> port-N mapping is only correct when the + // selected variant's payload arity is exactly 1; for any + // other arity it silently reads the WRONG container port. + // Case expansion populates these Input ports explicitly, so + // decline to trace rather than guess. + debug!("Conditional Input ports are populated by case expansion; not tracing"); + return None; } ContainerType::TailLoop => { // TailLoop is complex - inputs come from both initial values and CONTINUE tag @@ -189,6 +195,20 @@ impl HugrEngine { node: Node, port: usize, ) -> Option { + self.get_input_value_depth(hugr, node, port, 0) + } + + fn get_input_value_depth( + &self, + hugr: &Hugr, + node: Node, + port: usize, + depth: usize, + ) -> Option { + if depth > Self::MAX_DFG_TRACE_DEPTH { + debug!("get_input_value({node:?}, {port}): DFG trace depth exceeded"); + return None; + } let in_port = IncomingPort::from(port); if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { let wire_key = (src_node, src_port.index()); @@ -209,11 +229,17 @@ impl HugrEngine { // structurally, exactly like qubit tracing does. A DFG node's // outputs live at its Output child's sources; a DFG's Input // node ports live at the DFG node's own input wires. Bounded - // by nesting depth. + // by nesting depth (belt-and-braces cap above: a malformed + // graph must not recurse unboundedly). match hugr.get_optype(src_node) { OpType::DFG(_) => { let output_node = hugr.get_io(src_node).map(|[_, o]| o)?; - return self.get_input_value(hugr, output_node, src_port.index()); + return self.get_input_value_depth( + hugr, + output_node, + src_port.index(), + depth + 1, + ); } OpType::Input(_) if hugr @@ -221,7 +247,7 @@ impl HugrEngine { .is_some_and(|p| matches!(hugr.get_optype(p), OpType::DFG(_))) => { let dfg_node = hugr.get_parent(src_node)?; - return self.get_input_value(hugr, dfg_node, src_port.index()); + return self.get_input_value_depth(hugr, dfg_node, src_port.index(), depth + 1); } _ => {} } @@ -235,12 +261,45 @@ impl HugrEngine { /// Get a qubit ID from an input port. /// /// Follows the wire connected to the specified input port and returns - /// the qubit ID at the source, if any. + /// the qubit ID at the source, if any. Traces across flattened-DFG + /// boundaries the same way [`Self::get_input_value`] does, so both + /// value kinds resolve identically. pub(crate) fn get_input_qubit(&self, hugr: &Hugr, node: Node, port: usize) -> Option { + self.get_input_qubit_depth(hugr, node, port, 0) + } + + fn get_input_qubit_depth( + &self, + hugr: &Hugr, + node: Node, + port: usize, + depth: usize, + ) -> Option { + if depth > Self::MAX_DFG_TRACE_DEPTH { + debug!("get_input_qubit({node:?}, {port}): DFG trace depth exceeded"); + return None; + } let in_port = IncomingPort::from(port); if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { let wire_key = (src_node, src_port.index()); - self.wire_state.wire_to_qubit.get(&wire_key).copied() + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&wire_key) { + return Some(qubit_id); + } + match hugr.get_optype(src_node) { + OpType::DFG(_) => { + let output_node = hugr.get_io(src_node).map(|[_, o]| o)?; + self.get_input_qubit_depth(hugr, output_node, src_port.index(), depth + 1) + } + OpType::Input(_) + if hugr + .get_parent(src_node) + .is_some_and(|p| matches!(hugr.get_optype(p), OpType::DFG(_))) => + { + let dfg_node = hugr.get_parent(src_node)?; + self.get_input_qubit_depth(hugr, dfg_node, src_port.index(), depth + 1) + } + _ => None, + } } else { None } @@ -248,23 +307,32 @@ impl HugrEngine { /// Propagate qubit array from input to output (for pass-through operations). /// - /// This handles operations like barriers that pass qubit arrays through unchanged. - pub(crate) fn propagate_qubit_array(&mut self, hugr: &Hugr, node: Node) { + /// This handles operations like barriers that pass qubit arrays through + /// unchanged. Returns false when the input carried NEITHER an array nor + /// a single qubit: the caller must DEFER -- marking the pass-through + /// processed would permanently drop the late-arriving value (these ops + /// have no retry of their own). + #[must_use] + pub(crate) fn propagate_qubit_array(&mut self, hugr: &Hugr, node: Node) -> bool { // For now, just propagate qubit wire mappings let in_port = IncomingPort::from(0); + let mut found = false; if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { let src_key = (src_node, src_port.index()); // Propagate qubit array if present if let Some(qubits) = self.wire_state.qubit_arrays.get(&src_key).cloned() { self.wire_state.qubit_arrays.insert((node, 0), qubits); + found = true; } // Also propagate individual qubit mappings if let Some(qubit_id) = self.wire_state.wire_to_qubit.get(&src_key).copied() { self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); + found = true; } } + found } /// Resolve qubit IDs for an operation by following input wires. diff --git a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py index 368366472..d63297212 100644 --- a/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_comprehensive_guppy_features.py @@ -282,7 +282,7 @@ def boolean_or_test() -> bool: rows = results_or["hugr_llvm"]["result"]["results"] assert [int(r) for r in rows] == [1] * 10, f"OR path measurements: {rows}" - def test_classical_arithmetic(self, pipeline_tester: GuppyPipelineTest) -> None: + def test_classical_arithmetic(self) -> None: """Pure-classical programs surface the entrypoint's return value under the "return" key (no measurements, no result() calls).""" diff --git a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py index a21996342..8480c00cb 100644 --- a/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py +++ b/python/quantum-pecos/tests/guppy/test_extended_guppy_features.py @@ -333,7 +333,7 @@ def tuple_test() -> tuple[bool, bool]: correlated = sum(1 for (a, b) in measurements if a == b) assert correlated > 80, f"Tuple ops failed, correlation={correlated}/100" - def test_boolean_expressions(self, tester: ExtendedGuppyTester) -> None: + def test_boolean_expressions(self) -> None: """Pure-classical boolean returns surface under the "return" key.""" @guppy diff --git a/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py b/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py index 09b67d14d..41e9c43a1 100644 --- a/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py +++ b/python/quantum-pecos/tests/guppy/test_qubit_allocation_limits.py @@ -66,14 +66,16 @@ def dynamic_loop_test() -> int: average = sum(measurements) / len(measurements) assert 0.3 < average < 0.7, f"Average should be around 0.5 (last measurement only), got {average}" - def test_dynamic_allocation_exceeds_limit(self) -> None: - """Test behavior when program requires more qubits than available. + def test_allocation_exceeds_limit_fixed_size_simulator(self) -> None: + """A fixed-size simulator must reject allocation past the qubit limit. - This test verifies how the system handles programs that need more - qubits than the specified limit. The behavior depends on whether - the compiler can optimize the program to fit within the limit. + Stabilizer-family simulators do not grow: a program that touches a + qubit index at or beyond the configured capacity must fail with the + capacity-guard error naming the op, the qubit, and the capacity -- + not succeed silently or die with an unrelated IPC failure. """ from guppylang.std.quantum import cx + from pecos_rslib import sparse_stab @guppy def four_qubit_program() -> tuple[bool, bool, bool, bool]: @@ -92,62 +94,41 @@ def four_qubit_program() -> tuple[bool, bool, bool, bool]: # Measure all return measure(q0), measure(q1), measure(q2), measure(q3) - # Try to run with only 3 qubits available (need 4) - # This tests the system's resource constraint handling - allocation_succeeded = False - error_was_expected = False - - try: - results = sim(Guppy(four_qubit_program)).qubits(3).quantum(state_vector()).run(10) - allocation_succeeded = True - - # If it succeeded, verify we got some results - # The compiler might have optimized the program - assert hasattr(results, "__getitem__"), "Results should be dict-like" - - # Check if we got any measurements - # Results dict should have measurement keys - has_measurements = "measurement_0" in results or "measurements" in results or "result" in results - - # If no measurement keys, check if results dict has any content - if not has_measurements and len(results) > 0: - has_measurements = True - - # The assertion is not critical - if the sim succeeded with 3 qubits - # for a 4-qubit program, it means optimization worked - # An empty results dict can happen if the simulation framework - # optimized away the measurements or hasn't returned them yet - if not has_measurements: - pass # Simulation succeeded, which is the main test - - except (RuntimeError, ValueError, OSError) as e: - error_was_expected = True - error_msg = str(e).lower() - - # Verify the error is related to resource constraints or IPC failure - # IPC failures often happen when subprocess terminates due to resource limits - expected_error_keywords = [ - "qubit", # Qubit allocation error - "range", # Index out of range - "sigpipe", # Process communication error - "subprocess", # Subprocess failure - "cannot send", # Communication failure - "resource", # Resource limit - "allocation", # Allocation failure - "exceeded", # Limit exceeded - "broken pipe", # IPC failure when subprocess terminates - "pipe", # General pipe errors - "ipc", # IPC errors - ] - - assert any( - keyword in error_msg for keyword in expected_error_keywords - ), f"Error should be related to resource constraints, got: {e}" - - # Either optimization succeeded or we got an expected error - assert ( - allocation_succeeded or error_was_expected - ), "Should either succeed with optimization or fail with resource error" + with pytest.raises(RuntimeError, match=r"targets qubit 3.*holds 3 qubits"): + sim(Guppy(four_qubit_program)).qubits(3).quantum(sparse_stab()).run(10) + + def test_allocation_exceeds_limit_state_vector_grows(self) -> None: + """The state-vector engine grows past the configured qubit count. + + Unlike the fixed-size simulators, the state-vector engine expands to + the highest qubit index a message touches, so a 4-qubit program with + .qubits(3) runs anyway -- and must still produce CORRECT physics + (a GHZ chain measures all-equal), not results computed on a + truncated register. + """ + from guppylang.std.quantum import cx + + @guppy + def four_qubit_ghz() -> tuple[bool, bool, bool, bool]: + q0 = qubit() + q1 = qubit() + q2 = qubit() + q3 = qubit() + + h(q0) + cx(q0, q1) + cx(q1, q2) + cx(q2, q3) + + return measure(q0), measure(q1), measure(q2), measure(q3) + + results = sim(Guppy(four_qubit_ghz)).qubits(3).quantum(state_vector()).seed(42).run(10).to_dict() + + measurements = results["measurements"] + assert len(measurements) == 10, "Should have 10 shots" + for m in measurements: + assert len(m) == 4, f"Each shot should measure 4 qubits, got {len(m)}" + assert len(set(m)) == 1, f"GHZ measurements must all agree, got {m}" @pytest.mark.skip( reason="Nested loops with int return not supported by HUGR interpreter", From ff530670310c2f49b0d11c26f13f8f0b53de7767 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 20:40:43 -0600 Subject: [PATCH 382/388] Fold in round-11 verification: boundary copiers and resolve_qubits read through the DFG-tracing layer (a gate on a DFG output ran on a phantom qubit), conditional Sum payloads move to a dedicated map ending the real-port aliasing, TailLoop/Call inputs clear stale state and gain fill-only post-measurement repair, block re-activation resets descend flattened DFG interiors, replay edges carry cascade ids so within-cascade chains survive purging and only the latest cascade refills, return capture is positional at collection with signature-derived arity, and batch scheduling counts builder messages so handler-emitted commands are never dropped --- crates/pecos-hugr/src/engine.rs | 178 ++++++++++-------- crates/pecos-hugr/src/engine/activation.rs | 1 + .../src/engine/control_flow/call.rs | 85 ++++++--- .../pecos-hugr/src/engine/control_flow/cfg.rs | 155 +++++++++------ .../src/engine/control_flow/conditional.rs | 131 ++++++------- .../src/engine/control_flow/tailloop.rs | 77 ++++++-- .../pecos-hugr/src/engine/handlers/quantum.rs | 6 +- crates/pecos-hugr/src/engine/propagation.rs | 70 ++++--- crates/pecos-hugr/src/engine/types.rs | 14 +- 9 files changed, 433 insertions(+), 284 deletions(-) diff --git a/crates/pecos-hugr/src/engine.rs b/crates/pecos-hugr/src/engine.rs index 7f7bf6529..6dc547057 100644 --- a/crates/pecos-hugr/src/engine.rs +++ b/crates/pecos-hugr/src/engine.rs @@ -98,9 +98,11 @@ pub struct HugrEngine { pub(crate) active_scans: BTreeMap, /// The entrypoint's classical return values, captured when its CFG - /// completes. Pure-classical programs (no measurements, no `result()` + /// completes: entry i is output port i's value, or None if it never + /// materialized (positional, so a missing port cannot relabel the + /// rest). Pure-classical programs (no measurements, no `result()` /// calls) surface these as their shot results. - pub(crate) return_values: Vec, + pub(crate) return_values: Vec>, /// Container regions the engine actually activated this shot /// (`DataflowBlocks`, selected Cases, `TailLoop` bodies), with a label for @@ -161,7 +163,16 @@ pub struct HugrEngine { /// Pending block propagations that need re-propagation after measurement results. /// Stores (`cfg_node`, `from_block`, `to_block`) tuples. - pub(crate) pending_measurement_propagations: Vec<(Node, Node, Node)>, + pub(crate) pending_measurement_propagations: Vec<(Node, Node, Node, u64)>, + + /// Monotone id for each `transition_to_cfg_successor` invocation (one + /// synchronous cascade of block hops). Recorded on each replay edge so + /// (a) a block revisited WITHIN one cascade does not purge the older + /// hop into it -- the chain a late measurement value must walk -- while + /// a re-entry in a LATER cascade (next loop iteration) does, and (b) + /// replay uses only each CFG's latest cascade, never re-filling ports + /// from a superseded iteration's sources. + pub(crate) cfg_transition_cascade: u64, // === Call/FuncDefn Support === /// `FuncDefn` nodes extracted from the HUGR. @@ -394,6 +405,7 @@ impl HugrEngine { self.active_cfgs.clear(); self.pending_cfg_branches.clear(); self.pending_measurement_propagations.clear(); + self.cfg_transition_cascade = 0; // Clear Call/FuncDefn control flow state self.active_calls.clear(); @@ -721,7 +733,7 @@ impl HugrEngine { // measurement-propagation edges (completion purges them; // this covers a walk that never completed). self.pending_measurement_propagations - .retain(|(cfg, _, _)| *cfg != current_node); + .retain(|(cfg, _, _, _)| *cfg != current_node); // Start CFG execution by activating the entry block's operations let entry_block = cfg_info.entry_block; @@ -984,51 +996,53 @@ impl HugrEngine { } if let Some(func_info) = self.func_defns.get(&func_defn_node).cloned() { + // Resolve arguments through the tracing layer (an + // argument produced inside a flattened DFG must resolve + // like any other read). A port with neither a qubit nor + // a classical value stays cleared: some argument types + // are legitimately not modeled per-wire, so the Call + // launches anyway, and a late-arriving value (e.g. a + // measurement result) is repaired fill-only by + // repropagate_active_call_inputs after each + // measurement round. + let args: Vec<(Option, Option)> = (0..func_info + .num_inputs) + .map(|in_port| { + ( + self.get_input_qubit(&hugr, current_node, in_port), + self.get_input_value(&hugr, current_node, in_port), + ) + }) + .collect(); + // Clear every FuncDefn Input port before copying: the // frame reset below exempts the Input node (keep_wires) - // so the fresh arguments survive, which means a port - // whose source is missing would otherwise keep the - // PREVIOUS call's argument. Missing sources must starve - // loudly instead. + // so the fresh arguments survive -- a port must never + // keep the PREVIOUS call's argument. for in_port in 0..func_info.num_inputs { let func_input_wire = (func_info.input_node, in_port); self.wire_state.classical_values.remove(&func_input_wire); self.wire_state.wire_to_qubit.remove(&func_input_wire); } - - // Map Call inputs to FuncDefn Input node outputs - // Call inputs come from upstream nodes - for in_port in 0..func_info.num_inputs { - let call_in_port = IncomingPort::from(in_port); - if let Some((src_node, src_port)) = - hugr.single_linked_output(current_node, call_in_port) - { - let src_wire = (src_node, src_port.index()); - - // Map qubits - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - let func_input_wire = (func_info.input_node, in_port); - self.wire_state - .wire_to_qubit - .insert(func_input_wire, qubit_id); - debug!( - "Call {:?}: mapped input {} qubit {:?} to FuncDefn Input {:?}", - current_node, in_port, qubit_id, func_info.input_node - ); - } - // Map classical values (including arrays) - if let Some(value) = - self.wire_state.classical_values.get(&src_wire).cloned() - { - let func_input_wire = (func_info.input_node, in_port); - self.wire_state - .classical_values - .insert(func_input_wire, value.clone()); - debug!( - "Call {:?}: mapped input {} classical value {:?} to FuncDefn Input {:?}", - current_node, in_port, value, func_info.input_node - ); - } + for (in_port, (qubit, value)) in args.into_iter().enumerate() { + let func_input_wire = (func_info.input_node, in_port); + if let Some(qubit_id) = qubit { + self.wire_state + .wire_to_qubit + .insert(func_input_wire, qubit_id); + debug!( + "Call {:?}: mapped input {} qubit {:?} to FuncDefn Input {:?}", + current_node, in_port, qubit_id, func_info.input_node + ); + } + if let Some(value) = value { + debug!( + "Call {:?}: mapped input {} classical value {:?} to FuncDefn Input {:?}", + current_node, in_port, value, func_info.input_node + ); + self.wire_state + .classical_values + .insert(func_input_wire, value); } } @@ -1386,11 +1400,22 @@ impl HugrEngine { if let Some(fault) = self.execution_error.take() { return Err(PecosError::Generic(fault)); } - if operation_count == 0 { - // Queue drained with no measurement pause: this is the engine's + // "Anything to send?" is the BUILDER's message count, not the + // dispatch loop's operation_count: extension handlers (qsystem + // Measure/MeasureReset/...) emit commands without passing through + // the quantum-op arm, and QAlloc/QFree count as operations without + // emitting anything. Judging by operation_count dropped a batch + // whose only commands were handler-emitted. + if operation_count == 0 && self.message_builder.message_count() == 0 { + // No progress at all this batch: this is the engine's // completion claim. Any still-active control flow or starved - // deferred node at this point means execution stalled mid-program - // -- fail loud instead of returning silently truncated results. + // deferred node at this point means execution stalled + // mid-program -- fail loud instead of returning silently + // truncated results. A batch that made progress but emitted + // nothing (e.g. lifecycle ops only) falls through and returns + // an empty message: the driver's round-trip re-enters + // handle_measurements, whose repropagation can unstick work + // that is waiting on already-recorded values. self.ensure_no_stalled_execution()?; debug!("No operations processed"); return Ok(None); @@ -1885,6 +1910,7 @@ impl Default for HugrEngine { active_cfgs: BTreeMap::new(), pending_cfg_branches: BTreeMap::new(), pending_measurement_propagations: Vec::new(), + cfg_transition_cascade: 0, // Control flow fields (Call/FuncDefn) func_defns: BTreeMap::new(), call_targets: BTreeMap::new(), @@ -2018,6 +2044,12 @@ impl ClassicalEngine for HugrEngine { // own control before OTHER data inputs (later // measurements) exist; refresh its Input ports now. self.repropagate_active_case_inputs(&hugr); + // And for launched calls whose argument was a + // measurement result still in flight (fill-only). + self.repropagate_active_call_inputs(&hugr); + // And for first-iteration tail loops in the same + // situation (fill-only, never past the first Continue). + self.repropagate_tailloop_initial_inputs(&hugr); // Replay can fill the very port a pending control was // starving on (the resolver pass above ran before the @@ -2067,36 +2099,26 @@ impl ClassicalEngine for HugrEngine { // entrypoint's return values -- "return" for a single value, // "return_{port}" for multiple. if self.measurement_state.results.is_empty() && !self.return_values.is_empty() { - // Keys carry the ACTUAL port index: compacting around an - // unconvertible value (a Tuple/Sum return) would silently - // relabel every later port, and "return" only applies when - // the function genuinely returns one value. - let scalars: Vec<(usize, Data)> = self - .return_values - .iter() - .enumerate() - .filter_map(|(port, value)| { - let data = match value { - ClassicalValue::Bool(b) => Some(Data::Bool(*b)), - ClassicalValue::Int(i) => Some(Data::I64(*i)), - ClassicalValue::UInt(u) => Some(Data::U64(*u)), - ClassicalValue::Float(f) => Some(Data::F64(*f)), - _ => { - debug!( - "return port {port}: non-scalar value {value:?} not surfaced" - ); - None - } - }; - data.map(|d| (port, d)) - }) - .collect(); - if self.return_values.len() == 1 { - if let Some((_, data)) = scalars.into_iter().next() { + // Positional capture: return_values.len() IS the entrypoint's + // output arity (missing ports are None), so "return" applies + // exactly when the function returns one value, and every key + // carries the actual port index. + let single_return = self.return_values.len() == 1; + for (port, value) in self.return_values.iter().enumerate() { + let Some(value) = value else { continue }; + let data = match value { + ClassicalValue::Bool(b) => Data::Bool(*b), + ClassicalValue::Int(i) => Data::I64(*i), + ClassicalValue::UInt(u) => Data::U64(*u), + ClassicalValue::Float(f) => Data::F64(*f), + _ => { + debug!("return port {port}: non-scalar value {value:?} not surfaced"); + continue; + } + }; + if single_return { result.data.insert("return".to_string(), data); - } - } else { - for (port, data) in scalars { + } else { result.data.insert(format!("return_{port}"), data); } } @@ -2734,8 +2756,10 @@ mod tests { // idivmod_s with a "negative" divisor: the divisor port is UNSIGNED // per the spec, so canonical -3 reads as its bit pattern (2^64 - 3) - // and the huge divisor makes q = 0, r = the dividend. This is the - // Selene-validated guppy semantics (7 // -3 == 0, 7 % -3 == 7). + // and the huge divisor makes q = 0, r = the dividend. This pins the + // RAW OP's semantics (what guppy-compiled programs observe, validated + // against the Selene reference) -- NOT Python-level `//`, which a + // frontend handling divisor sign separately would layer on top. let got = run( &mut engine, "idivmod_s", diff --git a/crates/pecos-hugr/src/engine/activation.rs b/crates/pecos-hugr/src/engine/activation.rs index e1d5870c8..7aa4611aa 100644 --- a/crates/pecos-hugr/src/engine/activation.rs +++ b/crates/pecos-hugr/src/engine/activation.rs @@ -133,6 +133,7 @@ impl HugrEngine { for port in 0..hugr.num_outputs(*node) { self.wire_state.classical_values.remove(&(*node, port)); self.wire_state.wire_to_qubit.remove(&(*node, port)); + self.wire_state.conditional_payloads.remove(&(*node, port)); } } diff --git a/crates/pecos-hugr/src/engine/control_flow/call.rs b/crates/pecos-hugr/src/engine/control_flow/call.rs index 70dc22ca2..e7e9beb14 100644 --- a/crates/pecos-hugr/src/engine/control_flow/call.rs +++ b/crates/pecos-hugr/src/engine/control_flow/call.rs @@ -35,11 +35,49 @@ use log::debug; use tket::hugr::ops::OpType; use tket::hugr::types::TypeArg; -use tket::hugr::{Hugr, HugrView, IncomingPort, Node, PortIndex}; +use tket::hugr::{Hugr, HugrView, Node}; use crate::engine::HugrEngine; impl HugrEngine { + /// Fill-only repair of active calls' argument ports. + /// + /// A Call launches as soon as it is dispatched; an argument that is a + /// measurement result still in flight leaves its `FuncDefn` Input port + /// cleared. Called after each measurement round: writes only ports that + /// are currently MISSING (never overwrites live frame state), reading + /// through the tracing layer. + pub(crate) fn repropagate_active_call_inputs(&mut self, hugr: &Hugr) { + let targets: Vec<(Node, Node, usize)> = self + .active_calls + .iter() + .filter_map(|(&call_node, info)| { + self.func_defns + .get(&info.func_defn_node) + .map(|fi| (call_node, fi.input_node, fi.num_inputs)) + }) + .collect(); + for (call_node, input_node, num_inputs) in targets { + for port in 0..num_inputs { + let wire = (input_node, port); + if self.wire_state.classical_values.contains_key(&wire) + || self.wire_state.wire_to_qubit.contains_key(&wire) + { + continue; + } + if let Some(qubit_id) = self.get_input_qubit(hugr, call_node, port) { + self.wire_state.wire_to_qubit.insert(wire, qubit_id); + } + if let Some(value) = self.get_input_value(hugr, call_node, port) { + debug!( + "Call {call_node:?}: late argument {port} repaired with {value:?} on {wire:?}" + ); + self.wire_state.classical_values.insert(wire, value); + } + } + } + } + /// Resolve a type variable used inside a called function body to the /// concrete `BoundedNat` from the calling `Call`'s instantiation args. /// @@ -138,33 +176,26 @@ impl HugrEngine { for port in 0..func_info.num_outputs { // Check if we have a wire mapping for the FuncDefn Output input // FuncDefn Output receives from CFG outputs - let output_in_port = IncomingPort::from(port); - if let Some((src_node, src_port)) = - hugr.single_linked_output(func_info.output_node, output_in_port) + // Traced reads: a return value produced inside a + // flattened DFG must resolve like any other read. + let call_output_wire = (call_node, port); + if let Some(qubit_id) = self.get_input_qubit(hugr, func_info.output_node, port) { - let src_wire = (src_node, src_port.index()); - // Map qubits - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - let call_output_wire = (call_node, port); - self.wire_state - .wire_to_qubit - .insert(call_output_wire, qubit_id); - debug!( - "Call {call_node:?}: mapped FuncDefn output {port} qubit {qubit_id:?} to Call output" - ); - } - // Map classical values (including arrays) - if let Some(value) = - self.wire_state.classical_values.get(&src_wire).cloned() - { - let call_output_wire = (call_node, port); - self.wire_state - .classical_values - .insert(call_output_wire, value.clone()); - debug!( - "Call {call_node:?}: mapped FuncDefn output {port} classical value {value:?} to Call output" - ); - } + self.wire_state + .wire_to_qubit + .insert(call_output_wire, qubit_id); + debug!( + "Call {call_node:?}: mapped FuncDefn output {port} qubit {qubit_id:?} to Call output" + ); + } + // Map classical values (including arrays) + if let Some(value) = self.get_input_value(hugr, func_info.output_node, port) { + debug!( + "Call {call_node:?}: mapped FuncDefn output {port} classical value {value:?} to Call output" + ); + self.wire_state + .classical_values + .insert(call_output_wire, value); } } diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index 14fca9301..b5c77dde5 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -379,6 +379,9 @@ impl HugrEngine { // ITERATIVE transition: a chain of empty blocks re-enters this // logic once per hop. Recursing here overflowed the stack around // ~10^5 hops -- an abort, not the clean MAX_CFG_TRANSITIONS error. + // Every hop inside this invocation shares one cascade id. + self.cfg_transition_cascade += 1; + let cascade = self.cfg_transition_cascade; let mut from_block = from_block; let mut to_block = to_block; 'transition: loop { @@ -406,10 +409,13 @@ impl HugrEngine { // and carry a late measurement value across it. (The old // latest-edge-only policy amputated chains: the A->empty hop // was dropped and the value never reached the consumer.) + // A revisit WITHIN this cascade must not purge the older hop + // into the same block (that hop is part of the chain a late + // value walks); a re-entry in a LATER cascade supersedes it. self.pending_measurement_propagations - .retain(|(cfg, _, to)| *cfg != cfg_node || *to != to_block); + .retain(|(cfg, _, to, c)| *cfg != cfg_node || *to != to_block || *c == cascade); self.pending_measurement_propagations - .push((cfg_node, from_block, to_block)); + .push((cfg_node, from_block, to_block, cascade)); // Update active CFG state. Guppy loops lower to CFG cycles, so the // transition count doubles as the loop-iteration ceiling: a @@ -456,10 +462,36 @@ impl HugrEngine { // only when their loop expands. let block_input_node = find_input_node(hugr, to_block); let extension_ops: Vec = find_extension_ops_in_block(hugr, to_block); + + // DFG interiors FLATTEN into the global op maps, so no + // container machinery owns their re-activation -- without + // resetting them here, an interior op (e.g. a Tag over + // loop-carried values) keeps its previous-iteration wire + // forever, and the tracing layer serves that frozen value + // SILENTLY. Collect them and treat them like direct + // children. (Nested Conditionals/TailLoops own their own + // frames; only DFG chains are descended.) + let mut dfg_interior: Vec = Vec::new(); + let mut dfg_stack: Vec = hugr + .children(to_block) + .filter(|c| matches!(hugr.get_optype(*c), OpType::DFG(_))) + .collect(); + while let Some(dfg) = dfg_stack.pop() { + for child in hugr.children(dfg) { + dfg_interior.push(child); + if matches!(hugr.get_optype(child), OpType::DFG(_)) { + dfg_stack.push(child); + } + } + } + let mut act = ContainerActivation::new(); for child in hugr.children(to_block) { act.reset_wires(child); } + for &child in &dfg_interior { + act.reset_wires(child); + } if let Some(input) = block_input_node { act.keep_wires(input); } @@ -474,7 +506,7 @@ impl HugrEngine { { act.reset_processed(op_node); } - for child in hugr.children(to_block) { + for child in hugr.children(to_block).chain(dfg_interior.iter().copied()) { if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) || self.classical_ops.contains_key(&child) { @@ -507,7 +539,7 @@ impl HugrEngine { for &op_node in &extension_ops { submit(&mut act, op_node, QueuePolicy::Always); } - for child in hugr.children(to_block) { + for child in hugr.children(to_block).chain(dfg_interior.iter().copied()) { if matches!(hugr.get_optype(child), OpType::LoadConstant(_)) { submit(&mut act, child, QueuePolicy::Always); } @@ -647,7 +679,7 @@ impl HugrEngine { // re-invocation (next Call / scan element) starts a fresh chain // against reset frame state. self.pending_measurement_propagations - .retain(|(cfg, _, _)| *cfg != cfg_node); + .retain(|(cfg, _, _, _)| *cfg != cfg_node); // The ENTRYPOINT's CFG completing means main returned: capture its // classical return values so pure-classical programs surface them. @@ -668,13 +700,25 @@ impl HugrEngine { } }); if parent_is_entry_func { - let mut values = Vec::new(); - for port in 0..hugr.num_outputs(cfg_node) { - if let Some(value) = self.wire_state.classical_values.get(&(cfg_node, port)) { - values.push(value.clone()); - } - } - if !values.is_empty() { + // Positional capture: entry i holds port i's value or None. + // Compacting present values would silently relabel every later + // port (and make a 2-port return with port 0 missing surface + // port 1 under "return"). Arity comes from the dataflow + // SIGNATURE -- the portgraph count includes the order port. + use tket::hugr::ops::OpTrait; + let arity = hugr + .get_optype(cfg_node) + .dataflow_signature() + .map_or(0, |sig| sig.output_count()); + let values: Vec> = (0..arity) + .map(|port| { + self.wire_state + .classical_values + .get(&(cfg_node, port)) + .cloned() + }) + .collect(); + if values.iter().any(Option::is_some) { self.return_values = values; } } @@ -807,9 +851,10 @@ impl HugrEngine { // If the branch Sum has an executed VALUE (e.g. built by a Tag // over classical values, like a loop's carried state), its - // payload elements are the successor's first inputs. + // payload elements are the successor's first inputs. Traced + // read: the Sum may be produced inside a flattened DFG. if let Some(ClassicalValue::Sum { values, .. }) = - self.wire_state.classical_values.get(&sum_wire).cloned() + self.get_input_value(hugr, from_output, 0) { payload_len = values.len(); for (i, value) in values.into_iter().enumerate().take(expected_payload_len) { @@ -834,22 +879,25 @@ impl HugrEngine { .insert((to_input, i), value); } } - // Check if it's a Conditional - extract payload from virtual output ports + // Check if it's a Conditional -- read the payload recorded for + // the EXACT output port feeding this block's Sum (the payload + // map is keyed by (conditional, output port); the old scheme + // stored payloads at "virtual" classical-value ports, which + // aliased real output ports AND was read from index 1 + // regardless of which output the Tag fed). else if matches!(sum_src_op, OpType::Conditional(_)) { - // Look for payload values at virtual output ports (1, 2, ...) - let mut idx = 1; - while let Some(value) = self + let payload = self .wire_state - .classical_values - .get(&(sum_src_node, idx)) + .conditional_payloads + .get(&sum_wire) .cloned() - { - let to_wire = (to_input, idx - 1); + .unwrap_or_default(); + for (idx, value) in payload.into_iter().enumerate() { + let to_wire = (to_input, idx); if !(fill_only && self.wire_state.classical_values.contains_key(&to_wire)) { self.wire_state.classical_values.insert(to_wire, value); } payload_len += 1; - idx += 1; } } } @@ -885,7 +933,7 @@ impl HugrEngine { ); let src_wire = (src_node, src_port.index()); - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) + if let Some(qubit_id) = self.get_input_qubit(hugr, from_output, port_idx + 1) && !(fill_only && self .wire_state @@ -914,8 +962,7 @@ impl HugrEngine { // Also propagate classical values if target_filled { // Fill-only replay never overwrites a live value. - } else if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() - { + } else if let Some(value) = self.get_input_value(hugr, from_output, port_idx + 1) { let to_wire = (to_input, to_port_idx); debug!( "[TRACE] Block transition: propagated classical value {value:?} from {src_wire:?} to {to_wire:?}" @@ -984,9 +1031,18 @@ impl HugrEngine { // across empty-block hops. Edges stay recorded (fill-only is // idempotent); they are purged when their target re-activates or // their CFG completes. + // Only each CFG's LATEST cascade replays: older cascades' edges + // point at sources a later iteration may have rewritten, and + // fill-only stops overwrites but not wrong FILLS of legitimately + // missing ports. + let mut latest: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for (cfg_node, _, _, cascade) in &self.pending_measurement_propagations { + let entry = latest.entry(*cfg_node).or_insert(*cascade); + *entry = (*entry).max(*cascade); + } let pending = self.pending_measurement_propagations.clone(); - for (cfg_node, from_block, to_block) in pending { - if self.active_cfgs.contains_key(&cfg_node) { + for (cfg_node, from_block, to_block, cascade) in pending { + if self.active_cfgs.contains_key(&cfg_node) && latest.get(&cfg_node) == Some(&cascade) { self.propagate_block_outputs(hugr, from_block, to_block, true); } } @@ -1022,9 +1078,9 @@ impl HugrEngine { // direct Tag, or a Sum routed through a Conditional's output): // its payload elements are the CFG outputs (a called function's // return value rides here). - let sum_wire = (sum_src, sum_src_port.index()); + let _sum_wire = (sum_src, sum_src_port.index()); if let Some(ClassicalValue::Sum { values, .. }) = - self.wire_state.classical_values.get(&sum_wire).cloned() + self.get_input_value(hugr, output_node, 0) { payload_len = values.len(); for (i, value) in values.into_iter().enumerate().take(expected_payload_len) { @@ -1046,26 +1102,17 @@ impl HugrEngine { .dataflow_signature() .map_or(0, |sig| sig.input_count()); for i in 0..num_payload { - if let Some((vsrc, vport)) = - hugr.single_linked_output(sum_src, IncomingPort::from(i)) - { - let src_wire = (vsrc, vport.index()); - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - self.wire_state - .wire_to_qubit - .insert((cfg_node, i), qubit_id); - debug!( - "CFG {cfg_node:?} output {i}: mapped payload qubit {qubit_id:?}" - ); - } - if let Some(value) = - self.wire_state.classical_values.get(&src_wire).cloned() - { - debug!("CFG {cfg_node:?} output {i}: mapped payload value {value:?}"); - self.wire_state - .classical_values - .insert((cfg_node, i), value); - } + if let Some(qubit_id) = self.get_input_qubit(hugr, sum_src, i) { + self.wire_state + .wire_to_qubit + .insert((cfg_node, i), qubit_id); + debug!("CFG {cfg_node:?} output {i}: mapped payload qubit {qubit_id:?}"); + } + if let Some(value) = self.get_input_value(hugr, sum_src, i) { + debug!("CFG {cfg_node:?} output {i}: mapped payload value {value:?}"); + self.wire_state + .classical_values + .insert((cfg_node, i), value); } } payload_len = num_payload; @@ -1084,16 +1131,14 @@ impl HugrEngine { let block_port = IncomingPort::from(port_idx + 1); // Skip Sum port let cfg_out_idx = expected_payload_len + port_idx; - if let Some((src_node, src_port)) = hugr.single_linked_output(output_node, block_port) { - let src_wire = (src_node, src_port.index()); - - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { + if hugr.single_linked_output(output_node, block_port).is_some() { + if let Some(qubit_id) = self.get_input_qubit(hugr, output_node, port_idx + 1) { self.wire_state .wire_to_qubit .insert((cfg_node, cfg_out_idx), qubit_id); debug!("CFG {cfg_node:?} output {cfg_out_idx}: mapped qubit {qubit_id:?}"); } - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { + if let Some(value) = self.get_input_value(hugr, output_node, port_idx + 1) { debug!("CFG {cfg_node:?} output {cfg_out_idx}: mapped value {value:?}"); self.wire_state .classical_values diff --git a/crates/pecos-hugr/src/engine/control_flow/conditional.rs b/crates/pecos-hugr/src/engine/control_flow/conditional.rs index ad1829429..e53269de1 100644 --- a/crates/pecos-hugr/src/engine/control_flow/conditional.rs +++ b/crates/pecos-hugr/src/engine/control_flow/conditional.rs @@ -237,18 +237,17 @@ impl HugrEngine { let out_in_port = IncomingPort::from(port_idx); // Find what's connected to this Output node input - if let Some((src_node, src_port)) = hugr.single_linked_output(output_node, out_in_port) + if let Some((src_node, _src_port)) = hugr.single_linked_output(output_node, out_in_port) { - let src_wire = (src_node, src_port.index()); - - // Check if we have a qubit mapping for this wire - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { + // Reads go through the tracing layer so a case output + // produced inside a flattened DFG resolves. + if let Some(qubit_id) = self.get_input_qubit(hugr, output_node, port_idx) { // Map to the Conditional's output port self.wire_state .wire_to_qubit .insert((cond_node, port_idx), qubit_id); debug!( - "Mapped Conditional {cond_node:?} output {port_idx} to qubit {qubit_id:?} (from {src_wire:?})" + "Mapped Conditional {cond_node:?} output {port_idx} to qubit {qubit_id:?}" ); } @@ -257,14 +256,14 @@ impl HugrEngine { // value -- the structural fallback below must NOT overwrite // it with just the tag number. let has_value = if let Some(value) = - self.wire_state.classical_values.get(&src_wire).cloned() + self.get_input_value(hugr, output_node, port_idx) { - self.wire_state - .classical_values - .insert((cond_node, port_idx), value.clone()); debug!( - "Mapped Conditional {cond_node:?} output {port_idx} to classical value {value:?} (from {src_wire:?})" + "Mapped Conditional {cond_node:?} output {port_idx} to classical value {value:?}" ); + self.wire_state + .classical_values + .insert((cond_node, port_idx), value); true } else { false @@ -280,53 +279,35 @@ impl HugrEngine { .classical_values .insert((cond_node, port_idx), ClassicalValue::Int(tag_value as i64)); - // Also extract and store the Tag's inputs (Sum payload values) - // Store them at "virtual" output ports (1, 2, ...) on the Conditional - // These will be used during CFG block transitions + // Also extract and store the Tag's inputs (Sum payload + // values) in the DEDICATED payload map, keyed by this + // output port, for CFG block transitions to consume. + // Whole-vector replace doubles as the stale-run clear + // (a previous, longer payload cannot survive), and a + // not-yet-ready element truncates the vector -- the + // consumer sees exactly the ready prefix, never a stale + // value. Elements read through the tracing layer so a + // payload produced inside a flattened DFG resolves. let num_tag_inputs = hugr.num_inputs(src_node); - - // Clear the previous execution's virtual run first: the - // consumer walks virtual ports upward until the first - // gap, so a survivor from a LONGER previous payload - // would extend this one with stale data (and a payload - // element that is not ready yet must read as a gap, not - // as the old value). - let mut stale_idx = port_idx + 1; - while self - .wire_state - .classical_values - .remove(&(cond_node, stale_idx)) - .is_some() - { - stale_idx += 1; - } - + let mut payload = Vec::with_capacity(num_tag_inputs); for payload_idx in 0..num_tag_inputs { - let tag_in_port = IncomingPort::from(payload_idx); - if let Some((payload_src_node, payload_src_port)) = - hugr.single_linked_output(src_node, tag_in_port) + if let Some(payload_value) = + self.get_input_value(hugr, src_node, payload_idx) { - let payload_src_wire = (payload_src_node, payload_src_port.index()); - if let Some(payload_value) = self - .wire_state - .classical_values - .get(&payload_src_wire) - .cloned() - { - // Store at virtual output port (port_idx + 1 + payload_idx) - // This allows CFG block transitions to find the payload values - let virtual_port = port_idx + 1 + payload_idx; - debug!( - "Conditional {cond_node:?} Tag payload {payload_idx}: {payload_value:?} at virtual port {virtual_port}" - ); - self.wire_state - .classical_values - .insert((cond_node, virtual_port), payload_value); - } else { - debug!("No payload value at {payload_src_wire:?}"); - } + debug!( + "Conditional {cond_node:?} output {port_idx} Tag payload {payload_idx}: {payload_value:?}" + ); + payload.push(payload_value); + } else { + debug!( + "Conditional {cond_node:?} Tag payload {payload_idx} not ready; truncating" + ); + break; } } + self.wire_state + .conditional_payloads + .insert((cond_node, port_idx), payload); } } } @@ -394,32 +375,30 @@ impl HugrEngine { // following the payload values unpacked above. let num_cond_inputs = hugr.num_inputs(cond_node); - // Start from port 1 (skip control), propagate all inputs + // Start from port 1 (skip control), propagate all inputs. Reads go + // through the tracing layer so a data input produced inside a + // flattened DFG resolves. for port_idx in 1..num_cond_inputs { - let cond_in_port = IncomingPort::from(port_idx); - if let Some((src_node, src_port)) = hugr.single_linked_output(cond_node, cond_in_port) { - let src_wire = (src_node, src_port.index()); - let input_output_idx = payload_len + port_idx - 1; + let input_output_idx = payload_len + port_idx - 1; - // Propagate qubit mappings - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - self.wire_state - .wire_to_qubit - .insert((input_node, input_output_idx), qubit_id); - debug!( - "Propagated qubit {qubit_id:?} to Input node {input_node:?} port {input_output_idx}" - ); - } + // Propagate qubit mappings + if let Some(qubit_id) = self.get_input_qubit(hugr, cond_node, port_idx) { + self.wire_state + .wire_to_qubit + .insert((input_node, input_output_idx), qubit_id); + debug!( + "Propagated qubit {qubit_id:?} to Input node {input_node:?} port {input_output_idx}" + ); + } - // Also propagate classical values (integers, bools, etc.) - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { - debug!( - "Propagated classical value {value:?} from {src_wire:?} to Case Input ({input_node:?}, {input_output_idx})" - ); - self.wire_state - .classical_values - .insert((input_node, input_output_idx), value); - } + // Also propagate classical values (integers, bools, etc.) + if let Some(value) = self.get_input_value(hugr, cond_node, port_idx) { + debug!( + "Propagated classical value {value:?} to Case Input ({input_node:?}, {input_output_idx})" + ); + self.wire_state + .classical_values + .insert((input_node, input_output_idx), value); } } } diff --git a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs index 0fe802499..ac710fe8d 100644 --- a/crates/pecos-hugr/src/engine/control_flow/tailloop.rs +++ b/crates/pecos-hugr/src/engine/control_flow/tailloop.rs @@ -216,33 +216,70 @@ impl HugrEngine { let input_node = tailloop_info.input_node; if iteration == 0 { - // First iteration: inputs come from TailLoop's external inputs + // First iteration: inputs come from TailLoop's external inputs, + // read through the tracing layer (an initial value produced + // inside a flattened DFG must resolve like any other read). + // Clear each port first: a loop re-expanded inside a re-run + // frame must not keep the PREVIOUS execution's final-iteration + // value on a port whose source is not ready yet (the same + // stale-argument class fixed for Call inputs). Late values are + // repaired fill-only after measurement rounds. for port_idx in 0..tailloop_info.num_inputs { - let tailloop_in_port = IncomingPort::from(port_idx); - if let Some((src_node, src_port)) = - hugr.single_linked_output(tailloop_node, tailloop_in_port) - { - let src_wire = (src_node, src_port.index()); - if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&src_wire) { - self.wire_state - .wire_to_qubit - .insert((input_node, port_idx), qubit_id); - debug!( - "TailLoop {tailloop_node:?} iter {iteration}: propagated qubit {qubit_id:?} to Input port {port_idx}" - ); - } - // Also propagate classical values - if let Some(value) = self.wire_state.classical_values.get(&src_wire).cloned() { - self.wire_state - .classical_values - .insert((input_node, port_idx), value); - } + let input_wire = (input_node, port_idx); + self.wire_state.classical_values.remove(&input_wire); + self.wire_state.wire_to_qubit.remove(&input_wire); + if let Some(qubit_id) = self.get_input_qubit(hugr, tailloop_node, port_idx) { + self.wire_state.wire_to_qubit.insert(input_wire, qubit_id); + debug!( + "TailLoop {tailloop_node:?} iter {iteration}: propagated qubit {qubit_id:?} to Input port {port_idx}" + ); + } + if let Some(value) = self.get_input_value(hugr, tailloop_node, port_idx) { + self.wire_state.classical_values.insert(input_wire, value); } } } // For subsequent iterations, propagate_continue_values handles this } + /// Fill-only repair of active tail loops still on their FIRST + /// iteration: an initial value that was a measurement result in flight + /// at expansion left its body-Input port cleared. Called after each + /// measurement round; never touches a loop past its first Continue + /// (those ports belong to continue propagation) and never overwrites a + /// live port. + pub(crate) fn repropagate_tailloop_initial_inputs(&mut self, hugr: &Hugr) { + let targets: Vec<(Node, Node, usize)> = self + .active_tailloops + .iter() + .filter(|(_, active)| active.iteration == 0) + .filter_map(|(&tailloop_node, _)| { + self.tailloops + .get(&tailloop_node) + .map(|info| (tailloop_node, info.input_node, info.num_inputs)) + }) + .collect(); + for (tailloop_node, input_node, num_inputs) in targets { + for port_idx in 0..num_inputs { + let input_wire = (input_node, port_idx); + if self.wire_state.classical_values.contains_key(&input_wire) + || self.wire_state.wire_to_qubit.contains_key(&input_wire) + { + continue; + } + if let Some(qubit_id) = self.get_input_qubit(hugr, tailloop_node, port_idx) { + self.wire_state.wire_to_qubit.insert(input_wire, qubit_id); + } + if let Some(value) = self.get_input_value(hugr, tailloop_node, port_idx) { + debug!( + "TailLoop {tailloop_node:?}: late initial input {port_idx} repaired with {value:?}" + ); + self.wire_state.classical_values.insert(input_wire, value); + } + } + } + } + /// Continue a `TailLoop` with a new iteration after receiving `CONTINUE_TAG`. #[allow(clippy::too_many_lines)] // Loop iteration control flow is inherently complex pub(crate) fn continue_tailloop_iteration(&mut self, hugr: &Hugr, tailloop_node: Node) { diff --git a/crates/pecos-hugr/src/engine/handlers/quantum.rs b/crates/pecos-hugr/src/engine/handlers/quantum.rs index 89625b3fe..34abcd678 100644 --- a/crates/pecos-hugr/src/engine/handlers/quantum.rs +++ b/crates/pecos-hugr/src/engine/handlers/quantum.rs @@ -267,7 +267,11 @@ impl HugrEngine { // global_phase: Rotation -> () // Add global phase to the circuit. No silent zero default: the // rotation may simply not have resolved YET, and folding it to - // 0 would drop the phase without any visible failure. + // 0 would drop the phase without any visible failure. Note the + // blast radius: global phase is unobservable in every result + // this engine surfaces, yet a rotation that NEVER resolves + // fails the whole program via the stall report -- accepted + // (defer-forever bugs must stay loud), revisit if it bites. let Some(phase) = self .get_input_value(hugr, node, 0) .and_then(|v| v.as_rotation()) diff --git a/crates/pecos-hugr/src/engine/propagation.rs b/crates/pecos-hugr/src/engine/propagation.rs index b8aab2463..ee595b05a 100644 --- a/crates/pecos-hugr/src/engine/propagation.rs +++ b/crates/pecos-hugr/src/engine/propagation.rs @@ -83,8 +83,15 @@ impl HugrEngine { return None; } ContainerType::TailLoop => { - // TailLoop is complex - inputs come from both initial values and CONTINUE tag - // For simplicity, use direct mapping + // Direct mapping reads the loop node's own input wire: the + // iteration-0 value. Guppy carries every live value in the + // varying row, so past the first Continue this CAN serve a + // stale generation for a mutated value -- but the corpus + // relies on it for carried-but-invariant values whose + // continue propagation gapped on a pending measurement. + // The proper fix is completeness of the population paths + // (continue repropagation after measurement rounds), not + // starving this fallback. output_port } ContainerType::Call => { @@ -305,32 +312,29 @@ impl HugrEngine { } } - /// Propagate qubit array from input to output (for pass-through operations). + /// Propagate a pass-through operation's input to its output port + /// (barriers, `StateResult`). /// - /// This handles operations like barriers that pass qubit arrays through - /// unchanged. Returns false when the input carried NEITHER an array nor - /// a single qubit: the caller must DEFER -- marking the pass-through - /// processed would permanently drop the late-arriving value (these ops - /// have no retry of their own). + /// Qubit arrays travel as `ClassicalValue::Array` of `QubitRef`s, so + /// the CLASSICAL value is what must pass through; a bare qubit wire + /// passes its mapping. Both read through the tracing layer so an input + /// produced across a flattened-DFG boundary resolves. Returns false + /// when the input carried NEITHER: the caller must DEFER -- marking the + /// pass-through processed would permanently drop the late-arriving + /// value (these ops have no retry of their own). #[must_use] pub(crate) fn propagate_qubit_array(&mut self, hugr: &Hugr, node: Node) -> bool { - // For now, just propagate qubit wire mappings - let in_port = IncomingPort::from(0); let mut found = false; - if let Some((src_node, src_port)) = hugr.single_linked_output(node, in_port) { - let src_key = (src_node, src_port.index()); - - // Propagate qubit array if present - if let Some(qubits) = self.wire_state.qubit_arrays.get(&src_key).cloned() { - self.wire_state.qubit_arrays.insert((node, 0), qubits); - found = true; - } - - // Also propagate individual qubit mappings - if let Some(qubit_id) = self.wire_state.wire_to_qubit.get(&src_key).copied() { - self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); - found = true; + if let Some(value) = self.get_input_value(hugr, node, 0) { + if let ClassicalValue::QubitRef(qubit_id) = &value { + self.wire_state.wire_to_qubit.insert((node, 0), *qubit_id); } + self.wire_state.classical_values.insert((node, 0), value); + found = true; + } + if let Some(qubit_id) = self.get_input_qubit(hugr, node, 0) { + self.wire_state.wire_to_qubit.insert((node, 0), qubit_id); + found = true; } found } @@ -395,6 +399,26 @@ impl HugrEngine { } } + // A DFG source executes by FLATTENING: its output qubits + // live at its Output child's sources, never at the DFG + // node's own wires -- and the DFG node is marked processed + // at dispatch, so falling through would hit the implicit- + // allocation branch below and run this gate on a PHANTOM + // qubit. Trace instead; defer if the interior has not + // produced the qubit yet. + if matches!(src_op, OpType::DFG(_)) + && !self.wire_state.wire_to_qubit.contains_key(&wire_key) + { + if let Some(qubit_id) = self.get_input_qubit(hugr, node, port_idx) { + self.wire_state.wire_to_qubit.insert(wire_key, qubit_id); + } else { + debug!( + "resolve_qubits at {node:?}: DFG source {wire_key:?} not resolved, deferring" + ); + return None; + } + } + if let Some(&qubit_id) = self.wire_state.wire_to_qubit.get(&wire_key) { qubits.push(qubit_id); diff --git a/crates/pecos-hugr/src/engine/types.rs b/crates/pecos-hugr/src/engine/types.rs index fda1d5ccc..3dc400c9f 100644 --- a/crates/pecos-hugr/src/engine/types.rs +++ b/crates/pecos-hugr/src/engine/types.rs @@ -44,16 +44,20 @@ use std::collections::BTreeMap; /// State for tracking wire values through the HUGR graph. /// /// This groups all wire-related state: qubit mappings, classical values, -/// and qubit arrays. The propagation system uses this to track values -/// as they flow from output ports to connected input ports. +/// and conditional Sum payloads. The propagation system uses this to track +/// values as they flow from output ports to connected input ports. #[derive(Debug, Default, Clone)] pub struct WireState { /// Map from (node, `output_port`) to qubit ID for tracking wire flow. pub wire_to_qubit: BTreeMap, /// Classical wire values: tracks bool/integer/float values flowing through wires. pub classical_values: BTreeMap, - /// Maps array wire keys to lists of qubit IDs for qubit arrays. - pub qubit_arrays: BTreeMap>, + /// Payload elements of a Conditional output resolved through the + /// structural Tag fallback, keyed by (conditional node, output port). + /// A DEDICATED map: storing these at "virtual" classical-value ports + /// aliased the Conditional's real output ports (payload for output 0 + /// landed on output 1's wire key). + pub conditional_payloads: BTreeMap>, /// Next available qubit ID. pub next_qubit_id: usize, } @@ -63,7 +67,7 @@ impl WireState { pub fn reset(&mut self) { self.wire_to_qubit.clear(); self.classical_values.clear(); - self.qubit_arrays.clear(); + self.conditional_payloads.clear(); self.next_qubit_id = 0; } } From 898d383568f72af34121717cdb0c832247fb43e3 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 21:16:37 -0600 Subject: [PATCH 383/388] Raise idna floor to 3.18 in docs requirements, matching Dependabot #323 --- docs/requirements.txt | 2 +- python/quantum-pecos/docs/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index a85de4885..b030fb559 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,4 +4,4 @@ mkdocstrings>=0.24.0 mkdocstrings-python>=1.7.4 markdown-exec>=1.10.3 pymdown-extensions>=10.21.3 -idna>=3.15 # CVE-2026-45409 (re-fix of CVE-2024-3651): idna.encode() DoS on crafted long inputs +idna>=3.18 # CVE-2026-45409 (re-fix of CVE-2024-3651): idna.encode() DoS on crafted long inputs diff --git a/python/quantum-pecos/docs/requirements.txt b/python/quantum-pecos/docs/requirements.txt index 8b28a56f4..127a31858 100644 --- a/python/quantum-pecos/docs/requirements.txt +++ b/python/quantum-pecos/docs/requirements.txt @@ -1,6 +1,6 @@ matplotlib==3.9.2 pillow>=11.3.0 # CVE-2023-50447 + libwebp; transitive via matplotlib -idna>=3.15 # CVE-2026-45409 (re-fix of CVE-2024-3651): idna.encode() DoS on crafted long inputs +idna>=3.18 # CVE-2026-45409 (re-fix of CVE-2024-3651): idna.encode() DoS on crafted long inputs networkx==3.3 numpy==1.26.4 scipy==1.14.1 From 920fe20bba284d40f2a20e7d7ad2b994a172c1eb Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 21:31:23 -0600 Subject: [PATCH 384/388] Allow workflow_dispatch on the optional-dependency postmerge lane and dedupe exp/** in rust-test path filters --- .github/workflows/python-test.yml | 6 +++++- .github/workflows/rust-test.yml | 2 -- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index 955b29e07..cda6c3424 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -517,7 +517,11 @@ jobs: # tests and the optional-dependency suite (guppy/selene/QIR runtime tests # marked `optional_dependency`). python-slow-postmerge: - if: github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name) + # workflow_dispatch escape hatch: the optional-dependency suite + # (guppy/selene/QIR) otherwise has NO pre-merge path -- a branch whose + # risk lives in that suite could only get its first CI signal after + # landing. Dispatch is manual-only; the PR fast path is unaffected. + if: (github.event_name == 'push' && contains(fromJSON('["main", "master", "development", "dev"]'), github.ref_name)) || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 90 env: diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 5e3dc16dd..27e58a966 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -17,7 +17,6 @@ on: - 'crates/**' - 'exp/**' - 'examples/**' - - 'exp/**' - 'julia/pecos-julia-ffi/**' - 'python/pecos-rslib-cuda/**' - 'python/pecos-rslib/**' @@ -36,7 +35,6 @@ on: - 'crates/**' - 'exp/**' - 'examples/**' - - 'exp/**' - 'julia/pecos-julia-ffi/**' - 'python/pecos-rslib-cuda/**' - 'python/pecos-rslib/**' From fb78c85112ca9709de2af4540ecce27a14c4607d Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 21:45:18 -0600 Subject: [PATCH 385/388] Add workflow_dispatch trigger to rust-test.yml so the suite can be run manually against a branch --- .github/workflows/rust-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/rust-test.yml b/.github/workflows/rust-test.yml index 27e58a966..056214914 100644 --- a/.github/workflows/rust-test.yml +++ b/.github/workflows/rust-test.yml @@ -11,6 +11,7 @@ env: LLVM_RELEASE_VERSION: "21.1.8" on: + workflow_dispatch: push: branches: [ "main", "master", "development", "dev" ] paths: From f4ae972918025dd543b507c0a57e51a0b25eb821 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Sun, 5 Jul 2026 22:11:25 -0600 Subject: [PATCH 386/388] Build the exp wheel in the postmerge lane: pytest collection imports tests/qec modules that need pecos_rslib_exp (lane has failed on dev since those tests landed) --- .github/workflows/python-test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/python-test.yml b/.github/workflows/python-test.yml index cda6c3424..f69ddb28f 100644 --- a/.github/workflows/python-test.yml +++ b/.github/workflows/python-test.yml @@ -599,7 +599,12 @@ jobs: key: ${{ steps.cache-llvm.outputs.cache-primary-key }} - name: Build core Python packages - run: just python-ci-build + # python-ci-build-test, not python-ci-build: pytest COLLECTION imports + # every module under tests/, and tests/qec imports pecos_rslib_exp at + # module level -- without the exp wheel the whole lane dies in + # collection (it has failed this way on dev since the qec tests + # landed). + run: just python-ci-build-test - name: Run optional-dependency Python tests run: just pytest-dep From 8911fa56ea7079c92d2e2ef6b081812d1cd664a4 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 6 Jul 2026 00:59:09 -0600 Subject: [PATCH 387/388] Keep LLVM out of the base pecos-rslib wheel: split pecos's hugr feature into lean hugr (interpreter) and hugr-qis (LLVM compile chain), move compile_hugr_to_qis to the pecos-rslib-llvm wheel and result_tags/envelope loading to pecos-hugr, lower HUGR to QIS at runtime via the LLVM wheel when a QIS/Selene classical engine is selected, and re-attach the stored program when classical() swaps the engine builder (LLVM 21's Windows build links zlib.dll, which broke clean-machine imports of the base wheel) --- Cargo.lock | 2 +- crates/pecos-hugr-qis/src/lib.rs | 3 - crates/pecos-hugr/src/lib.rs | 2 + .../src/result_tags.rs | 2 +- .../tests/fixtures/arr.hugr | Bin .../tests/fixtures/computed.hugr | Bin .../tests/fixtures/looped.hugr | Bin .../tests/fixtures/scrambled.hugr | Bin crates/pecos/Cargo.toml | 11 +- crates/pecos/src/lib.rs | 2 +- crates/pecos/src/prelude.rs | 2 +- python/pecos-rslib-llvm/Cargo.toml | 1 + .../src/hugr_compilation_bindings.rs | 2 +- python/pecos-rslib-llvm/src/lib.rs | 7 ++ python/pecos-rslib/Cargo.toml | 8 +- .../pecos-rslib/src/dag_circuit_bindings.rs | 7 +- python/pecos-rslib/src/engine_builders.rs | 4 + .../pecos-rslib/src/experimental_bindings.rs | 2 +- python/pecos-rslib/src/lib.rs | 2 - python/pecos-rslib/src/prelude.rs | 1 - python/pecos-rslib/src/sim.rs | 97 ++++++++++-------- .../pecos-rslib/tests/test_additional_hugr.py | 2 +- .../tests/test_hugr_integration.py | 2 +- python/pecos-rslib/tests/test_phir.py | 4 +- .../src/pecos/_compilation/guppy.py | 2 +- .../src/pecos/compilation_pipeline.py | 2 +- .../src/pecos/engines/__init__.py | 32 +++++- .../quantum-pecos/src/pecos/execute_llvm.py | 9 +- .../tests/guppy/test_advanced_gates.py | 11 +- .../tests/guppy/test_advanced_types.py | 13 +-- .../tests/guppy/test_crz_angle_arithmetic.py | 11 +- .../tests/guppy/test_guppy_sim_builder.py | 5 +- .../tests/guppy/test_hugr_compiler_parity.py | 2 +- .../guppy/test_hugr_interpreter_parity.py | 3 +- .../tests/guppy/test_hugr_to_llvm_parsing.py | 8 +- .../tests/guppy/test_multi_module_handling.py | 2 +- .../tests/guppy/test_project_z.py | 13 +-- .../guppy/test_quantum_gates_complete.py | 33 +++--- .../quantum-pecos/tests/guppy/test_reset.py | 13 +-- .../tests/guppy/test_rotation_extension.py | 14 +-- .../quantum-pecos/tests/guppy/test_v_gates.py | 11 +- .../guppy/test_working_guppy_pipeline.py | 3 +- 42 files changed, 211 insertions(+), 139 deletions(-) rename crates/{pecos-hugr-qis => pecos-hugr}/src/result_tags.rs (99%) rename crates/{pecos-hugr-qis => pecos-hugr}/tests/fixtures/arr.hugr (100%) rename crates/{pecos-hugr-qis => pecos-hugr}/tests/fixtures/computed.hugr (100%) rename crates/{pecos-hugr-qis => pecos-hugr}/tests/fixtures/looped.hugr (100%) rename crates/{pecos-hugr-qis => pecos-hugr}/tests/fixtures/scrambled.hugr (100%) rename python/{pecos-rslib => pecos-rslib-llvm}/src/hugr_compilation_bindings.rs (96%) diff --git a/Cargo.lock b/Cargo.lock index 979fe6f41..2398038d6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4778,7 +4778,6 @@ dependencies = [ "pecos-engines", "pecos-experimental", "pecos-hugr", - "pecos-hugr-qis", "pecos-num", "pecos-phir", "pecos-phir-json", @@ -4836,6 +4835,7 @@ version = "0.2.0-dev.0" dependencies = [ "inkwell", "log", + "pecos-hugr-qis", "pecos-llvm", "pyo3", "regex", diff --git a/crates/pecos-hugr-qis/src/lib.rs b/crates/pecos-hugr-qis/src/lib.rs index d1e836c76..2186dcdb5 100644 --- a/crates/pecos-hugr-qis/src/lib.rs +++ b/crates/pecos-hugr-qis/src/lib.rs @@ -62,7 +62,6 @@ The compiler supports standard LLVM optimization levels: pub mod array; pub mod compiler; pub mod prelude; -pub mod result_tags; mod utils; // Re-export main types and functions @@ -75,8 +74,6 @@ pub use compiler::{ // Re-export read_hugr_envelope from utils pub use utils::read_hugr_envelope; -pub use result_tags::{extract_result_tag_measurements, measurement_op_count}; - // Re-export inkwell's OptimizationLevel for convenience pub use tket::hugr::llvm::inkwell::OptimizationLevel; diff --git a/crates/pecos-hugr/src/lib.rs b/crates/pecos-hugr/src/lib.rs index 97984cf6e..334ed77ea 100644 --- a/crates/pecos-hugr/src/lib.rs +++ b/crates/pecos-hugr/src/lib.rs @@ -77,10 +77,12 @@ mod builder; mod engine; mod loader; +mod result_tags; pub use builder::{HugrEngineBuilder, hugr_engine, hugr_sim}; pub use engine::{CapturedResult, ClassicalValue, FutureId, HugrEngine, ResultValue, RngContextId}; pub use loader::{load_hugr_from_bytes, load_hugr_from_file}; +pub use result_tags::{extract_result_tag_measurements, measurement_op_count}; // Re-export key types for convenience pub use pecos_engines::prelude::{ByteMessage, ClassicalEngine, ControlEngine, Engine}; diff --git a/crates/pecos-hugr-qis/src/result_tags.rs b/crates/pecos-hugr/src/result_tags.rs similarity index 99% rename from crates/pecos-hugr-qis/src/result_tags.rs rename to crates/pecos-hugr/src/result_tags.rs index e0c7466e5..7fb338d5a 100644 --- a/crates/pecos-hugr-qis/src/result_tags.rs +++ b/crates/pecos-hugr/src/result_tags.rs @@ -143,7 +143,7 @@ pub fn extract_result_tag_measurements>( #[cfg(test)] mod tests { use super::*; - use crate::read_hugr_envelope; + use crate::load_hugr_from_bytes as read_hugr_envelope; // Fixtures compiled from Guppy (committed so the regression does not // depend on a Python toolchain at test time): diff --git a/crates/pecos-hugr-qis/tests/fixtures/arr.hugr b/crates/pecos-hugr/tests/fixtures/arr.hugr similarity index 100% rename from crates/pecos-hugr-qis/tests/fixtures/arr.hugr rename to crates/pecos-hugr/tests/fixtures/arr.hugr diff --git a/crates/pecos-hugr-qis/tests/fixtures/computed.hugr b/crates/pecos-hugr/tests/fixtures/computed.hugr similarity index 100% rename from crates/pecos-hugr-qis/tests/fixtures/computed.hugr rename to crates/pecos-hugr/tests/fixtures/computed.hugr diff --git a/crates/pecos-hugr-qis/tests/fixtures/looped.hugr b/crates/pecos-hugr/tests/fixtures/looped.hugr similarity index 100% rename from crates/pecos-hugr-qis/tests/fixtures/looped.hugr rename to crates/pecos-hugr/tests/fixtures/looped.hugr diff --git a/crates/pecos-hugr-qis/tests/fixtures/scrambled.hugr b/crates/pecos-hugr/tests/fixtures/scrambled.hugr similarity index 100% rename from crates/pecos-hugr-qis/tests/fixtures/scrambled.hugr rename to crates/pecos-hugr/tests/fixtures/scrambled.hugr diff --git a/crates/pecos/Cargo.toml b/crates/pecos/Cargo.toml index a6836f2f5..c945dc42e 100644 --- a/crates/pecos/Cargo.toml +++ b/crates/pecos/Cargo.toml @@ -85,8 +85,13 @@ llvm = ["sim", "dep:pecos-llvm"] # qis: QIS/LLVM IR execution support (Selene runtime) qis = ["llvm", "dep:pecos-qis", "pecos-qis/llvm"] -# hugr: HUGR program support (requires QIS) + direct Guppy HUGR interpreter -hugr = ["qis", "dep:pecos-hugr-qis", "pecos-hugr-qis/llvm", "pecos-qis/hugr", "pecos-quantum/hugr", "dep:pecos-hugr"] +# hugr: the direct Guppy HUGR interpreter + HUGR<->DAG conversion. NO LLVM: +# this is what the base Python wheel enables, and it must not force an LLVM +# link (LLVM 21's Windows build drags zlib.dll into any linking artifact). +hugr = ["sim", "pecos-quantum/hugr", "dep:pecos-hugr"] + +# hugr-qis: HUGR -> QIS (LLVM IR) compilation on top of the interpreter. +hugr-qis = ["hugr", "qis", "dep:pecos-hugr-qis", "pecos-hugr-qis/llvm", "pecos-qis/hugr"] wasm = ["sim", "dep:pecos-wasm", "pecos-wasm/wasm"] # Quantum simulator backends (C++ wrappers) @@ -106,7 +111,7 @@ all-decoders = ["ldpc", "pymatching", "fusion-blossom", "tesseract", "chromobius qec = ["quantum", "dep:pecos-qec"] # Everything -full = ["runtime", "all-simulators", "all-decoders", "hugr", "wasm", "qec"] +full = ["runtime", "all-simulators", "all-decoders", "hugr-qis", "wasm", "qec"] [dev-dependencies] criterion.workspace = true diff --git a/crates/pecos/src/lib.rs b/crates/pecos/src/lib.rs index 52a90f5cf..0aede2b66 100644 --- a/crates/pecos/src/lib.rs +++ b/crates/pecos/src/lib.rs @@ -48,7 +48,7 @@ pub mod engines { /// Quantum circuit representation and Pauli algebra. #[cfg(feature = "quantum")] pub mod quantum { - #[cfg(feature = "hugr")] + #[cfg(feature = "hugr-qis")] pub use pecos_hugr_qis::read_hugr_envelope; #[cfg(feature = "hugr")] pub use pecos_quantum::hugr_convert::{ diff --git a/crates/pecos/src/prelude.rs b/crates/pecos/src/prelude.rs index 072e34f38..5d5d266de 100644 --- a/crates/pecos/src/prelude.rs +++ b/crates/pecos/src/prelude.rs @@ -95,7 +95,7 @@ pub use pecos_random::prelude::*; pub use pecos_num::prelude::*; // Re-export HUGR compiler prelude -#[cfg(feature = "hugr")] +#[cfg(feature = "hugr-qis")] pub use pecos_hugr_qis::prelude::*; // Re-export Guppy HUGR direct interpreter (HugrEngine) diff --git a/python/pecos-rslib-llvm/Cargo.toml b/python/pecos-rslib-llvm/Cargo.toml index 3a81db1af..321c3e236 100644 --- a/python/pecos-rslib-llvm/Cargo.toml +++ b/python/pecos-rslib-llvm/Cargo.toml @@ -26,6 +26,7 @@ extension-module = [ [dependencies] pecos-llvm.workspace = true +pecos-hugr-qis.workspace = true inkwell.workspace = true pyo3.workspace = true regex.workspace = true diff --git a/python/pecos-rslib/src/hugr_compilation_bindings.rs b/python/pecos-rslib-llvm/src/hugr_compilation_bindings.rs similarity index 96% rename from python/pecos-rslib/src/hugr_compilation_bindings.rs rename to python/pecos-rslib-llvm/src/hugr_compilation_bindings.rs index d3da7798a..bf37bfbd4 100644 --- a/python/pecos-rslib/src/hugr_compilation_bindings.rs +++ b/python/pecos-rslib-llvm/src/hugr_compilation_bindings.rs @@ -1,5 +1,5 @@ // Python bindings for HUGR to LLVM compilation -use crate::prelude::*; +use pecos_hugr_qis::{CompileArgs, QSystemPlatform, compile_hugr_bytes_to_string_with_options}; use std::fs; use pyo3::prelude::*; diff --git a/python/pecos-rslib-llvm/src/lib.rs b/python/pecos-rslib-llvm/src/lib.rs index 7e254139c..1b5ef9ea1 100644 --- a/python/pecos-rslib-llvm/src/lib.rs +++ b/python/pecos-rslib-llvm/src/lib.rs @@ -15,6 +15,7 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. +mod hugr_compilation_bindings; mod llvm_bindings; use pyo3::prelude::*; @@ -28,5 +29,11 @@ fn pecos_rslib_llvm(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { // Register binding module (pecos_rslib_llvm.binding) llvm_bindings::register_binding_module(m)?; + // HUGR -> QIS (LLVM IR) compilation entry points. These live in the + // LLVM wheel, not base pecos-rslib: they are what forces an LLVM link, + // and the base wheel must import cleanly on machines without LLVM's + // runtime dependencies (LLVM 21's Windows build pulls zlib.dll). + hugr_compilation_bindings::register_hugr_compilation_functions(m)?; + Ok(()) } diff --git a/python/pecos-rslib/Cargo.toml b/python/pecos-rslib/Cargo.toml index c31890e3b..a252957ed 100644 --- a/python/pecos-rslib/Cargo.toml +++ b/python/pecos-rslib/Cargo.toml @@ -61,8 +61,12 @@ pecos-phir = { workspace = true, features = ["hugr"] } pecos-phir-json.workspace = true # QIS / HUGR -pecos-qis = { workspace = true, features = ["llvm", "hugr"] } -pecos-hugr-qis.workspace = true +# pecos-qis WITHOUT its llvm/hugr features, and NO pecos-hugr-qis: those +# pull inkwell/LLVM link directives into this cdylib, and the base wheel +# must import cleanly on machines without LLVM's runtime dependencies +# (LLVM 21's Windows build links zlib.dll). HUGR->QIS compilation lives in +# pecos-rslib-llvm. +pecos-qis.workspace = true pecos-hugr.workspace = true # WASM (optional, enabled by default via "wasm" feature) diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 50f110181..a7df3b1d2 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -1800,7 +1800,7 @@ fn tick_gate_error_to_pyerr(err: TickGateError, tick_idx: Option) -> PyEr #[pyfunction] #[pyo3(name = "hugr_to_dag_circuit")] fn py_hugr_to_dag_circuit(hugr_bytes: &Bound<'_, PyBytes>) -> PyResult { - use pecos_hugr_qis::read_hugr_envelope; + use pecos_hugr::load_hugr_from_bytes as read_hugr_envelope; use pecos_quantum::hugr_convert::hugr_to_dag_circuit; let bytes = hugr_bytes.as_bytes(); @@ -1842,8 +1842,9 @@ fn py_resolve_result_tags_for_guppy( hugr_bytes: &Bound<'_, PyBytes>, traced_meas_count: usize, ) -> PyResult<(String, String)> { - use pecos_hugr_qis::{ - extract_result_tag_measurements, measurement_op_count, read_hugr_envelope, + use pecos_hugr::{ + extract_result_tag_measurements, load_hugr_from_bytes as read_hugr_envelope, + measurement_op_count, }; use pecos_qec::fault_tolerance::dem_builder::resolve_result_tags; diff --git a/python/pecos-rslib/src/engine_builders.rs b/python/pecos-rslib/src/engine_builders.rs index 08226789a..2f6210c35 100644 --- a/python/pecos-rslib/src/engine_builders.rs +++ b/python/pecos-rslib/src/engine_builders.rs @@ -234,6 +234,7 @@ impl PyQisEngineBuilder { explicit_num_qubits: None, keep_intermediate_files: false, hugr_bytes: None, + qis_source: None, operation_trace_dir: None, }), }) @@ -399,6 +400,9 @@ pub struct PyQisControlSimBuilder { pub(crate) explicit_num_qubits: Option, pub(crate) keep_intermediate_files: bool, pub(crate) hugr_bytes: Option>, + /// The QIS IR source, kept so classical() can re-attach the program + /// when a fresh engine builder replaces the program-loaded one. + pub(crate) qis_source: Option, pub(crate) operation_trace_dir: Option, } diff --git a/python/pecos-rslib/src/experimental_bindings.rs b/python/pecos-rslib/src/experimental_bindings.rs index 75b82c39b..30f8143d7 100644 --- a/python/pecos-rslib/src/experimental_bindings.rs +++ b/python/pecos-rslib/src/experimental_bindings.rs @@ -21,7 +21,7 @@ use pecos_experimental::{ DepolarizingNoiseModel, HugrExecutionError, NoisyMeasurementHistory, NoisyMeasurementHistoryBuilder, NoisyMeasurementSampler, execute_hugr, }; -use pecos_hugr_qis::read_hugr_envelope; +use pecos_hugr::load_hugr_from_bytes as read_hugr_envelope; use pecos_quantum::Circuit; use pecos_quantum::hugr_convert::SimpleHugr; use pecos_simulators::{MeasurementHistory, MeasurementSampler, SymbolicSparseStab}; diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index c01231733..0de139841 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -48,7 +48,6 @@ mod experimental_bindings; mod fault_tolerance_bindings; mod gate_registry_bindings; mod graph_bindings; -mod hugr_compilation_bindings; mod namespace_modules; mod num_bindings; mod pauli_bindings; @@ -291,7 +290,6 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { engine_builders::register_engine_builders(m)?; // Register HUGR compilation functions - hugr_compilation_bindings::register_hugr_compilation_functions(m)?; // Register numerical computing module (scipy.optimize replacements) num_bindings::register_num_module(m)?; diff --git a/python/pecos-rslib/src/prelude.rs b/python/pecos-rslib/src/prelude.rs index fadea427a..caeb493e3 100644 --- a/python/pecos-rslib/src/prelude.rs +++ b/python/pecos-rslib/src/prelude.rs @@ -25,7 +25,6 @@ pub use pecos_num::prelude::*; pub use pecos_qis::prelude::*; // HUGR compilation -pub use pecos_hugr_qis::prelude::*; // PHIR-JSON format pub use pecos_phir_json::prelude::*; diff --git a/python/pecos-rslib/src/sim.rs b/python/pecos-rslib/src/sim.rs index 4413c85a2..efe540af8 100644 --- a/python/pecos-rslib/src/sim.rs +++ b/python/pecos-rslib/src/sim.rs @@ -34,10 +34,6 @@ fn unwrap_engine_builder_proxy(py: Python, engine_builder: Py) -> PyResul } } -fn clone_py_any_option(py: Python, value: Option<&Py>) -> Option> { - value.map(|inner| inner.clone_ref(py)) -} - /// Check if a Python object is a Guppy function fn is_guppy_function(py: Python, obj: &Py) -> PyResult { // Check if guppylang module is available @@ -155,6 +151,10 @@ pub fn sim(py: Python, program: Py) -> PyResult { explicit_num_qubits: None, keep_intermediate_files: false, hugr_bytes: None, // QIS programs don't have HUGR bytes + qis_source: match &qis_prog.inner.content { + pecos_programs::QisContent::Ir(ir) => Some(ir.clone()), + pecos_programs::QisContent::Bitcode(_) => None, + }, operation_trace_dir: None, }), }) @@ -283,7 +283,20 @@ impl PySimBuilder { } SimBuilderInner::QisControl(sim_builder) => { if let Ok(qis_engine) = engine_builder.extract::(py) { - sim_builder.engine_builder = Arc::new(Mutex::new(Some(qis_engine.inner))); + // A fresh builder replaces the program-loaded one: + // re-attach the stored QIS source, or the built + // engine has no program to run. + let mut inner = qis_engine.inner; + if let Some(ref source) = sim_builder.qis_source { + inner = inner + .try_program(pecos_programs::Qis::from_string(source)) + .map_err(|e| { + PyRuntimeError::new_err(format!( + "Failed to re-attach QIS program to the new engine builder: {e}" + )) + })?; + } + sim_builder.engine_builder = Arc::new(Mutex::new(Some(inner))); Ok(PySimBuilder { inner: self.inner.clone(), }) @@ -324,13 +337,29 @@ impl PySimBuilder { "HUGR program bytes are not available to switch this simulation onto the QIS/Helios path", ) })?; + // HUGR -> QIS lowering lives in the pecos-rslib-llvm + // extension (this wheel does not LINK LLVM): call it + // through Python at runtime, failing loudly when it + // is not installed. + let ir: String = py + .import("pecos_rslib_llvm") + .map_err(|_| { + PyRuntimeError::new_err( + "running a HUGR program on the QIS/Selene engine requires \ + the pecos-rslib-llvm package for HUGR -> QIS lowering \ + (the base pecos-rslib wheel does not link LLVM)", + ) + })? + .getattr("compile_hugr_to_qis")? + .call1((pyo3::types::PyBytes::new(py, &hugr_bytes), py.None()))? + .extract()?; let qis_engine = qis_engine .inner .clone() - .try_program(Hugr::from_bytes(hugr_bytes.clone())) + .try_program(pecos_programs::Qis::from_string(&ir)) .map_err(|e| { PyRuntimeError::new_err(format!( - "Failed to load HUGR program into QIS engine: {e}" + "Failed to load lowered HUGR program into QIS engine: {e}" )) })?; @@ -340,17 +369,18 @@ impl PySimBuilder { seed: sim_builder.seed, workers: sim_builder.workers, shots: sim_builder.shots, - quantum_engine_builder: clone_py_any_option( - py, - sim_builder.quantum_engine_builder.as_ref(), - ), - noise_builder: clone_py_any_option( - py, - sim_builder.noise_builder.as_ref(), - ), + quantum_engine_builder: sim_builder + .quantum_engine_builder + .as_ref() + .map(|obj| obj.clone_ref(py)), + noise_builder: sim_builder + .noise_builder + .as_ref() + .map(|obj| obj.clone_ref(py)), explicit_num_qubits: sim_builder.explicit_num_qubits, keep_intermediate_files: sim_builder.keep_intermediate_files, hugr_bytes: Some(hugr_bytes), + qis_source: Some(ir), operation_trace_dir: None, }), }) @@ -574,7 +604,9 @@ impl PySimBuilder { /// When enabled, the built simulation will have a `temp_dir` attribute /// pointing to a directory containing: /// - `program.hugr` - The HUGR bytes (if available) - /// - `program.ll` - The compiled LLVM IR + /// + /// (LLVM IR is no longer saved here: HUGR -> QIS compilation lives in + /// the pecos-rslib-llvm wheel; use its `compile_hugr_to_qis`.) fn keep_intermediate_files(&mut self, keep: bool) -> PyResult { match &mut self.inner { SimBuilderInner::QisControl(builder) => { @@ -1500,20 +1532,9 @@ impl PySimBuilder { PyRuntimeError::new_err(format!("Failed to write HUGR file: {e}")) })?; - // Also compile and save LLVM IR - match compile_hugr_bytes_to_string(hugr_bytes) { - Ok(llvm_ir) => { - let ll_file = temp_path.join("program.ll"); - std::fs::write(&ll_file, llvm_ir).map_err(|e| { - PyRuntimeError::new_err(format!( - "Failed to write LLVM IR file: {e}" - )) - })?; - } - Err(e) => { - log::warn!("Could not compile HUGR to LLVM IR for saving: {e}"); - } - } + // LLVM IR is not saved: HUGR -> QIS compilation + // lives in the pecos-rslib-llvm wheel (the base + // wheel must not link LLVM). } // Keep the directory (don't let it be deleted on drop) @@ -1682,18 +1703,9 @@ impl PySimBuilder { PyRuntimeError::new_err(format!("Failed to write HUGR file: {e}")) })?; - // Also compile and save LLVM IR for debugging (graceful failure) - match compile_hugr_bytes_to_string(hugr_bytes) { - Ok(llvm_ir) => { - let ll_file = temp_path.join("program.ll"); - if let Err(e) = std::fs::write(&ll_file, llvm_ir) { - log::warn!("Could not write LLVM IR file: {e}"); - } - } - Err(e) => { - log::warn!("Could not compile HUGR to LLVM IR for saving: {e}"); - } - } + // LLVM IR is not saved: HUGR -> QIS compilation + // lives in the pecos-rslib-llvm wheel (the base + // wheel must not link LLVM). } // Keep the directory (don't let it be deleted on drop) @@ -1975,6 +1987,7 @@ impl Clone for SimBuilderInner { explicit_num_qubits: builder.explicit_num_qubits, keep_intermediate_files: builder.keep_intermediate_files, hugr_bytes: builder.hugr_bytes.clone(), + qis_source: builder.qis_source.clone(), operation_trace_dir: builder.operation_trace_dir.clone(), }) } diff --git a/python/pecos-rslib/tests/test_additional_hugr.py b/python/pecos-rslib/tests/test_additional_hugr.py index 603b70708..8876189cf 100644 --- a/python/pecos-rslib/tests/test_additional_hugr.py +++ b/python/pecos-rslib/tests/test_additional_hugr.py @@ -4,7 +4,7 @@ import pytest -from pecos_rslib import compile_hugr_to_qis +from pecos_rslib_llvm import compile_hugr_to_qis def test_hugr_compilation_with_support() -> None: diff --git a/python/pecos-rslib/tests/test_hugr_integration.py b/python/pecos-rslib/tests/test_hugr_integration.py index 3829bd064..06f43b54a 100644 --- a/python/pecos-rslib/tests/test_hugr_integration.py +++ b/python/pecos-rslib/tests/test_hugr_integration.py @@ -11,7 +11,7 @@ from guppylang import guppy from guppylang.std.quantum import h, measure, qubit -from pecos_rslib import compile_hugr_to_qis +from pecos_rslib_llvm import compile_hugr_to_qis def test_hugr_compiler_creation() -> None: diff --git a/python/pecos-rslib/tests/test_phir.py b/python/pecos-rslib/tests/test_phir.py index 22e1a8672..ac0c09847 100644 --- a/python/pecos-rslib/tests/test_phir.py +++ b/python/pecos-rslib/tests/test_phir.py @@ -26,7 +26,7 @@ def test_phir_json_program_creation() -> None: def test_compile_hugr_to_qis_with_invalid_input() -> None: """Test compile_hugr_to_qis rejects invalid HUGR bytes.""" - from pecos_rslib import compile_hugr_to_qis + from pecos_rslib_llvm import compile_hugr_to_qis with pytest.raises((RuntimeError, ValueError, TypeError)): compile_hugr_to_qis(b"not valid hugr") @@ -34,7 +34,7 @@ def test_compile_hugr_to_qis_with_invalid_input() -> None: def test_compile_hugr_to_qis_with_wrong_type() -> None: """Test compile_hugr_to_qis rejects string input.""" - from pecos_rslib import compile_hugr_to_qis + from pecos_rslib_llvm import compile_hugr_to_qis with pytest.raises(TypeError): compile_hugr_to_qis("{}") diff --git a/python/quantum-pecos/src/pecos/_compilation/guppy.py b/python/quantum-pecos/src/pecos/_compilation/guppy.py index 482f465be..4c1494195 100644 --- a/python/quantum-pecos/src/pecos/_compilation/guppy.py +++ b/python/quantum-pecos/src/pecos/_compilation/guppy.py @@ -12,7 +12,7 @@ from pathlib import Path from guppylang import guppy -from pecos_rslib import compile_hugr_to_qis +from pecos_rslib_llvm import compile_hugr_to_qis def _raise_external_compiler_error() -> None: diff --git a/python/quantum-pecos/src/pecos/compilation_pipeline.py b/python/quantum-pecos/src/pecos/compilation_pipeline.py index 1bdb470d0..40a3c7fea 100644 --- a/python/quantum-pecos/src/pecos/compilation_pipeline.py +++ b/python/quantum-pecos/src/pecos/compilation_pipeline.py @@ -82,7 +82,7 @@ def compile_hugr_to_qis( """ # Try to use PECOS's HUGR to LLVM compiler try: - from pecos_rslib import compile_hugr_to_qis + from pecos_rslib_llvm import compile_hugr_to_qis rust_backend_available = True except ImportError: diff --git a/python/quantum-pecos/src/pecos/engines/__init__.py b/python/quantum-pecos/src/pecos/engines/__init__.py index 1299c30cb..f598d2b7c 100644 --- a/python/quantum-pecos/src/pecos/engines/__init__.py +++ b/python/quantum-pecos/src/pecos/engines/__init__.py @@ -48,13 +48,41 @@ QisControlSimulation, QisInterfaceBuilder, SimBuilder, - compile_hugr_to_qis, find_llvm_tool, - get_compilation_backends, qis_helios_interface, qis_selene_helios_interface, sim_builder, ) + +# HUGR -> QIS (LLVM IR) compilation lives in the optional pecos-rslib-llvm +# wheel (the base wheel does not link LLVM). Capability introspection stays +# callable either way; compilation fails loudly when the wheel is absent. +try: + from pecos_rslib_llvm import compile_hugr_to_qis, get_compilation_backends +except ImportError: + + def compile_hugr_to_qis(*_args: object, **_kwargs: object) -> str: + """Raise: HUGR -> QIS compilation requires pecos-rslib-llvm.""" + msg = ( + "HUGR -> QIS compilation requires the pecos-rslib-llvm package " + "(the base pecos-rslib wheel does not link LLVM)." + ) + raise RuntimeError(msg) + + def get_compilation_backends() -> dict: + """Report available compilation backends (LLVM wheel absent).""" + return { + "default_backend": None, + "backends": { + "hugr-llvm": { + "available": False, + "description": "HUGR-LLVM pipeline: install pecos-rslib-llvm to enable", + }, + }, + "qsystem_platforms": [], + } + + from pecos_rslib.engines import ( PhirEngineBuilder, PhirJsonEngine, diff --git a/python/quantum-pecos/src/pecos/execute_llvm.py b/python/quantum-pecos/src/pecos/execute_llvm.py index eaf6d1846..2c97e9bde 100644 --- a/python/quantum-pecos/src/pecos/execute_llvm.py +++ b/python/quantum-pecos/src/pecos/execute_llvm.py @@ -19,11 +19,14 @@ def compile_module_to_string(hugr_bytes: bytes) -> str: RuntimeError: If compilation fails """ try: - from pecos_rslib import compile_hugr_to_qis + from pecos_rslib_llvm import compile_hugr_to_qis return compile_hugr_to_qis(hugr_bytes, None) except ImportError as e: - msg = "PECOS's Rust HUGR compiler is not available. This should not happen - please report this as a bug." + msg = ( + "HUGR -> QIS compilation requires the pecos-rslib-llvm package " + "(the base pecos-rslib wheel does not link LLVM)." + ) raise RuntimeError( msg, ) from e @@ -79,7 +82,7 @@ def is_available() -> bool: # Check Rust backend import importlib.util - if importlib.util.find_spec("pecos_rslib.compile_hugr_to_qis") is not None: + if importlib.util.find_spec("pecos_rslib_llvm") is not None: return True try: diff --git a/python/quantum-pecos/tests/guppy/test_advanced_gates.py b/python/quantum-pecos/tests/guppy/test_advanced_gates.py index da5edbab9..07c699207 100644 --- a/python/quantum-pecos/tests/guppy/test_advanced_gates.py +++ b/python/quantum-pecos/tests/guppy/test_advanced_gates.py @@ -1,6 +1,7 @@ """Test suite for advanced quantum gates (Toffoli, CRz, etc.).""" import pecos_rslib +import pecos_rslib_llvm import pytest from guppylang import guppy from guppylang.std.quantum import h, measure, pi, qubit @@ -40,7 +41,7 @@ def test_toffoli() -> tuple[bool, bool, bool]: return measure(q0), measure(q1), measure(q2) hugr = test_toffoli.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Toffoli should decompose into multiple gates assert "___rxy" in output @@ -68,7 +69,7 @@ def test_crz() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_crz.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # CRz should use RZZ and RZ gates assert "___rzz" in output @@ -88,7 +89,7 @@ def simple() -> bool: return measure(q) hugr = simple.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should compile successfully assert "qmain" in output @@ -114,7 +115,7 @@ def complex_circuit() -> tuple[bool, bool, bool]: return measure(q0), measure(q1), measure(q2) hugr = complex_circuit.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have all operation types assert "___rxy" in output @@ -136,7 +137,7 @@ def only_cnot() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = only_cnot.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should declare the operations we use assert "declare" in output diff --git a/python/quantum-pecos/tests/guppy/test_advanced_types.py b/python/quantum-pecos/tests/guppy/test_advanced_types.py index 93b127b7a..835d7c838 100644 --- a/python/quantum-pecos/tests/guppy/test_advanced_types.py +++ b/python/quantum-pecos/tests/guppy/test_advanced_types.py @@ -1,6 +1,7 @@ """Test suite for advanced type support (futures, collections, etc).""" import pecos_rslib +import pecos_rslib_llvm from guppylang import guppy from guppylang.std.quantum import h, measure, qubit @@ -19,7 +20,7 @@ def test_measure_future() -> bool: return measure(q) hugr = test_measure_future.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should compile successfully assert "___lazy_measure" in output @@ -39,7 +40,7 @@ def test_multi_measure() -> tuple[bool, bool]: return result1, result2 hugr = test_multi_measure.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should handle multiple futures correctly measure_calls = output.count("___lazy_measure") @@ -56,7 +57,7 @@ def test_advanced() -> bool: return measure(q) hugr = test_advanced.compile() - pecos_out = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + pecos_out = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should compile successfully assert len(pecos_out) > 100 @@ -73,8 +74,8 @@ def test_compat() -> bool: hugr = test_compat.compile() try: - pecos_out = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) - selene_out = pecos_rslib.compile_hugr_to_qis_selene(hugr.to_bytes()) + pecos_out = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) + selene_out = pecos_rslib_llvm.compile_hugr_to_qis_selene(hugr.to_bytes()) # Both should handle advanced types assert "___lazy_measure" in pecos_out or "measure" in pecos_out.lower() @@ -106,7 +107,7 @@ def test_complex() -> tuple[bool, bool, bool]: return r1, r2, r3 hugr = test_complex.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should handle the complex program correctly assert "___qalloc" in output diff --git a/python/quantum-pecos/tests/guppy/test_crz_angle_arithmetic.py b/python/quantum-pecos/tests/guppy/test_crz_angle_arithmetic.py index 861cf3c07..969c89006 100644 --- a/python/quantum-pecos/tests/guppy/test_crz_angle_arithmetic.py +++ b/python/quantum-pecos/tests/guppy/test_crz_angle_arithmetic.py @@ -1,6 +1,7 @@ """Test suite for CRz angle arithmetic improvements.""" import pecos_rslib +import pecos_rslib_llvm from guppylang import guppy from guppylang.std.quantum import crz, h, measure, pi, qubit @@ -20,7 +21,7 @@ def test_crz_pi() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_crz_pi.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have proper angle arithmetic assert "___rzz" in output @@ -46,7 +47,7 @@ def test_crz_pi_half() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_crz_pi_half.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should decompose correctly assert "___rzz" in output @@ -63,7 +64,7 @@ def test_crz_pi_fourth() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_crz_pi_fourth.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Verify the decomposition is present assert "tail call void @___rzz" in output @@ -83,7 +84,7 @@ def simple_crz() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = simple_crz.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should decompose CRz into RZZ and RZ operations assert "___rzz" in output, "CRz should use RZZ in its decomposition" @@ -107,7 +108,7 @@ def test_crz_zero() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_crz_zero.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Even with zero angle, should still have the decomposition structure assert "___rzz" in output or len(output) > 100 # Should compile successfully diff --git a/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py b/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py index 0aecf18c6..118950af4 100644 --- a/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py +++ b/python/quantum-pecos/tests/guppy/test_guppy_sim_builder.py @@ -136,8 +136,10 @@ def test_keep_intermediate_files(self) -> None: ll_files = list(temp_path.glob("*.ll")) hugr_files = list(temp_path.glob("*.hugr")) - assert len(ll_files) > 0, "Should have created LLVM IR file" assert len(hugr_files) > 0, "Should have created HUGR file" + # LLVM IR is deliberately NOT saved: HUGR -> QIS compilation lives + # in the pecos-rslib-llvm wheel (the base wheel does not link LLVM). + assert len(ll_files) == 0, "Base wheel must not emit LLVM IR artifacts" # Run simulation results = sim_obj.run(10).to_dict() @@ -147,7 +149,6 @@ def test_keep_intermediate_files(self) -> None: # Files should still exist after run assert Path(sim_obj.temp_dir).exists() - assert ll_files[0].exists() assert hugr_files[0].exists() # Manually clean up diff --git a/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py b/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py index 0cdddf93f..283359aee 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_compiler_parity.py @@ -9,7 +9,7 @@ import pytest from guppylang import GuppyModule, guppy -from pecos_rslib import compile_hugr_to_qis as rust_compile +from pecos_rslib_llvm import compile_hugr_to_qis as rust_compile from selene_hugr_qis_compiler import compile_to_llvm_ir as selene_compile # Import quantum operations - try stdlib first, fall back to std diff --git a/python/quantum-pecos/tests/guppy/test_hugr_interpreter_parity.py b/python/quantum-pecos/tests/guppy/test_hugr_interpreter_parity.py index 3d62f99c8..c5b8c4b04 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_interpreter_parity.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_interpreter_parity.py @@ -33,7 +33,8 @@ from guppylang.std.quantum import ch, cx, discard, h, measure, qubit, x, y, z from pecos import Guppy, sim from pecos.compilation_pipeline import compile_guppy_to_hugr -from pecos_rslib import Qis, compile_hugr_to_qis, state_vector +from pecos_rslib import Qis, state_vector +from pecos_rslib_llvm import compile_hugr_to_qis from selene_sim import build from selene_sim.backends import IdealErrorModel as IdealNoiseModel from selene_sim.backends import Quest, SimpleRuntime diff --git a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py index e811012c1..3a9840e21 100644 --- a/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py +++ b/python/quantum-pecos/tests/guppy/test_hugr_to_llvm_parsing.py @@ -8,7 +8,7 @@ def test_hugr_to_llvm_compilation() -> None: try: from guppylang import guppy from guppylang.std.quantum import cx, h, measure, qubit - from pecos_rslib import compile_hugr_to_qis + from pecos_rslib_llvm import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") @@ -40,7 +40,7 @@ def test_simple_hadamard_circuit() -> None: try: from guppylang import guppy from guppylang.std.quantum import h, measure, qubit - from pecos_rslib import compile_hugr_to_qis + from pecos_rslib_llvm import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") @@ -69,7 +69,7 @@ def test_trace_metadata_helper_uses_public_symbol() -> None: from guppylang import guppy from guppylang.std.builtins import owned from guppylang.std.quantum import h, measure, qubit - from pecos_rslib import compile_hugr_to_qis + from pecos_rslib_llvm import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") @@ -95,7 +95,7 @@ def test_runtime_barrier_pair_helper_uses_public_symbol() -> None: from guppylang import guppy from guppylang.std.builtins import owned from guppylang.std.quantum import cx, h, measure, qubit - from pecos_rslib import compile_hugr_to_qis + from pecos_rslib_llvm import compile_hugr_to_qis except ImportError as e: pytest.skip(f"Required imports not available: {e}") diff --git a/python/quantum-pecos/tests/guppy/test_multi_module_handling.py b/python/quantum-pecos/tests/guppy/test_multi_module_handling.py index 51c71becb..8c0c46ab2 100644 --- a/python/quantum-pecos/tests/guppy/test_multi_module_handling.py +++ b/python/quantum-pecos/tests/guppy/test_multi_module_handling.py @@ -11,7 +11,7 @@ import pytest from guppylang import GuppyModule, guppy from hugr.package import Package -from pecos_rslib import compile_hugr_to_qis as rust_compile +from pecos_rslib_llvm import compile_hugr_to_qis as rust_compile from selene_hugr_qis_compiler import compile_to_llvm_ir as selene_compile # Import quantum operations - try stdlib first, fall back to std diff --git a/python/quantum-pecos/tests/guppy/test_project_z.py b/python/quantum-pecos/tests/guppy/test_project_z.py index 55c404b1f..befdb4266 100644 --- a/python/quantum-pecos/tests/guppy/test_project_z.py +++ b/python/quantum-pecos/tests/guppy/test_project_z.py @@ -1,6 +1,7 @@ """Test suite for project_z operation.""" import pecos_rslib +import pecos_rslib_llvm from guppylang import guppy from guppylang.std.quantum import h, project_z, qubit, x @@ -19,7 +20,7 @@ def test_project_z() -> tuple[qubit, bool]: return q, result hugr = test_project_z.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # project_z should compile to a measurement operation # Since it doesn't consume the qubit, it should work like measure @@ -36,7 +37,7 @@ def test_project_z_x() -> tuple[qubit, bool]: return q, result hugr = test_project_z_x.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have both X gate operations and measurement assert "___rxy" in output # X gate uses RXY @@ -52,7 +53,7 @@ def simple_project_z() -> tuple[qubit, bool]: return q, result hugr = simple_project_z.compile() - pecos_out = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + pecos_out = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should compile successfully and have measurement assert len(pecos_out) > 100 # Non-empty compilation @@ -71,8 +72,8 @@ def test_project_z_compat() -> tuple[qubit, bool]: hugr = test_project_z_compat.compile() try: - pecos_out = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) - selene_out = pecos_rslib.compile_hugr_to_qis_selene(hugr.to_bytes()) + pecos_out = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) + selene_out = pecos_rslib_llvm.compile_hugr_to_qis_selene(hugr.to_bytes()) # Both should compile successfully assert len(pecos_out) > 100 @@ -96,7 +97,7 @@ def project_z_circuit() -> tuple[qubit, qubit, bool, bool]: return q1, q2, result1, result2 hugr = project_z_circuit.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have multiple allocations and measurements assert "___qalloc" in output diff --git a/python/quantum-pecos/tests/guppy/test_quantum_gates_complete.py b/python/quantum-pecos/tests/guppy/test_quantum_gates_complete.py index 4859c92ef..ccde202b7 100644 --- a/python/quantum-pecos/tests/guppy/test_quantum_gates_complete.py +++ b/python/quantum-pecos/tests/guppy/test_quantum_gates_complete.py @@ -1,6 +1,7 @@ """Test suite for complete quantum gate coverage in PECOS compiler.""" import pecos_rslib +import pecos_rslib_llvm from guppylang import guppy from guppylang.std.quantum import ( ch, @@ -50,7 +51,7 @@ def test_z() -> bool: for func in [test_x, test_y, test_z]: hugr = func.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "tail call" in output assert "@___r" in output # Should have rotation calls @@ -71,7 +72,7 @@ def test_t() -> bool: for func in [test_s, test_t]: hugr = func.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rz" in output assert "tail call" in output @@ -85,7 +86,7 @@ def test_h() -> bool: return measure(q) hugr = test_h.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rxy" in output assert "___rz" in output @@ -112,7 +113,7 @@ def test_tdg_gate() -> bool: for func in [test_sdg_gate, test_tdg_gate]: hugr = func.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rz" in output # Should have negative angle for adjoint assert "0xBF" in output # Negative hex prefix @@ -131,7 +132,7 @@ def test_rx_pi4() -> bool: return measure(q) hugr = test_rx_pi4.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rxy" in output assert "double 0.0" in output # First angle should be 0 for Rx @@ -145,7 +146,7 @@ def test_ry_pi2() -> bool: return measure(q) hugr = test_ry_pi2.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rxy" in output # For Ry, second angle should be 0 @@ -159,7 +160,7 @@ def test_rz_pi() -> bool: return measure(q) hugr = test_rz_pi.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rz" in output # Should have an angle parameter assert "double" in output @@ -180,7 +181,7 @@ def test_cx() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_cx.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rxy" in output assert "___rzz" in output assert "___rz" in output @@ -197,7 +198,7 @@ def test_cy() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_cy.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rxy" in output assert "___rzz" in output assert "___rz" in output @@ -216,7 +217,7 @@ def test_cz() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_cz.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rzz" in output assert "___rz" in output @@ -232,7 +233,7 @@ def test_ch() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = test_ch.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rxy" in output assert "___rz" in output # CH has its own decomposition @@ -253,7 +254,7 @@ def bell() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = bell.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rxy" in output assert "___rzz" in output assert "___lazy_measure" in output @@ -273,7 +274,7 @@ def ghz() -> tuple[bool, bool, bool]: return measure(q0), measure(q1), measure(q2) hugr = ghz.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rzz" in output # Has CX gates assert "___lazy_measure" in output # Has measurements @@ -292,7 +293,7 @@ def mixed() -> tuple[bool, bool]: return measure(q0), measure(q1) hugr = mixed.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) assert "___rxy" in output assert "___rz" in output assert "___rzz" in output @@ -311,7 +312,7 @@ def simple() -> bool: return measure(q) hugr = simple.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have the expected quantum operations assert "___qalloc" in output, "Should allocate qubit" @@ -334,7 +335,7 @@ def only_h() -> bool: return measure(q) hugr = only_h.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should declare only what's used assert "declare" in output diff --git a/python/quantum-pecos/tests/guppy/test_reset.py b/python/quantum-pecos/tests/guppy/test_reset.py index ef2f9ce9c..f5d7cbbb3 100644 --- a/python/quantum-pecos/tests/guppy/test_reset.py +++ b/python/quantum-pecos/tests/guppy/test_reset.py @@ -1,6 +1,7 @@ """Test suite for Reset operation.""" import pecos_rslib +import pecos_rslib_llvm from guppylang import guppy from guppylang.std.quantum import h, measure, qubit, reset, x @@ -19,7 +20,7 @@ def test_reset() -> bool: return measure(q) hugr = test_reset.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have reset operation assert "___reset" in output @@ -36,7 +37,7 @@ def test_reset_x() -> bool: return measure(q) hugr = test_reset_x.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have both X gate operations and reset assert "___rxy" in output # X gate uses RXY @@ -55,7 +56,7 @@ def test_multi_reset() -> bool: return measure(q) hugr = test_multi_reset.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have two reset calls (plus potentially one from QAlloc) reset_calls = output.count("tail call void @___reset") @@ -75,7 +76,7 @@ def test_reset_two() -> tuple[bool, bool]: return measure(q1), measure(q2) hugr = test_reset_two.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have multiple reset calls assert "___reset" in output @@ -94,7 +95,7 @@ def simple_reset() -> bool: return measure(q) hugr = simple_reset.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should declare and use reset assert "declare" in output @@ -120,7 +121,7 @@ def reset_circuit() -> tuple[bool, bool]: return measure(q1), measure(q2) hugr = reset_circuit.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have all operations assert "___rxy" in output # From H and CX diff --git a/python/quantum-pecos/tests/guppy/test_rotation_extension.py b/python/quantum-pecos/tests/guppy/test_rotation_extension.py index 66acca8bc..aab8e5a7f 100644 --- a/python/quantum-pecos/tests/guppy/test_rotation_extension.py +++ b/python/quantum-pecos/tests/guppy/test_rotation_extension.py @@ -1,6 +1,6 @@ """Test suite for rotation extension support.""" -import pecos_rslib +import pecos_rslib_llvm from guppylang import guppy from guppylang.std.quantum import measure, pi, qubit, rz @@ -19,7 +19,7 @@ def test_angle_ops() -> bool: return measure(q) hugr = test_angle_ops.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should compile successfully with angle arithmetic assert "___rz" in output @@ -36,7 +36,7 @@ def test_multi_angles() -> bool: return measure(q) hugr = test_multi_angles.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have multiple RZ calls rz_calls = output.count("tail call void @___rz") @@ -52,7 +52,7 @@ def test_rotation_compat() -> bool: return measure(q) hugr = test_rotation_compat.compile() - pecos_out = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + pecos_out = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should compile successfully assert "___rz" in pecos_out @@ -70,7 +70,7 @@ def test_complex_angles() -> bool: return measure(q) hugr = test_complex_angles.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should handle complex angle expressions assert "___rz" in output @@ -87,8 +87,8 @@ def simple_rotation() -> bool: hugr = simple_rotation.compile() try: - pecos_out = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) - selene_out = pecos_rslib.compile_hugr_to_qis_selene(hugr.to_bytes()) + pecos_out = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) + selene_out = pecos_rslib_llvm.compile_hugr_to_qis_selene(hugr.to_bytes()) # Both should compile successfully assert "___rz" in pecos_out diff --git a/python/quantum-pecos/tests/guppy/test_v_gates.py b/python/quantum-pecos/tests/guppy/test_v_gates.py index 7fc0a47e3..58844db71 100644 --- a/python/quantum-pecos/tests/guppy/test_v_gates.py +++ b/python/quantum-pecos/tests/guppy/test_v_gates.py @@ -1,6 +1,7 @@ """Test suite for V and Vdg gates.""" import pecos_rslib +import pecos_rslib_llvm from guppylang import guppy from guppylang.std.quantum import h, measure, qubit, v, vdg @@ -19,7 +20,7 @@ def test_v() -> bool: return measure(q) hugr = test_v.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # V gate should be decomposed to RXY(0, π/2) assert "___rxy" in output @@ -37,7 +38,7 @@ def test_vdg() -> bool: return measure(q) hugr = test_vdg.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Vdg gate should be decomposed to RXY(0, -π/2) assert "___rxy" in output @@ -56,7 +57,7 @@ def test_v_vdg() -> bool: return measure(q) hugr = test_v_vdg.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have two RXY calls (V and Vdg) assert output.count("___rxy") >= 2 @@ -72,7 +73,7 @@ def test_double_v() -> bool: return measure(q) hugr = test_double_v.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # Should have two RXY calls for the two V gates (plus one declaration) rxy_calls = output.count("tail call void @___rxy") @@ -89,7 +90,7 @@ def simple_v() -> bool: return measure(q) hugr = simple_v.compile() - output = pecos_rslib.compile_hugr_to_qis(hugr.to_bytes()) + output = pecos_rslib_llvm.compile_hugr_to_qis(hugr.to_bytes()) # V gate should be decomposed into RXY assert "declare" in output diff --git a/python/quantum-pecos/tests/guppy/test_working_guppy_pipeline.py b/python/quantum-pecos/tests/guppy/test_working_guppy_pipeline.py index cf6b3fb1d..dd88a19ab 100644 --- a/python/quantum-pecos/tests/guppy/test_working_guppy_pipeline.py +++ b/python/quantum-pecos/tests/guppy/test_working_guppy_pipeline.py @@ -4,7 +4,8 @@ from guppylang import guppy from guppylang.std.quantum import cx, h, measure, qubit, x from pecos import Guppy, sim -from pecos_rslib import compile_hugr_to_qis, state_vector +from pecos_rslib import state_vector +from pecos_rslib_llvm import compile_hugr_to_qis def decode_integer_results(results: list[int], n_bits: int) -> list[tuple[bool, ...]]: From 472234e9847a2519bae8de6e3ea62054bc814773 Mon Sep 17 00:00:00 2001 From: Ciaran Ryan-Anderson Date: Mon, 6 Jul 2026 11:42:06 -0600 Subject: [PATCH 388/388] Raise pr-core gate timeouts: the python job timed out at 45 min on a cold-cache build of a large dependency change --- .github/workflows/pr-core-gate.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-core-gate.yml b/.github/workflows/pr-core-gate.yml index 1e3cab254..b71b364e8 100644 --- a/.github/workflows/pr-core-gate.yml +++ b/.github/workflows/pr-core-gate.yml @@ -43,7 +43,9 @@ defaults: jobs: pr-core-python: runs-on: ubuntu-latest - timeout-minutes: 45 + # 75 min: a cold-cache build plus the core suite exceeded 45 (timed + # out at 45:17 on the first branch with a large Cargo-graph change). + timeout-minutes: 75 steps: - name: Harden the runner (egress audit) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4 @@ -145,7 +147,8 @@ jobs: pr-core-rust: runs-on: ubuntu-latest - timeout-minutes: 45 + # 60 min: 31 observed on a cold cache. + timeout-minutes: 60 steps: - name: Harden the runner (egress audit) uses: step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411 # v2.19.4